diff --git a/lerna.json b/lerna.json index 4d36affd04..ba3db109d0 100644 --- a/lerna.json +++ b/lerna.json @@ -1,6 +1,6 @@ { "$schema": "node_modules/lerna/schemas/lerna-schema.json", - "version": "2.32.15", + "version": "2.32.16", "npmClient": "yarn", "packages": [ "packages/*", diff --git a/packages/backend-core/src/db/couch/DatabaseImpl.ts b/packages/backend-core/src/db/couch/DatabaseImpl.ts index 2b37526dde..8ca20bf8e1 100644 --- a/packages/backend-core/src/db/couch/DatabaseImpl.ts +++ b/packages/backend-core/src/db/couch/DatabaseImpl.ts @@ -211,6 +211,17 @@ export class DatabaseImpl implements Database { }) } + async tryGet(id?: string): Promise { + try { + return await this.get(id) + } catch (err: any) { + if (err.statusCode === 404) { + return undefined + } + throw err + } + } + async getMultiple( ids: string[], opts?: { allowMissing?: boolean; excludeDocs?: boolean } diff --git a/packages/backend-core/src/db/instrumentation.ts b/packages/backend-core/src/db/instrumentation.ts index 7026224564..e08bfc0362 100644 --- a/packages/backend-core/src/db/instrumentation.ts +++ b/packages/backend-core/src/db/instrumentation.ts @@ -42,6 +42,13 @@ export class DDInstrumentedDatabase implements Database { }) } + tryGet(id?: string | undefined): Promise { + return tracer.trace("db.tryGet", span => { + span?.addTags({ db_name: this.name, doc_id: id }) + return this.db.tryGet(id) + }) + } + getMultiple( ids: string[], opts?: { allowMissing?: boolean | undefined } | undefined diff --git a/packages/backend-core/src/security/roles.ts b/packages/backend-core/src/security/roles.ts index 65339832cf..fa2d114d7d 100644 --- a/packages/backend-core/src/security/roles.ts +++ b/packages/backend-core/src/security/roles.ts @@ -435,7 +435,9 @@ export function getExternalRoleID(roleId: string, version?: string) { roleId.startsWith(DocumentType.ROLE) && (isBuiltin(roleId) || version === RoleIDVersion.NAME) ) { - return roleId.split(`${DocumentType.ROLE}${SEPARATOR}`)[1] + const parts = roleId.split(SEPARATOR) + parts.shift() + return parts.join(SEPARATOR) } return roleId } diff --git a/packages/builder/src/components/backend/DataTable/buttons/grid/ColumnsSettingContent.svelte b/packages/builder/src/components/backend/DataTable/buttons/grid/ColumnsSettingContent.svelte index a61fcf8346..9e9449976b 100644 --- a/packages/builder/src/components/backend/DataTable/buttons/grid/ColumnsSettingContent.svelte +++ b/packages/builder/src/components/backend/DataTable/buttons/grid/ColumnsSettingContent.svelte @@ -124,7 +124,7 @@ subtype: column.subtype, visible: column.visible, readonly: column.readonly, - constraints: column.constraints, // This is needed to properly display "users" column + icon: column.icon, }, } }) diff --git a/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/SaveRow.svelte b/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/SaveRow.svelte index c23edbeb58..819ed10880 100644 --- a/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/SaveRow.svelte +++ b/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/SaveRow.svelte @@ -33,7 +33,7 @@ const getSchemaFields = resourceId => { const { schema } = getSchemaForDatasourcePlus(resourceId) - return Object.values(schema || {}) + return Object.values(schema || {}).filter(field => !field.readonly) } const onFieldsChanged = e => { diff --git a/packages/builder/src/components/portal/licensing/EnterpriseBasicTrialBanner.svelte b/packages/builder/src/components/portal/licensing/EnterpriseBasicTrialBanner.svelte index 111f0481b9..350ebb0f11 100644 --- a/packages/builder/src/components/portal/licensing/EnterpriseBasicTrialBanner.svelte +++ b/packages/builder/src/components/portal/licensing/EnterpriseBasicTrialBanner.svelte @@ -14,7 +14,13 @@ function daysUntilCancel() { const cancelAt = license?.billing?.subscription?.cancelAt const diffTime = Math.abs(cancelAt - new Date().getTime()) / 1000 - return Math.floor(diffTime / oneDayInSeconds) + const days = Math.floor(diffTime / oneDayInSeconds) + if (days === 1) { + return "tomorrow." + } else if (days === 0) { + return "today." + } + return `in ${days} days.` } @@ -28,7 +34,7 @@ extraLinkAction={$licensing.goToUpgradePage} showCloseButton={false} > - Your free trial will end in {daysUntilCancel()} days. + Your free trial will end {daysUntilCancel()} {/if} diff --git a/packages/builder/src/pages/builder/app/[application]/data/table/[tableId]/_components/ViewNavBar.svelte b/packages/builder/src/pages/builder/app/[application]/data/table/[tableId]/_components/ViewNavBar.svelte index 69b792c612..bf3d073f60 100644 --- a/packages/builder/src/pages/builder/app/[application]/data/table/[tableId]/_components/ViewNavBar.svelte +++ b/packages/builder/src/pages/builder/app/[application]/data/table/[tableId]/_components/ViewNavBar.svelte @@ -196,7 +196,7 @@ on:contextmenu={openTableContextMenu} > {#if tableSelectedBy} diff --git a/packages/frontend-core/src/utils/schema.js b/packages/frontend-core/src/utils/schema.js index b98ae1c375..30b0bd0079 100644 --- a/packages/frontend-core/src/utils/schema.js +++ b/packages/frontend-core/src/utils/schema.js @@ -2,6 +2,9 @@ import { helpers } from "@budibase/shared-core" import { TypeIconMap } from "../constants" export const getColumnIcon = column => { + if (column.schema.icon) { + return column.schema.icon + } if (column.calculation) { return "Calculator" } diff --git a/packages/server/src/api/controllers/rowAction/crud.ts b/packages/server/src/api/controllers/rowAction/crud.ts index 67b6d9383e..ca84b2ea30 100644 --- a/packages/server/src/api/controllers/rowAction/crud.ts +++ b/packages/server/src/api/controllers/rowAction/crud.ts @@ -21,14 +21,15 @@ export async function find(ctx: Ctx) { const table = await getTable(ctx) const tableId = table._id! - if (!(await sdk.rowActions.docExists(tableId))) { + const rowActions = await sdk.rowActions.getAll(tableId) + if (!rowActions) { ctx.body = { actions: {}, } return } - const { actions } = await sdk.rowActions.getAll(tableId) + const { actions } = rowActions const result: RowActionsResponse = { actions: Object.entries(actions).reduce>( (acc, [key, action]) => ({ diff --git a/packages/server/src/api/controllers/table/index.ts b/packages/server/src/api/controllers/table/index.ts index 404f82e57c..efe1a88e4a 100644 --- a/packages/server/src/api/controllers/table/index.ts +++ b/packages/server/src/api/controllers/table/index.ts @@ -139,6 +139,7 @@ export async function save(ctx: UserCtx) { export async function destroy(ctx: UserCtx) { const appId = ctx.appId const tableId = ctx.params.tableId + await sdk.rowActions.deleteAll(tableId) const deletedTable = await pickApi({ tableId }).destroy(ctx) await events.table.deleted(deletedTable) ctx.eventEmitter && diff --git a/packages/server/src/api/routes/tests/role.spec.js b/packages/server/src/api/routes/tests/role.spec.js index 4575f9b213..00025e396a 100644 --- a/packages/server/src/api/routes/tests/role.spec.js +++ b/packages/server/src/api/routes/tests/role.spec.js @@ -161,7 +161,7 @@ describe("/roles", () => { it("should not fetch higher level accessible roles when a custom role header is provided", async () => { await createRole({ - name: `CUSTOM_ROLE`, + name: `custom_role_1`, inherits: roles.BUILTIN_ROLE_IDS.BASIC, permissionId: permissions.BuiltinPermissionID.READ_ONLY, version: "name", @@ -170,11 +170,11 @@ describe("/roles", () => { .get("/api/roles/accessible") .set({ ...config.defaultHeaders(), - "x-budibase-role": "CUSTOM_ROLE", + "x-budibase-role": "custom_role_1", }) .expect(200) expect(res.body.length).toBe(3) - expect(res.body[0]).toBe("CUSTOM_ROLE") + expect(res.body[0]).toBe("custom_role_1") expect(res.body[1]).toBe("BASIC") expect(res.body[2]).toBe("PUBLIC") }) diff --git a/packages/server/src/api/routes/tests/rowAction.spec.ts b/packages/server/src/api/routes/tests/rowAction.spec.ts index 71e831cb90..a84e3402ca 100644 --- a/packages/server/src/api/routes/tests/rowAction.spec.ts +++ b/packages/server/src/api/routes/tests/rowAction.spec.ts @@ -1043,4 +1043,44 @@ describe("/rowsActions", () => { ) }) }) + + describe("scenarios", () => { + // https://linear.app/budibase/issue/BUDI-8717/ + it("should not brick the app when deleting a table with row actions", async () => { + const view = await config.api.viewV2.create({ + tableId, + name: generator.guid(), + schema: { + name: { visible: true }, + }, + }) + + const rowAction = await config.api.rowAction.save(tableId, { + name: generator.guid(), + }) + + await config.api.rowAction.setViewPermission( + tableId, + view.id, + rowAction.id + ) + + let actionsResp = await config.api.rowAction.find(tableId) + expect(actionsResp.actions[rowAction.id]).toEqual({ + ...rowAction, + allowedSources: [tableId, view.id], + }) + + const table = await config.api.table.get(tableId) + await config.api.table.destroy(table._id!, table._rev!) + + // In the bug reported by Conor, when a delete got deleted its row action + // document was not being cleaned up. This meant there existed code paths + // that would find it and try to reference the tables within it, resulting + // in errors. + await config.api.automation.fetchEnriched({ + status: 200, + }) + }) + }) }) diff --git a/packages/server/src/sdk/app/automations/utils.ts b/packages/server/src/sdk/app/automations/utils.ts index 2c34de255b..8b397df75f 100644 --- a/packages/server/src/sdk/app/automations/utils.ts +++ b/packages/server/src/sdk/app/automations/utils.ts @@ -26,13 +26,13 @@ export async function getBuilderData( return tableNameCache[tableId] } - const rowActionNameCache: Record = {} + const rowActionNameCache: Record = {} async function getRowActionName(tableId: string, rowActionId: string) { if (!rowActionNameCache[tableId]) { rowActionNameCache[tableId] = await sdk.rowActions.getAll(tableId) } - return rowActionNameCache[tableId].actions[rowActionId]?.name + return rowActionNameCache[tableId]?.actions[rowActionId]?.name } const result: Record = {} @@ -51,6 +51,10 @@ export async function getBuilderData( const tableName = await getTableName(tableId) const rowActionName = await getRowActionName(tableId, rowActionId) + if (!rowActionName) { + throw new Error(`Row action not found: ${rowActionId}`) + } + result[automation._id!] = { displayName: rowActionName, triggerInfo: { diff --git a/packages/server/src/sdk/app/rowActions.ts b/packages/server/src/sdk/app/rowActions.ts index 86edd8f263..4b128ae003 100644 --- a/packages/server/src/sdk/app/rowActions.ts +++ b/packages/server/src/sdk/app/rowActions.ts @@ -1,5 +1,6 @@ import { context, docIds, HTTPError, utils } from "@budibase/backend-core" import { + Automation, AutomationTriggerStepId, SEPARATOR, TableRowActions, @@ -102,7 +103,25 @@ export async function get(tableId: string, rowActionId: string) { export async function getAll(tableId: string) { const db = context.getAppDB() const rowActionsId = generateRowActionsID(tableId) - return await db.get(rowActionsId) + return await db.tryGet(rowActionsId) +} + +export async function deleteAll(tableId: string) { + const db = context.getAppDB() + + const doc = await getAll(tableId) + if (!doc) { + return + } + + const automationIds = Object.values(doc.actions).map(a => a.automationId) + const automations = await db.getMultiple(automationIds) + + for (const automation of automations) { + await sdk.automations.remove(automation._id!, automation._rev!) + } + + await db.remove(doc) } export async function docExists(tableId: string) { @@ -223,9 +242,8 @@ export async function run(tableId: any, rowActionId: any, rowId: string) { throw new HTTPError("Table not found", 404) } - const { actions } = await getAll(tableId) - - const rowAction = actions[rowActionId] + const rowActions = await getAll(tableId) + const rowAction = rowActions?.actions[rowActionId] if (!rowAction) { throw new HTTPError("Row action not found", 404) } diff --git a/packages/server/src/sdk/app/views/index.ts b/packages/server/src/sdk/app/views/index.ts index c9c9fd780b..b6b122a56f 100644 --- a/packages/server/src/sdk/app/views/index.ts +++ b/packages/server/src/sdk/app/views/index.ts @@ -1,4 +1,5 @@ import { + BBReferenceFieldSubType, CalculationType, canGroupBy, FeatureFlag, @@ -7,6 +8,7 @@ import { PermissionLevel, RelationSchemaField, RenameColumn, + RequiredKeys, Table, TableSchema, View, @@ -322,13 +324,26 @@ export async function enrichSchema( const viewFieldSchema = viewFields[relTableFieldName] const isVisible = !!viewFieldSchema?.visible const isReadonly = !!viewFieldSchema?.readonly - result[relTableFieldName] = { - ...relTableField, - ...viewFieldSchema, - name: relTableField.name, + const enrichedFieldSchema: RequiredKeys = { visible: isVisible, readonly: isReadonly, + order: viewFieldSchema?.order, + width: viewFieldSchema?.width, + + icon: relTableField.icon, + type: relTableField.type, + subtype: relTableField.subtype, } + if ( + !enrichedFieldSchema.icon && + relTableField.type === FieldType.BB_REFERENCE && + relTableField.subtype === BBReferenceFieldSubType.USER && + !helpers.schema.isDeprecatedSingleUserColumn(relTableField) + ) { + // Forcing the icon, otherwise we would need to pass the constraints to show the proper icon + enrichedFieldSchema.icon = "UserGroup" + } + result[relTableFieldName] = enrichedFieldSchema } return result } 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 1d7360c5eb..948ffbf096 100644 --- a/packages/server/src/sdk/app/views/tests/views.spec.ts +++ b/packages/server/src/sdk/app/views/tests/views.spec.ts @@ -355,13 +355,11 @@ describe("table sdk", () => { visible: true, columns: { title: { - name: "title", type: "string", visible: true, readonly: true, }, age: { - name: "age", type: "number", visible: false, readonly: false, diff --git a/packages/server/src/tests/utilities/api/automation.ts b/packages/server/src/tests/utilities/api/automation.ts index 61bd915647..11c041d52b 100644 --- a/packages/server/src/tests/utilities/api/automation.ts +++ b/packages/server/src/tests/utilities/api/automation.ts @@ -1,4 +1,4 @@ -import { Automation } from "@budibase/types" +import { Automation, FetchAutomationResponse } from "@budibase/types" import { Expectations, TestAPI } from "./base" export class AutomationAPI extends TestAPI { @@ -14,6 +14,26 @@ export class AutomationAPI extends TestAPI { ) return result } + + fetch = async ( + expectations?: Expectations + ): Promise => { + return await this._get(`/api/automations`, { + expectations, + }) + } + + fetchEnriched = async ( + expectations?: Expectations + ): Promise => { + return await this._get( + `/api/automations?enrich=true`, + { + expectations, + } + ) + } + post = async ( body: Automation, expectations?: Expectations diff --git a/packages/types/src/sdk/db.ts b/packages/types/src/sdk/db.ts index 49baaa5bb1..b679d6e182 100644 --- a/packages/types/src/sdk/db.ts +++ b/packages/types/src/sdk/db.ts @@ -129,7 +129,12 @@ export interface Database { name: string exists(): Promise + /** + * @deprecated the plan is to get everything using `tryGet` instead, then rename + * `tryGet` to `get`. + */ get(id?: string): Promise + tryGet(id?: string): Promise exists(docId: string): Promise getMultiple( ids: string[], diff --git a/packages/types/src/sdk/view.ts b/packages/types/src/sdk/view.ts index 422207197d..4c555fbaa7 100644 --- a/packages/types/src/sdk/view.ts +++ b/packages/types/src/sdk/view.ts @@ -1,4 +1,10 @@ -import { FieldSchema, RelationSchemaField, ViewV2 } from "../documents" +import { + FieldSchema, + FieldSubType, + FieldType, + RelationSchemaField, + ViewV2, +} from "../documents" export interface ViewV2Enriched extends ViewV2 { schema?: { @@ -8,4 +14,7 @@ export interface ViewV2Enriched extends ViewV2 { } } -export type ViewV2ColumnEnriched = RelationSchemaField & FieldSchema +export interface ViewV2ColumnEnriched extends RelationSchemaField { + type: FieldType + subtype?: FieldSubType +}