diff --git a/lerna.json b/lerna.json index 87f0731cda..4b258aad77 100644 --- a/lerna.json +++ b/lerna.json @@ -1,6 +1,6 @@ { "$schema": "node_modules/lerna/schemas/lerna-schema.json", - "version": "3.9.5", + "version": "3.10.1", "npmClient": "yarn", "concurrency": 20, "command": { diff --git a/packages/bbui/src/Form/Core/DatePicker/DatePicker.svelte b/packages/bbui/src/Form/Core/DatePicker/DatePicker.svelte index f5189c9edd..6c8c367b31 100644 --- a/packages/bbui/src/Form/Core/DatePicker/DatePicker.svelte +++ b/packages/bbui/src/Form/Core/DatePicker/DatePicker.svelte @@ -13,7 +13,7 @@ export let readonly = false export let error = null export let enableTime = true - export let value = null + export let value = undefined export let placeholder = null export let timeOnly = false export let ignoreTimezones = false diff --git a/packages/bbui/src/Form/Core/DatePicker/NumberInput.svelte b/packages/bbui/src/Form/Core/DatePicker/NumberInput.svelte index 84b55c403f..794984c4b6 100644 --- a/packages/bbui/src/Form/Core/DatePicker/NumberInput.svelte +++ b/packages/bbui/src/Form/Core/DatePicker/NumberInput.svelte @@ -7,6 +7,8 @@ export let type = "number" $: style = width ? `width:${width}px;` : "" + + const selectAll = event => event.target.select() diff --git a/packages/bbui/src/Form/Core/DateRangePicker.svelte b/packages/bbui/src/Form/Core/DateRangePicker.svelte index 72180a98d6..af4f67679b 100644 --- a/packages/bbui/src/Form/Core/DateRangePicker.svelte +++ b/packages/bbui/src/Form/Core/DateRangePicker.svelte @@ -1,24 +1,87 @@ -
(fromDate = e.detail)} - enableTime={false} + value={parsedFrom} + on:change={e => onChangeFrom(e.detail)} + {enableTime} + {timeOnly} + {ignoreTimezones} />
(toDate = e.detail)} - enableTime={false} + value={parsedTo} + on:change={e => onChangeTo(e.detail)} + {enableTime} + {timeOnly} + {ignoreTimezones} />
diff --git a/packages/bbui/src/Form/DateRangePicker.svelte b/packages/bbui/src/Form/DateRangePicker.svelte index 39c2acb96a..12037e0ee4 100644 --- a/packages/bbui/src/Form/DateRangePicker.svelte +++ b/packages/bbui/src/Form/DateRangePicker.svelte @@ -3,7 +3,7 @@ import DateRangePicker from "./Core/DateRangePicker.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 @@ -12,6 +12,8 @@ export let helpText = null export let appendTo = undefined export let ignoreTimezones = false + export let enableTime = false + export let timeOnly = false const dispatch = createEventDispatcher() @@ -29,6 +31,8 @@ {value} {appendTo} {ignoreTimezones} + {enableTime} + {timeOnly} on:change={onChange} /> diff --git a/packages/bbui/src/Icon/Icon.svelte b/packages/bbui/src/Icon/Icon.svelte index c74421a261..f7e101b547 100644 --- a/packages/bbui/src/Icon/Icon.svelte +++ b/packages/bbui/src/Icon/Icon.svelte @@ -11,7 +11,7 @@ export let hoverColor: string | undefined = undefined export let tooltip: string | undefined = undefined export let tooltipPosition: TooltipPosition = TooltipPosition.Bottom - export let tooltipType = TooltipType.Default + export let tooltipType: TooltipType = TooltipType.Default export let tooltipColor: string | undefined = undefined export let tooltipWrap: boolean = true export let newStyles: boolean = false diff --git a/packages/bbui/src/Layout/Layout.svelte b/packages/bbui/src/Layout/Layout.svelte index b3de7b49db..61d33e3ab2 100644 --- a/packages/bbui/src/Layout/Layout.svelte +++ b/packages/bbui/src/Layout/Layout.svelte @@ -1,7 +1,7 @@ @@ -108,7 +110,7 @@ bind:expanded bind:value={inputValue} readonly={!!suggestedCode} - {expandedOnly} + expandedOnly={shouldAlwaysBeExpanded} /> diff --git a/packages/builder/src/components/common/EditableIcon.svelte b/packages/builder/src/components/common/EditableIcon.svelte index 150a23ecb7..1c3975c6f9 100644 --- a/packages/builder/src/components/common/EditableIcon.svelte +++ b/packages/builder/src/components/common/EditableIcon.svelte @@ -1,15 +1,13 @@ - @@ -28,7 +26,7 @@ - + diff --git a/packages/builder/src/components/common/ai/AIInput.svelte b/packages/builder/src/components/common/ai/AIInput.svelte index 3d1a2e25c2..fc28aa20c9 100644 --- a/packages/builder/src/components/common/ai/AIInput.svelte +++ b/packages/builder/src/components/common/ai/AIInput.svelte @@ -13,7 +13,6 @@ export let value: string = "" export const submit = onPromptSubmit - $: expanded = expandedOnly || expanded const dispatch = createEventDispatcher() let promptInput: HTMLInputElement @@ -22,6 +21,7 @@ let switchOnAIModal: Modal let addCreditsModal: Modal + $: expanded = expandedOnly || expanded $: accountPortalAccess = $auth?.user?.accountPortalAccess $: accountPortal = $admin.accountPortalUrl $: aiEnabled = $auth?.user?.llm @@ -92,9 +92,12 @@ class="ai-icon" class:loading={promptLoading} class:disabled={expanded && disabled} + class:no-toggle={expandedOnly} on:click={e => { - e.stopPropagation() - toggleExpand() + if (!expandedOnly) { + e.stopPropagation() + toggleExpand() + } }} /> {#if expanded} @@ -290,6 +293,10 @@ z-index: 2; } + .ai-icon.no-toggle { + cursor: default; + } + .ai-gen-text { white-space: nowrap; overflow: hidden; diff --git a/packages/builder/src/components/design/ScreenDetailsModal.svelte b/packages/builder/src/components/design/ScreenDetailsModal.svelte index 410ddee8a9..d7af9d8102 100644 --- a/packages/builder/src/components/design/ScreenDetailsModal.svelte +++ b/packages/builder/src/components/design/ScreenDetailsModal.svelte @@ -1,25 +1,25 @@ -
diff --git a/packages/builder/src/components/design/settings/controls/DraggableList/DraggableList.svelte b/packages/builder/src/components/design/settings/controls/DraggableList/DraggableList.svelte index 0ba7de42c2..f1f7005800 100644 --- a/packages/builder/src/components/design/settings/controls/DraggableList/DraggableList.svelte +++ b/packages/builder/src/components/design/settings/controls/DraggableList/DraggableList.svelte @@ -11,7 +11,7 @@ export let listTypeProps = {} export let listItemKey export let draggable = true - export let focus + export let focus = undefined let zoneType = generate() diff --git a/packages/builder/src/components/design/settings/controls/FilterConfiguration/EditFilterPopover.svelte b/packages/builder/src/components/design/settings/controls/FilterConfiguration/EditFilterPopover.svelte new file mode 100644 index 0000000000..e0afb94123 --- /dev/null +++ b/packages/builder/src/components/design/settings/controls/FilterConfiguration/EditFilterPopover.svelte @@ -0,0 +1,127 @@ + + + + + 0} + maxHeight={600} + minWidth={360} + maxWidth={360} + offset={18} +> + + + + { + drawers = [...drawers, e.detail] + }} + on:drawerHide={() => { + drawers = drawers.slice(0, -1) + }} + /> + + + + + diff --git a/packages/builder/src/components/design/settings/controls/FilterConfiguration/FilterConfiguration.svelte b/packages/builder/src/components/design/settings/controls/FilterConfiguration/FilterConfiguration.svelte new file mode 100644 index 0000000000..5d6202b32c --- /dev/null +++ b/packages/builder/src/components/design/settings/controls/FilterConfiguration/FilterConfiguration.svelte @@ -0,0 +1,186 @@ + + +
+
+ Fields + { + selectedAll = !selectedAll + let update = parsedColumns.map(field => { + return { + ...field, + active: selectedAll, + } + }) + listUpdate(update) + }} + value={selectedAll} + text="" + /> +
+ + {#if parsedColumns?.length} + { + listUpdate(e.detail) + }} + on:itemChange={itemUpdate} + items={parsedColumns || []} + listItemKey={"field"} + listType={FilterSetting} + listTypeProps={{ + bindings, + }} + /> + {:else} + + {/if} +
+ + diff --git a/packages/builder/src/components/design/settings/controls/FilterConfiguration/FilterSetting.svelte b/packages/builder/src/components/design/settings/controls/FilterConfiguration/FilterSetting.svelte new file mode 100644 index 0000000000..626576b5db --- /dev/null +++ b/packages/builder/src/components/design/settings/controls/FilterConfiguration/FilterSetting.svelte @@ -0,0 +1,135 @@ + + +
+
+ +
+ + {item.field} +
+
+
{item.label || item.field}
+
+
+ { + e.stopPropagation() + }} + on:change={onToggle(item)} + text="" + value={item.active} + thin + /> +
+
+ + diff --git a/packages/builder/src/components/design/settings/controls/FilterableSelect.svelte b/packages/builder/src/components/design/settings/controls/FilterableSelect.svelte new file mode 100644 index 0000000000..6904cc8435 --- /dev/null +++ b/packages/builder/src/components/design/settings/controls/FilterableSelect.svelte @@ -0,0 +1,36 @@ + + + { + if (!editableFilter) return + + const sanitized = sanitizeOperator({ + ...editableFilter, + operator: e.detail, + }) + + editableFilter = { ...(sanitized || editableFilter) } + }} + /> +
+ + {#if editableFilter?.type && [FieldType.STRING, FieldType.LONGFORM, FieldType.NUMBER, FieldType.BIGINT, FieldType.FORMULA, FieldType.AI].includes(editableFilter.type)} + { + if (!editableFilter) return + editableFilter = sanitizeOperator({ + ...editableFilter, + value: e.detail, + }) + }} + /> + {:else if (editableFilter?.type && editableFilter?.type === FieldType.ARRAY) || (editableFilter.type === FieldType.OPTIONS && editableFilter.operator === ArrayOperator.ONE_OF)} + {@const isMulti = isArrayOperator(editableFilter.operator)} + {@const type = isMulti ? CoreCheckboxGroup : CoreRadioGroup} + {#key type} + { + if (!editableFilter) return + editableFilter = sanitizeOperator({ + ...editableFilter, + value: e.detail, + }) + }} + /> + {/key} + {:else if editableFilter.type === FieldType.OPTIONS} + { + if (!editableFilter) return + editableFilter = sanitizeOperator({ + ...editableFilter, + value: e.detail, + }) + }} + /> + {:else if editableFilter.type === FieldType.DATETIME && editableFilter.operator === "range"} + { + const [from, to] = e.detail + const parsedFrom = enableTime + ? from.utc().format() + : Helpers.stringifyDate(from, { + enableTime, + timeOnly, + ignoreTimezones, + }) + + const parsedTo = enableTime + ? to.utc().format() + : Helpers.stringifyDate(to, { + enableTime, + timeOnly, + ignoreTimezones, + }) + + if (!editableFilter) return + editableFilter = sanitizeOperator({ + ...editableFilter, + value: { + low: parsedFrom, + high: parsedTo, + }, + }) + }} + /> + {:else if editableFilter.type === FieldType.DATETIME} + { + if (!editableFilter) return + editableFilter = sanitizeOperator({ + ...editableFilter, + value: e.detail, + }) + }} + /> + {:else if editableFilter.type === FieldType.BOOLEAN} + + {/if} + + + {/if} + + + + + diff --git a/packages/client/src/components/app/forms/BBReferenceField.svelte b/packages/client/src/components/app/forms/BBReferenceField.svelte index 60932ecf56..7dc220e76a 100644 --- a/packages/client/src/components/app/forms/BBReferenceField.svelte +++ b/packages/client/src/components/app/forms/BBReferenceField.svelte @@ -1,10 +1,12 @@ {#if user} diff --git a/packages/frontend-core/src/components/UserAvatars.svelte b/packages/frontend-core/src/components/UserAvatars.svelte index 2399c659cc..eaf4727811 100644 --- a/packages/frontend-core/src/components/UserAvatars.svelte +++ b/packages/frontend-core/src/components/UserAvatars.svelte @@ -1,27 +1,31 @@ -
{#each avatars as user} - {#if user._id === "overflow"} + {#if !isUser(user)} row.doc!) } -async function getScreens() { - const db = context.getAppDB() - return ( - await db.allDocs( - getScreenParams(null, { - include_docs: true, - }) - ) - ).rows.map(row => row.doc!) -} - function getUserRoleId(ctx: UserCtx) { return !ctx.user?.role || !ctx.user.role._id ? roles.BUILTIN_ROLE_IDS.PUBLIC @@ -241,7 +229,7 @@ export async function fetchAppDefinition( const userRoleId = getUserRoleId(ctx) const accessController = new roles.AccessController() const screens = await accessController.checkScreensAccess( - await getScreens(), + await sdk.screens.fetch(), userRoleId ) ctx.body = { @@ -257,7 +245,7 @@ export async function fetchAppPackage( const appId = context.getAppId() const application = await sdk.applications.metadata.get() const layouts = await getLayouts() - let screens = await getScreens() + let screens = await sdk.screens.fetch() const license = await licensing.cache.getCachedLicense() // Enrich plugin URLs @@ -915,7 +903,7 @@ async function migrateAppNavigation() { const db = context.getAppDB() const existing = await sdk.applications.metadata.get() const layouts: Layout[] = await getLayouts() - const screens: Screen[] = await getScreens() + const screens: Screen[] = await sdk.screens.fetch() // Migrate all screens, removing custom layouts for (let screen of screens) { diff --git a/packages/server/src/api/controllers/layout.ts b/packages/server/src/api/controllers/layout.ts index 4c996dde35..c23d8c4e2f 100644 --- a/packages/server/src/api/controllers/layout.ts +++ b/packages/server/src/api/controllers/layout.ts @@ -1,13 +1,13 @@ import { EMPTY_LAYOUT } from "../../constants/layouts" -import { generateLayoutID, getScreenParams } from "../../db/utils" +import { generateLayoutID } from "../../db/utils" import { events, context } from "@budibase/backend-core" import { DeleteLayoutResponse, - Layout, SaveLayoutRequest, SaveLayoutResponse, UserCtx, } from "@budibase/types" +import sdk from "../../sdk" export async function save( ctx: UserCtx @@ -36,13 +36,9 @@ export async function destroy(ctx: UserCtx) { const layoutId = ctx.params.layoutId, layoutRev = ctx.params.layoutRev - const layoutsUsedByScreens = ( - await db.allDocs( - getScreenParams(null, { - include_docs: true, - }) - ) - ).rows.map(element => element.doc!.layoutId) + const layoutsUsedByScreens = (await sdk.screens.fetch()).map( + element => element.layoutId + ) if (layoutsUsedByScreens.includes(layoutId)) { ctx.throw(400, "Cannot delete a layout that's being used by a screen") } diff --git a/packages/server/src/api/routes/tests/row.spec.ts b/packages/server/src/api/routes/tests/row.spec.ts index 3fb882ff2f..829b7f56d2 100644 --- a/packages/server/src/api/routes/tests/row.spec.ts +++ b/packages/server/src/api/routes/tests/row.spec.ts @@ -918,7 +918,9 @@ if (descriptions.length) { describe("get", () => { it("reads an existing row successfully", async () => { - const existing = await config.api.row.save(table._id!, {}) + const existing = await config.api.row.save(table._id!, { + name: "foo", + }) const res = await config.api.row.get(table._id!, existing._id!) @@ -930,7 +932,7 @@ if (descriptions.length) { it("returns 404 when row does not exist", async () => { const table = await config.api.table.save(defaultTable()) - await config.api.row.save(table._id!, {}) + await config.api.row.save(table._id!, { name: "foo" }) await config.api.row.get(table._id!, "1234567", { status: 404, }) @@ -958,8 +960,8 @@ if (descriptions.length) { it("fetches all rows for given tableId", async () => { const table = await config.api.table.save(defaultTable()) const rows = await Promise.all([ - config.api.row.save(table._id!, {}), - config.api.row.save(table._id!, {}), + config.api.row.save(table._id!, { name: "foo" }), + config.api.row.save(table._id!, { name: "bar" }), ]) const res = await config.api.row.fetch(table._id!) @@ -975,7 +977,9 @@ if (descriptions.length) { describe("update", () => { it("updates an existing row successfully", async () => { - const existing = await config.api.row.save(table._id!, {}) + const existing = await config.api.row.save(table._id!, { + name: "foo", + }) await expectRowUsage(0, async () => { const res = await config.api.row.save(table._id!, { @@ -1166,7 +1170,9 @@ if (descriptions.length) { }) it("should update only the fields that are supplied", async () => { - const existing = await config.api.row.save(table._id!, {}) + const existing = await config.api.row.save(table._id!, { + name: "foo", + }) await expectRowUsage(0, async () => { const row = await config.api.row.patch(table._id!, { @@ -1186,6 +1192,22 @@ if (descriptions.length) { }) }) + it("should not require the primary display", async () => { + const existing = await config.api.row.save(table._id!, { + name: "foo", + description: "bar", + }) + await expectRowUsage(0, async () => { + const row = await config.api.row.patch(table._id!, { + _id: existing._id!, + _rev: existing._rev!, + tableId: table._id!, + description: "baz", + }) + expect(row.description).toEqual("baz") + }) + }) + it("should update only the fields that are supplied and emit the correct oldRow", async () => { let beforeRow = await config.api.row.save(table._id!, { name: "test", @@ -1213,7 +1235,9 @@ if (descriptions.length) { }) it("should throw an error when given improper types", async () => { - const existing = await config.api.row.save(table._id!, {}) + const existing = await config.api.row.save(table._id!, { + name: "foo", + }) await expectRowUsage(0, async () => { await config.api.row.patch( @@ -1289,6 +1313,7 @@ if (descriptions.length) { description: "test", }) const { _id } = await config.api.row.save(table._id!, { + name: "test", relationship: [{ _id: row._id }, { _id: row2._id }], }) const relatedRow = await config.api.row.get(table._id!, _id!, { @@ -1440,7 +1465,9 @@ if (descriptions.length) { }) it("should be able to delete a row", async () => { - const createdRow = await config.api.row.save(table._id!, {}) + const createdRow = await config.api.row.save(table._id!, { + name: "foo", + }) await expectRowUsage(isInternal ? -1 : 0, async () => { const res = await config.api.row.bulkDelete(table._id!, { @@ -1451,7 +1478,9 @@ if (descriptions.length) { }) it("should be able to delete a row with ID only", async () => { - const createdRow = await config.api.row.save(table._id!, {}) + const createdRow = await config.api.row.save(table._id!, { + name: "foo", + }) await expectRowUsage(isInternal ? -1 : 0, async () => { const res = await config.api.row.bulkDelete(table._id!, { @@ -1463,8 +1492,12 @@ if (descriptions.length) { }) it("should be able to bulk delete rows, including a row that doesn't exist", async () => { - const createdRow = await config.api.row.save(table._id!, {}) - const createdRow2 = await config.api.row.save(table._id!, {}) + const createdRow = await config.api.row.save(table._id!, { + name: "foo", + }) + const createdRow2 = await config.api.row.save(table._id!, { + name: "bar", + }) const res = await config.api.row.bulkDelete(table._id!, { rows: [createdRow, createdRow2, { _id: "9999999" }], @@ -1581,8 +1614,8 @@ if (descriptions.length) { }) it("should be able to delete a bulk set of rows", async () => { - const row1 = await config.api.row.save(table._id!, {}) - const row2 = await config.api.row.save(table._id!, {}) + const row1 = await config.api.row.save(table._id!, { name: "foo" }) + const row2 = await config.api.row.save(table._id!, { name: "bar" }) await expectRowUsage(isInternal ? -2 : 0, async () => { const res = await config.api.row.bulkDelete(table._id!, { @@ -1596,9 +1629,9 @@ if (descriptions.length) { it("should be able to delete a variety of row set types", async () => { const [row1, row2, row3] = await Promise.all([ - config.api.row.save(table._id!, {}), - config.api.row.save(table._id!, {}), - config.api.row.save(table._id!, {}), + config.api.row.save(table._id!, { name: "foo" }), + config.api.row.save(table._id!, { name: "bar" }), + config.api.row.save(table._id!, { name: "baz" }), ]) await expectRowUsage(isInternal ? -3 : 0, async () => { @@ -1612,7 +1645,7 @@ if (descriptions.length) { }) it("should accept a valid row object and delete the row", async () => { - const row1 = await config.api.row.save(table._id!, {}) + const row1 = await config.api.row.save(table._id!, { name: "foo" }) await expectRowUsage(isInternal ? -1 : 0, async () => { const res = await config.api.row.delete( @@ -2347,7 +2380,9 @@ if (descriptions.length) { !isInternal && it("should allow exporting all columns", async () => { - const existing = await config.api.row.save(table._id!, {}) + const existing = await config.api.row.save(table._id!, { + name: "foo", + }) const res = await config.api.row.exportRows(table._id!, { rows: [existing._id!], }) @@ -2363,7 +2398,9 @@ if (descriptions.length) { }) it("should allow exporting without filtering", async () => { - const existing = await config.api.row.save(table._id!, {}) + const existing = await config.api.row.save(table._id!, { + name: "foo", + }) const res = await config.api.row.exportRows(table._id!) const results = JSON.parse(res) expect(results.length).toEqual(1) @@ -2373,7 +2410,9 @@ if (descriptions.length) { }) it("should allow exporting only certain columns", async () => { - const existing = await config.api.row.save(table._id!, {}) + const existing = await config.api.row.save(table._id!, { + name: "foo", + }) const res = await config.api.row.exportRows(table._id!, { rows: [existing._id!], columns: ["_id"], @@ -2388,7 +2427,9 @@ if (descriptions.length) { }) it("should handle single quotes in row filtering", async () => { - const existing = await config.api.row.save(table._id!, {}) + const existing = await config.api.row.save(table._id!, { + name: "foo", + }) const res = await config.api.row.exportRows(table._id!, { rows: [`['${existing._id!}']`], }) @@ -2399,7 +2440,9 @@ if (descriptions.length) { }) it("should return an error if no table is found", async () => { - const existing = await config.api.row.save(table._id!, {}) + const existing = await config.api.row.save(table._id!, { + name: "foo", + }) await config.api.row.exportRows( "1234567", { rows: [existing._id!] }, diff --git a/packages/server/src/api/routes/tests/search.spec.ts b/packages/server/src/api/routes/tests/search.spec.ts index 7a7f388a2c..034e3422cb 100644 --- a/packages/server/src/api/routes/tests/search.spec.ts +++ b/packages/server/src/api/routes/tests/search.spec.ts @@ -1897,7 +1897,7 @@ if (descriptions.length) { tableOrViewId = await createTableOrView({ product: { name: "product", type: FieldType.STRING }, ai: { - name: "AI", + name: "ai", type: FieldType.AI, operation: AIOperationEnum.PROMPT, prompt: "Translate '{{ product }}' into German", diff --git a/packages/server/src/db/utils.ts b/packages/server/src/db/utils.ts index 6c1065e847..b5ef5d3727 100644 --- a/packages/server/src/db/utils.ts +++ b/packages/server/src/db/utils.ts @@ -10,6 +10,7 @@ import { SourceName, VirtualDocumentType, LinkDocument, + AIFieldMetadata, } from "@budibase/types" export { DocumentType, VirtualDocumentType } from "@budibase/types" @@ -338,6 +339,10 @@ export function isRelationshipColumn( return column.type === FieldType.LINK } +export function isAIColumn(column: FieldSchema): column is AIFieldMetadata { + return column.type === FieldType.AI +} + /** * Generates a new row actions ID. * @returns The new row actions ID which the row actions doc can be stored under. diff --git a/packages/server/src/sdk/app/backups/statistics.ts b/packages/server/src/sdk/app/backups/statistics.ts index aecb3de423..7169d38512 100644 --- a/packages/server/src/sdk/app/backups/statistics.ts +++ b/packages/server/src/sdk/app/backups/statistics.ts @@ -4,8 +4,8 @@ import { getDatasourceParams, getTableParams, getAutomationParams, - getScreenParams, } from "../../../db/utils" +import sdk from "../.." async function runInContext(appId: string, cb: any, db?: Database) { if (db) { @@ -46,8 +46,8 @@ export async function calculateScreenCount(appId: string, db?: Database) { return runInContext( appId, async (db: Database) => { - const screenList = await db.allDocs(getScreenParams()) - return screenList.rows.length + const screenList = await sdk.screens.fetch(db) + return screenList.length }, db ) diff --git a/packages/server/src/sdk/app/rows/tests/utils.spec.ts b/packages/server/src/sdk/app/rows/tests/utils.spec.ts index 516334d31d..0ae9e0c079 100644 --- a/packages/server/src/sdk/app/rows/tests/utils.spec.ts +++ b/packages/server/src/sdk/app/rows/tests/utils.spec.ts @@ -375,4 +375,31 @@ describe("validate", () => { }) }) }) + + describe("primary display", () => { + const getTable = (): Table => ({ + type: "table", + _id: generateTableID(), + name: "table", + sourceId: INTERNAL_TABLE_SOURCE_ID, + sourceType: TableSourceType.INTERNAL, + primaryDisplay: "foo", + schema: { + foo: { + name: "foo", + type: FieldType.STRING, + }, + }, + }) + + it("should always require primary display column", async () => { + const row = {} + const table = getTable() + const output = await validate({ source: table, row }) + expect(output.valid).toBe(false) + expect(output.errors).toStrictEqual({ + foo: ["can't be blank"], + }) + }) + }) }) diff --git a/packages/server/src/sdk/app/rows/utils.ts b/packages/server/src/sdk/app/rows/utils.ts index c19654d817..cac44aaac9 100644 --- a/packages/server/src/sdk/app/rows/utils.ts +++ b/packages/server/src/sdk/app/rows/utils.ts @@ -206,8 +206,14 @@ export async function validate({ ] for (let fieldName of Object.keys(table.schema)) { const column = table.schema[fieldName] - const constraints = cloneDeep(column.constraints) const type = column.type + let constraints = cloneDeep(column.constraints) + + // Ensure display column is required + if (table.primaryDisplay === fieldName) { + constraints = { ...constraints, presence: true } + } + // foreign keys are likely to be enriched if (isForeignKey(fieldName, table)) { continue diff --git a/packages/server/src/sdk/app/screens/screens.ts b/packages/server/src/sdk/app/screens/screens.ts index c600825efb..49554fb28a 100644 --- a/packages/server/src/sdk/app/screens/screens.ts +++ b/packages/server/src/sdk/app/screens/screens.ts @@ -2,9 +2,7 @@ import { getScreenParams } from "../../../db/utils" import { context } from "@budibase/backend-core" import { Screen } from "@budibase/types" -export async function fetch(): Promise { - const db = context.getAppDB() - +export async function fetch(db = context.getAppDB()): Promise { return ( await db.allDocs( getScreenParams(null, { diff --git a/packages/server/src/sdk/app/tables/getters.ts b/packages/server/src/sdk/app/tables/getters.ts index f32150c63d..d472aaef22 100644 --- a/packages/server/src/sdk/app/tables/getters.ts +++ b/packages/server/src/sdk/app/tables/getters.ts @@ -17,177 +17,234 @@ import datasources from "../datasources" import sdk from "../../../sdk" import { ensureQueryUISet } from "../views/utils" import { isV2 } from "../views" +import { tracer } from "dd-trace" export async function processTable(table: Table): Promise { - if (!table) { - return table - } + return await tracer.trace("processTable", async span => { + if (!table) { + return table + } - table = { ...table } - if (table.views) { - for (const [key, view] of Object.entries(table.views)) { - if (!isV2(view)) { - continue + span.addTags({ tableId: table._id }) + + table = { ...table } + if (table.views) { + span.addTags({ numViews: Object.keys(table.views).length }) + for (const [key, view] of Object.entries(table.views)) { + if (!isV2(view)) { + continue + } + table.views[key] = ensureQueryUISet(view) } - table.views[key] = ensureQueryUISet(view) } - } - if (table._id && isExternalTableID(table._id)) { - // Old created external tables via Budibase might have a missing field name breaking some UI such as filters - if (table.schema["id"] && !table.schema["id"].name) { - table.schema["id"].name = "id" + if (table._id && isExternalTableID(table._id)) { + span.addTags({ isExternal: true }) + // Old created external tables via Budibase might have a missing field name breaking some UI such as filters + if (table.schema["id"] && !table.schema["id"].name) { + table.schema["id"].name = "id" + } + return { + ...table, + type: "table", + sourceType: TableSourceType.EXTERNAL, + } + } else { + span.addTags({ isExternal: false }) + const processed: Table = { + ...table, + type: "table", + primary: ["_id"], // internal tables must always use _id as primary key + sourceId: table.sourceId || INTERNAL_TABLE_SOURCE_ID, + sourceType: TableSourceType.INTERNAL, + sql: true, + } + return processed } - return { - ...table, - type: "table", - sourceType: TableSourceType.EXTERNAL, - } - } else { - const processed: Table = { - ...table, - type: "table", - primary: ["_id"], // internal tables must always use _id as primary key - sourceId: table.sourceId || INTERNAL_TABLE_SOURCE_ID, - sourceType: TableSourceType.INTERNAL, - sql: true, - } - return processed - } + }) } export async function processTables(tables: Table[]): Promise { - return await Promise.all(tables.map(table => processTable(table))) + return await tracer.trace("processTables", async span => { + span.addTags({ numTables: tables.length }) + return await Promise.all(tables.map(table => processTable(table))) + }) } async function processEntities(tables: Record) { - for (let key of Object.keys(tables)) { - tables[key] = await processTable(tables[key]) - } - return tables + return await tracer.trace("processEntities", async span => { + span.addTags({ numTables: Object.keys(tables).length }) + for (let key of Object.keys(tables)) { + tables[key] = await processTable(tables[key]) + } + return tables + }) } export async function getAllInternalTables(db?: Database): Promise { - if (!db) { - db = context.getAppDB() - } - const internalTables = await db.allDocs
( - getTableParams(null, { - include_docs: true, - }) - ) - return await processTables(internalTables.rows.map(row => row.doc!)) + return await tracer.trace("getAllInternalTables", async span => { + if (!db) { + db = context.getAppDB() + } + span.addTags({ db: db.name }) + const internalTables = await db.allDocs
( + getTableParams(null, { + include_docs: true, + }) + ) + span.addTags({ numTables: internalTables.rows.length }) + return await processTables(internalTables.rows.map(row => row.doc!)) + }) } async function getAllExternalTables(): Promise { - // this is all datasources, we'll need to filter out internal - const datasources = await sdk.datasources.fetch({ enriched: true }) - const allEntities = datasources - .filter(datasource => datasource._id !== INTERNAL_TABLE_SOURCE_ID) - .map(datasource => datasource.entities) - let final: Table[] = [] - for (let entities of allEntities) { - if (entities) { - final = final.concat(Object.values(entities)) + return await tracer.trace("getAllExternalTables", async span => { + // this is all datasources, we'll need to filter out internal + const datasources = await sdk.datasources.fetch({ enriched: true }) + span.addTags({ numDatasources: datasources.length }) + + const allEntities = datasources + .filter(datasource => datasource._id !== INTERNAL_TABLE_SOURCE_ID) + .map(datasource => datasource.entities) + span.addTags({ numEntities: allEntities.length }) + + let final: Table[] = [] + for (let entities of allEntities) { + if (entities) { + final = final.concat(Object.values(entities)) + } } - } - return await processTables(final) + span.addTags({ numTables: final.length }) + return await processTables(final) + }) } export async function getExternalTable( datasourceId: string, tableName: string ): Promise
{ - const entities = await getExternalTablesInDatasource(datasourceId) - if (!entities[tableName]) { - throw new Error(`Unable to find table named "${tableName}"`) - } - const table = await processTable(entities[tableName]) - if (!table.sourceId) { - table.sourceId = datasourceId - } - return table + return await tracer.trace("getExternalTable", async span => { + span.addTags({ datasourceId, tableName }) + const entities = await getExternalTablesInDatasource(datasourceId) + if (!entities[tableName]) { + throw new Error(`Unable to find table named "${tableName}"`) + } + const table = await processTable(entities[tableName]) + if (!table.sourceId) { + table.sourceId = datasourceId + } + return table + }) } export async function getTable(tableId: string): Promise
{ - const db = context.getAppDB() - let output: Table - if (tableId && isExternalTableID(tableId)) { - let { datasourceId, tableName } = breakExternalTableId(tableId) - const datasource = await datasources.get(datasourceId) - const table = await getExternalTable(datasourceId, tableName) - output = { ...table, sql: isSQL(datasource) } - } else { - output = await db.get
(tableId) - } - return await processTable(output) + return await tracer.trace("getTable", async span => { + const db = context.getAppDB() + span.addTags({ tableId, db: db.name }) + let output: Table + if (tableId && isExternalTableID(tableId)) { + let { datasourceId, tableName } = breakExternalTableId(tableId) + span.addTags({ isExternal: true, datasourceId, tableName }) + const datasource = await datasources.get(datasourceId) + const table = await getExternalTable(datasourceId, tableName) + output = { ...table, sql: isSQL(datasource) } + span.addTags({ isSQL: isSQL(datasource) }) + } else { + output = await db.get
(tableId) + } + return await processTable(output) + }) } export async function doesTableExist(tableId: string): Promise { - try { - const table = await getTable(tableId) - return !!table - } catch (err) { - return false - } + return await tracer.trace("doesTableExist", async span => { + span.addTags({ tableId }) + try { + const table = await getTable(tableId) + span.addTags({ tableExists: !!table }) + return !!table + } catch (err) { + span.addTags({ tableExists: false }) + return false + } + }) } export async function getAllTables() { - const [internal, external] = await Promise.all([ - getAllInternalTables(), - getAllExternalTables(), - ]) - return await processTables([...internal, ...external]) + return await tracer.trace("getAllTables", async span => { + const [internal, external] = await Promise.all([ + getAllInternalTables(), + getAllExternalTables(), + ]) + span.addTags({ + numInternalTables: internal.length, + numExternalTables: external.length, + }) + return await processTables([...internal, ...external]) + }) } export async function getExternalTablesInDatasource( datasourceId: string ): Promise> { - const datasource = await datasources.get(datasourceId, { enriched: true }) - if (!datasource || !datasource.entities) { - throw new Error("Datasource is not configured fully.") - } - return await processEntities(datasource.entities) + return await tracer.trace("getExternalTablesInDatasource", async span => { + const datasource = await datasources.get(datasourceId, { enriched: true }) + if (!datasource || !datasource.entities) { + throw new Error("Datasource is not configured fully.") + } + span.addTags({ + datasourceId, + numEntities: Object.keys(datasource.entities).length, + }) + return await processEntities(datasource.entities) + }) } export async function getTables(tableIds: string[]): Promise { - const externalTableIds = tableIds.filter(tableId => - isExternalTableID(tableId) - ), - internalTableIds = tableIds.filter(tableId => !isExternalTableID(tableId)) - let tables: Table[] = [] - if (externalTableIds.length) { - const externalTables = await getAllExternalTables() - tables = tables.concat( - externalTables.filter( - table => externalTableIds.indexOf(table._id!) !== -1 + return tracer.trace("getTables", async span => { + span.addTags({ numTableIds: tableIds.length }) + const externalTableIds = tableIds.filter(tableId => + isExternalTableID(tableId) + ), + internalTableIds = tableIds.filter(tableId => !isExternalTableID(tableId)) + let tables: Table[] = [] + if (externalTableIds.length) { + const externalTables = await getAllExternalTables() + tables = tables.concat( + externalTables.filter( + table => externalTableIds.indexOf(table._id!) !== -1 + ) ) - ) - } - if (internalTableIds.length) { - const db = context.getAppDB() - const internalTables = await db.getMultiple
(internalTableIds, { - allowMissing: true, - }) - tables = tables.concat(internalTables) - } - return await processTables(tables) + } + if (internalTableIds.length) { + const db = context.getAppDB() + const internalTables = await db.getMultiple
(internalTableIds, { + allowMissing: true, + }) + tables = tables.concat(internalTables) + } + span.addTags({ numTables: tables.length }) + return await processTables(tables) + }) } export async function enrichViewSchemas( table: Table ): Promise { - const views = [] - for (const view of Object.values(table.views ?? [])) { - if (sdk.views.isV2(view)) { - views.push(await sdk.views.enrichSchema(view, table.schema)) - } else views.push(view) - } + return await tracer.trace("enrichViewSchemas", async span => { + span.addTags({ tableId: table._id }) + const views = [] + for (const view of Object.values(table.views ?? [])) { + if (sdk.views.isV2(view)) { + views.push(await sdk.views.enrichSchema(view, table.schema)) + } else views.push(view) + } - return { - ...table, - views: views.reduce((p, v) => { - p[v.name!] = v - return p - }, {} as TableViewsResponse), - } + return { + ...table, + views: views.reduce((p, v) => { + p[v.name!] = v + return p + }, {} as TableViewsResponse), + } + }) } diff --git a/packages/server/src/utilities/rowProcessor/utils.ts b/packages/server/src/utilities/rowProcessor/utils.ts index 8344ca5ce1..921a848afd 100644 --- a/packages/server/src/utilities/rowProcessor/utils.ts +++ b/packages/server/src/utilities/rowProcessor/utils.ts @@ -1,21 +1,20 @@ -import { AutoFieldDefaultNames } from "../../constants" +import { context } from "@budibase/backend-core" +import { ai } from "@budibase/pro" +import { OperationFields } from "@budibase/shared-core" import { processStringSync } from "@budibase/string-templates" import { AutoColumnFieldMetadata, + AutoFieldSubType, FieldSchema, + FieldType, + FormulaType, + OperationFieldTypeEnum, Row, Table, - FormulaType, - AutoFieldSubType, - FieldType, - OperationFieldTypeEnum, - AIOperationEnum, - AIFieldMetadata, } from "@budibase/types" -import { OperationFields } from "@budibase/shared-core" import tracer from "dd-trace" -import { context } from "@budibase/backend-core" -import { ai } from "@budibase/pro" +import { AutoFieldDefaultNames } from "../../constants" +import { isAIColumn } from "../../db/utils" import { coerce } from "./index" interface FormulaOpts { @@ -122,52 +121,56 @@ export async function processAIColumns( inputRows: T, { contextRows }: FormulaOpts ): Promise { + const aiColumns = Object.values(table.schema).filter(isAIColumn) + if (!aiColumns.length) { + return inputRows + } + return tracer.trace("processAIColumns", {}, async span => { const numRows = Array.isArray(inputRows) ? inputRows.length : 1 span?.addTags({ table_id: table._id, numRows }) const rows = Array.isArray(inputRows) ? inputRows : [inputRows] + const llm = await ai.getLLM() if (rows && llm) { // Ensure we have snippet context await context.ensureSnippetContext() - for (let [column, schema] of Object.entries(table.schema)) { - if (schema.type !== FieldType.AI) { - continue - } + const aiColumns = Object.values(table.schema).filter(isAIColumn) - const operation = schema.operation - const aiSchema: AIFieldMetadata = schema - const rowUpdates = rows.map((row, i) => { - const contextRow = contextRows ? contextRows[i] : row + const rowUpdates = rows.flatMap((row, i) => { + const contextRow = contextRows ? contextRows[i] : row + + return aiColumns.map(aiColumn => { + const column = aiColumn.name // Check if the type is bindable and pass through HBS if so - const operationField = OperationFields[operation as AIOperationEnum] - for (const key in schema) { + const operationField = OperationFields[aiColumn.operation] + for (const key in aiColumn) { const fieldType = operationField[key as keyof typeof operationField] if (fieldType === OperationFieldTypeEnum.BINDABLE_TEXT) { - // @ts-ignore - schema[key] = processStringSync(schema[key], contextRow) + // @ts-expect-error: keys are not casted + aiColumn[key] = processStringSync(aiColumn[key], contextRow) } } return tracer.trace("processAIColumn", {}, async span => { span?.addTags({ table_id: table._id, column }) - const llmResponse = await llm.operation(aiSchema, row) + const llmResponse = await llm.operation(aiColumn, row) return { - ...row, - [column]: llmResponse.message, + rowIndex: i, + columnName: column, + value: llmResponse.message, } }) }) + }) - const processedRows = await Promise.all(rowUpdates) + const processedAIColumns = await Promise.all(rowUpdates) - // Promise.all is deterministic so can rely on the indexing here - processedRows.forEach( - (processedRow, index) => (rows[index] = processedRow) - ) - } + processedAIColumns.forEach(aiColumn => { + rows[aiColumn.rowIndex][aiColumn.columnName] = aiColumn.value + }) } return Array.isArray(inputRows) ? rows : rows[0] }) diff --git a/packages/shared-core/src/filters.ts b/packages/shared-core/src/filters.ts index a0bd54fee6..e4ad413aed 100644 --- a/packages/shared-core/src/filters.ts +++ b/packages/shared-core/src/filters.ts @@ -323,7 +323,13 @@ function buildCondition(filter?: SearchFilter): SearchFilters | undefined { if (!value) { return } - value = new Date(value).toISOString() + if (typeof value === "string") { + value = new Date(value).toISOString() + } else if (isRangeSearchOperator(operator)) { + query[operator] ??= {} + query[operator][field] = value + return query + } } break case FieldType.NUMBER: @@ -349,7 +355,6 @@ function buildCondition(filter?: SearchFilter): SearchFilters | undefined { } break } - if (isRangeSearchOperator(operator)) { const key = externalType as keyof typeof SqlNumberTypeRangeMap const limits = SqlNumberTypeRangeMap[key] || { @@ -637,7 +642,6 @@ export function runQuery>( if (docValue == null || docValue === "") { return false } - if (isPlainObject(testValue.low) && isEmpty(testValue.low)) { testValue.low = undefined } diff --git a/packages/types/src/ui/components/index.ts b/packages/types/src/ui/components/index.ts index a7990e1c12..bcc3c3fbea 100644 --- a/packages/types/src/ui/components/index.ts +++ b/packages/types/src/ui/components/index.ts @@ -1,3 +1,5 @@ +import { FieldType } from "@budibase/types" + export * from "./codeEditor" export * from "./errors" @@ -83,3 +85,11 @@ export const enum ComponentContextScopes { Local = "local", Global = "global", } + +export type FilterConfig = { + active: boolean + field: string + label?: string + _id?: string + columnType?: FieldType +}