diff --git a/lerna.json b/lerna.json index 8742093f39..ec967e61ea 100644 --- a/lerna.json +++ b/lerna.json @@ -19,4 +19,4 @@ "loadEnvFiles": false } } -} \ No newline at end of file +} diff --git a/packages/backend-core/src/db/db.ts b/packages/backend-core/src/db/db.ts index f13eb9a965..9aae64b892 100644 --- a/packages/backend-core/src/db/db.ts +++ b/packages/backend-core/src/db/db.ts @@ -11,7 +11,11 @@ export function getDB(dbName?: string, opts?: any): Database { // we have to use a callback for this so that we can close // the DB when we're done, without this manual requests would // need to close the database when done with it to avoid memory leaks -export async function doWithDB(dbName: string, cb: any, opts = {}) { +export async function doWithDB( + dbName: string, + cb: (db: Database) => Promise, + opts = {} +) { const db = getDB(dbName, opts) // need this to be async so that we can correctly close DB after all // async operations have been completed diff --git a/packages/backend-core/src/security/permissions.ts b/packages/backend-core/src/security/permissions.ts index aa0b20a30c..13083534b1 100644 --- a/packages/backend-core/src/security/permissions.ts +++ b/packages/backend-core/src/security/permissions.ts @@ -87,6 +87,7 @@ export const BUILTIN_PERMISSIONS = { new Permission(PermissionType.QUERY, PermissionLevel.WRITE), new Permission(PermissionType.TABLE, PermissionLevel.WRITE), new Permission(PermissionType.AUTOMATION, PermissionLevel.EXECUTE), + new Permission(PermissionType.LEGACY_VIEW, PermissionLevel.READ), ], }, POWER: { @@ -97,6 +98,7 @@ export const BUILTIN_PERMISSIONS = { new Permission(PermissionType.USER, PermissionLevel.READ), new Permission(PermissionType.AUTOMATION, PermissionLevel.EXECUTE), new Permission(PermissionType.WEBHOOK, PermissionLevel.READ), + new Permission(PermissionType.LEGACY_VIEW, PermissionLevel.READ), ], }, ADMIN: { @@ -108,6 +110,7 @@ export const BUILTIN_PERMISSIONS = { new Permission(PermissionType.AUTOMATION, PermissionLevel.ADMIN), new Permission(PermissionType.WEBHOOK, PermissionLevel.READ), new Permission(PermissionType.QUERY, PermissionLevel.ADMIN), + new Permission(PermissionType.LEGACY_VIEW, PermissionLevel.READ), ], }, } diff --git a/packages/backend-core/src/security/roles.ts b/packages/backend-core/src/security/roles.ts index 081193b433..05d536562b 100644 --- a/packages/backend-core/src/security/roles.ts +++ b/packages/backend-core/src/security/roles.ts @@ -253,7 +253,7 @@ export function checkForRoleResourceArray( * Given an app ID this will retrieve all of the roles that are currently within that app. * @return {Promise} An array of the role objects that were found. */ -export async function getAllRoles(appId?: string) { +export async function getAllRoles(appId?: string): Promise { if (appId) { return doWithDB(appId, internal) } else { @@ -312,37 +312,6 @@ export async function getAllRoles(appId?: string) { } } -/** - * This retrieves the required role for a resource - * @param permLevel The level of request - * @param resourceId The resource being requested - * @param subResourceId The sub resource being requested - * @return {Promise<{permissions}|Object>} returns the permissions required to access. - */ -export async function getRequiredResourceRole( - permLevel: string, - { resourceId, subResourceId }: { resourceId?: string; subResourceId?: string } -) { - const roles = await getAllRoles() - let main = [], - sub = [] - for (let role of roles) { - // no permissions, ignore it - if (!role.permissions) { - continue - } - const mainRes = resourceId ? role.permissions[resourceId] : undefined - const subRes = subResourceId ? role.permissions[subResourceId] : undefined - if (mainRes && mainRes.indexOf(permLevel) !== -1) { - main.push(role._id) - } else if (subRes && subRes.indexOf(permLevel) !== -1) { - sub.push(role._id) - } - } - // for now just return the IDs - return main.concat(sub) -} - export class AccessController { userHierarchies: { [key: string]: string[] } constructor() { diff --git a/packages/bbui/src/Actions/position_dropdown.js b/packages/bbui/src/Actions/position_dropdown.js index 4a5ef890bf..cc169eac09 100644 --- a/packages/bbui/src/Actions/position_dropdown.js +++ b/packages/bbui/src/Actions/position_dropdown.js @@ -17,6 +17,7 @@ export default function positionDropdown(element, opts) { maxWidth, useAnchorWidth, offset = 5, + customUpdate, } = opts if (!anchor) { return @@ -32,33 +33,42 @@ export default function positionDropdown(element, opts) { left: null, top: null, } - // Determine vertical styles - if (align === "right-outside") { - styles.top = anchorBounds.top - } else if (window.innerHeight - anchorBounds.bottom < (maxHeight || 100)) { - styles.top = anchorBounds.top - elementBounds.height - offset - styles.maxHeight = maxHeight || 240 - } else { - styles.top = anchorBounds.bottom + offset - styles.maxHeight = - maxHeight || window.innerHeight - anchorBounds.bottom - 20 - } - // Determine horizontal styles - if (!maxWidth && useAnchorWidth) { - styles.maxWidth = anchorBounds.width - } - if (useAnchorWidth) { - styles.minWidth = anchorBounds.width - } - if (align === "right") { - styles.left = anchorBounds.left + anchorBounds.width - elementBounds.width - } else if (align === "right-outside") { - styles.left = anchorBounds.right + offset - } else if (align === "left-outside") { - styles.left = anchorBounds.left - elementBounds.width - offset + if (typeof customUpdate === "function") { + styles = customUpdate(anchorBounds, elementBounds, styles) } else { - styles.left = anchorBounds.left + // Determine vertical styles + if (align === "right-outside") { + styles.top = anchorBounds.top + } else if ( + window.innerHeight - anchorBounds.bottom < + (maxHeight || 100) + ) { + styles.top = anchorBounds.top - elementBounds.height - offset + styles.maxHeight = maxHeight || 240 + } else { + styles.top = anchorBounds.bottom + offset + styles.maxHeight = + maxHeight || window.innerHeight - anchorBounds.bottom - 20 + } + + // Determine horizontal styles + if (!maxWidth && useAnchorWidth) { + styles.maxWidth = anchorBounds.width + } + if (useAnchorWidth) { + styles.minWidth = anchorBounds.width + } + if (align === "right") { + styles.left = + anchorBounds.left + anchorBounds.width - elementBounds.width + } else if (align === "right-outside") { + styles.left = anchorBounds.right + offset + } else if (align === "left-outside") { + styles.left = anchorBounds.left - elementBounds.width - offset + } else { + styles.left = anchorBounds.left + } } // Apply styles diff --git a/packages/bbui/src/DetailSummary/DetailSummary.svelte b/packages/bbui/src/DetailSummary/DetailSummary.svelte index f7e2611792..daa9f3f5ca 100644 --- a/packages/bbui/src/DetailSummary/DetailSummary.svelte +++ b/packages/bbui/src/DetailSummary/DetailSummary.svelte @@ -44,7 +44,9 @@ align-items: stretch; border-bottom: var(--border-light); } - + .property-group-container:last-child { + border-bottom: 0px; + } .property-group-name { cursor: pointer; display: flex; diff --git a/packages/bbui/src/Drawer/Drawer.svelte b/packages/bbui/src/Drawer/Drawer.svelte index 4ff4df854b..421d12615f 100644 --- a/packages/bbui/src/Drawer/Drawer.svelte +++ b/packages/bbui/src/Drawer/Drawer.svelte @@ -4,6 +4,8 @@ import Body from "../Typography/Body.svelte" import Heading from "../Typography/Heading.svelte" import { setContext } from "svelte" + import { createEventDispatcher } from "svelte" + import { generate } from "shortid" export let title export let fillWidth @@ -11,13 +13,17 @@ export let width = "calc(100% - 626px)" export let headless = false + const dispatch = createEventDispatcher() + let visible = false + let drawerId = generate() export function show() { if (visible) { return } visible = true + dispatch("drawerShow", drawerId) } export function hide() { @@ -25,6 +31,7 @@ return } visible = false + dispatch("drawerHide", drawerId) } setContext("drawer-actions", { diff --git a/packages/bbui/src/FancyForm/FancySelect.svelte b/packages/bbui/src/FancyForm/FancySelect.svelte index ee43ecc3ca..e015f51570 100644 --- a/packages/bbui/src/FancyForm/FancySelect.svelte +++ b/packages/bbui/src/FancyForm/FancySelect.svelte @@ -2,8 +2,9 @@ import { createEventDispatcher } from "svelte" import FancyField from "./FancyField.svelte" import Icon from "../Icon/Icon.svelte" - import Popover from "../Popover/Popover.svelte" import FancyFieldLabel from "./FancyFieldLabel.svelte" + import StatusLight from "../StatusLight/StatusLight.svelte" + import Picker from "../Form/Core/Picker.svelte" export let label export let value @@ -11,18 +12,30 @@ export let error = null export let validate = null export let options = [] + export let isOptionEnabled = () => true export let getOptionLabel = option => extractProperty(option, "label") export let getOptionValue = option => extractProperty(option, "value") - + export let getOptionSubtitle = option => extractProperty(option, "subtitle") + export let getOptionColour = () => null const dispatch = createEventDispatcher() let open = false - let popover let wrapper $: placeholder = !value $: selectedLabel = getSelectedLabel(value) + $: fieldColour = getFieldAttribute(getOptionColour, value, options) + const getFieldAttribute = (getAttribute, value, options) => { + // Wait for options to load if there is a value but no options + if (!options?.length) { + return "" + } + const index = options.findIndex( + (option, idx) => getOptionValue(option, idx) === value + ) + return index !== -1 ? getAttribute(options[index], index) : null + } const extractProperty = (value, property) => { if (value && typeof value === "object") { return value[property] @@ -64,46 +77,45 @@ {label} {/if} + {#if fieldColour} + + + + {/if} +
{selectedLabel || ""}
-
+
- (open = false)} - useAnchorWidth={true} - maxWidth={null} -> -
- {#if options.length} - {#each options as option, idx} -
onChange(getOptionValue(option, idx))} - > - - {getOptionLabel(option, idx)} - - {#if value === getOptionValue(option, idx)} - - {/if} -
- {/each} - {/if} -
-
+
+ option === value} + /> +
diff --git a/packages/bbui/src/Form/Core/Combobox.svelte b/packages/bbui/src/Form/Core/Combobox.svelte index b68a24d8db..b1b264a9b7 100644 --- a/packages/bbui/src/Form/Core/Combobox.svelte +++ b/packages/bbui/src/Form/Core/Combobox.svelte @@ -2,8 +2,8 @@ import "@spectrum-css/inputgroup/dist/index-vars.css" import "@spectrum-css/popover/dist/index-vars.css" import "@spectrum-css/menu/dist/index-vars.css" - import { fly } from "svelte/transition" import { createEventDispatcher } from "svelte" + import clickOutside from "../../Actions/click_outside" export let value = null export let id = null @@ -80,10 +80,11 @@ {#if open} -
(open = false)} />
{ + open = false + }} >
    {#if options && Array.isArray(options)} @@ -125,14 +126,6 @@ .spectrum-Textfield-input { width: 0; } - .overlay { - position: fixed; - top: 0; - left: 0; - width: 100vw; - height: 100vh; - z-index: 999; - } .spectrum-Popover { max-height: 240px; width: 100%; diff --git a/packages/bbui/src/Form/Core/Picker.svelte b/packages/bbui/src/Form/Core/Picker.svelte index aada17b318..f736f523ad 100644 --- a/packages/bbui/src/Form/Core/Picker.svelte +++ b/packages/bbui/src/Form/Core/Picker.svelte @@ -8,6 +8,8 @@ import Icon from "../../Icon/Icon.svelte" import StatusLight from "../../StatusLight/StatusLight.svelte" import Popover from "../../Popover/Popover.svelte" + import Tags from "../../Tags/Tags.svelte" + import Tag from "../../Tags/Tag.svelte" export let id = null export let disabled = false @@ -26,6 +28,7 @@ export let getOptionIcon = () => null export let useOptionIconImage = false export let getOptionColour = () => null + export let getOptionSubtitle = () => null export let open = false export let readonly = false export let quiet = false @@ -37,7 +40,7 @@ export let customPopoverHeight export let align = "left" export let footer = null - + export let customAnchor = null const dispatch = createEventDispatcher() let searchTerm = null @@ -99,7 +102,7 @@ bind:this={button} > {#if fieldIcon} - {#if !useOptionIconImage} + {#if !useOptionIconImage}x @@ -139,9 +142,8 @@ - {/if} + {#if getOptionSubtitle(option, idx)} + {getOptionSubtitle(option, idx)} + {/if} + {getOptionLabel(option, idx)} + {#if option.tag} + + + {option.tag} + + + {/if} .spectrum-Icon) { + margin-top: 2px; + } diff --git a/packages/bbui/src/Form/Core/Select.svelte b/packages/bbui/src/Form/Core/Select.svelte index 2fad886910..e79ead7e8c 100644 --- a/packages/bbui/src/Form/Core/Select.svelte +++ b/packages/bbui/src/Form/Core/Select.svelte @@ -21,7 +21,7 @@ export let sort = false export let align export let footer = null - + export let tag = null const dispatch = createEventDispatcher() let open = false @@ -83,6 +83,7 @@ {isOptionEnabled} {autocomplete} {sort} + {tag} isPlaceholder={value == null || value === ""} placeholderOption={placeholder === false ? null : placeholder} isOptionSelected={option => option === value} diff --git a/packages/bbui/src/Form/Select.svelte b/packages/bbui/src/Form/Select.svelte index e87496652d..a9214320f9 100644 --- a/packages/bbui/src/Form/Select.svelte +++ b/packages/bbui/src/Form/Select.svelte @@ -25,7 +25,7 @@ export let customPopoverHeight export let align export let footer = null - + export let tag = null const dispatch = createEventDispatcher() const onChange = e => { value = e.detail @@ -61,6 +61,7 @@ {isOptionEnabled} {autocomplete} {customPopoverHeight} + {tag} on:change={onChange} on:click /> 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/Popover/Popover.svelte b/packages/bbui/src/Popover/Popover.svelte index 1259f2704f..13a6822853 100644 --- a/packages/bbui/src/Popover/Popover.svelte +++ b/packages/bbui/src/Popover/Popover.svelte @@ -23,6 +23,10 @@ export let animate = true export let customZindex + export let handlePostionUpdate + export let showPopover = true + export let clickOutsideOverride = false + $: target = portalTarget || getContext(Context.PopoverRoot) || ".spectrum" export const show = () => { @@ -44,6 +48,9 @@ } const handleOutsideClick = e => { + if (clickOutsideOverride) { + return + } if (open) { // Stop propagation if the source is the anchor let node = e.target @@ -62,6 +69,9 @@ } function handleEscape(e) { + if (!clickOutsideOverride) { + return + } if (open && e.key === "Escape") { hide() } @@ -79,6 +89,7 @@ maxWidth, useAnchorWidth, offset, + customUpdate: handlePostionUpdate, }} use:clickOutside={{ callback: dismissible ? handleOutsideClick : () => {}, @@ -87,6 +98,7 @@ on:keydown={handleEscape} class="spectrum-Popover is-open" class:customZindex + class:hide-popover={open && !showPopover} role="presentation" style="height: {customHeight}; --customZindex: {customZindex};" transition:fly|local={{ y: -20, duration: animate ? 200 : 0 }} @@ -97,6 +109,10 @@ {/if}
    @@ -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..5c0b7df742 100644 --- a/packages/builder/src/components/backend/DataTable/buttons/ManageAccessButton.svelte +++ b/packages/builder/src/components/backend/DataTable/buttons/ManageAccessButton.svelte @@ -9,19 +9,15 @@ let modal let resourcePermissions - async function openDropdown() { - resourcePermissions = await permissions.forResource(resourceId) + async function openModal() { + resourcePermissions = await permissions.forResourceDetailed(resourceId) modal.show() } - + Access - + 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..3441d8de17 100644 --- a/packages/builder/src/components/backend/DataTable/buttons/grid/GridCreateViewButton.svelte +++ b/packages/builder/src/components/backend/DataTable/buttons/grid/GridCreateViewButton.svelte @@ -1,18 +1,30 @@ - - Add view - + + + Create view + + - + diff --git a/packages/builder/src/components/backend/DataTable/buttons/grid/GridExportButton.svelte b/packages/builder/src/components/backend/DataTable/buttons/grid/GridExportButton.svelte index b5fe202d11..f1bbc04328 100644 --- a/packages/builder/src/components/backend/DataTable/buttons/grid/GridExportButton.svelte +++ b/packages/builder/src/components/backend/DataTable/buttons/grid/GridExportButton.svelte @@ -2,7 +2,7 @@ import ExportButton from "../ExportButton.svelte" import { getContext } from "svelte" - const { rows, columns, tableId, sort, selectedRows, filter } = + const { rows, columns, datasource, sort, selectedRows, filter } = getContext("grid") $: disabled = !$rows.length || !$columns.length @@ -12,7 +12,7 @@ { filter.set(e.detail || []) } -{#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..0cd008bab1 100644 --- a/packages/builder/src/components/backend/DataTable/buttons/grid/GridManageAccessButton.svelte +++ b/packages/builder/src/components/backend/DataTable/buttons/grid/GridManageAccessButton.svelte @@ -2,7 +2,16 @@ import ManageAccessButton from "../ManageAccessButton.svelte" import { getContext } from "svelte" - const { tableId } = getContext("grid") + const { datasource } = getContext("grid") + + $: resourceId = getResourceID($datasource) + + const getResourceID = datasource => { + if (!datasource) { + return null + } + return datasource.type === "table" ? datasource.tableId : datasource.id + } - + 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..161757c570 100644 --- a/packages/builder/src/components/backend/DataTable/modals/ManageAccessModal.svelte +++ b/packages/builder/src/components/backend/DataTable/modals/ManageAccessModal.svelte @@ -1,4 +1,5 @@ - + + + Manage Access + {#if requiresPlanToModify} + + + {capitalise(requiresPlanToModify)} + + + {/if} + Specify the minimum access level role for this data.
    - {#each Object.keys(permissions) as level} + {#each Object.keys(computedPermissions) 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 @@ - role.name} + getOptionValue={role => role._id} + getOptionColour={getColor} + getOptionIcon={getIcon} + isOptionEnabled={option => + option._id !== Constants.Roles.CREATOR || + $licensing.perAppBuildersEnabled} + {placeholder} + {error} + /> +{/if} diff --git a/packages/builder/src/components/common/bindings/DrawerBindableInput.svelte b/packages/builder/src/components/common/bindings/DrawerBindableInput.svelte index dacb076bdb..5c4f90606d 100644 --- a/packages/builder/src/components/common/bindings/DrawerBindableInput.svelte +++ b/packages/builder/src/components/common/bindings/DrawerBindableInput.svelte @@ -74,6 +74,8 @@ {/if}
    {actionText}
    - + Define what actions to run. @@ -86,6 +87,7 @@ {bindings} {key} {nested} + {componentInstance} /> diff --git a/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/DeleteRow.svelte b/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/DeleteRow.svelte index e4a5f171ff..1e79c51051 100644 --- a/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/DeleteRow.svelte +++ b/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/DeleteRow.svelte @@ -1,12 +1,20 @@
    @@ -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 @@ + +
      + {#each draggableItems as draggable (draggable.id)} +
    • +
      + {#if showHandle} +
      + +
      + {/if} +
      +
      + +
      +
    • + {/each} +
    + + diff --git a/packages/builder/src/components/design/settings/controls/FieldConfiguration/EditFieldPopover.svelte b/packages/builder/src/components/design/settings/controls/FieldConfiguration/EditFieldPopover.svelte new file mode 100644 index 0000000000..7d2eaae478 --- /dev/null +++ b/packages/builder/src/components/design/settings/controls/FieldConfiguration/EditFieldPopover.svelte @@ -0,0 +1,160 @@ + + + { + if (!open) { + popover.show() + open = true + } + }} +/> + + { + drawers = [] + $draggable.actions.select(field._id) + }} + on:close={() => { + open = false + if ($draggable.selected == field._id) { + $draggable.actions.select() + } + }} + {anchor} + align="left-outside" + showPopover={drawers.length == 0} + clickOutsideOverride={drawers.length > 0} + maxHeight={600} + handlePostionUpdate={(anchorBounds, eleBounds, cfg) => { + let { left, top } = cfg + let percentageOffset = 30 + // left-outside + left = anchorBounds.left - eleBounds.width - 18 + + // shift up from the anchor, if space allows + let offsetPos = Math.floor(eleBounds.height / 100) * percentageOffset + let defaultTop = anchorBounds.top - offsetPos + + if (window.innerHeight - defaultTop < eleBounds.height) { + top = window.innerHeight - eleBounds.height - 5 + } else { + top = anchorBounds.top - offsetPos + } + + return { ...cfg, left, top } + }} +> + + +
    + + {field.field} +
    + { + drawers = [...drawers, e.detail] + }} + on:drawerHide={() => { + drawers = drawers.slice(0, -1) + }} + /> +
    +
    +
    + + diff --git a/packages/builder/src/components/design/settings/controls/FieldConfiguration/FieldConfiguration.svelte b/packages/builder/src/components/design/settings/controls/FieldConfiguration/FieldConfiguration.svelte index f9dccf586c..f12e8d27ae 100644 --- a/packages/builder/src/components/design/settings/controls/FieldConfiguration/FieldConfiguration.svelte +++ b/packages/builder/src/components/design/settings/controls/FieldConfiguration/FieldConfiguration.svelte @@ -1,45 +1,70 @@
    - {text} + {#if fieldList?.length} + + {/if}
    - - - Configure the fields in your form. - - - - - diff --git a/packages/builder/src/components/design/settings/controls/FieldConfiguration/utils.js b/packages/builder/src/components/design/settings/controls/FieldConfiguration/utils.js new file mode 100644 index 0000000000..d4a8963dba --- /dev/null +++ b/packages/builder/src/components/design/settings/controls/FieldConfiguration/utils.js @@ -0,0 +1,46 @@ +export const convertOldFieldFormat = fields => { + if (!fields) { + return [] + } + const converted = fields.map(field => { + if (typeof field === "string") { + // existed but was a string + return { + field, + active: true, + } + } else if (typeof field?.active != "boolean") { + // existed but had no state + return { + field: field.name, + active: true, + } + } else { + return field + } + }) + return converted +} + +export const getComponentForField = (field, schema) => { + if (!field || !schema?.[field]) { + return null + } + const type = schema[field].type + return FieldTypeToComponentMap[type] +} + +export const FieldTypeToComponentMap = { + string: "stringfield", + number: "numberfield", + bigint: "bigintfield", + options: "optionsfield", + array: "multifieldselect", + boolean: "booleanfield", + longform: "longformfield", + datetime: "datetimefield", + attachment: "attachmentfield", + link: "relationshipfield", + json: "jsonfield", + barcodeqr: "codescanner", +} diff --git a/packages/builder/src/components/design/settings/controls/OptionsEditor/OptionsEditor.svelte b/packages/builder/src/components/design/settings/controls/OptionsEditor/OptionsEditor.svelte index 1201edd31e..c626081042 100644 --- a/packages/builder/src/components/design/settings/controls/OptionsEditor/OptionsEditor.svelte +++ b/packages/builder/src/components/design/settings/controls/OptionsEditor/OptionsEditor.svelte @@ -24,11 +24,22 @@ } -Define Options - +
    +
    +
    Define Options
    +
    + Define the options for this picker. + + diff --git a/packages/builder/src/components/design/settings/controls/PropertyControl.svelte b/packages/builder/src/components/design/settings/controls/PropertyControl.svelte index 5125c3bade..c8135b4f61 100644 --- a/packages/builder/src/components/design/settings/controls/PropertyControl.svelte +++ b/packages/builder/src/components/design/settings/controls/PropertyControl.svelte @@ -100,6 +100,8 @@ {key} {type} {...props} + on:drawerHide + on:drawerShow />
    {#if info} diff --git a/packages/builder/src/components/design/settings/controls/TableSelect.svelte b/packages/builder/src/components/design/settings/controls/TableSelect.svelte index 384bbe1e3a..4a6f33202b 100644 --- a/packages/builder/src/components/design/settings/controls/TableSelect.svelte +++ b/packages/builder/src/components/design/settings/controls/TableSelect.svelte @@ -1,28 +1,47 @@ - + {/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..198d88196b 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 = { @@ -23,11 +24,23 @@ export const BudibaseRoles = { } export const BudibaseRoleOptions = [ - { label: "App User", value: BudibaseRoles.AppUser }, - { label: "Developer", value: BudibaseRoles.Developer }, + { label: "Member", value: BudibaseRoles.AppUser }, { label: "Admin", value: BudibaseRoles.Admin }, ] +export const BudibaseRoleOptionsNew = [ + { + label: "Admin", + value: "admin", + subtitle: "Has full access to all apps and settings in your account", + }, + { + label: "Member", + value: "appUser", + subtitle: "Can only view apps they have access to", + }, +] + export const BuilderRoleDescriptions = [ { value: BudibaseRoles.AppUser, @@ -62,17 +75,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", @@ -80,6 +82,7 @@ export const Roles = { BASIC: "BASIC", PUBLIC: "PUBLIC", BUILDER: "BUILDER", + CREATOR: "CREATOR", } export const Themes = [ 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/frontend-core/src/utils/roles.js b/packages/frontend-core/src/utils/roles.js index dc9138455d..1ae9d3ac14 100644 --- a/packages/frontend-core/src/utils/roles.js +++ b/packages/frontend-core/src/utils/roles.js @@ -1,20 +1,22 @@ import { Roles } from "../constants" const RolePriorities = { - [Roles.ADMIN]: 4, + [Roles.ADMIN]: 5, + [Roles.CREATOR]: 4, [Roles.POWER]: 3, [Roles.BASIC]: 2, [Roles.PUBLIC]: 1, } const RoleColours = { [Roles.ADMIN]: "var(--spectrum-global-color-static-red-400)", + [Roles.CREATOR]: "var(--spectrum-global-color-static-magenta-600)", [Roles.POWER]: "var(--spectrum-global-color-static-orange-400)", [Roles.BASIC]: "var(--spectrum-global-color-static-green-400)", [Roles.PUBLIC]: "var(--spectrum-global-color-static-blue-400)", } -export const getRolePriority = roleId => { - return RolePriorities[roleId] ?? 0 +export const getRolePriority = role => { + return RolePriorities[role] ?? 0 } export const getRoleColour = roleId => { diff --git a/packages/pro b/packages/pro index b7815e099b..140386c7ad 160000 --- a/packages/pro +++ b/packages/pro @@ -1 +1 @@ -Subproject commit b7815e099bbd5e1410185c464dbd54f7287e732f +Subproject commit 140386c7ad7e3d50bd361fc702e49b288c1747c2 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/permission.ts b/packages/server/src/api/controllers/permission.ts index 8314f29398..a9cd686674 100644 --- a/packages/server/src/api/controllers/permission.ts +++ b/packages/server/src/api/controllers/permission.ts @@ -1,5 +1,13 @@ import { permissions, roles, context, HTTPError } from "@budibase/backend-core" -import { UserCtx, Database, Role, PermissionLevel } from "@budibase/types" +import { + UserCtx, + Database, + Role, + PermissionLevel, + GetResourcePermsResponse, + ResourcePermissionInfo, + GetDependantResourcesResponse, +} from "@budibase/types" import { getRoleParams } from "../../db/utils" import { CURRENTLY_SUPPORTED_LEVELS, @@ -145,33 +153,40 @@ export async function fetch(ctx: UserCtx) { ctx.body = finalPermissions } -export async function getResourcePerms(ctx: UserCtx) { +export async function getResourcePerms( + ctx: UserCtx +) { const resourceId = ctx.params.resourceId - const db = context.getAppDB() - const body = await db.allDocs( - getRoleParams(null, { - include_docs: true, - }) - ) - const rolesList = body.rows.map(row => row.doc) - let permissions: Record = {} - for (let level of SUPPORTED_LEVELS) { - // update the various roleIds in the resource permissions - for (let role of rolesList) { - const rolePerms = roles.checkForRoleResourceArray( - role.permissions, - resourceId - ) - if ( - rolePerms && - rolePerms[resourceId] && - rolePerms[resourceId].indexOf(level) !== -1 - ) { - permissions[level] = roles.getExternalRoleID(role._id, role.version)! - } - } + const resourcePermissions = await sdk.permissions.getResourcePerms(resourceId) + const inheritablePermissions = + await sdk.permissions.getInheritablePermissions(resourceId) + + ctx.body = { + permissions: Object.entries(resourcePermissions).reduce( + (p, [level, role]) => { + p[level] = { + role: role.role, + permissionType: role.type, + inheritablePermission: + inheritablePermissions && inheritablePermissions[level].role, + } + return p + }, + {} as Record + ), + requiresPlanToModify: ( + await sdk.permissions.allowsExplicitPermissions(resourceId) + ).minPlan, + } +} + +export async function getDependantResources( + ctx: UserCtx +) { + const resourceId = ctx.params.resourceId + ctx.body = { + resourceByType: await sdk.permissions.getDependantResources(resourceId), } - ctx.body = Object.assign(getBasePermissions(resourceId), permissions) } export async function addPermission(ctx: UserCtx) { diff --git a/packages/server/src/api/controllers/row/index.ts b/packages/server/src/api/controllers/row/index.ts index c4e2b9baca..88d3f50dbe 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/permission.ts b/packages/server/src/api/routes/permission.ts index 7f82d34052..119853e066 100644 --- a/packages/server/src/api/routes/permission.ts +++ b/packages/server/src/api/routes/permission.ts @@ -23,6 +23,11 @@ router authorized(permissions.BUILDER), controller.getResourcePerms ) + .get( + "/api/permission/:resourceId/dependants", + authorized(permissions.BUILDER), + controller.getDependantResources + ) // adding a specific role/level for the resource overrides the underlying access control .post( "/api/permission/:roleId/:resourceId/:level", diff --git a/packages/server/src/api/routes/row.ts b/packages/server/src/api/routes/row.ts index a4ac8aa3ee..c29cb65eac 100644 --- a/packages/server/src/api/routes/row.ts +++ b/packages/server/src/api/routes/row.ts @@ -1,10 +1,11 @@ import Router from "@koa/router" import * as rowController from "../controllers/row" -import authorized from "../../middleware/authorized" +import authorized, { authorizedResource } from "../../middleware/authorized" import { paramResource, paramSubResource } from "../../middleware/resourceId" import { permissions } from "@budibase/backend-core" import { internalSearchValidator } from "./utils/validators" import trimViewRowInfo from "../../middleware/trimViewRowInfo" + const { PermissionType, PermissionLevel } = permissions const router: Router = new Router() @@ -269,7 +270,7 @@ router router.post( "/api/v2/views/:viewId/search", - authorized(PermissionType.TABLE, PermissionLevel.READ), + authorizedResource(PermissionType.VIEW, PermissionLevel.READ, "viewId"), rowController.views.searchView ) diff --git a/packages/server/src/api/routes/tests/permissions.spec.ts b/packages/server/src/api/routes/tests/permissions.spec.ts index 118d35f8fd..129bc00b44 100644 --- a/packages/server/src/api/routes/tests/permissions.spec.ts +++ b/packages/server/src/api/routes/tests/permissions.spec.ts @@ -1,5 +1,6 @@ const mockedSdk = sdk.permissions as jest.Mocked jest.mock("../../../sdk/app/permissions", () => ({ + ...jest.requireActual("../../../sdk/app/permissions"), resourceActionAllowed: jest.fn(), })) @@ -12,8 +13,10 @@ import { PermissionLevel, Row, Table, + ViewV2, } from "@budibase/types" import * as setup from "./utilities" +import { mocks } from "@budibase/backend-core/tests" const { basicRow } = setup.structures const { BUILTIN_ROLE_IDS } = roles @@ -27,6 +30,7 @@ describe("/permission", () => { let table: Table & { _id: string } let perms: Document[] let row: Row + let view: ViewV2 afterAll(setup.afterAll) @@ -35,10 +39,12 @@ describe("/permission", () => { }) beforeEach(async () => { + mocks.licenses.useCloudFree() mockedSdk.resourceActionAllowed.mockResolvedValue({ allowed: true }) table = (await config.createTable()) as typeof table row = await config.createRow() + view = await config.api.viewV2.create({ tableId: table._id }) perms = await config.api.permission.set({ roleId: STD_ROLE_ID, resourceId: table._id, @@ -73,8 +79,12 @@ describe("/permission", () => { .set(config.defaultHeaders()) .expect("Content-Type", /json/) .expect(200) - expect(res.body["read"]).toEqual(STD_ROLE_ID) - expect(res.body["write"]).toEqual(HIGHER_ROLE_ID) + expect(res.body).toEqual({ + permissions: { + read: { permissionType: "EXPLICIT", role: STD_ROLE_ID }, + write: { permissionType: "BASE", role: HIGHER_ROLE_ID }, + }, + }) }) it("should get resource permissions with multiple roles", async () => { @@ -84,15 +94,20 @@ describe("/permission", () => { level: PermissionLevel.WRITE, }) const res = await config.api.permission.get(table._id) - expect(res.body["read"]).toEqual(STD_ROLE_ID) - expect(res.body["write"]).toEqual(HIGHER_ROLE_ID) + expect(res.body).toEqual({ + permissions: { + read: { permissionType: "EXPLICIT", role: STD_ROLE_ID }, + write: { permissionType: "EXPLICIT", role: HIGHER_ROLE_ID }, + }, + }) + const allRes = await request .get(`/api/permission`) .set(config.defaultHeaders()) .expect("Content-Type", /json/) .expect(200) - expect(allRes.body[table._id]["write"]).toEqual(HIGHER_ROLE_ID) expect(allRes.body[table._id]["read"]).toEqual(STD_ROLE_ID) + expect(allRes.body[table._id]["write"]).toEqual(HIGHER_ROLE_ID) }) it("throw forbidden if the action is not allowed for the resource", async () => { @@ -162,6 +177,72 @@ describe("/permission", () => { expect(res.body[0]._id).toEqual(row._id) }) + it("should be able to access the view data when the table is set to public and with no view permissions overrides", async () => { + // replicate changes before checking permissions + await config.publish() + + const res = await config.api.viewV2.search(view.id, undefined, { + usePublicUser: true, + }) + expect(res.body.rows[0]._id).toEqual(row._id) + }) + + it("should not be able to access the view data when the table is not public and there are no view permissions overrides", async () => { + await config.api.permission.revoke({ + roleId: STD_ROLE_ID, + resourceId: table._id, + level: PermissionLevel.READ, + }) + // replicate changes before checking permissions + await config.publish() + + await config.api.viewV2.search(view.id, undefined, { + expectStatus: 403, + usePublicUser: true, + }) + }) + + it("should ignore the view permissions if the flag is not on", async () => { + await config.api.permission.set({ + roleId: STD_ROLE_ID, + resourceId: view.id, + level: PermissionLevel.READ, + }) + await config.api.permission.revoke({ + roleId: STD_ROLE_ID, + resourceId: table._id, + level: PermissionLevel.READ, + }) + // replicate changes before checking permissions + await config.publish() + + await config.api.viewV2.search(view.id, undefined, { + expectStatus: 403, + usePublicUser: true, + }) + }) + + it("should use the view permissions if the flag is on", async () => { + mocks.licenses.useViewPermissions() + await config.api.permission.set({ + roleId: STD_ROLE_ID, + resourceId: view.id, + level: PermissionLevel.READ, + }) + await config.api.permission.revoke({ + roleId: STD_ROLE_ID, + resourceId: table._id, + level: PermissionLevel.READ, + }) + // replicate changes before checking permissions + await config.publish() + + const res = await config.api.viewV2.search(view.id, undefined, { + usePublicUser: true, + }) + expect(res.body.rows[0]._id).toEqual(row._id) + }) + it("shouldn't allow writing from a public user", async () => { const res = await request .post(`/api/${table._id}/rows`) @@ -189,4 +270,21 @@ describe("/permission", () => { expect(publicPerm.name).toBeDefined() }) }) + + describe("default permissions", () => { + it("legacy views", async () => { + const legacyView = await config.createLegacyView() + + const res = await config.api.permission.get(legacyView.name) + + expect(res.body).toEqual({ + permissions: { + read: { + permissionType: "BASE", + role: "BASIC", + }, + }, + }) + }) + }) }) diff --git a/packages/server/src/api/routes/tests/row.spec.ts b/packages/server/src/api/routes/tests/row.spec.ts index 8d4c9a91fd..f63be2318f 100644 --- a/packages/server/src/api/routes/tests/row.spec.ts +++ b/packages/server/src/api/routes/tests/row.spec.ts @@ -1,28 +1,31 @@ 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 { context, roles, tenancy } from "@budibase/backend-core" import { quotas } from "@budibase/pro" import { - QuotaUsageType, - StaticQuotaName, - MonthlyQuotaName, - Row, - Table, FieldType, - SortType, + MonthlyQuotaName, + PermissionLevel, + QuotaUsageType, + Row, SortOrder, + SortType, + StaticQuotaName, + Table, } from "@budibase/types" import { expectAnyInternalColsAttributes, generator, + mocks, 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() @@ -36,6 +39,7 @@ describe("/rows", () => { }) beforeEach(async () => { + mocks.licenses.useCloudFree() table = await config.createTable() row = basicRow(table._id!) }) @@ -392,6 +396,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() @@ -626,7 +673,7 @@ describe("/rows", () => { }) it("should be able to run on a view", async () => { - const view = await config.createView() + const view = await config.createLegacyView() const row = await config.createRow() const rowUsage = await getRowUsage() const queryUsage = await getQueryUsage() @@ -941,6 +988,7 @@ describe("/rows", () => { }) describe("view search", () => { + const viewSchema = { age: { visible: true }, name: { visible: true } } function userTable(): Table { return { name: "user", @@ -997,6 +1045,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 +1146,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 +1181,7 @@ describe("/rows", () => { order: SortOrder.ASCENDING, type: SortType.STRING, }, + schema: viewSchema, }) const response = await config.api.viewV2.search( @@ -1139,6 +1190,7 @@ describe("/rows", () => { sort: sortParams.field, sortOrder: sortParams.order, sortType: sortParams.type, + query: {}, } ) @@ -1163,7 +1215,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 +1232,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 +1251,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 +1274,7 @@ describe("/rows", () => { { paginate: true, limit: 4, + query: {}, } ) expect(firstPageResponse.body).toEqual({ @@ -1236,6 +1290,8 @@ describe("/rows", () => { paginate: true, limit: 4, bookmark: firstPageResponse.body.bookmark, + + query: {}, } ) expect(secondPageResponse.body).toEqual({ @@ -1251,6 +1307,7 @@ describe("/rows", () => { paginate: true, limit: 4, bookmark: secondPageResponse.body.bookmark, + query: {}, } ) expect(lastPageResponse.body).toEqual({ @@ -1260,6 +1317,85 @@ describe("/rows", () => { bookmark: expect.any(String), }) }) + + describe("permissions", () => { + let viewId: string + let tableId: string + + beforeAll(async () => { + const table = await config.createTable(userTable()) + const rows = [] + for (let i = 0; i < 10; i++) { + rows.push(await config.createRow({ tableId: table._id })) + } + + const createViewResponse = await config.api.viewV2.create() + + tableId = table._id! + viewId = createViewResponse.id + }) + + beforeEach(() => { + mocks.licenses.useViewPermissions() + }) + + it("does not allow public users to fetch by default", async () => { + await config.publish() + await config.api.viewV2.search(viewId, undefined, { + expectStatus: 403, + usePublicUser: true, + }) + }) + + it("allow public users to fetch when permissions are explicit", async () => { + await config.api.permission.set({ + roleId: roles.BUILTIN_ROLE_IDS.PUBLIC, + level: PermissionLevel.READ, + resourceId: viewId, + }) + await config.publish() + + const response = await config.api.viewV2.search(viewId, undefined, { + usePublicUser: true, + }) + + expect(response.body.rows).toHaveLength(10) + }) + + it("allow public users to fetch when permissions are inherited", async () => { + await config.api.permission.set({ + roleId: roles.BUILTIN_ROLE_IDS.PUBLIC, + level: PermissionLevel.READ, + resourceId: tableId, + }) + await config.publish() + + const response = await config.api.viewV2.search(viewId, undefined, { + usePublicUser: true, + }) + + expect(response.body.rows).toHaveLength(10) + }) + + it("respects inherited permissions, not allowing not public views from public tables", async () => { + await config.api.permission.set({ + roleId: roles.BUILTIN_ROLE_IDS.PUBLIC, + level: PermissionLevel.READ, + resourceId: tableId, + }) + await config.api.permission.set({ + roleId: roles.BUILTIN_ROLE_IDS.POWER, + level: PermissionLevel.READ, + resourceId: viewId, + }) + await config.publish() + + await config.api.viewV2.search(viewId, undefined, { + usePublicUser: true, + expectStatus: 403, + }) + }) + }) }) }) }) diff --git a/packages/server/src/api/routes/tests/table.spec.ts b/packages/server/src/api/routes/tests/table.spec.ts index 04911e5505..9914e6d66f 100644 --- a/packages/server/src/api/routes/tests/table.spec.ts +++ b/packages/server/src/api/routes/tests/table.spec.ts @@ -87,7 +87,7 @@ describe("/tables", () => { it("updates all the row fields for a table when a schema key is renamed", async () => { const testTable = await config.createTable() - await config.createView({ + await config.createLegacyView({ name: "TestView", field: "Price", calculation: "stats", @@ -254,7 +254,7 @@ describe("/tables", () => { })) await config.api.viewV2.create({ tableId }) - await config.createView({ tableId, name: generator.guid() }) + await config.createLegacyView({ tableId, name: generator.guid() }) const res = await config.api.table.fetch() diff --git a/packages/server/src/api/routes/tests/view.spec.js b/packages/server/src/api/routes/tests/view.spec.js index df07ffa4af..e633279058 100644 --- a/packages/server/src/api/routes/tests/view.spec.js +++ b/packages/server/src/api/routes/tests/view.spec.js @@ -249,7 +249,7 @@ describe("/views", () => { }) it("returns only custom views", async () => { - await config.createView({ + await config.createLegacyView({ name: "TestView", field: "Price", calculation: "stats", @@ -267,7 +267,7 @@ describe("/views", () => { describe("query", () => { it("returns data for the created view", async () => { - await config.createView({ + await config.createLegacyView({ name: "TestView", field: "Price", calculation: "stats", @@ -295,7 +295,7 @@ describe("/views", () => { }) it("returns data for the created view using a group by", async () => { - await config.createView({ + await config.createLegacyView({ calculation: "stats", name: "TestView", field: "Price", @@ -331,7 +331,7 @@ describe("/views", () => { describe("destroy", () => { it("should be able to delete a view", async () => { const table = await config.createTable(priceTable()) - const view = await config.createView() + const view = await config.createLegacyView() const res = await request .delete(`/api/views/${view.name}`) .set(config.defaultHeaders()) @@ -395,7 +395,7 @@ describe("/views", () => { it("should be able to export a view as JSON", async () => { let table = await setupExport() - const view = await config.createView() + const view = await config.createLegacyView() table = await config.getTable(table._id) let res = await exportView(view.name, "json") @@ -407,7 +407,7 @@ describe("/views", () => { it("should be able to export a view as CSV", async () => { let table = await setupExport() - const view = await config.createView() + const view = await config.createLegacyView() table = await config.getTable(table._id) let res = await exportView(view.name, "csv") diff --git a/packages/server/src/api/routes/tests/viewV2.spec.ts b/packages/server/src/api/routes/tests/viewV2.spec.ts index e30bc2c0b1..5e8ae09e55 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, @@ -311,7 +296,7 @@ describe("/v2/views", () => { }) it("cannot update views v1", async () => { - const viewV1 = await config.createView() + const viewV1 = await config.createLegacyView() await config.api.viewV2.update( { ...viewV1, @@ -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..545d3016a3 100644 --- a/packages/server/src/api/routes/view.ts +++ b/packages/server/src/api/routes/view.ts @@ -1,13 +1,22 @@ import Router from "@koa/router" import * as viewController from "../controllers/view" import * as rowController from "../controllers/row" -import authorized from "../../middleware/authorized" +import authorized, { authorizedResource } from "../../middleware/authorized" import { paramResource } from "../../middleware/resourceId" import { permissions } from "@budibase/backend-core" const router: Router = new Router() router + .get( + "/api/v2/views/:viewId", + authorizedResource( + permissions.PermissionType.VIEW, + permissions.PermissionLevel.READ, + "viewId" + ), + viewController.v2.get + ) .post( "/api/v2/views", authorized(permissions.BUILDER), diff --git a/packages/server/src/middleware/authorized.ts b/packages/server/src/middleware/authorized.ts index 915344f747..b2ffeacaf8 100644 --- a/packages/server/src/middleware/authorized.ts +++ b/packages/server/src/middleware/authorized.ts @@ -8,6 +8,8 @@ import { import { PermissionLevel, PermissionType, Role, UserCtx } from "@budibase/types" import builderMiddleware from "./builder" import { isWebhookEndpoint } from "./utils" +import { paramResource } from "./resourceId" +import sdk from "../sdk" function hasResource(ctx: any) { return ctx.resourceId != null @@ -74,10 +76,12 @@ const checkAuthorizedResource = async ( } } -export default ( +const authorized = + ( permType: PermissionType, permLevel?: PermissionLevel, - opts = { schema: false } + opts = { schema: false }, + resourcePath?: string ) => async (ctx: any, next: any) => { // webhooks don't need authentication, each webhook unique @@ -91,17 +95,40 @@ export default ( } // get the resource roles - let resourceRoles: any = [] - let otherLevelRoles: any = [] + let resourceRoles: string[] = [] + let otherLevelRoles: string[] = [] const otherLevel = permLevel === PermissionLevel.READ ? PermissionLevel.WRITE : PermissionLevel.READ - const appId = context.getAppId() - if (appId && hasResource(ctx)) { - resourceRoles = await roles.getRequiredResourceRole(permLevel!, ctx) + + if (resourcePath) { + // Reusing the existing middleware to extract the value + paramResource(resourcePath)(ctx, () => {}) + } + + if (hasResource(ctx)) { + const { resourceId, subResourceId } = ctx + + const permissions = await sdk.permissions.getResourcePerms(resourceId) + const subPermissions = + !!subResourceId && + (await sdk.permissions.getResourcePerms(subResourceId)) + + function getPermLevel(permLevel: string) { + let result: string[] = [] + if (permissions[permLevel]) { + result.push(permissions[permLevel].role) + } + if (subPermissions && subPermissions[permLevel]) { + result.push(subPermissions[permLevel].role) + } + return result + } + + resourceRoles = getPermLevel(permLevel!) if (opts && opts.schema) { - otherLevelRoles = await roles.getRequiredResourceRole(otherLevel, ctx) + otherLevelRoles = getPermLevel(otherLevel!) } } @@ -143,3 +170,17 @@ export default ( // csrf protection return csrf(ctx, next) } + +export default ( + permType: PermissionType, + permLevel?: PermissionLevel, + opts = { schema: false } +) => authorized(permType, permLevel, opts) + +export const authorizedResource = ( + permType: PermissionType, + permLevel: PermissionLevel, + resourcePath: string +) => { + return authorized(permType, permLevel, undefined, resourcePath) +} diff --git a/packages/server/src/middleware/resourceId.ts b/packages/server/src/middleware/resourceId.ts index 0917941061..1ad0b2a0c1 100644 --- a/packages/server/src/middleware/resourceId.ts +++ b/packages/server/src/middleware/resourceId.ts @@ -43,6 +43,7 @@ export class ResourceIdGetter { } } +/** @deprecated we should use the authorizedResource middleware instead */ export function paramResource(main: string) { return new ResourceIdGetter("params").mainResource(main).build() } diff --git a/packages/server/src/middleware/tests/authorized.spec.js b/packages/server/src/middleware/tests/authorized.spec.js deleted file mode 100644 index 3adc4d99a1..0000000000 --- a/packages/server/src/middleware/tests/authorized.spec.js +++ /dev/null @@ -1,163 +0,0 @@ -jest.mock("../../environment", () => ({ - prod: false, - isTest: () => true, - isProd: () => this.prod, - _set: function(key, value) { - this.prod = value === "production" - } - }) -) -const authorizedMiddleware = require("../authorized").default -const env = require("../../environment") -const { PermissionType, PermissionLevel } = require("@budibase/types") - -const APP_ID = "" - -class TestConfiguration { - constructor(role) { - this.middleware = authorizedMiddleware(role) - this.next = jest.fn() - this.throw = jest.fn() - this.headers = {} - this.ctx = { - headers: {}, - request: { - url: "" - }, - appId: APP_ID, - auth: {}, - next: this.next, - throw: this.throw, - get: (name) => this.headers[name], - } - } - - executeMiddleware() { - return this.middleware(this.ctx, this.next) - } - - setUser(user) { - this.ctx.user = user - } - - setMiddlewareRequiredPermission(...perms) { - this.middleware = authorizedMiddleware(...perms) - } - - setResourceId(id) { - this.ctx.resourceId = id - } - - setAuthenticated(isAuthed) { - this.ctx.isAuthenticated = isAuthed - } - - setRequestUrl(url) { - this.ctx.request.url = url - } - - setEnvironment(isProd) { - env._set("NODE_ENV", isProd ? "production" : "jest") - } - - setRequestHeaders(headers) { - this.ctx.headers = headers - } - - afterEach() { - jest.clearAllMocks() - } -} - - -describe("Authorization middleware", () => { - const next = jest.fn() - let config - - afterEach(() => { - config.afterEach() - }) - - beforeEach(() => { - config = new TestConfiguration() - }) - - describe("non-webhook call", () => { - let config - - beforeEach(() => { - config = new TestConfiguration() - config.setEnvironment(true) - config.setAuthenticated(true) - }) - - it("throws when no user data is present in context", async () => { - await config.executeMiddleware() - - expect(config.throw).toHaveBeenCalledWith(403, "No user info found") - }) - - it("passes on to next() middleware if user is an admin", async () => { - config.setUser({ - _id: "user", - role: { - _id: "ADMIN", - } - }) - - await config.executeMiddleware() - - expect(config.next).toHaveBeenCalled() - }) - - it("throws if the user does not have builder permissions", async () => { - config.setEnvironment(false) - config.setMiddlewareRequiredPermission(PermissionType.BUILDER) - config.setUser({ - role: { - _id: "" - } - }) - await config.executeMiddleware() - - expect(config.throw).toHaveBeenCalledWith(403, "Not Authorized") - }) - - it("passes on to next() middleware if the user has resource permission", async () => { - config.setResourceId(PermissionType.QUERY) - config.setUser({ - role: { - _id: "" - } - }) - config.setMiddlewareRequiredPermission(PermissionType.QUERY) - - await config.executeMiddleware() - expect(config.next).toHaveBeenCalled() - }) - - it("throws if the user session is not authenticated", async () => { - config.setUser({ - role: { - _id: "" - }, - }) - config.setAuthenticated(false) - - await config.executeMiddleware() - expect(config.throw).toHaveBeenCalledWith(403, "Session not authenticated") - }) - - it("throws if the user does not have base permissions to perform the operation", async () => { - config.setUser({ - role: { - _id: "" - }, - }) - config.setMiddlewareRequiredPermission(PermissionType.ADMIN, PermissionLevel.BASIC) - - await config.executeMiddleware() - expect(config.throw).toHaveBeenCalledWith(403, "User does not have permission") - }) - }) -}) diff --git a/packages/server/src/middleware/tests/authorized.spec.ts b/packages/server/src/middleware/tests/authorized.spec.ts new file mode 100644 index 0000000000..0741f50e4d --- /dev/null +++ b/packages/server/src/middleware/tests/authorized.spec.ts @@ -0,0 +1,224 @@ +jest.mock("../../sdk/app/permissions", () => ({ + ...jest.requireActual("../../sdk/app/permissions"), + getResourcePerms: jest.fn().mockResolvedValue([]), +})) + +import { + PermissionType, + PermissionLevel, + PermissionSource, +} from "@budibase/types" + +import authorizedMiddleware from "../authorized" +import env from "../../environment" +import { generateTableID, generateViewID } from "../../db/utils" +import { generator, mocks } from "@budibase/backend-core/tests" +import { initProMocks } from "../../tests/utilities/mocks/pro" +import { getResourcePerms } from "../../sdk/app/permissions" + +const APP_ID = "" + +initProMocks() + +class TestConfiguration { + middleware: (ctx: any, next: any) => Promise + next: () => void + throw: () => void + headers: Record + ctx: any + + constructor() { + this.middleware = authorizedMiddleware(PermissionType.APP) + this.next = jest.fn() + this.throw = jest.fn() + this.headers = {} + this.ctx = { + headers: {}, + request: { + url: "", + }, + appId: APP_ID, + auth: {}, + next: this.next, + throw: this.throw, + get: (name: string) => this.headers[name], + } + } + + executeMiddleware() { + return this.middleware(this.ctx, this.next) + } + + setUser(user: any) { + this.ctx.user = user + } + + setMiddlewareRequiredPermission(...perms: any[]) { + // @ts-ignore + this.middleware = authorizedMiddleware(...perms) + } + + setResourceId(id?: string) { + this.ctx.resourceId = id + } + + setAuthenticated(isAuthed: boolean) { + this.ctx.isAuthenticated = isAuthed + } + + setRequestUrl(url: string) { + this.ctx.request.url = url + } + + setEnvironment(isProd: boolean) { + env._set("NODE_ENV", isProd ? "production" : "jest") + } + + setRequestHeaders(headers: Record) { + this.ctx.headers = headers + } + + afterEach() { + jest.clearAllMocks() + } +} + +describe("Authorization middleware", () => { + let config: TestConfiguration + + afterEach(() => { + config.afterEach() + }) + + beforeEach(() => { + jest.clearAllMocks() + mocks.licenses.useCloudFree() + config = new TestConfiguration() + }) + + describe("non-webhook call", () => { + beforeEach(() => { + config = new TestConfiguration() + config.setEnvironment(true) + config.setAuthenticated(true) + }) + + it("throws when no user data is present in context", async () => { + await config.executeMiddleware() + + expect(config.throw).toHaveBeenCalledWith(403, "No user info found") + }) + + it("passes on to next() middleware if user is an admin", async () => { + config.setUser({ + _id: "user", + role: { + _id: "ADMIN", + }, + }) + + await config.executeMiddleware() + + expect(config.next).toHaveBeenCalled() + }) + + it("throws if the user does not have builder permissions", async () => { + config.setEnvironment(false) + config.setMiddlewareRequiredPermission(PermissionType.BUILDER) + config.setUser({ + role: { + _id: "", + }, + }) + await config.executeMiddleware() + + expect(config.throw).toHaveBeenCalledWith(403, "Not Authorized") + }) + + it("passes on to next() middleware if the user has resource permission", async () => { + config.setResourceId(PermissionType.QUERY) + config.setUser({ + role: { + _id: "", + }, + }) + config.setMiddlewareRequiredPermission(PermissionType.QUERY) + + await config.executeMiddleware() + expect(config.next).toHaveBeenCalled() + }) + + it("throws if the user session is not authenticated", async () => { + config.setUser({ + role: { + _id: "", + }, + }) + config.setAuthenticated(false) + + await config.executeMiddleware() + expect(config.throw).toHaveBeenCalledWith( + 403, + "Session not authenticated" + ) + }) + + it("throws if the user does not have base permissions to perform the operation", async () => { + config.setUser({ + role: { + _id: "", + }, + }) + config.setMiddlewareRequiredPermission( + PermissionType.APP, + PermissionLevel.READ + ) + + await config.executeMiddleware() + expect(config.throw).toHaveBeenCalledWith( + 403, + "User does not have permission" + ) + }) + + describe("with resource", () => { + let resourceId: string + const mockedGetResourcePerms = getResourcePerms as jest.MockedFunction< + typeof getResourcePerms + > + + beforeEach(() => { + config.setMiddlewareRequiredPermission( + PermissionType.VIEW, + PermissionLevel.READ + ) + resourceId = generator.guid() + config.setResourceId(resourceId) + + mockedGetResourcePerms.mockResolvedValue({ + [PermissionLevel.READ]: { + role: "PUBLIC", + type: PermissionSource.BASE, + }, + }) + + config.setUser({ + _id: "user", + role: { + _id: "PUBLIC", + }, + }) + }) + + it("will fetch resource permissions when resource is set", async () => { + await config.executeMiddleware() + + expect(config.throw).not.toBeCalled() + expect(config.next).toHaveBeenCalled() + + expect(mockedGetResourcePerms).toBeCalledTimes(1) + expect(mockedGetResourcePerms).toBeCalledWith(resourceId) + }) + }) + }) +}) 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/migrations/tests/index.spec.ts b/packages/server/src/migrations/tests/index.spec.ts index 2465c930b4..b64cad26c1 100644 --- a/packages/server/src/migrations/tests/index.spec.ts +++ b/packages/server/src/migrations/tests/index.spec.ts @@ -50,9 +50,9 @@ describe("migrations", () => { await config.createRole() await config.createRole() await config.createTable() - await config.createView() + await config.createLegacyView() await config.createTable() - await config.createView(structures.view(config.table!._id!)) + await config.createLegacyView(structures.view(config.table!._id!)) await config.createScreen() await config.createScreen() diff --git a/packages/server/src/sdk/app/backups/exports.ts b/packages/server/src/sdk/app/backups/exports.ts index 306918cf80..307cdf4015 100644 --- a/packages/server/src/sdk/app/backups/exports.ts +++ b/packages/server/src/sdk/app/backups/exports.ts @@ -60,7 +60,7 @@ function tarFilesToTmp(tmpDir: string, files: string[]) { export async function exportDB( dbName: string, opts: DBDumpOpts = {} -): Promise { +): Promise { const exportOpts = { filter: opts?.filter, batch_size: 1000, diff --git a/packages/server/src/sdk/app/permissions/index.ts b/packages/server/src/sdk/app/permissions/index.ts index 2219120db6..53f6756ae1 100644 --- a/packages/server/src/sdk/app/permissions/index.ts +++ b/packages/server/src/sdk/app/permissions/index.ts @@ -1,10 +1,24 @@ +import { context, db, env, roles } from "@budibase/backend-core" +import { features } from "@budibase/pro" import { DocumentType, PermissionLevel, + PermissionSource, + PlanType, + Role, VirtualDocumentType, } from "@budibase/types" -import { isViewID } from "../../../db/utils" -import { features } from "@budibase/pro" +import { + extractViewInfoFromID, + getRoleParams, + isViewID, +} from "../../../db/utils" +import { + CURRENTLY_SUPPORTED_LEVELS, + getBasePermissions, +} from "../../../utilities/security" +import sdk from "../../../sdk" +import { isV2 } from "../views" type ResourceActionAllowedResult = | { allowed: true } @@ -35,3 +49,117 @@ export async function resourceActionAllowed({ resourceType: VirtualDocumentType.VIEW, } } + +type ResourcePermissions = Record< + string, + { role: string; type: PermissionSource } +> + +export async function getInheritablePermissions( + resourceId: string +): Promise { + if (isViewID(resourceId)) { + return await getResourcePerms(extractViewInfoFromID(resourceId).tableId) + } +} + +export async function allowsExplicitPermissions(resourceId: string) { + if (isViewID(resourceId)) { + const allowed = await features.isViewPermissionEnabled() + const minPlan = !allowed + ? env.SELF_HOSTED + ? PlanType.BUSINESS + : PlanType.PREMIUM + : undefined + + return { + allowed, + minPlan, + } + } + + return { allowed: true } +} + +export async function getResourcePerms( + resourceId: string +): Promise { + const db = context.getAppDB() + const body = await db.allDocs( + getRoleParams(null, { + include_docs: true, + }) + ) + const rolesList = body.rows.map(row => row.doc) + let permissions: ResourcePermissions = {} + + const permsToInherit = await getInheritablePermissions(resourceId) + + const allowsExplicitPerm = (await allowsExplicitPermissions(resourceId)) + .allowed + + for (let level of CURRENTLY_SUPPORTED_LEVELS) { + // update the various roleIds in the resource permissions + for (let role of rolesList) { + const rolePerms = allowsExplicitPerm + ? roles.checkForRoleResourceArray(role.permissions, resourceId) + : {} + if (rolePerms[resourceId]?.indexOf(level) > -1) { + permissions[level] = { + role: roles.getExternalRoleID(role._id!, role.version), + type: PermissionSource.EXPLICIT, + } + } else if ( + !permissions[level] && + permsToInherit && + permsToInherit[level] + ) { + permissions[level] = { + role: permsToInherit[level].role, + type: PermissionSource.INHERITED, + } + } + } + } + + const basePermissions = Object.entries( + getBasePermissions(resourceId) + ).reduce((p, [level, role]) => { + p[level] = { role, type: PermissionSource.BASE } + return p + }, {}) + const result = Object.assign(basePermissions, permissions) + return result +} + +export async function getDependantResources( + resourceId: string +): Promise | undefined> { + if (db.isTableId(resourceId)) { + const dependants: Record> = {} + + const table = await sdk.tables.getTable(resourceId) + const views = Object.values(table.views || {}) + + for (const view of views) { + if (!isV2(view)) { + continue + } + + const permissions = await getResourcePerms(view.id) + for (const [level, roleInfo] of Object.entries(permissions)) { + if (roleInfo.type === PermissionSource.INHERITED) { + dependants[VirtualDocumentType.VIEW] ??= new Set() + dependants[VirtualDocumentType.VIEW].add(view.id) + } + } + } + + return Object.entries(dependants).reduce((p, [type, resources]) => { + p[type] = resources.size + return p + }, {} as Record) + } + + return +} diff --git a/packages/server/src/sdk/app/permissions/tests/permissions.spec.ts b/packages/server/src/sdk/app/permissions/tests/permissions.spec.ts index 4c2768dde4..4c233e68fa 100644 --- a/packages/server/src/sdk/app/permissions/tests/permissions.spec.ts +++ b/packages/server/src/sdk/app/permissions/tests/permissions.spec.ts @@ -1,12 +1,13 @@ -import TestConfiguration from "../../../../tests/utilities/TestConfiguration" import { PermissionLevel } from "@budibase/types" import { mocks, structures } from "@budibase/backend-core/tests" import { resourceActionAllowed } from ".." import { generateViewID } from "../../../../db/utils" +import { initProMocks } from "../../../../tests/utilities/mocks/pro" + +initProMocks() describe("permissions sdk", () => { beforeEach(() => { - new TestConfiguration() mocks.licenses.useCloudFree() }) 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/TestConfiguration.ts b/packages/server/src/tests/utilities/TestConfiguration.ts index c1db54fe60..81bfa0abbd 100644 --- a/packages/server/src/tests/utilities/TestConfiguration.ts +++ b/packages/server/src/tests/utilities/TestConfiguration.ts @@ -622,7 +622,7 @@ class TestConfiguration { // VIEW - async createView(config?: any) { + async createLegacyView(config?: any) { if (!this.table) { throw "Test requires table to be configured." } diff --git a/packages/server/src/tests/utilities/api/viewV2.ts b/packages/server/src/tests/utilities/api/viewV2.ts index 1520154641..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(), @@ -77,12 +78,16 @@ export class ViewV2API extends TestAPI { search = async ( viewId: string, params?: SearchViewRowRequest, - { expectStatus } = { expectStatus: 200 } + { expectStatus = 200, usePublicUser = false } = {} ) => { return this.request .post(`/api/v2/views/${viewId}/search`) .send(params) - .set(this.config.defaultHeaders()) + .set( + usePublicUser + ? this.config.publicHeaders() + : this.config.defaultHeaders() + ) .expect("Content-Type", /json/) .expect(expectStatus) } diff --git a/packages/server/src/tests/utilities/mocks/pro.ts b/packages/server/src/tests/utilities/mocks/pro.ts new file mode 100644 index 0000000000..a365ff40c4 --- /dev/null +++ b/packages/server/src/tests/utilities/mocks/pro.ts @@ -0,0 +1,10 @@ +// init the licensing mock +import { mocks } from "@budibase/backend-core/tests" +import * as pro from "@budibase/pro" + +export const initProMocks = () => { + mocks.licenses.init(pro) + + // use unlimited license by default + mocks.licenses.useUnlimited() +} diff --git a/packages/server/src/utilities/security.ts b/packages/server/src/utilities/security.ts index 0da7621773..01a3468c9c 100644 --- a/packages/server/src/utilities/security.ts +++ b/packages/server/src/utilities/security.ts @@ -23,6 +23,9 @@ export function getPermissionType(resourceId: string) { case DocumentType.QUERY: case DocumentType.DATASOURCE: return permissions.PermissionType.QUERY + default: + // legacy views don't have an ID, will end up here + return permissions.PermissionType.LEGACY_VIEW } } 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/shared-core/src/sdk/documents/users.ts b/packages/shared-core/src/sdk/documents/users.ts index 0f0983c6f7..1be8845656 100644 --- a/packages/shared-core/src/sdk/documents/users.ts +++ b/packages/shared-core/src/sdk/documents/users.ts @@ -35,6 +35,13 @@ export function isAdminOrBuilder( return isBuilder(user, appId) || isAdmin(user) } +export function isAdminOrGlobalBuilder( + user: User | ContextUser, + appId?: string +): boolean { + return isGlobalBuilder(user) || isAdmin(user) +} + // check if they are a builder within an app (not necessarily a global builder) export function hasAppBuilderPermissions(user?: User | ContextUser): boolean { if (!user) { diff --git a/packages/types/src/api/web/app/index.ts b/packages/types/src/api/web/app/index.ts index e7b4b87aa9..276d7fa7c1 100644 --- a/packages/types/src/api/web/app/index.ts +++ b/packages/types/src/api/web/app/index.ts @@ -4,3 +4,4 @@ export * from "./row" export * from "./view" export * from "./rows" export * from "./table" +export * from "./permission" diff --git a/packages/types/src/api/web/app/permission.ts b/packages/types/src/api/web/app/permission.ts new file mode 100644 index 0000000000..a8ab0e8084 --- /dev/null +++ b/packages/types/src/api/web/app/permission.ts @@ -0,0 +1,16 @@ +import { PlanType } from "../../../sdk" + +export interface ResourcePermissionInfo { + role: string + permissionType: string + inheritablePermission?: string +} + +export interface GetResourcePermsResponse { + permissions: Record + requiresPlanToModify?: PlanType +} + +export interface GetDependantResourcesResponse { + resourceByType?: Record +} 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 } diff --git a/packages/types/src/sdk/permissions.ts b/packages/types/src/sdk/permissions.ts index 9fe1970e44..c855bbd219 100644 --- a/packages/types/src/sdk/permissions.ts +++ b/packages/types/src/sdk/permissions.ts @@ -15,4 +15,12 @@ export enum PermissionType { BUILDER = "builder", GLOBAL_BUILDER = "globalBuilder", QUERY = "query", + VIEW = "view", + LEGACY_VIEW = "legacy_view", +} + +export enum PermissionSource { + EXPLICIT = "EXPLICIT", + INHERITED = "INHERITED", + BASE = "BASE", } diff --git a/packages/worker/src/api/controllers/global/users.ts b/packages/worker/src/api/controllers/global/users.ts index ad906c8688..0ccf9a356f 100644 --- a/packages/worker/src/api/controllers/global/users.ts +++ b/packages/worker/src/api/controllers/global/users.ts @@ -266,14 +266,17 @@ export const onboardUsers = async (ctx: Ctx) => { // Temp password to be passed to the user. createdPasswords[invite.email] = password - + let builder: { global: boolean; apps?: string[] } = { global: false } + if (invite.userInfo.appBuilders) { + builder.apps = invite.userInfo.appBuilders + } return { email: invite.email, password, forceResetPassword: true, roles: invite.userInfo.apps, admin: { global: false }, - builder: { global: false }, + builder, tenantId: tenancy.getTenantId(), } }) @@ -368,6 +371,15 @@ export const updateInvite = async (ctx: any) => { ...invite, } + if (!updateBody?.appBuilders || !updateBody.appBuilders?.length) { + updated.info.appBuilders = [] + } else { + updated.info.appBuilders = [ + ...(invite.info.appBuilders ?? []), + ...updateBody.appBuilders, + ] + } + if (!updateBody?.apps || !Object.keys(updateBody?.apps).length) { updated.info.apps = [] } else { @@ -392,7 +404,7 @@ export const inviteAccept = async ( // info is an extension of the user object that was stored by global const { email, info }: any = await checkInviteCode(inviteCode) const user = await tenancy.doInTenant(info.tenantId, async () => { - let request = { + let request: any = { firstName, lastName, password, @@ -400,9 +412,14 @@ export const inviteAccept = async ( roles: info.apps, tenantId: info.tenantId, } + let builder: { global: boolean; apps?: string[] } = { global: false } + if (info.appBuilders) { + builder.apps = info.appBuilders + request.builder = builder + delete info.appBuilders + } delete info.apps - request = { ...request, ...info, diff --git a/scripts/link-dependencies.sh b/scripts/link-dependencies.sh index ed42f29b4a..921e3be8f3 100755 --- a/scripts/link-dependencies.sh +++ b/scripts/link-dependencies.sh @@ -54,24 +54,36 @@ if [ -d "../account-portal" ]; then yarn bootstrap cd packages/server - echo "Linking backend-core to account-portal" + echo "Linking backend-core to account-portal (server)" yarn link "@budibase/backend-core" - echo "Linking string-templates to account-portal" + echo "Linking string-templates to account-portal (server)" yarn link "@budibase/string-templates" - echo "Linking types to account-portal" + echo "Linking types to account-portal (server)" yarn link "@budibase/types" + echo "Linking shared-core to account-portal (server)" + yarn link "@budibase/shared-core" + if [ $pro_loaded_locally = true ]; then - echo "Linking pro to account-portal" + echo "Linking pro to account-portal (server)" yarn link "@budibase/pro" fi cd ../ui - echo "Linking bbui to account-portal" + echo "Linking bbui to account-portal (ui)" yarn link "@budibase/bbui" - echo "Linking frontend-core to account-portal" + echo "Linking shared-core to account-portal (ui)" + yarn link "@budibase/shared-core" + + echo "Linking string-templates to account-portal (ui)" + yarn link "@budibase/string-templates" + + echo "Linking types to account-portal (ui)" + yarn link "@budibase/types" + + echo "Linking frontend-core to account-portal (ui)" yarn link "@budibase/frontend-core" fi