diff --git a/packages/bbui/src/Modal/Modal.svelte b/packages/bbui/src/Modal/Modal.svelte index 384cfe6cac..da97bf332e 100644 --- a/packages/bbui/src/Modal/Modal.svelte +++ b/packages/bbui/src/Modal/Modal.svelte @@ -9,6 +9,7 @@ export let fixed = false export let inline = false export let disableCancel = false + export let autoFocus = true const dispatch = createEventDispatcher() let visible = fixed || inline @@ -53,6 +54,9 @@ } async function focusModal(node) { + if (!autoFocus) { + return + } await tick() // Try to focus first input diff --git a/packages/bbui/src/Tabs/Tabs.svelte b/packages/bbui/src/Tabs/Tabs.svelte index 9c3d25a807..c94b396398 100644 --- a/packages/bbui/src/Tabs/Tabs.svelte +++ b/packages/bbui/src/Tabs/Tabs.svelte @@ -57,10 +57,8 @@ function calculateIndicatorLength() { if (!vertical) { width = $tab.info?.width + "px" - height = $tab.info?.height } else { height = $tab.info?.height + 4 + "px" - width = $tab.info?.width } } diff --git a/packages/builder/src/builderStore/dataBinding.js b/packages/builder/src/builderStore/dataBinding.js index 20172e4f9c..d2f354a361 100644 --- a/packages/builder/src/builderStore/dataBinding.js +++ b/packages/builder/src/builderStore/dataBinding.js @@ -351,12 +351,19 @@ const getProviderContextBindings = (asset, dataProviders) => { schema = info.schema table = info.table - // For JSON arrays, use the array name as the readable prefix. - // Otherwise use the table name + // Determine what to prefix bindings with if (datasource.type === "jsonarray") { + // For JSON arrays, use the array name as the readable prefix const split = datasource.label.split(".") readablePrefix = split[split.length - 1] + } else if (datasource.type === "viewV2") { + // For views, use the view name + const view = Object.values(table?.views || {}).find( + view => view.id === datasource.id + ) + readablePrefix = view?.name } else { + // Otherwise use the table name readablePrefix = info.table?.name } } @@ -464,7 +471,7 @@ const getComponentBindingCategory = (component, context, def) => { */ export const getUserBindings = () => { let bindings = [] - const { schema } = getSchemaForTable(TableNames.USERS) + const { schema } = getSchemaForDatasourcePlus(TableNames.USERS) const keys = Object.keys(schema).sort() const safeUser = makePropSafe("user") @@ -714,17 +721,25 @@ export const getActionBindings = (actions, actionId) => { } /** - * Gets the schema for a certain table ID. + * Gets the schema for a certain datasource plus. * The options which can be passed in are: * formSchema: whether the schema is for a form * searchableSchema: whether to generate a searchable schema, which may have * fewer fields than a readable schema - * @param tableId the table ID to get the schema for + * @param resourceId the DS+ resource ID * @param options options for generating the schema * @return {{schema: Object, table: Object}} */ -export const getSchemaForTable = (tableId, options) => { - return getSchemaForDatasource(null, { type: "table", tableId }, options) +export const getSchemaForDatasourcePlus = (resourceId, options) => { + const isViewV2 = resourceId?.includes("view_") + const datasource = isViewV2 + ? { + type: "viewV2", + id: resourceId, + tableId: resourceId.split("_").slice(1, 3).join("_"), + } + : { type: "table", tableId: resourceId } + return getSchemaForDatasource(null, datasource, options) } /** @@ -801,9 +816,21 @@ export const getSchemaForDatasource = (asset, datasource, options) => { // Determine the schema from the backing entity if not already determined if (table && !schema) { if (type === "view") { - // For views, the schema is pulled from the `views` property of the - // table + // Old views schema = cloneDeep(table.views?.[datasource.name]?.schema) + } else if (type === "viewV2") { + // New views which are DS+ + const view = Object.values(table.views || {}).find( + view => view.id === datasource.id + ) + schema = cloneDeep(view?.schema) + + // Strip hidden fields + Object.keys(schema || {}).forEach(field => { + if (!schema[field].visible) { + delete schema[field] + } + }) } else if ( type === "query" && (options.formSchema || options.searchableSchema) @@ -849,12 +876,12 @@ export const getSchemaForDatasource = (asset, datasource, options) => { // Determine if we should add ID and rev to the schema const isInternal = table && !table.sql - const isTable = ["table", "link"].includes(datasource.type) + const isDSPlus = ["table", "link", "viewV2"].includes(datasource.type) // ID is part of the readable schema for all tables // Rev is part of the readable schema for internal tables only - let addId = isTable - let addRev = isTable && isInternal + let addId = isDSPlus + let addRev = isDSPlus && isInternal // Don't add ID or rev for form schemas if (options.formSchema) { @@ -864,7 +891,7 @@ export const getSchemaForDatasource = (asset, datasource, options) => { // ID is only searchable for internal tables else if (options.searchableSchema) { - addId = isTable && isInternal + addId = isDSPlus && isInternal } // Add schema properties if required diff --git a/packages/builder/src/builderStore/store/screenTemplates/index.js b/packages/builder/src/builderStore/store/screenTemplates/index.js index 1bf0af6eeb..3ff42fdec6 100644 --- a/packages/builder/src/builderStore/store/screenTemplates/index.js +++ b/packages/builder/src/builderStore/store/screenTemplates/index.js @@ -1,10 +1,10 @@ import rowListScreen from "./rowListScreen" import createFromScratchScreen from "./createFromScratchScreen" -const allTemplates = tables => [...rowListScreen(tables)] +const allTemplates = datasources => [...rowListScreen(datasources)] // Allows us to apply common behaviour to all create() functions -const createTemplateOverride = (frontendState, template) => () => { +const createTemplateOverride = template => () => { const screen = template.create() screen.name = screen.props._id screen.routing.route = screen.routing.route.toLowerCase() @@ -12,14 +12,13 @@ const createTemplateOverride = (frontendState, template) => () => { return screen } -export default (frontendState, tables) => { +export default datasources => { const enrichTemplate = template => ({ ...template, - create: createTemplateOverride(frontendState, template), + create: createTemplateOverride(template), }) - const fromScratch = enrichTemplate(createFromScratchScreen) - const tableTemplates = allTemplates(tables).map(enrichTemplate) + const tableTemplates = allTemplates(datasources).map(enrichTemplate) return [ fromScratch, ...tableTemplates.sort((templateA, templateB) => { diff --git a/packages/builder/src/builderStore/store/screenTemplates/rowListScreen.js b/packages/builder/src/builderStore/store/screenTemplates/rowListScreen.js index 06d9e5aa3e..968d6deb4a 100644 --- a/packages/builder/src/builderStore/store/screenTemplates/rowListScreen.js +++ b/packages/builder/src/builderStore/store/screenTemplates/rowListScreen.js @@ -2,31 +2,26 @@ import sanitizeUrl from "./utils/sanitizeUrl" import { Screen } from "./utils/Screen" import { Component } from "./utils/Component" -export default function (tables) { - return tables.map(table => { +export default function (datasources) { + return datasources.map(datasource => { return { - name: `${table.name} - List`, - create: () => createScreen(table), + name: `${datasource.name} - List`, + create: () => createScreen(datasource), id: ROW_LIST_TEMPLATE, - table: table._id, + resourceId: datasource.resourceId, } }) } export const ROW_LIST_TEMPLATE = "ROW_LIST_TEMPLATE" -export const rowListUrl = table => sanitizeUrl(`/${table.name}`) +export const rowListUrl = datasource => sanitizeUrl(`/${datasource.name}`) -const generateTableBlock = table => { +const generateTableBlock = datasource => { const tableBlock = new Component("@budibase/standard-components/tableblock") tableBlock .customProps({ - title: table.name, - dataSource: { - label: table.name, - name: table._id, - tableId: table._id, - type: "table", - }, + title: datasource.name, + dataSource: datasource, sortOrder: "Ascending", size: "spectrum--medium", paginate: true, @@ -36,14 +31,14 @@ const generateTableBlock = table => { titleButtonText: "Create row", titleButtonClickBehaviour: "new", }) - .instanceName(`${table.name} - Table block`) + .instanceName(`${datasource.name} - Table block`) return tableBlock } -const createScreen = table => { +const createScreen = datasource => { return new Screen() - .route(rowListUrl(table)) - .instanceName(`${table.name} - List`) - .addChild(generateTableBlock(table)) + .route(rowListUrl(datasource)) + .instanceName(`${datasource.name} - List`) + .addChild(generateTableBlock(datasource)) .json() } diff --git a/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte b/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte index 6b57fe3d18..02340ab2b7 100644 --- a/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte +++ b/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte @@ -39,7 +39,7 @@ import FilterDrawer from "components/design/settings/controls/FilterEditor/FilterDrawer.svelte" import { LuceneUtils } from "@budibase/frontend-core" import { - getSchemaForTable, + getSchemaForDatasourcePlus, getEnvironmentBindings, } from "builderStore/dataBinding" import { Utils } from "@budibase/frontend-core" @@ -67,7 +67,9 @@ $: table = tableId ? $tables.list.find(table => table._id === inputData.tableId) : { schema: {} } - $: schema = getSchemaForTable(tableId, { searchableSchema: true }).schema + $: schema = getSchemaForDatasourcePlus(tableId, { + searchableSchema: true, + }).schema $: schemaFields = Object.values(schema || {}) $: queryLimit = tableId?.includes("datasource") ? "∞" : "1000" $: isTrigger = block?.type === "TRIGGER" @@ -158,7 +160,7 @@ // instead fetch the schema in the backend at runtime. let schema if (e.detail?.tableId) { - schema = getSchemaForTable(e.detail.tableId, { + schema = getSchemaForDatasourcePlus(e.detail.tableId, { searchableSchema: true, }).schema } diff --git a/packages/builder/src/components/backend/DataTable/DataTable.svelte b/packages/builder/src/components/backend/DataTable/TableDataTable.svelte similarity index 90% rename from packages/builder/src/components/backend/DataTable/DataTable.svelte rename to packages/builder/src/components/backend/DataTable/TableDataTable.svelte index 38eb87aa73..e0c653e9d7 100644 --- a/packages/builder/src/components/backend/DataTable/DataTable.svelte +++ b/packages/builder/src/components/backend/DataTable/TableDataTable.svelte @@ -26,12 +26,14 @@ $: id = $tables.selected?._id $: isUsersTable = id === TableNames.USERS $: isInternal = $tables.selected?.type !== "external" - - $: datasource = $datasources.list.find(datasource => { + $: gridDatasource = { + type: "table", + tableId: id, + } + $: tableDatasource = $datasources.list.find(datasource => { return datasource._id === $tables.selected?.sourceId }) - - $: relationshipsEnabled = relationshipSupport(datasource) + $: relationshipsEnabled = relationshipSupport(tableDatasource) const relationshipSupport = datasource => { const integration = $integrations[datasource?.source] @@ -54,12 +56,12 @@
@@ -72,9 +74,7 @@ - {#if isInternal} - - {/if} + {#if relationshipsEnabled} diff --git a/packages/builder/src/components/backend/DataTable/ViewV2DataTable.svelte b/packages/builder/src/components/backend/DataTable/ViewV2DataTable.svelte new file mode 100644 index 0000000000..0c6a0cca9a --- /dev/null +++ b/packages/builder/src/components/backend/DataTable/ViewV2DataTable.svelte @@ -0,0 +1,49 @@ + + +
+ + + + + + + + + +
+ + diff --git a/packages/builder/src/components/backend/DataTable/buttons/ManageAccessButton.svelte b/packages/builder/src/components/backend/DataTable/buttons/ManageAccessButton.svelte index bc8e0c5318..f6a74784fa 100644 --- a/packages/builder/src/components/backend/DataTable/buttons/ManageAccessButton.svelte +++ b/packages/builder/src/components/backend/DataTable/buttons/ManageAccessButton.svelte @@ -5,6 +5,7 @@ export let resourceId export let disabled = false + export let requiresLicence let modal let resourcePermissions @@ -21,6 +22,7 @@ diff --git a/packages/builder/src/components/backend/DataTable/buttons/TableFilterButton.svelte b/packages/builder/src/components/backend/DataTable/buttons/TableFilterButton.svelte index b96738ab1a..23f6d1dea1 100644 --- a/packages/builder/src/components/backend/DataTable/buttons/TableFilterButton.svelte +++ b/packages/builder/src/components/backend/DataTable/buttons/TableFilterButton.svelte @@ -15,6 +15,7 @@ $: tempValue = filters || [] $: schemaFields = Object.values(schema || {}) $: text = getText(filters) + $: selected = tempValue.filter(x => !x.onEmptyFilter)?.length > 0 const getText = filters => { const count = filters?.filter(filter => filter.field)?.length @@ -22,13 +23,7 @@ } - 0} -> + {text} diff --git a/packages/builder/src/components/backend/DataTable/buttons/grid/GridCreateViewButton.svelte b/packages/builder/src/components/backend/DataTable/buttons/grid/GridCreateViewButton.svelte index 33c416d7ef..3244ce3277 100644 --- a/packages/builder/src/components/backend/DataTable/buttons/grid/GridCreateViewButton.svelte +++ b/packages/builder/src/components/backend/DataTable/buttons/grid/GridCreateViewButton.svelte @@ -1,7 +1,7 @@ -{#key $tableId} +{#key $datasource} {/key} diff --git a/packages/builder/src/components/backend/DataTable/buttons/grid/GridImportButton.svelte b/packages/builder/src/components/backend/DataTable/buttons/grid/GridImportButton.svelte index a0881163b4..71d971891c 100644 --- a/packages/builder/src/components/backend/DataTable/buttons/grid/GridImportButton.svelte +++ b/packages/builder/src/components/backend/DataTable/buttons/grid/GridImportButton.svelte @@ -4,12 +4,12 @@ export let disabled = false - const { rows, tableId, table } = getContext("grid") + const { rows, datasource, definition } = getContext("grid") diff --git a/packages/builder/src/components/backend/DataTable/buttons/grid/GridManageAccessButton.svelte b/packages/builder/src/components/backend/DataTable/buttons/grid/GridManageAccessButton.svelte index 154007950a..e4c48528f4 100644 --- a/packages/builder/src/components/backend/DataTable/buttons/grid/GridManageAccessButton.svelte +++ b/packages/builder/src/components/backend/DataTable/buttons/grid/GridManageAccessButton.svelte @@ -1,8 +1,29 @@ - + diff --git a/packages/builder/src/components/backend/DataTable/buttons/grid/GridRelationshipButton.svelte b/packages/builder/src/components/backend/DataTable/buttons/grid/GridRelationshipButton.svelte index 460391366f..baa7dbed14 100644 --- a/packages/builder/src/components/backend/DataTable/buttons/grid/GridRelationshipButton.svelte +++ b/packages/builder/src/components/backend/DataTable/buttons/grid/GridRelationshipButton.svelte @@ -2,12 +2,12 @@ import ExistingRelationshipButton from "../ExistingRelationshipButton.svelte" import { getContext } from "svelte" - const { table, rows } = getContext("grid") + const { definition, rows } = getContext("grid") -{#if $table} +{#if $definition} rows.actions.refreshData()} /> {/if} diff --git a/packages/builder/src/components/backend/DataTable/modals/CreateViewModal.svelte b/packages/builder/src/components/backend/DataTable/modals/CreateViewModal.svelte deleted file mode 100644 index 8f2679f874..0000000000 --- a/packages/builder/src/components/backend/DataTable/modals/CreateViewModal.svelte +++ /dev/null @@ -1,38 +0,0 @@ - - - - - diff --git a/packages/builder/src/components/backend/DataTable/modals/ManageAccessModal.svelte b/packages/builder/src/components/backend/DataTable/modals/ManageAccessModal.svelte index aa6dbc93e0..678fb3b1c5 100644 --- a/packages/builder/src/components/backend/DataTable/modals/ManageAccessModal.svelte +++ b/packages/builder/src/components/backend/DataTable/modals/ManageAccessModal.svelte @@ -7,11 +7,14 @@ notifications, Body, ModalContent, + Tags, + Tag, } from "@budibase/bbui" import { capitalise } from "helpers" export let resourceId export let permissions + export let requiresLicence async function changePermission(level, role) { try { @@ -30,22 +33,36 @@ } - - Specify the minimum access level role for this data. -
- - - {#each Object.keys(permissions) as level} - - + + diff --git a/packages/builder/src/components/backend/DatasourceNavigator/DatasourceNavigator.svelte b/packages/builder/src/components/backend/DatasourceNavigator/DatasourceNavigator.svelte index f7b6f61a10..1c264a5aaf 100644 --- a/packages/builder/src/components/backend/DatasourceNavigator/DatasourceNavigator.svelte +++ b/packages/builder/src/components/backend/DatasourceNavigator/DatasourceNavigator.svelte @@ -1,7 +1,14 @@ diff --git a/packages/builder/src/components/backend/TableNavigator/TableNavigator.svelte b/packages/builder/src/components/backend/TableNavigator/TableNavigator.svelte index d9def682dc..056a36c4a7 100644 --- a/packages/builder/src/components/backend/TableNavigator/TableNavigator.svelte +++ b/packages/builder/src/components/backend/TableNavigator/TableNavigator.svelte @@ -1,5 +1,5 @@ {#if $database?._id} @@ -37,18 +45,23 @@ {/if} - {#each [...Object.keys(table.views || {})].sort() as viewName, idx (idx)} + {#each [...Object.entries(table.views || {})].sort() as [name, view], idx (idx)} $goto(`./view/${encodeURIComponent(viewName)}`)} - selectedBy={$userSelectedResourceMap[viewName]} + text={name} + selected={isViewActive(view, $isActive, $views, $viewsV2)} + on:click={() => { + if (view.version === 2) { + $goto(`./view/v2/${view.id}`) + } else { + $goto(`./view/v1/${encodeURIComponent(name)}`) + } + }} + selectedBy={$userSelectedResourceMap[name] || + $userSelectedResourceMap[view.id]} > - + {/each} {/each} diff --git a/packages/builder/src/components/backend/TableNavigator/popovers/EditTablePopover.svelte b/packages/builder/src/components/backend/TableNavigator/popovers/EditTablePopover.svelte index 11ef60480b..1760938c53 100644 --- a/packages/builder/src/components/backend/TableNavigator/popovers/EditTablePopover.svelte +++ b/packages/builder/src/components/backend/TableNavigator/popovers/EditTablePopover.svelte @@ -35,7 +35,7 @@ screen => screen.autoTableId === table._id ) willBeDeleted = ["All table data"].concat( - templateScreens.map(screen => `Screen ${screen.props._instanceName}`) + templateScreens.map(screen => `Screen ${screen.routing?.route || ""}`) ) confirmDeleteDialog.show() } @@ -44,7 +44,10 @@ const isSelected = $params.tableId === table._id try { await tables.delete(table) - await store.actions.screens.delete(templateScreens) + // Screens need deleted one at a time because of undo/redo + for (let screen of templateScreens) { + await store.actions.screens.delete(screen) + } if (table.type === "external") { await datasources.fetch() } diff --git a/packages/builder/src/components/backend/TableNavigator/popovers/EditViewPopover.svelte b/packages/builder/src/components/backend/TableNavigator/popovers/EditViewPopover.svelte index 99f19935a1..5e2b0102f8 100644 --- a/packages/builder/src/components/backend/TableNavigator/popovers/EditViewPopover.svelte +++ b/packages/builder/src/components/backend/TableNavigator/popovers/EditViewPopover.svelte @@ -1,6 +1,5 @@
@@ -15,9 +23,9 @@ table.name} - getOptionValue={table => table._id} + {options} + getOptionLabel={table => table.label} + getOptionValue={table => table.resourceId} /> diff --git a/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/SaveRow.svelte b/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/SaveRow.svelte index d16a279c68..c1917ad90f 100644 --- a/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/SaveRow.svelte +++ b/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/SaveRow.svelte @@ -1,10 +1,10 @@ - + {/if}
diff --git a/packages/frontend-core/src/components/grid/layout/Grid.svelte b/packages/frontend-core/src/components/grid/layout/Grid.svelte index 509c91c7dc..44e0a972f1 100644 --- a/packages/frontend-core/src/components/grid/layout/Grid.svelte +++ b/packages/frontend-core/src/components/grid/layout/Grid.svelte @@ -1,5 +1,6 @@ @@ -20,8 +19,8 @@ {/each}
- {#if $config.allowSchemaChanges} - {#key $tableId} + {#if $config.canEditColumns} + {#key $datasource} (total += col.width), 0 @@ -17,6 +18,7 @@ const close = () => { open = false } + onMount(() => subscribe("close-edit-column", close)) @@ -35,7 +37,6 @@ align={$renderedColumns.length ? "right" : "left"} offset={0} popoverTarget={document.getElementById(`add-column-button`)} - animate={false} customZindex={100} >
{ - newRow[columnName] = val + const updateValue = ({ column, value }) => { + newRow[column] = value } const addViaModal = () => { @@ -154,7 +154,7 @@ condition={hasNoRows && !$loading} type={TooltipType.Info} > - {#if !visible && !selectedRowCount && $canAddRows} + {#if !visible && !selectedRowCount && $config.canAddRows}
dispatch("add-row-inline")} diff --git a/packages/frontend-core/src/components/grid/layout/StickyColumn.svelte b/packages/frontend-core/src/components/grid/layout/StickyColumn.svelte index 587d25a20c..f3af0d9362 100644 --- a/packages/frontend-core/src/components/grid/layout/StickyColumn.svelte +++ b/packages/frontend-core/src/components/grid/layout/StickyColumn.svelte @@ -16,7 +16,7 @@ renderedRows, focusedCellId, hoveredRowId, - canAddRows, + config, selectedCellMap, focusedRow, scrollLeft, @@ -94,7 +94,7 @@ {/if}
{/each} - {#if $canAddRows} + {#if $config.canAddRows}
{ - const { rows, tableId, users, focusedCellId, table, API } = context + const { rows, datasource, users, focusedCellId, definition, API } = context const socket = createWebsocket("/socket/grid") - const connectToTable = tableId => { + const connectToDatasource = datasource => { if (!socket.connected) { return } // Identify which table we are editing const appId = API.getAppID() socket.emit( - GridSocketEvent.SelectTable, - { tableId, appId }, + GridSocketEvent.SelectDatasource, + { + datasource, + appId, + }, ({ users: gridUsers }) => { users.set(gridUsers) } @@ -23,7 +26,7 @@ export const createGridWebsocket = context => { // Built-in events socket.on("connect", () => { - connectToTable(get(tableId)) + connectToDatasource(get(datasource)) }) socket.on("connect_error", err => { console.log("Failed to connect to grid websocket:", err.message) @@ -48,16 +51,19 @@ export const createGridWebsocket = context => { }) // Table events - socket.onOther(GridSocketEvent.TableChange, ({ table: newTable }) => { - // Only update table if one exists. If the table was deleted then we don't - // want to know - let the builder navigate away - if (newTable) { - table.set(newTable) + socket.onOther( + GridSocketEvent.DatasourceChange, + ({ datasource: newDatasource }) => { + // Only update definition if one exists. If the datasource was deleted + // then we don't want to know - let the builder navigate away + if (newDatasource) { + definition.set(newDatasource) + } } - }) + ) // Change websocket connection when table changes - tableId.subscribe(connectToTable) + datasource.subscribe(connectToDatasource) // Notify selected cell changes focusedCellId.subscribe($focusedCellId => { diff --git a/packages/frontend-core/src/components/grid/overlays/KeyboardManager.svelte b/packages/frontend-core/src/components/grid/overlays/KeyboardManager.svelte index df3b93d9c9..cd23f154b5 100644 --- a/packages/frontend-core/src/components/grid/overlays/KeyboardManager.svelte +++ b/packages/frontend-core/src/components/grid/overlays/KeyboardManager.svelte @@ -4,7 +4,7 @@ import { NewRowID } from "../lib/constants" const { - enrichedRows, + rows, focusedCellId, visibleColumns, focusedRow, @@ -16,7 +16,6 @@ config, menu, gridFocused, - canAddRows, } = getContext("grid") const ignoredOriginSelectors = [ @@ -46,12 +45,12 @@ e.preventDefault() focusFirstCell() } else if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { - if ($canAddRows) { + if ($config.canAddRows) { e.preventDefault() dispatch("add-row-inline") } } else if (e.key === "Delete" || e.key === "Backspace") { - if (Object.keys($selectedRows).length && $config.allowDeleteRows) { + if (Object.keys($selectedRows).length && $config.canDeleteRows) { dispatch("request-bulk-delete") } } @@ -100,7 +99,7 @@ } break case "Enter": - if ($canAddRows) { + if ($config.canAddRows) { dispatch("add-row-inline") } } @@ -120,7 +119,7 @@ break case "Delete": case "Backspace": - if (Object.keys($selectedRows).length && $config.allowDeleteRows) { + if (Object.keys($selectedRows).length && $config.canDeleteRows) { dispatch("request-bulk-delete") } else { deleteSelectedCell() @@ -131,7 +130,7 @@ break case " ": case "Space": - if ($config.allowDeleteRows) { + if ($config.canDeleteRows) { toggleSelectRow() } break @@ -143,7 +142,7 @@ // Focuses the first cell in the grid const focusFirstCell = () => { - const firstRow = $enrichedRows[0] + const firstRow = $rows[0] if (!firstRow) { return } @@ -184,7 +183,7 @@ if (!$focusedRow) { return } - const newRow = $enrichedRows[$focusedRow.__idx + delta] + const newRow = $rows[$focusedRow.__idx + delta] if (newRow) { const split = $focusedCellId.split("-") $focusedCellId = `${newRow._id}-${split[1]}` @@ -216,13 +215,15 @@ if ($focusedCellAPI && !$focusedCellAPI.isReadonly()) { const type = $focusedCellAPI.getType() if (type === "number" && keyCodeIsNumber(keyCode)) { - $focusedCellAPI.setValue(parseInt(key)) + // Update the value locally but don't save it yet + $focusedCellAPI.setValue(parseInt(key), { save: false }) $focusedCellAPI.focus() } else if ( ["string", "barcodeqr", "longform"].includes(type) && (keyCodeIsLetter(keyCode) || keyCodeIsNumber(keyCode)) ) { - $focusedCellAPI.setValue(key) + // Update the value locally but don't save it yet + $focusedCellAPI.setValue(key, { save: false }) $focusedCellAPI.focus() } } diff --git a/packages/frontend-core/src/components/grid/overlays/MenuOverlay.svelte b/packages/frontend-core/src/components/grid/overlays/MenuOverlay.svelte index 56f97c48d6..cbf2c6ee4e 100644 --- a/packages/frontend-core/src/components/grid/overlays/MenuOverlay.svelte +++ b/packages/frontend-core/src/components/grid/overlays/MenuOverlay.svelte @@ -17,7 +17,6 @@ focusedCellAPI, focusedRowId, notifications, - canAddRows, } = getContext("grid") $: style = makeStyle($menu) @@ -68,9 +67,7 @@ dispatch("edit-row", $focusedRow)} on:click={menu.actions.close} > @@ -94,14 +91,14 @@ Duplicate row Delete row diff --git a/packages/frontend-core/src/components/grid/stores/clipboard.js b/packages/frontend-core/src/components/grid/stores/clipboard.js index cf59dafc54..71db36de9b 100644 --- a/packages/frontend-core/src/components/grid/stores/clipboard.js +++ b/packages/frontend-core/src/components/grid/stores/clipboard.js @@ -8,7 +8,7 @@ export const createStores = () => { } } -export const deriveStores = context => { +export const createActions = context => { const { copiedCell, focusedCellAPI } = context const copy = () => { diff --git a/packages/frontend-core/src/components/grid/stores/columns.js b/packages/frontend-core/src/components/grid/stores/columns.js index 6ca8704c1c..629d5dd893 100644 --- a/packages/frontend-core/src/components/grid/stores/columns.js +++ b/packages/frontend-core/src/components/grid/stores/columns.js @@ -35,20 +35,10 @@ export const createStores = () => { [] ) - // Checks if we have a certain column by name - const hasColumn = column => { - const $columns = get(columns) - const $sticky = get(stickyColumn) - return $columns.some(col => col.name === column) || $sticky?.name === column - } - return { columns: { ...columns, subscribe: enrichedColumns.subscribe, - actions: { - hasColumn, - }, }, stickyColumn, visibleColumns, @@ -56,12 +46,35 @@ export const createStores = () => { } export const deriveStores = context => { - const { table, columns, stickyColumn, API, dispatch, config } = context + const { columns, stickyColumn } = context - // Updates the tables primary display column + // Derive if we have any normal columns + const hasNonAutoColumn = derived( + [columns, stickyColumn], + ([$columns, $stickyColumn]) => { + let allCols = $columns || [] + if ($stickyColumn) { + allCols = [...allCols, $stickyColumn] + } + const normalCols = allCols.filter(column => { + return !column.schema?.autocolumn + }) + return normalCols.length > 0 + } + ) + + return { + hasNonAutoColumn, + } +} + +export const createActions = context => { + const { columns, stickyColumn, datasource, definition } = context + + // Updates the datasources primary display column const changePrimaryDisplay = async column => { - return await saveTable({ - ...get(table), + return await datasource.actions.saveDefinition({ + ...get(definition), primaryDisplay: column, }) } @@ -83,29 +96,14 @@ export const deriveStores = context => { await saveChanges() } - // Derive if we have any normal columns - const hasNonAutoColumn = derived( - [columns, stickyColumn], - ([$columns, $stickyColumn]) => { - let allCols = $columns || [] - if ($stickyColumn) { - allCols = [...allCols, $stickyColumn] - } - const normalCols = allCols.filter(column => { - return !column.schema?.autocolumn - }) - return normalCols.length > 0 - } - ) - - // Persists column changes by saving metadata against table schema + // Persists column changes by saving metadata against datasource schema const saveChanges = async () => { const $columns = get(columns) - const $table = get(table) + const $definition = get(definition) const $stickyColumn = get(stickyColumn) - const newSchema = cloneDeep($table.schema) + const newSchema = cloneDeep($definition.schema) - // Build new updated table schema + // Build new updated datasource schema Object.keys(newSchema).forEach(column => { // Respect order specified by columns const index = $columns.findIndex(x => x.name === column) @@ -125,31 +123,17 @@ export const deriveStores = context => { } }) - await saveTable({ ...$table, schema: newSchema }) - } - - const saveTable = async newTable => { - // Update local state - table.set(newTable) - - // Update server - if (get(config).allowSchemaChanges) { - await API.saveTable(newTable) - } - - // Broadcast change to external state can be updated, as this change - // will not be received by the builder websocket because we caused it ourselves - dispatch("updatetable", newTable) + await datasource.actions.saveDefinition({ + ...$definition, + schema: newSchema, + }) } return { - hasNonAutoColumn, columns: { ...columns, actions: { - ...columns.actions, saveChanges, - saveTable, changePrimaryDisplay, changeAllColumnWidths, }, @@ -158,51 +142,7 @@ export const deriveStores = context => { } export const initialise = context => { - const { table, columns, stickyColumn, schemaOverrides, columnWhitelist } = - context - - const schema = derived( - [table, schemaOverrides, columnWhitelist], - ([$table, $schemaOverrides, $columnWhitelist]) => { - if (!$table?.schema) { - return null - } - let newSchema = { ...$table?.schema } - - // Edge case to temporarily allow deletion of duplicated user - // fields that were saved with the "disabled" flag set. - // By overriding the saved schema we ensure only overrides can - // set the disabled flag. - // TODO: remove in future - Object.keys(newSchema).forEach(field => { - newSchema[field] = { - ...newSchema[field], - disabled: false, - } - }) - - // Apply schema overrides - Object.keys($schemaOverrides || {}).forEach(field => { - if (newSchema[field]) { - newSchema[field] = { - ...newSchema[field], - ...$schemaOverrides[field], - } - } - }) - - // Apply whitelist if specified - if ($columnWhitelist?.length) { - Object.keys(newSchema).forEach(key => { - if (!$columnWhitelist.includes(key)) { - delete newSchema[key] - } - }) - } - - return newSchema - } - ) + const { definition, columns, stickyColumn, schema } = context // Merge new schema fields with existing schema in order to preserve widths schema.subscribe($schema => { @@ -211,12 +151,12 @@ export const initialise = context => { stickyColumn.set(null) return } - const $table = get(table) + const $definition = get(definition) // Find primary display let primaryDisplay - if ($table.primaryDisplay && $schema[$table.primaryDisplay]) { - primaryDisplay = $table.primaryDisplay + if ($definition.primaryDisplay && $schema[$definition.primaryDisplay]) { + primaryDisplay = $definition.primaryDisplay } // Get field list diff --git a/packages/frontend-core/src/components/grid/stores/config.js b/packages/frontend-core/src/components/grid/stores/config.js index fc9decc1ef..ae995b4ac7 100644 --- a/packages/frontend-core/src/components/grid/stores/config.js +++ b/packages/frontend-core/src/components/grid/stores/config.js @@ -1,12 +1,12 @@ -import { writable } from "svelte/store" import { derivedMemo } from "../../../utils" +import { derived } from "svelte/store" export const createStores = context => { - const config = writable(context.props) - const getProp = prop => derivedMemo(config, $config => $config[prop]) + const { props } = context + const getProp = prop => derivedMemo(props, $props => $props[prop]) // Derive and memoize some props so that we can react to them in isolation - const tableId = getProp("tableId") + const datasource = getProp("datasource") const initialSortColumn = getProp("initialSortColumn") const initialSortOrder = getProp("initialSortOrder") const initialFilter = getProp("initialFilter") @@ -17,8 +17,7 @@ export const createStores = context => { const notifyError = getProp("notifyError") return { - config, - tableId, + datasource, initialSortColumn, initialSortOrder, initialFilter, @@ -29,3 +28,31 @@ export const createStores = context => { notifyError, } } + +export const deriveStores = context => { + const { props, hasNonAutoColumn } = context + + // Derive features + const config = derived( + [props, hasNonAutoColumn], + ([$props, $hasNonAutoColumn]) => { + let config = { ...$props } + + // Disable some features if we're editing a view + if ($props.datasource?.type === "viewV2") { + config.canEditColumns = false + } + + // Disable adding rows if we don't have any valid columns + if (!$hasNonAutoColumn) { + config.canAddRows = false + } + + return config + } + ) + + return { + config, + } +} diff --git a/packages/frontend-core/src/components/grid/stores/datasource.js b/packages/frontend-core/src/components/grid/stores/datasource.js new file mode 100644 index 0000000000..3f4347953e --- /dev/null +++ b/packages/frontend-core/src/components/grid/stores/datasource.js @@ -0,0 +1,131 @@ +import { derived, get, writable } from "svelte/store" + +export const createStores = () => { + const definition = writable(null) + + return { + definition, + } +} + +export const deriveStores = context => { + const { definition, schemaOverrides, columnWhitelist } = context + + const schema = derived( + [definition, schemaOverrides, columnWhitelist], + ([$definition, $schemaOverrides, $columnWhitelist]) => { + if (!$definition?.schema) { + return null + } + let newSchema = { ...$definition?.schema } + + // Apply schema overrides + Object.keys($schemaOverrides || {}).forEach(field => { + if (newSchema[field]) { + newSchema[field] = { + ...newSchema[field], + ...$schemaOverrides[field], + } + } + }) + + // Apply whitelist if specified + if ($columnWhitelist?.length) { + Object.keys(newSchema).forEach(key => { + if (!$columnWhitelist.includes(key)) { + delete newSchema[key] + } + }) + } + + return newSchema + } + ) + + return { + schema, + } +} + +export const createActions = context => { + const { datasource, definition, config, dispatch, table, viewV2 } = context + + // Gets the appropriate API for the configured datasource type + const getAPI = () => { + const $datasource = get(datasource) + switch ($datasource?.type) { + case "table": + return table + case "viewV2": + return viewV2 + default: + return null + } + } + + // Refreshes the datasource definition + const refreshDefinition = async () => { + return await getAPI()?.actions.refreshDefinition() + } + + // Saves the datasource definition + const saveDefinition = async newDefinition => { + // Update local state + definition.set(newDefinition) + + // Update server + if (get(config).canSaveSchema) { + await getAPI()?.actions.saveDefinition(newDefinition) + } + + // Broadcast change to external state can be updated, as this change + // will not be received by the builder websocket because we caused it ourselves + dispatch("updatedatasource", newDefinition) + } + + // Adds a row to the datasource + const addRow = async row => { + return await getAPI()?.actions.addRow(row) + } + + // Updates an existing row in the datasource + const updateRow = async row => { + return await getAPI()?.actions.updateRow(row) + } + + // Deletes rows from the datasource + const deleteRows = async rows => { + return await getAPI()?.actions.deleteRows(rows) + } + + // Gets a single row from a datasource + const getRow = async id => { + return await getAPI()?.actions.getRow(id) + } + + // Checks if a certain datasource config is valid + const isDatasourceValid = datasource => { + return getAPI()?.actions.isDatasourceValid(datasource) + } + + // Checks if this datasource can use a specific column by name + const canUseColumn = name => { + return getAPI()?.actions.canUseColumn(name) + } + + return { + datasource: { + ...datasource, + actions: { + refreshDefinition, + saveDefinition, + addRow, + updateRow, + deleteRows, + getRow, + isDatasourceValid, + canUseColumn, + }, + }, + } +} diff --git a/packages/frontend-core/src/components/grid/stores/filter.js b/packages/frontend-core/src/components/grid/stores/filter.js index 76c6a86c21..a59c98ccdd 100644 --- a/packages/frontend-core/src/components/grid/stores/filter.js +++ b/packages/frontend-core/src/components/grid/stores/filter.js @@ -1,10 +1,10 @@ -import { writable } from "svelte/store" +import { writable, get } from "svelte/store" export const createStores = context => { const { props } = context // Initialise to default props - const filter = writable(props.initialFilter) + const filter = writable(get(props).initialFilter) return { filter, diff --git a/packages/frontend-core/src/components/grid/stores/index.js b/packages/frontend-core/src/components/grid/stores/index.js index 2a52b55f59..7b73ea8be6 100644 --- a/packages/frontend-core/src/components/grid/stores/index.js +++ b/packages/frontend-core/src/components/grid/stores/index.js @@ -15,16 +15,20 @@ import * as Config from "./config" import * as Sort from "./sort" import * as Filter from "./filter" import * as Notifications from "./notifications" +import * as Table from "./table" +import * as ViewV2 from "./viewV2" +import * as Datasource from "./datasource" const DependencyOrderedStores = [ - Config, - Notifications, Sort, Filter, Bounds, Scroll, - Rows, + Table, + ViewV2, + Datasource, Columns, + Rows, UI, Validation, Resize, @@ -34,6 +38,8 @@ const DependencyOrderedStores = [ Menu, Pagination, Clipboard, + Config, + Notifications, ] export const attachStores = context => { @@ -47,6 +53,11 @@ export const attachStores = context => { context = { ...context, ...store.deriveStores?.(context) } } + // Action creation + for (let store of DependencyOrderedStores) { + context = { ...context, ...store.createActions?.(context) } + } + // Initialise any store logic for (let store of DependencyOrderedStores) { store.initialise?.(context) diff --git a/packages/frontend-core/src/components/grid/stores/menu.js b/packages/frontend-core/src/components/grid/stores/menu.js index f1d822aeaf..2d11b65bd4 100644 --- a/packages/frontend-core/src/components/grid/stores/menu.js +++ b/packages/frontend-core/src/components/grid/stores/menu.js @@ -12,7 +12,7 @@ export const createStores = () => { } } -export const deriveStores = context => { +export const createActions = context => { const { menu, focusedCellId, rand } = context const open = (cellId, e) => { diff --git a/packages/frontend-core/src/components/grid/stores/pagination.js b/packages/frontend-core/src/components/grid/stores/pagination.js index c6a856e229..1dbea6e0d2 100644 --- a/packages/frontend-core/src/components/grid/stores/pagination.js +++ b/packages/frontend-core/src/components/grid/stores/pagination.js @@ -1,4 +1,4 @@ -import { derived } from "svelte/store" +import { derived, get } from "svelte/store" export const initialise = context => { const { scrolledRowCount, rows, visualRowCapacity } = context @@ -11,13 +11,12 @@ export const initialise = context => { [scrolledRowCount, rowCount, visualRowCapacity], ([$scrolledRowCount, $rowCount, $visualRowCapacity]) => { return Math.max(0, $rowCount - $scrolledRowCount - $visualRowCapacity) - }, - 100 + } ) // Fetch next page when fewer than 25 remaining rows to scroll remainingRows.subscribe(remaining => { - if (remaining < 25) { + if (remaining < 25 && get(rowCount)) { rows.actions.loadNextPage() } }) diff --git a/packages/frontend-core/src/components/grid/stores/reorder.js b/packages/frontend-core/src/components/grid/stores/reorder.js index a99c1b1ab2..f820593174 100644 --- a/packages/frontend-core/src/components/grid/stores/reorder.js +++ b/packages/frontend-core/src/components/grid/stores/reorder.js @@ -23,7 +23,7 @@ export const createStores = () => { } } -export const deriveStores = context => { +export const createActions = context => { const { reorder, columns, diff --git a/packages/frontend-core/src/components/grid/stores/resize.js b/packages/frontend-core/src/components/grid/stores/resize.js index d007e70c4e..2dc9e0784c 100644 --- a/packages/frontend-core/src/components/grid/stores/resize.js +++ b/packages/frontend-core/src/components/grid/stores/resize.js @@ -19,7 +19,7 @@ export const createStores = () => { } } -export const deriveStores = context => { +export const createActions = context => { const { resize, columns, stickyColumn, ui } = context // Starts resizing a certain column diff --git a/packages/frontend-core/src/components/grid/stores/rows.js b/packages/frontend-core/src/components/grid/stores/rows.js index 59d7c633d6..392bf392e8 100644 --- a/packages/frontend-core/src/components/grid/stores/rows.js +++ b/packages/frontend-core/src/components/grid/stores/rows.js @@ -3,30 +3,24 @@ import { fetchData } from "../../../fetch/fetchData" import { NewRowID, RowPageSize } from "../lib/constants" import { tick } from "svelte" -const SuppressErrors = true - export const createStores = () => { const rows = writable([]) - const table = writable(null) const loading = writable(false) const loaded = writable(false) const rowChangeCache = writable({}) const inProgressChanges = writable({}) const hasNextPage = writable(false) const error = writable(null) + const fetch = writable(null) // Generate a lookup map to quick find a row by ID - const rowLookupMap = derived( - rows, - $rows => { - let map = {} - for (let i = 0; i < $rows.length; i++) { - map[$rows[i]._id] = i - } - return map - }, - {} - ) + const rowLookupMap = derived(rows, $rows => { + let map = {} + for (let i = 0; i < $rows.length; i++) { + map[$rows[i]._id] = i + } + return map + }) // Mark loaded as true if we've ever stopped loading let hasStartedLoading = false @@ -38,10 +32,25 @@ export const createStores = () => { } }) + // Enrich rows with an index property and any pending changes + const enrichedRows = derived( + [rows, rowChangeCache], + ([$rows, $rowChangeCache]) => { + return $rows.map((row, idx) => ({ + ...row, + ...$rowChangeCache[row._id], + __idx: idx, + })) + } + ) + return { - rows, + rows: { + ...rows, + subscribe: enrichedRows.subscribe, + }, + fetch, rowLookupMap, - table, loaded, loading, rowChangeCache, @@ -51,15 +60,15 @@ export const createStores = () => { } } -export const deriveStores = context => { +export const createActions = context => { const { rows, rowLookupMap, - table, + definition, filter, loading, sort, - tableId, + datasource, API, scroll, validation, @@ -71,37 +80,29 @@ export const deriveStores = context => { hasNextPage, error, notifications, + fetch, } = context const instanceLoaded = writable(false) - const fetch = writable(null) // Local cache of row IDs to speed up checking if a row exists let rowCacheMap = {} - // Enrich rows with an index property and any pending changes - const enrichedRows = derived( - [rows, rowChangeCache], - ([$rows, $rowChangeCache]) => { - return $rows.map((row, idx) => ({ - ...row, - ...$rowChangeCache[row._id], - __idx: idx, - })) - }, - [] - ) - - // Reset everything when table ID changes + // Reset everything when datasource changes let unsubscribe = null let lastResetKey = null - tableId.subscribe(async $tableId => { + datasource.subscribe(async $datasource => { // Unsub from previous fetch if one exists unsubscribe?.() fetch.set(null) instanceLoaded.set(false) loading.set(true) - // Tick to allow other reactive logic to update stores when table ID changes + // Abandon if we don't have a valid datasource + if (!datasource.actions.isDatasourceValid($datasource)) { + return + } + + // Tick to allow other reactive logic to update stores when datasource changes // before proceeding. This allows us to wipe filters etc if needed. await tick() const $filter = get(filter) @@ -110,10 +111,7 @@ export const deriveStores = context => { // Create new fetch model const newFetch = fetchData({ API, - datasource: { - type: "table", - tableId: $tableId, - }, + datasource: $datasource, options: { filter: $filter, sortColumn: $sort.column, @@ -142,7 +140,7 @@ export const deriveStores = context => { const previousResetKey = lastResetKey lastResetKey = $fetch.resetKey - // If resetting rows due to a table change, wipe data and wait for + // If resetting rows due to a datasource change, wipe data and wait for // derived stores to compute. This prevents stale data being passed // to cells when we save the new schema. if (!$instanceLoaded && previousResetKey) { @@ -152,16 +150,12 @@ export const deriveStores = context => { // Reset state properties when dataset changes if (!$instanceLoaded || resetRows) { - table.set($fetch.definition) - sort.set({ - column: $fetch.sortColumn, - order: $fetch.sortOrder, - }) + definition.set($fetch.definition) } // Reset scroll state when data changes if (!$instanceLoaded) { - // Reset both top and left for a new table ID + // Reset both top and left for a new datasource ID instanceLoaded.set(true) scroll.set({ top: 0, left: 0 }) } else if (resetRows) { @@ -180,19 +174,6 @@ export const deriveStores = context => { fetch.set(newFetch) }) - // Update fetch when filter or sort config changes - filter.subscribe($filter => { - get(fetch)?.update({ - filter: $filter, - }) - }) - sort.subscribe($sort => { - get(fetch)?.update({ - sortOrder: $sort.order, - sortColumn: $sort.column, - }) - }) - // Gets a row by ID const getRow = id => { const index = get(rowLookupMap)[id] @@ -211,7 +192,7 @@ export const deriveStores = context => { let erroredColumns = [] let missingColumns = [] for (let column of keys) { - if (columns.actions.hasColumn(column)) { + if (datasource.actions.canUseColumn(column)) { erroredColumns.push(column) } else { missingColumns.push(column) @@ -252,11 +233,9 @@ export const deriveStores = context => { // Adds a new row const addRow = async (row, idx, bubble = false) => { try { - // Create row - const newRow = await API.saveRow( - { ...row, tableId: get(tableId) }, - SuppressErrors - ) + // Create row. Spread row so we can mutate and enrich safely. + let newRow = { ...row } + newRow = await datasource.actions.addRow(newRow) // Update state if (idx != null) { @@ -294,21 +273,6 @@ export const deriveStores = context => { } } - // Fetches a row by ID using the search endpoint - const fetchRow = async id => { - const res = await API.searchTable({ - tableId: get(tableId), - limit: 1, - query: { - equal: { - _id: id, - }, - }, - paginate: false, - }) - return res?.rows?.[0] - } - // Replaces a row in state with the newly defined row, handling updates, // addition and deletion const replaceRow = (id, row) => { @@ -337,7 +301,7 @@ export const deriveStores = context => { // Refreshes a specific row const refreshRow = async id => { - const row = await fetchRow(id) + const row = await datasource.actions.getRow(id) replaceRow(id, row) } @@ -347,7 +311,7 @@ export const deriveStores = context => { } // Patches a row with some changes - const updateRow = async (rowId, changes) => { + const updateRow = async (rowId, changes, options = { save: true }) => { const $rows = get(rows) const $rowLookupMap = get(rowLookupMap) const index = $rowLookupMap[rowId] @@ -377,16 +341,23 @@ export const deriveStores = context => { }, })) + // Stop here if we don't want to persist the change + if (!options?.save) { + return + } + // Save change try { inProgressChanges.update(state => ({ ...state, [rowId]: true, })) - const saved = await API.saveRow( - { ...row, ...get(rowChangeCache)[rowId] }, - SuppressErrors - ) + + // Update row + const saved = await datasource.actions.updateRow({ + ...row, + ...get(rowChangeCache)[rowId], + }) // Update state after a successful change if (saved?._id) { @@ -412,8 +383,8 @@ export const deriveStores = context => { } // Updates a value of a row - const updateValue = async (rowId, column, value) => { - return await updateRow(rowId, { [column]: value }) + const updateValue = async ({ rowId, column, value, save = true }) => { + return await updateRow(rowId, { [column]: value }, { save }) } // Deletes an array of rows @@ -426,10 +397,7 @@ export const deriveStores = context => { rowsToDelete.forEach(row => { delete row.__idx }) - await API.deleteRows({ - tableId: get(tableId), - rows: rowsToDelete, - }) + await datasource.actions.deleteRows(rowsToDelete) // Update state handleRemoveRows(rowsToDelete) @@ -473,12 +441,6 @@ export const deriveStores = context => { get(fetch)?.nextPage() } - // Refreshes the schema of the data fetch subscription - const refreshTableDefinition = async () => { - const definition = await API.fetchTableDefinition(get(tableId)) - table.set(definition) - } - // Checks if we have a row with a certain ID const hasRow = id => { if (id === NewRowID) { @@ -498,7 +460,6 @@ export const deriveStores = context => { }) return { - enrichedRows, rows: { ...rows, actions: { @@ -513,7 +474,6 @@ export const deriveStores = context => { refreshRow, replaceRow, refreshData, - refreshTableDefinition, }, }, } diff --git a/packages/frontend-core/src/components/grid/stores/sort.js b/packages/frontend-core/src/components/grid/stores/sort.js index 211124f640..734a876eed 100644 --- a/packages/frontend-core/src/components/grid/stores/sort.js +++ b/packages/frontend-core/src/components/grid/stores/sort.js @@ -1,12 +1,14 @@ -import { writable } from "svelte/store" +import { derived, get } from "svelte/store" +import { memo } from "../../../utils" export const createStores = context => { const { props } = context + const $props = get(props) // Initialise to default props - const sort = writable({ - column: props.initialSortColumn, - order: props.initialSortOrder || "ascending", + const sort = memo({ + column: $props.initialSortColumn, + order: $props.initialSortOrder || "ascending", }) return { @@ -15,13 +17,34 @@ export const createStores = context => { } export const initialise = context => { - const { sort, initialSortColumn, initialSortOrder } = context + const { sort, initialSortColumn, initialSortOrder, definition } = context // Reset sort when initial sort props change initialSortColumn.subscribe(newSortColumn => { sort.update(state => ({ ...state, column: newSortColumn })) }) initialSortOrder.subscribe(newSortOrder => { - sort.update(state => ({ ...state, order: newSortOrder })) + sort.update(state => ({ ...state, order: newSortOrder || "ascending" })) + }) + + // Derive if the current sort column exists in the schema + const sortColumnExists = derived( + [sort, definition], + ([$sort, $definition]) => { + if (!$sort?.column || !$definition) { + return true + } + return $definition.schema?.[$sort.column] != null + } + ) + + // Clear sort state if our sort column does not exist + sortColumnExists.subscribe(exists => { + if (!exists) { + sort.set({ + column: null, + order: "ascending", + }) + } }) } diff --git a/packages/frontend-core/src/components/grid/stores/table.js b/packages/frontend-core/src/components/grid/stores/table.js new file mode 100644 index 0000000000..ed13609f45 --- /dev/null +++ b/packages/frontend-core/src/components/grid/stores/table.js @@ -0,0 +1,129 @@ +import { get } from "svelte/store" + +const SuppressErrors = true + +export const createActions = context => { + const { definition, API, datasource, columns, stickyColumn } = context + + const refreshDefinition = async () => { + definition.set(await API.fetchTableDefinition(get(datasource).tableId)) + } + + const saveDefinition = async newDefinition => { + await API.saveTable(newDefinition) + } + + const saveRow = async row => { + row.tableId = get(datasource)?.tableId + return await API.saveRow(row, SuppressErrors) + } + + const deleteRows = async rows => { + await API.deleteRows({ + tableId: get(datasource).tableId, + rows, + }) + } + + const isDatasourceValid = datasource => { + return datasource?.type === "table" && datasource?.tableId + } + + const getRow = async id => { + const res = await API.searchTable({ + tableId: get(datasource).tableId, + limit: 1, + query: { + equal: { + _id: id, + }, + }, + paginate: false, + }) + return res?.rows?.[0] + } + + const canUseColumn = name => { + const $columns = get(columns) + const $sticky = get(stickyColumn) + return $columns.some(col => col.name === name) || $sticky?.name === name + } + + return { + table: { + actions: { + refreshDefinition, + saveDefinition, + addRow: saveRow, + updateRow: saveRow, + deleteRows, + getRow, + isDatasourceValid, + canUseColumn, + }, + }, + } +} + +export const initialise = context => { + const { + datasource, + fetch, + filter, + sort, + table, + initialFilter, + initialSortColumn, + initialSortOrder, + } = context + + // Keep a list of subscriptions so that we can clear them when the datasource + // config changes + let unsubscribers = [] + + // Observe datasource changes and apply logic for table datasources + datasource.subscribe($datasource => { + // Clear previous subscriptions + unsubscribers?.forEach(unsubscribe => unsubscribe()) + unsubscribers = [] + if (!table.actions.isDatasourceValid($datasource)) { + return + } + + // Wipe state + filter.set(get(initialFilter)) + sort.set({ + column: get(initialSortColumn), + order: get(initialSortOrder) || "ascending", + }) + + // Update fetch when filter changes + unsubscribers.push( + filter.subscribe($filter => { + // Ensure we're updating the correct fetch + const $fetch = get(fetch) + if ($fetch?.options?.datasource?.tableId !== $datasource.tableId) { + return + } + $fetch.update({ + filter: $filter, + }) + }) + ) + + // Update fetch when sorting changes + unsubscribers.push( + sort.subscribe($sort => { + // Ensure we're updating the correct fetch + const $fetch = get(fetch) + if ($fetch?.options?.datasource?.tableId !== $datasource.tableId) { + return + } + $fetch.update({ + sortOrder: $sort.order || "ascending", + sortColumn: $sort.column, + }) + }) + ) + }) +} diff --git a/packages/frontend-core/src/components/grid/stores/ui.js b/packages/frontend-core/src/components/grid/stores/ui.js index 2f16265273..e10e96a722 100644 --- a/packages/frontend-core/src/components/grid/stores/ui.js +++ b/packages/frontend-core/src/components/grid/stores/ui.js @@ -14,7 +14,7 @@ export const createStores = context => { const focusedCellAPI = writable(null) const selectedRows = writable({}) const hoveredRowId = writable(null) - const rowHeight = writable(props.fixedRowHeight || DefaultRowHeight) + const rowHeight = writable(get(props).fixedRowHeight || DefaultRowHeight) const previousFocusedRowId = writable(null) const gridFocused = writable(false) const isDragging = writable(false) @@ -61,23 +61,13 @@ export const createStores = context => { } export const deriveStores = context => { - const { - focusedCellId, - selectedRows, - hoveredRowId, - enrichedRows, - rowLookupMap, - rowHeight, - stickyColumn, - width, - hasNonAutoColumn, - config, - } = context + const { focusedCellId, rows, rowLookupMap, rowHeight, stickyColumn, width } = + context // Derive the row that contains the selected cell const focusedRow = derived( - [focusedCellId, rowLookupMap, enrichedRows], - ([$focusedCellId, $rowLookupMap, $enrichedRows]) => { + [focusedCellId, rowLookupMap, rows], + ([$focusedCellId, $rowLookupMap, $rows]) => { const rowId = $focusedCellId?.split("-")[0] // Edge case for new rows @@ -87,18 +77,11 @@ export const deriveStores = context => { // All normal rows const index = $rowLookupMap[rowId] - return $enrichedRows[index] + return $rows[index] }, null ) - // Callback when leaving the grid, deselecting all focussed or selected items - const blur = () => { - focusedCellId.set(null) - selectedRows.set({}) - hoveredRowId.set(null) - } - // Derive the amount of content lines to show in cells depending on row height const contentLines = derived(rowHeight, $rowHeight => { if ($rowHeight >= LargeRowHeight) { @@ -114,19 +97,24 @@ export const deriveStores = context => { return ($stickyColumn?.width || 0) + $width + GutterWidth < 1100 }) - // Derive if we're able to add rows - const canAddRows = derived( - [config, hasNonAutoColumn], - ([$config, $hasNonAutoColumn]) => { - return $config.allowAddRows && $hasNonAutoColumn - } - ) - return { - canAddRows, focusedRow, contentLines, compact, + } +} + +export const createActions = context => { + const { focusedCellId, selectedRows, hoveredRowId } = context + + // Callback when leaving the grid, deselecting all focussed or selected items + const blur = () => { + focusedCellId.set(null) + selectedRows.set({}) + hoveredRowId.set(null) + } + + return { ui: { actions: { blur, @@ -143,7 +131,7 @@ export const initialise = context => { focusedCellId, selectedRows, hoveredRowId, - table, + definition, rowHeight, fixedRowHeight, } = context @@ -199,9 +187,9 @@ export const initialise = context => { }) // Pull row height from table as long as we don't have a fixed height - table.subscribe($table => { + definition.subscribe($definition => { if (!get(fixedRowHeight)) { - rowHeight.set($table?.rowHeight || DefaultRowHeight) + rowHeight.set($definition?.rowHeight || DefaultRowHeight) } }) @@ -210,7 +198,7 @@ export const initialise = context => { if (height) { rowHeight.set(height) } else { - rowHeight.set(get(table)?.rowHeight || DefaultRowHeight) + rowHeight.set(get(definition)?.rowHeight || DefaultRowHeight) } }) } diff --git a/packages/frontend-core/src/components/grid/stores/users.js b/packages/frontend-core/src/components/grid/stores/users.js index 5a39f3769a..7dd7a69592 100644 --- a/packages/frontend-core/src/components/grid/stores/users.js +++ b/packages/frontend-core/src/components/grid/stores/users.js @@ -39,6 +39,14 @@ export const deriveStores = context => { } ) + return { + selectedCellMap, + } +} + +export const createActions = context => { + const { users } = context + const updateUser = user => { const $users = get(users) if (!$users.some(x => x.sessionId === user.sessionId)) { @@ -66,6 +74,5 @@ export const deriveStores = context => { removeUser, }, }, - selectedCellMap, } } diff --git a/packages/frontend-core/src/components/grid/stores/viewV2.js b/packages/frontend-core/src/components/grid/stores/viewV2.js new file mode 100644 index 0000000000..b9a4bc099b --- /dev/null +++ b/packages/frontend-core/src/components/grid/stores/viewV2.js @@ -0,0 +1,212 @@ +import { get } from "svelte/store" + +const SuppressErrors = true + +export const createActions = context => { + const { definition, API, datasource, columns, stickyColumn } = context + + const refreshDefinition = async () => { + const $datasource = get(datasource) + if (!$datasource) { + definition.set(null) + return + } + const table = await API.fetchTableDefinition($datasource.tableId) + const view = Object.values(table?.views || {}).find( + view => view.id === $datasource.id + ) + definition.set(view) + } + + const saveDefinition = async newDefinition => { + await API.viewV2.update(newDefinition) + } + + const saveRow = async row => { + const $datasource = get(datasource) + row.tableId = $datasource?.tableId + row._viewId = $datasource?.id + return { + ...(await API.saveRow(row, SuppressErrors)), + _viewId: row._viewId, + } + } + + const deleteRows = async rows => { + await API.deleteRows({ + tableId: get(datasource).id, + rows, + }) + } + + const getRow = () => { + throw "Views don't support fetching individual rows" + } + + const isDatasourceValid = datasource => { + return ( + datasource?.type === "viewV2" && datasource?.id && datasource?.tableId + ) + } + + const canUseColumn = name => { + const $columns = get(columns) + const $sticky = get(stickyColumn) + return ( + $columns.some(col => col.name === name && col.visible) || + $sticky?.name === name + ) + } + + return { + viewV2: { + actions: { + refreshDefinition, + saveDefinition, + addRow: saveRow, + updateRow: saveRow, + deleteRows, + getRow, + isDatasourceValid, + canUseColumn, + }, + }, + } +} + +export const initialise = context => { + const { + definition, + datasource, + sort, + rows, + filter, + subscribe, + viewV2, + initialFilter, + initialSortColumn, + initialSortOrder, + config, + fetch, + } = context + + // Keep a list of subscriptions so that we can clear them when the datasource + // config changes + let unsubscribers = [] + + // Observe datasource changes and apply logic for view V2 datasources + datasource.subscribe($datasource => { + // Clear previous subscriptions + unsubscribers?.forEach(unsubscribe => unsubscribe()) + unsubscribers = [] + if (!viewV2.actions.isDatasourceValid($datasource)) { + return + } + + // Reset state for new view + filter.set(get(initialFilter)) + sort.set({ + column: get(initialSortColumn), + order: get(initialSortOrder) || "ascending", + }) + + // Keep sort and filter state in line with the view definition + unsubscribers.push( + definition.subscribe($definition => { + if ($definition?.id !== $datasource.id) { + return + } + // Only override sorting if we don't have an initial sort column + if (!get(initialSortColumn)) { + sort.set({ + column: $definition.sort?.field, + order: $definition.sort?.order || "ascending", + }) + } + // Only override filter state if we don't have an initial filter + if (!get(initialFilter)) { + filter.set($definition.query) + } + }) + ) + + // When sorting changes, ensure view definition is kept up to date + unsubscribers.push( + sort.subscribe(async $sort => { + // If we can mutate schema then update the view definition + if (get(config).canSaveSchema) { + // Ensure we're updating the correct view + const $view = get(definition) + if ($view?.id !== $datasource.id) { + return + } + if ( + $sort?.column !== $view.sort?.field || + $sort?.order !== $view.sort?.order + ) { + await datasource.actions.saveDefinition({ + ...$view, + sort: { + field: $sort.column, + order: $sort.order || "ascending", + }, + }) + await rows.actions.refreshData() + } + } + // Otherwise just update the fetch + else { + // Ensure we're updating the correct fetch + const $fetch = get(fetch) + if ($fetch?.options?.datasource?.tableId !== $datasource.tableId) { + return + } + $fetch.update({ + sortOrder: $sort.order || "ascending", + sortColumn: $sort.column, + }) + } + }) + ) + + // When filters change, ensure view definition is kept up to date + unsubscribers?.push( + filter.subscribe(async $filter => { + // If we can mutate schema then update the view definition + if (get(config).canSaveSchema) { + // Ensure we're updating the correct view + const $view = get(definition) + if ($view?.id !== $datasource.id) { + return + } + if (JSON.stringify($filter) !== JSON.stringify($view.query)) { + await datasource.actions.saveDefinition({ + ...$view, + query: $filter, + }) + await rows.actions.refreshData() + } + } + // Otherwise just update the fetch + else { + // Ensure we're updating the correct fetch + const $fetch = get(fetch) + if ($fetch?.options?.datasource?.tableId !== $datasource.tableId) { + return + } + $fetch.update({ + filter: $filter, + }) + } + }) + ) + + // When hidden we show columns, we need to refresh data in order to fetch + // values for those columns + unsubscribers.push( + subscribe("show-column", async () => { + await rows.actions.refreshData() + }) + ) + }) +} diff --git a/packages/frontend-core/src/components/grid/stores/viewport.js b/packages/frontend-core/src/components/grid/stores/viewport.js index b03f10f8fa..6c0c4708b9 100644 --- a/packages/frontend-core/src/components/grid/stores/viewport.js +++ b/packages/frontend-core/src/components/grid/stores/viewport.js @@ -10,7 +10,7 @@ export const deriveStores = context => { const { rowHeight, visibleColumns, - enrichedRows, + rows, scrollTop, scrollLeft, width, @@ -35,9 +35,9 @@ export const deriveStores = context => { 0 ) const renderedRows = derived( - [enrichedRows, scrolledRowCount, visualRowCapacity], - ([$enrichedRows, $scrolledRowCount, $visualRowCapacity]) => { - return $enrichedRows.slice( + [rows, scrolledRowCount, visualRowCapacity], + ([$rows, $scrolledRowCount, $visualRowCapacity]) => { + return $rows.slice( $scrolledRowCount, $scrolledRowCount + $visualRowCapacity ) diff --git a/packages/frontend-core/src/constants.js b/packages/frontend-core/src/constants.js index 0497a392f3..93a857ef9d 100644 --- a/packages/frontend-core/src/constants.js +++ b/packages/frontend-core/src/constants.js @@ -2,6 +2,7 @@ * Operator options for lucene queries */ export { OperatorOptions, SqlNumberTypeRangeMap } from "@budibase/shared-core" +export { Feature as Features } from "@budibase/types" // Cookie names export const Cookies = { @@ -62,17 +63,6 @@ export const PlanType = { */ export const ApiVersion = "1" -export const Features = { - USER_GROUPS: "userGroups", - BACKUPS: "appBackups", - ENVIRONMENT_VARIABLES: "environmentVariables", - AUDIT_LOGS: "auditLogs", - ENFORCEABLE_SSO: "enforceableSSO", - BRANDING: "branding", - SCIM: "scim", - SYNC_AUTOMATIONS: "syncAutomations", -} - // Role IDs export const Roles = { ADMIN: "ADMIN", diff --git a/packages/frontend-core/src/fetch/DataFetch.js b/packages/frontend-core/src/fetch/DataFetch.js index ea1cfdde77..857072601e 100644 --- a/packages/frontend-core/src/fetch/DataFetch.js +++ b/packages/frontend-core/src/fetch/DataFetch.js @@ -110,14 +110,27 @@ export default class DataFetch { return this.derivedStore.subscribe } + /** + * Gets the default sort column for this datasource + */ + getDefaultSortColumn(definition, schema) { + if (definition?.primaryDisplay && schema[definition.primaryDisplay]) { + return definition.primaryDisplay + } else { + return Object.keys(schema)[0] + } + } + /** * Fetches a fresh set of data from the server, resetting pagination */ async getInitialData() { const { datasource, filter, paginate } = this.options - // Fetch datasource definition and determine feature flags + // Fetch datasource definition and extract sort properties if configured const definition = await this.getDefinition(datasource) + + // Determine feature flags const features = this.determineFeatureFlags(definition) this.features = { supportsSearch: !!features?.supportsSearch, @@ -132,32 +145,32 @@ export default class DataFetch { return } - // If no sort order, default to descending - if (!this.options.sortOrder) { + // If an invalid sort column is specified, delete it + if (this.options.sortColumn && !schema[this.options.sortColumn]) { + this.options.sortColumn = null + } + + // If no sort column, get the default column for this datasource + if (!this.options.sortColumn) { + this.options.sortColumn = this.getDefaultSortColumn(definition, schema) + } + + // If we don't have a sort column specified then just ensure we don't set + // any sorting params + if (!this.options.sortColumn) { this.options.sortOrder = "ascending" - } + this.options.sortType = null + } else { + // Otherwise determine what sort type to use base on sort column + const type = schema?.[this.options.sortColumn]?.type + this.options.sortType = + type === "number" || type === "bigint" ? "number" : "string" - // If no sort column, or an invalid sort column is provided, use the primary - // display and fallback to first column - const sortValid = this.options.sortColumn && schema[this.options.sortColumn] - if (!sortValid) { - let newSortColumn - if (definition?.primaryDisplay && schema[definition.primaryDisplay]) { - newSortColumn = definition.primaryDisplay - } else { - newSortColumn = Object.keys(schema)[0] + // If no sort order, default to ascending + if (!this.options.sortOrder) { + this.options.sortOrder = "ascending" } - this.options.sortColumn = newSortColumn } - const { sortOrder, sortColumn } = this.options - - // Determine what sort type to use - let sortType = "string" - if (sortColumn) { - const type = schema?.[sortColumn]?.type - sortType = type === "number" || type === "bigint" ? "number" : "string" - } - this.options.sortType = sortType // Build the lucene query let query = this.options.query @@ -174,8 +187,6 @@ export default class DataFetch { loading: true, cursors: [], cursor: null, - sortOrder, - sortColumn, })) // Actually fetch data diff --git a/packages/frontend-core/src/fetch/QueryFetch.js b/packages/frontend-core/src/fetch/QueryFetch.js index 456abaec79..6420893515 100644 --- a/packages/frontend-core/src/fetch/QueryFetch.js +++ b/packages/frontend-core/src/fetch/QueryFetch.js @@ -29,6 +29,10 @@ export default class QueryFetch extends DataFetch { } } + getDefaultSortColumn() { + return null + } + async getData() { const { datasource, limit, paginate } = this.options const { supportsPagination } = this.features diff --git a/packages/frontend-core/src/fetch/ViewV2Fetch.js b/packages/frontend-core/src/fetch/ViewV2Fetch.js new file mode 100644 index 0000000000..b9eaf4bdf7 --- /dev/null +++ b/packages/frontend-core/src/fetch/ViewV2Fetch.js @@ -0,0 +1,65 @@ +import DataFetch from "./DataFetch.js" +import { get } from "svelte/store" + +export default class ViewV2Fetch extends DataFetch { + determineFeatureFlags() { + return { + supportsSearch: true, + supportsSort: true, + supportsPagination: true, + } + } + + getSchema(datasource, definition) { + return definition?.schema + } + + async getDefinition(datasource) { + if (!datasource?.id) { + return null + } + try { + const res = await this.API.viewV2.fetchDefinition(datasource.id) + return res?.data + } catch (error) { + this.store.update(state => ({ + ...state, + error, + })) + return null + } + } + + getDefaultSortColumn() { + return null + } + + async getData() { + const { datasource, limit, sortColumn, sortOrder, sortType, paginate } = + this.options + const { cursor, query } = get(this.store) + try { + const res = await this.API.viewV2.fetch({ + viewId: datasource.id, + query, + paginate, + limit, + bookmark: cursor, + sort: sortColumn, + sortOrder: sortOrder?.toLowerCase(), + sortType, + }) + return { + rows: res?.rows || [], + hasNextPage: res?.hasNextPage || false, + cursor: res?.bookmark || null, + } + } catch (error) { + return { + rows: [], + hasNextPage: false, + error, + } + } + } +} diff --git a/packages/frontend-core/src/fetch/fetchData.js b/packages/frontend-core/src/fetch/fetchData.js index c4968eabc0..063dd02cbf 100644 --- a/packages/frontend-core/src/fetch/fetchData.js +++ b/packages/frontend-core/src/fetch/fetchData.js @@ -1,5 +1,6 @@ import TableFetch from "./TableFetch.js" import ViewFetch from "./ViewFetch.js" +import ViewV2Fetch from "./ViewV2Fetch.js" import QueryFetch from "./QueryFetch.js" import RelationshipFetch from "./RelationshipFetch.js" import NestedProviderFetch from "./NestedProviderFetch.js" @@ -11,6 +12,7 @@ import GroupUserFetch from "./GroupUserFetch.js" const DataFetchMap = { table: TableFetch, view: ViewFetch, + viewV2: ViewV2Fetch, query: QueryFetch, link: RelationshipFetch, user: UserFetch, diff --git a/packages/server/src/api/controllers/datasource.ts b/packages/server/src/api/controllers/datasource.ts index 19a38206dc..399d5f1d0c 100644 --- a/packages/server/src/api/controllers/datasource.ts +++ b/packages/server/src/api/controllers/datasource.ts @@ -17,7 +17,6 @@ import { FetchDatasourceInfoRequest, FetchDatasourceInfoResponse, IntegrationBase, - RestConfig, SourceName, UpdateDatasourceResponse, UserCtx, @@ -27,7 +26,6 @@ import { import sdk from "../../sdk" import { builderSocket } from "../../websockets" import { setupCreationAuth as googleSetupCreationAuth } from "../../integrations/googlesheets" -import { areRESTVariablesValid } from "../../sdk/app/datasources/datasources" function getErrorTables(errors: any, errorType: string) { return Object.entries(errors) diff --git a/packages/server/src/api/controllers/row/index.ts b/packages/server/src/api/controllers/row/index.ts index 695e626630..ebe0c32e63 100644 --- a/packages/server/src/api/controllers/row/index.ts +++ b/packages/server/src/api/controllers/row/index.ts @@ -159,7 +159,7 @@ async function deleteRows(ctx: UserCtx) { for (let row of rows) { ctx.eventEmitter && ctx.eventEmitter.emitRow(`row:delete`, appId, row) - gridSocket?.emitRowDeletion(ctx, row._id!) + gridSocket?.emitRowDeletion(ctx, row) } return rows @@ -175,7 +175,7 @@ async function deleteRow(ctx: UserCtx) { await quotas.removeRow() ctx.eventEmitter && ctx.eventEmitter.emitRow(`row:delete`, appId, resp.row) - gridSocket?.emitRowDeletion(ctx, resp.row._id!) + gridSocket?.emitRowDeletion(ctx, resp.row) return resp } diff --git a/packages/server/src/api/controllers/row/internal.ts b/packages/server/src/api/controllers/row/internal.ts index 3432ec80f3..4a412c42eb 100644 --- a/packages/server/src/api/controllers/row/internal.ts +++ b/packages/server/src/api/controllers/row/internal.ts @@ -6,9 +6,9 @@ import { } from "../../../db/utils" import * as userController from "../user" import { + cleanupAttachments, inputProcessing, outputProcessing, - cleanupAttachments, } from "../../../utilities/rowProcessor" import { FieldTypes } from "../../../constants" import * as utils from "./utils" @@ -16,12 +16,12 @@ import { cloneDeep } from "lodash/fp" import { context } from "@budibase/backend-core" import { finaliseRow, updateRelatedFormula } from "./staticFormula" import { - UserCtx, LinkDocumentValue, - Row, - Table, PatchRowRequest, PatchRowResponse, + Row, + Table, + UserCtx, } from "@budibase/types" import sdk from "../../../sdk" @@ -94,8 +94,7 @@ export async function patch(ctx: UserCtx) { export async function save(ctx: UserCtx) { let inputs = ctx.request.body - const tableId = utils.getTableId(ctx) - inputs.tableId = tableId + inputs.tableId = utils.getTableId(ctx) if (!inputs._rev && !inputs._id) { inputs._id = generateRowID(inputs.tableId) diff --git a/packages/server/src/api/controllers/row/views.ts b/packages/server/src/api/controllers/row/views.ts index c3b716ef88..36a0b588b6 100644 --- a/packages/server/src/api/controllers/row/views.ts +++ b/packages/server/src/api/controllers/row/views.ts @@ -6,9 +6,11 @@ import { SearchViewRowRequest, RequiredKeys, SearchParams, + SearchFilters, } from "@budibase/types" import { dataFilters } from "@budibase/shared-core" import sdk from "../../../sdk" +import { db } from "@budibase/backend-core" export async function searchView( ctx: UserCtx @@ -19,23 +21,41 @@ export async function searchView( if (!view) { ctx.throw(404, `View ${viewId} not found`) } - if (view.version !== 2) { ctx.throw(400, `This method only supports viewsV2`) } - const table = await sdk.tables.getTable(view?.tableId) - - const viewFields = - (view.columns && - Object.entries(view.columns).length && - Object.keys(sdk.views.enrichSchema(view, table.schema).schema)) || - undefined - - ctx.status = 200 - + const viewFields = Object.keys(view.schema || {}) const { body } = ctx.request - const query = dataFilters.buildLuceneQuery(view.query || []) + + // Enrich saved query with ephemeral query params. + // We prevent searching on any fields that are saved as part of the query, as + // that could let users find rows they should not be allowed to access. + let query = dataFilters.buildLuceneQuery(view.query || []) + if (body.query) { + // Extract existing fields + const existingFields = + view.query + ?.filter(filter => filter.field) + .map(filter => db.removeKeyNumbering(filter.field)) || [] + + // Delete extraneous search params that cannot be overridden + delete body.query.allOr + delete body.query.onEmptyFilter + + // Carry over filters for unused fields + Object.keys(body.query).forEach(key => { + const operator = key as keyof Omit< + SearchFilters, + "allOr" | "onEmptyFilter" + > + Object.keys(body.query[operator] || {}).forEach(field => { + if (!existingFields.includes(db.removeKeyNumbering(field))) { + query[operator]![field] = body.query[operator]![field] + } + }) + }) + } const searchOptions: RequiredKeys & RequiredKeys> = { diff --git a/packages/server/src/api/controllers/table/index.ts b/packages/server/src/api/controllers/table/index.ts index e44ac94881..759974d6a7 100644 --- a/packages/server/src/api/controllers/table/index.ts +++ b/packages/server/src/api/controllers/table/index.ts @@ -1,16 +1,16 @@ import * as internal from "./internal" import * as external from "./external" import { - validate as validateSchema, - isSchema, isRows, + isSchema, + validate as validateSchema, } from "../../../utilities/schema" import { isExternalTable, isSQL } from "../../../integrations/utils" import { events } from "@budibase/backend-core" import { FetchTablesResponse, - SaveTableResponse, SaveTableRequest, + SaveTableResponse, Table, TableResponse, UserCtx, @@ -51,8 +51,7 @@ export async function fetch(ctx: UserCtx) { } }) - const response = [...internal, ...external].map(sdk.tables.enrichViewSchemas) - ctx.body = response + ctx.body = [...internal, ...external].map(sdk.tables.enrichViewSchemas) } export async function find(ctx: UserCtx) { @@ -94,7 +93,7 @@ export async function destroy(ctx: UserCtx) { ctx.status = 200 ctx.table = deletedTable ctx.body = { message: `Table ${tableId} deleted.` } - builderSocket?.emitTableDeletion(ctx, tableId) + builderSocket?.emitTableDeletion(ctx, deletedTable) } export async function bulkImport(ctx: UserCtx) { diff --git a/packages/server/src/api/controllers/table/internal.ts b/packages/server/src/api/controllers/table/internal.ts index 5f9a01bd0b..e468848c57 100644 --- a/packages/server/src/api/controllers/table/internal.ts +++ b/packages/server/src/api/controllers/table/internal.ts @@ -10,10 +10,10 @@ import { } from "../../../utilities/rowProcessor" import { runStaticFormulaChecks } from "./bulkFormula" import { + RenameColumn, SaveTableRequest, SaveTableResponse, Table, - TableRequest, UserCtx, ViewStatisticsSchema, ViewV2, @@ -44,7 +44,9 @@ function checkAutoColumns(table: Table, oldTable?: Table) { export async function save(ctx: UserCtx) { const db = context.getAppDB() const { rows, ...rest } = ctx.request.body - let tableToSave: TableRequest = { + let tableToSave: Table & { + _rename?: { old: string; updated: string } | undefined + } = { type: "table", _id: generateTableID(), views: {}, @@ -85,7 +87,7 @@ export async function save(ctx: UserCtx) { } // Don't rename if the name is the same - let { _rename } = tableToSave + let _rename: RenameColumn | undefined = tableToSave._rename /* istanbul ignore next */ if (_rename && _rename.old === _rename.updated) { _rename = undefined diff --git a/packages/server/src/api/controllers/view/viewsV2.ts b/packages/server/src/api/controllers/view/viewsV2.ts index 80a115e365..f4f969622a 100644 --- a/packages/server/src/api/controllers/view/viewsV2.ts +++ b/packages/server/src/api/controllers/view/viewsV2.ts @@ -2,51 +2,19 @@ import sdk from "../../../sdk" import { CreateViewRequest, Ctx, + RequiredKeys, UIFieldMetadata, UpdateViewRequest, ViewResponse, ViewV2, - RequiredKeys, } from "@budibase/types" +import { builderSocket, gridSocket } from "../../../websockets" -async function parseSchemaUI(ctx: Ctx, view: CreateViewRequest) { +async function parseSchema(view: CreateViewRequest) { if (!view.schema) { return } - - function hasOverrides( - newObj: Record, - existingObj: Record - ) { - const result = Object.entries(newObj).some(([key, value]) => { - const isObject = typeof value === "object" - const existing = existingObj[key] - if (isObject && hasOverrides(value, existing || {})) { - return true - } - if (!isObject && value !== existing) { - return true - } - }) - - return result - } - - const table = await sdk.tables.getTable(view.tableId) - for (const [ - fieldName, - { order, width, visible, icon, ...schemaNonUI }, - ] of Object.entries(view.schema)) { - const overrides = hasOverrides(schemaNonUI, table.schema[fieldName]) - if (overrides) { - ctx.throw( - 400, - "This endpoint does not support overriding non UI fields in the schema" - ) - } - } - - const schemaUI = + const finalViewSchema = view.schema && Object.entries(view.schema).reduce((p, [fieldName, schemaValue]) => { const fieldSchema: RequiredKeys = { @@ -63,22 +31,32 @@ async function parseSchemaUI(ctx: Ctx, view: CreateViewRequest) { p[fieldName] = fieldSchema return p }, {} as Record>) - return schemaUI + for (let [key, column] of Object.entries(finalViewSchema)) { + if (!column.visible) { + delete finalViewSchema[key] + } + } + return finalViewSchema +} + +export async function get(ctx: Ctx) { + ctx.body = { + data: await sdk.views.get(ctx.params.viewId, { enriched: true }), + } } export async function create(ctx: Ctx) { const view = ctx.request.body const { tableId } = view - const schemaUI = await parseSchemaUI(ctx, view) + const schema = await parseSchema(view) const parsedView: Omit, "id" | "version"> = { name: view.name, tableId: view.tableId, query: view.query, sort: view.sort, - columns: view.schema && Object.keys(view.schema), - schemaUI, + schema, primaryDisplay: view.primaryDisplay, } const result = await sdk.views.create(tableId, parsedView) @@ -86,6 +64,10 @@ export async function create(ctx: Ctx) { ctx.body = { data: result, } + + const table = await sdk.tables.getTable(tableId) + builderSocket?.emitTableUpdate(ctx, table) + gridSocket?.emitViewUpdate(ctx, result) } export async function update(ctx: Ctx) { @@ -101,7 +83,7 @@ export async function update(ctx: Ctx) { const { tableId } = view - const schemaUI = await parseSchemaUI(ctx, view) + const schema = await parseSchema(view) const parsedView: RequiredKeys = { id: view.id, name: view.name, @@ -109,8 +91,7 @@ export async function update(ctx: Ctx) { tableId: view.tableId, query: view.query, sort: view.sort, - columns: view.schema && Object.keys(view.schema), - schemaUI, + schema, primaryDisplay: view.primaryDisplay, } @@ -118,11 +99,19 @@ export async function update(ctx: Ctx) { ctx.body = { data: result, } + + const table = await sdk.tables.getTable(tableId) + builderSocket?.emitTableUpdate(ctx, table) + gridSocket?.emitViewUpdate(ctx, result) } export async function remove(ctx: Ctx) { const { viewId } = ctx.params - await sdk.views.remove(viewId) + const view = await sdk.views.remove(viewId) ctx.status = 204 + + const table = await sdk.tables.getTable(view.tableId) + builderSocket?.emitTableUpdate(ctx, table) + gridSocket?.emitViewDeletion(ctx, view) } diff --git a/packages/server/src/api/routes/tests/row.spec.ts b/packages/server/src/api/routes/tests/row.spec.ts index 8d4c9a91fd..f28b96b9d8 100644 --- a/packages/server/src/api/routes/tests/row.spec.ts +++ b/packages/server/src/api/routes/tests/row.spec.ts @@ -1,21 +1,17 @@ import tk from "timekeeper" -const timestamp = new Date("2023-01-26T11:48:57.597Z").toISOString() -tk.freeze(timestamp) - import { outputProcessing } from "../../../utilities/rowProcessor" import * as setup from "./utilities" -const { basicRow } = setup.structures import { context, tenancy } from "@budibase/backend-core" import { quotas } from "@budibase/pro" import { - QuotaUsageType, - StaticQuotaName, - MonthlyQuotaName, - Row, - Table, FieldType, - SortType, + MonthlyQuotaName, + QuotaUsageType, + Row, SortOrder, + SortType, + StaticQuotaName, + Table, } from "@budibase/types" import { expectAnyInternalColsAttributes, @@ -23,6 +19,11 @@ import { structures, } from "@budibase/backend-core/tests" +const timestamp = new Date("2023-01-26T11:48:57.597Z").toISOString() +tk.freeze(timestamp) + +const { basicRow } = setup.structures + describe("/rows", () => { let request = setup.getRequest() let config = setup.getConfig() @@ -392,6 +393,49 @@ describe("/rows", () => { }) }) + describe("view save", () => { + function orderTable(): Table { + return { + name: "orders", + schema: { + Country: { + type: FieldType.STRING, + name: "Country", + }, + OrderID: { + type: FieldType.STRING, + name: "OrderID", + }, + Story: { + type: FieldType.STRING, + name: "Story", + }, + }, + } + } + + it("views have extra data trimmed", async () => { + const table = await config.createTable(orderTable()) + + const createViewResponse = await config.api.viewV2.create({ + tableId: table._id, + schema: { + Country: {}, + OrderID: {}, + }, + }) + + const response = await config.api.row.save(createViewResponse.id, { + Country: "Aussy", + OrderID: "1111", + Story: "aaaaa", + }) + + const row = await config.api.row.get(table._id!, response._id!) + expect(row.body.Story).toBeUndefined() + }) + }) + describe("patch", () => { it("should update only the fields that are supplied", async () => { const existing = await config.createRow() @@ -941,6 +985,7 @@ describe("/rows", () => { }) describe("view search", () => { + const viewSchema = { age: { visible: true }, name: { visible: true } } function userTable(): Table { return { name: "user", @@ -997,6 +1042,7 @@ describe("/rows", () => { const createViewResponse = await config.api.viewV2.create({ query: [{ operator: "equal", field: "age", value: 40 }], + schema: viewSchema, }) const response = await config.api.viewV2.search(createViewResponse.id) @@ -1097,6 +1143,7 @@ describe("/rows", () => { const createViewResponse = await config.api.viewV2.create({ sort: sortParams, + schema: viewSchema, }) const response = await config.api.viewV2.search(createViewResponse.id) @@ -1131,6 +1178,7 @@ describe("/rows", () => { order: SortOrder.ASCENDING, type: SortType.STRING, }, + schema: viewSchema, }) const response = await config.api.viewV2.search( @@ -1139,6 +1187,7 @@ describe("/rows", () => { sort: sortParams.field, sortOrder: sortParams.order, sortType: sortParams.type, + query: {}, } ) @@ -1163,7 +1212,7 @@ describe("/rows", () => { } const view = await config.api.viewV2.create({ - schema: { name: {} }, + schema: { name: { visible: true } }, }) const response = await config.api.viewV2.search(view.id) @@ -1180,7 +1229,7 @@ describe("/rows", () => { }) it("views without data can be returned", async () => { - const table = await config.createTable(userTable()) + await config.createTable(userTable()) const createViewResponse = await config.api.viewV2.create() const response = await config.api.viewV2.search(createViewResponse.id) @@ -1199,6 +1248,7 @@ describe("/rows", () => { const createViewResponse = await config.api.viewV2.create() const response = await config.api.viewV2.search(createViewResponse.id, { limit, + query: {}, }) expect(response.body.rows).toHaveLength(limit) @@ -1221,6 +1271,7 @@ describe("/rows", () => { { paginate: true, limit: 4, + query: {}, } ) expect(firstPageResponse.body).toEqual({ @@ -1236,6 +1287,8 @@ describe("/rows", () => { paginate: true, limit: 4, bookmark: firstPageResponse.body.bookmark, + + query: {}, } ) expect(secondPageResponse.body).toEqual({ @@ -1251,6 +1304,7 @@ describe("/rows", () => { paginate: true, limit: 4, bookmark: secondPageResponse.body.bookmark, + query: {}, } ) expect(lastPageResponse.body).toEqual({ diff --git a/packages/server/src/api/routes/tests/viewV2.spec.ts b/packages/server/src/api/routes/tests/viewV2.spec.ts index e30bc2c0b1..a9c9f3a320 100644 --- a/packages/server/src/api/routes/tests/viewV2.spec.ts +++ b/packages/server/src/api/routes/tests/viewV2.spec.ts @@ -10,6 +10,7 @@ import { ViewV2, } from "@budibase/types" import { generator } from "@budibase/backend-core/tests" +import { generateDatasourceID } from "../../../db/utils" function priceTable(): Table { return { @@ -32,27 +33,52 @@ function priceTable(): Table { } } -describe("/v2/views", () => { - const config = setup.getConfig() +const config = setup.getConfig() - afterAll(setup.afterAll) +beforeAll(async () => { + await config.init() +}) + +describe.each([ + ["internal ds", () => config.createTable(priceTable())], + [ + "external ds", + async () => { + const datasource = await config.createDatasource({ + datasource: { + ...setup.structures.basicDatasource().datasource, + plus: true, + _id: generateDatasourceID({ plus: true }), + }, + }) + + return config.createTable({ + ...priceTable(), + sourceId: datasource._id, + type: "external", + }) + }, + ], +])("/v2/views (%s)", (_, tableBuilder) => { + let table: Table beforeAll(async () => { - await config.init() - await config.createTable(priceTable()) + table = await tableBuilder() }) + afterAll(setup.afterAll) + describe("create", () => { it("persist the view when the view is successfully created", async () => { const newView: CreateViewRequest = { name: generator.name(), - tableId: config.table!._id!, + tableId: table._id!, } const res = await config.api.viewV2.create(newView) expect(res).toEqual({ ...newView, - id: expect.stringMatching(new RegExp(`${config.table?._id!}_`)), + id: expect.stringMatching(new RegExp(`${table._id!}_`)), version: 2, }) }) @@ -60,7 +86,7 @@ describe("/v2/views", () => { it("can persist views with all fields", async () => { const newView: Required = { name: generator.name(), - tableId: config.table!._id!, + tableId: table._id!, primaryDisplay: generator.word(), query: [{ operator: "equal", field: "field", value: "value" }], sort: { @@ -78,9 +104,7 @@ describe("/v2/views", () => { expect(res).toEqual({ ...newView, - schema: undefined, - columns: ["name"], - schemaUI: newView.schema, + schema: newView.schema, id: expect.any(String), version: 2, }) @@ -89,7 +113,7 @@ describe("/v2/views", () => { it("persist only UI schema overrides", async () => { const newView: CreateViewRequest = { name: generator.name(), - tableId: config.table!._id!, + tableId: table._id!, schema: { Price: { name: "Price", @@ -111,54 +135,22 @@ describe("/v2/views", () => { expect(await config.api.viewV2.get(createdView.id)).toEqual({ ...newView, - schema: undefined, - columns: ["Price", "Category"], - schemaUI: { + schema: { Price: { visible: true, order: 1, width: 100, }, - Category: { - visible: false, - icon: "ic", - }, }, id: createdView.id, version: 2, }) }) - it("throw an exception if the schema overrides a non UI field", async () => { - const newView: CreateViewRequest = { - name: generator.name(), - tableId: config.table!._id!, - schema: { - Price: { - name: "Price", - type: FieldType.NUMBER, - visible: true, - }, - Category: { - name: "Category", - type: FieldType.STRING, - constraints: { - type: "string", - presence: true, - }, - }, - } as Record, - } - - await config.api.viewV2.create(newView, { - expectStatus: 400, - }) - }) - it("will not throw an exception if the schema is 'deleting' non UI fields", async () => { const newView: CreateViewRequest = { name: generator.name(), - tableId: config.table!._id!, + tableId: table._id!, schema: { Price: { name: "Price", @@ -182,35 +174,29 @@ describe("/v2/views", () => { let view: ViewV2 beforeEach(async () => { - await config.createTable(priceTable()) + table = await tableBuilder() + view = await config.api.viewV2.create({ name: "View A" }) }) it("can update an existing view data", async () => { - const tableId = config.table!._id! + const tableId = table._id! await config.api.viewV2.update({ ...view, query: [{ operator: "equal", field: "newField", value: "thatValue" }], }) - expect(await config.api.table.get(tableId)).toEqual({ - ...config.table, - views: { - [view.name]: { - ...view, - query: [ - { operator: "equal", field: "newField", value: "thatValue" }, - ], - schema: expect.anything(), - }, + expect((await config.api.table.get(tableId)).views).toEqual({ + [view.name]: { + ...view, + query: [{ operator: "equal", field: "newField", value: "thatValue" }], + schema: expect.anything(), }, - _rev: expect.any(String), - updatedAt: expect.any(String), }) }) it("can update all fields", async () => { - const tableId = config.table!._id! + const tableId = table._id! const updatedData: Required = { version: view.version, @@ -238,25 +224,24 @@ describe("/v2/views", () => { } await config.api.viewV2.update(updatedData) - expect(await config.api.table.get(tableId)).toEqual({ - ...config.table, - views: { - [view.name]: { - ...updatedData, - schema: { - Category: expect.objectContaining({ - visible: false, - }), - }, + expect((await config.api.table.get(tableId)).views).toEqual({ + [view.name]: { + ...updatedData, + schema: { + ...table.schema, + Category: expect.objectContaining({ + visible: false, + }), + Price: expect.objectContaining({ + visible: false, + }), }, }, - _rev: expect.any(String), - updatedAt: expect.any(String), }) }) it("can update an existing view name", async () => { - const tableId = config.table!._id! + const tableId = table._id! await config.api.viewV2.update({ ...view, name: "View B" }) expect(await config.api.table.get(tableId)).toEqual( @@ -269,7 +254,7 @@ describe("/v2/views", () => { }) it("cannot update an unexisting views nor edit ids", async () => { - const tableId = config.table!._id! + const tableId = table._id! await config.api.viewV2.update( { ...view, id: generator.guid() }, { expectStatus: 404 } @@ -288,7 +273,7 @@ describe("/v2/views", () => { }) it("cannot update views with the wrong tableId", async () => { - const tableId = config.table!._id! + const tableId = table._id! await config.api.viewV2.update( { ...view, @@ -365,50 +350,18 @@ describe("/v2/views", () => { expect(await config.api.viewV2.get(view.id)).toEqual({ ...view, - schema: undefined, - columns: ["Price", "Category"], - schemaUI: { + schema: { Price: { visible: true, order: 1, width: 100, }, - Category: { - visible: false, - icon: "ic", - }, }, id: view.id, version: 2, }) }) - it("throw an exception if the schema overrides a non UI field", async () => { - await config.api.viewV2.update( - { - ...view, - schema: { - Price: { - name: "Price", - type: FieldType.NUMBER, - visible: true, - }, - Category: { - name: "Category", - type: FieldType.STRING, - constraints: { - type: "string", - presence: true, - }, - }, - } as Record, - }, - { - expectStatus: 400, - } - ) - }) - it("will not throw an exception if the schema is 'deleting' non UI fields", async () => { await config.api.viewV2.update( { @@ -436,12 +389,11 @@ describe("/v2/views", () => { let view: ViewV2 beforeAll(async () => { - await config.createTable(priceTable()) view = await config.api.viewV2.create() }) it("can delete an existing view", async () => { - const tableId = config.table!._id! + const tableId = table._id! const getPersistedView = async () => (await config.api.table.get(tableId)).views![view.name] @@ -452,4 +404,23 @@ describe("/v2/views", () => { expect(await getPersistedView()).toBeUndefined() }) }) + + describe("fetch view (through table)", () => { + it("should be able to fetch a view V2", async () => { + const newView: CreateViewRequest = { + name: generator.name(), + tableId: table._id!, + schema: { + Price: { visible: false }, + Category: { visible: true }, + }, + } + const res = await config.api.viewV2.create(newView) + const view = await config.api.viewV2.get(res.id) + expect(view!.schema?.Price).toBeUndefined() + const updatedTable = await config.getTable(table._id!) + const viewSchema = updatedTable.views[view!.name!].schema + expect(viewSchema.Price.visible).toEqual(false) + }) + }) }) diff --git a/packages/server/src/api/routes/view.ts b/packages/server/src/api/routes/view.ts index 07e31fc701..e09ecba679 100644 --- a/packages/server/src/api/routes/view.ts +++ b/packages/server/src/api/routes/view.ts @@ -8,6 +8,15 @@ import { permissions } from "@budibase/backend-core" const router: Router = new Router() router + .get( + "/api/v2/views/:viewId", + paramResource("viewId"), + authorized( + permissions.PermissionType.TABLE, + permissions.PermissionLevel.READ + ), + viewController.v2.get + ) .post( "/api/v2/views", authorized(permissions.BUILDER), diff --git a/packages/server/src/middleware/tests/trimViewRowInfo.spec.ts b/packages/server/src/middleware/tests/trimViewRowInfo.spec.ts index 69d1272df9..17b4cc7b93 100644 --- a/packages/server/src/middleware/tests/trimViewRowInfo.spec.ts +++ b/packages/server/src/middleware/tests/trimViewRowInfo.spec.ts @@ -102,12 +102,13 @@ describe("trimViewRowInfo middleware", () => { address: generator.address(), }) - it("when no columns are defined, same data is returned", async () => { + it("when no columns are defined, don't allow anything", async () => { mockGetView.mockResolvedValue({ version: 2, id: viewId, name: generator.guid(), tableId: table._id!, + schema: {}, }) const data = getRandomData() @@ -116,7 +117,9 @@ describe("trimViewRowInfo middleware", () => { ...data, }) - expect(config.request?.body).toEqual(data) + expect(config.request?.body).toEqual({ + _id: data._id, + }) expect(config.params.sourceId).toEqual(table._id) expect(config.next).toBeCalledTimes(1) @@ -129,7 +132,10 @@ describe("trimViewRowInfo middleware", () => { id: viewId, name: generator.guid(), tableId: table._id!, - columns: ["name", "address"], + schema: { + name: {}, + address: {}, + }, }) const data = getRandomData() diff --git a/packages/server/src/middleware/trimViewRowInfo.ts b/packages/server/src/middleware/trimViewRowInfo.ts index cff9dabd37..6a7448262b 100644 --- a/packages/server/src/middleware/trimViewRowInfo.ts +++ b/packages/server/src/middleware/trimViewRowInfo.ts @@ -23,39 +23,27 @@ export default async (ctx: Ctx, next: Next) => { // don't need to trim delete requests if (ctx?.method?.toLowerCase() !== "delete") { - const { _viewId, ...trimmedView } = await trimViewFields( - viewId, - tableId, - body - ) - ctx.request.body = trimmedView + await trimViewFields(ctx.request.body, viewId) } ctx.params.sourceId = tableId + ctx.params.viewId = viewId return next() } +// have to mutate the koa context, can't return export async function trimViewFields( - viewId: string, - tableId: string, - data: T -): Promise { + body: Row, + viewId: string +): Promise { const view = await sdk.views.get(viewId) - if (!view?.columns || !Object.keys(view.columns).length) { - return data + const allowedKeys = sdk.views.allowedFields(view) + // have to mutate the context, can't update reference + const toBeRemoved = Object.keys(body).filter( + key => !allowedKeys.includes(key) + ) + for (let removeKey of toBeRemoved) { + delete body[removeKey] } - - const table = await sdk.tables.getTable(tableId) - const { schema } = sdk.views.enrichSchema(view!, table.schema) - const result: Record = {} - for (const key of [ - ...Object.keys(schema), - ...db.CONSTANT_EXTERNAL_ROW_COLS, - ...db.CONSTANT_INTERNAL_ROW_COLS, - ]) { - result[key] = data[key] !== null ? data[key] : undefined - } - - return result as T } diff --git a/packages/server/src/sdk/app/tables/index.ts b/packages/server/src/sdk/app/tables/index.ts index 3313e8dfc6..1bf9117837 100644 --- a/packages/server/src/sdk/app/tables/index.ts +++ b/packages/server/src/sdk/app/tables/index.ts @@ -6,13 +6,13 @@ import { isSQL, } from "../../../integrations/utils" import { - Table, Database, + Table, TableResponse, TableViewsResponse, } from "@budibase/types" import datasources from "../datasources" -import { populateExternalTableSchemas, isEditableColumn } from "./validation" +import { isEditableColumn, populateExternalTableSchemas } from "./validation" import sdk from "../../../sdk" async function getAllInternalTables(db?: Database): Promise { @@ -62,7 +62,7 @@ async function getTable(tableId: any): Promise { } function enrichViewSchemas(table: Table): TableResponse { - const result: TableResponse = { + return { ...table, views: Object.values(table.views ?? []) .map(v => sdk.views.enrichSchema(v, table.schema)) @@ -71,8 +71,6 @@ function enrichViewSchemas(table: Table): TableResponse { return p }, {} as TableViewsResponse), } - - return result } export default { diff --git a/packages/server/src/sdk/app/views/external.ts b/packages/server/src/sdk/app/views/external.ts new file mode 100644 index 0000000000..bc240ae17b --- /dev/null +++ b/packages/server/src/sdk/app/views/external.ts @@ -0,0 +1,88 @@ +import { ViewV2 } from "@budibase/types" +import { context, HTTPError } from "@budibase/backend-core" + +import sdk from "../../../sdk" +import * as utils from "../../../db/utils" +import { enrichSchema, isV2 } from "." +import { breakExternalTableId } from "../../../integrations/utils" + +export async function get( + viewId: string, + opts?: { enriched: boolean } +): Promise { + const { tableId } = utils.extractViewInfoFromID(viewId) + + const { datasourceId, tableName } = breakExternalTableId(tableId) + const ds = await sdk.datasources.get(datasourceId!) + + const table = ds.entities![tableName!] + const views = Object.values(table.views!) + const found = views.find(v => isV2(v) && v.id === viewId) + if (!found) { + throw new Error("No view found") + } + if (opts?.enriched) { + return enrichSchema(found, table.schema) as ViewV2 + } else { + return found as ViewV2 + } +} + +export async function create( + tableId: string, + viewRequest: Omit +): Promise { + const view: ViewV2 = { + ...viewRequest, + id: utils.generateViewID(tableId), + version: 2, + } + + const db = context.getAppDB() + + const { datasourceId, tableName } = breakExternalTableId(tableId) + const ds = await sdk.datasources.get(datasourceId!) + ds.entities![tableName!].views ??= {} + ds.entities![tableName!].views![view.name] = view + await db.put(ds) + return view +} + +export async function update(tableId: string, view: ViewV2): Promise { + const db = context.getAppDB() + + const { datasourceId, tableName } = breakExternalTableId(tableId) + const ds = await sdk.datasources.get(datasourceId!) + ds.entities![tableName!].views ??= {} + const views = ds.entities![tableName!].views! + + const existingView = Object.values(views).find( + v => isV2(v) && v.id === view.id + ) + if (!existingView) { + throw new HTTPError(`View ${view.id} not found in table ${tableId}`, 404) + } + + console.log("set to", view) + delete views[existingView.name] + views[view.name] = view + await db.put(ds) + return view +} + +export async function remove(viewId: string): Promise { + const db = context.getAppDB() + + const view = await get(viewId) + + if (!view) { + throw new HTTPError(`View ${viewId} not found`, 404) + } + + const { datasourceId, tableName } = breakExternalTableId(view.tableId) + const ds = await sdk.datasources.get(datasourceId!) + + delete ds.entities![tableName!].views![view?.name] + await db.put(ds) + return view +} diff --git a/packages/server/src/sdk/app/views/index.ts b/packages/server/src/sdk/app/views/index.ts index a0776510cc..927f82cc68 100644 --- a/packages/server/src/sdk/app/views/index.ts +++ b/packages/server/src/sdk/app/views/index.ts @@ -1,74 +1,55 @@ -import { - FieldSchema, - RenameColumn, - TableSchema, - View, - ViewV2, -} from "@budibase/types" -import { context, HTTPError } from "@budibase/backend-core" +import { RenameColumn, TableSchema, View, ViewV2 } from "@budibase/types" +import { db as dbCore } from "@budibase/backend-core" +import { cloneDeep } from "lodash" import sdk from "../../../sdk" import * as utils from "../../../db/utils" +import { isExternalTable } from "../../../integrations/utils" -export async function get(viewId: string): Promise { +import * as internal from "./internal" +import * as external from "./external" + +function pickApi(tableId: any) { + if (isExternalTable(tableId)) { + return external + } + return internal +} + +export async function get( + viewId: string, + opts?: { enriched: boolean } +): Promise { const { tableId } = utils.extractViewInfoFromID(viewId) - const table = await sdk.tables.getTable(tableId) - const views = Object.values(table.views!) - return views.find(v => isV2(v) && v.id === viewId) as ViewV2 | undefined + return pickApi(tableId).get(viewId, opts) } export async function create( tableId: string, viewRequest: Omit ): Promise { - const view: ViewV2 = { - ...viewRequest, - id: utils.generateViewID(tableId), - version: 2, - } - - const db = context.getAppDB() - const table = await sdk.tables.getTable(tableId) - table.views ??= {} - - table.views[view.name] = view - await db.put(table) - return view + return pickApi(tableId).create(tableId, viewRequest) } export async function update(tableId: string, view: ViewV2): Promise { - const db = context.getAppDB() - const table = await sdk.tables.getTable(tableId) - table.views ??= {} - - const existingView = Object.values(table.views).find( - v => isV2(v) && v.id === view.id - ) - if (!existingView) { - throw new HTTPError(`View ${view.id} not found in table ${tableId}`, 404) - } - - delete table.views[existingView.name] - table.views[view.name] = view - await db.put(table) - return view + return pickApi(tableId).update(tableId, view) } export function isV2(view: View | ViewV2): view is ViewV2 { return (view as ViewV2).version === 2 } -export async function remove(viewId: string): Promise { - const db = context.getAppDB() +export async function remove(viewId: string): Promise { + const { tableId } = utils.extractViewInfoFromID(viewId) + return pickApi(tableId).remove(viewId) +} - const view = await get(viewId) - const table = await sdk.tables.getTable(view?.tableId) - if (!view) { - throw new HTTPError(`View ${viewId} not found`, 404) - } - - delete table.views![view?.name] - await db.put(table) +export function allowedFields(view: View | ViewV2) { + return [ + ...Object.keys(view?.schema || {}), + ...dbCore.CONSTANT_EXTERNAL_ROW_COLS, + ...dbCore.CONSTANT_INTERNAL_ROW_COLS, + ] } export function enrichSchema(view: View | ViewV2, tableSchema: TableSchema) { @@ -76,32 +57,22 @@ export function enrichSchema(view: View | ViewV2, tableSchema: TableSchema) { return view } - let schema = { ...tableSchema } - if (view.schemaUI) { - const viewOverridesEntries = Object.entries(view.schemaUI) - const viewSetsOrder = viewOverridesEntries.some(([_, v]) => v.order) - for (const [fieldName, schemaUI] of viewOverridesEntries) { - schema[fieldName] = { - ...schema[fieldName], - ...schemaUI, - order: viewSetsOrder - ? schemaUI.order || undefined - : schema[fieldName].order, + let schema = cloneDeep(tableSchema) + const anyViewOrder = Object.values(view.schema || {}).some( + ui => ui.order != null + ) + for (const key of Object.keys(schema)) { + // if nothing specified in view, then it is not visible + const ui = view.schema?.[key] || { visible: false } + if (ui.visible === false) { + schema[key].visible = false + } else { + schema[key] = { + ...schema[key], + ...ui, + order: anyViewOrder ? ui?.order ?? undefined : schema[key].order, } } - delete view.schemaUI - } - - if (view?.columns?.length) { - const pickedSchema: Record = {} - for (const fieldName of view.columns) { - if (!schema[fieldName]) { - continue - } - pickedSchema[fieldName] = { ...schema[fieldName] } - } - schema = pickedSchema - delete view.columns } return { @@ -115,31 +86,23 @@ export function syncSchema( schema: TableSchema, renameColumn: RenameColumn | undefined ): ViewV2 { - if (renameColumn) { - if (view.columns) { - view.columns[view.columns.indexOf(renameColumn.old)] = - renameColumn.updated - } - if (view.schemaUI) { - view.schemaUI[renameColumn.updated] = view.schemaUI[renameColumn.old] - delete view.schemaUI[renameColumn.old] - } + if (renameColumn && view.schema) { + view.schema[renameColumn.updated] = view.schema[renameColumn.old] + delete view.schema[renameColumn.old] } - if (view.schemaUI) { - for (const fieldName of Object.keys(view.schemaUI)) { + if (view.schema) { + for (const fieldName of Object.keys(view.schema)) { if (!schema[fieldName]) { - delete view.schemaUI[fieldName] + delete view.schema[fieldName] } } for (const fieldName of Object.keys(schema)) { - if (!view.schemaUI[fieldName]) { - view.schemaUI[fieldName] = { visible: false } + if (!view.schema[fieldName]) { + view.schema[fieldName] = { visible: false } } } } - view.columns = view.columns?.filter(x => schema[x]) - return view } diff --git a/packages/server/src/sdk/app/views/internal.ts b/packages/server/src/sdk/app/views/internal.ts new file mode 100644 index 0000000000..97b47fbcb5 --- /dev/null +++ b/packages/server/src/sdk/app/views/internal.ts @@ -0,0 +1,77 @@ +import { ViewV2 } from "@budibase/types" +import { context, HTTPError } from "@budibase/backend-core" + +import sdk from "../../../sdk" +import * as utils from "../../../db/utils" +import { enrichSchema, isV2 } from "." + +export async function get( + viewId: string, + opts?: { enriched: boolean } +): Promise { + const { tableId } = utils.extractViewInfoFromID(viewId) + const table = await sdk.tables.getTable(tableId) + const views = Object.values(table.views!) + const found = views.find(v => isV2(v) && v.id === viewId) + if (!found) { + throw new Error("No view found") + } + if (opts?.enriched) { + return enrichSchema(found, table.schema) as ViewV2 + } else { + return found as ViewV2 + } +} + +export async function create( + tableId: string, + viewRequest: Omit +): Promise { + const view: ViewV2 = { + ...viewRequest, + id: utils.generateViewID(tableId), + version: 2, + } + + const db = context.getAppDB() + + const table = await sdk.tables.getTable(tableId) + table.views ??= {} + + table.views[view.name] = view + await db.put(table) + return view +} + +export async function update(tableId: string, view: ViewV2): Promise { + const db = context.getAppDB() + const table = await sdk.tables.getTable(tableId) + table.views ??= {} + + const existingView = Object.values(table.views).find( + v => isV2(v) && v.id === view.id + ) + if (!existingView) { + throw new HTTPError(`View ${view.id} not found in table ${tableId}`, 404) + } + + console.log("set to", view) + delete table.views[existingView.name] + table.views[view.name] = view + await db.put(table) + return view +} + +export async function remove(viewId: string): Promise { + const db = context.getAppDB() + + const view = await get(viewId) + const table = await sdk.tables.getTable(view?.tableId) + if (!view) { + throw new HTTPError(`View ${viewId} not found`, 404) + } + + delete table.views![view?.name] + await db.put(table) + return view +} diff --git a/packages/server/src/sdk/app/views/tests/views.spec.ts b/packages/server/src/sdk/app/views/tests/views.spec.ts index d3d938f9cf..2314f362e9 100644 --- a/packages/server/src/sdk/app/views/tests/views.spec.ts +++ b/packages/server/src/sdk/app/views/tests/views.spec.ts @@ -49,7 +49,7 @@ describe("table sdk", () => { } describe("enrichViewSchemas", () => { - it("should fetch the default schema if not overriden", async () => { + it("should fetch the default schema if not overridden", async () => { const tableId = basicTable._id! const view: ViewV2 = { version: 2, @@ -66,7 +66,7 @@ describe("table sdk", () => { name: { type: "string", name: "name", - visible: true, + visible: false, order: 2, width: 80, constraints: { @@ -76,7 +76,7 @@ describe("table sdk", () => { description: { type: "string", name: "description", - visible: true, + visible: false, width: 200, constraints: { type: "string", @@ -85,7 +85,7 @@ describe("table sdk", () => { id: { type: "number", name: "id", - visible: true, + visible: false, order: 1, constraints: { type: "number", @@ -110,7 +110,10 @@ describe("table sdk", () => { id: generator.guid(), name: generator.guid(), tableId, - columns: ["name", "id"], + schema: { + name: { visible: true }, + id: { visible: true }, + }, } const res = enrichSchema(view, basicTable.schema) @@ -119,23 +122,20 @@ describe("table sdk", () => { ...view, schema: { name: { - type: "string", - name: "name", + ...basicTable.schema.name, visible: true, - order: 2, - width: 80, - constraints: { - type: "string", - }, + }, + description: { + ...basicTable.schema.description, + visible: false, }, id: { - type: "number", - name: "id", + ...basicTable.schema.id, visible: true, - order: 1, - constraints: { - type: "number", - }, + }, + hiddenField: { + ...basicTable.schema.hiddenField, + visible: false, }, }, }) @@ -148,28 +148,35 @@ describe("table sdk", () => { id: generator.guid(), name: generator.guid(), tableId, - columns: ["unnexisting", "name"], + schema: { + unnexisting: { visible: true }, + name: { visible: true }, + }, } const res = enrichSchema(view, basicTable.schema) - expect(res).toEqual( - expect.objectContaining({ - ...view, - schema: { - name: { - type: "string", - name: "name", - order: 2, - visible: true, - width: 80, - constraints: { - type: "string", - }, - }, + expect(res).toEqual({ + ...view, + schema: { + name: { + ...basicTable.schema.name, + visible: true, }, - }) - ) + description: { + ...basicTable.schema.description, + visible: false, + }, + id: { + ...basicTable.schema.id, + visible: false, + }, + hiddenField: { + ...basicTable.schema.hiddenField, + visible: false, + }, + }, + }) }) it("if the view schema overrides the schema UI, the table schema should be overridden", async () => { @@ -179,8 +186,7 @@ describe("table sdk", () => { id: generator.guid(), name: generator.guid(), tableId, - columns: ["name", "id", "description"], - schemaUI: { + schema: { name: { visible: true, width: 100 }, id: { visible: true, width: 20 }, description: { visible: false }, @@ -193,6 +199,7 @@ describe("table sdk", () => { expect.objectContaining({ ...view, schema: { + ...basicTable.schema, name: { type: "string", name: "name", @@ -234,11 +241,10 @@ describe("table sdk", () => { id: generator.guid(), name: generator.guid(), tableId, - columns: ["name", "id", "description"], - schemaUI: { + schema: { name: { visible: true, order: 1 }, id: { visible: true }, - description: { visible: false, order: 2 }, + description: { visible: true, order: 2 }, }, } @@ -248,6 +254,7 @@ describe("table sdk", () => { expect.objectContaining({ ...view, schema: { + ...basicTable.schema, name: { type: "string", name: "name", @@ -261,6 +268,7 @@ describe("table sdk", () => { id: { type: "number", name: "id", + order: undefined, visible: true, constraints: { type: "number", @@ -270,7 +278,7 @@ describe("table sdk", () => { type: "string", name: "description", order: 2, - visible: false, + visible: true, width: 200, constraints: { type: "string", @@ -294,7 +302,6 @@ describe("table sdk", () => { it("no table schema changes will not amend the view", () => { const view: ViewV2 = { ...basicView, - columns: ["name", "id", "description"], } const result = syncSchema( _.cloneDeep(view), @@ -307,7 +314,6 @@ describe("table sdk", () => { it("adding new columns will not change the view schema", () => { const view: ViewV2 = { ...basicView, - columns: ["name", "id", "description"], } const newTableSchema = { @@ -327,29 +333,26 @@ describe("table sdk", () => { const result = syncSchema(_.cloneDeep(view), newTableSchema, undefined) expect(result).toEqual({ ...view, - schemaUI: undefined, + schema: undefined, }) }) it("deleting columns will not change the view schema", () => { const view: ViewV2 = { ...basicView, - columns: ["name", "id", "description"], } const { name, description, ...newTableSchema } = basicTable.schema const result = syncSchema(_.cloneDeep(view), newTableSchema, undefined) expect(result).toEqual({ ...view, - columns: ["id"], - schemaUI: undefined, + schema: undefined, }) }) it("renaming mapped columns will update the view column mapping", () => { const view: ViewV2 = { ...basicView, - columns: ["name", "id", "description"], } const { description, ...newTableSchema } = { ...basicTable.schema, @@ -365,8 +368,7 @@ describe("table sdk", () => { }) expect(result).toEqual({ ...view, - columns: ["name", "id", "updatedDescription"], - schemaUI: undefined, + schema: undefined, }) }) }) @@ -375,8 +377,7 @@ describe("table sdk", () => { it("no table schema changes will not amend the view", () => { const view: ViewV2 = { ...basicView, - columns: ["name", "id", "description"], - schemaUI: { + schema: { name: { visible: true, width: 100 }, id: { visible: true, width: 20 }, description: { visible: false }, @@ -394,8 +395,7 @@ describe("table sdk", () => { it("adding new columns will add them as not visible to the view", () => { const view: ViewV2 = { ...basicView, - columns: ["name", "id", "description"], - schemaUI: { + schema: { name: { visible: true, width: 100 }, id: { visible: true, width: 20 }, description: { visible: false }, @@ -420,8 +420,8 @@ describe("table sdk", () => { const result = syncSchema(_.cloneDeep(view), newTableSchema, undefined) expect(result).toEqual({ ...view, - schemaUI: { - ...view.schemaUI, + schema: { + ...view.schema, newField1: { visible: false }, newField2: { visible: false }, }, @@ -431,8 +431,7 @@ describe("table sdk", () => { it("deleting columns will remove them from the UI", () => { const view: ViewV2 = { ...basicView, - columns: ["name", "id", "description"], - schemaUI: { + schema: { name: { visible: true, width: 100 }, id: { visible: true, width: 20 }, description: { visible: false }, @@ -444,9 +443,8 @@ describe("table sdk", () => { const result = syncSchema(_.cloneDeep(view), newTableSchema, undefined) expect(result).toEqual({ ...view, - columns: ["id"], - schemaUI: { - ...view.schemaUI, + schema: { + ...view.schema, name: undefined, description: undefined, }, @@ -456,8 +454,7 @@ describe("table sdk", () => { it("can handle additions and deletions at the same them UI", () => { const view: ViewV2 = { ...basicView, - columns: ["name", "id", "description"], - schemaUI: { + schema: { name: { visible: true, width: 100 }, id: { visible: true, width: 20 }, description: { visible: false }, @@ -476,9 +473,8 @@ describe("table sdk", () => { const result = syncSchema(_.cloneDeep(view), newTableSchema, undefined) expect(result).toEqual({ ...view, - columns: ["id"], - schemaUI: { - ...view.schemaUI, + schema: { + ...view.schema, name: undefined, description: undefined, newField1: { visible: false }, @@ -489,8 +485,7 @@ describe("table sdk", () => { it("renaming mapped columns will update the view column mapping and it's schema", () => { const view: ViewV2 = { ...basicView, - columns: ["name", "id", "description"], - schemaUI: { + schema: { name: { visible: true }, id: { visible: true }, description: { visible: true, width: 150, icon: "ic-any" }, @@ -511,9 +506,8 @@ describe("table sdk", () => { }) expect(result).toEqual({ ...view, - columns: ["name", "id", "updatedDescription"], - schemaUI: { - ...view.schemaUI, + schema: { + ...view.schema, description: undefined, updatedDescription: { visible: true, width: 150, icon: "ic-any" }, }, @@ -523,8 +517,7 @@ describe("table sdk", () => { it("changing no UI schema will not affect the view", () => { const view: ViewV2 = { ...basicView, - columns: ["name", "id", "description"], - schemaUI: { + schema: { name: { visible: true, width: 100 }, id: { visible: true, width: 20 }, description: { visible: false }, @@ -548,8 +541,7 @@ describe("table sdk", () => { it("changing table column UI fields will not affect the view schema", () => { const view: ViewV2 = { ...basicView, - columns: ["name", "id", "description"], - schemaUI: { + schema: { name: { visible: true, width: 100 }, id: { visible: true, width: 20 }, description: { visible: false }, diff --git a/packages/server/src/tests/utilities/api/viewV2.ts b/packages/server/src/tests/utilities/api/viewV2.ts index bba65e187f..0682361e16 100644 --- a/packages/server/src/tests/utilities/api/viewV2.ts +++ b/packages/server/src/tests/utilities/api/viewV2.ts @@ -23,7 +23,8 @@ export class ViewV2API extends TestAPI { if (!tableId && !this.config.table) { throw "Test requires table to be configured." } - tableId = this.config.table!._id! + const table = this.config.table + tableId = table!._id! const view = { tableId, name: generator.guid(), diff --git a/packages/server/src/websockets/builder.ts b/packages/server/src/websockets/builder.ts index 7f4c9fbd33..d2fdbca20c 100644 --- a/packages/server/src/websockets/builder.ts +++ b/packages/server/src/websockets/builder.ts @@ -108,12 +108,12 @@ export default class BuilderSocket extends BaseSocket { gridSocket?.emitTableUpdate(ctx, table) } - emitTableDeletion(ctx: any, id: string) { + emitTableDeletion(ctx: any, table: Table) { this.emitToRoom(ctx, ctx.appId, BuilderSocketEvent.TableChange, { - id, + id: table._id, table: null, }) - gridSocket?.emitTableDeletion(ctx, id) + gridSocket?.emitTableDeletion(ctx, table) } emitDatasourceUpdate(ctx: any, datasource: Datasource) { diff --git a/packages/server/src/websockets/grid.ts b/packages/server/src/websockets/grid.ts index f95137ee08..979a0bf125 100644 --- a/packages/server/src/websockets/grid.ts +++ b/packages/server/src/websockets/grid.ts @@ -5,7 +5,7 @@ import { auth, permissions } from "@budibase/backend-core" import http from "http" import Koa from "koa" import { getTableId } from "../api/controllers/row/utils" -import { Row, Table } from "@budibase/types" +import { Row, Table, View, ViewV2 } from "@budibase/types" import { Socket } from "socket.io" import { GridSocketEvent } from "@budibase/shared-core" import { userAgent } from "koa-useragent" @@ -20,48 +20,49 @@ export default class GridSocket extends BaseSocket { async onConnect(socket: Socket) { // Initial identification of connected spreadsheet - socket.on( - GridSocketEvent.SelectTable, - async ({ tableId, appId }, callback) => { - // Ignore if no table or app specified - if (!tableId || !appId) { - socket.disconnect(true) - return - } + socket.on(GridSocketEvent.SelectDatasource, async (payload, callback) => { + const ds = payload.datasource + const appId = payload.appId + const resourceId = ds?.type === "table" ? ds?.tableId : ds?.id - // Create context - const ctx = createContext(this.app, socket, { - resourceId: tableId, - appId, - }) - - // Construct full middleware chain to assess permissions - const middlewares = [ - userAgent, - auth.buildAuthMiddleware([], { - publicAllowed: true, - }), - currentApp, - authorized(PermissionType.TABLE, PermissionLevel.READ), - ] - - // Run all koa middlewares - try { - await runMiddlewares(ctx, middlewares, async () => { - // Middlewares are finished and we have permission - // Join room for this resource - const room = `${appId}-${tableId}` - await this.joinRoom(socket, room) - - // Reply with all users in current room - const sessions = await this.getRoomSessions(room) - callback({ users: sessions }) - }) - } catch (error) { - socket.disconnect(true) - } + // Ignore if no table or app specified + if (!resourceId || !appId) { + socket.disconnect(true) + return } - ) + + // Create context + const ctx = createContext(this.app, socket, { + resourceId, + appId, + }) + + // Construct full middleware chain to assess permissions + const middlewares = [ + userAgent, + auth.buildAuthMiddleware([], { + publicAllowed: true, + }), + currentApp, + authorized(PermissionType.TABLE, PermissionLevel.READ), + ] + + // Run all koa middlewares + try { + await runMiddlewares(ctx, middlewares, async () => { + // Middlewares are finished and we have permission + // Join room for this resource + const room = `${appId}-${resourceId}` + await this.joinRoom(socket, room) + + // Reply with all users in current room + const sessions = await this.getRoomSessions(room) + callback({ users: sessions }) + }) + } catch (error) { + socket.disconnect(true) + } + }) // Handle users selecting a new cell socket.on(GridSocketEvent.SelectCell, ({ cellId }) => { @@ -79,30 +80,60 @@ export default class GridSocket extends BaseSocket { } emitRowUpdate(ctx: any, row: Row) { - const tableId = getTableId(ctx) - const room = `${ctx.appId}-${tableId}` + const resourceId = ctx.params?.viewId || getTableId(ctx) + const room = `${ctx.appId}-${resourceId}` this.emitToRoom(ctx, room, GridSocketEvent.RowChange, { id: row._id, row, }) } - emitRowDeletion(ctx: any, id: string) { - const tableId = getTableId(ctx) - const room = `${ctx.appId}-${tableId}` - this.emitToRoom(ctx, room, GridSocketEvent.RowChange, { id, row: null }) + emitRowDeletion(ctx: any, row: Row) { + const resourceId = ctx.params?.viewId || getTableId(ctx) + const room = `${ctx.appId}-${resourceId}` + this.emitToRoom(ctx, room, GridSocketEvent.RowChange, { + id: row._id, + row: null, + }) } emitTableUpdate(ctx: any, table: Table) { const room = `${ctx.appId}-${table._id}` - this.emitToRoom(ctx, room, GridSocketEvent.TableChange, { + this.emitToRoom(ctx, room, GridSocketEvent.DatasourceChange, { id: table._id, - table, + datasource: table, }) } - emitTableDeletion(ctx: any, id: string) { - const room = `${ctx.appId}-${id}` - this.emitToRoom(ctx, room, GridSocketEvent.TableChange, { id, table: null }) + emitTableDeletion(ctx: any, table: Table) { + const room = `${ctx.appId}-${table._id}` + this.emitToRoom(ctx, room, GridSocketEvent.DatasourceChange, { + id: table._id, + datasource: null, + }) + + // When the table is deleted we need to notify all views that they have + // also been deleted + Object.values(table.views || {}) + .filter((view: View | ViewV2) => (view as ViewV2).version === 2) + .forEach((view: View | ViewV2) => { + this.emitViewDeletion(ctx, view as ViewV2) + }) + } + + emitViewUpdate(ctx: any, view: ViewV2) { + const room = `${ctx.appId}-${view.id}` + this.emitToRoom(ctx, room, GridSocketEvent.DatasourceChange, { + id: view.id, + datasource: view, + }) + } + + emitViewDeletion(ctx: any, view: ViewV2) { + const room = `${ctx.appId}-${view.id}` + this.emitToRoom(ctx, room, GridSocketEvent.DatasourceChange, { + id: view.id, + datasource: null, + }) } } diff --git a/packages/server/src/websockets/websocket.ts b/packages/server/src/websockets/websocket.ts index 9dea67ef5f..c92f0b6897 100644 --- a/packages/server/src/websockets/websocket.ts +++ b/packages/server/src/websockets/websocket.ts @@ -270,7 +270,7 @@ export class BaseSocket { // Emit an event to everyone in a room, including metadata of whom // the originator of the request was - emitToRoom(ctx: any, room: string, event: string, payload: any) { + emitToRoom(ctx: any, room: string | string[], event: string, payload: any) { this.io.in(room).emit(event, { ...payload, apiSessionId: ctx.headers?.[Header.SESSION_ID], diff --git a/packages/shared-core/src/constants.ts b/packages/shared-core/src/constants.ts index 864009fbf2..725c246e2f 100644 --- a/packages/shared-core/src/constants.ts +++ b/packages/shared-core/src/constants.ts @@ -76,8 +76,8 @@ export enum SocketEvent { export enum GridSocketEvent { RowChange = "RowChange", - TableChange = "TableChange", - SelectTable = "SelectTable", + DatasourceChange = "DatasourceChange", + SelectDatasource = "SelectDatasource", SelectCell = "SelectCell", } diff --git a/packages/types/src/api/web/app/rows.ts b/packages/types/src/api/web/app/rows.ts index 2b51c7b203..a99ef0e837 100644 --- a/packages/types/src/api/web/app/rows.ts +++ b/packages/types/src/api/web/app/rows.ts @@ -16,7 +16,13 @@ export interface SearchRowRequest extends Omit {} export interface SearchViewRowRequest extends Pick< SearchRowRequest, - "sort" | "sortOrder" | "sortType" | "limit" | "bookmark" | "paginate" + | "sort" + | "sortOrder" + | "sortType" + | "limit" + | "bookmark" + | "paginate" + | "query" > {} export interface SearchRowResponse { diff --git a/packages/types/src/api/web/app/view.ts b/packages/types/src/api/web/app/view.ts index 60b5a01b15..30e7bf77d7 100644 --- a/packages/types/src/api/web/app/view.ts +++ b/packages/types/src/api/web/app/view.ts @@ -5,11 +5,10 @@ export interface ViewResponse { } export interface CreateViewRequest - extends Omit { + extends Omit { schema?: Record } -export interface UpdateViewRequest - extends Omit { +export interface UpdateViewRequest extends Omit { schema?: Record } diff --git a/packages/types/src/documents/app/view.ts b/packages/types/src/documents/app/view.ts index 204b995337..0de5c69123 100644 --- a/packages/types/src/documents/app/view.ts +++ b/packages/types/src/documents/app/view.ts @@ -25,8 +25,7 @@ export interface ViewV2 { order?: SortOrder type?: SortType } - columns?: string[] - schemaUI?: Record + schema?: Record } export type ViewSchema = ViewCountOrSumSchema | ViewStatisticsSchema diff --git a/packages/types/src/sdk/licensing/feature.ts b/packages/types/src/sdk/licensing/feature.ts index 218c2c5429..bd3a6583bf 100644 --- a/packages/types/src/sdk/licensing/feature.ts +++ b/packages/types/src/sdk/licensing/feature.ts @@ -12,7 +12,7 @@ export enum Feature { APP_BUILDERS = "appBuilders", OFFLINE = "offline", USER_ROLE_PUBLIC_API = "userRolePublicApi", - VIEW_PERMISSIONS = "viewPermission", + VIEW_PERMISSIONS = "viewPermissions", } export type PlanFeatures = { [key in PlanType]: Feature[] | undefined }