UNPKG

@budibase/server

Version:
1,601 lines (1,489 loc) • 162 kB
import { AIOperationEnum, ArrayOperator, BasicOperator, BBReferenceFieldSubType, CalculationType, CreateViewRequest, Datasource, EmptyFilterOption, FieldSchema, FieldType, INTERNAL_TABLE_SOURCE_ID, JsonFieldSubType, JsonTypes, LegacyFilter, NumericCalculationFieldMetadata, PermissionLevel, QuotaUsageType, RelationshipType, RenameColumn, Row, SaveTableRequest, SearchFilters, SearchResponse, SearchViewRowRequest, SortOrder, SortType, StaticQuotaName, Table, TableSchema, TableSourceType, UILogicalOperator, UISearchFilter, UpdateViewRequest, ViewV2, ViewV2Schema, ViewV2Type, FormulaType, } from "@budibase/types" import { generator, mocks } from "@budibase/backend-core/tests" import { datasourceDescribe } from "../../../integrations/tests/utils" import merge from "lodash/merge" import { quotas } from "@budibase/pro" import { context, db, events, roles, setEnv } from "@budibase/backend-core" import { mockChatGPTResponse } from "../../../tests/utilities/mocks/ai/openai" import nock from "nock" const descriptions = datasourceDescribe({ plus: true }) if (descriptions.length) { describe.each(descriptions)( "/v2/views ($dbName)", ({ config, isInternal, dsProvider }) => { let table: Table let rawDatasource: Datasource | undefined let datasource: Datasource | undefined function saveTableRequest( ...overrides: Partial<SaveTableRequest>[] ): SaveTableRequest { const req: SaveTableRequest = { name: generator.guid().replaceAll("-", "").substring(0, 16), type: "table", sourceType: datasource ? TableSourceType.EXTERNAL : TableSourceType.INTERNAL, sourceId: datasource ? datasource._id! : INTERNAL_TABLE_SOURCE_ID, primary: ["id"], schema: { id: { type: FieldType.NUMBER, name: "id", autocolumn: true, constraints: { presence: true, }, }, }, } return merge(req, ...overrides) } function priceTable(): SaveTableRequest { return saveTableRequest({ schema: { Price: { type: FieldType.NUMBER, name: "Price", constraints: {}, }, Category: { type: FieldType.STRING, name: "Category", constraints: { type: "string", }, }, }, }) } beforeAll(async () => { await config.init() mocks.licenses.useCloudFree() const ds = await dsProvider() rawDatasource = ds.rawDatasource datasource = ds.datasource table = await config.api.table.save(priceTable()) }) beforeEach(() => { jest.clearAllMocks() }) 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 }, }, } const res = await config.api.viewV2.create(newView) expect(res).toEqual({ ...newView, id: expect.stringMatching(new RegExp(`${table._id!}_`)), version: 2, }) expect(events.view.created).toHaveBeenCalledTimes(1) }) it("can persist views with all fields", async () => { const newView: Required<Omit<CreateViewRequest, "query" | "type">> = { name: generator.name(), tableId: table._id!, primaryDisplay: "id", queryUI: { groups: [ { filters: [ { operator: BasicOperator.EQUAL, field: "field", value: "value", }, ], }, ], }, sort: { field: "fieldToSort", order: SortOrder.DESCENDING, type: SortType.STRING, }, schema: { id: { visible: true }, Price: { visible: true, }, }, rowHeight: generator.integer(), } const res = await config.api.viewV2.create(newView) const expected: ViewV2 = { ...newView, schema: { id: { visible: true }, Price: { visible: true, }, }, query: { onEmptyFilter: EmptyFilterOption.RETURN_ALL, $and: { conditions: [ { $and: { conditions: [ { equal: { field: "value", }, }, ], }, }, ], }, }, id: expect.any(String), version: 2, } expect(res).toEqual(expected) expect(events.view.created).toHaveBeenCalledTimes(1) }) it("can create a view with just a query field, no queryUI, for backwards compatibility", async () => { const newView: Required< Omit<CreateViewRequest, "queryUI" | "type"> > = { 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, }, }, rowHeight: generator.integer(), } const res = await config.api.viewV2.create(newView) expect(events.view.created).toHaveBeenCalledTimes(1) const expected: ViewV2 = { ...newView, schema: { id: { visible: true }, Price: { visible: true, }, }, queryUI: { logicalOperator: UILogicalOperator.ALL, onEmptyFilter: EmptyFilterOption.RETURN_ALL, groups: [ { logicalOperator: UILogicalOperator.ALL, filters: [ { operator: BasicOperator.EQUAL, field: "field", value: "value", }, ], }, ], }, id: expect.any(String), version: 2, } expect(res).toEqual(expected) }) 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 ViewV2Schema, } const createdView = await config.api.viewV2.create(newView) expect(events.view.created).toHaveBeenCalledTimes(1) 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 ViewV2Schema, } 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", () => { 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 can be used on free license", 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, }, }, } await config.api.viewV2.create(newView, { status: 201, }) }) }) 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 () => { 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 create a view with calculation fields", async () => { let view = await config.api.viewV2.create({ tableId: table._id!, name: generator.guid(), type: ViewV2Type.CALCULATION, schema: { sum: { visible: true, calculationType: CalculationType.SUM, field: "Price", }, }, }) expect(Object.keys(view.schema!)).toHaveLength(1) let sum = view.schema!.sum as NumericCalculationFieldMetadata expect(sum).toBeDefined() expect(sum.calculationType).toEqual(CalculationType.SUM) expect(sum.field).toEqual("Price") view = await config.api.viewV2.get(view.id) sum = view.schema!.sum as NumericCalculationFieldMetadata expect(sum).toBeDefined() expect(sum.calculationType).toEqual(CalculationType.SUM) expect(sum.field).toEqual("Price") }) it("cannot create a view with calculation fields unless it has the right type", async () => { await config.api.viewV2.create( { tableId: table._id!, name: generator.guid(), schema: { sum: { visible: true, calculationType: CalculationType.SUM, field: "Price", }, }, }, { status: 400, body: { message: "Calculation fields are not allowed in non-calculation views", }, } ) }) it("cannot create a calculation view with more than 5 aggregations", async () => { await config.api.viewV2.create( { tableId: table._id!, name: generator.guid(), type: ViewV2Type.CALCULATION, schema: { sum: { visible: true, calculationType: CalculationType.SUM, field: "Price", }, count: { visible: true, calculationType: CalculationType.COUNT, field: "Price", }, countDistinct: { visible: true, calculationType: CalculationType.COUNT, distinct: true, field: "Price", }, min: { visible: true, calculationType: CalculationType.MIN, field: "Price", }, max: { visible: true, calculationType: CalculationType.MAX, field: "Price", }, avg: { visible: true, calculationType: CalculationType.AVG, field: "Price", }, }, }, { status: 400, body: { message: "Calculation views can only have a maximum of 5 fields", }, } ) }) it("cannot create a calculation view with duplicate calculations", async () => { await config.api.viewV2.create( { tableId: table._id!, name: generator.guid(), type: ViewV2Type.CALCULATION, schema: { sum: { visible: true, calculationType: CalculationType.SUM, field: "Price", }, sum2: { visible: true, calculationType: CalculationType.SUM, field: "Price", }, }, }, { status: 400, body: { message: 'Duplicate calculation on field "Price", calculation type "sum"', }, } ) }) it("finds duplicate counts", async () => { await config.api.viewV2.create( { tableId: table._id!, name: generator.guid(), type: ViewV2Type.CALCULATION, schema: { count: { visible: true, calculationType: CalculationType.COUNT, field: "Price", }, count2: { visible: true, calculationType: CalculationType.COUNT, field: "Price", }, }, }, { status: 400, body: { message: 'Duplicate calculation on field "Price", calculation type "count"', }, } ) }) it("finds duplicate count distincts", async () => { await config.api.viewV2.create( { tableId: table._id!, name: generator.guid(), type: ViewV2Type.CALCULATION, schema: { count: { visible: true, calculationType: CalculationType.COUNT, distinct: true, field: "Price", }, count2: { visible: true, calculationType: CalculationType.COUNT, distinct: true, field: "Price", }, }, }, { status: 400, body: { message: 'Duplicate calculation on field "Price", calculation type "count distinct"', }, } ) }) it("does not confuse counts and count distincts in the duplicate check", async () => { await config.api.viewV2.create({ tableId: table._id!, name: generator.guid(), type: ViewV2Type.CALCULATION, schema: { count: { visible: true, calculationType: CalculationType.COUNT, field: "Price", }, count2: { visible: true, calculationType: CalculationType.COUNT, distinct: true, field: "Price", }, }, }) }) it("does not confuse counts on different fields in the duplicate check", async () => { await config.api.viewV2.create({ tableId: table._id!, name: generator.guid(), type: ViewV2Type.CALCULATION, schema: { count: { visible: true, calculationType: CalculationType.COUNT, field: "Price", }, count2: { visible: true, calculationType: CalculationType.COUNT, field: "Category", }, }, }) }) it("does not get confused when a calculation field shadows a basic one", async () => { const table = await config.api.table.save( saveTableRequest({ schema: { age: { name: "age", type: FieldType.NUMBER, }, }, }) ) await config.api.row.bulkImport(table._id!, { rows: [{ age: 1 }, { age: 2 }, { age: 3 }], }) const view = await config.api.viewV2.create({ tableId: table._id!, name: generator.guid(), type: ViewV2Type.CALCULATION, schema: { age: { visible: true, calculationType: CalculationType.SUM, field: "age", }, }, }) const { rows } = await config.api.row.search(view.id) expect(rows).toHaveLength(1) expect(rows[0].age).toEqual(6) }) // We don't allow the creation of tables with most JsonTypes when using // external datasources. isInternal && it("cannot use complex types as group-by fields", async () => { for (const type of JsonTypes) { const field = { name: "field", type } as FieldSchema const table = await config.api.table.save( saveTableRequest({ schema: { field } }) ) await config.api.viewV2.create( { tableId: table._id!, name: generator.guid(), type: ViewV2Type.CALCULATION, schema: { field: { visible: true }, }, }, { status: 400, body: { message: `Grouping by fields of type "${type}" is not supported`, }, } ) } }) isInternal && it("shouldn't trigger a complex type check on a group by field if field is invisible", async () => { const table = await config.api.table.save( saveTableRequest({ schema: { field: { name: "field", type: FieldType.JSON, }, }, }) ) await config.api.viewV2.create( { tableId: table._id!, name: generator.guid(), type: ViewV2Type.CALCULATION, schema: { field: { visible: false }, }, }, { status: 201, } ) }) isInternal && describe("AI fields", () => { let envCleanup: () => void beforeAll(() => { mocks.licenses.useBudibaseAI() mocks.licenses.useAICustomConfigs() envCleanup = setEnv({ OPENAI_API_KEY: "sk-abcdefghijklmnopqrstuvwxyz1234567890abcd", }) mockChatGPTResponse(prompt => { if (prompt.includes("elephant")) { return "big" } if (prompt.includes("mouse")) { return "small" } if (prompt.includes("whale")) { return "big" } return "unknown" }) }) afterAll(() => { nock.cleanAll() envCleanup() mocks.licenses.useCloudFree() }) it("can use AI fields in view calculations", async () => { const table = await config.api.table.save( saveTableRequest({ schema: { animal: { name: "animal", type: FieldType.STRING, }, bigOrSmall: { name: "bigOrSmall", type: FieldType.AI, operation: AIOperationEnum.CATEGORISE_TEXT, categories: "big,small", columns: ["animal"], }, }, }) ) const view = await config.api.viewV2.create({ tableId: table._id!, name: generator.guid(), type: ViewV2Type.CALCULATION, schema: { bigOrSmall: { visible: true, }, count: { visible: true, calculationType: CalculationType.COUNT, field: "animal", }, }, }) await config.api.row.save(table._id!, { animal: "elephant", }) await config.api.row.save(table._id!, { animal: "mouse", }) await config.api.row.save(table._id!, { animal: "whale", }) const { rows } = await config.api.row.search(view.id, { sort: "bigOrSmall", sortOrder: SortOrder.ASCENDING, }) expect(rows).toHaveLength(2) expect(rows[0].bigOrSmall).toEqual("big") expect(rows[1].bigOrSmall).toEqual("small") expect(rows[0].count).toEqual(2) expect(rows[1].count).toEqual(1) }) }) }) describe("update", () => { let view: ViewV2 let table: Table 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", }, ], }) const expected: ViewV2 = { ...view, query: [ { operator: BasicOperator.EQUAL, field: "newField", value: "thatValue", }, ], // Should also update queryUI because query was not previously set. queryUI: { onEmptyFilter: EmptyFilterOption.RETURN_ALL, logicalOperator: UILogicalOperator.ALL, groups: [ { logicalOperator: UILogicalOperator.ALL, filters: [ { operator: BasicOperator.EQUAL, field: "newField", value: "thatValue", }, ], }, ], }, schema: expect.anything(), } expect((await config.api.table.get(tableId)).views).toEqual({ [view.name]: expected, }) expect(events.view.updated).toHaveBeenCalledTimes(1) }) it("handles view grouped filter events", async () => { view.queryUI = { logicalOperator: UILogicalOperator.ALL, onEmptyFilter: EmptyFilterOption.RETURN_ALL, groups: [ { logicalOperator: UILogicalOperator.ALL, filters: [ { operator: BasicOperator.EQUAL, field: "newField", value: "newValue", }, ], }, ], } await config.api.viewV2.update(view) expect(events.view.filterUpdated).not.toHaveBeenCalled() // @ts-ignore view.queryUI.groups.push({ logicalOperator: UILogicalOperator.ALL, filters: [ { operator: BasicOperator.EQUAL, field: "otherField", value: "otherValue", }, ], }) await config.api.viewV2.update(view) expect(events.view.filterUpdated).toHaveBeenCalledWith({ filterGroups: 2, tableId: view.tableId, }) }) it("can update all fields", async () => { const tableId = table._id! const updatedData: Required< Omit<UpdateViewRequest, "queryUI" | "type"> > = { version: view.version, id: view.id, tableId, name: view.name, primaryDisplay: "Price", query: [ { operator: BasicOperator.EQUAL, field: "newField", value: "newValue", }, ], sort: { field: generator.word(), order: SortOrder.DESCENDING, type: SortType.STRING, }, schema: { id: { visible: true }, Category: { visible: false, }, Price: { visible: true, readonly: true, }, }, rowHeight: generator.integer(), } await config.api.viewV2.update(updatedData) const expected: ViewV2 = { ...updatedData, // queryUI gets generated from query queryUI: { logicalOperator: UILogicalOperator.ALL, onEmptyFilter: EmptyFilterOption.RETURN_ALL, groups: [ { logicalOperator: UILogicalOperator.ALL, filters: [ { operator: BasicOperator.EQUAL, field: "newField", value: "newValue", }, ], }, ], }, schema: { ...table.schema, id: expect.objectContaining({ visible: true, }), Category: expect.objectContaining({ visible: false, }), Price: expect.objectContaining({ visible: true, readonly: true, }), }, } expect((await config.api.table.get(tableId)).views).toEqual({ [view.name]: expected, }) }) 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(), }, }, }) ) }) isInternal && 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 ViewV2Schema, }) 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 ViewV2Schema, }, { status: 200, } ) }) it("cannot update view type after creation", async () => { const view = await config.api.viewV2.create({ tableId: table._id!, name: generator.guid(), schema: { id: { visible: true }, Price: { visible: true, }, }, }) await config.api.viewV2.update( { ...view, type: ViewV2Type.CALCULATION, }, { status: 400, body: { message: "Cannot update view type after creation", }, } ) }) 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, }, } ) }) it("can update queryUI field and query gets regenerated", async () => { await config.api.viewV2.update({ ...view, queryUI: { groups: [ { filters: [ { operator: BasicOperator.EQUAL, field: "field", value: "value", }, ], }, ], }, }) let updatedView = await config.api.viewV2.get(view.id) let expected: SearchFilters = { onEmptyFilter: EmptyFilterOption.RETURN_ALL, $and: { conditions: [ { $and: { conditions: [ { equal: { field: "value" }, }, ], }, }, ], }, } expect(updatedView.query).toEqual(expected) await config.api.viewV2.update({ ...updatedView, queryUI: { groups: [ { filters: [ { operator: BasicOperator.EQUAL, field: "newField", value: "newValue", }, ], }, ], }, }) updatedView = await config.api.viewV2.get(view.id) expected = { onEmptyFilter: EmptyFilterOption.RETURN_ALL, $and: { conditions: [ { $and: { conditions: [ { equal: { newField: "newValue" }, }, ], }, }, ], }, } expect(updatedView.query).toEqual(expected) }) it("can delete either query and it will get regenerated from queryUI", async () => { await config.api.viewV2.update({ ...view, query: [ { operator: BasicOperator.EQUAL, field: "field", value: "value", }, ], }) let updatedView = await config.api.viewV2.get(view.id) expect(updatedView.queryUI).toBeDefined() await config.api.viewV2.update({ ...updatedView, query: undefined, }) updatedView = await config.api.viewV2.get(view.id) expect(updatedView.query).toBeDefined() }) // This is because the conversion from queryUI -> query loses data, so you // can't accurately reproduce the original queryUI from the query. If // query is a LegacyFilter[] we allow it, because for Budibase v3 // everything in the db had query set to a LegacyFilter[], and there's no // loss of information converting from a LegacyFilter[] to a // UISearchFilter. But we convert to a SearchFilters and that can't be // accurately converted to a UISearchFilter. it("can't regenerate queryUI from a query once it has been generated from a queryUI", async () => { await config.api.viewV2.update({ ...view, queryUI: { groups: [ { filters: [ { operator: BasicOperator.EQUAL, field: "field", value: "value", }, ], }, ], }, }) let updatedView = await config.api.viewV2.get(view.id) expect(updatedView.query).toBeDefined() await config.api.viewV2.update( { ...updatedView, queryUI: undefined, }, { status: 400, body: { message: "view is missing queryUI field", }, } ) }) describe("calculation views", () => { let table: Table let view: ViewV2 beforeEach(async () => { table = await config.api.table.save( saveTableRequest({ schema: { name: { name: "name", type: FieldType.STRING, constraints: { presence: true, }, }, country: { name: "country", type: FieldType.STRING, }, age: { name: "age", type: FieldType.NUMB