Merge branch 'develop' into account-portal-auth-api-testing-2

This commit is contained in:
Mitch-Budibase 2023-08-01 16:46:27 +01:00
commit 0380068473
25 changed files with 1312 additions and 221 deletions

View File

@ -201,25 +201,24 @@ spec:
image: budibase/apps:{{ .Values.globals.appVersion | default .Chart.AppVersion }} image: budibase/apps:{{ .Values.globals.appVersion | default .Chart.AppVersion }}
imagePullPolicy: Always imagePullPolicy: Always
{{- if .Values.services.apps.startupProbe }}
{{- with .Values.services.apps.startupProbe }}
startupProbe:
{{- toYaml . | nindent 10 }}
{{- end }}
{{- end }}
{{- if .Values.services.apps.livenessProbe }}
{{- with .Values.services.apps.livenessProbe }}
livenessProbe: livenessProbe:
httpGet: {{- toYaml . | nindent 10 }}
path: /health {{- end }}
port: {{ .Values.services.apps.port }} {{- end }}
initialDelaySeconds: 10 {{- if .Values.services.apps.readinessProbe }}
periodSeconds: 5 {{- with .Values.services.apps.readinessProbe }}
successThreshold: 1
failureThreshold: 3
timeoutSeconds: 3
readinessProbe: readinessProbe:
httpGet: {{- toYaml . | nindent 10 }}
path: /health {{- end }}
port: {{ .Values.services.apps.port }} {{- end }}
initialDelaySeconds: 5
periodSeconds: 5
successThreshold: 1
failureThreshold: 3
timeoutSeconds: 3
name: bbapps name: bbapps
ports: ports:
- containerPort: {{ .Values.services.apps.port }} - containerPort: {{ .Values.services.apps.port }}

View File

@ -40,24 +40,24 @@ spec:
- image: budibase/proxy:{{ .Values.globals.appVersion | default .Chart.AppVersion }} - image: budibase/proxy:{{ .Values.globals.appVersion | default .Chart.AppVersion }}
imagePullPolicy: Always imagePullPolicy: Always
name: proxy-service name: proxy-service
{{- if .Values.services.proxy.startupProbe }}
{{- with .Values.services.proxy.startupProbe }}
startupProbe:
{{- toYaml . | nindent 10 }}
{{- end }}
{{- end }}
{{- if .Values.services.proxy.livenessProbe }}
{{- with .Values.services.proxy.livenessProbe }}
livenessProbe: livenessProbe:
httpGet: {{- toYaml . | nindent 10 }}
path: /health {{- end }}
port: {{ .Values.services.proxy.port }} {{- end }}
initialDelaySeconds: 0 {{- if .Values.services.proxy.readinessProbe }}
periodSeconds: 5 {{- with .Values.services.proxy.readinessProbe }}
successThreshold: 1
failureThreshold: 2
timeoutSeconds: 3
readinessProbe: readinessProbe:
httpGet: {{- toYaml . | nindent 10 }}
path: /health {{- end }}
port: {{ .Values.services.proxy.port }} {{- end }}
initialDelaySeconds: 0
periodSeconds: 5
successThreshold: 1
failureThreshold: 2
timeoutSeconds: 3
ports: ports:
- containerPort: {{ .Values.services.proxy.port }} - containerPort: {{ .Values.services.proxy.port }}
env: env:

View File

@ -190,24 +190,24 @@ spec:
{{ end }} {{ end }}
image: budibase/worker:{{ .Values.globals.appVersion | default .Chart.AppVersion }} image: budibase/worker:{{ .Values.globals.appVersion | default .Chart.AppVersion }}
imagePullPolicy: Always imagePullPolicy: Always
{{- if .Values.services.worker.startupProbe }}
{{- with .Values.services.worker.startupProbe }}
startupProbe:
{{- toYaml . | nindent 10 }}
{{- end }}
{{- end }}
{{- if .Values.services.worker.livenessProbe }}
{{- with .Values.services.worker.livenessProbe }}
livenessProbe: livenessProbe:
httpGet: {{- toYaml . | nindent 10 }}
path: /health {{- end }}
port: {{ .Values.services.worker.port }} {{- end }}
initialDelaySeconds: 10 {{- if .Values.services.worker.readinessProbe }}
periodSeconds: 5 {{- with .Values.services.worker.readinessProbe }}
successThreshold: 1
failureThreshold: 3
timeoutSeconds: 3
readinessProbe: readinessProbe:
httpGet: {{- toYaml . | nindent 10 }}
path: /health {{- end }}
port: {{ .Values.services.worker.port }} {{- end }}
initialDelaySeconds: 5
periodSeconds: 5
successThreshold: 1
failureThreshold: 3
timeoutSeconds: 3
name: bbworker name: bbworker
ports: ports:
- containerPort: {{ .Values.services.worker.port }} - containerPort: {{ .Values.services.worker.port }}

View File

