@budibase/server
Version:
Budibase Web Server
1,601 lines (1,489 loc) • 162 kB
text/typescript
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