diff --git a/packages/bbui/package.json b/packages/bbui/package.json index b03c83d71b..4d39f6330b 100644 --- a/packages/bbui/package.json +++ b/packages/bbui/package.json @@ -96,7 +96,8 @@ "dependsOn": [ { "projects": [ - "@budibase/string-templates" + "@budibase/string-templates", + "@budibase/shared-core" ], "target": "build" } diff --git a/packages/server/src/api/controllers/row/external.ts b/packages/server/src/api/controllers/row/external.ts index 2122ada068..6bae6afd48 100644 --- a/packages/server/src/api/controllers/row/external.ts +++ b/packages/server/src/api/controllers/row/external.ts @@ -73,9 +73,11 @@ export async function patch(ctx: UserCtx) { row: inputs, }) const row = await getRow(tableId, id, { relationships: true }) + const table = await sdk.tables.getTable(tableId) return { ...response, row, + table, } } diff --git a/packages/server/src/api/controllers/row/index.ts b/packages/server/src/api/controllers/row/index.ts index aa3aa3e21c..4a9047d2cd 100644 --- a/packages/server/src/api/controllers/row/index.ts +++ b/packages/server/src/api/controllers/row/index.ts @@ -2,7 +2,13 @@ import { quotas } from "@budibase/pro" import * as internal from "./internal" import * as external from "./external" import { isExternalTable } from "../../../integrations/utils" -import { Ctx } from "@budibase/types" +import { + Ctx, + SearchResponse, + SortOrder, + SortType, + ViewV2, +} from "@budibase/types" import * as utils from "./utils" import { gridSocket } from "../../../websockets" import sdk from "../../../sdk" @@ -114,7 +120,7 @@ export async function destroy(ctx: any) { response = rows for (let row of rows) { ctx.eventEmitter && ctx.eventEmitter.emitRow(`row:delete`, appId, row) - gridSocket?.emitRowDeletion(ctx, row._id) + gridSocket?.emitRowDeletion(ctx, row._id!) } } else { let resp = await quotas.addQuery(() => pickApi(tableId).destroy(ctx), { @@ -124,7 +130,7 @@ export async function destroy(ctx: any) { response = resp.response row = resp.row ctx.eventEmitter && ctx.eventEmitter.emitRow(`row:delete`, appId, row) - gridSocket?.emitRowDeletion(ctx, row._id) + gridSocket?.emitRowDeletion(ctx, row._id!) } ctx.status = 200 // for automations include the row that was deleted @@ -146,6 +152,80 @@ export async function search(ctx: any) { }) } +function getSortOptions( + ctx: Ctx, + view: ViewV2 +): + | { + sort: string + sortOrder?: SortOrder + sortType?: SortType + } + | undefined { + const { sort_column, sort_order, sort_type } = ctx.query + if (Array.isArray(sort_column)) { + ctx.throw(400, "sort_column cannot be an array") + } + if (Array.isArray(sort_order)) { + ctx.throw(400, "sort_order cannot be an array") + } + if (Array.isArray(sort_type)) { + ctx.throw(400, "sort_type cannot be an array") + } + + if (sort_column) { + return { + sort: sort_column, + sortOrder: sort_order as SortOrder, + sortType: sort_type as SortType, + } + } + if (view.sort) { + return { + sort: view.sort.field, + sortOrder: view.sort.order, + sortType: view.sort.type, + } + } + + return +} + +export async function searchView(ctx: Ctx) { + const { viewId } = ctx.params + + const view = await sdk.views.get(viewId) + if (!view) { + ctx.throw(404, `View ${viewId} not found`) + } + + if (view.version !== 2) { + ctx.throw(400, `This method only supports viewsV2`) + } + + const table = await sdk.tables.getTable(view?.tableId) + + const viewFields = + (view.columns && + Object.entries(view.columns).length && + Object.keys(sdk.views.enrichSchema(view, table.schema).schema)) || + undefined + + ctx.status = 200 + ctx.body = await quotas.addQuery( + () => + sdk.rows.search({ + tableId: view.tableId, + query: view.query || {}, + fields: viewFields, + ...getSortOptions(ctx, view), + }), + { + datasourceId: view.tableId, + } + ) +} + export async function validate(ctx: Ctx) { const tableId = utils.getTableId(ctx) // external tables are hard to validate currently diff --git a/packages/server/src/api/controllers/row/internal.ts b/packages/server/src/api/controllers/row/internal.ts index 0367346832..d56ba3f14a 100644 --- a/packages/server/src/api/controllers/row/internal.ts +++ b/packages/server/src/api/controllers/row/internal.ts @@ -19,7 +19,6 @@ import { UserCtx, LinkDocumentValue, Row, Table } from "@budibase/types" import sdk from "../../../sdk" export async function patch(ctx: UserCtx) { - const db = context.getAppDB() const inputs = ctx.request.body const tableId = inputs.tableId const isUserTable = tableId === InternalTables.USER_METADATA @@ -77,7 +76,7 @@ export async function patch(ctx: UserCtx) { // the row has been updated, need to put it into the ctx ctx.request.body = row await userController.updateMetadata(ctx) - return { row: ctx.body, table } + return { row: ctx.body as Row, table } } return finaliseRow(table, row, { diff --git a/packages/server/src/api/controllers/table/index.ts b/packages/server/src/api/controllers/table/index.ts index 5ade77592a..53202d6878 100644 --- a/packages/server/src/api/controllers/table/index.ts +++ b/packages/server/src/api/controllers/table/index.ts @@ -6,9 +6,13 @@ import { isRows, } from "../../../utilities/schema" import { isExternalTable, isSQL } from "../../../integrations/utils" -import { getDatasourceParams } from "../../../db/utils" -import { context, events } from "@budibase/backend-core" -import { Table, UserCtx } from "@budibase/types" +import { events } from "@budibase/backend-core" +import { + FetchTablesResponse, + Table, + TableResponse, + UserCtx, +} from "@budibase/types" import sdk from "../../../sdk" import { jsonFromCsvString } from "../../../utilities/csv" import { builderSocket } from "../../../websockets" @@ -26,37 +30,34 @@ function pickApi({ tableId, table }: { tableId?: string; table?: Table }) { } // covers both internal and external -export async function fetch(ctx: UserCtx) { - const db = context.getAppDB() - +export async function fetch(ctx: UserCtx) { const internal = await sdk.tables.getAllInternalTables() - const externalTables = await db.allDocs( - getDatasourceParams("plus", { - include_docs: true, - }) - ) + const externalTables = await sdk.datasources.getExternalDatasources() - const external = externalTables.rows.flatMap(tableDoc => { - let entities = tableDoc.doc.entities + const external = externalTables.flatMap(table => { + let entities = table.entities if (entities) { - return Object.values(entities).map((entity: any) => ({ + return Object.values(entities).map((entity: Table) => ({ ...entity, type: "external", - sourceId: tableDoc.doc._id, - sql: isSQL(tableDoc.doc), + sourceId: table._id, + sql: isSQL(table), })) } else { return [] } }) - ctx.body = [...internal, ...external] + const response = [...internal, ...external].map(sdk.tables.enrichViewSchemas) + ctx.body = response } -export async function find(ctx: UserCtx) { +export async function find(ctx: UserCtx) { const tableId = ctx.params.tableId - ctx.body = await sdk.tables.getTable(tableId) + const table = await sdk.tables.getTable(tableId) + + ctx.body = sdk.tables.enrichViewSchemas(table) } export async function save(ctx: UserCtx) { diff --git a/packages/server/src/api/controllers/view/index.ts b/packages/server/src/api/controllers/view/index.ts index 99c4224c62..0d66969dd6 100644 --- a/packages/server/src/api/controllers/view/index.ts +++ b/packages/server/src/api/controllers/view/index.ts @@ -1,195 +1,2 @@ -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 { context, events } from "@budibase/backend-core" -import { DocumentType } from "../../../db/utils" -import sdk from "../../../sdk" -import { FieldTypes } from "../../../constants" -import { - Ctx, - Row, - Table, - TableExportFormat, - TableSchema, - View, -} from "@budibase/types" -import { builderSocket } from "../../../websockets" - -const { cloneDeep, isEqual } = require("lodash") - -export async function fetch(ctx: Ctx) { - ctx.body = await getViews() -} - -export async function save(ctx: Ctx) { - const db = context.getAppDB() - const { originalName, ...viewToSave } = ctx.request.body - - const existingTable = await sdk.tables.getTable(ctx.request.body.tableId) - existingTable.views ??= {} - const table = cloneDeep(existingTable) - - const groupByField: any = Object.values(table.schema).find( - (field: any) => field.name == viewToSave.groupBy - ) - - const view = viewTemplate(viewToSave, groupByField?.type === FieldTypes.ARRAY) - const viewName = viewToSave.name - - if (!viewName) { - ctx.throw(400, "Cannot create view without a name") - } - - await saveView(originalName, viewName, view) - - // add views to table document - if (!table.views) table.views = {} - if (!view.meta.schema) { - view.meta.schema = table.schema - } - table.views[viewName] = { ...view.meta, name: viewName } - if (originalName) { - delete table.views[originalName] - existingTable.views[viewName] = existingTable.views[originalName] - } - await db.put(table) - await handleViewEvents(existingTable.views[viewName], table.views[viewName]) - - ctx.body = table.views[viewName] - builderSocket?.emitTableUpdate(ctx, table) -} - -export async function calculationEvents(existingView: View, newView: View) { - const existingCalculation = existingView && existingView.calculation - const newCalculation = newView && newView.calculation - - if (existingCalculation && !newCalculation) { - await events.view.calculationDeleted(existingView) - } - - if (!existingCalculation && newCalculation) { - await events.view.calculationCreated(newView) - } - - if ( - existingCalculation && - newCalculation && - existingCalculation !== newCalculation - ) { - await events.view.calculationUpdated(newView) - } -} - -export async function filterEvents(existingView: View, newView: View) { - const hasExistingFilters = !!( - existingView && - existingView.filters && - existingView.filters.length - ) - const hasNewFilters = !!(newView && newView.filters && newView.filters.length) - - if (hasExistingFilters && !hasNewFilters) { - await events.view.filterDeleted(newView) - } - - if (!hasExistingFilters && hasNewFilters) { - await events.view.filterCreated(newView) - } - - if ( - hasExistingFilters && - hasNewFilters && - !isEqual(existingView.filters, newView.filters) - ) { - await events.view.filterUpdated(newView) - } -} - -async function handleViewEvents(existingView: View, newView: View) { - if (!existingView) { - await events.view.created(newView) - } else { - await events.view.updated(newView) - } - await calculationEvents(existingView, newView) - await filterEvents(existingView, newView) -} - -export async function destroy(ctx: Ctx) { - const db = context.getAppDB() - const viewName = decodeURIComponent(ctx.params.viewName) - const view = await deleteView(viewName) - const table = await sdk.tables.getTable(view.meta.tableId) - delete table.views![viewName] - await db.put(table) - await events.view.deleted(view) - - ctx.body = view - builderSocket?.emitTableUpdate(ctx, table) -} - -export async function exportView(ctx: Ctx) { - const viewName = decodeURIComponent(ctx.query.view as string) - const view = await getView(viewName) - - const format = ctx.query.format as unknown - - if (!isFormat(format)) { - ctx.throw( - 400, - "Format must be specified, either csv, json or jsonWithSchema" - ) - } - - if (view) { - ctx.params.viewName = viewName - // Fetch view rows - ctx.query = { - group: view.meta.groupBy, - calculation: view.meta.calculation, - // @ts-ignore - stats: !!view.meta.field, - field: view.meta.field, - } - } else { - // table all_ view - /* istanbul ignore next */ - ctx.params.viewName = viewName - } - - await fetchView(ctx) - let rows = ctx.body as Row[] - - let schema: TableSchema = view && view.meta && view.meta.schema - const tableId = - ctx.params.tableId || - view?.meta?.tableId || - (viewName.startsWith(DocumentType.TABLE) && viewName) - const table: Table = await sdk.tables.getTable(tableId) - if (!schema) { - schema = table.schema - } - - let exportRows = sdk.rows.utils.cleanExportRows(rows, schema, format, []) - - if (format === Format.CSV) { - ctx.attachment(`${viewName}.csv`) - ctx.body = apiFileReturn(csv(Object.keys(schema), exportRows)) - } else if (format === Format.JSON) { - ctx.attachment(`${viewName}.json`) - ctx.body = apiFileReturn(json(exportRows)) - } else if (format === Format.JSON_WITH_SCHEMA) { - ctx.attachment(`${viewName}.json`) - ctx.body = apiFileReturn(jsonWithSchema(schema, exportRows)) - } else { - throw "Format not recognised" - } - - if (viewName.startsWith(DocumentType.TABLE)) { - await events.table.exported(table, format as TableExportFormat) - } else { - await events.view.exported(table, format as TableExportFormat) - } -} +export * as v1 from "./views" +export * as v2 from "./viewsV2" diff --git a/packages/server/src/api/controllers/view/views.ts b/packages/server/src/api/controllers/view/views.ts new file mode 100644 index 0000000000..257baf5e45 --- /dev/null +++ b/packages/server/src/api/controllers/view/views.ts @@ -0,0 +1,198 @@ +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 { context, events } from "@budibase/backend-core" +import { DocumentType } from "../../../db/utils" +import sdk from "../../../sdk" +import { FieldTypes } from "../../../constants" +import { + Ctx, + Row, + Table, + TableExportFormat, + TableSchema, + View, +} from "@budibase/types" +import { builderSocket } from "../../../websockets" + +const { cloneDeep, isEqual } = require("lodash") + +export async function fetch(ctx: Ctx) { + ctx.body = await getViews() +} + +export async function save(ctx: Ctx) { + const db = context.getAppDB() + const { originalName, ...viewToSave } = ctx.request.body + + const existingTable = await sdk.tables.getTable(ctx.request.body.tableId) + existingTable.views ??= {} + const table = cloneDeep(existingTable) + + const groupByField: any = Object.values(table.schema).find( + (field: any) => field.name == viewToSave.groupBy + ) + + const view = viewTemplate(viewToSave, groupByField?.type === FieldTypes.ARRAY) + const viewName = viewToSave.name + + if (!viewName) { + ctx.throw(400, "Cannot create view without a name") + } + + await saveView(originalName, viewName, view) + + // add views to table document + if (!table.views) table.views = {} + if (!view.meta.schema) { + view.meta.schema = table.schema + } + table.views[viewName] = { ...view.meta, name: viewName } + if (originalName) { + delete table.views[originalName] + existingTable.views[viewName] = existingTable.views[originalName] + } + await db.put(table) + await handleViewEvents( + existingTable.views[viewName] as View, + table.views[viewName] + ) + + ctx.body = table.views[viewName] + builderSocket?.emitTableUpdate(ctx, table) +} + +export async function calculationEvents(existingView: View, newView: View) { + const existingCalculation = existingView && existingView.calculation + const newCalculation = newView && newView.calculation + + if (existingCalculation && !newCalculation) { + await events.view.calculationDeleted(existingView) + } + + if (!existingCalculation && newCalculation) { + await events.view.calculationCreated(newView) + } + + if ( + existingCalculation && + newCalculation && + existingCalculation !== newCalculation + ) { + await events.view.calculationUpdated(newView) + } +} + +export async function filterEvents(existingView: View, newView: View) { + const hasExistingFilters = !!( + existingView && + existingView.filters && + existingView.filters.length + ) + const hasNewFilters = !!(newView && newView.filters && newView.filters.length) + + if (hasExistingFilters && !hasNewFilters) { + await events.view.filterDeleted(newView) + } + + if (!hasExistingFilters && hasNewFilters) { + await events.view.filterCreated(newView) + } + + if ( + hasExistingFilters && + hasNewFilters && + !isEqual(existingView.filters, newView.filters) + ) { + await events.view.filterUpdated(newView) + } +} + +async function handleViewEvents(existingView: View, newView: View) { + if (!existingView) { + await events.view.created(newView) + } else { + await events.view.updated(newView) + } + await calculationEvents(existingView, newView) + await filterEvents(existingView, newView) +} + +export async function destroy(ctx: Ctx) { + const db = context.getAppDB() + const viewName = decodeURIComponent(ctx.params.viewName) + const view = await deleteView(viewName) + const table = await sdk.tables.getTable(view.meta.tableId) + delete table.views![viewName] + await db.put(table) + await events.view.deleted(view) + + ctx.body = view + builderSocket?.emitTableUpdate(ctx, table) +} + +export async function exportView(ctx: Ctx) { + const viewName = decodeURIComponent(ctx.query.view as string) + const view = await getView(viewName) + + const format = ctx.query.format as unknown + + if (!isFormat(format)) { + ctx.throw( + 400, + "Format must be specified, either csv, json or jsonWithSchema" + ) + } + + if (view) { + ctx.params.viewName = viewName + // Fetch view rows + ctx.query = { + group: view.meta.groupBy, + calculation: view.meta.calculation, + // @ts-ignore + stats: !!view.meta.field, + field: view.meta.field, + } + } else { + // table all_ view + /* istanbul ignore next */ + ctx.params.viewName = viewName + } + + await fetchView(ctx) + let rows = ctx.body as Row[] + + let schema: TableSchema = view && view.meta && view.meta.schema + const tableId = + ctx.params.tableId || + view?.meta?.tableId || + (viewName.startsWith(DocumentType.TABLE) && viewName) + const table: Table = await sdk.tables.getTable(tableId) + if (!schema) { + schema = table.schema + } + + let exportRows = sdk.rows.utils.cleanExportRows(rows, schema, format, []) + + if (format === Format.CSV) { + ctx.attachment(`${viewName}.csv`) + ctx.body = apiFileReturn(csv(Object.keys(schema), exportRows)) + } else if (format === Format.JSON) { + ctx.attachment(`${viewName}.json`) + ctx.body = apiFileReturn(json(exportRows)) + } else if (format === Format.JSON_WITH_SCHEMA) { + ctx.attachment(`${viewName}.json`) + ctx.body = apiFileReturn(jsonWithSchema(schema, exportRows)) + } else { + throw "Format not recognised" + } + + if (viewName.startsWith(DocumentType.TABLE)) { + await events.table.exported(table, format as TableExportFormat) + } else { + await events.view.exported(table, format as TableExportFormat) + } +} diff --git a/packages/server/src/api/controllers/view/viewsV2.ts b/packages/server/src/api/controllers/view/viewsV2.ts new file mode 100644 index 0000000000..94e53e52fb --- /dev/null +++ b/packages/server/src/api/controllers/view/viewsV2.ts @@ -0,0 +1,20 @@ +import sdk from "../../../sdk" +import { CreateViewRequest, Ctx, ViewResponse } from "@budibase/types" + +export async function create(ctx: Ctx) { + const view = ctx.request.body + const { tableId } = view + + const result = await sdk.views.create(tableId, view) + ctx.status = 201 + ctx.body = { + data: result, + } +} + +export async function remove(ctx: Ctx) { + const { viewId } = ctx.params + + await sdk.views.remove(viewId) + ctx.status = 204 +} diff --git a/packages/server/src/api/routes/row.ts b/packages/server/src/api/routes/row.ts index 6d1cd206c6..5fdc02b7a7 100644 --- a/packages/server/src/api/routes/row.ts +++ b/packages/server/src/api/routes/row.ts @@ -146,6 +146,11 @@ router authorized(PermissionType.TABLE, PermissionLevel.READ), rowController.search ) + .get( + "/api/v2/views/:viewId/search", + authorized(PermissionType.VIEW, PermissionLevel.READ), + rowController.searchView + ) /** * @api {post} /api/:tableId/rows Creates a new row * @apiName Creates a new row diff --git a/packages/server/src/api/routes/tests/row.spec.ts b/packages/server/src/api/routes/tests/row.spec.ts index 8e99c30246..c872128b08 100644 --- a/packages/server/src/api/routes/tests/row.spec.ts +++ b/packages/server/src/api/routes/tests/row.spec.ts @@ -14,8 +14,10 @@ import { Row, Table, FieldType, + SortType, + SortOrder, } from "@budibase/types" -import { structures } from "@budibase/backend-core/tests" +import { generator, structures } from "@budibase/backend-core/tests" describe("/rows", () => { let request = setup.getRequest() @@ -685,4 +687,244 @@ describe("/rows", () => { expect(row._id).toEqual(existing._id) }) }) + + describe("view search", () => { + function userTable(): Table { + return { + name: "user", + type: "user", + schema: { + name: { + type: FieldType.STRING, + name: "name", + constraints: { type: "string" }, + }, + age: { + type: FieldType.NUMBER, + name: "age", + constraints: {}, + }, + }, + } + } + + it("returns table rows from view", async () => { + const table = await config.createTable(userTable()) + const rows = [] + for (let i = 0; i < 10; i++) { + rows.push(await config.createRow({ tableId: table._id })) + } + + const createViewResponse = await config.api.viewV2.create() + const response = await config.api.viewV2.search(createViewResponse.id) + + expect(response.body.rows).toHaveLength(10) + expect(response.body).toEqual({ + rows: expect.arrayContaining(rows.map(expect.objectContaining)), + }) + }) + + it("searching respects the view filters", async () => { + const table = await config.createTable(userTable()) + const expectedRows = [] + for (let i = 0; i < 10; i++) + await config.createRow({ + tableId: table._id, + name: generator.name(), + age: generator.integer({ min: 10, max: 30 }), + }) + + for (let i = 0; i < 5; i++) + expectedRows.push( + await config.createRow({ + tableId: table._id, + name: generator.name(), + age: 40, + }) + ) + + const createViewResponse = await config.api.viewV2.create({ + query: { equal: { age: 40 } }, + }) + + const response = await config.api.viewV2.search(createViewResponse.id) + + expect(response.body.rows).toHaveLength(5) + expect(response.body).toEqual({ + rows: expect.arrayContaining(expectedRows.map(expect.objectContaining)), + }) + }) + + const sortTestOptions: [ + { + field: string + order?: SortOrder + type?: SortType + }, + string[] + ][] = [ + [ + { + field: "name", + order: SortOrder.ASCENDING, + type: SortType.STRING, + }, + ["Alice", "Bob", "Charly", "Danny"], + ], + [ + { + field: "name", + }, + ["Alice", "Bob", "Charly", "Danny"], + ], + [ + { + field: "name", + order: SortOrder.DESCENDING, + }, + ["Danny", "Charly", "Bob", "Alice"], + ], + [ + { + field: "name", + order: SortOrder.DESCENDING, + type: SortType.STRING, + }, + ["Danny", "Charly", "Bob", "Alice"], + ], + [ + { + field: "age", + order: SortOrder.ASCENDING, + type: SortType.number, + }, + ["Danny", "Alice", "Charly", "Bob"], + ], + [ + { + field: "age", + order: SortOrder.ASCENDING, + }, + ["Danny", "Alice", "Charly", "Bob"], + ], + [ + { + field: "age", + order: SortOrder.DESCENDING, + }, + ["Bob", "Charly", "Alice", "Danny"], + ], + [ + { + field: "age", + order: SortOrder.DESCENDING, + type: SortType.number, + }, + ["Bob", "Charly", "Alice", "Danny"], + ], + ] + + it.each(sortTestOptions)( + "allow sorting (%s)", + async (sortParams, expected) => { + await config.createTable(userTable()) + const users = [ + { name: "Alice", age: 25 }, + { name: "Bob", age: 30 }, + { name: "Charly", age: 27 }, + { name: "Danny", age: 15 }, + ] + for (const user of users) { + await config.createRow({ + tableId: config.table!._id, + ...user, + }) + } + + const createViewResponse = await config.api.viewV2.create({ + sort: sortParams, + }) + + const response = await config.api.viewV2.search(createViewResponse.id) + + expect(response.body.rows).toHaveLength(4) + expect(response.body).toEqual({ + rows: expected.map(name => expect.objectContaining({ name })), + }) + } + ) + + it.each(sortTestOptions)( + "allow override the default view sorting (%s)", + async (sortParams, expected) => { + await config.createTable(userTable()) + const users = [ + { name: "Alice", age: 25 }, + { name: "Bob", age: 30 }, + { name: "Charly", age: 27 }, + { name: "Danny", age: 15 }, + ] + for (const user of users) { + await config.createRow({ + tableId: config.table!._id, + ...user, + }) + } + + const createViewResponse = await config.api.viewV2.create({ + sort: { + field: "name", + order: SortOrder.ASCENDING, + type: SortType.STRING, + }, + }) + + const response = await config.api.viewV2.search(createViewResponse.id, { + sort: { + column: sortParams.field, + order: sortParams.order, + type: sortParams.type, + }, + }) + + expect(response.body.rows).toHaveLength(4) + expect(response.body).toEqual({ + rows: expected.map(name => expect.objectContaining({ name })), + }) + } + ) + + it("when schema is defined, no other columns are returned", async () => { + const table = await config.createTable(userTable()) + const rows = [] + for (let i = 0; i < 10; i++) { + rows.push( + await config.createRow({ + tableId: table._id, + name: generator.name(), + age: generator.age(), + }) + ) + } + + const createViewResponse = await config.api.viewV2.create({ + columns: { name: { visible: true } }, + }) + const response = await config.api.viewV2.search(createViewResponse.id) + + expect(response.body.rows).toHaveLength(10) + expect(response.body.rows).toEqual( + expect.arrayContaining(rows.map(r => ({ name: r.name }))) + ) + }) + + it("views without data can be returned", async () => { + const table = await config.createTable(userTable()) + + const createViewResponse = await config.api.viewV2.create() + const response = await config.api.viewV2.search(createViewResponse.id) + + expect(response.body.rows).toHaveLength(0) + }) + }) }) diff --git a/packages/server/src/api/routes/tests/table.spec.js b/packages/server/src/api/routes/tests/table.spec.ts similarity index 67% rename from packages/server/src/api/routes/tests/table.spec.js rename to packages/server/src/api/routes/tests/table.spec.ts index 9c6980c1d7..04911e5505 100644 --- a/packages/server/src/api/routes/tests/table.spec.js +++ b/packages/server/src/api/routes/tests/table.spec.ts @@ -1,12 +1,15 @@ -const { checkBuilderEndpoint } = require("./utilities/TestFunctions") -const setup = require("./utilities") +import { generator } from "@budibase/backend-core/tests" +import { events, context } from "@budibase/backend-core" +import { FieldType, Table } from "@budibase/types" +import { checkBuilderEndpoint } from "./utilities/TestFunctions" +import * as setup from "./utilities" const { basicTable } = setup.structures -const { events, context } = require("@budibase/backend-core") +import sdk from "../../../sdk" describe("/tables", () => { let request = setup.getRequest() let config = setup.getConfig() - let appId + let appId: string afterAll(setup.afterAll) @@ -16,12 +19,11 @@ describe("/tables", () => { }) describe("create", () => { - beforeEach(() => { jest.clearAllMocks() }) - const createTable = (table) => { + const createTable = (table?: Table) => { if (!table) { table = basicTable() } @@ -29,15 +31,16 @@ describe("/tables", () => { .post(`/api/tables`) .send(table) .set(config.defaultHeaders()) - .expect('Content-Type', /json/) + .expect("Content-Type", /json/) .expect(200) - } it("returns a success message when the table is successfully created", async () => { const res = await createTable() - expect(res.res.statusMessage).toEqual("Table TestTable saved successfully.") + expect((res as any).res.statusMessage).toEqual( + "Table TestTable saved successfully." + ) expect(res.body.name).toEqual("TestTable") expect(events.table.created).toBeCalledTimes(1) expect(events.table.created).toBeCalledWith(res.body) @@ -45,7 +48,7 @@ describe("/tables", () => { it("creates a table via data import", async () => { const table = basicTable() - table.rows = [{ name: 'test-name', description: 'test-desc' }] + table.rows = [{ name: "test-name", description: "test-desc" }] const res = await createTable(table) @@ -62,7 +65,7 @@ describe("/tables", () => { config, method: "POST", url: `/api/tables`, - body: basicTable() + body: basicTable(), }) }) }) @@ -75,7 +78,7 @@ describe("/tables", () => { .post(`/api/tables`) .send(testTable) .set(config.defaultHeaders()) - .expect('Content-Type', /json/) + .expect("Content-Type", /json/) .expect(200) expect(events.table.updated).toBeCalledTimes(1) @@ -94,10 +97,10 @@ describe("/tables", () => { const testRow = await request .post(`/api/${testTable._id}/rows`) .send({ - name: "test" + name: "test", }) .set(config.defaultHeaders()) - .expect('Content-Type', /json/) + .expect("Content-Type", /json/) .expect(200) const updatedTable = await request @@ -109,22 +112,24 @@ describe("/tables", () => { key: "name", _rename: { old: "name", - updated: "updatedName" + updated: "updatedName", }, schema: { - updatedName: { type: "string" } - } + updatedName: { type: "string" }, + }, }) .set(config.defaultHeaders()) - .expect('Content-Type', /json/) + .expect("Content-Type", /json/) .expect(200) - expect(updatedTable.res.statusMessage).toEqual("Table TestTable saved successfully.") + expect((updatedTable as any).res.statusMessage).toEqual( + "Table TestTable saved successfully." + ) expect(updatedTable.body.name).toEqual("TestTable") const res = await request .get(`/api/${testTable._id}/rows/${testRow.body._id}`) .set(config.defaultHeaders()) - .expect('Content-Type', /json/) + .expect("Content-Type", /json/) .expect(200) expect(res.body.updatedName).toEqual("test") @@ -140,7 +145,7 @@ describe("/tables", () => { _id: "ta_users", }) .set(config.defaultHeaders()) - .expect('Content-Type', /json/) + .expect("Content-Type", /json/) .expect(200) expect(res.body.schema.email).toBeDefined() expect(res.body.schema.roleId).toBeDefined() @@ -153,7 +158,7 @@ describe("/tables", () => { const table = await config.createTable() const importRequest = { schema: table.schema, - rows: [{ name: 'test-name', description: 'test-desc' }] + rows: [{ name: "test-name", description: "test-desc" }], } jest.clearAllMocks() @@ -162,20 +167,23 @@ describe("/tables", () => { .post(`/api/tables/${table._id}/import`) .send(importRequest) .set(config.defaultHeaders()) - .expect('Content-Type', /json/) + .expect("Content-Type", /json/) .expect(200) expect(events.table.created).not.toHaveBeenCalled() expect(events.rows.imported).toBeCalledTimes(1) - expect(events.rows.imported).toBeCalledWith(expect.objectContaining({ - name: "TestTable", - _id: table._id - }), 1) + expect(events.rows.imported).toBeCalledWith( + expect.objectContaining({ + name: "TestTable", + _id: table._id, + }), + 1 + ) }) }) describe("fetch", () => { - let testTable + let testTable: Table beforeEach(async () => { testTable = await config.createTable(testTable) @@ -189,7 +197,7 @@ describe("/tables", () => { const res = await request .get(`/api/tables`) .set(config.defaultHeaders()) - .expect('Content-Type', /json/) + .expect("Content-Type", /json/) .expect(200) const fetchedTable = res.body[0] expect(fetchedTable.name).toEqual(testTable.name) @@ -203,6 +211,70 @@ describe("/tables", () => { url: `/api/tables`, }) }) + + it("should fetch views", async () => { + const tableId = config.table!._id! + const views = [ + await config.api.viewV2.create({ tableId }), + await config.api.viewV2.create({ tableId }), + ] + + const res = await request + .get(`/api/tables`) + .set(config.defaultHeaders()) + .expect("Content-Type", /json/) + .expect(200) + + expect(res.body).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + _id: tableId, + views: views.reduce((p, c) => { + p[c.name] = { ...c, schema: expect.anything() } + return p + }, {} as any), + }), + ]) + ) + }) + + it("should enrich the view schemas for viewsV2", async () => { + const tableId = config.table!._id! + jest.spyOn(sdk.tables, "enrichViewSchemas").mockImplementation(t => ({ + ...t, + views: { + view1: { + version: 2, + name: "view1", + schema: {}, + id: "new_view_id", + tableId, + }, + }, + })) + + await config.api.viewV2.create({ tableId }) + await config.createView({ tableId, name: generator.guid() }) + + const res = await config.api.table.fetch() + + expect(res).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + _id: tableId, + views: { + view1: { + version: 2, + name: "view1", + schema: {}, + id: "new_view_id", + tableId, + }, + }, + }), + ]) + ) + }) }) describe("indexing", () => { @@ -216,7 +288,7 @@ describe("/tables", () => { .post(`/api/tables`) .send(table) .set(config.defaultHeaders()) - .expect('Content-Type', /json/) + .expect("Content-Type", /json/) .expect(200) expect(res.body._id).toBeDefined() expect(res.body._rev).toBeDefined() @@ -231,7 +303,7 @@ describe("/tables", () => { _rev: res.body._rev, }) .set(config.defaultHeaders()) - .expect('Content-Type', /json/) + .expect("Content-Type", /json/) .expect(200) // shouldn't have created a new index expect((await db.getIndexes()).total_rows).toEqual(indexCount + 1) @@ -240,7 +312,7 @@ describe("/tables", () => { }) describe("destroy", () => { - let testTable + let testTable: Table beforeEach(async () => { testTable = await config.createTable(testTable) @@ -254,40 +326,44 @@ describe("/tables", () => { const res = await request .delete(`/api/tables/${testTable._id}/${testTable._rev}`) .set(config.defaultHeaders()) - .expect('Content-Type', /json/) + .expect("Content-Type", /json/) .expect(200) expect(res.body.message).toEqual(`Table ${testTable._id} deleted.`) expect(events.table.deleted).toBeCalledTimes(1) - expect(events.table.deleted).toBeCalledWith({ ...testTable, tableId: testTable._id }) + expect(events.table.deleted).toBeCalledWith({ + ...testTable, + tableId: testTable._id, + }) }) it("deletes linked references to the table after deletion", async () => { const linkedTable = await config.createTable({ name: "LinkedTable", type: "table", - key: "name", schema: { name: { - type: "string", + type: FieldType.STRING, + name: "name", constraints: { type: "string", }, }, TestTable: { - type: "link", + type: FieldType.LINK, + name: "TestTable", fieldName: "TestTable", tableId: testTable._id, constraints: { - type: "array" - } - } + type: "array", + }, + }, }, }) const res = await request .delete(`/api/tables/${testTable._id}/${testTable._rev}`) .set(config.defaultHeaders()) - .expect('Content-Type', /json/) + .expect("Content-Type", /json/) .expect(200) expect(res.body.message).toEqual(`Table ${testTable._id} deleted.`) const dependentTable = await config.getTable(linkedTable._id) diff --git a/packages/server/src/api/routes/tests/viewV2.spec.ts b/packages/server/src/api/routes/tests/viewV2.spec.ts new file mode 100644 index 0000000000..480580eb86 --- /dev/null +++ b/packages/server/src/api/routes/tests/viewV2.spec.ts @@ -0,0 +1,109 @@ +import * as setup from "./utilities" +import { + CreateViewRequest, + FieldType, + SortOrder, + SortType, + Table, + ViewV2, +} from "@budibase/types" +import { generator } from "@budibase/backend-core/tests" + +function priceTable(): Table { + return { + name: "table", + type: "table", + schema: { + Price: { + type: FieldType.NUMBER, + name: "Price", + constraints: {}, + }, + Category: { + type: FieldType.STRING, + name: "Category", + constraints: { + type: "string", + }, + }, + }, + } +} + +describe("/v2/views", () => { + const config = setup.getConfig() + + const viewFilters: Omit = { + query: { allOr: false, equal: { field: "value" } }, + sort: { + field: "fieldToSort", + order: SortOrder.DESCENDING, + type: SortType.STRING, + }, + columns: { + name: { + visible: true, + }, + }, + } + + afterAll(setup.afterAll) + + beforeAll(async () => { + await config.init() + await config.createTable(priceTable()) + }) + + describe("create", () => { + it("persist the view when the view is successfully created", async () => { + const newView: CreateViewRequest = { + name: generator.name(), + tableId: config.table!._id!, + } + const res = await config.api.viewV2.create(newView) + + expect(res).toEqual({ + ...newView, + id: expect.stringMatching(new RegExp(`${config.table?._id!}_`)), + version: 2, + }) + }) + + it("can persist views with queries", async () => { + const newView: CreateViewRequest = { + name: generator.name(), + tableId: config.table!._id!, + ...viewFilters, + } + const res = await config.api.viewV2.create(newView) + + expect(res).toEqual({ + ...newView, + ...viewFilters, + id: expect.any(String), + version: 2, + }) + }) + }) + + describe("delete", () => { + let view: ViewV2 + + beforeAll(async () => { + await config.createTable(priceTable()) + view = await config.api.viewV2.create() + }) + + it("can delete an existing view", async () => { + const tableId = config.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() + }) + }) +}) diff --git a/packages/server/src/api/routes/view.ts b/packages/server/src/api/routes/view.ts index 8b366993c0..f8ae4abf0d 100644 --- a/packages/server/src/api/routes/view.ts +++ b/packages/server/src/api/routes/view.ts @@ -7,11 +7,23 @@ import { permissions } from "@budibase/backend-core" const router: Router = new Router() +router + .post( + "/api/v2/views", + authorized(permissions.BUILDER), + viewController.v2.create + ) + .delete( + `/api/v2/views/:viewId`, + authorized(permissions.BUILDER), + viewController.v2.remove + ) + router .get( "/api/views/export", authorized(permissions.BUILDER), - viewController.exportView + viewController.v1.exportView ) .get( "/api/views/:viewName", @@ -22,13 +34,13 @@ router ), rowController.fetchView ) - .get("/api/views", authorized(permissions.BUILDER), viewController.fetch) + .get("/api/views", authorized(permissions.BUILDER), viewController.v1.fetch) .delete( "/api/views/:viewName", paramResource("viewName"), authorized(permissions.BUILDER), - viewController.destroy + viewController.v1.destroy ) - .post("/api/views", authorized(permissions.BUILDER), viewController.save) + .post("/api/views", authorized(permissions.BUILDER), viewController.v1.save) export default router diff --git a/packages/server/src/db/utils.ts b/packages/server/src/db/utils.ts index e08392c3a1..f2cc8618cb 100644 --- a/packages/server/src/db/utils.ts +++ b/packages/server/src/db/utils.ts @@ -186,6 +186,13 @@ export function getDatasourceParams( return getDocParams(DocumentType.DATASOURCE, datasourceId, otherProps) } +export function getDatasourcePlusParams( + datasourceId?: Optional, + otherProps?: { include_docs: boolean } +) { + return getDocParams(DocumentType.DATASOURCE_PLUS, datasourceId, otherProps) +} + /** * Generates a new query ID. * @returns {string} The new query ID which the query doc can be stored under. @@ -271,3 +278,19 @@ export function getMultiIDParams(ids: string[]) { include_docs: true, } } + +/** + * Generates a new view ID. + * @returns {string} The new view ID which the view doc can be stored under. + */ +export function generateViewID(tableId: string) { + return `${tableId}${SEPARATOR}${newid()}` +} + +export function extractViewInfoFromID(viewId: string) { + const regex = new RegExp(`^(?.+)${SEPARATOR}([^${SEPARATOR}]+)$`) + const res = regex.exec(viewId) + return { + tableId: res!.groups!["tableId"], + } +} diff --git a/packages/server/src/migrations/functions/backfill/app/tables.ts b/packages/server/src/migrations/functions/backfill/app/tables.ts index 51b0de5d29..081b81ede5 100644 --- a/packages/server/src/migrations/functions/backfill/app/tables.ts +++ b/packages/server/src/migrations/functions/backfill/app/tables.ts @@ -10,6 +10,10 @@ export const backfill = async (appDb: Database, timestamp: string | number) => { if (table.views) { for (const view of Object.values(table.views)) { + if (sdk.views.isV2(view)) { + continue + } + await events.view.created(view, timestamp) if (view.calculation) { diff --git a/packages/server/src/sdk/app/datasources/datasources.ts b/packages/server/src/sdk/app/datasources/datasources.ts index 4145b1db63..9713cea38f 100644 --- a/packages/server/src/sdk/app/datasources/datasources.ts +++ b/packages/server/src/sdk/app/datasources/datasources.ts @@ -19,6 +19,7 @@ import _ from "lodash" import { BudibaseInternalDB, getDatasourceParams, + getDatasourcePlusParams, getTableParams, } from "../../../db/utils" import sdk from "../../index" @@ -243,3 +244,15 @@ export function mergeConfigs(update: Datasource, old: Datasource) { return update } + +export async function getExternalDatasources(): Promise { + const db = context.getAppDB() + + const externalDatasources = await db.allDocs( + getDatasourcePlusParams(undefined, { + include_docs: true, + }) + ) + + return externalDatasources.rows.map(r => r.doc) +} diff --git a/packages/server/src/sdk/app/rows/search.ts b/packages/server/src/sdk/app/rows/search.ts index 87a1662a54..4937460686 100644 --- a/packages/server/src/sdk/app/rows/search.ts +++ b/packages/server/src/sdk/app/rows/search.ts @@ -1,8 +1,9 @@ -import { SearchFilters } from "@budibase/types" +import { SearchFilters, SortOrder, SortType } from "@budibase/types" import { isExternalTable } from "../../../integrations/utils" import * as internal from "./search/internal" import * as external from "./search/external" import { Format } from "../../../api/controllers/view/exporters" +import _ from "lodash" export interface SearchParams { tableId: string @@ -11,10 +12,11 @@ export interface SearchParams { bookmark?: string limit?: number sort?: string - sortOrder?: string - sortType?: string + sortOrder?: SortOrder + sortType?: SortType version?: string disableEscaping?: boolean + fields?: string[] } export interface ViewParams { @@ -30,8 +32,17 @@ function pickApi(tableId: any) { return internal } -export async function search(options: SearchParams) { - return pickApi(options.tableId).search(options) +export async function search(options: SearchParams): Promise<{ + rows: any[] + hasNextPage?: boolean + bookmark?: number | null +}> { + const result = await pickApi(options.tableId).search(options) + + if (options.fields) { + result.rows = result.rows.map((r: any) => _.pick(r, options.fields!)) + } + return result } export interface ExportRowsParams { diff --git a/packages/server/src/sdk/app/tables/index.ts b/packages/server/src/sdk/app/tables/index.ts index 65010aeaa4..7f88c35010 100644 --- a/packages/server/src/sdk/app/tables/index.ts +++ b/packages/server/src/sdk/app/tables/index.ts @@ -5,9 +5,16 @@ import { isExternalTable, isSQL, } from "../../../integrations/utils" -import { Table, Database } from "@budibase/types" +import { + Table, + Database, + TableResponse, + TableViewsResponse, +} from "@budibase/types" import datasources from "../datasources" import { populateExternalTableSchemas, isEditableColumn } from "./validation" +import sdk from "../../../sdk" +import _ from "lodash" async function getAllInternalTables(db?: Database): Promise { if (!db) { @@ -55,6 +62,20 @@ async function getTable(tableId: any): Promise
{ } } +function enrichViewSchemas(table: Table): TableResponse { + const result: TableResponse = { + ...table, + views: Object.values(table.views ?? []) + .map(v => sdk.views.enrichSchema(v, table.schema)) + .reduce((p, v) => { + p[v.name] = v + return p + }, {} as TableViewsResponse), + } + + return result +} + export default { getAllInternalTables, getAllExternalTables, @@ -62,4 +83,5 @@ export default { getTable, populateExternalTableSchemas, isEditableColumn, + enrichViewSchemas, } diff --git a/packages/server/src/sdk/app/tables/tests/tables.spec.ts b/packages/server/src/sdk/app/tables/tests/tables.spec.ts new file mode 100644 index 0000000000..78ebe59f01 --- /dev/null +++ b/packages/server/src/sdk/app/tables/tests/tables.spec.ts @@ -0,0 +1,100 @@ +import { FieldType, Table, ViewV2 } from "@budibase/types" +import { generator } from "@budibase/backend-core/tests" +import sdk from "../../.." + +jest.mock("../../views", () => ({ + ...jest.requireActual("../../views"), + enrichSchema: jest.fn().mockImplementation(v => ({ ...v, mocked: true })), +})) + +describe("table sdk", () => { + describe("enrichViewSchemas", () => { + const basicTable: Table = { + _id: generator.guid(), + name: "TestTable", + type: "table", + schema: { + name: { + type: FieldType.STRING, + name: "name", + visible: true, + width: 80, + order: 2, + constraints: { + type: "string", + }, + }, + description: { + type: FieldType.STRING, + name: "description", + visible: true, + width: 200, + constraints: { + type: "string", + }, + }, + id: { + type: FieldType.NUMBER, + name: "id", + visible: true, + order: 1, + constraints: { + type: "number", + }, + }, + hiddenField: { + type: FieldType.STRING, + name: "hiddenField", + visible: false, + constraints: { + type: "string", + }, + }, + }, + } + + it("should fetch the default schema if not overriden", async () => { + const tableId = basicTable._id! + function getTable() { + const view: ViewV2 = { + version: 2, + id: generator.guid(), + name: generator.guid(), + tableId, + } + return view + } + const view1 = getTable() + const view2 = getTable() + const view3 = getTable() + const res = sdk.tables.enrichViewSchemas({ + ...basicTable, + views: { + [view1.name]: view1, + [view2.name]: view2, + [view3.name]: view3, + }, + }) + + expect(sdk.views.enrichSchema).toBeCalledTimes(3) + + expect(res).toEqual({ + ...basicTable, + views: { + [view1.name]: { + ...view1, + mocked: true, + }, + [view2.name]: { + ...view2, + mocked: true, + }, + [view3.name]: { + ...view3, + mocked: true, + }, + }, + }) + }) + }) +}) diff --git a/packages/server/src/sdk/app/views/index.ts b/packages/server/src/sdk/app/views/index.ts new file mode 100644 index 0000000000..17691c999d --- /dev/null +++ b/packages/server/src/sdk/app/views/index.ts @@ -0,0 +1,91 @@ +import { HTTPError, context } from "@budibase/backend-core" +import { TableSchema, UIFieldMetadata, View, ViewV2 } from "@budibase/types" + +import sdk from "../../../sdk" +import * as utils from "../../../db/utils" +import _ from "lodash" + +export async function get(viewId: string): Promise { + const { tableId } = utils.extractViewInfoFromID(viewId) + const table = await sdk.tables.getTable(tableId) + const views = Object.values(table.views!) + const view = views.find(v => isV2(v) && v.id === viewId) as ViewV2 | undefined + + return view +} + +export async function create( + tableId: string, + viewRequest: Omit +): Promise { + const view: ViewV2 = { + ...viewRequest, + id: utils.generateViewID(tableId), + version: 2, + } + + const db = context.getAppDB() + const table = await sdk.tables.getTable(tableId) + table.views ??= {} + + table.views[view.name] = view + await db.put(table) + return view +} + +export function isV2(view: View | ViewV2): view is ViewV2 { + return (view as ViewV2).version === 2 +} + +export async function remove(viewId: string): Promise { + const db = context.getAppDB() + + const view = await get(viewId) + const table = await sdk.tables.getTable(view?.tableId) + if (!view) { + throw new HTTPError(`View ${viewId} not found`, 404) + } + + delete table.views![view?.name] + await db.put(table) +} + +export function enrichSchema(view: View | ViewV2, tableSchema: TableSchema) { + if (!sdk.views.isV2(view)) { + return view + } + + return { + ...view, + schema: + !view?.columns || !Object.entries(view?.columns).length + ? tableSchema + : enrichViewV2Schema(tableSchema, view.columns), + } +} + +function enrichViewV2Schema( + tableSchema: TableSchema, + viewOverrides: Record +) { + const result: TableSchema = {} + const viewOverridesEntries = Object.entries(viewOverrides) + const viewSetsOrder = viewOverridesEntries.some(([_, v]) => v.order) + for (const [columnName, columnUIMetadata] of viewOverridesEntries) { + if (!columnUIMetadata.visible) { + continue + } + + if (!tableSchema[columnName]) { + continue + } + + const tableFieldSchema = tableSchema[columnName] + if (viewSetsOrder) { + delete tableFieldSchema.order + } + + result[columnName] = _.merge(tableFieldSchema, columnUIMetadata) + } + return result +} diff --git a/packages/server/src/sdk/app/views/tests/views.spec.ts b/packages/server/src/sdk/app/views/tests/views.spec.ts new file mode 100644 index 0000000000..cbcd98eb91 --- /dev/null +++ b/packages/server/src/sdk/app/views/tests/views.spec.ts @@ -0,0 +1,265 @@ +import { FieldType, Table, ViewV2 } from "@budibase/types" +import { generator } from "@budibase/backend-core/tests" +import { enrichSchema } from ".." + +describe("table sdk", () => { + describe("enrichViewSchemas", () => { + const basicTable: Table = { + _id: generator.guid(), + name: "TestTable", + type: "table", + schema: { + name: { + type: FieldType.STRING, + name: "name", + visible: true, + width: 80, + order: 2, + constraints: { + type: "string", + }, + }, + description: { + type: FieldType.STRING, + name: "description", + visible: true, + width: 200, + constraints: { + type: "string", + }, + }, + id: { + type: FieldType.NUMBER, + name: "id", + visible: true, + order: 1, + constraints: { + type: "number", + }, + }, + hiddenField: { + type: FieldType.STRING, + name: "hiddenField", + visible: false, + constraints: { + type: "string", + }, + }, + }, + } + + it("should fetch the default schema if not overriden", async () => { + const tableId = basicTable._id! + const view: ViewV2 = { + version: 2, + id: generator.guid(), + name: generator.guid(), + tableId, + } + + const res = enrichSchema(view, basicTable.schema) + + expect(res).toEqual({ + ...view, + schema: { + name: { + type: "string", + name: "name", + visible: true, + order: 2, + width: 80, + constraints: { + type: "string", + }, + }, + description: { + type: "string", + name: "description", + visible: true, + width: 200, + constraints: { + type: "string", + }, + }, + id: { + type: "number", + name: "id", + visible: true, + order: 1, + constraints: { + type: "number", + }, + }, + hiddenField: { + type: "string", + name: "hiddenField", + visible: false, + constraints: { + type: "string", + }, + }, + }, + }) + }) + + it("if view schema only defines visiblility, should only fetch the selected fields", async () => { + const tableId = basicTable._id! + const view: ViewV2 = { + version: 2, + id: generator.guid(), + name: generator.guid(), + tableId, + columns: { + name: { visible: true }, + id: { visible: true }, + description: { visible: false }, + }, + } + + const res = enrichSchema(view, basicTable.schema) + + expect(res).toEqual({ + ...view, + schema: { + name: { + type: "string", + name: "name", + visible: true, + order: 2, + width: 80, + constraints: { + type: "string", + }, + }, + id: { + type: "number", + name: "id", + visible: true, + order: 1, + constraints: { + type: "number", + }, + }, + }, + }) + }) + + it("schema does not break if the view has corrupted columns", async () => { + const tableId = basicTable._id! + const view: ViewV2 = { + version: 2, + id: generator.guid(), + name: generator.guid(), + tableId, + columns: { unnexisting: { visible: true }, name: { visible: true } }, + } + + const res = enrichSchema(view, basicTable.schema) + + expect(res).toEqual( + expect.objectContaining({ + ...view, + schema: { + name: { + type: "string", + name: "name", + order: 2, + visible: true, + width: 80, + constraints: { + type: "string", + }, + }, + }, + }) + ) + }) + + it("if view schema only defines visiblility, should only fetch the selected fields", async () => { + const tableId = basicTable._id! + const view: ViewV2 = { + version: 2, + id: generator.guid(), + name: generator.guid(), + tableId, + columns: { + name: { visible: true }, + id: { visible: true }, + description: { visible: false }, + }, + } + + const res = enrichSchema(view, basicTable.schema) + + expect(res).toEqual( + expect.objectContaining({ + ...view, + schema: { + name: { + type: "string", + name: "name", + order: 2, + visible: true, + width: 80, + constraints: { + type: "string", + }, + }, + id: { + type: "number", + name: "id", + order: 1, + visible: true, + constraints: { + type: "number", + }, + }, + }, + }) + ) + }) + + it("if view defines order, the table schema order should be ignored", async () => { + const tableId = basicTable._id! + const view: ViewV2 = { + version: 2, + id: generator.guid(), + name: generator.guid(), + tableId, + columns: { + name: { visible: true, order: 1 }, + id: { visible: true }, + description: { visible: false, order: 2 }, + }, + } + + const res = enrichSchema(view, basicTable.schema) + + expect(res).toEqual( + expect.objectContaining({ + ...view, + schema: { + name: { + type: "string", + name: "name", + order: 1, + visible: true, + width: 80, + constraints: { + type: "string", + }, + }, + id: { + type: "number", + name: "id", + visible: true, + constraints: { + type: "number", + }, + }, + }, + }) + ) + }) + }) +}) diff --git a/packages/server/src/sdk/index.ts b/packages/server/src/sdk/index.ts index 1bf7d89604..85ac483c05 100644 --- a/packages/server/src/sdk/index.ts +++ b/packages/server/src/sdk/index.ts @@ -7,6 +7,7 @@ import { default as queries } from "./app/queries" import { default as rows } from "./app/rows" import { default as users } from "./users" import { default as plugins } from "./plugins" +import * as views from "./app/views" const sdk = { backups, @@ -18,6 +19,7 @@ const sdk = { datasources, queries, plugins, + views, } // default export for TS diff --git a/packages/server/src/tests/utilities/TestConfiguration.ts b/packages/server/src/tests/utilities/TestConfiguration.ts index f0e3678099..a93c78d5fc 100644 --- a/packages/server/src/tests/utilities/TestConfiguration.ts +++ b/packages/server/src/tests/utilities/TestConfiguration.ts @@ -53,6 +53,8 @@ import { } from "@budibase/types" import { BUILTIN_ROLE_IDS } from "@budibase/backend-core/src/security/roles" +import API from "./api" + type DefaultUserValues = { globalUserId: string email: string @@ -73,12 +75,13 @@ class TestConfiguration { user: any globalUserId: any userMetadataId: any - table: any + table?: Table linkedTable: any automation: any datasource: any tenantId?: string defaultUserValues: DefaultUserValues + api: API constructor(openServer = true) { if (openServer) { @@ -94,6 +97,8 @@ class TestConfiguration { this.appId = null this.allApps = [] this.defaultUserValues = this.populateDefaultUserValues() + + this.api = new API(this) } populateDefaultUserValues(): DefaultUserValues { @@ -242,7 +247,7 @@ class TestConfiguration { const db = tenancy.getTenantDB(this.getTenantId()) let existing try { - existing = await db.get(id) + existing = await db.get(id) } catch (err) { existing = { email } } @@ -460,7 +465,7 @@ class TestConfiguration { async generateApiKey(userId = this.defaultUserValues.globalUserId) { const db = tenancy.getTenantDB(this.getTenantId()) const id = dbCore.generateDevInfoID(userId) - let devInfo + let devInfo: any try { devInfo = await db.get(id) } catch (err) { @@ -522,21 +527,27 @@ class TestConfiguration { // TABLE - async updateTable(config?: any): Promise
{ + async updateTable( + config?: any, + { skipReassigning } = { skipReassigning: false } + ): Promise
{ config = config || basicTable() - this.table = await this._req(config, null, controllers.table.save) - return this.table + const response = await this._req(config, null, controllers.table.save) + if (!skipReassigning) { + this.table = response + } + return response } - async createTable(config?: Table) { + async createTable(config?: Table, options = { skipReassigning: false }) { if (config != null && config._id) { delete config._id } - return this.updateTable(config) + return this.updateTable(config, options) } async getTable(tableId?: string) { - tableId = tableId || this.table._id + tableId = tableId || this.table?._id return this._req(null, { tableId }, controllers.table.find) } @@ -577,7 +588,7 @@ class TestConfiguration { throw "Test requires table to be configured." } const tableId = (config && config.tableId) || this.table._id - config = config || basicRow(tableId) + config = config || basicRow(tableId!) return this._req(config, { tableId }, controllers.row.save) } @@ -587,14 +598,14 @@ class TestConfiguration { async getRows(tableId: string) { if (!tableId && this.table) { - tableId = this.table._id + tableId = this.table._id! } return this._req(null, { tableId }, controllers.row.fetch) } async searchRows(tableId: string, searchParams: SearchFilters = {}) { if (!tableId && this.table) { - tableId = this.table._id + tableId = this.table._id! } const body = { query: searchParams, @@ -631,7 +642,7 @@ class TestConfiguration { tableId: this.table._id, name: "ViewTest", } - return this._req(view, null, controllers.view.save) + return this._req(view, null, controllers.view.v1.save) } // AUTOMATION diff --git a/packages/server/src/tests/utilities/api/base.ts b/packages/server/src/tests/utilities/api/base.ts new file mode 100644 index 0000000000..34120277c0 --- /dev/null +++ b/packages/server/src/tests/utilities/api/base.ts @@ -0,0 +1,17 @@ +import TestConfiguration from "../TestConfiguration" +import { SuperTest, Test } from "supertest" + +export interface TestAPIOpts { + headers?: any + status?: number +} + +export abstract class TestAPI { + config: TestConfiguration + request: SuperTest + + protected constructor(config: TestConfiguration) { + this.config = config + this.request = config.request! + } +} diff --git a/packages/server/src/tests/utilities/api/index.ts b/packages/server/src/tests/utilities/api/index.ts new file mode 100644 index 0000000000..cd9f42b82c --- /dev/null +++ b/packages/server/src/tests/utilities/api/index.ts @@ -0,0 +1,13 @@ +import TestConfiguration from "../TestConfiguration" +import { TableAPI } from "./table" +import { ViewV2API } from "./viewV2" + +export default class API { + table: TableAPI + viewV2: ViewV2API + + constructor(config: TestConfiguration) { + this.table = new TableAPI(config) + this.viewV2 = new ViewV2API(config) + } +} diff --git a/packages/server/src/tests/utilities/api/table.ts b/packages/server/src/tests/utilities/api/table.ts new file mode 100644 index 0000000000..70f0869650 --- /dev/null +++ b/packages/server/src/tests/utilities/api/table.ts @@ -0,0 +1,32 @@ +import { Table } from "@budibase/types" +import TestConfiguration from "../TestConfiguration" +import { TestAPI } from "./base" + +export class TableAPI extends TestAPI { + constructor(config: TestConfiguration) { + super(config) + } + + fetch = async ( + { expectStatus } = { expectStatus: 200 } + ): Promise => { + const res = await this.request + .get(`/api/tables`) + .set(this.config.defaultHeaders()) + .expect("Content-Type", /json/) + .expect(expectStatus) + return res.body + } + + get = async ( + tableId: string, + { expectStatus } = { expectStatus: 200 } + ): Promise
=> { + const res = await this.request + .get(`/api/tables/${tableId}`) + .set(this.config.defaultHeaders()) + .expect("Content-Type", /json/) + .expect(expectStatus) + return res.body + } +} diff --git a/packages/server/src/tests/utilities/api/viewV2.ts b/packages/server/src/tests/utilities/api/viewV2.ts new file mode 100644 index 0000000000..15111ad977 --- /dev/null +++ b/packages/server/src/tests/utilities/api/viewV2.ts @@ -0,0 +1,72 @@ +import { SortOrder, SortType, ViewV2 } from "@budibase/types" +import TestConfiguration from "../TestConfiguration" +import { TestAPI } from "./base" +import { generator } from "@budibase/backend-core/tests" + +export class ViewV2API extends TestAPI { + constructor(config: TestConfiguration) { + super(config) + } + + create = async ( + viewData?: Partial, + { expectStatus } = { expectStatus: 201 } + ): Promise => { + let tableId = viewData?.tableId + if (!tableId && !this.config.table) { + throw "Test requires table to be configured." + } + tableId = this.config.table!._id! + const view = { + tableId, + name: generator.guid(), + ...viewData, + } + const result = await this.request + .post(`/api/v2/views`) + .send(view) + .set(this.config.defaultHeaders()) + .expect("Content-Type", /json/) + .expect(expectStatus) + return result.body.data as ViewV2 + } + + delete = async (viewId: string, { expectStatus } = { expectStatus: 204 }) => { + return this.request + .delete(`/api/v2/views/${viewId}`) + .set(this.config.defaultHeaders()) + .expect(expectStatus) + } + + search = async ( + viewId: string, + options?: { + sort: { + column: string + order?: SortOrder + type?: SortType + } + }, + { expectStatus } = { expectStatus: 200 } + ) => { + const qs: [string, any][] = [] + if (options?.sort.column) { + qs.push(["sort_column", options.sort.column]) + } + if (options?.sort.order) { + qs.push(["sort_order", options.sort.order]) + } + if (options?.sort.type) { + qs.push(["sort_type", options.sort.type]) + } + let url = `/api/v2/views/${viewId}/search` + if (qs.length) { + url += "?" + qs.map(q => q.join("=")).join("&") + } + return this.request + .get(url) + .set(this.config.defaultHeaders()) + .expect("Content-Type", /json/) + .expect(expectStatus) + } +} diff --git a/packages/server/src/tests/utilities/structures.ts b/packages/server/src/tests/utilities/structures.ts index 8fb988f5fb..d3e92ea34d 100644 --- a/packages/server/src/tests/utilities/structures.ts +++ b/packages/server/src/tests/utilities/structures.ts @@ -16,23 +16,26 @@ import { AutomationTrigger, AutomationTriggerStepId, Datasource, + FieldType, SourceName, + Table, } from "@budibase/types" -export function basicTable() { +export function basicTable(): Table { return { name: "TestTable", type: "table", - key: "name", schema: { name: { - type: "string", + type: FieldType.STRING, + name: "name", constraints: { type: "string", }, }, description: { - type: "string", + type: FieldType.STRING, + name: "description", constraints: { type: "string", }, diff --git a/packages/types/src/api/web/app/index.ts b/packages/types/src/api/web/app/index.ts index 9be15ecfe3..9c4aa35f57 100644 --- a/packages/types/src/api/web/app/index.ts +++ b/packages/types/src/api/web/app/index.ts @@ -1,2 +1,5 @@ export * from "./backup" export * from "./datasource" +export * from "./view" +export * from "./rows" +export * from "./table" diff --git a/packages/types/src/api/web/app/rows.ts b/packages/types/src/api/web/app/rows.ts new file mode 100644 index 0000000000..d40d2ee15d --- /dev/null +++ b/packages/types/src/api/web/app/rows.ts @@ -0,0 +1,3 @@ +export interface SearchResponse { + rows: any[] +} diff --git a/packages/types/src/api/web/app/table.ts b/packages/types/src/api/web/app/table.ts new file mode 100644 index 0000000000..178f758254 --- /dev/null +++ b/packages/types/src/api/web/app/table.ts @@ -0,0 +1,13 @@ +import { Table, TableSchema, View, ViewV2 } from "../../../documents" + +interface ViewV2Response extends ViewV2 { + schema: TableSchema +} + +export type TableViewsResponse = { [key: string]: View | ViewV2Response } + +export interface TableResponse extends Table { + views?: TableViewsResponse +} + +export type FetchTablesResponse = TableResponse[] diff --git a/packages/types/src/api/web/app/view.ts b/packages/types/src/api/web/app/view.ts new file mode 100644 index 0000000000..e5c5855c1b --- /dev/null +++ b/packages/types/src/api/web/app/view.ts @@ -0,0 +1,7 @@ +import { TableSchema, ViewV2 } from "../../../documents" + +export interface ViewResponse { + data: ViewV2 +} + +export type CreateViewRequest = Omit diff --git a/packages/types/src/documents/app/table/table.ts b/packages/types/src/documents/app/table/table.ts index f4dc790267..76b2c587b2 100644 --- a/packages/types/src/documents/app/table/table.ts +++ b/packages/types/src/documents/app/table/table.ts @@ -1,11 +1,11 @@ import { Document } from "../../document" -import { View } from "../view" +import { View, ViewV2 } from "../view" import { RenameColumn } from "../../../sdk" import { TableSchema } from "./schema" export interface Table extends Document { type?: string - views?: { [key: string]: View } + views?: { [key: string]: View | ViewV2 } name: string primary?: string[] schema: TableSchema diff --git a/packages/types/src/documents/app/view.ts b/packages/types/src/documents/app/view.ts index de0dfea7f5..aeef95ed5b 100644 --- a/packages/types/src/documents/app/view.ts +++ b/packages/types/src/documents/app/view.ts @@ -1,3 +1,7 @@ +import { SortOrder, SortType } from "../../api" +import { SearchFilters } from "../../sdk" +import { TableSchema, UIFieldMetadata } from "./table" + export interface View { name: string tableId: string @@ -10,6 +14,20 @@ export interface View { meta?: Record } +export interface ViewV2 { + version: 2 + id: string + name: string + tableId: string + query?: SearchFilters + sort?: { + field: string + order?: SortOrder + type?: SortType + } + columns?: Record +} + export type ViewSchema = ViewCountOrSumSchema | ViewStatisticsSchema export interface ViewCountOrSumSchema {