@ -119,15 +119,37 @@ services:
port: 10000 port: 10000
replicaCount: 1 replicaCount: 1
upstreams: upstreams:
apps: 'http://app-service.{{ .Release.Namespace }}.svc.{{ .Values.services.dns }}:{{ .Values.services.apps.port }}' apps: "http://app-service.{{ .Release.Namespace }}.svc.{{ .Values.services.dns }}:{{ .Values.services.apps.port }}"
worker: 'http://worker-service.{{ .Release.Namespace }}.svc.{{ .Values.services.dns }}:{{ .Values.services.worker.port }}' worker: "http://worker-service.{{ .Release.Namespace }}.svc.{{ .Values.services.dns }}:{{ .Values.services.worker.port }}"
minio: 'http://minio-service.{{ .Release.Namespace }}.svc.{{ .Values.services.dns }}:{{ .Values.services.objectStore.port }}' minio: "http://minio-service.{{ .Release.Namespace }}.svc.{{ .Values.services.dns }}:{{ .Values.services.objectStore.port }}"
couchdb: 'http://{{ .Release.Name }}-svc-couchdb:{{ .Values.services.couchdb.port }}' couchdb: "http://{{ .Release.Name }}-svc-couchdb:{{ .Values.services.couchdb.port }}"
resources: {} resources: {}
# annotations: startupProbe:
# co.elastic.logs/module: nginx httpGet:
# co.elastic.logs/fileset.stdout: access path: /health
# co.elastic.logs/fileset.stderr: error port: 10000
scheme: HTTP
failureThreshold: 30
periodSeconds: 3
readinessProbe:
httpGet:
path: /health
port: 10000
scheme: HTTP
enabled: true
periodSeconds: 3
failureThreshold: 1
livenessProbe:
httpGet:
path: /health
port: 10000
scheme: HTTP
failureThreshold: 3
periodSeconds: 5
# annotations:
# co.elastic.logs/module: nginx
# co.elastic.logs/fileset.stdout: access
# co.elastic.logs/fileset.stderr: error
apps: apps:
port: 4002 port: 4002
@ -135,23 +157,67 @@ services:
logLevel: info logLevel: info
httpLogging: 1 httpLogging: 1
resources: {} resources: {}
# nodeDebug: "" # set the value of NODE_DEBUG startupProbe:
# annotations: httpGet:
# co.elastic.logs/multiline.type: pattern path: /health
# co.elastic.logs/multiline.pattern: '^[[:space:]]' port: 4002
# co.elastic.logs/multiline.negate: false scheme: HTTP
# co.elastic.logs/multiline.match: after failureThreshold: 30
periodSeconds: 3
readinessProbe:
httpGet:
path: /health
port: 4002
scheme: HTTP
enabled: true
periodSeconds: 3
failureThreshold: 1
livenessProbe:
httpGet:
path: /health
port: 4002
scheme: HTTP
failureThreshold: 3
periodSeconds: 5
# nodeDebug: "" # set the value of NODE_DEBUG
# annotations:
# co.elastic.logs/multiline.type: pattern
# co.elastic.logs/multiline.pattern: '^[[:space:]]'
# co.elastic.logs/multiline.negate: false
# co.elastic.logs/multiline.match: after
worker: worker:
port: 4003 port: 4003
replicaCount: 1 replicaCount: 1
logLevel: info logLevel: info
httpLogging: 1 httpLogging: 1
resources: {} resources: {}
# annotations: startupProbe:
# co.elastic.logs/multiline.type: pattern httpGet:
# co.elastic.logs/multiline.pattern: '^[[:space:]]' path: /health
# co.elastic.logs/multiline.negate: false port: 4003
# co.elastic.logs/multiline.match: after scheme: HTTP
failureThreshold: 30
periodSeconds: 3
readinessProbe:
httpGet:
path: /health
port: 4003
scheme: HTTP
enabled: true
periodSeconds: 3
failureThreshold: 1
livenessProbe:
httpGet:
path: /health
port: 4003
scheme: HTTP
failureThreshold: 3
periodSeconds: 5
# annotations:
# co.elastic.logs/multiline.type: pattern
# co.elastic.logs/multiline.pattern: '^[[:space:]]'
# co.elastic.logs/multiline.negate: false
# co.elastic.logs/multiline.match: after
couchdb: couchdb:
enabled: true enabled: true

View File

