diff --git a/eslint.config.mjs b/eslint.config.mjs index 2f4072a188..9db2f17f9c 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -47,6 +47,9 @@ export default [ parserOptions: { allowImportExportEverywhere: true, + svelteFeatures: { + experimentalGenerics: true, + }, }, }, diff --git a/packages/backend-core/src/sql/sqlTable.ts b/packages/backend-core/src/sql/sqlTable.ts index 8e15d3d4ef..9fa062bfb6 100644 --- a/packages/backend-core/src/sql/sqlTable.ts +++ b/packages/backend-core/src/sql/sqlTable.ts @@ -141,19 +141,23 @@ function generateSchema( .references(`${tableName}.${relatedPrimary}`) } break + case FieldType.SIGNATURE_SINGLE: + case FieldType.ATTACHMENTS: + case FieldType.ATTACHMENT_SINGLE: + // single attachments are stored as an object, multi attachments + // are stored as an array + schema.json(key) + break case FieldType.FORMULA: // This is allowed, but nothing to do on the external datasource break case FieldType.AI: // This is allowed, but nothing to do on the external datasource break - case FieldType.ATTACHMENTS: - case FieldType.ATTACHMENT_SINGLE: - case FieldType.SIGNATURE_SINGLE: case FieldType.AUTO: case FieldType.JSON: case FieldType.INTERNAL: - throw `${column.type} is not a valid SQL type` + throw new Error(`${column.type} is not a valid SQL type`) default: utils.unreachable(columnType) diff --git a/packages/bbui/src/Form/Core/Multiselect.svelte b/packages/bbui/src/Form/Core/Multiselect.svelte index 7c4c857056..25c35f3d3c 100644 --- a/packages/bbui/src/Form/Core/Multiselect.svelte +++ b/packages/bbui/src/Form/Core/Multiselect.svelte @@ -20,7 +20,7 @@ export let searchTerm: string | null = null export let customPopoverHeight: string | undefined = undefined export let open: boolean = false - export let loading: boolean + export let loading: boolean = false export let onOptionMouseenter = () => {} export let onOptionMouseleave = () => {} diff --git a/packages/bbui/src/Form/DatePicker.svelte b/packages/bbui/src/Form/DatePicker.svelte index ec3dfdfd2c..e48bf665e1 100644 --- a/packages/bbui/src/Form/DatePicker.svelte +++ b/packages/bbui/src/Form/DatePicker.svelte @@ -3,7 +3,7 @@ import DatePicker from "./Core/DatePicker/DatePicker.svelte" import { createEventDispatcher } from "svelte" - export let value = null + export let value = undefined export let label = null export let labelPosition = "above" export let disabled = false diff --git a/packages/bbui/src/Form/Multiselect.svelte b/packages/bbui/src/Form/Multiselect.svelte index 9878605f4b..025cc7f536 100644 --- a/packages/bbui/src/Form/Multiselect.svelte +++ b/packages/bbui/src/Form/Multiselect.svelte @@ -1,29 +1,31 @@ - - + {body} diff --git a/packages/builder/src/components/common/RelationshipSelector.svelte b/packages/builder/src/components/common/RelationshipSelector.svelte index 5e840d4ffb..2e9038ee41 100644 --- a/packages/builder/src/components/common/RelationshipSelector.svelte +++ b/packages/builder/src/components/common/RelationshipSelector.svelte @@ -11,8 +11,8 @@ export let errors export let relationshipOpts1 export let relationshipOpts2 - export let primaryTableChanged - export let secondaryTableChanged + export let primaryTableChanged = undefined + export let secondaryTableChanged = undefined export let primaryDisabled = true diff --git a/packages/builder/src/components/integration/RestQueryViewer.svelte b/packages/builder/src/components/integration/RestQueryViewer.svelte index 657a46245a..178032ac35 100644 --- a/packages/builder/src/components/integration/RestQueryViewer.svelte +++ b/packages/builder/src/components/integration/RestQueryViewer.svelte @@ -1,5 +1,5 @@ diff --git a/packages/builder/src/components/integration/rest/AuthPicker.svelte b/packages/builder/src/components/integration/rest/AuthPicker.svelte index 9b4ab26542..3e93fbecbf 100644 --- a/packages/builder/src/components/integration/rest/AuthPicker.svelte +++ b/packages/builder/src/components/integration/rest/AuthPicker.svelte @@ -76,7 +76,7 @@ - Basic (Username & Password Authentication) + Basic & Bearer Authentication {#if authConfigs.length} @@ -92,7 +92,7 @@ {/if}
Add config
diff --git a/packages/builder/src/constants/backend/index.ts b/packages/builder/src/constants/backend/index.ts index 0a1e6b8740..ef72722356 100644 --- a/packages/builder/src/constants/backend/index.ts +++ b/packages/builder/src/constants/backend/index.ts @@ -6,6 +6,7 @@ import { Hosting, } from "@budibase/types" import { Constants } from "@budibase/frontend-core" +import { UIField } from "@budibase/types" const { TypeIconMap } = Constants @@ -27,7 +28,7 @@ export const AUTO_COLUMN_DISPLAY_NAMES: Record< UPDATED_AT: "Updated At", } -export const FIELDS = { +export const FIELDS: Record = { STRING: { name: "Text", type: FieldType.STRING, diff --git a/packages/builder/src/helpers/confirm.ts b/packages/builder/src/helpers/confirm.ts new file mode 100644 index 0000000000..59c9d21490 --- /dev/null +++ b/packages/builder/src/helpers/confirm.ts @@ -0,0 +1,41 @@ +import ConfirmDialog from "@/components/common/ConfirmDialog.svelte" + +export enum ConfirmOutput {} + +export async function confirm(props: { + title: string + body?: string + okText?: string + cancelText?: string + size?: "S" | "M" | "L" | "XL" + onConfirm?: () => void + onCancel?: () => void + onClose?: () => void +}) { + return await new Promise(resolve => { + const dialog = new ConfirmDialog({ + target: document.body, + props: { + title: props.title, + body: props.body, + okText: props.okText, + cancelText: props.cancelText, + size: props.size, + warning: false, + onOk: () => { + dialog.$destroy() + resolve(props.onConfirm?.() || true) + }, + onCancel: () => { + dialog.$destroy() + resolve(props.onCancel?.() || false) + }, + onClose: () => { + dialog.$destroy() + resolve(props.onClose?.() || false) + }, + }, + }) + dialog.show() + }) +} diff --git a/packages/builder/src/helpers/index.ts b/packages/builder/src/helpers/index.ts index 0e61eeb9c6..717d9cdd85 100644 --- a/packages/builder/src/helpers/index.ts +++ b/packages/builder/src/helpers/index.ts @@ -11,3 +11,4 @@ export { } from "./helpers" export * as featureFlag from "./featureFlags" export * as bindings from "./bindings" +export * from "./confirm" diff --git a/packages/builder/src/pages/builder/app/[application]/settings/oauth2/AddButton.svelte b/packages/builder/src/pages/builder/app/[application]/settings/oauth2/AddButton.svelte index 88f3ad2a2a..4358888091 100644 --- a/packages/builder/src/pages/builder/app/[application]/settings/oauth2/AddButton.svelte +++ b/packages/builder/src/pages/builder/app/[application]/settings/oauth2/AddButton.svelte @@ -7,22 +7,79 @@ Divider, Heading, Input, + keepOpen, Link, Modal, ModalContent, + notifications, } from "@budibase/bbui" + import type { ZodType } from "zod" + import { z } from "zod" let modal: Modal function openModal() { + config = {} + errors = {} + hasBeenSubmitted = false modal.show() } let config: Partial = {} + let errors: Record = {} + let hasBeenSubmitted = false + + const requiredString = (errorMessage: string) => + z.string({ required_error: errorMessage }).trim().min(1, errorMessage) + + const validateConfig = (config: Partial) => { + const validator = z.object({ + name: requiredString("Name is required.").refine( + val => + !$oauth2.configs + .map(c => c.name.toLowerCase()) + .includes(val.toLowerCase()), + { + message: "This name is already taken.", + } + ), + url: requiredString("Url is required.").url(), + clientId: requiredString("Client ID is required."), + clientSecret: requiredString("Client secret is required."), + }) satisfies ZodType + + const validationResult = validator.safeParse(config) + errors = {} + if (!validationResult.success) { + errors = Object.entries( + validationResult.error.formErrors.fieldErrors + ).reduce>((acc, [field, errors]) => { + if (errors[0]) { + acc[field] = errors[0] + } + return acc + }, {}) + } + + return validationResult + } $: saveOAuth2Config = async () => { - await oauth2.create(config as any) // TODO + hasBeenSubmitted = true + const validationResult = validateConfig(config) + if (validationResult.error) { + return keepOpen + } + + try { + await oauth2.create(validationResult.data) + } catch (e: any) { + notifications.error(e.message) + return keepOpen + } } + + $: hasBeenSubmitted && validateConfig(config) @@ -34,11 +91,11 @@ machine) grant type. - +
@@ -50,11 +107,14 @@ label="Client ID*" placeholder="Type here..." bind:value={config.clientId} + error={errors.clientId} /> To learn how to configure OAuth2, our documentation - - +
+ diff --git a/packages/client/src/components/app/forms/RelationshipField.svelte b/packages/client/src/components/app/forms/RelationshipField.svelte index b5906b72fa..6fa7b32621 100644 --- a/packages/client/src/components/app/forms/RelationshipField.svelte +++ b/packages/client/src/components/app/forms/RelationshipField.svelte @@ -1,17 +1,26 @@ - + + - + { - group.filters = group.filters?.filter((filter: any) => { - return filter.field && filter.operator + if (update.groups) { + update.groups = update.groups + .map(group => { + if (group.filters) { + group.filters = group.filters.filter((filter: any) => { + return filter.field && filter.operator + }) + return group.filters?.length ? group : null + } + return group }) - return group.filters?.length ? group : null - }) - .filter((group): group is SearchFilterGroup => !!group) + .filter((group): group is SearchFilterGroup => !!group) + } return update } diff --git a/packages/server/src/api/controllers/application.ts b/packages/server/src/api/controllers/application.ts index 9a4b241f30..6b5ef149a4 100644 --- a/packages/server/src/api/controllers/application.ts +++ b/packages/server/src/api/controllers/application.ts @@ -358,8 +358,8 @@ async function performAppCreate( }, theme: DefaultAppTheme, customTheme: { - primaryColor: "var(--spectrum-global-color-static-blue-1200)", - primaryColorHover: "var(--spectrum-global-color-static-blue-800)", + primaryColor: "var(--spectrum-global-color-blue-700)", + primaryColorHover: "var(--spectrum-global-color-blue-600)", buttonBorderRadius: "16px", }, features: { diff --git a/packages/server/src/api/controllers/oauth2.ts b/packages/server/src/api/controllers/oauth2.ts index 8e770ac459..e2ca2dad1d 100644 --- a/packages/server/src/api/controllers/oauth2.ts +++ b/packages/server/src/api/controllers/oauth2.ts @@ -1,6 +1,6 @@ import { - CreateOAuth2ConfigRequest, - CreateOAuth2ConfigResponse, + UpsertOAuth2ConfigRequest, + UpsertOAuth2ConfigResponse, Ctx, FetchOAuth2ConfigsResponse, OAuth2Config, @@ -22,7 +22,7 @@ export async function fetch(ctx: Ctx) { } export async function create( - ctx: Ctx + ctx: Ctx ) { const { body } = ctx.request const newConfig: RequiredKeys> = { @@ -36,3 +36,28 @@ export async function create( ctx.status = 201 ctx.body = { config } } + +export async function edit( + ctx: Ctx +) { + const { body } = ctx.request + const toUpdate: RequiredKeys = { + id: ctx.params.id, + name: body.name, + url: body.url, + clientId: ctx.clientId, + clientSecret: ctx.clientSecret, + } + + const config = await sdk.oauth2.update(toUpdate) + ctx.body = { config } +} + +export async function remove( + ctx: Ctx +) { + const configToRemove = ctx.params.id + + await sdk.oauth2.remove(configToRemove) + ctx.status = 204 +} diff --git a/packages/server/src/api/routes/oauth2.ts b/packages/server/src/api/routes/oauth2.ts index 4fb82d6dfd..2ae3cbdf82 100644 --- a/packages/server/src/api/routes/oauth2.ts +++ b/packages/server/src/api/routes/oauth2.ts @@ -1,8 +1,22 @@ import Router from "@koa/router" import { PermissionType } from "@budibase/types" +import { middleware } from "@budibase/backend-core" import authorized from "../../middleware/authorized" import * as controller from "../controllers/oauth2" +import Joi from "joi" + +function oAuth2ConfigValidator() { + return middleware.joiValidator.body( + Joi.object({ + name: Joi.string().required(), + url: Joi.string().required(), + clientId: Joi.string().required(), + clientSecret: Joi.string().required(), + }), + { allowUnknown: false } + ) +} const router: Router = new Router() @@ -10,7 +24,19 @@ router.get("/api/oauth2", authorized(PermissionType.BUILDER), controller.fetch) router.post( "/api/oauth2", authorized(PermissionType.BUILDER), + oAuth2ConfigValidator(), controller.create ) +router.put( + "/api/oauth2/:id", + authorized(PermissionType.BUILDER), + oAuth2ConfigValidator(), + controller.edit +) +router.delete( + "/api/oauth2/:id", + authorized(PermissionType.BUILDER), + controller.remove +) export default router diff --git a/packages/server/src/api/routes/tests/oauth2.spec.ts b/packages/server/src/api/routes/tests/oauth2.spec.ts index c9f1ada2a2..9d616203f9 100644 --- a/packages/server/src/api/routes/tests/oauth2.spec.ts +++ b/packages/server/src/api/routes/tests/oauth2.spec.ts @@ -1,11 +1,16 @@ -import { CreateOAuth2ConfigRequest, VirtualDocumentType } from "@budibase/types" +import { + OAuth2Config, + UpsertOAuth2ConfigRequest, + VirtualDocumentType, +} from "@budibase/types" import * as setup from "./utilities" import { generator } from "@budibase/backend-core/tests" +import _ from "lodash/fp" describe("/oauth2", () => { let config = setup.getConfig() - function makeOAuth2Config(): CreateOAuth2ConfigRequest { + function makeOAuth2Config(): UpsertOAuth2ConfigRequest { return { name: generator.guid(), url: generator.url(), @@ -19,7 +24,7 @@ describe("/oauth2", () => { beforeEach(async () => await config.newTenant()) const expectOAuth2ConfigId = expect.stringMatching( - `^${VirtualDocumentType.ROW_ACTION}_.+$` + `^${VirtualDocumentType.OAUTH2_CONFIG}_.+$` ) describe("fetch", () => { @@ -92,4 +97,96 @@ describe("/oauth2", () => { ]) }) }) + + describe("update", () => { + let existingConfigs: OAuth2Config[] = [] + + beforeEach(async () => { + existingConfigs = [] + for (let i = 0; i < 10; i++) { + const oauth2Config = makeOAuth2Config() + const result = await config.api.oauth2.create(oauth2Config) + + existingConfigs.push({ ...oauth2Config, id: result.config.id }) + } + }) + + it("can update an existing configuration", async () => { + const { id: configId, ...configData } = _.sample(existingConfigs)! + + await config.api.oauth2.update(configId, { + ...configData, + name: "updated name", + }) + + const response = await config.api.oauth2.fetch() + expect(response.configs).toHaveLength(existingConfigs.length) + expect(response.configs).toEqual( + expect.arrayContaining([ + { + id: configId, + name: "updated name", + url: configData.url, + }, + ]) + ) + }) + + it("throw if config not found", async () => { + await config.api.oauth2.update("unexisting", makeOAuth2Config(), { + status: 404, + body: { message: "OAuth2 config with id 'unexisting' not found." }, + }) + }) + + it("throws if trying to use an existing name", async () => { + const [config1, config2] = _.sampleSize(2, existingConfigs) + const { id: configId, ...configData } = config1 + + await config.api.oauth2.update( + configId, + { + ...configData, + name: config2.name, + }, + { + status: 400, + body: { + message: `OAuth2 config with name '${config2.name}' is already taken.`, + }, + } + ) + }) + }) + + describe("delete", () => { + let existingConfigs: OAuth2Config[] = [] + + beforeEach(async () => { + existingConfigs = [] + for (let i = 0; i < 5; i++) { + const oauth2Config = makeOAuth2Config() + const result = await config.api.oauth2.create(oauth2Config) + + existingConfigs.push({ ...oauth2Config, id: result.config.id }) + } + }) + + it("can delete an existing configuration", async () => { + const { id: configId } = _.sample(existingConfigs)! + + await config.api.oauth2.delete(configId, { status: 204 }) + + const response = await config.api.oauth2.fetch() + expect(response.configs).toHaveLength(existingConfigs.length - 1) + expect(response.configs.find(c => c.id === configId)).toBeUndefined() + }) + + it("throw if config not found", async () => { + await config.api.oauth2.delete("unexisting", { + status: 404, + body: { message: "OAuth2 config with id 'unexisting' not found." }, + }) + }) + }) }) diff --git a/packages/server/src/api/routes/tests/row.spec.ts b/packages/server/src/api/routes/tests/row.spec.ts index 1ee0e168a1..e910e5ab55 100644 --- a/packages/server/src/api/routes/tests/row.spec.ts +++ b/packages/server/src/api/routes/tests/row.spec.ts @@ -2195,95 +2195,94 @@ if (descriptions.length) { }) }) - isInternal && - describe("attachments and signatures", () => { - const coreAttachmentEnrichment = async ( - schema: TableSchema, - field: string, - attachmentCfg: string | string[] - ) => { - const testTable = await config.api.table.save( - defaultTable({ - schema, - }) - ) - const attachmentToStoreKey = (attachmentId: string) => { - return { - key: `${config.getAppId()}/attachments/${attachmentId}`, - } - } - const draftRow = { - name: "test", - description: "test", - [field]: - typeof attachmentCfg === "string" - ? attachmentToStoreKey(attachmentCfg) - : attachmentCfg.map(attachmentToStoreKey), - tableId: testTable._id, - } - const row = await config.api.row.save(testTable._id!, draftRow) - - await withEnv({ SELF_HOSTED: "true" }, async () => { - return context.doInAppContext(config.getAppId(), async () => { - const enriched: Row[] = await outputProcessing(testTable, [row]) - const [targetRow] = enriched - const attachmentEntries = Array.isArray(targetRow[field]) - ? targetRow[field] - : [targetRow[field]] - - for (const entry of attachmentEntries) { - const attachmentId = entry.key.split("/").pop() - expect(entry.url.split("?")[0]).toBe( - `/files/signed/prod-budi-app-assets/${config.getProdAppId()}/attachments/${attachmentId}` - ) - } - }) + describe("attachments and signatures", () => { + const coreAttachmentEnrichment = async ( + schema: TableSchema, + field: string, + attachmentCfg: string | string[] + ) => { + const testTable = await config.api.table.save( + defaultTable({ + schema, }) + ) + const attachmentToStoreKey = (attachmentId: string) => { + return { + key: `${config.getAppId()}/attachments/${attachmentId}`, + } } + const draftRow = { + name: "test", + description: "test", + [field]: + typeof attachmentCfg === "string" + ? attachmentToStoreKey(attachmentCfg) + : attachmentCfg.map(attachmentToStoreKey), + tableId: testTable._id, + } + const row = await config.api.row.save(testTable._id!, draftRow) - it("should allow enriching single attachment rows", async () => { - await coreAttachmentEnrichment( - { - attachment: { - type: FieldType.ATTACHMENT_SINGLE, - name: "attachment", - constraints: { presence: false }, - }, - }, - "attachment", - `${uuid.v4()}.csv` - ) - }) + await withEnv({ SELF_HOSTED: "true" }, async () => { + return context.doInAppContext(config.getAppId(), async () => { + const enriched: Row[] = await outputProcessing(testTable, [row]) + const [targetRow] = enriched + const attachmentEntries = Array.isArray(targetRow[field]) + ? targetRow[field] + : [targetRow[field]] - it("should allow enriching attachment list rows", async () => { - await coreAttachmentEnrichment( - { - attachments: { - type: FieldType.ATTACHMENTS, - name: "attachments", - constraints: { type: "array", presence: false }, - }, - }, - "attachments", - [`${uuid.v4()}.csv`] - ) + for (const entry of attachmentEntries) { + const attachmentId = entry.key.split("/").pop() + expect(entry.url.split("?")[0]).toBe( + `/files/signed/prod-budi-app-assets/${config.getProdAppId()}/attachments/${attachmentId}` + ) + } + }) }) + } - it("should allow enriching signature rows", async () => { - await coreAttachmentEnrichment( - { - signature: { - type: FieldType.SIGNATURE_SINGLE, - name: "signature", - constraints: { presence: false }, - }, + it("should allow enriching single attachment rows", async () => { + await coreAttachmentEnrichment( + { + attachment: { + type: FieldType.ATTACHMENT_SINGLE, + name: "attachment", + constraints: { presence: false }, }, - "signature", - `${uuid.v4()}.png` - ) - }) + }, + "attachment", + `${uuid.v4()}.csv` + ) }) + it("should allow enriching attachment list rows", async () => { + await coreAttachmentEnrichment( + { + attachments: { + type: FieldType.ATTACHMENTS, + name: "attachments", + constraints: { type: "array", presence: false }, + }, + }, + "attachments", + [`${uuid.v4()}.csv`] + ) + }) + + it("should allow enriching signature rows", async () => { + await coreAttachmentEnrichment( + { + signature: { + type: FieldType.SIGNATURE_SINGLE, + name: "signature", + constraints: { presence: false }, + }, + }, + "signature", + `${uuid.v4()}.png` + ) + }) + }) + describe("exportRows", () => { beforeEach(async () => { table = await config.api.table.save(defaultTable()) diff --git a/packages/server/src/integrations/rest.ts b/packages/server/src/integrations/rest.ts index 9247f5ef86..7889457903 100644 --- a/packages/server/src/integrations/rest.ts +++ b/packages/server/src/integrations/rest.ts @@ -381,32 +381,37 @@ export class RestIntegration implements IntegrationBase { authConfigId?: string, authConfigType?: RestAuthType ): Promise<{ [key: string]: any }> { - let headers: any = {} + if (!authConfigId) { + return {} + } - if (authConfigId) { - if (authConfigType === RestAuthType.OAUTH2) { - headers.Authorization = await sdk.oauth2.generateToken(authConfigId) - } else if (this.config.authConfigs) { - const authConfig = this.config.authConfigs.filter( - c => c._id === authConfigId - )[0] - // check the config still exists before proceeding - // if not - do nothing - if (authConfig) { - const { type, config } = authConfig - switch (type) { - case RestAuthType.BASIC: - headers.Authorization = `Basic ${Buffer.from( - `${config.username}:${config.password}` - ).toString("base64")}` - break - case RestAuthType.BEARER: - headers.Authorization = `Bearer ${config.token}` - break - default: - throw utils.unreachable(type) - } - } + if (authConfigType === RestAuthType.OAUTH2) { + return { Authorization: await sdk.oauth2.generateToken(authConfigId) } + } + + if (!this.config.authConfigs) { + return {} + } + + let headers: any = {} + const authConfig = this.config.authConfigs.filter( + c => c._id === authConfigId + )[0] + // check the config still exists before proceeding + // if not - do nothing + if (authConfig) { + const { type, config } = authConfig + switch (type) { + case RestAuthType.BASIC: + headers.Authorization = `Basic ${Buffer.from( + `${config.username}:${config.password}` + ).toString("base64")}` + break + case RestAuthType.BEARER: + headers.Authorization = `Bearer ${config.token}` + break + default: + throw utils.unreachable(type) } } diff --git a/packages/server/src/sdk/app/oauth2/crud.ts b/packages/server/src/sdk/app/oauth2/crud.ts index 66ba718ee6..374c953710 100644 --- a/packages/server/src/sdk/app/oauth2/crud.ts +++ b/packages/server/src/sdk/app/oauth2/crud.ts @@ -34,7 +34,7 @@ export async function create( throw new HTTPError("Name already used", 400) } - const id = `${VirtualDocumentType.ROW_ACTION}${SEPARATOR}${utils.newid()}` + const id = `${VirtualDocumentType.OAUTH2_CONFIG}${SEPARATOR}${utils.newid()}` doc.configs[id] = { id, ...config, @@ -48,3 +48,49 @@ export async function get(id: string): Promise { const doc = await getDocument() return doc?.configs?.[id] } + +export async function update(config: OAuth2Config): Promise { + const db = context.getAppDB() + const doc: OAuth2Configs = (await getDocument(db)) ?? { + _id: DocumentType.OAUTH2_CONFIG, + configs: {}, + } + + if (!doc.configs[config.id]) { + throw new HTTPError(`OAuth2 config with id '${config.id}' not found.`, 404) + } + + if ( + Object.values(doc.configs).find( + c => c.name === config.name && c.id !== config.id + ) + ) { + throw new HTTPError( + `OAuth2 config with name '${config.name}' is already taken.`, + 400 + ) + } + + doc.configs[config.id] = { + ...config, + } + + await db.put(doc) + return doc.configs[config.id] +} + +export async function remove(configId: string): Promise { + const db = context.getAppDB() + const doc: OAuth2Configs = (await getDocument(db)) ?? { + _id: DocumentType.OAUTH2_CONFIG, + configs: {}, + } + + if (!doc.configs[configId]) { + throw new HTTPError(`OAuth2 config with id '${configId}' not found.`, 404) + } + + delete doc.configs[configId] + + await db.put(doc) +} diff --git a/packages/server/src/tests/utilities/api/oauth2.ts b/packages/server/src/tests/utilities/api/oauth2.ts index 9f52f27bfe..d4c99c9598 100644 --- a/packages/server/src/tests/utilities/api/oauth2.ts +++ b/packages/server/src/tests/utilities/api/oauth2.ts @@ -1,6 +1,6 @@ import { - CreateOAuth2ConfigRequest, - CreateOAuth2ConfigResponse, + UpsertOAuth2ConfigRequest, + UpsertOAuth2ConfigResponse, FetchOAuth2ConfigsResponse, } from "@budibase/types" import { Expectations, TestAPI } from "./base" @@ -13,10 +13,10 @@ export class OAuth2API extends TestAPI { } create = async ( - body: CreateOAuth2ConfigRequest, + body: UpsertOAuth2ConfigRequest, expectations?: Expectations ) => { - return await this._post("/api/oauth2", { + return await this._post("/api/oauth2", { body, expectations: { status: expectations?.status ?? 201, @@ -24,4 +24,21 @@ export class OAuth2API extends TestAPI { }, }) } + + update = async ( + id: string, + body: UpsertOAuth2ConfigRequest, + expectations?: Expectations + ) => { + return await this._put(`/api/oauth2/${id}`, { + body, + expectations, + }) + } + + delete = async (id: string, expectations?: Expectations) => { + return await this._delete(`/api/oauth2/${id}`, { + expectations, + }) + } } diff --git a/packages/shared-core/src/filters.ts b/packages/shared-core/src/filters.ts index afe99d9565..a0bd54fee6 100644 --- a/packages/shared-core/src/filters.ts +++ b/packages/shared-core/src/filters.ts @@ -1,30 +1,30 @@ import { - Datasource, + ArrayOperator, + BasicOperator, BBReferenceFieldSubType, + Datasource, + EmptyFilterOption, + FieldConstraints, FieldType, FormulaType, + isArraySearchOperator, + isBasicSearchOperator, + isLogicalSearchOperator, + isRangeSearchOperator, LegacyFilter, + LogicalOperator, + RangeOperator, + RowSearchParams, + SearchFilter, + SearchFilterOperator, SearchFilters, SearchQueryFields, - ArrayOperator, - SearchFilterOperator, - SortType, - FieldConstraints, - SortOrder, - RowSearchParams, - EmptyFilterOption, SearchResponse, + SortOrder, + SortType, Table, - BasicOperator, - RangeOperator, - LogicalOperator, - isLogicalSearchOperator, - UISearchFilter, UILogicalOperator, - isBasicSearchOperator, - isArraySearchOperator, - isRangeSearchOperator, - SearchFilter, + UISearchFilter, } from "@budibase/types" import dayjs from "dayjs" import { OperatorOptions, SqlNumberTypeRangeMap } from "./constants" @@ -444,6 +444,7 @@ export function buildQuery( return {} } + // Migrate legacy filters if required if (Array.isArray(filter)) { filter = processSearchFilters(filter) if (!filter) { @@ -451,10 +452,7 @@ export function buildQuery( } } - const operator = logicalOperatorFromUI( - filter.logicalOperator || UILogicalOperator.ALL - ) - + // Determine top level empty filter behaviour const query: SearchFilters = {} if (filter.onEmptyFilter) { query.onEmptyFilter = filter.onEmptyFilter @@ -462,8 +460,24 @@ export function buildQuery( query.onEmptyFilter = EmptyFilterOption.RETURN_ALL } + // Default to matching all groups/filters + const operator = logicalOperatorFromUI( + filter.logicalOperator || UILogicalOperator.ALL + ) + query[operator] = { conditions: (filter.groups || []).map(group => { + // Check if we contain more groups + if (group.groups) { + const searchFilter = buildQuery(group) + + // We don't define this properly in the types, but certain fields should + // not be present in these nested search filters + delete searchFilter.onEmptyFilter + return searchFilter + } + + // Otherwise handle filters const { allOr, onEmptyFilter, filters } = splitFiltersArray( group.filters || [] ) @@ -471,7 +485,7 @@ export function buildQuery( query.onEmptyFilter = onEmptyFilter } - // logicalOperator takes precendence over allOr + // logicalOperator takes precedence over allOr let operator = allOr ? LogicalOperator.OR : LogicalOperator.AND if (group.logicalOperator) { operator = logicalOperatorFromUI(group.logicalOperator) diff --git a/packages/shared-core/src/tests/filters.spec.ts b/packages/shared-core/src/tests/filters.spec.ts new file mode 100644 index 0000000000..87c43d83f2 --- /dev/null +++ b/packages/shared-core/src/tests/filters.spec.ts @@ -0,0 +1,156 @@ +import { buildQuery } from "../filters" +import { + BasicOperator, + EmptyFilterOption, + FieldType, + UILogicalOperator, + UISearchFilter, +} from "@budibase/types" + +describe("filter to query conversion", () => { + it("handles a filter with 1 group", () => { + const filter: UISearchFilter = { + logicalOperator: UILogicalOperator.ALL, + onEmptyFilter: EmptyFilterOption.RETURN_NONE, + groups: [ + { + logicalOperator: UILogicalOperator.ALL, + filters: [ + { + field: "city", + operator: BasicOperator.STRING, + value: "lon", + }, + ], + }, + ], + } + const query = buildQuery(filter) + expect(query).toEqual({ + onEmptyFilter: "none", + $and: { + conditions: [ + { + $and: { + conditions: [ + { + string: { + city: "lon", + }, + }, + ], + }, + }, + ], + }, + }) + }) + + it("handles an empty filter", () => { + const filter = undefined + const query = buildQuery(filter) + expect(query).toEqual({}) + }) + + it("handles legacy filters", () => { + const filter = [ + { + field: "city", + operator: BasicOperator.STRING, + value: "lon", + }, + ] + const query = buildQuery(filter) + expect(query).toEqual({ + onEmptyFilter: "all", + $and: { + conditions: [ + { + $and: { + conditions: [ + { + string: { + city: "lon", + }, + }, + ], + }, + }, + ], + }, + }) + }) + + it("handles nested groups", () => { + const filter: UISearchFilter = { + logicalOperator: UILogicalOperator.ALL, + onEmptyFilter: EmptyFilterOption.RETURN_NONE, + groups: [ + { + logicalOperator: UILogicalOperator.ALL, + filters: [ + { + field: "city", + operator: BasicOperator.STRING, + value: "lon", + }, + ], + }, + { + logicalOperator: UILogicalOperator.ALL, + groups: [ + { + logicalOperator: UILogicalOperator.ANY, + filters: [ + { + valueType: "Binding", + field: "country.country_name", + type: FieldType.STRING, + operator: BasicOperator.EQUAL, + noValue: false, + value: "England", + }, + ], + }, + ], + }, + ], + } + const query = buildQuery(filter) + expect(query).toEqual({ + onEmptyFilter: "none", + $and: { + conditions: [ + { + $and: { + conditions: [ + { + string: { + city: "lon", + }, + }, + ], + }, + }, + { + $and: { + conditions: [ + { + $or: { + conditions: [ + { + equal: { + "country.country_name": "England", + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }) + }) +}) diff --git a/packages/types/src/api/web/app/oauth2.ts b/packages/types/src/api/web/app/oauth2.ts index 4a6e836ef4..050cdf67fd 100644 --- a/packages/types/src/api/web/app/oauth2.ts +++ b/packages/types/src/api/web/app/oauth2.ts @@ -7,13 +7,13 @@ export interface FetchOAuth2ConfigsResponse { configs: OAuth2ConfigResponse[] } -export interface CreateOAuth2ConfigRequest { +export interface UpsertOAuth2ConfigRequest { name: string url: string clientId: string clientSecret: string } -export interface CreateOAuth2ConfigResponse { +export interface UpsertOAuth2ConfigResponse { config: OAuth2ConfigResponse } diff --git a/packages/types/src/api/web/searchFilter.ts b/packages/types/src/api/web/searchFilter.ts index 7a6920fb60..1872fc0112 100644 --- a/packages/types/src/api/web/searchFilter.ts +++ b/packages/types/src/api/web/searchFilter.ts @@ -38,11 +38,19 @@ export type SearchFilter = { // involved. We convert this to a SearchFilters before use with the search SDK. export type LegacyFilter = AllOr | OnEmptyFilter | SearchFilter +// A search filter group should either contain groups or filters, but not both export type SearchFilterGroup = { logicalOperator?: UILogicalOperator - groups?: SearchFilterGroup[] - filters?: LegacyFilter[] -} +} & ( + | { + groups?: (SearchFilterGroup | UISearchFilter)[] + filters?: never + } + | { + filters?: LegacyFilter[] + groups?: never + } +) // As of v3, this is the format that the frontend always sends when search // filters are involved. We convert this to SearchFilters before use with the diff --git a/packages/types/src/documents/app/table/schema.ts b/packages/types/src/documents/app/table/schema.ts index 551b1f16a8..86e15e4974 100644 --- a/packages/types/src/documents/app/table/schema.ts +++ b/packages/types/src/documents/app/table/schema.ts @@ -156,8 +156,8 @@ export interface FieldConstraints { message?: string } numericality?: { - greaterThanOrEqualTo: string | null - lessThanOrEqualTo: string | null + greaterThanOrEqualTo?: string | null + lessThanOrEqualTo?: string | null } presence?: | boolean @@ -165,8 +165,8 @@ export interface FieldConstraints { allowEmpty?: boolean } datetime?: { - latest: string - earliest: string + latest?: string + earliest?: string } } @@ -197,7 +197,7 @@ export interface BigIntFieldMetadata extends BaseFieldSchema { default?: string } -interface BaseFieldSchema extends UIFieldMetadata { +export interface BaseFieldSchema extends UIFieldMetadata { type: FieldType name: string sortable?: boolean diff --git a/packages/types/src/ui/fields.ts b/packages/types/src/ui/fields.ts new file mode 100644 index 0000000000..54faf16252 --- /dev/null +++ b/packages/types/src/ui/fields.ts @@ -0,0 +1,43 @@ +import { + FieldType, + FieldConstraints, + type FieldSchema, + type FormulaResponseType, +} from "../" + +export interface UIField { + name: string + type: FieldType + subtype?: string + icon: string + constraints?: { + type?: string + presence?: boolean + length?: any + inclusion?: string[] + numericality?: { + greaterThanOrEqualTo?: string + lessThanOrEqualTo?: string + } + datetime?: { + latest?: string + earliest?: string + } + } +} + +// an empty/partial field schema which is used when building new columns in the UI +// the current construction process of a column means that it is never certain what +// this object contains, or what type it is currently set to, meaning that our +// strict FieldSchema isn't really usable here, the strict fieldSchema only occurs +// when the table is saved, but in the UI in can be in a real mix of states +export type FieldSchemaConfig = FieldSchema & { + constraints: FieldConstraints + fieldName?: string + responseType?: FormulaResponseType + default?: any + fieldId?: string + optionColors?: string[] + schema?: any + json?: string +} diff --git a/packages/types/src/ui/index.ts b/packages/types/src/ui/index.ts index 0b219f54fb..b40baa61f5 100644 --- a/packages/types/src/ui/index.ts +++ b/packages/types/src/ui/index.ts @@ -5,3 +5,4 @@ export * from "./dataFetch" export * from "./datasource" export * from "./common" export * from "./BudibaseApp" +export * from "./fields"