diff --git a/lerna.json b/lerna.json index 00a472c483..754041d6ef 100644 --- a/lerna.json +++ b/lerna.json @@ -1,6 +1,6 @@ { "$schema": "node_modules/lerna/schemas/lerna-schema.json", - "version": "2.31.2", + "version": "2.31.3", "npmClient": "yarn", "packages": [ "packages/*", diff --git a/packages/account-portal b/packages/account-portal index 516b27b74c..c403315c5f 160000 --- a/packages/account-portal +++ b/packages/account-portal @@ -1 +1 @@ -Subproject commit 516b27b74cbcb7069a25f5e738dc91c22d7c4538 +Subproject commit c403315c5fa09a05dfd8fa4cd1890acfd8de0430 diff --git a/packages/bbui/src/Modal/Modal.svelte b/packages/bbui/src/Modal/Modal.svelte index dec1455d0c..4d55a8a266 100644 --- a/packages/bbui/src/Modal/Modal.svelte +++ b/packages/bbui/src/Modal/Modal.svelte @@ -10,7 +10,7 @@ export let inline = false export let disableCancel = false export let autoFocus = true - export let zIndex = 999 + export let zIndex = 99999 const dispatch = createEventDispatcher() let visible = fixed || inline diff --git a/packages/server/src/api/controllers/row/external.ts b/packages/server/src/api/controllers/row/external.ts index 8b95d9c2f3..e14e2d454a 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.getTableId(ctx) + const { tableId } = utils.getSourceId(ctx) const { _id, ...rowData } = ctx.request.body const table = await sdk.tables.getTable(tableId) @@ -93,7 +93,7 @@ export async function patch(ctx: UserCtx) { } export async function destroy(ctx: UserCtx) { - const tableId = utils.getTableId(ctx) + const { tableId } = utils.getSourceId(ctx) const _id = ctx.request.body._id const { row } = await handleRequest(Operation.DELETE, tableId, { id: breakRowIdField(_id), @@ -104,7 +104,7 @@ export async function destroy(ctx: UserCtx) { export async function bulkDestroy(ctx: UserCtx) { const { rows } = ctx.request.body - const tableId = utils.getTableId(ctx) + const { tableId } = utils.getSourceId(ctx) let promises: Promise<{ row: Row; table: Table }>[] = [] for (let row of rows) { promises.push( @@ -123,7 +123,7 @@ export async function bulkDestroy(ctx: UserCtx) { export async function fetchEnrichedRow(ctx: UserCtx) { const id = ctx.params.rowId - const tableId = utils.getTableId(ctx) + const { tableId } = utils.getSourceId(ctx) const { datasourceId, tableName } = breakExternalTableId(tableId) const datasource: Datasource = await sdk.datasources.get(datasourceId) if (!datasource || !datasource.entities) { diff --git a/packages/server/src/api/controllers/row/index.ts b/packages/server/src/api/controllers/row/index.ts index 237c30cc88..46aec4e11c 100644 --- a/packages/server/src/api/controllers/row/index.ts +++ b/packages/server/src/api/controllers/row/index.ts @@ -47,7 +47,7 @@ export async function patch( ctx: UserCtx ): Promise { const appId = ctx.appId - const tableId = utils.getTableId(ctx) + const { tableId } = utils.getSourceId(ctx) const body = ctx.request.body // if it doesn't have an _id then its save @@ -72,7 +72,7 @@ export async function patch( export const save = async (ctx: UserCtx) => { const appId = ctx.appId - const tableId = utils.getTableId(ctx) + const { tableId } = utils.getSourceId(ctx) const body = ctx.request.body // user metadata doesn't exist yet - don't allow creation @@ -97,13 +97,12 @@ export const save = async (ctx: UserCtx) => { gridSocket?.emitRowUpdate(ctx, row || squashed) } -export async function fetchView(ctx: any) { - const tableId = utils.getTableId(ctx) +export async function fetchLegacyView(ctx: any) { const viewName = decodeURIComponent(ctx.params.viewName) const { calculation, group, field } = ctx.query - ctx.body = await sdk.rows.fetchView(tableId, viewName, { + ctx.body = await sdk.rows.fetchLegacyView(viewName, { calculation, group: calculation ? group : null, field, @@ -111,12 +110,12 @@ export async function fetchView(ctx: any) { } export async function fetch(ctx: any) { - const tableId = utils.getTableId(ctx) + const { tableId } = utils.getSourceId(ctx) ctx.body = await sdk.rows.fetch(tableId) } export async function find(ctx: UserCtx) { - const tableId = utils.getTableId(ctx) + const { tableId } = utils.getSourceId(ctx) const rowId = ctx.params.rowId ctx.body = await sdk.rows.find(tableId, rowId) @@ -132,7 +131,7 @@ function isDeleteRow(input: any): input is DeleteRow { async function processDeleteRowsRequest(ctx: UserCtx) { let request = ctx.request.body as DeleteRows - const tableId = utils.getTableId(ctx) + const { tableId } = utils.getSourceId(ctx) const processedRows = request.rows.map(row => { let processedRow: Row = typeof row == "string" ? { _id: row } : row @@ -148,7 +147,7 @@ async function processDeleteRowsRequest(ctx: UserCtx) { } async function deleteRows(ctx: UserCtx) { - const tableId = utils.getTableId(ctx) + const { tableId } = utils.getSourceId(ctx) const appId = ctx.appId let deleteRequest = ctx.request.body as DeleteRows @@ -170,7 +169,7 @@ async function deleteRows(ctx: UserCtx) { async function deleteRow(ctx: UserCtx) { const appId = ctx.appId - const tableId = utils.getTableId(ctx) + const { tableId } = utils.getSourceId(ctx) const resp = await pickApi(tableId).destroy(ctx) if (!tableId.includes("datasource_plus")) { @@ -204,7 +203,7 @@ export async function destroy(ctx: UserCtx) { } export async function search(ctx: Ctx) { - const tableId = utils.getTableId(ctx) + const { tableId } = utils.getSourceId(ctx) await context.ensureSnippetContext(true) @@ -226,7 +225,7 @@ export async function search(ctx: Ctx) { } export async function validate(ctx: Ctx) { - const tableId = utils.getTableId(ctx) + const { tableId } = utils.getSourceId(ctx) // external tables are hard to validate currently if (isExternalTableID(tableId)) { ctx.body = { valid: true, errors: {} } @@ -239,14 +238,14 @@ export async function validate(ctx: Ctx) { } export async function fetchEnrichedRow(ctx: UserCtx) { - const tableId = utils.getTableId(ctx) + const { tableId } = utils.getSourceId(ctx) ctx.body = await pickApi(tableId).fetchEnrichedRow(ctx) } export const exportRows = async ( ctx: Ctx ) => { - const tableId = utils.getTableId(ctx) + const { tableId } = utils.getSourceId(ctx) const format = ctx.query.format @@ -279,7 +278,7 @@ export const exportRows = async ( export async function downloadAttachment(ctx: UserCtx) { const { columnName } = ctx.params - const tableId = utils.getTableId(ctx) + const { tableId } = utils.getSourceId(ctx) const rowId = ctx.params.rowId const row = await sdk.rows.find(tableId, rowId) diff --git a/packages/server/src/api/controllers/row/internal.ts b/packages/server/src/api/controllers/row/internal.ts index b2982a3542..e698efe981 100644 --- a/packages/server/src/api/controllers/row/internal.ts +++ b/packages/server/src/api/controllers/row/internal.ts @@ -16,7 +16,6 @@ import { PatchRowRequest, PatchRowResponse, Row, - Table, UserCtx, } from "@budibase/types" import sdk from "../../../sdk" @@ -24,7 +23,7 @@ import { getLinkedTableIDs } from "../../../db/linkedRows/linkUtils" import { flatten } from "lodash" export async function patch(ctx: UserCtx) { - const tableId = utils.getTableId(ctx) + const { tableId } = utils.getSourceId(ctx) const inputs = ctx.request.body const isUserTable = tableId === InternalTables.USER_METADATA let oldRow @@ -98,7 +97,7 @@ export async function patch(ctx: UserCtx) { export async function destroy(ctx: UserCtx) { const db = context.getAppDB() - const tableId = utils.getTableId(ctx) + const { tableId } = utils.getSourceId(ctx) const { _id } = ctx.request.body let row = await db.get(_id) let _rev = ctx.request.body._rev || row._rev @@ -137,7 +136,7 @@ export async function destroy(ctx: UserCtx) { } export async function bulkDestroy(ctx: UserCtx) { - const tableId = utils.getTableId(ctx) + const { tableId } = utils.getSourceId(ctx) const table = await sdk.tables.getTable(tableId) let { rows } = ctx.request.body @@ -179,7 +178,7 @@ export async function bulkDestroy(ctx: UserCtx) { export async function fetchEnrichedRow(ctx: UserCtx) { const fieldName = ctx.request.query.field as string | undefined const db = context.getAppDB() - const tableId = utils.getTableId(ctx) + const { tableId } = utils.getSourceId(ctx) const rowId = ctx.params.rowId as string // need table to work out where links go in row, as well as the link docs const [table, links] = await Promise.all([ @@ -197,7 +196,7 @@ export async function fetchEnrichedRow(ctx: UserCtx) { ) // get the linked tables - const linkTableIds = getLinkedTableIDs(table as Table) + const linkTableIds = getLinkedTableIDs(table.schema) const linkTables = await sdk.tables.getTables(linkTableIds) // perform output processing diff --git a/packages/server/src/api/controllers/row/utils/utils.ts b/packages/server/src/api/controllers/row/utils/utils.ts index 911cfe8d5b..46bdbb7662 100644 --- a/packages/server/src/api/controllers/row/utils/utils.ts +++ b/packages/server/src/api/controllers/row/utils/utils.ts @@ -1,4 +1,4 @@ -import { InternalTables } from "../../../../db/utils" +import * as utils from "../../../../db/utils" import { context } from "@budibase/backend-core" import { @@ -67,7 +67,7 @@ export async function findRow(tableId: string, rowId: string) { const db = context.getAppDB() let row: Row // TODO remove special user case in future - if (tableId === InternalTables.USER_METADATA) { + if (tableId === utils.InternalTables.USER_METADATA) { row = await getFullUser(rowId) } else { row = await db.get(rowId) @@ -78,22 +78,25 @@ export async function findRow(tableId: string, rowId: string) { return row } -export function getTableId(ctx: Ctx): string { +export function getSourceId(ctx: Ctx): { tableId: string; viewId?: string } { // top priority, use the URL first if (ctx.params?.sourceId) { - return ctx.params.sourceId + const { sourceId } = ctx.params + if (utils.isViewID(sourceId)) { + return { + tableId: utils.extractViewInfoFromID(sourceId).tableId, + viewId: sourceId, + } + } + return { tableId: ctx.params.sourceId } } // now check for old way of specifying table ID if (ctx.params?.tableId) { - return ctx.params.tableId + return { tableId: ctx.params.tableId } } // check body for a table ID if (ctx.request.body?.tableId) { - return ctx.request.body.tableId - } - // now check if a specific view name - if (ctx.params?.viewName) { - return ctx.params.viewName + return { tableId: ctx.request.body.tableId } } throw new Error("Unable to find table ID in request") } @@ -198,7 +201,7 @@ export async function sqlOutputProcessing( } export function isUserMetadataTable(tableId: string) { - return tableId === InternalTables.USER_METADATA + return tableId === utils.InternalTables.USER_METADATA } export async function enrichArrayContext( diff --git a/packages/server/src/api/controllers/view/views.ts b/packages/server/src/api/controllers/view/views.ts index abcc8627f3..b1f1f6c154 100644 --- a/packages/server/src/api/controllers/view/views.ts +++ b/packages/server/src/api/controllers/view/views.ts @@ -2,7 +2,7 @@ import viewTemplate from "./viewBuilder" import { apiFileReturn } from "../../../utilities/fileSystem" import { csv, json, jsonWithSchema, Format, isFormat } from "./exporters" import { deleteView, getView, getViews, saveView } from "./utils" -import { fetchView } from "../row" +import { fetchLegacyView } from "../row" import { context, events } from "@budibase/backend-core" import sdk from "../../../sdk" import { @@ -170,7 +170,7 @@ export async function exportView(ctx: Ctx) { ctx.params.viewName = viewName } - await fetchView(ctx) + await fetchLegacyView(ctx) let rows = ctx.body as Row[] let schema: TableSchema = view && view.meta && view.meta.schema diff --git a/packages/server/src/api/routes/tests/row.spec.ts b/packages/server/src/api/routes/tests/row.spec.ts index 95e714fbd6..b060b2f0c1 100644 --- a/packages/server/src/api/routes/tests/row.spec.ts +++ b/packages/server/src/api/routes/tests/row.spec.ts @@ -9,7 +9,13 @@ import { import tk from "timekeeper" import emitter from "../../../../src/events" import { outputProcessing } from "../../../utilities/rowProcessor" -import { context, InternalTable, tenancy } from "@budibase/backend-core" +import { + context, + InternalTable, + tenancy, + withEnv as withCoreEnv, + setEnv as setCoreEnv, +} from "@budibase/backend-core" import { quotas } from "@budibase/pro" import { AttachmentFieldMetadata, @@ -69,6 +75,7 @@ async function waitForEvent( describe.each([ ["internal", undefined], + ["sqs", undefined], [DatabaseName.POSTGRES, getDatasource(DatabaseName.POSTGRES)], [DatabaseName.MYSQL, getDatasource(DatabaseName.MYSQL)], [DatabaseName.SQL_SERVER, getDatasource(DatabaseName.SQL_SERVER)], @@ -76,6 +83,8 @@ describe.each([ [DatabaseName.ORACLE, getDatasource(DatabaseName.ORACLE)], ])("/rows (%s)", (providerType, dsProvider) => { const isInternal = dsProvider === undefined + const isLucene = providerType === "lucene" + const isSqs = providerType === "sqs" const isMSSQL = providerType === DatabaseName.SQL_SERVER const isOracle = providerType === DatabaseName.ORACLE const config = setup.getConfig() @@ -83,9 +92,17 @@ describe.each([ let table: Table let datasource: Datasource | undefined let client: Knex | undefined + let envCleanup: (() => void) | undefined beforeAll(async () => { - await config.init() + await withCoreEnv({ SQS_SEARCH_ENABLE: "true" }, () => config.init()) + if (isSqs) { + envCleanup = setCoreEnv({ + SQS_SEARCH_ENABLE: "true", + SQS_SEARCH_ENABLE_TENANTS: [config.getTenantId()], + }) + } + if (dsProvider) { const rawDatasource = await dsProvider datasource = await config.createDatasource({ @@ -97,6 +114,9 @@ describe.each([ afterAll(async () => { setup.afterAll() + if (envCleanup) { + envCleanup() + } }) function saveTableRequest( @@ -346,7 +366,7 @@ describe.each([ expect(ids).toEqual(expect.arrayContaining(sequence)) }) - isInternal && + isLucene && it("row values are coerced", async () => { const str: FieldSchema = { type: FieldType.STRING, diff --git a/packages/server/src/api/routes/tests/viewV2.spec.ts b/packages/server/src/api/routes/tests/viewV2.spec.ts index 079d85c684..83ab09cf6d 100644 --- a/packages/server/src/api/routes/tests/viewV2.spec.ts +++ b/packages/server/src/api/routes/tests/viewV2.spec.ts @@ -15,7 +15,6 @@ import { Table, TableSourceType, UpdateViewRequest, - ViewUIFieldMetadata, ViewV2, SearchResponse, BasicOperator, @@ -124,669 +123,122 @@ describe.each([ mocks.licenses.useCloudFree() }) - const getRowUsage = async () => { - const { total } = await config.doInContext(undefined, () => - quotas.getCurrentUsageValues(QuotaUsageType.STATIC, StaticQuotaName.ROWS) - ) - return total - } - - const assertRowUsage = async (expected: number) => { - const usage = await getRowUsage() - expect(usage).toBe(expected) - } - - describe("create", () => { - it("persist the view when the view is successfully created", async () => { - const newView: CreateViewRequest = { - name: generator.name(), - tableId: table._id!, - schema: { - id: { visible: true }, - }, - } - const res = await config.api.viewV2.create(newView) - - expect(res).toEqual({ - ...newView, - id: expect.stringMatching(new RegExp(`${table._id!}_`)), - version: 2, - }) - }) - - it("can persist views with all fields", async () => { - const newView: Required = { - name: generator.name(), - tableId: table._id!, - primaryDisplay: "id", - query: [ - { - operator: BasicOperator.EQUAL, - field: "field", - value: "value", - }, - ], - sort: { - field: "fieldToSort", - order: SortOrder.DESCENDING, - type: SortType.STRING, - }, - schema: { - id: { visible: true }, - Price: { - visible: true, - }, - }, - } - const res = await config.api.viewV2.create(newView) - - expect(res).toEqual({ - ...newView, - schema: { - id: { visible: true }, - Price: { - visible: true, - }, - }, - id: expect.any(String), - version: 2, - }) - }) - - it("persist only UI schema overrides", async () => { - const newView: CreateViewRequest = { - name: generator.name(), - tableId: table._id!, - schema: { - id: { - name: "id", - type: FieldType.NUMBER, - visible: true, - }, - Price: { - name: "Price", - type: FieldType.NUMBER, - visible: true, - order: 1, - width: 100, - }, - Category: { - name: "Category", - type: FieldType.STRING, - visible: false, - icon: "ic", - }, - } as Record, - } - - const createdView = await config.api.viewV2.create(newView) - - expect(createdView).toEqual({ - ...newView, - schema: { - id: { visible: true }, - Price: { - visible: true, - order: 1, - width: 100, - }, - Category: { - visible: false, - icon: "ic", - }, - }, - id: createdView.id, - version: 2, - }) - }) - - it("will not throw an exception if the schema is 'deleting' non UI fields", async () => { - const newView: CreateViewRequest = { - name: generator.name(), - tableId: table._id!, - schema: { - id: { - name: "id", - type: FieldType.NUMBER, - autocolumn: true, - visible: true, - }, - Price: { - name: "Price", - type: FieldType.NUMBER, - visible: true, - }, - Category: { - name: "Category", - type: FieldType.STRING, - }, - } as Record, - } - - await config.api.viewV2.create(newView, { - status: 201, - }) - }) - - it("does not persist non-visible fields", async () => { - const newView: CreateViewRequest = { - name: generator.name(), - tableId: table._id!, - primaryDisplay: "id", - schema: { - id: { visible: true }, - Price: { visible: true }, - Category: { visible: false }, - }, - } - const res = await config.api.viewV2.create(newView) - - expect(res).toEqual({ - ...newView, - schema: { - id: { visible: true }, - Price: { visible: true }, - Category: { visible: false }, - }, - id: expect.any(String), - version: 2, - }) - }) - - it("throws bad request when the schema fields are not valid", async () => { - const newView: CreateViewRequest = { - name: generator.name(), - tableId: table._id!, - schema: { - id: { visible: true }, - nonExisting: { - visible: true, - }, - }, - } - await config.api.viewV2.create(newView, { - status: 400, - body: { - message: 'Field "nonExisting" is not valid for the requested table', - }, - }) - }) - - describe("readonly fields", () => { - beforeEach(() => { - mocks.licenses.useViewReadonlyColumns() - }) - - it("readonly fields are persisted", async () => { - const table = await config.api.table.save( - saveTableRequest({ - schema: { - name: { - name: "name", - type: FieldType.STRING, - }, - description: { - name: "description", - type: FieldType.STRING, - }, - }, - }) - ) - + describe("view crud", () => { + describe("create", () => { + it("persist the view when the view is successfully created", async () => { const newView: CreateViewRequest = { name: generator.name(), tableId: table._id!, schema: { id: { visible: true }, - name: { - visible: true, - readonly: true, - }, - description: { - visible: true, - readonly: true, - }, }, } - const res = await config.api.viewV2.create(newView) - expect(res.schema).toEqual({ - id: { visible: true }, - name: { - visible: true, - readonly: true, - }, - description: { - visible: true, - readonly: true, - }, + + expect(res).toEqual({ + ...newView, + id: expect.stringMatching(new RegExp(`${table._id!}_`)), + version: 2, }) }) - it("required fields cannot be marked as readonly", async () => { - const table = await config.api.table.save( - saveTableRequest({ - schema: { - name: { - name: "name", - type: FieldType.STRING, - constraints: { presence: true }, - }, - description: { - name: "description", - type: FieldType.STRING, - }, - }, - }) - ) - - const newView: CreateViewRequest = { + it("can persist views with all fields", async () => { + const newView: Required = { name: generator.name(), tableId: table._id!, - schema: { - id: { visible: true }, - name: { - visible: true, - readonly: true, - }, - }, - } - - await config.api.viewV2.create(newView, { - status: 400, - body: { - message: - 'You can\'t make "name" readonly because it is a required field.', - status: 400, - }, - }) - }) - - it("readonly fields must be visible", async () => { - const table = await config.api.table.save( - saveTableRequest({ - schema: { - name: { - name: "name", - type: FieldType.STRING, - }, - description: { - name: "description", - type: FieldType.STRING, - }, - }, - }) - ) - - const newView: CreateViewRequest = { - name: generator.name(), - tableId: table._id!, - schema: { - id: { visible: true }, - name: { - visible: false, - readonly: true, - }, - }, - } - - await config.api.viewV2.create(newView, { - status: 400, - body: { - message: - 'Field "name" must be visible if you want to make it readonly', - status: 400, - }, - }) - }) - - it("readonly fields cannot be used on free license", async () => { - mocks.licenses.useCloudFree() - const table = await config.api.table.save( - saveTableRequest({ - schema: { - name: { - name: "name", - type: FieldType.STRING, - }, - description: { - name: "description", - type: FieldType.STRING, - }, - }, - }) - ) - - const newView: CreateViewRequest = { - name: generator.name(), - tableId: table._id!, - schema: { - id: { visible: true }, - name: { - visible: true, - readonly: true, - }, - }, - } - - await config.api.viewV2.create(newView, { - status: 400, - body: { - message: "Readonly fields are not enabled", - status: 400, - }, - }) - }) - }) - - it("display fields must be visible", async () => { - const table = await config.api.table.save( - saveTableRequest({ - schema: { - name: { - name: "name", - type: FieldType.STRING, - }, - description: { - name: "description", - type: FieldType.STRING, - }, - }, - }) - ) - - const newView: CreateViewRequest = { - name: generator.name(), - tableId: table._id!, - primaryDisplay: "name", - schema: { - id: { visible: true }, - name: { - visible: false, - }, - }, - } - - await config.api.viewV2.create(newView, { - status: 400, - body: { - message: 'You can\'t hide "name" because it is the display column.', - status: 400, - }, - }) - }) - - it("display fields can be readonly", async () => { - mocks.licenses.useViewReadonlyColumns() - const table = await config.api.table.save( - saveTableRequest({ - schema: { - name: { - name: "name", - type: FieldType.STRING, - }, - description: { - name: "description", - type: FieldType.STRING, - }, - }, - }) - ) - - const newView: CreateViewRequest = { - name: generator.name(), - tableId: table._id!, - primaryDisplay: "name", - schema: { - id: { visible: true }, - name: { - visible: true, - readonly: true, - }, - }, - } - - await config.api.viewV2.create(newView, { - status: 201, - }) - }) - }) - - describe("update", () => { - let view: ViewV2 - - beforeEach(async () => { - table = await config.api.table.save(priceTable()) - - view = await config.api.viewV2.create({ - tableId: table._id!, - name: generator.guid(), - schema: { - id: { visible: true }, - }, - }) - }) - - it("can update an existing view data", async () => { - const tableId = table._id! - await config.api.viewV2.update({ - ...view, - query: [ - { - operator: BasicOperator.EQUAL, - field: "newField", - value: "thatValue", - }, - ], - }) - - expect((await config.api.table.get(tableId)).views).toEqual({ - [view.name]: { - ...view, - query: [{ operator: "equal", field: "newField", value: "thatValue" }], - schema: expect.anything(), - }, - }) - }) - - it("can update all fields", async () => { - mocks.licenses.useViewReadonlyColumns() - const tableId = table._id! - - const updatedData: Required = { - version: view.version, - id: view.id, - tableId, - name: view.name, - primaryDisplay: "Price", - query: [ - { - operator: BasicOperator.EQUAL, - field: generator.word(), - value: generator.word(), - }, - ], - sort: { - field: generator.word(), - order: SortOrder.DESCENDING, - type: SortType.STRING, - }, - schema: { - id: { visible: true }, - Category: { - visible: false, - }, - Price: { - visible: true, - readonly: true, - }, - }, - } - await config.api.viewV2.update(updatedData) - - expect((await config.api.table.get(tableId)).views).toEqual({ - [view.name]: { - ...updatedData, - schema: { - ...table.schema, - id: expect.objectContaining({ - visible: true, - }), - Category: expect.objectContaining({ - visible: false, - }), - Price: expect.objectContaining({ - visible: true, - readonly: true, - }), - }, - }, - }) - }) - - it("can update an existing view name", async () => { - const tableId = table._id! - const newName = generator.guid() - await config.api.viewV2.update({ ...view, name: newName }) - - expect(await config.api.table.get(tableId)).toEqual( - expect.objectContaining({ - views: { - [newName]: { ...view, name: newName, schema: expect.anything() }, - }, - }) - ) - }) - - it("cannot update an unexisting views nor edit ids", async () => { - const tableId = table._id! - await config.api.viewV2.update( - { ...view, id: generator.guid() }, - { status: 404 } - ) - - expect(await config.api.table.get(tableId)).toEqual( - expect.objectContaining({ - views: { - [view.name]: { - ...view, - schema: expect.anything(), - }, - }, - }) - ) - }) - - it("cannot update views with the wrong tableId", async () => { - const tableId = table._id! - await config.api.viewV2.update( - { - ...view, - tableId: generator.guid(), + primaryDisplay: "id", query: [ { operator: BasicOperator.EQUAL, - field: "newField", - value: "thatValue", + field: "field", + value: "value", }, ], - }, - { status: 404 } - ) - - expect(await config.api.table.get(tableId)).toEqual( - expect.objectContaining({ - views: { - [view.name]: { - ...view, - schema: expect.anything(), + sort: { + field: "fieldToSort", + order: SortOrder.DESCENDING, + type: SortType.STRING, + }, + schema: { + id: { visible: true }, + Price: { + visible: true, }, }, - }) - ) - }) + } + const res = await config.api.viewV2.create(newView) - it("cannot update views v1", async () => { - const viewV1 = await config.api.legacyView.save({ - tableId: table._id!, - name: generator.guid(), - filters: [], - schema: {}, - }) - - await config.api.viewV2.update(viewV1 as unknown as ViewV2, { - status: 400, - body: { - message: "Only views V2 can be updated", - status: 400, - }, - }) - }) - - it("cannot update the a view with unmatching ids between url and body", async () => { - const anotherView = await config.api.viewV2.create({ - tableId: table._id!, - name: generator.guid(), - schema: { - id: { visible: true }, - }, - }) - const result = await config - .request!.put(`/api/v2/views/${anotherView.id}`) - .send(view) - .set(config.defaultHeaders()) - .expect("Content-Type", /json/) - .expect(400) - - expect(result.body).toEqual({ - message: "View id does not match between the body and the uri path", - status: 400, - }) - }) - - it("updates only UI schema overrides", async () => { - const updatedView = await config.api.viewV2.update({ - ...view, - schema: { - ...view.schema, - Price: { - name: "Price", - type: FieldType.NUMBER, - visible: true, - order: 1, - width: 100, - }, - Category: { - name: "Category", - type: FieldType.STRING, - visible: false, - icon: "ic", - }, - } as Record, - }) - - expect(updatedView).toEqual({ - ...view, - schema: { - id: { visible: true }, - Price: { - visible: true, - order: 1, - width: 100, - }, - Category: { visible: false, icon: "ic" }, - }, - id: view.id, - version: 2, - }) - }) - - it("will not throw an exception if the schema is 'deleting' non UI fields", async () => { - await config.api.viewV2.update( - { - ...view, + expect(res).toEqual({ + ...newView, schema: { - ...view.schema, + id: { visible: true }, + Price: { + visible: true, + }, + }, + id: expect.any(String), + version: 2, + }) + }) + + it("persist only UI schema overrides", async () => { + const newView: CreateViewRequest = { + name: generator.name(), + tableId: table._id!, + schema: { + id: { + name: "id", + type: FieldType.NUMBER, + visible: true, + }, + Price: { + name: "Price", + type: FieldType.NUMBER, + visible: true, + order: 1, + width: 100, + }, + Category: { + name: "Category", + type: FieldType.STRING, + visible: false, + icon: "ic", + }, + } as Record, + } + + const createdView = await config.api.viewV2.create(newView) + + expect(createdView).toEqual({ + ...newView, + schema: { + id: { visible: true }, + Price: { + visible: true, + order: 1, + width: 100, + }, + Category: { + visible: false, + icon: "ic", + }, + }, + id: createdView.id, + version: 2, + }) + }) + + it("will not throw an exception if the schema is 'deleting' non UI fields", async () => { + const newView: CreateViewRequest = { + name: generator.name(), + tableId: table._id!, + schema: { + id: { + name: "id", + type: FieldType.NUMBER, + autocolumn: true, + visible: true, + }, Price: { name: "Price", type: FieldType.NUMBER, @@ -797,66 +249,593 @@ describe.each([ type: FieldType.STRING, }, } as Record, - }, - { - status: 200, } - ) - }) - it("cannot update views with readonly on on free license", async () => { - mocks.licenses.useViewReadonlyColumns() - - view = await config.api.viewV2.update({ - ...view, - schema: { - id: { visible: true }, - Price: { - visible: true, - readonly: true, - }, - }, + await config.api.viewV2.create(newView, { + status: 201, + }) }) - mocks.licenses.useCloudFree() - await config.api.viewV2.update(view, { - status: 400, - body: { - message: "Readonly fields are not enabled", - }, + it("does not persist non-visible fields", async () => { + const newView: CreateViewRequest = { + name: generator.name(), + tableId: table._id!, + primaryDisplay: "id", + schema: { + id: { visible: true }, + Price: { visible: true }, + Category: { visible: false }, + }, + } + const res = await config.api.viewV2.create(newView) + + expect(res).toEqual({ + ...newView, + schema: { + id: { visible: true }, + Price: { visible: true }, + Category: { visible: false }, + }, + id: expect.any(String), + version: 2, + }) + }) + + it("throws bad request when the schema fields are not valid", async () => { + const newView: CreateViewRequest = { + name: generator.name(), + tableId: table._id!, + schema: { + id: { visible: true }, + nonExisting: { + visible: true, + }, + }, + } + await config.api.viewV2.create(newView, { + status: 400, + body: { + message: 'Field "nonExisting" is not valid for the requested table', + }, + }) + }) + + describe("readonly fields", () => { + beforeEach(() => { + mocks.licenses.useViewReadonlyColumns() + }) + + it("readonly fields are persisted", async () => { + const table = await config.api.table.save( + saveTableRequest({ + schema: { + name: { + name: "name", + type: FieldType.STRING, + }, + description: { + name: "description", + type: FieldType.STRING, + }, + }, + }) + ) + + const newView: CreateViewRequest = { + name: generator.name(), + tableId: table._id!, + schema: { + id: { visible: true }, + name: { + visible: true, + readonly: true, + }, + description: { + visible: true, + readonly: true, + }, + }, + } + + const res = await config.api.viewV2.create(newView) + expect(res.schema).toEqual({ + id: { visible: true }, + name: { + visible: true, + readonly: true, + }, + description: { + visible: true, + readonly: true, + }, + }) + }) + + it("required fields cannot be marked as readonly", async () => { + const table = await config.api.table.save( + saveTableRequest({ + schema: { + name: { + name: "name", + type: FieldType.STRING, + constraints: { presence: true }, + }, + description: { + name: "description", + type: FieldType.STRING, + }, + }, + }) + ) + + const newView: CreateViewRequest = { + name: generator.name(), + tableId: table._id!, + schema: { + id: { visible: true }, + name: { + visible: true, + readonly: true, + }, + }, + } + + await config.api.viewV2.create(newView, { + status: 400, + body: { + message: + 'You can\'t make "name" readonly because it is a required field.', + status: 400, + }, + }) + }) + + it("readonly fields must be visible", async () => { + const table = await config.api.table.save( + saveTableRequest({ + schema: { + name: { + name: "name", + type: FieldType.STRING, + }, + description: { + name: "description", + type: FieldType.STRING, + }, + }, + }) + ) + + const newView: CreateViewRequest = { + name: generator.name(), + tableId: table._id!, + schema: { + id: { visible: true }, + name: { + visible: false, + readonly: true, + }, + }, + } + + await config.api.viewV2.create(newView, { + status: 400, + body: { + message: + 'Field "name" must be visible if you want to make it readonly', + status: 400, + }, + }) + }) + + it("readonly fields cannot be used on free license", async () => { + mocks.licenses.useCloudFree() + const table = await config.api.table.save( + saveTableRequest({ + schema: { + name: { + name: "name", + type: FieldType.STRING, + }, + description: { + name: "description", + type: FieldType.STRING, + }, + }, + }) + ) + + const newView: CreateViewRequest = { + name: generator.name(), + tableId: table._id!, + schema: { + id: { visible: true }, + name: { + visible: true, + readonly: true, + }, + }, + } + + await config.api.viewV2.create(newView, { + status: 400, + body: { + message: "Readonly fields are not enabled", + status: 400, + }, + }) + }) + }) + + it("display fields must be visible", async () => { + const table = await config.api.table.save( + saveTableRequest({ + schema: { + name: { + name: "name", + type: FieldType.STRING, + }, + description: { + name: "description", + type: FieldType.STRING, + }, + }, + }) + ) + + const newView: CreateViewRequest = { + name: generator.name(), + tableId: table._id!, + primaryDisplay: "name", + schema: { + id: { visible: true }, + name: { + visible: false, + }, + }, + } + + await config.api.viewV2.create(newView, { + status: 400, + body: { + message: 'You can\'t hide "name" because it is the display column.', + status: 400, + }, + }) + }) + + it("display fields can be readonly", async () => { + mocks.licenses.useViewReadonlyColumns() + const table = await config.api.table.save( + saveTableRequest({ + schema: { + name: { + name: "name", + type: FieldType.STRING, + }, + description: { + name: "description", + type: FieldType.STRING, + }, + }, + }) + ) + + const newView: CreateViewRequest = { + name: generator.name(), + tableId: table._id!, + primaryDisplay: "name", + schema: { + id: { visible: true }, + name: { + visible: true, + readonly: true, + }, + }, + } + + await config.api.viewV2.create(newView, { + status: 201, + }) }) }) - it("can remove readonly config after license downgrade", async () => { - mocks.licenses.useViewReadonlyColumns() + describe("update", () => { + let view: ViewV2 - view = await config.api.viewV2.update({ - ...view, - schema: { - id: { visible: true }, - Price: { - visible: true, - readonly: true, + beforeEach(async () => { + table = await config.api.table.save(priceTable()) + + view = await config.api.viewV2.create({ + tableId: table._id!, + name: generator.guid(), + schema: { + id: { visible: true }, }, - Category: { - visible: true, - readonly: true, - }, - }, + }) }) - mocks.licenses.useCloudFree() - const res = await config.api.viewV2.update({ - ...view, - schema: { - id: { visible: true }, - Price: { - visible: true, - readonly: false, + + it("can update an existing view data", async () => { + const tableId = table._id! + await config.api.viewV2.update({ + ...view, + query: [ + { + operator: BasicOperator.EQUAL, + field: "newField", + value: "thatValue", + }, + ], + }) + + expect((await config.api.table.get(tableId)).views).toEqual({ + [view.name]: { + ...view, + query: [ + { operator: "equal", field: "newField", value: "thatValue" }, + ], + schema: expect.anything(), }, - }, + }) }) - expect(res).toEqual( - expect.objectContaining({ + + it("can update all fields", async () => { + mocks.licenses.useViewReadonlyColumns() + const tableId = table._id! + + const updatedData: Required = { + version: view.version, + id: view.id, + tableId, + name: view.name, + primaryDisplay: "Price", + query: [ + { + operator: BasicOperator.EQUAL, + field: generator.word(), + value: generator.word(), + }, + ], + sort: { + field: generator.word(), + order: SortOrder.DESCENDING, + type: SortType.STRING, + }, + schema: { + id: { visible: true }, + Category: { + visible: false, + }, + Price: { + visible: true, + readonly: true, + }, + }, + } + await config.api.viewV2.update(updatedData) + + expect((await config.api.table.get(tableId)).views).toEqual({ + [view.name]: { + ...updatedData, + schema: { + ...table.schema, + id: expect.objectContaining({ + visible: true, + }), + Category: expect.objectContaining({ + visible: false, + }), + Price: expect.objectContaining({ + visible: true, + readonly: true, + }), + }, + }, + }) + }) + + it("can update an existing view name", async () => { + const tableId = table._id! + const newName = generator.guid() + await config.api.viewV2.update({ ...view, name: newName }) + + expect(await config.api.table.get(tableId)).toEqual( + expect.objectContaining({ + views: { + [newName]: { ...view, name: newName, schema: expect.anything() }, + }, + }) + ) + }) + + it("cannot update an unexisting views nor edit ids", async () => { + const tableId = table._id! + await config.api.viewV2.update( + { ...view, id: generator.guid() }, + { status: 404 } + ) + + expect(await config.api.table.get(tableId)).toEqual( + expect.objectContaining({ + views: { + [view.name]: { + ...view, + schema: expect.anything(), + }, + }, + }) + ) + }) + + it("cannot update views with the wrong tableId", async () => { + const tableId = table._id! + await config.api.viewV2.update( + { + ...view, + tableId: generator.guid(), + query: [ + { + operator: BasicOperator.EQUAL, + field: "newField", + value: "thatValue", + }, + ], + }, + { status: 404 } + ) + + expect(await config.api.table.get(tableId)).toEqual( + expect.objectContaining({ + views: { + [view.name]: { + ...view, + schema: expect.anything(), + }, + }, + }) + ) + }) + + it("cannot update views v1", async () => { + const viewV1 = await config.api.legacyView.save({ + tableId: table._id!, + name: generator.guid(), + filters: [], + schema: {}, + }) + + await config.api.viewV2.update(viewV1 as unknown as ViewV2, { + status: 400, + body: { + message: "Only views V2 can be updated", + status: 400, + }, + }) + }) + + it("cannot update the a view with unmatching ids between url and body", async () => { + const anotherView = await config.api.viewV2.create({ + tableId: table._id!, + name: generator.guid(), + schema: { + id: { visible: true }, + }, + }) + const result = await config + .request!.put(`/api/v2/views/${anotherView.id}`) + .send(view) + .set(config.defaultHeaders()) + .expect("Content-Type", /json/) + .expect(400) + + expect(result.body).toEqual({ + message: "View id does not match between the body and the uri path", + status: 400, + }) + }) + + it("updates only UI schema overrides", async () => { + const updatedView = await config.api.viewV2.update({ + ...view, + schema: { + ...view.schema, + Price: { + name: "Price", + type: FieldType.NUMBER, + visible: true, + order: 1, + width: 100, + }, + Category: { + name: "Category", + type: FieldType.STRING, + visible: false, + icon: "ic", + }, + } as Record, + }) + + expect(updatedView).toEqual({ + ...view, + schema: { + id: { visible: true }, + Price: { + visible: true, + order: 1, + width: 100, + }, + Category: { visible: false, icon: "ic" }, + }, + id: view.id, + version: 2, + }) + }) + + it("will not throw an exception if the schema is 'deleting' non UI fields", async () => { + await config.api.viewV2.update( + { + ...view, + schema: { + ...view.schema, + Price: { + name: "Price", + type: FieldType.NUMBER, + visible: true, + }, + Category: { + name: "Category", + type: FieldType.STRING, + }, + } as Record, + }, + { + status: 200, + } + ) + }) + + it("cannot update views with readonly on on free license", async () => { + mocks.licenses.useViewReadonlyColumns() + + view = await config.api.viewV2.update({ + ...view, + schema: { + id: { visible: true }, + Price: { + visible: true, + readonly: true, + }, + }, + }) + + mocks.licenses.useCloudFree() + await config.api.viewV2.update(view, { + status: 400, + body: { + message: "Readonly fields are not enabled", + }, + }) + }) + + it("can remove readonly config after license downgrade", async () => { + mocks.licenses.useViewReadonlyColumns() + + view = await config.api.viewV2.update({ + ...view, + schema: { + id: { visible: true }, + Price: { + visible: true, + readonly: true, + }, + Category: { + visible: true, + readonly: true, + }, + }, + }) + mocks.licenses.useCloudFree() + const res = await config.api.viewV2.update({ ...view, schema: { id: { visible: true }, @@ -866,157 +845,334 @@ describe.each([ }, }, }) - ) + expect(res).toEqual( + expect.objectContaining({ + ...view, + schema: { + id: { visible: true }, + Price: { + visible: true, + readonly: false, + }, + }, + }) + ) + }) + + isInternal && + it("updating schema will only validate modified field", async () => { + let view = await config.api.viewV2.create({ + tableId: table._id!, + name: generator.guid(), + schema: { + id: { visible: true }, + Price: { + visible: true, + }, + Category: { visible: true }, + }, + }) + + // Update the view to an invalid state + const tableToUpdate = await config.api.table.get(table._id!) + ;(tableToUpdate.views![view.name] as ViewV2).schema!.id.visible = + false + await db.getDB(config.appId!).put(tableToUpdate) + + view = await config.api.viewV2.get(view.id) + await config.api.viewV2.update( + { + ...view, + schema: { + ...view.schema, + Price: { + visible: false, + }, + }, + }, + { + status: 400, + body: { + message: 'You can\'t hide "id" because it is a required field.', + status: 400, + }, + } + ) + }) }) - isInternal && - it("updating schema will only validate modified field", async () => { - let view = await config.api.viewV2.create({ + describe("delete", () => { + let view: ViewV2 + + beforeAll(async () => { + view = await config.api.viewV2.create({ tableId: table._id!, name: generator.guid(), schema: { id: { visible: true }, - Price: { - visible: true, - }, - Category: { visible: true }, }, }) + }) - // Update the view to an invalid state - const tableToUpdate = await config.api.table.get(table._id!) - ;(tableToUpdate.views![view.name] as ViewV2).schema!.id.visible = false - await db.getDB(config.appId!).put(tableToUpdate) + it("can delete an existing view", async () => { + const tableId = table._id! + const getPersistedView = async () => + (await config.api.table.get(tableId)).views![view.name] - view = await config.api.viewV2.get(view.id) - await config.api.viewV2.update( - { - ...view, + expect(await getPersistedView()).toBeDefined() + + await config.api.viewV2.delete(view.id) + + expect(await getPersistedView()).toBeUndefined() + }) + }) + + describe.each([ + ["from view api", (view: ViewV2) => config.api.viewV2.get(view.id)], + [ + "from table", + async (view: ViewV2) => { + const table = await config.api.table.get(view.tableId) + return table.views![view.name] as ViewV2 + }, + ], + ])("read (%s)", (_, getDelegate) => { + let table: Table + let tableId: string + + beforeEach(async () => { + table = await config.api.table.save( + saveTableRequest({ schema: { - ...view.schema, - Price: { - visible: false, + one: { + type: FieldType.STRING, + name: "one", + }, + two: { + type: FieldType.STRING, + name: "two", + }, + three: { + type: FieldType.STRING, + name: "three", }, }, - }, - { - status: 400, - body: { - message: 'You can\'t hide "id" because it is a required field.', - status: 400, - }, - } + }) ) - }) - }) - - describe("delete", () => { - let view: ViewV2 - - beforeAll(async () => { - view = await config.api.viewV2.create({ - tableId: table._id!, - name: generator.guid(), - schema: { - id: { visible: true }, - }, - }) - }) - - it("can delete an existing view", async () => { - const tableId = table._id! - const getPersistedView = async () => - (await config.api.table.get(tableId)).views![view.name] - - expect(await getPersistedView()).toBeDefined() - - await config.api.viewV2.delete(view.id) - - expect(await getPersistedView()).toBeUndefined() - }) - }) - - describe("fetch view (through table)", () => { - it("should be able to fetch a view V2", async () => { - const res = await config.api.viewV2.create({ - name: generator.name(), - tableId: table._id!, - schema: { - id: { visible: true }, - Price: { visible: false }, - Category: { visible: true }, - }, + tableId = table._id! }) - const view = await config.api.viewV2.get(res.id) - const updatedTable = await config.api.table.get(table._id!) - const viewSchema = updatedTable.views![view!.name!].schema as Record< - string, - ViewUIFieldMetadata - > - expect(viewSchema.Price?.visible).toEqual(false) - expect(viewSchema.Category?.visible).toEqual(true) - }) - - it("should be able to fetch readonly config after downgrades", async () => { - mocks.licenses.useViewReadonlyColumns() - const res = await config.api.viewV2.create({ - name: generator.name(), - tableId: table._id!, - schema: { - id: { visible: true }, - Price: { visible: true, readonly: true }, - }, - }) - - mocks.licenses.useCloudFree() - const view = await config.api.viewV2.get(res.id) - expect(view.schema?.Price).toEqual( - expect.objectContaining({ visible: true, readonly: true }) - ) - }) - }) - - describe("read", () => { - let view: ViewV2 - - beforeAll(async () => { - table = await config.api.table.save( - saveTableRequest({ + it("retrieves the view data with the enriched schema", async () => { + const view = await config.api.viewV2.create({ + tableId, + name: generator.guid(), schema: { - Country: { - type: FieldType.STRING, - name: "Country", - }, - Story: { - type: FieldType.STRING, - name: "Story", - }, + id: { visible: true }, + one: { visible: true }, + two: { visible: true }, }, }) - ) - view = await config.api.viewV2.create({ - tableId: table._id!, - name: generator.guid(), - schema: { - id: { visible: true }, - Country: { - visible: true, + expect(await getDelegate(view)).toEqual({ + ...view, + schema: { + id: { ...table.schema["id"], visible: true }, + one: { ...table.schema["one"], visible: true }, + two: { ...table.schema["two"], visible: true }, + three: { ...table.schema["three"], visible: false }, }, - }, + }) + }) + + it("does not include columns removed from the table", async () => { + const view = await config.api.viewV2.create({ + tableId, + name: generator.guid(), + schema: { + id: { visible: true }, + one: { visible: true }, + two: { visible: true }, + }, + }) + const table = await config.api.table.get(tableId) + const { one: _, ...newSchema } = table.schema + await config.api.table.save({ ...table, schema: newSchema }) + + expect(await getDelegate(view)).toEqual({ + ...view, + schema: { + id: { ...table.schema["id"], visible: true }, + two: { ...table.schema["two"], visible: true }, + three: { ...table.schema["three"], visible: false }, + }, + }) + }) + + it("does not include columns hidden from the table", async () => { + const view = await config.api.viewV2.create({ + tableId, + name: generator.guid(), + schema: { + id: { visible: true }, + one: { visible: true }, + two: { visible: true }, + }, + }) + const table = await config.api.table.get(tableId) + await config.api.table.save({ + ...table, + schema: { + ...table.schema, + two: { ...table.schema["two"], visible: false }, + }, + }) + + expect(await getDelegate(view)).toEqual({ + ...view, + schema: { + id: { ...table.schema["id"], visible: true }, + one: { ...table.schema["one"], visible: true }, + three: { ...table.schema["three"], visible: false }, + }, + }) + }) + + it("should be able to fetch readonly config after downgrades", async () => { + mocks.licenses.useViewReadonlyColumns() + const res = await config.api.viewV2.create({ + name: generator.name(), + tableId: table._id!, + schema: { + id: { visible: true }, + one: { visible: true, readonly: true }, + }, + }) + + mocks.licenses.useCloudFree() + const view = await getDelegate(res) + expect(view.schema?.one).toEqual( + expect.objectContaining({ visible: true, readonly: true }) + ) }) }) - it("views have extra data trimmed", async () => { - let row = await config.api.row.save(view.id, { - Country: "Aussy", - Story: "aaaaa", - }) + describe("updating table schema", () => { + describe("existing columns changed to required", () => { + beforeEach(async () => { + table = await config.api.table.save( + saveTableRequest({ + schema: { + id: { + name: "id", + type: FieldType.NUMBER, + autocolumn: true, + }, + name: { + name: "name", + type: FieldType.STRING, + }, + }, + }) + ) + }) - row = await config.api.row.get(table._id!, row._id!) - expect(row.Story).toBeUndefined() - expect(row.Country).toEqual("Aussy") + it("allows updating when no views constrains the field", async () => { + await config.api.viewV2.create({ + name: "view a", + tableId: table._id!, + schema: { + id: { visible: true }, + name: { visible: true }, + }, + }) + + table = await config.api.table.get(table._id!) + await config.api.table.save( + { + ...table, + schema: { + ...table.schema, + name: { + name: "name", + type: FieldType.STRING, + constraints: { presence: { allowEmpty: false } }, + }, + }, + }, + { status: 200 } + ) + }) + + it("rejects if field is readonly in any view", async () => { + mocks.licenses.useViewReadonlyColumns() + + await config.api.viewV2.create({ + name: "view a", + tableId: table._id!, + schema: { + id: { visible: true }, + name: { + visible: true, + readonly: true, + }, + }, + }) + + table = await config.api.table.get(table._id!) + await config.api.table.save( + { + ...table, + schema: { + ...table.schema, + name: { + name: "name", + type: FieldType.STRING, + constraints: { presence: true }, + }, + }, + }, + { + status: 400, + body: { + status: 400, + message: + 'To make field "name" required, this field must be present and writable in views: view a.', + }, + } + ) + }) + + it("rejects if field is hidden in any view", async () => { + await config.api.viewV2.create({ + name: "view a", + tableId: table._id!, + schema: { id: { visible: true } }, + }) + + table = await config.api.table.get(table._id!) + await config.api.table.save( + { + ...table, + schema: { + ...table.schema, + name: { + name: "name", + type: FieldType.STRING, + constraints: { presence: true }, + }, + }, + }, + { + status: 400, + body: { + status: 400, + message: + 'To make field "name" required, this field must be present and writable in views: view a.', + }, + } + ) + }) + }) }) }) @@ -1083,9 +1239,36 @@ describe.each([ expect(row.one).toBeUndefined() expect(row.two).toEqual("bar") }) + + it("should not return non-view view fields for a row", async () => { + const newRow = await config.api.row.save(view.id, { + one: "foo", + two: "bar", + }) + + expect(newRow.one).toBeUndefined() + expect(newRow.two).toEqual("bar") + }) }) describe("patch", () => { + it("should not return non-view view fields for a row", async () => { + const newRow = await config.api.row.save(table._id!, { + one: "foo", + two: "bar", + }) + const row = await config.api.row.patch(view.id, { + tableId: table._id!, + _id: newRow._id!, + _rev: newRow._rev!, + one: "newFoo", + two: "newBar", + }) + + expect(row.one).toBeUndefined() + expect(row.two).toEqual("newBar") + }) + it("should update only the view fields for a row", async () => { const newRow = await config.api.row.save(table._id!, { one: "foo", @@ -1134,6 +1317,21 @@ describe.each([ }) describe("destroy", () => { + const getRowUsage = async () => { + const { total } = await config.doInContext(undefined, () => + quotas.getCurrentUsageValues( + QuotaUsageType.STATIC, + StaticQuotaName.ROWS + ) + ) + return total + } + + const assertRowUsage = async (expected: number) => { + const usage = await getRowUsage() + expect(usage).toBe(expected) + } + it("should be able to delete a row", async () => { const createdRow = await config.api.row.save(table._id!, {}) const rowUsage = await getRowUsage() @@ -1166,6 +1364,50 @@ describe.each([ }) }) + describe("read", () => { + let view: ViewV2 + let table: Table + + beforeAll(async () => { + table = await config.api.table.save( + saveTableRequest({ + schema: { + Country: { + type: FieldType.STRING, + name: "Country", + }, + Story: { + type: FieldType.STRING, + name: "Story", + }, + }, + }) + ) + + view = await config.api.viewV2.create({ + tableId: table._id!, + name: generator.guid(), + schema: { + id: { visible: true }, + Country: { + visible: true, + }, + }, + }) + }) + + it("views have extra data trimmed", async () => { + let row = await config.api.row.save(view.id, { + Country: "Aussy", + Story: "aaaaa", + }) + + row = await config.api.row.get(table._id!, row._id!) + expect(row.Story).toBeUndefined() + expect(row.Country).toEqual("Aussy") + }) + }) + describe("search", () => { it("returns empty rows from view when no schema is passed", async () => { const rows = await Promise.all( @@ -1794,123 +2036,4 @@ describe.each([ }) }) }) - - describe("updating table schema", () => { - describe("existing columns changed to required", () => { - beforeEach(async () => { - table = await config.api.table.save( - saveTableRequest({ - schema: { - id: { - name: "id", - type: FieldType.NUMBER, - autocolumn: true, - }, - name: { - name: "name", - type: FieldType.STRING, - }, - }, - }) - ) - }) - - it("allows updating when no views constrains the field", async () => { - await config.api.viewV2.create({ - name: "view a", - tableId: table._id!, - schema: { - id: { visible: true }, - name: { visible: true }, - }, - }) - - table = await config.api.table.get(table._id!) - await config.api.table.save( - { - ...table, - schema: { - ...table.schema, - name: { - name: "name", - type: FieldType.STRING, - constraints: { presence: { allowEmpty: false } }, - }, - }, - }, - { status: 200 } - ) - }) - - it("rejects if field is readonly in any view", async () => { - mocks.licenses.useViewReadonlyColumns() - - await config.api.viewV2.create({ - name: "view a", - tableId: table._id!, - schema: { - id: { visible: true }, - name: { - visible: true, - readonly: true, - }, - }, - }) - - table = await config.api.table.get(table._id!) - await config.api.table.save( - { - ...table, - schema: { - ...table.schema, - name: { - name: "name", - type: FieldType.STRING, - constraints: { presence: true }, - }, - }, - }, - { - status: 400, - body: { - status: 400, - message: - 'To make field "name" required, this field must be present and writable in views: view a.', - }, - } - ) - }) - - it("rejects if field is hidden in any view", async () => { - await config.api.viewV2.create({ - name: "view a", - tableId: table._id!, - schema: { id: { visible: true } }, - }) - - table = await config.api.table.get(table._id!) - await config.api.table.save( - { - ...table, - schema: { - ...table.schema, - name: { - name: "name", - type: FieldType.STRING, - constraints: { presence: true }, - }, - }, - }, - { - status: 400, - body: { - status: 400, - message: - 'To make field "name" required, this field must be present and writable in views: view a.', - }, - } - ) - }) - }) - }) }) diff --git a/packages/server/src/api/routes/view.ts b/packages/server/src/api/routes/view.ts index 545d3016a3..807d8e2f28 100644 --- a/packages/server/src/api/routes/view.ts +++ b/packages/server/src/api/routes/view.ts @@ -46,7 +46,7 @@ router permissions.PermissionType.TABLE, permissions.PermissionLevel.READ ), - rowController.fetchView + rowController.fetchLegacyView ) .get("/api/views", authorized(permissions.BUILDER), viewController.v1.fetch) .delete( diff --git a/packages/server/src/db/linkedRows/index.ts b/packages/server/src/db/linkedRows/index.ts index 2da7e212b9..9a86df7041 100644 --- a/packages/server/src/db/linkedRows/index.ts +++ b/packages/server/src/db/linkedRows/index.ts @@ -18,6 +18,7 @@ import { LinkDocumentValue, Row, Table, + TableSchema, } from "@budibase/types" import sdk from "../../sdk" @@ -46,8 +47,8 @@ export const EventType = { TABLE_DELETE: "table:delete", } -function clearRelationshipFields(table: Table, rows: Row[]) { - for (let [key, field] of Object.entries(table.schema)) { +function clearRelationshipFields(schema: TableSchema, rows: Row[]) { + for (let [key, field] of Object.entries(schema)) { if (field.type === FieldType.LINK) { rows = rows.map(row => { delete row[key] @@ -158,11 +159,11 @@ export async function updateLinks(args: { * @return returns the rows with all of the enriched relationships on it. */ export async function attachFullLinkedDocs( - table: Table, + schema: TableSchema, rows: Row[], opts?: { fromRow?: Row } ) { - const linkedTableIds = getLinkedTableIDs(table) + const linkedTableIds = getLinkedTableIDs(schema) if (linkedTableIds.length === 0) { return rows } @@ -182,7 +183,7 @@ export async function attachFullLinkedDocs( } const linkedTables = response[1] as Table[] // clear any existing links that could be dupe'd - rows = clearRelationshipFields(table, rows) + rows = clearRelationshipFields(schema, rows) // now get the docs and combine into the rows let linked: Row[] = [] if (linksWithoutFromRow.length > 0) { @@ -201,7 +202,7 @@ export async function attachFullLinkedDocs( } if (linkedRow) { const linkedTableId = - linkedRow.tableId || getRelatedTableForField(table, link.fieldName) + linkedRow.tableId || getRelatedTableForField(schema, link.fieldName) const linkedTable = linkedTables.find( table => table._id === linkedTableId ) @@ -263,7 +264,8 @@ export async function squashLinksToPrimaryDisplay( } const newLinks = [] for (let link of row[column]) { - const linkTblId = link.tableId || getRelatedTableForField(table, 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) diff --git a/packages/server/src/db/linkedRows/linkUtils.ts b/packages/server/src/db/linkedRows/linkUtils.ts index c30d62ef35..4cedc4f86a 100644 --- a/packages/server/src/db/linkedRows/linkUtils.ts +++ b/packages/server/src/db/linkedRows/linkUtils.ts @@ -7,6 +7,7 @@ import { LinkDocument, LinkDocumentValue, Table, + TableSchema, } from "@budibase/types" import sdk from "../../sdk" @@ -121,8 +122,8 @@ export function getUniqueByProp(array: any[], prop: string) { return filteredArray } -export function getLinkedTableIDs(table: Table): string[] { - return Object.values(table.schema) +export function getLinkedTableIDs(schema: TableSchema): string[] { + return Object.values(schema) .filter(isRelationshipColumn) .map(column => column.tableId) } @@ -139,13 +140,16 @@ export async function getLinkedTable(id: string, tables: Table[]) { return linkedTable } -export function getRelatedTableForField(table: Table, fieldName: string) { +export function getRelatedTableForField( + schema: TableSchema, + fieldName: string +) { // look to see if its on the table, straight in the schema - const field = table.schema[fieldName] + const field = schema[fieldName] if (field?.type === FieldType.LINK) { return field.tableId } - for (let column of Object.values(table.schema)) { + for (let column of Object.values(schema)) { if (column.type === FieldType.LINK && column.fieldName === fieldName) { return column.tableId } diff --git a/packages/server/src/db/tests/linkTests.spec.ts b/packages/server/src/db/tests/linkTests.spec.ts index 3dbcb32011..2d6636388e 100644 --- a/packages/server/src/db/tests/linkTests.spec.ts +++ b/packages/server/src/db/tests/linkTests.spec.ts @@ -34,7 +34,7 @@ describe("test link functionality", () => { }) describe("getRelatedTableForField", () => { - let link = basicTable() + const link = basicTable() link.schema.link = { name: "link", relationshipType: RelationshipType.ONE_TO_MANY, @@ -44,11 +44,13 @@ describe("test link functionality", () => { } it("should get the field from the table directly", () => { - expect(linkUtils.getRelatedTableForField(link, "link")).toBe("tableID") + expect(linkUtils.getRelatedTableForField(link.schema, "link")).toBe( + "tableID" + ) }) it("should get the field from the link", () => { - expect(linkUtils.getRelatedTableForField(link, "otherLink")).toBe( + expect(linkUtils.getRelatedTableForField(link.schema, "otherLink")).toBe( "tableID" ) }) diff --git a/packages/server/src/middleware/trimViewRowInfo.ts b/packages/server/src/middleware/trimViewRowInfo.ts index 4c382e6123..2d29a88a6f 100644 --- a/packages/server/src/middleware/trimViewRowInfo.ts +++ b/packages/server/src/middleware/trimViewRowInfo.ts @@ -1,45 +1,44 @@ -import { Ctx, Row } from "@budibase/types" -import * as utils from "../db/utils" +import { Ctx, Row, ViewV2 } from "@budibase/types" + import sdk from "../sdk" import { Next } from "koa" -import { getTableId } from "../api/controllers/row/utils" +import { getSourceId } from "../api/controllers/row/utils" -export default async (ctx: Ctx, next: Next) => { +export default async (ctx: Ctx, next: Next) => { const { body } = ctx.request - let { _viewId: viewId } = body - - const possibleViewId = getTableId(ctx) - if (utils.isViewID(possibleViewId)) { - viewId = possibleViewId - } + const viewId = getSourceId(ctx).viewId ?? body._viewId // nothing to do, it is not a view (just a table ID) if (!viewId) { return next() } - const { tableId } = utils.extractViewInfoFromID(viewId) - // don't need to trim delete requests - if (ctx?.method?.toLowerCase() !== "delete") { - await trimViewFields(ctx.request.body, viewId) + const trimFields = ctx?.method?.toLowerCase() !== "delete" + if (!trimFields) { + return next() } - ctx.params.sourceId = tableId - ctx.params.viewId = viewId + const view = await sdk.views.get(viewId) + ctx.request.body = await trimNonViewFields(ctx.request.body, view, "WRITE") - return next() + await next() + + ctx.body = await trimNonViewFields(ctx.body, view, "READ") } // have to mutate the koa context, can't return -export async function trimViewFields(body: Row, viewId: string): Promise { - const view = await sdk.views.get(viewId) - const allowedKeys = sdk.views.allowedFields(view) +export async function trimNonViewFields( + row: Row, + view: ViewV2, + permission: "WRITE" | "READ" +): Promise { + row = { ...row } + const allowedKeys = sdk.views.allowedFields(view, permission) // have to mutate the context, can't update reference - const toBeRemoved = Object.keys(body).filter( - key => !allowedKeys.includes(key) - ) + const toBeRemoved = Object.keys(row).filter(key => !allowedKeys.includes(key)) for (let removeKey of toBeRemoved) { - delete body[removeKey] + delete row[removeKey] } + return row } diff --git a/packages/server/src/sdk/app/rows/search.ts b/packages/server/src/sdk/app/rows/search.ts index eac807ffa2..3d5de2e6cb 100644 --- a/packages/server/src/sdk/app/rows/search.ts +++ b/packages/server/src/sdk/app/rows/search.ts @@ -121,10 +121,9 @@ export async function fetchRaw(tableId: string): Promise { return pickApi(tableId).fetchRaw(tableId) } -export async function fetchView( - tableId: string, +export async function fetchLegacyView( viewName: string, params: ViewParams ): Promise { - return pickApi(tableId).fetchView(viewName, params) + return internal.fetchLegacyView(viewName, params) } diff --git a/packages/server/src/sdk/app/rows/search/external.ts b/packages/server/src/sdk/app/rows/search/external.ts index 07181d259b..992596ab34 100644 --- a/packages/server/src/sdk/app/rows/search/external.ts +++ b/packages/server/src/sdk/app/rows/search/external.ts @@ -272,11 +272,3 @@ export async function fetchRaw(tableId: string): Promise { }) return response.rows } - -export async function fetchView(viewName: string) { - // there are no views in external datasources, shouldn't ever be called - // for now just fetch - const split = viewName.split("all_") - const tableId = split[1] ? split[1] : split[0] - return fetch(tableId) -} 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 acc7033ce1..ae7bca3b0c 100644 --- a/packages/server/src/sdk/app/rows/search/internal/internal.ts +++ b/packages/server/src/sdk/app/rows/search/internal/internal.ts @@ -145,7 +145,7 @@ export async function fetchRaw(tableId: string): Promise { return rows as Row[] } -export async function fetchView( +export async function fetchLegacyView( viewName: string, options: { calculation: string; group: string; field: string } ): Promise { diff --git a/packages/server/src/sdk/app/views/index.ts b/packages/server/src/sdk/app/views/index.ts index 1c09f710d7..ed624c2b5c 100644 --- a/packages/server/src/sdk/app/views/index.ts +++ b/packages/server/src/sdk/app/views/index.ts @@ -13,7 +13,6 @@ import { PROTECTED_EXTERNAL_COLUMNS, PROTECTED_INTERNAL_COLUMNS, } from "@budibase/shared-core" -import { cloneDeep } from "lodash/fp" import * as utils from "../../../db/utils" import { isExternalTableID } from "../../../integrations/utils" @@ -139,14 +138,20 @@ export async function remove(viewId: string): Promise { return pickApi(tableId).remove(viewId) } -export function allowedFields(view: View | ViewV2) { +export function allowedFields( + view: View | ViewV2, + permission: "WRITE" | "READ" +) { return [ ...Object.keys(view?.schema || {}).filter(key => { if (!isV2(view)) { return true } const fieldSchema = view.schema![key] - return fieldSchema.visible && !fieldSchema.readonly + if (permission === "WRITE") { + return fieldSchema.visible && !fieldSchema.readonly + } + return fieldSchema.visible }), ...PROTECTED_EXTERNAL_COLUMNS, ...PROTECTED_INTERNAL_COLUMNS, @@ -157,17 +162,19 @@ export function enrichSchema( view: ViewV2, tableSchema: TableSchema ): ViewV2Enriched { - let schema = cloneDeep(tableSchema) + let schema: TableSchema = {} const anyViewOrder = Object.values(view.schema || {}).some( ui => ui.order != null ) - for (const key of Object.keys(schema)) { + for (const key of Object.keys(tableSchema).filter( + key => tableSchema[key].visible !== false + )) { // if nothing specified in view, then it is not visible const ui = view.schema?.[key] || { visible: false } schema[key] = { - ...schema[key], + ...tableSchema[key], ...ui, - order: anyViewOrder ? ui?.order ?? undefined : schema[key].order, + order: anyViewOrder ? ui?.order ?? undefined : tableSchema[key].order, } } 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 508285651a..9dd4a7fb69 100644 --- a/packages/server/src/sdk/app/views/tests/views.spec.ts +++ b/packages/server/src/sdk/app/views/tests/views.spec.ts @@ -101,14 +101,6 @@ describe("table sdk", () => { type: "number", }, }, - hiddenField: { - type: "string", - name: "hiddenField", - visible: false, - constraints: { - type: "string", - }, - }, }, }) }) @@ -143,10 +135,6 @@ describe("table sdk", () => { ...basicTable.schema.id, visible: true, }, - hiddenField: { - ...basicTable.schema.hiddenField, - visible: false, - }, }, }) }) @@ -181,10 +169,6 @@ describe("table sdk", () => { ...basicTable.schema.id, visible: false, }, - hiddenField: { - ...basicTable.schema.hiddenField, - visible: false, - }, }, }) }) @@ -209,7 +193,6 @@ describe("table sdk", () => { expect.objectContaining({ ...view, schema: { - ...basicTable.schema, name: { type: "string", name: "name", @@ -264,7 +247,6 @@ describe("table sdk", () => { expect.objectContaining({ ...view, schema: { - ...basicTable.schema, name: { type: "string", name: "name", diff --git a/packages/server/src/utilities/rowProcessor/index.ts b/packages/server/src/utilities/rowProcessor/index.ts index 4b2fd83882..795f6970ab 100644 --- a/packages/server/src/utilities/rowProcessor/index.ts +++ b/packages/server/src/utilities/rowProcessor/index.ts @@ -3,6 +3,7 @@ import { fixAutoColumnSubType, processFormulas } from "./utils" import { cache, context, + db, HTTPError, objectStore, utils, @@ -262,7 +263,7 @@ export async function outputProcessing( } // attach any linked row information let enriched = !opts.preserveLinks - ? await linkRows.attachFullLinkedDocs(table, safeRows, { + ? await linkRows.attachFullLinkedDocs(table.schema, safeRows, { fromRow: opts?.fromRow, }) : safeRows @@ -349,11 +350,19 @@ export async function outputProcessing( } // remove null properties to match internal API const isExternal = isExternalTableID(table._id!) - if (isExternal) { + if (isExternal || db.isSqsEnabledForTenant()) { for (const row of enriched) { for (const key of Object.keys(row)) { if (row[key] === null) { delete row[key] + } else if (row[key] && table.schema[key]?.type === FieldType.LINK) { + for (const link of row[key] || []) { + for (const linkKey of Object.keys(link)) { + if (link[linkKey] === null) { + delete link[linkKey] + } + } + } } } } diff --git a/packages/server/src/utilities/rowProcessor/tests/outputProcessing.spec.ts b/packages/server/src/utilities/rowProcessor/tests/outputProcessing.spec.ts index fd48f46016..0d0049a36e 100644 --- a/packages/server/src/utilities/rowProcessor/tests/outputProcessing.spec.ts +++ b/packages/server/src/utilities/rowProcessor/tests/outputProcessing.spec.ts @@ -10,6 +10,14 @@ import { outputProcessing } from ".." import { generator, structures } from "@budibase/backend-core/tests" import * as bbReferenceProcessor from "../bbReferenceProcessor" +jest.mock("@budibase/backend-core", () => ({ + ...jest.requireActual("@budibase/backend-core"), + db: { + ...jest.requireActual("@budibase/backend-core").db, + isSqsEnabledForTenant: () => true, + }, +})) + jest.mock("../bbReferenceProcessor", (): typeof bbReferenceProcessor => ({ processInputBBReference: jest.fn(), processInputBBReferences: jest.fn(), diff --git a/packages/server/src/websockets/grid.ts b/packages/server/src/websockets/grid.ts index 979a0bf125..a36413454e 100644 --- a/packages/server/src/websockets/grid.ts +++ b/packages/server/src/websockets/grid.ts @@ -4,7 +4,7 @@ import { BaseSocket } from "./websocket" import { auth, permissions } from "@budibase/backend-core" import http from "http" import Koa from "koa" -import { getTableId } from "../api/controllers/row/utils" +import { getSourceId } from "../api/controllers/row/utils" import { Row, Table, View, ViewV2 } from "@budibase/types" import { Socket } from "socket.io" import { GridSocketEvent } from "@budibase/shared-core" @@ -80,7 +80,7 @@ export default class GridSocket extends BaseSocket { } emitRowUpdate(ctx: any, row: Row) { - const resourceId = ctx.params?.viewId || getTableId(ctx) + const resourceId = ctx.params?.viewId || getSourceId(ctx) const room = `${ctx.appId}-${resourceId}` this.emitToRoom(ctx, room, GridSocketEvent.RowChange, { id: row._id, @@ -89,7 +89,7 @@ export default class GridSocket extends BaseSocket { } emitRowDeletion(ctx: any, row: Row) { - const resourceId = ctx.params?.viewId || getTableId(ctx) + const resourceId = ctx.params?.viewId || getSourceId(ctx) const room = `${ctx.appId}-${resourceId}` this.emitToRoom(ctx, room, GridSocketEvent.RowChange, { id: row._id,