@ -1,5 +1,5 @@
{ {
"version": "2.8.29-alpha.6", "version": "2.8.29-alpha.9",
"npmClient": "yarn", "npmClient": "yarn",
"packages": [ "packages": [
"packages/*" "packages/*"

View File

@ -11,10 +11,6 @@ import {
Row, Row,
PatchRowRequest, PatchRowRequest,
PatchRowResponse, PatchRowResponse,
SearchResponse,
SortOrder,
SortType,
ViewV2,
} from "@budibase/types" } from "@budibase/types"
import * as utils from "./utils" import * as utils from "./utils"
import { gridSocket } from "../../../websockets" import { gridSocket } from "../../../websockets"
@ -23,6 +19,7 @@ import { fixRow } from "../public/rows"
import sdk from "../../../sdk" import sdk from "../../../sdk"
import * as exporters from "../view/exporters" import * as exporters from "../view/exporters"
import { apiFileReturn } from "../../../utilities/fileSystem" import { apiFileReturn } from "../../../utilities/fileSystem"
export * as views from "./views"
function pickApi(tableId: any) { function pickApi(tableId: any) {
if (isExternalTable(tableId)) { if (isExternalTable(tableId)) {
@ -37,6 +34,7 @@ export async function patch(
const appId = ctx.appId const appId = ctx.appId
const tableId = utils.getTableId(ctx) const tableId = utils.getTableId(ctx)
const body = ctx.request.body const body = ctx.request.body
// if it doesn't have an _id then its save // if it doesn't have an _id then its save
if (body && !body._id) { if (body && !body._id) {
return save(ctx) return save(ctx)
@ -62,13 +60,14 @@ export async function patch(
} }
} }
export const save = async (ctx: any) => { export const save = async (ctx: UserCtx<Row, Row>) => {
const appId = ctx.appId const appId = ctx.appId
const tableId = utils.getTableId(ctx) const tableId = utils.getTableId(ctx)
const body = ctx.request.body const body = ctx.request.body
// if it has an ID already then its a patch // if it has an ID already then its a patch
if (body && body._id) { if (body && body._id) {
return patch(ctx) return patch(ctx as UserCtx<PatchRowRequest, PatchRowResponse>)
} }
const { row, table, squashed } = await quotas.addRow(() => const { row, table, squashed } = await quotas.addRow(() =>
quotas.addQuery(() => pickApi(tableId).save(ctx), { quotas.addQuery(() => pickApi(tableId).save(ctx), {
@ -147,7 +146,7 @@ async function deleteRows(ctx: UserCtx<DeleteRowRequest>) {
const rowDeletes: Row[] = await processDeleteRowsRequest(ctx) const rowDeletes: Row[] = await processDeleteRowsRequest(ctx)
deleteRequest.rows = rowDeletes deleteRequest.rows = rowDeletes
let { rows } = await quotas.addQuery<any>( const { rows } = await quotas.addQuery(
() => pickApi(tableId).bulkDestroy(ctx), () => pickApi(tableId).bulkDestroy(ctx),
{ {
datasourceId: tableId, datasourceId: tableId,
@ -167,13 +166,13 @@ async function deleteRow(ctx: UserCtx<DeleteRowRequest>) {
const appId = ctx.appId const appId = ctx.appId
const tableId = utils.getTableId(ctx) const tableId = utils.getTableId(ctx)
let resp = await quotas.addQuery<any>(() => pickApi(tableId).destroy(ctx), { const resp = await quotas.addQuery(() => pickApi(tableId).destroy(ctx), {
datasourceId: tableId, datasourceId: tableId,
}) })
await quotas.removeRow() await quotas.removeRow()
ctx.eventEmitter && ctx.eventEmitter.emitRow(`row:delete`, appId, resp.row) ctx.eventEmitter && ctx.eventEmitter.emitRow(`row:delete`, appId, resp.row)
gridSocket?.emitRowDeletion(ctx, resp.row._id) gridSocket?.emitRowDeletion(ctx, resp.row._id!)
return resp return resp
} }
@ -212,83 +211,6 @@ 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<void, SearchResponse>) {
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
const result = await quotas.addQuery(
() =>
sdk.rows.search({
tableId: view.tableId,
query: view.query || {},
fields: viewFields,
...getSortOptions(ctx, view),
}),
{
datasourceId: view.tableId,
}
)
result.rows.forEach(r => (r._viewId = view.id))
ctx.body = result
}
export async function validate(ctx: Ctx) { export async function validate(ctx: Ctx) {
const tableId = utils.getTableId(ctx) const tableId = utils.getTableId(ctx)
// external tables are hard to validate currently // external tables are hard to validate currently

View File

@ -93,7 +93,6 @@ export async function patch(ctx: UserCtx<PatchRowRequest, PatchRowResponse>) {
} }
export async function save(ctx: UserCtx) { export async function save(ctx: UserCtx) {
const db = context.getAppDB()
let inputs = ctx.request.body let inputs = ctx.request.body
inputs.tableId = ctx.params.tableId inputs.tableId = ctx.params.tableId
@ -177,7 +176,6 @@ export async function destroy(ctx: UserCtx) {
} }
export async function bulkDestroy(ctx: UserCtx) { export async function bulkDestroy(ctx: UserCtx) {
const db = context.getAppDB()
const tableId = ctx.params.tableId const tableId = ctx.params.tableId
const table = await sdk.tables.getTable(tableId) const table = await sdk.tables.getTable(tableId)
let { rows } = ctx.request.body let { rows } = ctx.request.body
@ -206,6 +204,7 @@ export async function bulkDestroy(ctx: UserCtx) {
}) })
) )
} else { } else {
const db = context.getAppDB()
await db.bulkDocs(processedRows.map(row => ({ ...row, _deleted: true }))) await db.bulkDocs(processedRows.map(row => ({ ...row, _deleted: true })))
} }
// remove any attachments that were on the rows from object storage // remove any attachments that were on the rows from object storage

View File

@ -0,0 +1,86 @@
import { quotas } from "@budibase/pro"
import {
UserCtx,
SearchResponse,
SortOrder,
SortType,
ViewV2,
} from "@budibase/types"
import sdk from "../../../sdk"
export async function searchView(ctx: UserCtx<void, SearchResponse>) {
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
const result = await quotas.addQuery(
() =>
sdk.rows.search({
tableId: view.tableId,
query: view.query || {},
fields: viewFields,
...getSortOptions(ctx, view),
}),
{
datasourceId: view.tableId,
}
)
result.rows.forEach(r => (r._viewId = view.id))
ctx.body = result
}
function getSortOptions(
ctx: UserCtx,
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
}

View File

@ -2,15 +2,79 @@ import sdk from "../../../sdk"
import { import {
CreateViewRequest, CreateViewRequest,
Ctx, Ctx,
UIFieldMetadata,
UpdateViewRequest, UpdateViewRequest,
ViewResponse, ViewResponse,
ViewV2,
RequiredKeys,
} from "@budibase/types" } from "@budibase/types"
async function parseSchemaUI(ctx: Ctx, view: CreateViewRequest) {
if (!view.schema) {
return
}
function hasOverrides(
newObj: Record<string, any>,
existingObj: Record<string, any>
) {
const result = Object.entries(newObj).some(([key, value]) => {
const isObject = typeof value === "object"
const existing = existingObj[key]
if (isObject && hasOverrides(value, existing || {})) {
return true
}
if (!isObject && value !== existing) {
return true
}
})
return result
}
const table = await sdk.tables.getTable(view.tableId)
for (const [
fieldName,
{ order, width, visible, icon, ...schemaNonUI },
] of Object.entries(view.schema)) {
const overrides = hasOverrides(schemaNonUI, table.schema[fieldName])
if (overrides) {
ctx.throw(
400,
"This endpoint does not support overriding non UI fields in the schema"
)
}
}
const schemaUI =
view.schema &&
Object.entries(view.schema).reduce((p, [fieldName, schemaValue]) => {
p[fieldName] = {
order: schemaValue.order,
width: schemaValue.width,
visible: schemaValue.visible,
icon: schemaValue.icon,
}
return p
}, {} as Record<string, RequiredKeys<UIFieldMetadata>>)
return schemaUI
}
export async function create(ctx: Ctx<CreateViewRequest, ViewResponse>) { export async function create(ctx: Ctx<CreateViewRequest, ViewResponse>) {
const view = ctx.request.body const view = ctx.request.body
const { tableId } = view const { tableId } = view
const result = await sdk.views.create(tableId, view) const schemaUI = await parseSchemaUI(ctx, view)
const parsedView: Omit<ViewV2, "id" | "version"> = {
name: view.name,
tableId: view.tableId,
query: view.query,
sort: view.sort,
columns: view.schema && Object.keys(view.schema),
schemaUI,
}
const result = await sdk.views.create(tableId, parsedView)
ctx.status = 201 ctx.status = 201
ctx.body = { ctx.body = {
data: result, data: result,
@ -30,7 +94,19 @@ export async function update(ctx: Ctx<UpdateViewRequest, ViewResponse>) {
const { tableId } = view const { tableId } = view
const result = await sdk.views.update(tableId, view) const schemaUI = await parseSchemaUI(ctx, view)
const parsedView: ViewV2 = {
id: view.id,
name: view.name,
version: view.version,
tableId: view.tableId,
query: view.query,
sort: view.sort,
columns: view.schema && Object.keys(view.schema),
schemaUI,
}
const result = await sdk.views.update(tableId, parsedView)
ctx.body = { ctx.body = {
data: result, data: result,
} }

View File

@ -4,6 +4,9 @@ import authorized from "../../middleware/authorized"
import { paramResource, paramSubResource } from "../../middleware/resourceId" import { paramResource, paramSubResource } from "../../middleware/resourceId"
import { permissions } from "@budibase/backend-core" import { permissions } from "@budibase/backend-core"
import { internalSearchValidator } from "./utils/validators" import { internalSearchValidator } from "./utils/validators"
import noViewData from "../../middleware/noViewData"
import trimViewRowInfo from "../../middleware/trimViewRowInfo"
import * as utils from "../../db/utils"
const { PermissionType, PermissionLevel } = permissions const { PermissionType, PermissionLevel } = permissions
const router: Router = new Router() const router: Router = new Router()
@ -146,11 +149,6 @@ router
authorized(PermissionType.TABLE, PermissionLevel.READ), authorized(PermissionType.TABLE, PermissionLevel.READ),
rowController.search rowController.search
) )
.get(
"/api/v2/views/:viewId/search",
authorized(PermissionType.VIEW, PermissionLevel.READ),
rowController.searchView
)
/** /**
* @api {post} /api/:tableId/rows Creates a new row * @api {post} /api/:tableId/rows Creates a new row
* @apiName Creates a new row * @apiName Creates a new row
@ -179,6 +177,7 @@ router
"/api/:tableId/rows", "/api/:tableId/rows",
paramResource("tableId"), paramResource("tableId"),
authorized(PermissionType.TABLE, PermissionLevel.WRITE), authorized(PermissionType.TABLE, PermissionLevel.WRITE),
noViewData,
rowController.save rowController.save
) )
/** /**
@ -193,6 +192,7 @@ router
"/api/:tableId/rows", "/api/:tableId/rows",
paramResource("tableId"), paramResource("tableId"),
authorized(PermissionType.TABLE, PermissionLevel.WRITE), authorized(PermissionType.TABLE, PermissionLevel.WRITE),
noViewData,
rowController.patch rowController.patch
) )
/** /**
@ -268,4 +268,91 @@ router
rowController.exportRows rowController.exportRows
) )
router
.get(
"/api/v2/views/:viewId/search",
authorized(PermissionType.VIEW, PermissionLevel.READ),
rowController.views.searchView
)
/**
* @api {post} /api/:tableId/rows Creates a new row
* @apiName Creates a new row
* @apiGroup rows
* @apiPermission table write access
* @apiDescription This API will create a new row based on the supplied body. If the
* body includes an "_id" field then it will update an existing row if the field
* links to one. Please note that "_id", "_rev" and "tableId" are fields that are
* already used by Budibase tables and cannot be used for columns.
*
* @apiParam {string} tableId The ID of the table to save a row to.
*
* @apiParam (Body) {string} [_id] If the row exists already then an ID for the row must be provided.
* @apiParam (Body) {string} [_rev] If working with an existing row for an internal table its revision
* must also be provided.
* @apiParam (Body) {string} _viewId The ID of the view should be specified in the row body itself.
* @apiParam (Body) {string} tableId The ID of the table should also be specified in the row body itself.
* @apiParam (Body) {any} [any] Any field supplied in the body will be assessed to see if it matches
* a column in the specified table. All other fields will be dropped and not stored.
*
* @apiSuccess {string} _id The ID of the row that was just saved, if it was just created this
* is the rows new ID.
* @apiSuccess {string} [_rev] If saving to an internal table a revision will also be returned.
* @apiSuccess {object} body The contents of the row that was saved will be returned as well.
*/
.post(
"/api/v2/views/:viewId/rows",
paramResource("viewId"),
authorized(PermissionType.VIEW, PermissionLevel.WRITE),
trimViewRowInfo,
rowController.save
)
/**
* @api {patch} /api/v2/views/:viewId/rows/:rowId Updates a row
* @apiName Update a row
* @apiGroup rows
* @apiPermission table write access
* @apiDescription This endpoint is identical to the row creation endpoint but instead it will
* error if an _id isn't provided, it will only function for existing rows.
*/
.patch(
"/api/v2/views/:viewId/rows/:rowId",
paramResource("viewId"),
authorized(PermissionType.VIEW, PermissionLevel.WRITE),
trimViewRowInfo,
rowController.patch
)
/**
* @api {delete} /api/v2/views/:viewId/rows Delete rows for a view
* @apiName Delete rows for a view
* @apiGroup rows
* @apiPermission table write access
* @apiDescription This endpoint can delete a single row, or delete them in a bulk
* fashion.
*
* @apiParam {string} tableId The ID of the table the row is to be deleted from.
*
* @apiParam (Body) {object[]} [rows] If bulk deletion is desired then provide the rows in this
* key of the request body that are to be deleted.
* @apiParam (Body) {string} [_id] If deleting a single row then provide its ID in this field.
* @apiParam (Body) {string} [_rev] If deleting a single row from an internal table then provide its
* revision here.
*
* @apiSuccess {object[]|object} body If deleting bulk then the response body will be an array
* of the deleted rows, if deleting a single row then the body will contain a "row" property which
* is the deleted row.
*/
.delete(
"/api/v2/views/:viewId/rows",
paramResource("viewId"),
authorized(PermissionType.VIEW, PermissionLevel.WRITE),
// This is required as the implementation relies on the table id
(ctx, next) => {
ctx.params.tableId = utils.extractViewInfoFromID(
ctx.params.viewId
).tableId
return next()
},
rowController.destroy
)
export default router export default router

View File

@ -16,13 +16,16 @@ import {
FieldType, FieldType,
SortType, SortType,
SortOrder, SortOrder,
PatchRowRequest, DeleteRow,
} from "@budibase/types" } from "@budibase/types"
import { import {
expectAnyInternalColsAttributes, expectAnyInternalColsAttributes,
generator, generator,
structures, structures,
} from "@budibase/backend-core/tests" } from "@budibase/backend-core/tests"
import trimViewRowInfoMiddleware from "../../../middleware/trimViewRowInfo"
import noViewDataMiddleware from "../../../middleware/noViewData"
import router from "../row"
describe("/rows", () => { describe("/rows", () => {
let request = setup.getRequest() let request = setup.getRequest()
@ -391,6 +394,26 @@ describe("/rows", () => {
expect(saved.arrayFieldArrayStrKnown).toEqual(["One"]) expect(saved.arrayFieldArrayStrKnown).toEqual(["One"])
expect(saved.optsFieldStrKnown).toEqual("Alpha") expect(saved.optsFieldStrKnown).toEqual("Alpha")
}) })
it("should throw an error when creating a table row with view id data", async () => {
const res = await request
.post(`/api/${row.tableId}/rows`)
.send({ ...row, _viewId: generator.guid() })
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(400)
expect(res.body.message).toEqual(
"Table row endpoints cannot contain view info"
)
})
it("should setup the noViewData middleware", async () => {
const route = router.stack.find(
r => r.methods.includes("POST") && r.path === "/api/:tableId/rows"
)
expect(route).toBeDefined()
expect(route?.stack).toContainEqual(noViewDataMiddleware)
})
}) })
describe("patch", () => { describe("patch", () => {
@ -440,6 +463,33 @@ describe("/rows", () => {
await assertRowUsage(rowUsage) await assertRowUsage(rowUsage)
await assertQueryUsage(queryUsage) await assertQueryUsage(queryUsage)
}) })
it("should throw an error when creating a table row with view id data", async () => {
const existing = await config.createRow()
const res = await config.api.row.patch(
table._id!,
{
...existing,
_id: existing._id!,
_rev: existing._rev!,
tableId: table._id!,
_viewId: generator.guid(),
},
{ expectStatus: 400 }
)
expect(res.body.message).toEqual(
"Table row endpoints cannot contain view info"
)
})
it("should setup the noViewData middleware", async () => {
const route = router.stack.find(
r => r.methods.includes("PATCH") && r.path === "/api/:tableId/rows"
)
expect(route).toBeDefined()
expect(route?.stack).toContainEqual(noViewDataMiddleware)
})
}) })
describe("destroy", () => { describe("destroy", () => {
@ -983,7 +1033,7 @@ describe("/rows", () => {
} }
const view = await config.api.viewV2.create({ const view = await config.api.viewV2.create({
columns: { name: { visible: true } }, schema: { name: {} },
}) })
const response = await config.api.viewV2.search(view.id) const response = await config.api.viewV2.search(view.id)
@ -1008,4 +1058,208 @@ describe("/rows", () => {
expect(response.body.rows).toHaveLength(0) expect(response.body.rows).toHaveLength(0)
}) })
}) })
describe("view 2.0", () => {
function userTable(): Table {
return {
name: "user",
type: "user",
schema: {
name: {
type: FieldType.STRING,
name: "name",
},
surname: {
type: FieldType.STRING,
name: "name",
},
age: {
type: FieldType.NUMBER,
name: "age",
},
address: {
type: FieldType.STRING,
name: "address",
},
jobTitle: {
type: FieldType.STRING,
name: "jobTitle",
},
},
}
}
const randomRowData = () => ({
name: generator.first(),
surname: generator.last(),
age: generator.age(),
address: generator.address(),
jobTitle: generator.word(),
})
describe("create", () => {
it("should persist a new row with only the provided view fields", async () => {
const table = await config.createTable(userTable())
const view = await config.api.viewV2.create({
tableId: table._id!,
schema: {
name: { visible: true },
surname: { visible: true },
address: { visible: true },
},
})
const data = randomRowData()
const newRow = await config.api.viewV2.row.create(view.id, {
tableId: config.table!._id,
_viewId: view.id,
...data,
})
const row = await config.api.row.get(table._id!, newRow._id!)
expect(row.body).toEqual({
name: data.name,
surname: data.surname,
address: data.address,
tableId: config.table!._id,
type: "row",
_id: expect.any(String),
_rev: expect.any(String),
createdAt: expect.any(String),
updatedAt: expect.any(String),
})
expect(row.body._viewId).toBeUndefined()
expect(row.body.age).toBeUndefined()
expect(row.body.jobTitle).toBeUndefined()
})
it("should setup the trimViewRowInfo middleware", async () => {
const route = router.stack.find(
r =>
r.methods.includes("POST") &&
r.path === "/api/v2/views/:viewId/rows"
)
expect(route).toBeDefined()
expect(route?.stack).toContainEqual(trimViewRowInfoMiddleware)
})
})
describe("patch", () => {
it("should update only the view fields for a row", async () => {
const table = await config.createTable(userTable())
const tableId = table._id!
const view = await config.api.viewV2.create({
tableId,
schema: {
name: { visible: true },
address: { visible: true },
},
})
const newRow = await config.api.viewV2.row.create(view.id, {
tableId,
_viewId: view.id,
...randomRowData(),
})
const newData = randomRowData()
await config.api.viewV2.row.update(view.id, newRow._id!, {
tableId,
_viewId: view.id,
_id: newRow._id!,
_rev: newRow._rev!,
...newData,
})
const row = await config.api.row.get(tableId, newRow._id!)
expect(row.body).toEqual({
...newRow,
type: "row",
name: newData.name,
address: newData.address,
_id: expect.any(String),
_rev: expect.any(String),
createdAt: expect.any(String),
updatedAt: expect.any(String),
})
expect(row.body._viewId).toBeUndefined()
expect(row.body.age).toBeUndefined()
expect(row.body.jobTitle).toBeUndefined()
})
it("should setup the trimViewRowInfo middleware", async () => {
const route = router.stack.find(
r =>
r.methods.includes("PATCH") &&
r.path === "/api/v2/views/:viewId/rows/:rowId"
)
expect(route).toBeDefined()
expect(route?.stack).toContainEqual(trimViewRowInfoMiddleware)
})
})
describe("destroy", () => {
it("should be able to delete a row", async () => {
const table = await config.createTable(userTable())
const tableId = table._id!
const view = await config.api.viewV2.create({
tableId,
schema: {
name: { visible: true },
address: { visible: true },
},
})
const createdRow = await config.createRow()
const rowUsage = await getRowUsage()
const queryUsage = await getQueryUsage()
const body: DeleteRow = {
_id: createdRow._id!,
}
await config.api.viewV2.row.delete(view.id, body)
await assertRowUsage(rowUsage - 1)
await assertQueryUsage(queryUsage + 1)
await config.api.row.get(tableId, createdRow._id!, {
expectStatus: 404,
})
})
it("should be able to delete multiple rows", async () => {
const table = await config.createTable(userTable())
const tableId = table._id!
const view = await config.api.viewV2.create({
tableId,
schema: {
name: { visible: true },
address: { visible: true },
},
})
const rows = [
await config.createRow(),
await config.createRow(),
await config.createRow(),
]
const rowUsage = await getRowUsage()
const queryUsage = await getQueryUsage()
await config.api.viewV2.row.delete(view.id, {
rows: [rows[0], rows[2]],
})
await assertRowUsage(rowUsage - 2)
await assertQueryUsage(queryUsage + 1)
await config.api.row.get(tableId, rows[0]._id!, {
expectStatus: 404,
})
await config.api.row.get(tableId, rows[2]._id!, {
expectStatus: 404,
})
await config.api.row.get(tableId, rows[1]._id!, { expectStatus: 200 })
})
})
})
}) })

View File

@ -1,6 +1,7 @@
import * as setup from "./utilities" import * as setup from "./utilities"
import { import {
CreateViewRequest, CreateViewRequest,
FieldSchema,
FieldType, FieldType,
SortOrder, SortOrder,
SortType, SortType,
@ -40,7 +41,7 @@ describe("/v2/views", () => {
order: SortOrder.DESCENDING, order: SortOrder.DESCENDING,
type: SortType.STRING, type: SortType.STRING,
}, },
columns: { schema: {
name: { name: {
visible: true, visible: true,
}, },
@ -73,17 +74,111 @@ describe("/v2/views", () => {
const newView: CreateViewRequest = { const newView: CreateViewRequest = {
name: generator.name(), name: generator.name(),
tableId: config.table!._id!, tableId: config.table!._id!,
...viewFilters, query: viewFilters.query,
sort: viewFilters.sort,
} }
delete newView.schema
const res = await config.api.viewV2.create(newView) const res = await config.api.viewV2.create(newView)
expect(res).toEqual({ expect(res).toEqual({
...newView, ...newView,
...viewFilters, query: viewFilters.query,
sort: viewFilters.sort,
id: expect.any(String), id: expect.any(String),
version: 2, version: 2,
}) })
}) })
it("persist only UI schema overrides", async () => {
const newView: CreateViewRequest = {
name: generator.name(),
tableId: config.table!._id!,
schema: {
Price: {
name: "Price",
type: FieldType.NUMBER,
visible: true,
order: 1,
width: 100,
},
Category: {
name: "Category",
type: FieldType.STRING,
visible: false,
icon: "ic",
},
} as Record<string, FieldSchema>,
}
const createdView = await config.api.viewV2.create(newView)
expect(await config.api.viewV2.get(createdView.id)).toEqual({
...newView,
schema: undefined,
columns: ["Price", "Category"],
schemaUI: {
Price: {
visible: true,
order: 1,
width: 100,
},
Category: {
visible: false,
icon: "ic",
},
},
id: createdView.id,
version: 2,
})
})
it("throw an exception if the schema overrides a non UI field", async () => {
const newView: CreateViewRequest = {
name: generator.name(),
tableId: config.table!._id!,
schema: {
Price: {
name: "Price",
type: FieldType.NUMBER,
visible: true,
},
Category: {
name: "Category",
type: FieldType.STRING,
constraints: {
type: "string",
presence: true,
},
},
} as Record<string, FieldSchema>,
}
await config.api.viewV2.create(newView, {
expectStatus: 400,
})
})
it("will not throw an exception if the schema is 'deleting' non UI fields", async () => {
const newView: CreateViewRequest = {
name: generator.name(),
tableId: config.table!._id!,
schema: {
Price: {
name: "Price",
type: FieldType.NUMBER,
visible: true,
},
Category: {
name: "Category",
type: FieldType.STRING,
},
} as Record<string, FieldSchema>,
}
await config.api.viewV2.create(newView, {
expectStatus: 201,
})
})
}) })
describe("update", () => { describe("update", () => {
@ -202,6 +297,94 @@ describe("/v2/views", () => {
status: 400, status: 400,
}) })
}) })
it("updates only UI schema overrides", async () => {
await config.api.viewV2.update({
...view,
schema: {
Price: {
name: "Price",
type: FieldType.NUMBER,
visible: true,
order: 1,
width: 100,
},
Category: {
name: "Category",
type: FieldType.STRING,
visible: false,
icon: "ic",
},
} as Record<string, FieldSchema>,
})
expect(await config.api.viewV2.get(view.id)).toEqual({
...view,
schema: undefined,
columns: ["Price", "Category"],
schemaUI: {
Price: {
visible: true,
order: 1,
width: 100,
},
Category: {
visible: false,
icon: "ic",
},
},
id: view.id,
version: 2,
})
})
it("throw an exception if the schema overrides a non UI field", async () => {
await config.api.viewV2.update(
{
...view,
schema: {
Price: {
name: "Price",
type: FieldType.NUMBER,
visible: true,
},
Category: {
name: "Category",
type: FieldType.STRING,
constraints: {
type: "string",
presence: true,
},
},
} as Record<string, FieldSchema>,
},
{
expectStatus: 400,
}
)
})
it("will not throw an exception if the schema is 'deleting' non UI fields", async () => {
await config.api.viewV2.update(
{
...view,
schema: {
Price: {
name: "Price",
type: FieldType.NUMBER,
visible: true,
},
Category: {
name: "Category",
type: FieldType.STRING,
},
} as Record<string, FieldSchema>,
},
{
expectStatus: 200,
}
)
})
}) })
describe("delete", () => { describe("delete", () => {

View File

@ -0,0 +1,9 @@
import { Ctx, Row } from "@budibase/types"
export default async (ctx: Ctx<Row>, next: any) => {
if (ctx.request.body._viewId) {
return ctx.throw(400, "Table row endpoints cannot contain view info")
}
return next()
}

View File

@ -0,0 +1,83 @@
import { generator } from "@budibase/backend-core/tests"
import { BBRequest, FieldType, Row, Table } from "@budibase/types"
import { Next } from "koa"
import * as utils from "../../db/utils"
import noViewDataMiddleware from "../noViewData"
class TestConfiguration {
next: Next
throw: jest.Mock<(status: number, message: string) => never>
middleware: typeof noViewDataMiddleware
params: Record<string, any>
request?: Pick<BBRequest<Row>, "body">
constructor() {
this.next = jest.fn()
this.throw = jest.fn()
this.params = {}
this.middleware = noViewDataMiddleware
}
executeMiddleware(ctxRequestBody: Row) {
this.request = {
body: ctxRequestBody,
}
return this.middleware(
{
request: this.request as any,
throw: this.throw as any,
params: this.params,
} as any,
this.next
)
}
afterEach() {
jest.clearAllMocks()
}
}
describe("noViewData middleware", () => {
let config: TestConfiguration
beforeEach(() => {
config = new TestConfiguration()
})
afterEach(() => {
config.afterEach()
})
const getRandomData = () => ({
_id: generator.guid(),
name: generator.name(),
age: generator.age(),
address: generator.address(),
})
it("it should pass without view id data", async () => {
const data = getRandomData()
await config.executeMiddleware({
...data,
})
expect(config.next).toBeCalledTimes(1)
expect(config.throw).not.toBeCalled()
})
it("it should throw an error if _viewid is provided", async () => {
const data = getRandomData()
await config.executeMiddleware({
_viewId: generator.guid(),
...data,
})
expect(config.throw).toBeCalledTimes(1)
expect(config.throw).toBeCalledWith(
400,
"Table row endpoints cannot contain view info"
)
expect(config.next).not.toBeCalled()
})
})

View File

@ -0,0 +1,174 @@
import { generator } from "@budibase/backend-core/tests"
import { BBRequest, FieldType, Row, Table } from "@budibase/types"
import * as utils from "../../db/utils"
import trimViewRowInfoMiddleware from "../trimViewRowInfo"
jest.mock("../../sdk", () => ({
views: {
...jest.requireActual("../../sdk/app/views"),
get: jest.fn(),
},
tables: {
getTable: jest.fn(),
},
}))
import sdk from "../../sdk"
import { Next } from "koa"
const mockGetView = sdk.views.get as jest.MockedFunction<typeof sdk.views.get>
const mockGetTable = sdk.tables.getTable as jest.MockedFunction<
typeof sdk.tables.getTable
>
class TestConfiguration {
next: Next
throw: jest.Mock<(status: number, message: string) => never>
middleware: typeof trimViewRowInfoMiddleware
params: Record<string, any>
request?: Pick<BBRequest<Row>, "body">
constructor() {
this.next = jest.fn()
this.throw = jest.fn()
this.params = {}
this.middleware = trimViewRowInfoMiddleware
}
executeMiddleware(viewId: string, ctxRequestBody: Row) {
this.request = {
body: ctxRequestBody,
}
this.params.viewId = viewId
return this.middleware(
{
request: this.request as any,
next: this.next,
throw: this.throw as any,
params: this.params,
} as any,
this.next
)
}
afterEach() {
jest.clearAllMocks()
}
}
describe("trimViewRowInfo middleware", () => {
let config: TestConfiguration
beforeEach(() => {
config = new TestConfiguration()
})
afterEach(() => {
config.afterEach()
})
const table: Table = {
_id: utils.generateTableID(),
name: generator.word(),
type: "table",
schema: {
name: {
name: "name",
type: FieldType.STRING,
},
age: {
name: "age",
type: FieldType.NUMBER,
},
address: {
name: "address",
type: FieldType.STRING,
},
},
}
let viewId: string = undefined!
beforeEach(() => {
jest.resetAllMocks()
mockGetTable.mockResolvedValue(table)
viewId = utils.generateViewID(table._id!)
})
const getRandomData = () => ({
_id: generator.guid(),
name: generator.name(),
age: generator.age(),
address: generator.address(),
})
it("when no columns are defined, same data is returned", async () => {
mockGetView.mockResolvedValue({
version: 2,
id: viewId,
name: generator.guid(),
tableId: table._id!,
})
const data = getRandomData()
await config.executeMiddleware(viewId, {
_viewId: viewId,
...data,
})
expect(config.request?.body).toEqual(data)
expect(config.params.tableId).toEqual(table._id)
expect(config.next).toBeCalledTimes(1)
expect(config.throw).not.toBeCalled()
})
it("when columns are defined, trimmed data is returned", async () => {
mockGetView.mockResolvedValue({
version: 2,
id: viewId,
name: generator.guid(),
tableId: table._id!,
columns: ["name", "address"],
})
const data = getRandomData()
await config.executeMiddleware(viewId, {
_viewId: viewId,
...data,
})
expect(config.request?.body).toEqual({
_id: data._id,
name: data.name,
address: data.address,
})
expect(config.params.tableId).toEqual(table._id)
expect(config.next).toBeCalledTimes(1)
expect(config.throw).not.toBeCalled()
})
it("it should throw an error if no viewid is provided on the body", async () => {
const data = getRandomData()
await config.executeMiddleware(viewId, {
...data,
})
expect(config.throw).toBeCalledTimes(1)
expect(config.throw).toBeCalledWith(400, "_viewId is required")
expect(config.next).not.toBeCalled()
})
it("it should throw an error if no viewid is provided on the parameters", async () => {
const data = getRandomData()
await config.executeMiddleware(undefined as any, {
_viewId: viewId,
...data,
})
expect(config.throw).toBeCalledTimes(1)
expect(config.throw).toBeCalledWith(400, "viewId path is required")
expect(config.next).not.toBeCalled()
})
})

View File

@ -0,0 +1,52 @@
import { Ctx, Row } from "@budibase/types"
import * as utils from "../db/utils"
import sdk from "../sdk"
import { db } from "@budibase/backend-core"
import { Next } from "koa"
export default async (ctx: Ctx<Row>, next: Next) => {
const { body } = ctx.request
const { _viewId: viewId } = body
if (!viewId) {
return ctx.throw(400, "_viewId is required")
}
if (!ctx.params.viewId) {
return ctx.throw(400, "viewId path is required")
}
const { tableId } = utils.extractViewInfoFromID(ctx.params.viewId)
const { _viewId, ...trimmedView } = await trimViewFields(
viewId,
tableId,
body
)
ctx.request.body = trimmedView
ctx.params.tableId = tableId
return next()
}
export async function trimViewFields<T extends Row>(
viewId: string,
tableId: string,
data: T
): Promise<T> {
const view = await sdk.views.get(viewId)
if (!view?.columns || !Object.keys(view.columns).length) {
return data
}
const table = await sdk.tables.getTable(tableId)
const { schema } = sdk.views.enrichSchema(view!, table.schema)
const result: Record<string, any> = {}
for (const key of [
...Object.keys(schema),
...db.CONSTANT_EXTERNAL_ROW_COLS,
...db.CONSTANT_INTERNAL_ROW_COLS,
]) {
result[key] = data[key] !== null ? data[key] : undefined
}
return result as T
}

View File

@ -13,7 +13,7 @@ jest.unmock("mysql2/promise")
jest.setTimeout(30000) jest.setTimeout(30000)
describe("external", () => { describe.skip("external", () => {
const config = new TestConfiguration() const config = new TestConfiguration()
let externalDatasource: Datasource let externalDatasource: Datasource

View File

@ -1,5 +1,5 @@
import { HTTPError, context } from "@budibase/backend-core" import { HTTPError, context } from "@budibase/backend-core"
import { TableSchema, UIFieldMetadata, View, ViewV2 } from "@budibase/types" import { FieldSchema, TableSchema, View, ViewV2 } from "@budibase/types"
import sdk from "../../../sdk" import sdk from "../../../sdk"
import * as utils from "../../../db/utils" import * as utils from "../../../db/utils"
@ -73,37 +73,34 @@ export function enrichSchema(view: View | ViewV2, tableSchema: TableSchema) {
return view return view
} }
let schema = { ...tableSchema }
if (view.schemaUI) {
const viewOverridesEntries = Object.entries(view.schemaUI)
const viewSetsOrder = viewOverridesEntries.some(([_, v]) => v.order)
for (const [fieldName, schemaUI] of viewOverridesEntries) {
schema[fieldName] = {
...schema[fieldName],
...schemaUI,
order: viewSetsOrder
? schemaUI.order || undefined
: schema[fieldName].order,
}
}
}
if (view?.columns?.length) {
const pickedSchema: Record<string, FieldSchema> = {}
for (const fieldName of view.columns) {
if (!schema[fieldName]) {
continue
}
pickedSchema[fieldName] = { ...schema[fieldName] }
}
schema = pickedSchema
}
return { return {
...view, ...view,
schema: schema: schema,
!view?.columns || !Object.entries(view?.columns).length
? tableSchema
: enrichViewV2Schema(tableSchema, view.columns),
} }
} }
function enrichViewV2Schema(
tableSchema: TableSchema,
viewOverrides: Record<string, UIFieldMetadata>
) {
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
}

View File

@ -102,18 +102,14 @@ describe("table sdk", () => {
}) })
}) })
it("if view schema only defines visiblility, should only fetch the selected fields", async () => { it("if view schema only defines columns, should only fetch the selected fields", async () => {
const tableId = basicTable._id! const tableId = basicTable._id!
const view: ViewV2 = { const view: ViewV2 = {
version: 2, version: 2,
id: generator.guid(), id: generator.guid(),
name: generator.guid(), name: generator.guid(),
tableId, tableId,
columns: { columns: ["name", "id"],
name: { visible: true },
id: { visible: true },
description: { visible: false },
},
} }
const res = enrichSchema(view, basicTable.schema) const res = enrichSchema(view, basicTable.schema)
@ -151,7 +147,7 @@ describe("table sdk", () => {
id: generator.guid(), id: generator.guid(),
name: generator.guid(), name: generator.guid(),
tableId, tableId,
columns: { unnexisting: { visible: true }, name: { visible: true } }, columns: ["unnexisting", "name"],
} }
const res = enrichSchema(view, basicTable.schema) const res = enrichSchema(view, basicTable.schema)
@ -175,16 +171,17 @@ describe("table sdk", () => {
) )
}) })
it("if view schema only defines visiblility, should only fetch the selected fields", async () => { it("if the view schema overrides the schema UI, the table schema should be overridden", async () => {
const tableId = basicTable._id! const tableId = basicTable._id!
const view: ViewV2 = { const view: ViewV2 = {
version: 2, version: 2,
id: generator.guid(), id: generator.guid(),
name: generator.guid(), name: generator.guid(),
tableId, tableId,
columns: { columns: ["name", "id", "description"],
name: { visible: true }, schemaUI: {
id: { visible: true }, name: { visible: true, width: 100 },
id: { visible: true, width: 20 },
description: { visible: false }, description: { visible: false },
}, },
} }
@ -200,7 +197,7 @@ describe("table sdk", () => {
name: "name", name: "name",
order: 2, order: 2,
visible: true, visible: true,
width: 80, width: 100,
constraints: { constraints: {
type: "string", type: "string",
}, },
@ -210,23 +207,34 @@ describe("table sdk", () => {
name: "id", name: "id",
order: 1, order: 1,
visible: true, visible: true,
width: 20,
constraints: { constraints: {
type: "number", type: "number",
}, },
}, },
description: {
type: "string",
name: "description",
visible: false,
width: 200,
constraints: {
type: "string",
},
},
}, },
}) })
) )
}) })
it("if view defines order, the table schema order should be ignored", async () => { it("if the view defines order, the table schema order should be ignored", async () => {
const tableId = basicTable._id! const tableId = basicTable._id!
const view: ViewV2 = { const view: ViewV2 = {
version: 2, version: 2,
id: generator.guid(), id: generator.guid(),
name: generator.guid(), name: generator.guid(),
tableId, tableId,
columns: { columns: ["name", "id", "description"],
schemaUI: {
name: { visible: true, order: 1 }, name: { visible: true, order: 1 },
id: { visible: true }, id: { visible: true },
description: { visible: false, order: 2 }, description: { visible: false, order: 2 },
@ -257,6 +265,16 @@ describe("table sdk", () => {
type: "number", type: "number",
}, },
}, },
description: {
type: "string",
name: "description",
order: 2,
visible: false,
width: 200,
constraints: {
type: "string",
},
},
}, },
}) })
) )

