diff --git a/lerna.json b/lerna.json index 3396c082bf..bb0302353e 100644 --- a/lerna.json +++ b/lerna.json @@ -1,6 +1,6 @@ { "$schema": "node_modules/lerna/schemas/lerna-schema.json", - "version": "2.31.4", + "version": "2.31.5", "npmClient": "yarn", "packages": [ "packages/*", diff --git a/packages/backend-core/src/docIds/params.ts b/packages/backend-core/src/docIds/params.ts index d9baee3dc6..093724b55e 100644 --- a/packages/backend-core/src/docIds/params.ts +++ b/packages/backend-core/src/docIds/params.ts @@ -71,7 +71,7 @@ export function getQueryIndex(viewName: ViewName) { export const isTableId = (id: string) => { // this includes datasource plus tables return ( - id && + !!id && (id.startsWith(`${DocumentType.TABLE}${SEPARATOR}`) || id.startsWith(`${DocumentType.DATASOURCE_PLUS}${SEPARATOR}`)) ) diff --git a/packages/backend-core/src/features/index.ts b/packages/backend-core/src/features/index.ts index b84237b1f0..49f8044e8f 100644 --- a/packages/backend-core/src/features/index.ts +++ b/packages/backend-core/src/features/index.ts @@ -1,7 +1,7 @@ import env from "../environment" import * as context from "../context" import { PostHog, PostHogOptions } from "posthog-node" -import { IdentityType, UserCtx } from "@budibase/types" +import { FeatureFlag, IdentityType, UserCtx } from "@budibase/types" import tracer from "dd-trace" let posthog: PostHog | undefined @@ -268,4 +268,5 @@ export class FlagSet, T extends { [key: string]: V }> { export const flags = new FlagSet({ DEFAULT_VALUES: Flag.boolean(env.isDev()), SQS: Flag.boolean(env.isDev()), + [FeatureFlag.ENRICHED_RELATIONSHIPS]: Flag.boolean(false), }) diff --git a/packages/builder/src/components/backend/DataTable/ViewV2DataTable.svelte b/packages/builder/src/components/backend/DataTable/ViewV2DataTable.svelte index 646b764a2c..b56c5f6568 100644 --- a/packages/builder/src/components/backend/DataTable/ViewV2DataTable.svelte +++ b/packages/builder/src/components/backend/DataTable/ViewV2DataTable.svelte @@ -6,6 +6,8 @@ import GridCreateEditRowModal from "components/backend/DataTable/modals/grid/GridCreateEditRowModal.svelte" import GridFilterButton from "components/backend/DataTable/buttons/grid/GridFilterButton.svelte" import GridManageAccessButton from "components/backend/DataTable/buttons/grid/GridManageAccessButton.svelte" + import { isEnabled } from "helpers/featureFlags" + import { FeatureFlag } from "@budibase/types" $: id = $viewsV2.selected?.id $: datasource = { @@ -29,6 +31,7 @@ on:updatedatasource={handleGridViewUpdate} isCloud={$admin.cloud} allowViewReadonlyColumns={$licensing.isViewReadonlyColumnsEnabled} + canSetRelationshipSchemas={isEnabled(FeatureFlag.ENRICHED_RELATIONSHIPS)} > diff --git a/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte b/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte index 1d66a0ba00..a956d09ee6 100644 --- a/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte +++ b/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte @@ -84,7 +84,7 @@ } let relationshipOpts1 = Object.values(PrettyRelationshipDefinitions) let relationshipOpts2 = Object.values(PrettyRelationshipDefinitions) - let relationshipMap = { + const relationshipMap = { [RelationshipType.ONE_TO_MANY]: { part1: PrettyRelationshipDefinitions.MANY, part2: PrettyRelationshipDefinitions.ONE, @@ -98,7 +98,7 @@ part2: PrettyRelationshipDefinitions.MANY, }, } - let autoColumnInfo = getAutoColumnInformation() + const autoColumnInfo = getAutoColumnInformation() let optionsValid = true $: rowGoldenSample = RowUtils.generateGoldenSample($rows) diff --git a/packages/frontend-core/src/components/grid/cells/RelationshipCell.svelte b/packages/frontend-core/src/components/grid/cells/RelationshipCell.svelte index 1b3b24ff2e..73c8a99cc2 100644 --- a/packages/frontend-core/src/components/grid/cells/RelationshipCell.svelte +++ b/packages/frontend-core/src/components/grid/cells/RelationshipCell.svelte @@ -29,6 +29,7 @@ let searching = false let container let anchor + let relationshipFields $: fieldValue = parseValue(value) $: oneRowOnly = schema?.relationshipType === "one-to-many" @@ -41,6 +42,26 @@ } } + $: relationFields = fieldValue?.reduce((acc, f) => { + const fields = {} + for (const [column] of Object.entries(schema?.columns || {}).filter( + ([key, column]) => + column.visible !== false && f[key] !== null && f[key] !== undefined + )) { + fields[column] = f[column] + } + if (Object.keys(fields).length) { + acc[f._id] = fields + } + return acc + }, {}) + + $: showRelationshipFields = + relationshipFields && + Object.keys(relationshipFields).length && + focused && + !isOpen + const parseValue = value => { if (Array.isArray(value) && value.every(x => x?._id)) { return value @@ -221,6 +242,14 @@ return value } + const displayRelationshipFields = relationship => { + relationshipFields = relationFields[relationship._id] + } + + const hideRelationshipFields = () => { + relationshipFields = undefined + } + onMount(() => { api = { focus: open, @@ -244,11 +273,18 @@
1} + class:disabled={!focused} on:wheel={e => (focused ? e.stopPropagation() : null)} > {#each fieldValue || [] as relationship} {#if relationship[primaryDisplay] || relationship.primaryDisplay} -
+
displayRelationshipFields(relationship)} + on:focus={() => {}} + on:mouseleave={() => hideRelationshipFields()} + > {readable( relationship[primaryDisplay] || relationship.primaryDisplay @@ -322,6 +358,21 @@ {/if} +{#if showRelationshipFields} + +
+ {#each Object.entries(relationshipFields) as [fieldName, fieldValue]} +
+ {fieldName} +
+
+ {fieldValue} +
+ {/each} +
+
+{/if} + diff --git a/packages/frontend-core/src/components/grid/controls/ColumnsSettingButton.svelte b/packages/frontend-core/src/components/grid/controls/ColumnsSettingButton.svelte index 8c66d9ecfc..b4940c8903 100644 --- a/packages/frontend-core/src/components/grid/controls/ColumnsSettingButton.svelte +++ b/packages/frontend-core/src/components/grid/controls/ColumnsSettingButton.svelte @@ -2,16 +2,29 @@ import { getContext } from "svelte" import { ActionButton, Popover } from "@budibase/bbui" import ColumnsSettingContent from "./ColumnsSettingContent.svelte" + import { FieldPermissions } from "../../../constants" export let allowViewReadonlyColumns = false - const { columns } = getContext("grid") + const { columns, datasource } = getContext("grid") let open = false let anchor $: anyRestricted = $columns.filter(col => !col.visible || col.readonly).length $: text = anyRestricted ? `Columns (${anyRestricted} restricted)` : "Columns" + + $: permissions = + $datasource.type === "viewV2" + ? [ + FieldPermissions.WRITABLE, + FieldPermissions.READONLY, + FieldPermissions.HIDDEN, + ] + : [FieldPermissions.WRITABLE, FieldPermissions.HIDDEN] + $: disabledPermissions = allowViewReadonlyColumns + ? [] + : [FieldPermissions.READONLY]
@@ -28,5 +41,9 @@
- + diff --git a/packages/frontend-core/src/components/grid/controls/ColumnsSettingContent.svelte b/packages/frontend-core/src/components/grid/controls/ColumnsSettingContent.svelte index 4f0e4424d4..02ed0a504c 100644 --- a/packages/frontend-core/src/components/grid/controls/ColumnsSettingContent.svelte +++ b/packages/frontend-core/src/components/grid/controls/ColumnsSettingContent.svelte @@ -1,22 +1,162 @@ @@ -94,16 +190,56 @@ {column.label}
- toggleColumn(column, e.detail)} - value={columnToPermissionOptions(column)} - options={column.options} - /> +
+ toggleColumn(column, e.detail)} + value={columnToPermissionOptions(column)} + options={column.options} + /> + {#if canSetRelationshipSchemas && column.schema.type === FieldType.LINK && columnToPermissionOptions(column) !== FieldPermissions.HIDDEN} +
+ { + relationshipFieldName = column.name + relationshipPanelAnchor = e.currentTarget + }} + size="S" + icon="ChevronRight" + quiet + /> +
+ {/if} +
{/each}
+{#if canSetRelationshipSchemas} + (relationshipFieldName = null)} + open={relationshipFieldName} + anchor={relationshipPanelAnchor} + align="right-outside" + > + {#if relationshipPanelColumns.length} +
+ {relationshipFieldName} columns +
+ {/if} + +
+{/if} + diff --git a/packages/frontend-core/src/components/grid/layout/Grid.svelte b/packages/frontend-core/src/components/grid/layout/Grid.svelte index 04a11da812..f24ff0ae10 100644 --- a/packages/frontend-core/src/components/grid/layout/Grid.svelte +++ b/packages/frontend-core/src/components/grid/layout/Grid.svelte @@ -43,6 +43,7 @@ export let canDeleteRows = true export let canEditColumns = true export let canSaveSchema = true + export let canSetRelationshipSchemas = false export let stripeRows = false export let quiet = false export let collaboration = true @@ -99,6 +100,7 @@ canDeleteRows, canEditColumns, canSaveSchema, + canSetRelationshipSchemas, stripeRows, quiet, collaboration, diff --git a/packages/frontend-core/src/components/grid/stores/cache.js b/packages/frontend-core/src/components/grid/stores/cache.js index 7eab6795e4..cf4690f15b 100644 --- a/packages/frontend-core/src/components/grid/stores/cache.js +++ b/packages/frontend-core/src/components/grid/stores/cache.js @@ -4,35 +4,40 @@ export const createActions = context => { // Cache for the primary display columns of different tables. // If we ever need to cache table definitions for other purposes then we can // expand this to be a more generic cache. - let primaryDisplayCache = {} + let tableCache = {} - const resetPrimaryDisplayCache = () => { - primaryDisplayCache = {} + const resetCache = () => { + tableCache = {} } - const getPrimaryDisplayForTableId = async tableId => { + const fetchTable = async tableId => { // If we've never encountered this tableId before then store a promise that // resolves to the primary display so that subsequent invocations before the // promise completes can reuse this promise - if (!primaryDisplayCache[tableId]) { - primaryDisplayCache[tableId] = new Promise(resolve => { - API.fetchTableDefinition(tableId).then(def => { - const display = def?.primaryDisplay || def?.schema?.[0]?.name - primaryDisplayCache[tableId] = display - resolve(display) - }) - }) + if (!tableCache[tableId]) { + tableCache[tableId] = API.fetchTableDefinition(tableId) } - // We await the result so that we account for both promises and primitives - return await primaryDisplayCache[tableId] + return await tableCache[tableId] + } + + const getPrimaryDisplayForTableId = async tableId => { + const table = await fetchTable(tableId) + const display = table?.primaryDisplay || table?.schema?.[0]?.name + return display + } + + const getTable = async tableId => { + const table = await fetchTable(tableId) + return table } return { cache: { actions: { getPrimaryDisplayForTableId, - resetPrimaryDisplayCache, + getTable, + resetCache, }, }, } @@ -43,5 +48,5 @@ export const initialise = context => { // Wipe the caches whenever the datasource changes to ensure we aren't // storing any stale information - datasource.subscribe(cache.actions.resetPrimaryDisplayCache) + datasource.subscribe(cache.actions.resetCache) } diff --git a/packages/frontend-core/src/components/grid/stores/datasource.js b/packages/frontend-core/src/components/grid/stores/datasource.js index 12a3bd5afe..68053f38ae 100644 --- a/packages/frontend-core/src/components/grid/stores/datasource.js +++ b/packages/frontend-core/src/components/grid/stores/datasource.js @@ -5,16 +5,24 @@ import { memo } from "../../../utils" export const createStores = () => { const definition = memo(null) const schemaMutations = memo({}) + const subSchemaMutations = memo({}) return { definition, schemaMutations, + subSchemaMutations, } } export const deriveStores = context => { - const { API, definition, schemaOverrides, datasource, schemaMutations } = - context + const { + API, + definition, + schemaOverrides, + datasource, + schemaMutations, + subSchemaMutations, + } = context const schema = derived(definition, $definition => { let schema = getDatasourceSchema({ @@ -40,8 +48,8 @@ export const deriveStores = context => { // Derives the total enriched schema, made up of the saved schema and any // prop and user overrides const enrichedSchema = derived( - [schema, schemaOverrides, schemaMutations], - ([$schema, $schemaOverrides, $schemaMutations]) => { + [schema, schemaOverrides, schemaMutations, subSchemaMutations], + ([$schema, $schemaOverrides, $schemaMutations, $subSchemaMutations]) => { if (!$schema) { return null } @@ -52,6 +60,18 @@ export const deriveStores = context => { ...$schemaOverrides?.[field], ...$schemaMutations[field], } + + if ($subSchemaMutations[field]) { + enrichedSchema[field].columns ??= {} + for (const [fieldName, mutation] of Object.entries( + $subSchemaMutations[field] + )) { + enrichedSchema[field].columns[fieldName] = { + ...enrichedSchema[field].columns[fieldName], + ...mutation, + } + } + } }) return enrichedSchema } @@ -83,6 +103,7 @@ export const createActions = context => { viewV2, nonPlus, schemaMutations, + subSchemaMutations, schema, notifications, } = context @@ -162,6 +183,25 @@ export const createActions = context => { }) } + // Adds a nested schema mutation for a single field + const addSubSchemaMutation = (field, fromField, mutation) => { + if (!field || !fromField || !mutation) { + return + } + subSchemaMutations.update($subSchemaMutations => { + return { + ...$subSchemaMutations, + [fromField]: { + ...$subSchemaMutations[fromField], + [field]: { + ...($subSchemaMutations[fromField] || {})[field], + ...mutation, + }, + }, + } + }) + } + // Adds schema mutations for multiple fields at once const addSchemaMutations = mutations => { const fields = Object.keys(mutations || {}) @@ -188,6 +228,7 @@ export const createActions = context => { } const $definition = get(definition) const $schemaMutations = get(schemaMutations) + const $subSchemaMutations = get(subSchemaMutations) const $schema = get(schema) let newSchema = {} @@ -197,6 +238,17 @@ export const createActions = context => { ...$schema[column], ...$schemaMutations[column], } + if ($subSchemaMutations[column]) { + newSchema[column].columns ??= {} + for (const [fieldName, mutation] of Object.entries( + $subSchemaMutations[column] + )) { + newSchema[column].columns[fieldName] = { + ...newSchema[column].columns[fieldName], + ...mutation, + } + } + } }) // Save the changes, then reset our local mutations @@ -209,6 +261,7 @@ export const createActions = context => { const resetSchemaMutations = () => { schemaMutations.set({}) + subSchemaMutations.set({}) } // Adds a row to the datasource @@ -255,6 +308,7 @@ export const createActions = context => { canUseColumn, changePrimaryDisplay, addSchemaMutation, + addSubSchemaMutation, addSchemaMutations, saveSchemaMutations, resetSchemaMutations, diff --git a/packages/frontend-core/src/constants.js b/packages/frontend-core/src/constants.js index c004f72dc2..995bff7e9c 100644 --- a/packages/frontend-core/src/constants.js +++ b/packages/frontend-core/src/constants.js @@ -187,3 +187,9 @@ export const FilterValueType = { BINDING: "Binding", VALUE: "Value", } + +export const FieldPermissions = { + WRITABLE: "writable", + READONLY: "readonly", + HIDDEN: "hidden", +} diff --git a/packages/server/src/api/controllers/row/external.ts b/packages/server/src/api/controllers/row/external.ts index e14e2d454a..bd5201c05c 100644 --- a/packages/server/src/api/controllers/row/external.ts +++ b/packages/server/src/api/controllers/row/external.ts @@ -38,7 +38,7 @@ export async function handleRequest( } export async function patch(ctx: UserCtx) { - const { tableId } = utils.getSourceId(ctx) + const { tableId, viewId } = utils.getSourceId(ctx) const { _id, ...rowData } = ctx.request.body const table = await sdk.tables.getTable(tableId) @@ -77,6 +77,7 @@ export async function patch(ctx: UserCtx) { outputProcessing(table, row, { squash: true, preserveLinks: true, + fromViewId: viewId, }), outputProcessing(table, beforeRow, { squash: true, @@ -163,14 +164,10 @@ export async function fetchEnrichedRow(ctx: UserCtx) { }, includeSqlRelationships: IncludeRelationship.INCLUDE, }) - row[fieldName] = await outputProcessing( - linkedTable, - relatedRows.rows, - { - squash: true, - preserveLinks: true, - } - ) + row[fieldName] = await outputProcessing(linkedTable, relatedRows.rows, { + squash: true, + preserveLinks: true, + }) } return row } diff --git a/packages/server/src/api/controllers/row/index.ts b/packages/server/src/api/controllers/row/index.ts index 46aec4e11c..cd85f57982 100644 --- a/packages/server/src/api/controllers/row/index.ts +++ b/packages/server/src/api/controllers/row/index.ts @@ -71,8 +71,10 @@ export async function patch( } export const save = async (ctx: UserCtx) => { + const { tableId, viewId } = utils.getSourceId(ctx) + const sourceId = viewId || tableId + const appId = ctx.appId - const { tableId } = utils.getSourceId(ctx) const body = ctx.request.body // user metadata doesn't exist yet - don't allow creation @@ -85,9 +87,9 @@ export const save = async (ctx: UserCtx) => { return patch(ctx as UserCtx) } const { row, table, squashed } = tableId.includes("datasource_plus") - ? await sdk.rows.save(tableId, ctx.request.body, ctx.user?._id) + ? await sdk.rows.save(sourceId, ctx.request.body, ctx.user?._id) : await quotas.addRow(() => - sdk.rows.save(tableId, ctx.request.body, ctx.user?._id) + sdk.rows.save(sourceId, ctx.request.body, ctx.user?._id) ) ctx.status = 200 ctx.eventEmitter && ctx.eventEmitter.emitRow(`row:save`, appId, row, table) @@ -115,10 +117,12 @@ export async function fetch(ctx: any) { } export async function find(ctx: UserCtx) { - const { tableId } = utils.getSourceId(ctx) + const { tableId, viewId } = utils.getSourceId(ctx) + const sourceId = viewId || tableId const rowId = ctx.params.rowId - ctx.body = await sdk.rows.find(tableId, rowId) + const response = await sdk.rows.find(sourceId, rowId) + ctx.body = response } function isDeleteRows(input: any): input is DeleteRows { diff --git a/packages/server/src/api/controllers/row/internal.ts b/packages/server/src/api/controllers/row/internal.ts index e698efe981..33e3c7707b 100644 --- a/packages/server/src/api/controllers/row/internal.ts +++ b/packages/server/src/api/controllers/row/internal.ts @@ -23,7 +23,7 @@ import { getLinkedTableIDs } from "../../../db/linkedRows/linkUtils" import { flatten } from "lodash" export async function patch(ctx: UserCtx) { - const { tableId } = utils.getSourceId(ctx) + const { tableId, viewId } = utils.getSourceId(ctx) const inputs = ctx.request.body const isUserTable = tableId === InternalTables.USER_METADATA let oldRow @@ -90,6 +90,7 @@ export async function patch(ctx: UserCtx) { const result = await finaliseRow(table, row, { oldTable: dbTable, updateFormula: true, + fromViewId: viewId, }) return { ...result, oldRow } diff --git a/packages/server/src/api/controllers/row/staticFormula.ts b/packages/server/src/api/controllers/row/staticFormula.ts index a6a02952d3..777379db14 100644 --- a/packages/server/src/api/controllers/row/staticFormula.ts +++ b/packages/server/src/api/controllers/row/staticFormula.ts @@ -123,7 +123,11 @@ export async function updateAllFormulasInTable(table: Table) { export async function finaliseRow( table: Table, row: Row, - { oldTable, updateFormula }: { oldTable?: Table; updateFormula: boolean } = { + { + oldTable, + updateFormula, + fromViewId, + }: { oldTable?: Table; updateFormula: boolean; fromViewId?: string } = { updateFormula: true, } ) { @@ -154,9 +158,8 @@ export async function finaliseRow( if (updateFormula) { await updateRelatedFormula(table, enrichedRow) } - const squashed = await linkRows.squashLinksToPrimaryDisplay( - table, - enrichedRow - ) + const squashed = await linkRows.squashLinks(table, enrichedRow, { + fromViewId, + }) return { row: enrichedRow, squashed, table } } diff --git a/packages/server/src/api/controllers/row/views.ts b/packages/server/src/api/controllers/row/views.ts index d2541dfa25..06a01646a7 100644 --- a/packages/server/src/api/controllers/row/views.ts +++ b/packages/server/src/api/controllers/row/views.ts @@ -75,8 +75,11 @@ export async function searchView( }) const searchOptions: RequiredKeys & - RequiredKeys> = { + RequiredKeys< + Pick + > = { tableId: view.tableId, + viewId: view.id, query: enrichedQuery, fields: viewFields, ...getSortOptions(body, view), diff --git a/packages/server/src/api/controllers/rowAction/crud.ts b/packages/server/src/api/controllers/rowAction/crud.ts index bd5326d957..c9b7756987 100644 --- a/packages/server/src/api/controllers/rowAction/crud.ts +++ b/packages/server/src/api/controllers/rowAction/crud.ts @@ -26,7 +26,7 @@ export async function find(ctx: Ctx) { return } - const { actions } = await sdk.rowActions.get(table._id!) + const { actions } = await sdk.rowActions.getAll(table._id!) const result: RowActionsResponse = { actions: Object.entries(actions).reduce>( (acc, [key, action]) => ({ @@ -36,6 +36,7 @@ export async function find(ctx: Ctx) { tableId: table._id!, name: action.name, automationId: action.automationId, + allowedViews: flattenAllowedViews(action.permissions.views), }, }), {} @@ -58,6 +59,7 @@ export async function create( id: createdAction.id, name: createdAction.name, automationId: createdAction.automationId, + allowedViews: undefined, } ctx.status = 201 } @@ -77,6 +79,7 @@ export async function update( id: action.id, name: action.name, automationId: action.automationId, + allowedViews: undefined, } } @@ -87,3 +90,53 @@ export async function remove(ctx: Ctx) { await sdk.rowActions.remove(table._id!, actionId) ctx.status = 204 } + +export async function setViewPermission(ctx: Ctx) { + const table = await getTable(ctx) + const { actionId, viewId } = ctx.params + + const action = await sdk.rowActions.setViewPermission( + table._id!, + actionId, + viewId + ) + ctx.body = { + tableId: table._id!, + id: action.id, + name: action.name, + automationId: action.automationId, + allowedViews: flattenAllowedViews(action.permissions.views), + } +} + +export async function unsetViewPermission(ctx: Ctx) { + const table = await getTable(ctx) + const { actionId, viewId } = ctx.params + + const action = await sdk.rowActions.unsetViewPermission( + table._id!, + actionId, + viewId + ) + + ctx.body = { + tableId: table._id!, + id: action.id, + name: action.name, + automationId: action.automationId, + allowedViews: flattenAllowedViews(action.permissions.views), + } +} + +function flattenAllowedViews( + permissions: Record +) { + const allowedPermissions = Object.entries(permissions || {}) + .filter(([_, p]) => p.runAllowed) + .map(([viewId]) => viewId) + if (!allowedPermissions.length) { + return undefined + } + + return allowedPermissions +} diff --git a/packages/server/src/api/controllers/table/index.ts b/packages/server/src/api/controllers/table/index.ts index 6e1b69b7ac..0e16077092 100644 --- a/packages/server/src/api/controllers/table/index.ts +++ b/packages/server/src/api/controllers/table/index.ts @@ -85,14 +85,19 @@ export async function fetch(ctx: UserCtx) { } }) - ctx.body = [...internal, ...external].map(sdk.tables.enrichViewSchemas) + const result: FetchTablesResponse = [] + for (const table of [...internal, ...external]) { + result.push(await sdk.tables.enrichViewSchemas(table)) + } + ctx.body = result } export async function find(ctx: UserCtx) { const tableId = ctx.params.tableId const table = await sdk.tables.getTable(tableId) - ctx.body = sdk.tables.enrichViewSchemas(table) + const result = await sdk.tables.enrichViewSchemas(table) + ctx.body = result } export async function save(ctx: UserCtx) { @@ -106,11 +111,14 @@ export async function save(ctx: UserCtx) { const api = pickApi({ table }) let savedTable = await api.save(ctx, renaming) if (!table._id) { - savedTable = sdk.tables.enrichViewSchemas(savedTable) + savedTable = await sdk.tables.enrichViewSchemas(savedTable) await events.table.created(savedTable) } else { await events.table.updated(savedTable) } + if (renaming) { + await sdk.views.renameLinkedViews(savedTable, renaming) + } if (isImport) { await events.table.imported(savedTable) } diff --git a/packages/server/src/api/controllers/view/viewsV2.ts b/packages/server/src/api/controllers/view/viewsV2.ts index 4208772fa6..90e80fe81d 100644 --- a/packages/server/src/api/controllers/view/viewsV2.ts +++ b/packages/server/src/api/controllers/view/viewsV2.ts @@ -3,11 +3,12 @@ import { CreateViewRequest, Ctx, RequiredKeys, - ViewUIFieldMetadata, UpdateViewRequest, ViewResponse, ViewResponseEnriched, ViewV2, + ViewFieldMetadata, + RelationSchemaField, } from "@budibase/types" import { builderSocket, gridSocket } from "../../../websockets" @@ -18,21 +19,41 @@ async function parseSchema(view: CreateViewRequest) { const finalViewSchema = view.schema && Object.entries(view.schema).reduce((p, [fieldName, schemaValue]) => { - const fieldSchema: RequiredKeys = { + let fieldRelatedSchema: + | Record> + | undefined + if (schemaValue.columns) { + fieldRelatedSchema = Object.entries(schemaValue.columns).reduce< + NonNullable + >((acc, [key, fieldSchema]) => { + acc[key] = { + visible: fieldSchema.visible, + readonly: fieldSchema.readonly, + } + return acc + }, {}) + } + + const fieldSchema: RequiredKeys< + ViewFieldMetadata & { + columns: typeof fieldRelatedSchema + } + > = { order: schemaValue.order, width: schemaValue.width, visible: schemaValue.visible, readonly: schemaValue.readonly, icon: schemaValue.icon, + columns: fieldRelatedSchema, } Object.entries(fieldSchema) .filter(([, val]) => val === undefined) .forEach(([key]) => { - delete fieldSchema[key as keyof ViewUIFieldMetadata] + delete fieldSchema[key as keyof ViewFieldMetadata] }) p[fieldName] = fieldSchema return p - }, {} as Record>) + }, {} as Record>) return finalViewSchema } diff --git a/packages/server/src/api/routes/rowAction.ts b/packages/server/src/api/routes/rowAction.ts index c84d94c007..54154e3ee8 100644 --- a/packages/server/src/api/routes/rowAction.ts +++ b/packages/server/src/api/routes/rowAction.ts @@ -2,9 +2,10 @@ import Router from "@koa/router" import Joi from "joi" import { middleware, permissions } from "@budibase/backend-core" import * as rowActionController from "../controllers/rowAction" -import { authorizedResource } from "../../middleware/authorized" +import authorized from "../../middleware/authorized" +import { triggerRowActionAuthorised } from "../../middleware/triggerRowActionAuthorised" -const { PermissionLevel, PermissionType } = permissions +const { BUILDER } = permissions function rowActionValidator() { return middleware.joiValidator.body( @@ -30,32 +31,42 @@ const router: Router = new Router() router .get( "/api/tables/:tableId/actions", - authorizedResource(PermissionType.TABLE, PermissionLevel.READ, "tableId"), + authorized(BUILDER), rowActionController.find ) .post( "/api/tables/:tableId/actions", - authorizedResource(PermissionType.TABLE, PermissionLevel.READ, "tableId"), + authorized(BUILDER), rowActionValidator(), rowActionController.create ) .put( "/api/tables/:tableId/actions/:actionId", - authorizedResource(PermissionType.TABLE, PermissionLevel.READ, "tableId"), + authorized(BUILDER), rowActionValidator(), rowActionController.update ) .delete( "/api/tables/:tableId/actions/:actionId", - authorizedResource(PermissionType.TABLE, PermissionLevel.READ, "tableId"), + authorized(BUILDER), rowActionController.remove ) + .post( + "/api/tables/:tableId/actions/:actionId/permissions/:viewId", + authorized(BUILDER), + rowActionController.setViewPermission + ) + .delete( + "/api/tables/:tableId/actions/:actionId/permissions/:viewId", + authorized(BUILDER), + rowActionController.unsetViewPermission + ) // Other endpoints .post( - "/api/tables/:tableId/actions/:actionId/trigger", + "/api/tables/:sourceId/actions/:actionId/trigger", rowTriggerValidator(), - authorizedResource(PermissionType.TABLE, PermissionLevel.READ, "tableId"), + triggerRowActionAuthorised("sourceId", "actionId"), rowActionController.run ) diff --git a/packages/server/src/api/routes/tests/row.spec.ts b/packages/server/src/api/routes/tests/row.spec.ts index 765d757680..9790703806 100644 --- a/packages/server/src/api/routes/tests/row.spec.ts +++ b/packages/server/src/api/routes/tests/row.spec.ts @@ -40,6 +40,8 @@ import { TableSchema, JsonFieldSubType, RowExportFormat, + FeatureFlag, + RelationSchemaField, } from "@budibase/types" import { generator, mocks } from "@budibase/backend-core/tests" import _, { merge } from "lodash" @@ -1231,7 +1233,7 @@ describe.each([ }) it.each([{ not: "valid" }, { rows: 123 }, "invalid"])( - "Should ignore malformed/invalid delete request: %s", + "should ignore malformed/invalid delete request: %s", async (request: any) => { const rowUsage = await getRowUsage() @@ -2426,6 +2428,376 @@ describe.each([ }) }) + // Upserting isn't yet supported in MSSQL or Oracle, see: + // https://github.com/knex/knex/pull/6050 + !isMSSQL && + !isOracle && + describe("relationships", () => { + let tableId: string + let viewId: string + + let auxData: Row[] = [] + + let flagCleanup: (() => void) | undefined + + beforeAll(async () => { + flagCleanup = setCoreEnv({ + TENANT_FEATURE_FLAGS: `*:${FeatureFlag.ENRICHED_RELATIONSHIPS}`, + }) + + const aux2Table = await config.api.table.save(saveTableRequest()) + const aux2Data = await config.api.row.save(aux2Table._id!, {}) + + const auxTable = await config.api.table.save( + saveTableRequest({ + primaryDisplay: "name", + schema: { + name: { + name: "name", + type: FieldType.STRING, + constraints: { presence: true }, + }, + age: { + name: "age", + type: FieldType.NUMBER, + constraints: { presence: true }, + }, + address: { + name: "address", + type: FieldType.STRING, + constraints: { presence: true }, + visible: false, + }, + link: { + name: "link", + type: FieldType.LINK, + tableId: aux2Table._id!, + relationshipType: RelationshipType.MANY_TO_MANY, + fieldName: "fk_aux", + constraints: { presence: true }, + }, + formula: { + name: "formula", + type: FieldType.FORMULA, + formula: "{{ any }}", + constraints: { presence: true }, + }, + }, + }) + ) + const auxTableId = auxTable._id! + + for (const name of generator.unique(() => generator.name(), 10)) { + auxData.push( + await config.api.row.save(auxTableId, { + name, + age: generator.age(), + address: generator.address(), + link: [aux2Data], + }) + ) + } + + const table = await config.api.table.save( + saveTableRequest({ + schema: { + title: { + name: "title", + type: FieldType.STRING, + constraints: { presence: true }, + }, + relWithNoSchema: { + name: "relWithNoSchema", + relationshipType: RelationshipType.ONE_TO_MANY, + type: FieldType.LINK, + tableId: auxTableId, + fieldName: "fk_relWithNoSchema", + constraints: { presence: true }, + }, + relWithEmptySchema: { + name: "relWithEmptySchema", + relationshipType: RelationshipType.ONE_TO_MANY, + type: FieldType.LINK, + tableId: auxTableId, + fieldName: "fk_relWithEmptySchema", + constraints: { presence: true }, + }, + relWithFullSchema: { + name: "relWithFullSchema", + relationshipType: RelationshipType.ONE_TO_MANY, + type: FieldType.LINK, + tableId: auxTableId, + fieldName: "fk_relWithFullSchema", + constraints: { presence: true }, + }, + relWithHalfSchema: { + name: "relWithHalfSchema", + relationshipType: RelationshipType.ONE_TO_MANY, + type: FieldType.LINK, + tableId: auxTableId, + fieldName: "fk_relWithHalfSchema", + constraints: { presence: true }, + }, + relWithIllegalSchema: { + name: "relWithIllegalSchema", + relationshipType: RelationshipType.ONE_TO_MANY, + type: FieldType.LINK, + tableId: auxTableId, + fieldName: "fk_relWithIllegalSchema", + constraints: { presence: true }, + }, + }, + }) + ) + tableId = table._id! + const view = await config.api.viewV2.create({ + name: generator.guid(), + tableId, + schema: { + title: { + visible: true, + }, + relWithNoSchema: { + visible: true, + }, + relWithEmptySchema: { + visible: true, + columns: {}, + }, + relWithFullSchema: { + visible: true, + columns: Object.keys(auxTable.schema).reduce< + Record + >((acc, c) => ({ ...acc, [c]: { visible: true } }), {}), + }, + relWithHalfSchema: { + visible: true, + columns: { + name: { visible: true }, + age: { visible: false, readonly: true }, + }, + }, + relWithIllegalSchema: { + visible: true, + columns: { + name: { visible: true }, + address: { visible: true }, + unexisting: { visible: true }, + }, + }, + }, + }) + + viewId = view.id + }) + + afterAll(() => { + flagCleanup?.() + }) + + const testScenarios: [string, (row: Row) => Promise | Row][] = [ + ["get row", (row: Row) => config.api.row.get(viewId, row._id!)], + [ + "from view search", + async (row: Row) => { + const { rows } = await config.api.viewV2.search(viewId) + return rows.find(r => r._id === row._id!) + }, + ], + ["from original saved row", (row: Row) => row], + ["from updated row", (row: Row) => config.api.row.save(viewId, row)], + ] + + it.each(testScenarios)( + "can retrieve rows with populated relationships (via %s)", + async (__, retrieveDelegate) => { + const otherRows = _.sampleSize(auxData, 5) + + const row = await config.api.row.save(viewId, { + title: generator.word(), + relWithNoSchema: [otherRows[0]], + relWithEmptySchema: [otherRows[1]], + relWithFullSchema: [otherRows[2]], + relWithHalfSchema: [otherRows[3]], + relWithIllegalSchema: [otherRows[4]], + }) + + const retrieved = await retrieveDelegate(row) + + expect(retrieved).toEqual( + expect.objectContaining({ + title: row.title, + relWithNoSchema: [ + { + _id: otherRows[0]._id, + primaryDisplay: otherRows[0].name, + }, + ], + relWithEmptySchema: [ + { + _id: otherRows[1]._id, + primaryDisplay: otherRows[1].name, + }, + ], + relWithFullSchema: [ + { + _id: otherRows[2]._id, + primaryDisplay: otherRows[2].name, + name: otherRows[2].name, + age: otherRows[2].age, + id: otherRows[2].id, + }, + ], + relWithHalfSchema: [ + { + _id: otherRows[3]._id, + primaryDisplay: otherRows[3].name, + name: otherRows[3].name, + }, + ], + relWithIllegalSchema: [ + { + _id: otherRows[4]._id, + primaryDisplay: otherRows[4].name, + name: otherRows[4].name, + }, + ], + }) + ) + } + ) + + it.each(testScenarios)( + "does not enrich relationships when not enabled (via %s)", + async (__, retrieveDelegate) => { + await withCoreEnv( + { + TENANT_FEATURE_FLAGS: ``, + }, + async () => { + const otherRows = _.sampleSize(auxData, 5) + + const row = await config.api.row.save(viewId, { + title: generator.word(), + relWithNoSchema: [otherRows[0]], + relWithEmptySchema: [otherRows[1]], + relWithFullSchema: [otherRows[2]], + relWithHalfSchema: [otherRows[3]], + relWithIllegalSchema: [otherRows[4]], + }) + + const retrieved = await retrieveDelegate(row) + + expect(retrieved).toEqual( + expect.objectContaining({ + title: row.title, + relWithNoSchema: [ + { + _id: otherRows[0]._id, + primaryDisplay: otherRows[0].name, + }, + ], + relWithEmptySchema: [ + { + _id: otherRows[1]._id, + primaryDisplay: otherRows[1].name, + }, + ], + relWithFullSchema: [ + { + _id: otherRows[2]._id, + primaryDisplay: otherRows[2].name, + }, + ], + relWithHalfSchema: [ + { + _id: otherRows[3]._id, + primaryDisplay: otherRows[3].name, + }, + ], + relWithIllegalSchema: [ + { + _id: otherRows[4]._id, + primaryDisplay: otherRows[4].name, + }, + ], + }) + ) + } + ) + } + ) + + it.each([ + [ + "from table fetch", + async (row: Row) => { + const rows = await config.api.row.fetch(tableId) + return rows.find(r => r._id === row._id!) + }, + ], + [ + "from table search", + async (row: Row) => { + const { rows } = await config.api.row.search(tableId) + return rows.find(r => r._id === row._id!) + }, + ], + ])( + "does not enrich when fetching from the table (via %s)", + async (__, retrieveDelegate) => { + const otherRows = _.sampleSize(auxData, 5) + + const row = await config.api.row.save(viewId, { + title: generator.word(), + relWithNoSchema: [otherRows[0]], + relWithEmptySchema: [otherRows[1]], + relWithFullSchema: [otherRows[2]], + relWithHalfSchema: [otherRows[3]], + relWithIllegalSchema: [otherRows[4]], + }) + + const retrieved = await retrieveDelegate(row) + + expect(retrieved).toEqual( + expect.objectContaining({ + title: row.title, + relWithNoSchema: [ + { + _id: otherRows[0]._id, + primaryDisplay: otherRows[0].name, + }, + ], + relWithEmptySchema: [ + { + _id: otherRows[1]._id, + primaryDisplay: otherRows[1].name, + }, + ], + relWithFullSchema: [ + { + _id: otherRows[2]._id, + primaryDisplay: otherRows[2].name, + }, + ], + relWithHalfSchema: [ + { + _id: otherRows[3]._id, + primaryDisplay: otherRows[3].name, + }, + ], + relWithIllegalSchema: [ + { + _id: otherRows[4]._id, + primaryDisplay: otherRows[4].name, + }, + ], + }) + ) + } + ) + }) + describe("Formula fields", () => { let table: Table let otherTable: Table diff --git a/packages/server/src/api/routes/tests/rowAction.spec.ts b/packages/server/src/api/routes/tests/rowAction.spec.ts index 5e043cb42c..d43f5075d0 100644 --- a/packages/server/src/api/routes/tests/rowAction.spec.ts +++ b/packages/server/src/api/routes/tests/rowAction.spec.ts @@ -4,10 +4,15 @@ import tk from "timekeeper" import { CreateRowActionRequest, DocumentType, + PermissionLevel, + Row, RowActionResponse, } from "@budibase/types" import * as setup from "./utilities" import { generator } from "@budibase/backend-core/tests" +import { Expectations } from "../../../tests/utilities/api/base" +import { roles } from "@budibase/backend-core" +import { automations } from "@budibase/pro" const expectAutomationId = () => expect.stringMatching(`^${DocumentType.AUTOMATION}_.+`) @@ -43,11 +48,14 @@ describe("/rowsActions", () => { .map(name => ({ name })) } - function unauthorisedTests() { + function unauthorisedTests( + apiDelegate: ( + expectations: Expectations, + testConfig?: { publicUser?: boolean } + ) => Promise + ) { it("returns unauthorised (401) for unauthenticated requests", async () => { - await createRowAction( - tableId, - createRowActionRequest(), + await apiDelegate( { status: 401, body: { @@ -65,6 +73,35 @@ describe("/rowsActions", () => { await config.withUser(user, async () => { await createRowAction(generator.guid(), createRowActionRequest(), { status: 403, + body: { + message: "Not Authorized", + }, + }) + }) + }) + + it("returns forbidden (403) for non-builder users even if they have table write permissions", async () => { + const user = await config.createUser({ + builder: {}, + }) + const tableId = generator.guid() + for (const role of Object.values(roles.BUILTIN_ROLE_IDS)) { + await config.api.permission.add({ + roleId: role, + resourceId: tableId, + level: PermissionLevel.EXECUTE, + }) + } + + // replicate changes before checking permissions + await config.publish() + + await config.withUser(user, async () => { + await createRowAction(tableId, createRowActionRequest(), { + status: 403, + body: { + message: "Not Authorized", + }, }) }) }) @@ -77,7 +114,14 @@ describe("/rowsActions", () => { } describe("create", () => { - unauthorisedTests() + unauthorisedTests((expectations, testConfig) => + createRowAction( + tableId, + createRowActionRequest(), + expectations, + testConfig + ) + ) it("creates new row actions for tables without existing actions", async () => { const rowAction = createRowActionRequest() @@ -106,7 +150,7 @@ describe("/rowsActions", () => { it("trims row action names", async () => { const name = " action name " - const res = await createRowAction(tableId, { name }, { status: 201 }) + const res = await createRowAction(tableId, { name }) expect(res).toEqual( expect.objectContaining({ @@ -174,9 +218,7 @@ describe("/rowsActions", () => { id: generator.guid(), valueToIgnore: generator.string(), } - const res = await createRowAction(tableId, dirtyRowAction, { - status: 201, - }) + const res = await createRowAction(tableId, dirtyRowAction) expect(res).toEqual({ name: rowAction.name, @@ -239,15 +281,17 @@ describe("/rowsActions", () => { const action2 = await createRowAction(tableId, createRowActionRequest()) for (const automationId of [action1.automationId, action2.automationId]) { - expect( - await config.api.automation.get(automationId, { status: 200 }) - ).toEqual(expect.objectContaining({ _id: automationId })) + expect(await config.api.automation.get(automationId)).toEqual( + expect.objectContaining({ _id: automationId }) + ) } }) }) describe("find", () => { - unauthorisedTests() + unauthorisedTests((expectations, testConfig) => + config.api.rowAction.find(tableId, expectations, testConfig) + ) it("returns only the actions for the requested table", async () => { const rowActions: RowActionResponse[] = [] @@ -279,7 +323,15 @@ describe("/rowsActions", () => { }) describe("update", () => { - unauthorisedTests() + unauthorisedTests((expectations, testConfig) => + config.api.rowAction.update( + tableId, + generator.guid(), + createRowActionRequest(), + expectations, + testConfig + ) + ) it("can update existing actions", async () => { for (const rowAction of createRowActionRequests(3)) { @@ -320,13 +372,7 @@ describe("/rowsActions", () => { }) it("trims row action names", async () => { - const rowAction = await createRowAction( - tableId, - createRowActionRequest(), - { - status: 201, - } - ) + const rowAction = await createRowAction(tableId, createRowActionRequest()) const res = await config.api.rowAction.update(tableId, rowAction.id, { name: " action name ", @@ -398,7 +444,14 @@ describe("/rowsActions", () => { }) describe("delete", () => { - unauthorisedTests() + unauthorisedTests((expectations, testConfig) => + config.api.rowAction.delete( + tableId, + generator.guid(), + expectations, + testConfig + ) + ) it("can delete existing actions", async () => { const actions: RowActionResponse[] = [] @@ -462,4 +515,240 @@ describe("/rowsActions", () => { } }) }) + + describe("set/unsetViewPermission", () => { + describe.each([ + ["setViewPermission", config.api.rowAction.setViewPermission], + ["unsetViewPermission", config.api.rowAction.unsetViewPermission], + ])("unauthorisedTests for %s", (__, delegateTest) => { + unauthorisedTests((expectations, testConfig) => + delegateTest( + tableId, + generator.guid(), + generator.guid(), + expectations, + testConfig + ) + ) + }) + + let tableIdForDescribe: string + let actionId1: string, actionId2: string + let viewId1: string, viewId2: string + beforeAll(async () => { + tableIdForDescribe = tableId + for (const rowAction of createRowActionRequests(3)) { + await createRowAction(tableId, rowAction) + } + const persisted = await config.api.rowAction.find(tableId) + + const actions = _.sampleSize(Object.keys(persisted.actions), 2) + actionId1 = actions[0] + actionId2 = actions[1] + + viewId1 = ( + await config.api.viewV2.create( + setup.structures.viewV2.createRequest(tableId) + ) + ).id + viewId2 = ( + await config.api.viewV2.create( + setup.structures.viewV2.createRequest(tableId) + ) + ).id + }) + + beforeEach(() => { + // Hack to reuse tables for these given tests + tableId = tableIdForDescribe + }) + + it("can set permission views", async () => { + await config.api.rowAction.setViewPermission(tableId, viewId1, actionId1) + const action1Result = await config.api.rowAction.setViewPermission( + tableId, + viewId2, + actionId1 + ) + const action2Result = await config.api.rowAction.setViewPermission( + tableId, + viewId1, + actionId2 + ) + + const expectedAction1 = expect.objectContaining({ + allowedViews: [viewId1, viewId2], + }) + const expectedAction2 = expect.objectContaining({ + allowedViews: [viewId1], + }) + + const expectedActions = expect.objectContaining({ + [actionId1]: expectedAction1, + [actionId2]: expectedAction2, + }) + expect(action1Result).toEqual(expectedAction1) + expect(action2Result).toEqual(expectedAction2) + expect((await config.api.rowAction.find(tableId)).actions).toEqual( + expectedActions + ) + }) + + it("can unset permission views", async () => { + const actionResult = await config.api.rowAction.unsetViewPermission( + tableId, + viewId1, + actionId1 + ) + + const expectedAction = expect.objectContaining({ + allowedViews: [viewId2], + }) + expect(actionResult).toEqual(expectedAction) + expect( + (await config.api.rowAction.find(tableId)).actions[actionId1] + ).toEqual(expectedAction) + }) + + it.each([ + ["setViewPermission", config.api.rowAction.setViewPermission], + ["unsetViewPermission", config.api.rowAction.unsetViewPermission], + ])( + "cannot update permission views for unexisting views (%s)", + async (__, delegateTest) => { + const viewId = generator.guid() + + await delegateTest(tableId, viewId, actionId1, { + status: 400, + body: { + message: `View '${viewId}' not found in '${tableId}'`, + }, + }) + } + ) + + it.each([ + ["setViewPermission", config.api.rowAction.setViewPermission], + ["unsetViewPermission", config.api.rowAction.unsetViewPermission], + ])( + "cannot update permission views crossing table views (%s)", + async (__, delegateTest) => { + const anotherTable = await config.api.table.save( + setup.structures.basicTable() + ) + const { id: viewId } = await config.api.viewV2.create( + setup.structures.viewV2.createRequest(anotherTable._id!) + ) + + await delegateTest(tableId, viewId, actionId1, { + status: 400, + body: { + message: `View '${viewId}' not found in '${tableId}'`, + }, + }) + } + ) + }) + + describe("trigger", () => { + let row: Row + let rowAction: RowActionResponse + + beforeEach(async () => { + row = await config.api.row.save(tableId, {}) + rowAction = await createRowAction(tableId, createRowActionRequest()) + + await config.publish() + tk.travel(Date.now() + 100) + }) + + async function getAutomationLogs() { + const { data: automationLogs } = await config.doInContext( + config.getProdAppId(), + async () => + automations.logs.logSearch({ startDate: new Date().toISOString() }) + ) + return automationLogs + } + + it("can trigger an automation given valid data", async () => { + expect(await getAutomationLogs()).toBeEmpty() + await config.api.rowAction.trigger(tableId, rowAction.id, { + rowId: row._id!, + }) + + const automationLogs = await getAutomationLogs() + expect(automationLogs).toEqual([ + expect.objectContaining({ + automationId: rowAction.automationId, + trigger: { + id: "trigger", + stepId: "ROW_ACTION", + inputs: null, + outputs: { + fields: {}, + row: await config.api.row.get(tableId, row._id!), + table: await config.api.table.get(tableId), + automation: expect.objectContaining({ + _id: rowAction.automationId, + }), + }, + }, + }), + ]) + }) + + it("rejects triggering from a non-allowed view", async () => { + const viewId = ( + await config.api.viewV2.create( + setup.structures.viewV2.createRequest(tableId) + ) + ).id + + await config.publish() + await config.api.rowAction.trigger( + viewId, + rowAction.id, + { + rowId: row._id!, + }, + { + status: 403, + body: { + message: `Row action '${rowAction.id}' is not enabled for view '${viewId}'`, + }, + } + ) + + const automationLogs = await getAutomationLogs() + expect(automationLogs).toEqual([]) + }) + + it("triggers from an allowed view", async () => { + const viewId = ( + await config.api.viewV2.create( + setup.structures.viewV2.createRequest(tableId) + ) + ).id + + await config.api.rowAction.setViewPermission( + tableId, + viewId, + rowAction.id + ) + + await config.publish() + expect(await getAutomationLogs()).toBeEmpty() + await config.api.rowAction.trigger(viewId, rowAction.id, { + rowId: row._id!, + }) + + const automationLogs = await getAutomationLogs() + expect(automationLogs).toEqual([ + expect.objectContaining({ + automationId: rowAction.automationId, + }), + ]) + }) + }) }) diff --git a/packages/server/src/api/routes/tests/viewV2.spec.ts b/packages/server/src/api/routes/tests/viewV2.spec.ts index 356f01dee0..6d2d13e580 100644 --- a/packages/server/src/api/routes/tests/viewV2.spec.ts +++ b/packages/server/src/api/routes/tests/viewV2.spec.ts @@ -18,6 +18,10 @@ import { ViewV2, SearchResponse, BasicOperator, + RelationshipType, + TableSchema, + ViewFieldMetadata, + RenameColumn, } from "@budibase/types" import { generator, mocks } from "@budibase/backend-core/tests" import { DatabaseName, getDatasource } from "../../../integrations/tests/utils" @@ -1177,6 +1181,263 @@ describe.each([ ) }) }) + + describe("foreign relationship columns", () => { + const createAuxTable = () => + config.api.table.save( + saveTableRequest({ + primaryDisplay: "name", + schema: { + name: { name: "name", type: FieldType.STRING }, + age: { name: "age", type: FieldType.NUMBER }, + }, + }) + ) + + const createMainTable = async ( + links: { + name: string + tableId: string + fk: string + }[] + ) => { + const table = await config.api.table.save( + saveTableRequest({ + schema: {}, + }) + ) + await config.api.table.save({ + ...table, + schema: { + ...table.schema, + ...links.reduce((acc, c) => { + acc[c.name] = { + name: c.name, + relationshipType: RelationshipType.ONE_TO_MANY, + type: FieldType.LINK, + tableId: c.tableId, + fieldName: c.fk, + constraints: { type: "array" }, + } + return acc + }, {}), + }, + }) + return table + } + + const createView = async ( + tableId: string, + schema: Record + ) => + await config.api.viewV2.create({ + name: generator.guid(), + tableId, + schema, + }) + + const renameColumn = async (table: Table, renaming: RenameColumn) => { + const newSchema = { ...table.schema } + newSchema[renaming.updated] = { + ...table.schema[renaming.old], + name: renaming.updated, + } + delete newSchema[renaming.old] + + await config.api.table.save({ + ...table, + schema: newSchema, + _rename: renaming, + }) + } + + it("updating a column will update link columns configuration", async () => { + let auxTable = await createAuxTable() + + const table = await createMainTable([ + { name: "aux", tableId: auxTable._id!, fk: "fk_aux" }, + ]) + // Refetch auxTable + auxTable = await config.api.table.get(auxTable._id!) + + const view = await createView(table._id!, { + aux: { + visible: true, + columns: { + name: { visible: true, readonly: true }, + age: { visible: true, readonly: true }, + }, + }, + }) + + await renameColumn(auxTable, { old: "age", updated: "dob" }) + + const updatedView = await config.api.viewV2.get(view.id) + expect(updatedView).toEqual( + expect.objectContaining({ + schema: expect.objectContaining({ + aux: expect.objectContaining({ + columns: { + id: { visible: false, readonly: false }, + name: { visible: true, readonly: true }, + dob: { visible: true, readonly: true }, + }, + }), + }), + }) + ) + }) + + it("handles multiple fields using the same table", async () => { + let auxTable = await createAuxTable() + + const table = await createMainTable([ + { name: "aux", tableId: auxTable._id!, fk: "fk_aux" }, + { name: "aux2", tableId: auxTable._id!, fk: "fk_aux2" }, + ]) + // Refetch auxTable + auxTable = await config.api.table.get(auxTable._id!) + + const view = await createView(table._id!, { + aux: { + visible: true, + columns: { + name: { visible: true, readonly: true }, + age: { visible: true, readonly: true }, + }, + }, + aux2: { + visible: true, + columns: { + name: { visible: true, readonly: true }, + age: { visible: true, readonly: true }, + }, + }, + }) + + await renameColumn(auxTable, { old: "age", updated: "dob" }) + + const updatedView = await config.api.viewV2.get(view.id) + expect(updatedView).toEqual( + expect.objectContaining({ + schema: expect.objectContaining({ + aux: expect.objectContaining({ + columns: { + id: { visible: false, readonly: false }, + name: { visible: true, readonly: true }, + dob: { visible: true, readonly: true }, + }, + }), + aux2: expect.objectContaining({ + columns: { + id: { visible: false, readonly: false }, + name: { visible: true, readonly: true }, + dob: { visible: true, readonly: true }, + }, + }), + }), + }) + ) + }) + + it("does not rename columns with the same name but from other tables", async () => { + let auxTable = await createAuxTable() + let aux2Table = await createAuxTable() + + const table = await createMainTable([ + { name: "aux", tableId: auxTable._id!, fk: "fk_aux" }, + { name: "aux2", tableId: aux2Table._id!, fk: "fk_aux2" }, + ]) + + // Refetch auxTable + auxTable = await config.api.table.get(auxTable._id!) + + const view = await createView(table._id!, { + aux: { + visible: true, + columns: { + name: { visible: true, readonly: true }, + }, + }, + aux2: { + visible: true, + columns: { + name: { visible: true, readonly: true }, + }, + }, + }) + + await renameColumn(auxTable, { old: "name", updated: "fullName" }) + + const updatedView = await config.api.viewV2.get(view.id) + expect(updatedView).toEqual( + expect.objectContaining({ + schema: expect.objectContaining({ + aux: expect.objectContaining({ + columns: { + id: { visible: false, readonly: false }, + fullName: { visible: true, readonly: true }, + age: { visible: false, readonly: false }, + }, + }), + aux2: expect.objectContaining({ + columns: { + id: { visible: false, readonly: false }, + name: { visible: true, readonly: true }, + age: { visible: false, readonly: false }, + }, + }), + }), + }) + ) + }) + + it("updates all views references", async () => { + let auxTable = await createAuxTable() + + const table1 = await createMainTable([ + { name: "aux", tableId: auxTable._id!, fk: "fk_aux_table1" }, + ]) + const table2 = await createMainTable([ + { name: "aux", tableId: auxTable._id!, fk: "fk_aux_table2" }, + ]) + + // Refetch auxTable + auxTable = await config.api.table.get(auxTable._id!) + + const viewSchema = { + aux: { + visible: true, + columns: { + name: { visible: true, readonly: true }, + age: { visible: true, readonly: true }, + }, + }, + } + const view1 = await createView(table1._id!, viewSchema) + const view2 = await createView(table1._id!, viewSchema) + const view3 = await createView(table2._id!, viewSchema) + + await renameColumn(auxTable, { old: "age", updated: "dob" }) + + for (const view of [view1, view2, view3]) { + const updatedView = await config.api.viewV2.get(view.id) + expect(updatedView).toEqual( + expect.objectContaining({ + schema: expect.objectContaining({ + aux: expect.objectContaining({ + columns: { + id: { visible: false, readonly: false }, + name: { visible: true, readonly: true }, + dob: { visible: true, readonly: true }, + }, + }), + }), + }) + ) + } + }) + }) }) }) diff --git a/packages/server/src/automations/automationUtils.ts b/packages/server/src/automations/automationUtils.ts index bb94df9131..eacf81ef92 100644 --- a/packages/server/src/automations/automationUtils.ts +++ b/packages/server/src/automations/automationUtils.ts @@ -294,3 +294,13 @@ export function typecastForLooping(input: LoopStepInputs) { } return input.binding } + +export function ensureMaxIterationsAsNumber( + value: number | string | undefined +): number | undefined { + if (typeof value === "number") return value + if (typeof value === "string") { + return parseInt(value) + } + return undefined +} diff --git a/packages/server/src/automations/tests/scenarios/scenarios.spec.ts b/packages/server/src/automations/tests/scenarios/scenarios.spec.ts index c0c30e46e4..a0dab7f177 100644 --- a/packages/server/src/automations/tests/scenarios/scenarios.spec.ts +++ b/packages/server/src/automations/tests/scenarios/scenarios.spec.ts @@ -23,42 +23,148 @@ describe("Automation Scenarios", () => { afterAll(setup.afterAll) - // eslint-disable-next-line jest/no-commented-out-tests - // describe("Branching automations", () => { - // eslint-disable-next-line jest/no-commented-out-tests - // it("should run an automation with a trigger, loop, and create row step", async () => { - // const builder = createAutomationBuilder({ - // name: "Test Trigger with Loop and Create Row", - // }) + describe("Branching automations", () => { + it("should run a multiple nested branching automation", async () => { + const builder = createAutomationBuilder({ + name: "Test Trigger with Loop and Create Row", + }) - // builder - // .serverLog({ text: "Starting automation" }) - // .branch({ - // topLevelBranch1: { - // steps: stepBuilder => - // stepBuilder.serverLog({ text: "Branch 1" }).branch({ - // branch1: { - // steps: stepBuilder => - // stepBuilder.serverLog({ text: "Branch 1.1" }), - // condition: { notEmpty: { column: 10 } }, - // }, - // branch2: { - // steps: stepBuilder => - // stepBuilder.serverLog({ text: "Branch 1.2" }), - // condition: { fuzzy: { column: "sadsd" } }, - // }, - // }), - // condition: { equal: { column: 10 } }, - // }, - // topLevelBranch2: { - // steps: stepBuilder => stepBuilder.serverLog({ text: "Branch 2" }), - // condition: { equal: { column: 20 } }, - // }, - // }) - // .run() - // }) + const results = await builder + .appAction({ fields: {} }) + .serverLog({ text: "Starting automation" }) + .branch({ + topLevelBranch1: { + steps: stepBuilder => + stepBuilder.serverLog({ text: "Branch 1" }).branch({ + branch1: { + steps: stepBuilder => + stepBuilder.serverLog({ text: "Branch 1.1" }), + condition: { + equal: { "steps.1.success": true }, + }, + }, + branch2: { + steps: stepBuilder => + stepBuilder.serverLog({ text: "Branch 1.2" }), + condition: { + equal: { "steps.1.success": false }, + }, + }, + }), + condition: { + equal: { "steps.1.success": true }, + }, + }, + topLevelBranch2: { + steps: stepBuilder => stepBuilder.serverLog({ text: "Branch 2" }), + condition: { + equal: { "steps.1.success": false }, + }, + }, + }) + .run() - // }) + expect(results.steps[2].outputs.message).toContain("Branch 1.1") + }) + + it("should execute correct branch based on string equality", async () => { + const builder = createAutomationBuilder({ + name: "String Equality Branching", + }) + + const results = await builder + .appAction({ fields: { status: "active" } }) + .branch({ + activeBranch: { + steps: stepBuilder => + stepBuilder.serverLog({ text: "Active user" }), + condition: { + equal: { "trigger.fields.status": "active" }, + }, + }, + inactiveBranch: { + steps: stepBuilder => + stepBuilder.serverLog({ text: "Inactive user" }), + condition: { + equal: { "trigger.fields.status": "inactive" }, + }, + }, + }) + .run() + + expect(results.steps[0].outputs.message).toContain("Active user") + }) + + it("should handle multiple conditions with AND operator", async () => { + const builder = createAutomationBuilder({ + name: "Multiple AND Conditions Branching", + }) + + const results = await builder + .appAction({ fields: { status: "active", role: "admin" } }) + .branch({ + activeAdminBranch: { + steps: stepBuilder => + stepBuilder.serverLog({ text: "Active admin user" }), + condition: { + $and: { + conditions: [ + { equal: { "trigger.fields.status": "active" } }, + { equal: { "trigger.fields.role": "admin" } }, + ], + }, + }, + }, + otherBranch: { + steps: stepBuilder => stepBuilder.serverLog({ text: "Other user" }), + condition: { + notEqual: { "trigger.fields.status": "active" }, + }, + }, + }) + .run() + + expect(results.steps[0].outputs.message).toContain("Active admin user") + }) + + it("should handle multiple conditions with OR operator", async () => { + const builder = createAutomationBuilder({ + name: "Multiple OR Conditions Branching", + }) + + const results = await builder + .appAction({ fields: { status: "test", role: "user" } }) + .branch({ + specialBranch: { + steps: stepBuilder => + stepBuilder.serverLog({ text: "Special user" }), + condition: { + $or: { + conditions: [ + { equal: { "trigger.fields.status": "test" } }, + { equal: { "trigger.fields.role": "admin" } }, + ], + }, + }, + }, + regularBranch: { + steps: stepBuilder => + stepBuilder.serverLog({ text: "Regular user" }), + condition: { + $and: { + conditions: [ + { notEqual: { "trigger.fields.status": "active" } }, + { notEqual: { "trigger.fields.role": "admin" } }, + ], + }, + }, + }, + }) + .run() + + expect(results.steps[0].outputs.message).toContain("Special user") + }) + }) describe("Loop automations", () => { it("should run an automation with a trigger, loop, and create row step", async () => { @@ -108,6 +214,89 @@ describe("Automation Scenarios", () => { }) }) + it("should run an automation where a loop step is between two normal steps to ensure context correctness", async () => { + const builder = createAutomationBuilder({ + name: "Test Trigger with Loop and Create Row", + }) + + const results = await builder + .rowSaved( + { tableId: table._id! }, + { + row: { + name: "Trigger Row", + description: "This row triggers the automation", + }, + id: "1234", + revision: "1", + } + ) + .queryRows({ + tableId: table._id!, + }) + .loop({ + option: LoopStepType.ARRAY, + binding: [1, 2, 3], + }) + .serverLog({ text: "Message {{loop.currentItem}}" }) + .serverLog({ text: "{{steps.1.rows.0._id}}" }) + .run() + + results.steps[1].outputs.items.forEach( + (output: ServerLogStepOutputs, index: number) => { + expect(output).toMatchObject({ + success: true, + }) + expect(output.message).toContain(`Message ${index + 1}`) + } + ) + + expect(results.steps[2].outputs.message).toContain("ro_ta") + }) + + it("if an incorrect type is passed to the loop it should return an error", async () => { + const builder = createAutomationBuilder({ + name: "Test Loop error", + }) + + const results = await builder + .appAction({ fields: {} }) + .loop({ + option: LoopStepType.ARRAY, + binding: "1, 2, 3", + }) + .serverLog({ text: "Message {{loop.currentItem}}" }) + .run() + + expect(results.steps[0].outputs).toEqual({ + success: false, + status: "INCORRECT_TYPE", + }) + }) + + it("ensure the loop stops if the failure condition is reached", async () => { + const builder = createAutomationBuilder({ + name: "Test Loop error", + }) + + const results = await builder + .appAction({ fields: {} }) + .loop({ + option: LoopStepType.ARRAY, + binding: ["test", "test2", "test3"], + failure: "test2", + }) + .serverLog({ text: "Message {{loop.currentItem}}" }) + .run() + + expect(results.steps[0].outputs).toEqual( + expect.objectContaining({ + status: "FAILURE_CONDITION_MET", + success: false, + }) + ) + }) + it("should run an automation where a loop is successfully run twice", async () => { const builder = createAutomationBuilder({ name: "Test Trigger with Loop and Create Row", diff --git a/packages/server/src/db/linkedRows/index.ts b/packages/server/src/db/linkedRows/index.ts index 9a86df7041..2c8d1f77ac 100644 --- a/packages/server/src/db/linkedRows/index.ts +++ b/packages/server/src/db/linkedRows/index.ts @@ -11,14 +11,17 @@ import { USER_METDATA_PREFIX } from "../utils" import partition from "lodash/partition" import { getGlobalUsersFromMetadata } from "../../utilities/global" import { processFormulas } from "../../utilities/rowProcessor" -import { context } from "@budibase/backend-core" +import { context, features } from "@budibase/backend-core" import { ContextUser, + FeatureFlag, FieldType, LinkDocumentValue, Row, Table, TableSchema, + ViewFieldMetadata, + ViewV2, } from "@budibase/types" import sdk from "../../sdk" @@ -241,34 +244,78 @@ function getPrimaryDisplayValue(row: Row, table?: Table) { } } +export type SquashTableFields = Record + /** * This function will take the given enriched rows and squash the links to only contain the primary display field. * @param table The table from which the rows originated. * @param enriched The pre-enriched rows (full docs) which are to be squashed. + * @param squashFields Per link column (key) define which columns are allowed while squashing. * @returns The rows after having their links squashed to only contain the ID and primary display. */ -export async function squashLinksToPrimaryDisplay( +export async function squashLinks( table: Table, - enriched: Row[] | Row -) { + enriched: T, + options?: { + fromViewId?: string + } +): Promise { + const allowRelationshipSchemas = await features.flags.isEnabled( + FeatureFlag.ENRICHED_RELATIONSHIPS + ) + + let viewSchema: Record = {} + if (options?.fromViewId && allowRelationshipSchemas) { + const view = Object.values(table.views || {}).find( + (v): v is ViewV2 => sdk.views.isV2(v) && v.id === options?.fromViewId + ) + viewSchema = view?.schema || {} + } + // will populate this as we find them const linkedTables = [table] const isArray = Array.isArray(enriched) - let enrichedArray = !isArray ? [enriched] : enriched - for (let row of enrichedArray) { + const enrichedArray = !isArray ? [enriched] : enriched + for (const row of enrichedArray) { // this only fetches the table if its not already in array const rowTable = await getLinkedTable(row.tableId!, linkedTables) - for (let [column, schema] of Object.entries(rowTable?.schema || {})) { + for (let [column, schema] of Object.entries(rowTable.schema)) { if (schema.type !== FieldType.LINK || !Array.isArray(row[column])) { continue } const newLinks = [] - for (let link of row[column]) { + for (const link of row[column]) { const linkTblId = link.tableId || getRelatedTableForField(table.schema, column) const linkedTable = await getLinkedTable(linkTblId!, linkedTables) const obj: any = { _id: link._id } obj.primaryDisplay = getPrimaryDisplayValue(link, linkedTable) + + if (viewSchema[column]?.columns) { + const squashFields = Object.entries(viewSchema[column].columns) + .filter(([columnName, viewColumnConfig]) => { + const tableColumn = linkedTable.schema[columnName] + if (!tableColumn) { + return false + } + if ( + [FieldType.LINK, FieldType.FORMULA].includes(tableColumn.type) + ) { + return false + } + return ( + tableColumn.visible !== false && + viewColumnConfig.visible !== false + ) + }) + + .map(([columnName]) => columnName) + + for (const relField of squashFields) { + obj[relField] = link[relField] + } + } + newLinks.push(obj) } row[column] = newLinks diff --git a/packages/server/src/middleware/triggerRowActionAuthorised.ts b/packages/server/src/middleware/triggerRowActionAuthorised.ts new file mode 100644 index 0000000000..be5c6a97e1 --- /dev/null +++ b/packages/server/src/middleware/triggerRowActionAuthorised.ts @@ -0,0 +1,45 @@ +import { Next } from "koa" +import { Ctx } from "@budibase/types" +import { paramSubResource } from "./resourceId" +import { docIds } from "@budibase/backend-core" +import * as utils from "../db/utils" +import sdk from "../sdk" + +export function triggerRowActionAuthorised( + sourcePath: string, + actionPath: string +) { + return async (ctx: Ctx, next: Next) => { + // Reusing the existing middleware to extract the value + paramSubResource(sourcePath, actionPath)(ctx, () => {}) + const { resourceId: sourceId, subResourceId: rowActionId } = ctx + + const isTableId = docIds.isTableId(sourceId) + const isViewId = utils.isViewID(sourceId) + if (!isTableId && !isViewId) { + ctx.throw(400, `'${sourceId}' is not a valid source id`) + } + + const tableId = isTableId + ? sourceId + : utils.extractViewInfoFromID(sourceId).tableId + + const rowAction = await sdk.rowActions.get(tableId, rowActionId) + + if (isTableId && !rowAction.permissions.table.runAllowed) { + ctx.throw( + 403, + `Row action '${rowActionId}' is not enabled for table '${sourceId}'` + ) + } else if (isViewId && !rowAction.permissions.views[sourceId]?.runAllowed) { + ctx.throw( + 403, + `Row action '${rowActionId}' is not enabled for view '${sourceId}'` + ) + } + + // Enrich tableId + ctx.params.tableId = tableId + return next() + } +} diff --git a/packages/server/src/middleware/trimViewRowInfo.ts b/packages/server/src/middleware/trimViewRowInfo.ts index 2d29a88a6f..0a5f066c9e 100644 --- a/packages/server/src/middleware/trimViewRowInfo.ts +++ b/packages/server/src/middleware/trimViewRowInfo.ts @@ -28,7 +28,7 @@ export default async (ctx: Ctx, next: Next) => { } // have to mutate the koa context, can't return -export async function trimNonViewFields( +async function trimNonViewFields( row: Row, view: ViewV2, permission: "WRITE" | "READ" diff --git a/packages/server/src/sdk/app/automations/utils.ts b/packages/server/src/sdk/app/automations/utils.ts index 5d057697ca..2ce0012b6a 100644 --- a/packages/server/src/sdk/app/automations/utils.ts +++ b/packages/server/src/sdk/app/automations/utils.ts @@ -29,7 +29,7 @@ export async function getBuilderData( const rowActionNameCache: Record = {} async function getRowActionName(tableId: string, rowActionId: string) { if (!rowActionNameCache[tableId]) { - rowActionNameCache[tableId] = await sdk.rowActions.get(tableId) + rowActionNameCache[tableId] = await sdk.rowActions.getAll(tableId) } return rowActionNameCache[tableId].actions[rowActionId]?.name diff --git a/packages/server/src/sdk/app/rowActions.ts b/packages/server/src/sdk/app/rowActions.ts index f557887b15..e963a4317b 100644 --- a/packages/server/src/sdk/app/rowActions.ts +++ b/packages/server/src/sdk/app/rowActions.ts @@ -1,12 +1,11 @@ import { context, HTTPError, utils } from "@budibase/backend-core" - import { AutomationTriggerStepId, SEPARATOR, TableRowActions, VirtualDocumentType, } from "@budibase/types" -import { generateRowActionsID } from "../../db/utils" +import { generateRowActionsID, isViewID } from "../../db/utils" import automations from "./automations" import { definitions as TRIGGER_DEFINITIONS } from "../../automations/triggerInfo" import * as triggers from "../../automations/triggers" @@ -75,6 +74,10 @@ export async function create(tableId: string, rowAction: { name: string }) { doc.actions[newRowActionId] = { name: action.name, automationId: automation._id!, + permissions: { + table: { runAllowed: true }, + views: {}, + }, } await db.put(doc) @@ -84,7 +87,19 @@ export async function create(tableId: string, rowAction: { name: string }) { } } -export async function get(tableId: string) { +export async function get(tableId: string, rowActionId: string) { + const actionsDoc = await getAll(tableId) + const rowAction = actionsDoc?.actions[rowActionId] + if (!rowAction) { + throw new HTTPError( + `Row action '${rowActionId}' not found in '${tableId}'`, + 400 + ) + } + return rowAction +} + +export async function getAll(tableId: string) { const db = context.getAppDB() const rowActionsId = generateRowActionsID(tableId) return await db.get(rowActionsId) @@ -97,41 +112,15 @@ export async function docExists(tableId: string) { return result } -export async function update( +async function updateDoc( tableId: string, rowActionId: string, - rowAction: { name: string } + transformer: ( + tableRowActions: TableRowActions + ) => TableRowActions | Promise ) { - const action = { name: rowAction.name.trim() } - const actionsDoc = await get(tableId) - - if (!actionsDoc.actions[rowActionId]) { - throw new HTTPError( - `Row action '${rowActionId}' not found in '${tableId}'`, - 400 - ) - } - - ensureUniqueAndThrow(actionsDoc, action.name, rowActionId) - - actionsDoc.actions[rowActionId] = { - automationId: actionsDoc.actions[rowActionId].automationId, - ...action, - } - - const db = context.getAppDB() - await db.put(actionsDoc) - - return { - id: rowActionId, - ...actionsDoc.actions[rowActionId], - } -} - -export async function remove(tableId: string, rowActionId: string) { - const actionsDoc = await get(tableId) - - const rowAction = actionsDoc.actions[rowActionId] + const actionsDoc = await getAll(tableId) + const rowAction = actionsDoc?.actions[rowActionId] if (!rowAction) { throw new HTTPError( `Row action '${rowActionId}' not found in '${tableId}'`, @@ -139,13 +128,76 @@ export async function remove(tableId: string, rowActionId: string) { ) } - const { automationId } = rowAction - const automation = await automations.get(automationId) - await automations.remove(automation._id, automation._rev) - delete actionsDoc.actions[rowActionId] + const updated = await transformer(actionsDoc) const db = context.getAppDB() - await db.put(actionsDoc) + await db.put(updated) + + return { + id: rowActionId, + ...updated.actions[rowActionId], + } +} + +export async function update( + tableId: string, + rowActionId: string, + rowActionData: { name: string } +) { + const newName = rowActionData.name.trim() + + return await updateDoc(tableId, rowActionId, actionsDoc => { + ensureUniqueAndThrow(actionsDoc, newName, rowActionId) + actionsDoc.actions[rowActionId].name = newName + return actionsDoc + }) +} + +async function guardView(tableId: string, viewId: string) { + let view + if (isViewID(viewId)) { + view = await sdk.views.get(viewId) + } + if (!view || view.tableId !== tableId) { + throw new HTTPError(`View '${viewId}' not found in '${tableId}'`, 400) + } +} + +export async function setViewPermission( + tableId: string, + rowActionId: string, + viewId: string +) { + await guardView(tableId, viewId) + return await updateDoc(tableId, rowActionId, async actionsDoc => { + actionsDoc.actions[rowActionId].permissions.views[viewId] = { + runAllowed: true, + } + return actionsDoc + }) +} + +export async function unsetViewPermission( + tableId: string, + rowActionId: string, + viewId: string +) { + await guardView(tableId, viewId) + return await updateDoc(tableId, rowActionId, async actionsDoc => { + delete actionsDoc.actions[rowActionId].permissions.views[viewId] + return actionsDoc + }) +} + +export async function remove(tableId: string, rowActionId: string) { + return await updateDoc(tableId, rowActionId, async actionsDoc => { + const { automationId } = actionsDoc.actions[rowActionId] + const automation = await automations.get(automationId) + await automations.remove(automation._id, automation._rev) + + delete actionsDoc.actions[rowActionId] + return actionsDoc + }) } export async function run(tableId: any, rowActionId: any, rowId: string) { @@ -154,7 +206,7 @@ export async function run(tableId: any, rowActionId: any, rowId: string) { throw new HTTPError("Table not found", 404) } - const { actions } = await get(tableId) + const { actions } = await getAll(tableId) const rowAction = actions[rowActionId] if (!rowAction) { @@ -164,11 +216,15 @@ export async function run(tableId: any, rowActionId: any, rowId: string) { const automation = await sdk.automations.get(rowAction.automationId) const row = await sdk.rows.find(tableId, rowId) - await triggers.externalTrigger(automation, { - fields: { - row, - table, + await triggers.externalTrigger( + automation, + { + fields: { + row, + table, + }, + appId: context.getAppId(), }, - appId: context.getAppId(), - }) + { getResponses: true } + ) } diff --git a/packages/server/src/sdk/app/rows/external.ts b/packages/server/src/sdk/app/rows/external.ts index f81d67f621..7630e5638b 100644 --- a/packages/server/src/sdk/app/rows/external.ts +++ b/packages/server/src/sdk/app/rows/external.ts @@ -9,6 +9,7 @@ import { } from "../../../utilities/rowProcessor" import cloneDeep from "lodash/fp/cloneDeep" import isEqual from "lodash/fp/isEqual" +import { tryExtractingTableAndViewId } from "./utils" export async function getRow( tableId: string, @@ -26,10 +27,11 @@ export async function getRow( } export async function save( - tableId: string, + tableOrViewId: string, inputs: Row, userId: string | undefined ) { + const { tableId, viewId } = tryExtractingTableAndViewId(tableOrViewId) const table = await sdk.tables.getTable(tableId) const { table: updatedTable, row } = await inputProcessing( userId, @@ -63,6 +65,7 @@ export async function save( row: await outputProcessing(table, row, { preserveLinks: true, squash: true, + fromViewId: viewId, }), } } else { @@ -70,7 +73,9 @@ export async function save( } } -export async function find(tableId: string, rowId: string): Promise { +export async function find(tableOrViewId: string, rowId: string): Promise { + const { tableId, viewId } = tryExtractingTableAndViewId(tableOrViewId) + const row = await getRow(tableId, rowId, { relationships: true, }) @@ -84,5 +89,6 @@ export async function find(tableId: string, rowId: string): Promise { return await outputProcessing(table, row, { squash: true, preserveLinks: true, + fromViewId: viewId, }) } diff --git a/packages/server/src/sdk/app/rows/internal.ts b/packages/server/src/sdk/app/rows/internal.ts index c21d3465a7..b5b3437e5e 100644 --- a/packages/server/src/sdk/app/rows/internal.ts +++ b/packages/server/src/sdk/app/rows/internal.ts @@ -10,12 +10,14 @@ import { import * as linkRows from "../../../db/linkedRows" import { InternalTables } from "../../../db/utils" import { getFullUser } from "../../../utilities/users" +import { tryExtractingTableAndViewId } from "./utils" export async function save( - tableId: string, + tableOrViewId: string, inputs: Row, userId: string | undefined ) { + const { tableId, viewId } = tryExtractingTableAndViewId(tableOrViewId) inputs.tableId = tableId if (!inputs._rev && !inputs._id) { @@ -50,14 +52,17 @@ export async function save( return finaliseRow(table, row, { oldTable: dbTable, updateFormula: true, + fromViewId: viewId, }) } -export async function find(tableId: string, rowId: string): Promise { +export async function find(tableOrViewId: string, rowId: string): Promise { + const { tableId, viewId } = tryExtractingTableAndViewId(tableOrViewId) + const table = await sdk.tables.getTable(tableId) let row = await findRow(tableId, rowId) - row = await outputProcessing(table, row) + row = await outputProcessing(table, row, { squash: true, fromViewId: viewId }) return row } diff --git a/packages/server/src/sdk/app/rows/rows.ts b/packages/server/src/sdk/app/rows/rows.ts index ef03210800..c61b8692ed 100644 --- a/packages/server/src/sdk/app/rows/rows.ts +++ b/packages/server/src/sdk/app/rows/rows.ts @@ -1,6 +1,10 @@ import { db as dbCore, context } from "@budibase/backend-core" import { Database, Row } from "@budibase/types" -import { getRowParams } from "../../../db/utils" +import { + extractViewInfoFromID, + getRowParams, + isViewID, +} from "../../../db/utils" import { isExternalTableID } from "../../../integrations/utils" import * as internal from "./internal" import * as external from "./external" @@ -20,7 +24,12 @@ export async function getAllInternalRows(appId?: string) { return response.rows.map(row => row.doc) as Row[] } -function pickApi(tableId: any) { +function pickApi(tableOrViewId: string) { + let tableId = tableOrViewId + if (isViewID(tableOrViewId)) { + tableId = extractViewInfoFromID(tableOrViewId).tableId + } + if (isExternalTableID(tableId)) { return external } @@ -28,13 +37,13 @@ function pickApi(tableId: any) { } export async function save( - tableId: string, + tableOrViewId: string, row: Row, userId: string | undefined ) { - return pickApi(tableId).save(tableId, row, userId) + return pickApi(tableOrViewId).save(tableOrViewId, row, userId) } -export async function find(tableId: string, rowId: string) { - return pickApi(tableId).find(tableId, rowId) +export async function find(tableOrViewId: string, rowId: string) { + return pickApi(tableOrViewId).find(tableOrViewId, rowId) } diff --git a/packages/server/src/sdk/app/rows/search/external.ts b/packages/server/src/sdk/app/rows/search/external.ts index 992596ab34..0ff25a00e4 100644 --- a/packages/server/src/sdk/app/rows/search/external.ts +++ b/packages/server/src/sdk/app/rows/search/external.ts @@ -112,9 +112,10 @@ export async function search( : Promise.resolve(undefined), ]) - let processed = await outputProcessing(table, rows, { + let processed = await outputProcessing(table, rows, { preserveLinks: true, squash: true, + fromViewId: options.viewId, }) let hasNextPage = false @@ -260,7 +261,7 @@ export async function fetch(tableId: string): Promise { includeSqlRelationships: IncludeRelationship.INCLUDE, }) const table = await sdk.tables.getTable(tableId) - return await outputProcessing(table, response.rows, { + return await outputProcessing(table, response.rows, { preserveLinks: true, squash: true, }) diff --git a/packages/server/src/sdk/app/rows/search/internal/internal.ts b/packages/server/src/sdk/app/rows/search/internal/internal.ts index ae7bca3b0c..6617fc376c 100644 --- a/packages/server/src/sdk/app/rows/search/internal/internal.ts +++ b/packages/server/src/sdk/app/rows/search/internal/internal.ts @@ -61,7 +61,7 @@ export async function exportRows( }) ).rows.map(row => row.doc!) - result = await outputProcessing(table, response) + result = await outputProcessing(table, response) } else if (query) { let searchResponse = await sdk.rows.search({ tableId, diff --git a/packages/server/src/sdk/app/rows/search/internal/lucene.ts b/packages/server/src/sdk/app/rows/search/internal/lucene.ts index a25803804b..2c149e5b21 100644 --- a/packages/server/src/sdk/app/rows/search/internal/lucene.ts +++ b/packages/server/src/sdk/app/rows/search/internal/lucene.ts @@ -59,7 +59,10 @@ export async function search( response.rows = response.rows.map((r: any) => pick(r, fields)) } - response.rows = await outputProcessing(table, response.rows) + response.rows = await outputProcessing(table, response.rows, { + squash: true, + fromViewId: options.viewId, + }) } return response diff --git a/packages/server/src/sdk/app/rows/search/internal/sqs.ts b/packages/server/src/sdk/app/rows/search/internal/sqs.ts index 6736ff6abf..64489042e5 100644 --- a/packages/server/src/sdk/app/rows/search/internal/sqs.ts +++ b/packages/server/src/sdk/app/rows/search/internal/sqs.ts @@ -379,9 +379,10 @@ export async function search( } // get the rows - let finalRows = await outputProcessing(table, processed, { + let finalRows = await outputProcessing(table, processed, { preserveLinks: true, squash: true, + fromViewId: options.viewId, }) // check if we need to pick specific rows out diff --git a/packages/server/src/sdk/app/rows/utils.ts b/packages/server/src/sdk/app/rows/utils.ts index 0cae39f5a9..bc09116b3b 100644 --- a/packages/server/src/sdk/app/rows/utils.ts +++ b/packages/server/src/sdk/app/rows/utils.ts @@ -17,7 +17,11 @@ import { import { makeExternalQuery } from "../../../integrations/base/query" import { Format } from "../../../api/controllers/view/exporters" import sdk from "../.." -import { isRelationshipColumn } from "../../../db/utils" +import { + extractViewInfoFromID, + isRelationshipColumn, + isViewID, +} from "../../../db/utils" import { isSQL } from "../../../integrations/utils" const SQL_CLIENT_SOURCE_MAP: Record = { @@ -317,3 +321,14 @@ function validateTimeOnlyField( export function isArrayFilter(operator: any): operator is ArrayOperator { return Object.values(ArrayOperator).includes(operator) } + +export function tryExtractingTableAndViewId(tableOrViewId: string) { + if (isViewID(tableOrViewId)) { + return { + tableId: extractViewInfoFromID(tableOrViewId).tableId, + viewId: tableOrViewId, + } + } + + return { tableId: tableOrViewId } +} diff --git a/packages/server/src/sdk/app/tables/getters.ts b/packages/server/src/sdk/app/tables/getters.ts index ddb1bbbf6c..27e9962a1a 100644 --- a/packages/server/src/sdk/app/tables/getters.ts +++ b/packages/server/src/sdk/app/tables/getters.ts @@ -143,16 +143,19 @@ export async function getTables(tableIds: string[]): Promise { return await processTables(tables) } -export function enrichViewSchemas(table: Table): TableResponse { +export async function enrichViewSchemas(table: Table): Promise { + const views = [] + for (const view of Object.values(table.views ?? [])) { + if (sdk.views.isV2(view)) { + views.push(await sdk.views.enrichSchema(view, table.schema)) + } else views.push(view) + } + return { ...table, - views: Object.values(table.views ?? []) - .map(v => - sdk.views.isV2(v) ? sdk.views.enrichSchema(v, table.schema) : v - ) - .reduce((p, v) => { - p[v.name!] = v - return p - }, {} as TableViewsResponse), + views: views.reduce((p, v) => { + p[v.name!] = v + return p + }, {} as TableViewsResponse), } } diff --git a/packages/server/src/sdk/app/tables/tests/tables.spec.ts b/packages/server/src/sdk/app/tables/tests/tables.spec.ts index 6e2cf9efa8..41ac808f5c 100644 --- a/packages/server/src/sdk/app/tables/tests/tables.spec.ts +++ b/packages/server/src/sdk/app/tables/tests/tables.spec.ts @@ -75,7 +75,7 @@ describe("table sdk", () => { const view1 = getTable() const view2 = getTable() const view3 = getTable() - const res = sdk.tables.enrichViewSchemas({ + const res = await sdk.tables.enrichViewSchemas({ ...basicTable, views: { [view1.name]: view1, diff --git a/packages/server/src/sdk/app/views/external.ts b/packages/server/src/sdk/app/views/external.ts index 2b3e271597..3afd7e9bf9 100644 --- a/packages/server/src/sdk/app/views/external.ts +++ b/packages/server/src/sdk/app/views/external.ts @@ -33,7 +33,7 @@ export async function getEnriched(viewId: string): Promise { if (!found) { throw new Error("No view found") } - return enrichSchema(found, table.schema) + return await enrichSchema(found, table.schema) } export async function create( diff --git a/packages/server/src/sdk/app/views/index.ts b/packages/server/src/sdk/app/views/index.ts index ed624c2b5c..a0cffb2634 100644 --- a/packages/server/src/sdk/app/views/index.ts +++ b/packages/server/src/sdk/app/views/index.ts @@ -1,8 +1,11 @@ import { + FieldType, + RelationSchemaField, RenameColumn, + Table, TableSchema, View, - ViewUIFieldMetadata, + ViewFieldMetadata, ViewV2, ViewV2Enriched, } from "@budibase/types" @@ -57,7 +60,7 @@ async function guardViewSchema( if (viewSchema[field].readonly) { if ( !(await features.isViewReadonlyColumnsEnabled()) && - !(tableSchemaField as ViewUIFieldMetadata).readonly + !(tableSchemaField as ViewFieldMetadata).readonly ) { throw new HTTPError(`Readonly fields are not enabled`, 400) } @@ -158,24 +161,64 @@ export function allowedFields( ] } -export function enrichSchema( +export async function enrichSchema( view: ViewV2, tableSchema: TableSchema -): ViewV2Enriched { - let schema: TableSchema = {} - const anyViewOrder = Object.values(view.schema || {}).some( - ui => ui.order != null - ) +): Promise { + const tableCache: Record = {} + + async function populateRelTableSchema( + tableId: string, + viewFields: Record + ) { + if (!tableCache[tableId]) { + tableCache[tableId] = await sdk.tables.getTable(tableId) + } + const relTable = tableCache[tableId] + + const result: Record = {} + + for (const relTableFieldName of Object.keys(relTable.schema)) { + const relTableField = relTable.schema[relTableFieldName] + if ([FieldType.LINK, FieldType.FORMULA].includes(relTableField.type)) { + continue + } + + if (relTableField.visible === false) { + continue + } + + const isVisible = !!viewFields[relTableFieldName]?.visible + const isReadonly = !!viewFields[relTableFieldName]?.readonly + result[relTableFieldName] = { + visible: isVisible, + readonly: isReadonly, + } + } + return result + } + + let schema: ViewV2Enriched["schema"] = {} + + const viewSchema = view.schema || {} + const anyViewOrder = Object.values(viewSchema).some(ui => ui.order != null) for (const key of Object.keys(tableSchema).filter( - key => tableSchema[key].visible !== false + k => tableSchema[k].visible !== false )) { // if nothing specified in view, then it is not visible - const ui = view.schema?.[key] || { visible: false } + const ui = viewSchema[key] || { visible: false } schema[key] = { ...tableSchema[key], ...ui, order: anyViewOrder ? ui?.order ?? undefined : tableSchema[key].order, } + + if (schema[key].type === FieldType.LINK) { + schema[key].columns = await populateRelTableSchema( + schema[key].tableId, + viewSchema[key]?.columns || {} + ) + } } return { @@ -209,3 +252,48 @@ export function syncSchema( return view } + +export async function renameLinkedViews(table: Table, renaming: RenameColumn) { + const relatedTables: Record = {} + + for (const field of Object.values(table.schema)) { + if (field.type !== FieldType.LINK) { + continue + } + + relatedTables[field.tableId] ??= await sdk.tables.getTable(field.tableId) + } + + for (const relatedTable of Object.values(relatedTables)) { + let toSave = false + const viewsV2 = Object.values(relatedTable.views || {}).filter( + sdk.views.isV2 + ) + if (!viewsV2) { + continue + } + + for (const view of viewsV2) { + for (const relField of Object.keys(view.schema || {}).filter(f => { + const tableField = relatedTable.schema[f] + if (!tableField || tableField.type !== FieldType.LINK) { + return false + } + + return tableField.tableId === table._id + })) { + const columns = view.schema?.[relField]?.columns + + if (columns && columns[renaming.old]) { + columns[renaming.updated] = columns[renaming.old] + delete columns[renaming.old] + toSave = true + } + } + } + + if (toSave) { + await sdk.tables.saveTable(relatedTable) + } + } +} diff --git a/packages/server/src/sdk/app/views/internal.ts b/packages/server/src/sdk/app/views/internal.ts index 7b2f9f6c80..19a9f6ab14 100644 --- a/packages/server/src/sdk/app/views/internal.ts +++ b/packages/server/src/sdk/app/views/internal.ts @@ -24,7 +24,7 @@ export async function getEnriched(viewId: string): Promise { if (!found) { throw new Error("No view found") } - return enrichSchema(found, table.schema) + return await enrichSchema(found, table.schema) } export async function create( 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 9dd4a7fb69..5a86702ab6 100644 --- a/packages/server/src/sdk/app/views/tests/views.spec.ts +++ b/packages/server/src/sdk/app/views/tests/views.spec.ts @@ -3,6 +3,7 @@ import { FieldSchema, FieldType, INTERNAL_TABLE_SOURCE_ID, + RelationshipType, Table, TableSchema, TableSourceType, @@ -10,6 +11,7 @@ import { } from "@budibase/types" import { generator } from "@budibase/backend-core/tests" import { enrichSchema, syncSchema } from ".." +import sdk from "../../../../sdk" describe("table sdk", () => { const basicTable: Table = { @@ -58,7 +60,7 @@ describe("table sdk", () => { }, } - describe("enrichViewSchemas", () => { + describe("enrichSchema", () => { it("should fetch the default schema if not overridden", async () => { const tableId = basicTable._id! const view: ViewV2 = { @@ -68,7 +70,7 @@ describe("table sdk", () => { tableId, } - const res = enrichSchema(view, basicTable.schema) + const res = await enrichSchema(view, basicTable.schema) expect(res).toEqual({ ...view, @@ -118,7 +120,7 @@ describe("table sdk", () => { }, } - const res = enrichSchema(view, basicTable.schema) + const res = await enrichSchema(view, basicTable.schema) expect(res).toEqual({ ...view, @@ -152,7 +154,7 @@ describe("table sdk", () => { }, } - const res = enrichSchema(view, basicTable.schema) + const res = await enrichSchema(view, basicTable.schema) expect(res).toEqual({ ...view, @@ -187,7 +189,7 @@ describe("table sdk", () => { }, } - const res = enrichSchema(view, basicTable.schema) + const res = await enrichSchema(view, basicTable.schema) expect(res).toEqual( expect.objectContaining({ @@ -241,7 +243,7 @@ describe("table sdk", () => { }, } - const res = enrichSchema(view, basicTable.schema) + const res = await enrichSchema(view, basicTable.schema) expect(res).toEqual( expect.objectContaining({ @@ -280,6 +282,92 @@ describe("table sdk", () => { }) ) }) + + it("should include related fields", async () => { + const table: Table = { + ...basicTable, + schema: { + name: { + name: "name", + type: FieldType.STRING, + }, + other: { + name: "other", + type: FieldType.LINK, + relationshipType: RelationshipType.ONE_TO_MANY, + fieldName: "table", + tableId: "otherTableId", + }, + }, + } + + const otherTable: Table = { + ...basicTable, + primaryDisplay: "title", + schema: { + title: { + name: "title", + type: FieldType.STRING, + }, + age: { + name: "age", + type: FieldType.NUMBER, + }, + }, + } + + const tableId = table._id! + + const getTableSpy = jest.spyOn(sdk.tables, "getTable") + getTableSpy.mockResolvedValueOnce(otherTable) + + const view: ViewV2 = { + version: 2, + id: generator.guid(), + name: generator.guid(), + tableId, + schema: { + name: { visible: true }, + other: { + visible: true, + columns: { + title: { + visible: true, + readonly: true, + }, + }, + }, + }, + } + + const res = await enrichSchema(view, table.schema) + + expect(res).toEqual( + expect.objectContaining({ + ...view, + schema: { + name: { + ...table.schema.name, + visible: true, + }, + other: { + ...table.schema.other, + visible: true, + columns: { + title: { + visible: true, + readonly: true, + }, + age: { + visible: false, + readonly: false, + }, + }, + }, + }, + }) + ) + }) }) describe("syncSchema", () => { diff --git a/packages/server/src/tests/utilities/api/base.ts b/packages/server/src/tests/utilities/api/base.ts index 3a5f6529f8..39ac5cefc0 100644 --- a/packages/server/src/tests/utilities/api/base.ts +++ b/packages/server/src/tests/utilities/api/base.ts @@ -40,6 +40,7 @@ export interface RequestOpts { > expectations?: Expectations publicUser?: boolean + useProdApp?: boolean } export abstract class TestAPI { @@ -107,8 +108,12 @@ export abstract class TestAPI { } const headersFn = publicUser - ? this.config.publicHeaders.bind(this.config) - : this.config.defaultHeaders.bind(this.config) + ? (_extras = {}) => + this.config.publicHeaders.bind(this.config)({ + prodApp: opts?.useProdApp, + }) + : (extras = {}) => + this.config.defaultHeaders.bind(this.config)(extras, opts?.useProdApp) const app = getServer() let req = request(app)[method](url) diff --git a/packages/server/src/tests/utilities/api/rowAction.ts b/packages/server/src/tests/utilities/api/rowAction.ts index 80535e5853..1adb60d235 100644 --- a/packages/server/src/tests/utilities/api/rowAction.ts +++ b/packages/server/src/tests/utilities/api/rowAction.ts @@ -2,6 +2,7 @@ import { CreateRowActionRequest, RowActionResponse, RowActionsResponse, + RowActionTriggerRequest, } from "@budibase/types" import { Expectations, TestAPI } from "./base" @@ -17,8 +18,8 @@ export class RowActionAPI extends TestAPI { { body: rowAction, expectations: { + status: 201, ...expectations, - status: expectations?.status || 201, }, ...config, } @@ -70,4 +71,59 @@ export class RowActionAPI extends TestAPI { } ) } + + setViewPermission = async ( + tableId: string, + viewId: string, + rowActionId: string, + expectations?: Expectations, + config?: { publicUser?: boolean } + ) => { + return await this._post( + `/api/tables/${tableId}/actions/${rowActionId}/permissions/${viewId}`, + { + expectations: { + status: 200, + ...expectations, + }, + ...config, + } + ) + } + + unsetViewPermission = async ( + tableId: string, + viewId: string, + rowActionId: string, + expectations?: Expectations, + config?: { publicUser?: boolean } + ) => { + return await this._delete( + `/api/tables/${tableId}/actions/${rowActionId}/permissions/${viewId}`, + { + expectations: { + status: 200, + ...expectations, + }, + ...config, + } + ) + } + + trigger = async ( + tableId: string, + rowActionId: string, + body: RowActionTriggerRequest, + expectations?: Expectations, + config?: { publicUser?: boolean; useProdApp?: boolean } + ) => { + return await this._post( + `/api/tables/${tableId}/actions/${rowActionId}/trigger`, + { + body, + expectations, + ...{ ...config, useProdApp: config?.useProdApp ?? true }, + } + ) + } } diff --git a/packages/server/src/tests/utilities/structures.ts b/packages/server/src/tests/utilities/structures.ts index 8d64734ee3..2e501932b8 100644 --- a/packages/server/src/tests/utilities/structures.ts +++ b/packages/server/src/tests/utilities/structures.ts @@ -30,6 +30,7 @@ import { BBReferenceFieldSubType, JsonFieldSubType, AutoFieldSubType, + CreateViewRequest, } from "@budibase/types" import { LoopInput } from "../../definitions/automations" import { merge } from "lodash" @@ -145,6 +146,17 @@ export function view(tableId: string) { } } +function viewV2CreateRequest(tableId: string): CreateViewRequest { + return { + tableId, + name: generator.guid(), + } +} + +export const viewV2 = { + createRequest: viewV2CreateRequest, +} + export function automationStep( actionDefinition = BUILTIN_ACTION_DEFINITIONS.CREATE_ROW ): AutomationStep { diff --git a/packages/server/src/threads/automation.ts b/packages/server/src/threads/automation.ts index c2470e78d4..eff8407104 100644 --- a/packages/server/src/threads/automation.ts +++ b/packages/server/src/threads/automation.ts @@ -8,7 +8,7 @@ import { import * as actions from "../automations/actions" import * as automationUtils from "../automations/automationUtils" import { replaceFakeBindings } from "../automations/loopUtils" - +import { dataFilters, helpers } from "@budibase/shared-core" import { default as AutomationEmitter } from "../events/AutomationEmitter" import { generateAutomationMetadataID, isProdAppID } from "../db/utils" import { definitions as triggerDefs } from "../automations/triggerInfo" @@ -23,12 +23,14 @@ import { AutomationStatus, AutomationStep, AutomationStepStatus, + BranchStep, LoopStep, + SearchFilters, } from "@budibase/types" import { AutomationContext, TriggerOutput } from "../definitions/automations" import { WorkerCallback } from "./definitions" import { context, logging } from "@budibase/backend-core" -import { processObject } from "@budibase/string-templates" +import { processObject, processStringSync } from "@budibase/string-templates" import { cloneDeep } from "lodash/fp" import { performance } from "perf_hooks" import * as sdkUtils from "../sdk/utils" @@ -64,36 +66,40 @@ function getLoopIterations(loopStep: LoopStep) { * inputs and handles any outputs. */ class Orchestrator { - _chainCount: number - _appId: string - _automation: Automation - _emitter: any - _context: AutomationContext - _job: Job - executionOutput: AutomationContext + private chainCount: number + private appId: string + private automation: Automation + private emitter: any + private context: AutomationContext + private job: Job + private loopStepOutputs: LoopStep[] + private stopped: boolean + private executionOutput: AutomationContext constructor(job: AutomationJob) { let automation = job.data.automation let triggerOutput = job.data.event const metadata = triggerOutput.metadata - this._chainCount = metadata ? metadata.automationChainCount! : 0 - this._appId = triggerOutput.appId as string - this._job = job + this.chainCount = metadata ? metadata.automationChainCount! : 0 + this.appId = triggerOutput.appId as string + this.job = job const triggerStepId = automation.definition.trigger.stepId triggerOutput = this.cleanupTriggerOutputs(triggerStepId, triggerOutput) // remove from context delete triggerOutput.appId delete triggerOutput.metadata // step zero is never used as the template string is zero indexed for customer facing - this._context = { steps: [{}], trigger: triggerOutput } - this._automation = automation + this.context = { steps: [{}], trigger: triggerOutput } + this.automation = automation // create an emitter which has the chain count for this automation run in it, so it can block // excessive chaining if required - this._emitter = new AutomationEmitter(this._chainCount + 1) + this.emitter = new AutomationEmitter(this.chainCount + 1) this.executionOutput = { trigger: {}, steps: [] } // setup the execution output const triggerId = automation.definition.trigger.id this.updateExecutionOutput(triggerId, triggerStepId, null, triggerOutput) + this.loopStepOutputs = [] + this.stopped = false } cleanupTriggerOutputs(stepId: string, triggerOutput: TriggerOutput) { @@ -112,7 +118,7 @@ class Orchestrator { } async getMetadata(): Promise { - const metadataId = generateAutomationMetadataID(this._automation._id!) + const metadataId = generateAutomationMetadataID(this.automation._id!) const db = context.getAppDB() let metadata: AutomationMetadata try { @@ -127,15 +133,15 @@ class Orchestrator { } async stopCron(reason: string) { - if (!this._job.opts.repeat) { + if (!this.job.opts.repeat) { return } logging.logWarn( - `CRON disabled reason=${reason} - ${this._appId}/${this._automation._id}` + `CRON disabled reason=${reason} - ${this.appId}/${this.automation._id}` ) - const automation = this._automation + const automation = this.automation const trigger = automation.definition.trigger - await disableCronById(this._job.id) + await disableCronById(this.job.id) this.updateExecutionOutput( trigger.id, trigger.stepId, @@ -149,7 +155,7 @@ class Orchestrator { } async checkIfShouldStop(metadata: AutomationMetadata): Promise { - if (!metadata.errorCount || !this._job.opts.repeat) { + if (!metadata.errorCount || !this.job.opts.repeat) { return false } if (metadata.errorCount >= MAX_AUTOMATION_RECURRING_ERRORS) { @@ -161,7 +167,7 @@ class Orchestrator { async updateMetadata(metadata: AutomationMetadata) { const output = this.executionOutput, - automation = this._automation + automation = this.automation if (!output || !isRecurring(automation)) { return } @@ -216,7 +222,7 @@ class Orchestrator { output: any, result: { success: boolean; status: string } ) { - if (!currentLoopStepIndex) { + if (currentLoopStepIndex === undefined) { throw new Error("No loop step number provided.") } this.executionOutput.steps.splice(currentLoopStepIndex, 0, { @@ -229,7 +235,7 @@ class Orchestrator { }, inputs: step.inputs, }) - this._context.steps.splice(currentLoopStepIndex, 0, { + this.context.steps.splice(currentLoopStepIndex, 0, { ...output, success: result.success, status: result.status, @@ -242,25 +248,15 @@ class Orchestrator { { resource: "automation" }, async span => { span?.addTags({ - appId: this._appId, - automationId: this._automation._id, + appId: this.appId, + automationId: this.automation._id, }) + this.context.env = await sdkUtils.getEnvironmentVariables() - // this will retrieve from context created at start of thread - this._context.env = await sdkUtils.getEnvironmentVariables() - let automation = this._automation - let stopped = false - let loopStep: LoopStep | undefined - - let stepCount = 0 - let currentLoopStepIndex: number = 0 - let loopSteps: LoopStep[] | undefined = [] let metadata - let timeoutFlag = false - let wasLoopStep = false - let timeout = this._job.data.event.timeout + // check if this is a recurring automation, - if (isProdAppID(this._appId) && isRecurring(automation)) { + if (isProdAppID(this.appId) && isRecurring(this.automation)) { span?.addTags({ recurring: true }) metadata = await this.getMetadata() const shouldStop = await this.checkIfShouldStop(metadata) @@ -270,272 +266,22 @@ class Orchestrator { } } const start = performance.now() - for (const step of automation.definition.steps) { - const stepSpan = tracer.startSpan("Orchestrator.execute.step", { - childOf: span, - }) - stepSpan.addTags({ - resource: "automation", - step: { - stepId: step.stepId, - id: step.id, - name: step.name, - type: step.type, - title: step.stepTitle, - internal: step.internal, - deprecated: step.deprecated, - }, - }) - let input, - iterations = 1, - iterationCount = 0 - - try { - if (timeoutFlag) { - span?.addTags({ timedOut: true }) - break - } - - if (timeout) { - setTimeout(() => { - timeoutFlag = true - }, timeout || env.AUTOMATION_THREAD_TIMEOUT) - } - - stepCount++ - if (step.stepId === AutomationActionStepId.LOOP) { - loopStep = step - currentLoopStepIndex = stepCount - continue - } - - if (loopStep) { - input = await processObject(loopStep.inputs, this._context) - iterations = getLoopIterations(loopStep) - stepSpan?.addTags({ step: { iterations } }) - } - - for (let stepIndex = 0; stepIndex < iterations; stepIndex++) { - let originalStepInput = cloneDeep(step.inputs) - if (loopStep && input?.binding) { - let tempOutput = { - items: loopSteps, - iterations: iterationCount, - } - try { - loopStep.inputs.binding = automationUtils.typecastForLooping( - loopStep.inputs - ) - } catch (err) { - this.updateContextAndOutput( - currentLoopStepIndex, - step, - tempOutput, - { - status: AutomationErrors.INCORRECT_TYPE, - success: false, - } - ) - loopSteps = undefined - loopStep = undefined - break - } - let item: any[] = [] - if ( - typeof loopStep.inputs.binding === "string" && - loopStep.inputs.option === "String" - ) { - item = automationUtils.stringSplit(loopStep.inputs.binding) - } else if (Array.isArray(loopStep.inputs.binding)) { - item = loopStep.inputs.binding - } - this._context.steps[currentLoopStepIndex] = { - currentItem: item[stepIndex], - } - - originalStepInput = replaceFakeBindings( - originalStepInput, - currentLoopStepIndex - ) - - if ( - stepIndex === env.AUTOMATION_MAX_ITERATIONS || - (loopStep.inputs.iterations && - stepIndex === loopStep.inputs.iterations) - ) { - this.updateContextAndOutput( - currentLoopStepIndex, - step, - tempOutput, - { - status: AutomationErrors.MAX_ITERATIONS, - success: true, - } - ) - loopSteps = undefined - loopStep = undefined - break - } - - let isFailure = false - const currentItem = - this._context.steps[currentLoopStepIndex]?.currentItem - if (currentItem && typeof currentItem === "object") { - isFailure = Object.keys(currentItem).some(value => { - return currentItem[value] === loopStep?.inputs.failure - }) - } else { - isFailure = - currentItem && currentItem === loopStep.inputs.failure - } - - if (isFailure) { - this.updateContextAndOutput( - currentLoopStepIndex, - step, - tempOutput, - { - status: AutomationErrors.FAILURE_CONDITION, - success: false, - } - ) - loopSteps = undefined - loopStep = undefined - break - } - } - - // execution stopped, record state for that - if (stopped) { - this.updateExecutionOutput( - step.id, - step.stepId, - {}, - STOPPED_STATUS - ) - continue - } - - let stepFn = await this.getStepFunctionality( - step.stepId as AutomationActionStepId - ) - let inputs = await processObject(originalStepInput, this._context) - inputs = automationUtils.cleanInputValues( - inputs, - step.schema.inputs - ) - try { - // appId is always passed - const outputs = await stepFn({ - inputs: inputs, - appId: this._appId, - emitter: this._emitter, - context: this._context, - }) - - this._context.steps[stepCount] = outputs - // if filter causes us to stop execution don't break the loop, set a var - // so that we can finish iterating through the steps and record that it stopped - if ( - step.stepId === AutomationActionStepId.FILTER && - !outputs.result - ) { - stopped = true - this.updateExecutionOutput( - step.id, - step.stepId, - step.inputs, - { - ...outputs, - ...STOPPED_STATUS, - } - ) - continue - } - if (loopStep && loopSteps) { - loopSteps.push(outputs) - } else { - this.updateExecutionOutput( - step.id, - step.stepId, - step.inputs, - outputs - ) - } - } catch (err) { - console.error(`Automation error - ${step.stepId} - ${err}`) - return err - } - - if (loopStep) { - iterationCount++ - if (stepIndex === iterations - 1) { - loopStep = undefined - this._context.steps.splice(currentLoopStepIndex, 1) - break - } - } - } - } finally { - stepSpan?.finish() - } - - if (loopStep && iterations === 0) { - loopStep = undefined - this.executionOutput.steps.splice(currentLoopStepIndex + 1, 0, { - id: step.id, - stepId: step.stepId, - outputs: { - status: AutomationStepStatus.NO_ITERATIONS, - success: true, - }, - inputs: {}, - }) - - this._context.steps.splice(currentLoopStepIndex, 1) - iterations = 1 - } - - // Delete the step after the loop step as it's irrelevant, since information is included - // in the loop step - if (wasLoopStep && !loopStep) { - this._context.steps.splice(currentLoopStepIndex + 1, 1) - wasLoopStep = false - } - if (loopSteps && loopSteps.length) { - let tempOutput = { - success: true, - items: loopSteps, - iterations: iterationCount, - } - this.executionOutput.steps.splice(currentLoopStepIndex + 1, 0, { - id: step.id, - stepId: step.stepId, - outputs: tempOutput, - inputs: step.inputs, - }) - this._context.steps[currentLoopStepIndex] = tempOutput - - wasLoopStep = true - loopSteps = [] - } - } + await this.executeSteps(this.automation.definition.steps) const end = performance.now() const executionTime = end - start console.info( - `Automation ID: ${automation._id} Execution time: ${executionTime} milliseconds`, + `Automation ID: ${this.automation._id} Execution time: ${executionTime} milliseconds`, { _logKey: "automation", executionTime, } ) - // store the logs for the automation run try { - await storeLog(this._automation, this.executionOutput) + await storeLog(this.automation, this.executionOutput) } catch (e: any) { if (e.status === 413 && e.request?.data) { // if content is too large we shouldn't log it @@ -544,13 +290,288 @@ class Orchestrator { } logging.logAlert("Error writing automation log", e) } - if (isProdAppID(this._appId) && isRecurring(automation) && metadata) { + if ( + isProdAppID(this.appId) && + isRecurring(this.automation) && + metadata + ) { await this.updateMetadata(metadata) } return this.executionOutput } ) } + + private async executeSteps(steps: AutomationStep[]): Promise { + return tracer.trace( + "Orchestrator.executeSteps", + { resource: "automation" }, + async span => { + let stepIndex = 0 + const timeout = + this.job.data.event.timeout || env.AUTOMATION_THREAD_TIMEOUT + + try { + await helpers.withTimeout( + timeout, + (async () => { + while (stepIndex < steps.length) { + const step = steps[stepIndex] + if (step.stepId === AutomationActionStepId.BRANCH) { + await this.executeBranchStep(step) + stepIndex++ + } else if (step.stepId === AutomationActionStepId.LOOP) { + stepIndex = await this.executeLoopStep(step, steps, stepIndex) + } else { + await this.executeStep(step) + stepIndex++ + } + } + })() + ) + } catch (error: any) { + if (error.errno === "ETIME") { + span?.addTags({ timedOut: true }) + console.warn(`Automation execution timed out after ${timeout}ms`) + } + } + } + ) + } + + private async executeLoopStep( + loopStep: LoopStep, + steps: AutomationStep[], + currentIndex: number + ): Promise { + await processObject(loopStep.inputs, this.context) + const iterations = getLoopIterations(loopStep) + let stepToLoopIndex = currentIndex + 1 + let iterationCount = 0 + let shouldCleanup = true + + for (let loopStepIndex = 0; loopStepIndex < iterations; loopStepIndex++) { + try { + loopStep.inputs.binding = automationUtils.typecastForLooping( + loopStep.inputs + ) + } catch (err) { + this.updateContextAndOutput( + stepToLoopIndex, + steps[stepToLoopIndex], + {}, + { + status: AutomationErrors.INCORRECT_TYPE, + success: false, + } + ) + shouldCleanup = false + break + } + const maxIterations = automationUtils.ensureMaxIterationsAsNumber( + loopStep.inputs.iterations + ) + + if ( + loopStepIndex === env.AUTOMATION_MAX_ITERATIONS || + (loopStep.inputs.iterations && loopStepIndex === maxIterations) + ) { + this.updateContextAndOutput( + stepToLoopIndex, + steps[stepToLoopIndex], + { + items: this.loopStepOutputs, + iterations: loopStepIndex, + }, + { + status: AutomationErrors.MAX_ITERATIONS, + success: true, + } + ) + shouldCleanup = false + break + } + + let isFailure = false + const currentItem = this.getCurrentLoopItem(loopStep, loopStepIndex) + if (currentItem && typeof currentItem === "object") { + isFailure = Object.keys(currentItem).some(value => { + return currentItem[value] === loopStep?.inputs.failure + }) + } else { + isFailure = currentItem && currentItem === loopStep.inputs.failure + } + + if (isFailure) { + this.updateContextAndOutput( + loopStepIndex, + steps[stepToLoopIndex], + { + items: this.loopStepOutputs, + iterations: loopStepIndex, + }, + { + status: AutomationErrors.FAILURE_CONDITION, + success: false, + } + ) + shouldCleanup = false + break + } + + this.context.steps[currentIndex + 1] = { + currentItem: this.getCurrentLoopItem(loopStep, loopStepIndex), + } + + stepToLoopIndex = currentIndex + 1 + + await this.executeStep(steps[stepToLoopIndex], stepToLoopIndex) + iterationCount++ + } + + if (shouldCleanup) { + let tempOutput = + iterations === 0 + ? { + status: AutomationStepStatus.NO_ITERATIONS, + success: true, + } + : { + success: true, + items: this.loopStepOutputs, + iterations: iterationCount, + } + + // Loop Step clean up + this.executionOutput.steps.splice(currentIndex + 1, 0, { + id: steps[stepToLoopIndex].id, + stepId: steps[stepToLoopIndex].stepId, + outputs: tempOutput, + inputs: steps[stepToLoopIndex].inputs, + }) + this.context.steps[currentIndex + 1] = tempOutput + this.loopStepOutputs = [] + } + + return stepToLoopIndex + 1 + } + private async executeBranchStep(branchStep: BranchStep): Promise { + const { branches, children } = branchStep.inputs + + for (const branch of branches) { + const condition = await this.evaluateBranchCondition(branch.condition) + if (condition) { + const branchSteps = children?.[branch.name] || [] + await this.executeSteps(branchSteps) + break + } + } + } + + private async evaluateBranchCondition( + conditions: SearchFilters + ): Promise { + const toFilter: Record = {} + + const processedConditions = dataFilters.recurseSearchFilters( + conditions, + filter => { + Object.entries(filter).forEach(([_, value]) => { + Object.entries(value).forEach(([field, _]) => { + const fromContext = processStringSync( + `{{ literal ${field} }}`, + this.context + ) + toFilter[field] = fromContext + }) + }) + return filter + } + ) + + const result = dataFilters.runQuery([toFilter], processedConditions) + return result.length > 0 + } + private async executeStep( + step: AutomationStep, + loopIteration?: number + ): Promise { + return tracer.trace( + "Orchestrator.execute.step", + { resource: "automation" }, + async span => { + span?.addTags({ + resource: "automation", + step: { + stepId: step.stepId, + id: step.id, + name: step.name, + type: step.type, + title: step.stepTitle, + internal: step.internal, + deprecated: step.deprecated, + }, + }) + + if (this.stopped) { + this.updateExecutionOutput(step.id, step.stepId, {}, STOPPED_STATUS) + return + } + + let originalStepInput = cloneDeep(step.inputs) + if (loopIteration !== undefined) { + originalStepInput = replaceFakeBindings( + originalStepInput, + loopIteration + ) + } + const stepFn = await this.getStepFunctionality(step.stepId) + let inputs = await processObject(originalStepInput, this.context) + inputs = automationUtils.cleanInputValues(inputs, step.schema.inputs) + + const outputs = await stepFn({ + inputs: inputs, + appId: this.appId, + emitter: this.emitter, + context: this.context, + }) + this.handleStepOutput(step, outputs, loopIteration) + } + ) + } + + private getCurrentLoopItem(loopStep: LoopStep, index: number): any { + if (!loopStep) return null + if ( + typeof loopStep.inputs.binding === "string" && + loopStep.inputs.option === "String" + ) { + return automationUtils.stringSplit(loopStep.inputs.binding)[index] + } else if (Array.isArray(loopStep.inputs.binding)) { + return loopStep.inputs.binding[index] + } + return null + } + + private handleStepOutput( + step: AutomationStep, + outputs: any, + loopIteration: number | undefined + ): void { + if (step.stepId === AutomationActionStepId.FILTER && !outputs.result) { + this.stopped = true + this.updateExecutionOutput(step.id, step.stepId, step.inputs, { + ...outputs, + ...STOPPED_STATUS, + }) + } else if (loopIteration !== undefined) { + this.loopStepOutputs = this.loopStepOutputs || [] + this.loopStepOutputs.push(outputs) + } else { + this.updateExecutionOutput(step.id, step.stepId, step.inputs, outputs) + } + this.context.steps[this.context.steps.length] = outputs + } } export function execute(job: Job, callback: WorkerCallback) { diff --git a/packages/server/src/utilities/rowProcessor/index.ts b/packages/server/src/utilities/rowProcessor/index.ts index 2940f2118e..030f696ac0 100644 --- a/packages/server/src/utilities/rowProcessor/index.ts +++ b/packages/server/src/utilities/rowProcessor/index.ts @@ -247,6 +247,7 @@ export async function outputProcessing( preserveLinks?: boolean fromRow?: Row skipBBReferences?: boolean + fromViewId?: string } = { squash: true, preserveLinks: false, @@ -343,10 +344,9 @@ export async function outputProcessing( enriched = await processFormulas(table, enriched, { dynamic: true }) if (opts.squash) { - enriched = (await linkRows.squashLinksToPrimaryDisplay( - table, - enriched - )) as Row[] + enriched = await linkRows.squashLinks(table, enriched, { + fromViewId: opts?.fromViewId, + }) } // remove null properties to match internal API const isExternal = isExternalTableID(table._id!) diff --git a/packages/shared-core/src/filters.ts b/packages/shared-core/src/filters.ts index 82ef6759d2..c71dfe9dba 100644 --- a/packages/shared-core/src/filters.ts +++ b/packages/shared-core/src/filters.ts @@ -129,6 +129,25 @@ export function recurseLogicalOperators( return filters } +export function recurseSearchFilters( + filters: SearchFilters, + processFn: (filter: SearchFilters) => SearchFilters +): SearchFilters { + // Process the current level + filters = processFn(filters) + + // Recurse through logical operators + for (const logical of Object.values(LogicalOperator)) { + if (filters[logical]) { + filters[logical]!.conditions = filters[logical]!.conditions.map( + condition => recurseSearchFilters(condition, processFn) + ) + } + } + + return filters +} + /** * Removes any fields that contain empty strings that would cause inconsistent * behaviour with how backend tables are filtered (no value means no filter). diff --git a/packages/shared-core/src/helpers/helpers.ts b/packages/shared-core/src/helpers/helpers.ts index 16891de35b..8dbdb7bbfd 100644 --- a/packages/shared-core/src/helpers/helpers.ts +++ b/packages/shared-core/src/helpers/helpers.ts @@ -83,3 +83,32 @@ export const getUserLabel = (user: User) => { return email } } + +export function cancelableTimeout( + timeout: number +): [Promise, () => void] { + let timeoutId: NodeJS.Timeout + return [ + new Promise((resolve, reject) => { + timeoutId = setTimeout(() => { + reject({ + status: 301, + errno: "ETIME", + }) + }, timeout) + }), + () => { + clearTimeout(timeoutId) + }, + ] +} + +export async function withTimeout( + timeout: number, + promise: Promise +): Promise { + const [timeoutPromise, cancel] = cancelableTimeout(timeout) + const result = (await Promise.race([promise, timeoutPromise])) as T + cancel() + return result +} diff --git a/packages/types/src/api/web/app/rowAction.ts b/packages/types/src/api/web/app/rowAction.ts index 2b2b6e1927..37cfaf0fbd 100644 --- a/packages/types/src/api/web/app/rowAction.ts +++ b/packages/types/src/api/web/app/rowAction.ts @@ -8,6 +8,7 @@ export interface RowActionResponse extends RowActionData { id: string tableId: string automationId: string + allowedViews: string[] | undefined } export interface RowActionsResponse { diff --git a/packages/types/src/documents/app/rowAction.ts b/packages/types/src/documents/app/rowAction.ts index 84fa0e7f00..fc9a25c2e2 100644 --- a/packages/types/src/documents/app/rowAction.ts +++ b/packages/types/src/documents/app/rowAction.ts @@ -2,11 +2,14 @@ import { Document } from "../document" export interface TableRowActions extends Document { _id: string - actions: Record< - string, - { - name: string - automationId: string - } - > + actions: Record +} + +export interface RowActionData { + name: string + automationId: string + permissions: { + table: { runAllowed: boolean } + views: Record + } } diff --git a/packages/types/src/documents/app/view.ts b/packages/types/src/documents/app/view.ts index d02e5af1b1..d38bf6e09d 100644 --- a/packages/types/src/documents/app/view.ts +++ b/packages/types/src/documents/app/view.ts @@ -33,7 +33,13 @@ export interface View { groupBy?: string } -export type ViewUIFieldMetadata = UIFieldMetadata & { +export type ViewFieldMetadata = UIFieldMetadata & { + readonly?: boolean + columns?: Record +} + +export type RelationSchemaField = { + visible?: boolean readonly?: boolean } @@ -45,7 +51,7 @@ export enum CalculationType { MAX = "max", } -export type ViewCalculationFieldMetadata = ViewUIFieldMetadata & { +export type ViewCalculationFieldMetadata = ViewFieldMetadata & { calculationType: CalculationType field: string } @@ -62,7 +68,7 @@ export interface ViewV2 { order?: SortOrder type?: SortType } - schema?: Record + schema?: Record } export type ViewSchema = ViewCountOrSumSchema | ViewStatisticsSchema diff --git a/packages/types/src/sdk/featureFlag.ts b/packages/types/src/sdk/featureFlag.ts index 257b4ee576..3d96b63c64 100644 --- a/packages/types/src/sdk/featureFlag.ts +++ b/packages/types/src/sdk/featureFlag.ts @@ -1,6 +1,7 @@ export enum FeatureFlag { PER_CREATOR_PER_USER_PRICE = "PER_CREATOR_PER_USER_PRICE", PER_CREATOR_PER_USER_PRICE_ALERT = "PER_CREATOR_PER_USER_PRICE_ALERT", + ENRICHED_RELATIONSHIPS = "ENRICHED_RELATIONSHIPS", } export interface TenantFeatureFlags { diff --git a/packages/types/src/sdk/row.ts b/packages/types/src/sdk/row.ts index 6850359cc3..af9f1481d1 100644 --- a/packages/types/src/sdk/row.ts +++ b/packages/types/src/sdk/row.ts @@ -5,6 +5,7 @@ import { WithRequired } from "../shared" export interface SearchParams { tableId?: string + viewId?: string query?: SearchFilters paginate?: boolean bookmark?: string | number diff --git a/packages/types/src/sdk/view.ts b/packages/types/src/sdk/view.ts index cb551dada9..b330db3950 100644 --- a/packages/types/src/sdk/view.ts +++ b/packages/types/src/sdk/view.ts @@ -1,5 +1,9 @@ -import { TableSchema, ViewV2 } from "../documents" +import { FieldSchema, RelationSchemaField, ViewV2 } from "../documents" export interface ViewV2Enriched extends ViewV2 { - schema?: TableSchema + schema?: { + [key: string]: FieldSchema & { + columns?: Record + } + } } diff --git a/packages/worker/src/api/routes/global/tests/realEmail.spec.ts b/packages/worker/src/api/routes/global/tests/realEmail.spec.ts index a18d8ee247..99dfb7f824 100644 --- a/packages/worker/src/api/routes/global/tests/realEmail.spec.ts +++ b/packages/worker/src/api/routes/global/tests/realEmail.spec.ts @@ -2,6 +2,8 @@ jest.unmock("node-fetch") import { TestConfiguration } from "../../../../tests" import { EmailTemplatePurpose } from "../../../../constants" import { objectStore } from "@budibase/backend-core" +import { helpers } from "@budibase/shared-core" + import tk from "timekeeper" import { EmailAttachment } from "@budibase/types" @@ -12,33 +14,6 @@ const nodemailer = require("nodemailer") // for the real email tests give them a long time to try complete/fail jest.setTimeout(30000) -function cancelableTimeout(timeout: number): [Promise, () => void] { - let timeoutId: NodeJS.Timeout - return [ - new Promise((resolve, reject) => { - timeoutId = setTimeout(() => { - reject({ - status: 301, - errno: "ETIME", - }) - }, timeout) - }), - () => { - clearTimeout(timeoutId) - }, - ] -} - -async function withTimeout( - timeout: number, - promise: Promise -): Promise { - const [timeoutPromise, cancel] = cancelableTimeout(timeout) - const result = (await Promise.race([promise, timeoutPromise])) as T - cancel() - return result -} - describe("/api/global/email", () => { const config = new TestConfiguration() @@ -57,8 +32,8 @@ describe("/api/global/email", () => { ) { let response, text try { - await withTimeout(20000, config.saveEtherealSmtpConfig()) - await withTimeout(20000, config.saveSettingsConfig()) + await helpers.withTimeout(20000, config.saveEtherealSmtpConfig()) + await helpers.withTimeout(20000, config.saveSettingsConfig()) let res if (attachments) { res = await config.api.emails