View File

@ -7,6 +7,21 @@ export class RowAPI extends TestAPI {
super(config) super(config)
} }
get = async (
tableId: string,
rowId: string,
{ expectStatus } = { expectStatus: 200 }
) => {
const request = this.request
.get(`/api/${tableId}/rows/${rowId}`)
.set(this.config.defaultHeaders())
.expect(expectStatus)
if (expectStatus !== 404) {
request.expect("Content-Type", /json/)
}
return request
}
patch = async ( patch = async (
tableId: string, tableId: string,
row: PatchRowRequest, row: PatchRowRequest,

View File

@ -1,8 +1,19 @@
import { CreateViewRequest, SortOrder, SortType, ViewV2 } from "@budibase/types" import {
CreateViewRequest,
SortOrder,
SortType,
UpdateViewRequest,
DeleteRowRequest,
PatchRowRequest,
PatchRowResponse,
Row,
ViewV2,
} from "@budibase/types"
import TestConfiguration from "../TestConfiguration" import TestConfiguration from "../TestConfiguration"
import { TestAPI } from "./base" import { TestAPI } from "./base"
import { generator } from "@budibase/backend-core/tests" import { generator } from "@budibase/backend-core/tests"
import { Response } from "superagent" import { Response } from "superagent"
import sdk from "../../../sdk"
export class ViewV2API extends TestAPI { export class ViewV2API extends TestAPI {
constructor(config: TestConfiguration) { constructor(config: TestConfiguration) {
@ -33,7 +44,7 @@ export class ViewV2API extends TestAPI {
} }
update = async ( update = async (
view: ViewV2, view: UpdateViewRequest,
{ {
expectStatus, expectStatus,
handleResponse, handleResponse,
@ -62,6 +73,12 @@ export class ViewV2API extends TestAPI {
.expect(expectStatus) .expect(expectStatus)
} }
get = async (viewId: string) => {
return await this.config.doInContext(this.config.appId, () =>
sdk.views.get(viewId)
)
}
search = async ( search = async (
viewId: string, viewId: string,
options?: { options?: {
@ -93,4 +110,46 @@ export class ViewV2API extends TestAPI {
.expect("Content-Type", /json/) .expect("Content-Type", /json/)
.expect(expectStatus) .expect(expectStatus)
} }
row = {
create: async (
viewId: string,
row: Row,
{ expectStatus } = { expectStatus: 200 }
): Promise<Row> => {
const result = await this.request
.post(`/api/v2/views/${viewId}/rows`)
.send(row)
.set(this.config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(expectStatus)
return result.body as Row
},
update: async (
viewId: string,
rowId: string,
row: PatchRowRequest,
{ expectStatus } = { expectStatus: 200 }
): Promise<PatchRowResponse> => {
const result = await this.request
.patch(`/api/v2/views/${viewId}/rows/${rowId}`)
.send(row)
.set(this.config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(expectStatus)
return result.body as PatchRowResponse
},
delete: async (
viewId: string,
body: DeleteRowRequest,
{ expectStatus } = { expectStatus: 200 }
): Promise<any> => {
const result = await this.request
.delete(`/api/v2/views/${viewId}/rows`)
.send(body)
.set(this.config.defaultHeaders())
.expect(expectStatus)
return result.body
},
}
} }

View File

@ -1,9 +1,15 @@
import { TableSchema, ViewV2 } from "../../../documents" import { ViewV2, UIFieldMetadata } from "../../../documents"
export interface ViewResponse { export interface ViewResponse {
data: ViewV2 data: ViewV2
} }
export type CreateViewRequest = Omit<ViewV2, "version" | "id"> export interface CreateViewRequest
extends Omit<ViewV2, "version" | "id" | "columns" | "schemaUI"> {
schema?: Record<string, UIFieldMetadata>
}
export type UpdateViewRequest = ViewV2 export interface UpdateViewRequest
extends Omit<ViewV2, "columns" | "schemaUI"> {
schema?: Record<string, UIFieldMetadata>
}

View File

@ -25,7 +25,8 @@ export interface ViewV2 {
order?: SortOrder order?: SortOrder
type?: SortType type?: SortType
} }
columns?: Record<string, UIFieldMetadata> columns?: string[]
schemaUI?: Record<string, UIFieldMetadata>
} }
export type ViewSchema = ViewCountOrSumSchema | ViewStatisticsSchema export type ViewSchema = ViewCountOrSumSchema | ViewStatisticsSchema

View File

@ -1,3 +1,4 @@
export * from "./documents" export * from "./documents"
export * from "./sdk" export * from "./sdk"
export * from "./api" export * from "./api"
export * from "./shared"

View File

@ -3,3 +3,7 @@ export type DeepPartial<T> = {
} }
export type ISO8601 = string export type ISO8601 = string
export type RequiredKeys<T> = {
[K in keyof Required<T>]: T[K]
}