Merge branch 'master' of github.com:Budibase/budibase into v3-ui

This commit is contained in:
Andrew Kingston 2024-10-10 13:59:03 +01:00
commit d1bc83ab0a
No known key found for this signature in database
11 changed files with 316 additions and 50 deletions

View File

@ -272,6 +272,7 @@ export const flags = new FlagSet({
SQS: Flag.boolean(env.isDev()), SQS: Flag.boolean(env.isDev()),
[FeatureFlag.AI_CUSTOM_CONFIGS]: Flag.boolean(env.isDev()), [FeatureFlag.AI_CUSTOM_CONFIGS]: Flag.boolean(env.isDev()),
[FeatureFlag.ENRICHED_RELATIONSHIPS]: Flag.boolean(env.isDev()), [FeatureFlag.ENRICHED_RELATIONSHIPS]: Flag.boolean(env.isDev()),
[FeatureFlag.TABLES_DEFAULT_ADMIN]: Flag.boolean(env.isDev()),
}) })
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T type UnwrapPromise<T> = T extends Promise<infer U> ? U : T

View File

@ -28,6 +28,7 @@ import {
import { cloneDeep } from "lodash" import { cloneDeep } from "lodash"
import { generateIdForRow } from "./utils" import { generateIdForRow } from "./utils"
import { helpers } from "@budibase/shared-core" import { helpers } from "@budibase/shared-core"
import { HTTPError } from "@budibase/backend-core"
export async function handleRequest<T extends Operation>( export async function handleRequest<T extends Operation>(
operation: T, operation: T,
@ -102,6 +103,11 @@ export async function patch(ctx: UserCtx<PatchRowRequest, PatchRowResponse>) {
export async function destroy(ctx: UserCtx) { export async function destroy(ctx: UserCtx) {
const source = await utils.getSource(ctx) const source = await utils.getSource(ctx)
if (sdk.views.isView(source) && helpers.views.isCalculationView(source)) {
throw new HTTPError("Cannot delete rows through a calculation view", 400)
}
const _id = ctx.request.body._id const _id = ctx.request.body._id
const { row } = await handleRequest(Operation.DELETE, source, { const { row } = await handleRequest(Operation.DELETE, source, {
id: breakRowIdField(_id), id: breakRowIdField(_id),

View File

@ -8,7 +8,7 @@ import {
} from "../../../utilities/rowProcessor" } from "../../../utilities/rowProcessor"
import * as utils from "./utils" import * as utils from "./utils"
import { cloneDeep } from "lodash/fp" import { cloneDeep } from "lodash/fp"
import { context } from "@budibase/backend-core" import { context, HTTPError } from "@budibase/backend-core"
import { finaliseRow, updateRelatedFormula } from "./staticFormula" import { finaliseRow, updateRelatedFormula } from "./staticFormula"
import { import {
FieldType, FieldType,
@ -16,6 +16,7 @@ import {
PatchRowRequest, PatchRowRequest,
PatchRowResponse, PatchRowResponse,
Row, Row,
Table,
UserCtx, UserCtx,
} from "@budibase/types" } from "@budibase/types"
import sdk from "../../../sdk" import sdk from "../../../sdk"
@ -97,15 +98,26 @@ export async function patch(ctx: UserCtx<PatchRowRequest, PatchRowResponse>) {
export async function destroy(ctx: UserCtx) { export async function destroy(ctx: UserCtx) {
const db = context.getAppDB() const db = context.getAppDB()
const { tableId } = utils.getSourceId(ctx) const source = await utils.getSource(ctx)
if (sdk.views.isView(source) && helpers.views.isCalculationView(source)) {
throw new HTTPError("Cannot delete rows through a calculation view", 400)
}
let table: Table
if (sdk.views.isView(source)) {
table = await sdk.views.getTable(source.id)
} else {
table = source
}
const { _id } = ctx.request.body const { _id } = ctx.request.body
let row = await db.get<Row>(_id) let row = await db.get<Row>(_id)
let _rev = ctx.request.body._rev || row._rev let _rev = ctx.request.body._rev || row._rev
if (row.tableId !== tableId) { if (row.tableId !== table._id) {
throw "Supplied tableId doesn't match the row's tableId" throw "Supplied tableId doesn't match the row's tableId"
} }
const table = await sdk.tables.getTable(tableId)
// update the row to include full relationships before deleting them // update the row to include full relationships before deleting them
row = await outputProcessing(table, row, { row = await outputProcessing(table, row, {
squash: false, squash: false,
@ -115,7 +127,7 @@ export async function destroy(ctx: UserCtx) {
await linkRows.updateLinks({ await linkRows.updateLinks({
eventType: linkRows.EventType.ROW_DELETE, eventType: linkRows.EventType.ROW_DELETE,
row, row,
tableId, tableId: table._id!,
}) })
// remove any attachments that were on the row from object storage // remove any attachments that were on the row from object storage
await AttachmentCleanup.rowDelete(table, [row]) await AttachmentCleanup.rowDelete(table, [row])
@ -123,7 +135,7 @@ export async function destroy(ctx: UserCtx) {
await updateRelatedFormula(table, row) await updateRelatedFormula(table, row)
let response let response
if (tableId === InternalTables.USER_METADATA) { if (table._id === InternalTables.USER_METADATA) {
ctx.params = { ctx.params = {
id: _id, id: _id,
} }

View File

@ -133,9 +133,7 @@ export async function buildSqlFieldList(
let fields: string[] = [] let fields: string[] = []
if (sdk.views.isView(source)) { if (sdk.views.isView(source)) {
fields = Object.keys(helpers.views.basicFields(source)).filter( fields = Object.keys(helpers.views.basicFields(source))
key => source.schema?.[key]?.visible !== false
)
} else { } else {
fields = extractRealFields(source) fields = extractRealFields(source)
} }

View File

@ -800,6 +800,34 @@ describe.each([
) )
} }
}) })
isInternal &&
it("shouldn't trigger a complex type check on a group by field if field is invisible", async () => {
const table = await config.api.table.save(
saveTableRequest({
schema: {
field: {
name: "field",
type: FieldType.JSON,
},
},
})
)
await config.api.viewV2.create(
{
tableId: table._id!,
name: generator.guid(),
type: ViewV2Type.CALCULATION,
schema: {
field: { visible: false },
},
},
{
status: 201,
}
)
})
}) })
describe("update", () => { describe("update", () => {
@ -1904,6 +1932,59 @@ describe.each([
}) })
}) })
}) })
describe("calculation views", () => {
it("should not remove calculation columns when modifying table schema", async () => {
let table = await config.api.table.save(
saveTableRequest({
schema: {
name: {
name: "name",
type: FieldType.STRING,
},
age: {
name: "age",
type: FieldType.NUMBER,
},
},
})
)
let view = await config.api.viewV2.create({
tableId: table._id!,
name: generator.guid(),
type: ViewV2Type.CALCULATION,
schema: {
sum: {
visible: true,
calculationType: CalculationType.SUM,
field: "age",
},
},
})
table = await config.api.table.get(table._id!)
await config.api.table.save({
...table,
schema: {
...table.schema,
name: {
name: "name",
type: FieldType.STRING,
constraints: { presence: true },
},
},
})
view = await config.api.viewV2.get(view.id)
expect(Object.keys(view.schema!).sort()).toEqual([
"age",
"id",
"name",
"sum",
])
})
})
}) })
describe("row operations", () => { describe("row operations", () => {
@ -2148,6 +2229,32 @@ describe.each([
}) })
await config.api.row.get(table._id!, rows[1]._id!, { status: 200 }) await config.api.row.get(table._id!, rows[1]._id!, { status: 200 })
}) })
it("should not be possible to delete a row in a calculation view", async () => {
const row = await config.api.row.save(table._id!, {})
const view = await config.api.viewV2.create({
tableId: table._id!,
name: generator.guid(),
type: ViewV2Type.CALCULATION,
schema: {
id: { visible: true },
one: { visible: true },
},
})
await config.api.row.delete(
view.id,
{ _id: row._id! },
{
status: 400,
body: {
message: "Cannot delete rows through a calculation view",
status: 400,
},
}
)
})
}) })
describe("read", () => { describe("read", () => {
@ -3157,6 +3264,123 @@ describe.each([
} }
) )
}) })
it("should be able to filter rows on the view itself", async () => {
const table = await config.api.table.save(
saveTableRequest({
schema: {
quantity: {
type: FieldType.NUMBER,
name: "quantity",
},
price: {
type: FieldType.NUMBER,
name: "price",
},
},
})
)
const view = await config.api.viewV2.create({
tableId: table._id!,
name: generator.guid(),
type: ViewV2Type.CALCULATION,
query: {
equal: {
quantity: 1,
},
},
schema: {
sum: {
visible: true,
calculationType: CalculationType.SUM,
field: "price",
},
},
})
await config.api.row.bulkImport(table._id!, {
rows: [
{
quantity: 1,
price: 1,
},
{
quantity: 1,
price: 2,
},
{
quantity: 2,
price: 10,
},
],
})
const { rows } = await config.api.viewV2.search(view.id, {
query: {},
})
expect(rows).toHaveLength(1)
expect(rows[0].sum).toEqual(3)
})
it("should be able to filter on group by fields", async () => {
const table = await config.api.table.save(
saveTableRequest({
schema: {
quantity: {
type: FieldType.NUMBER,
name: "quantity",
},
price: {
type: FieldType.NUMBER,
name: "price",
},
},
})
)
const view = await config.api.viewV2.create({
tableId: table._id!,
name: generator.guid(),
type: ViewV2Type.CALCULATION,
schema: {
quantity: { visible: true },
sum: {
visible: true,
calculationType: CalculationType.SUM,
field: "price",
},
},
})
await config.api.row.bulkImport(table._id!, {
rows: [
{
quantity: 1,
price: 1,
},
{
quantity: 1,
price: 2,
},
{
quantity: 2,
price: 10,
},
],
})
const { rows } = await config.api.viewV2.search(view.id, {
query: {
equal: {
quantity: 1,
},
},
})
expect(rows).toHaveLength(1)
expect(rows[0].sum).toEqual(3)
})
}) })
!isLucene && !isLucene &&

View File

@ -20,29 +20,28 @@ const OPTIONAL_NUMBER = Joi.number().optional().allow(null)
const OPTIONAL_BOOLEAN = Joi.boolean().optional().allow(null) const OPTIONAL_BOOLEAN = Joi.boolean().optional().allow(null)
const APP_NAME_REGEX = /^[\w\s]+$/ const APP_NAME_REGEX = /^[\w\s]+$/
const validateViewSchemas: CustomValidator<Table> = (table, helpers) => { const validateViewSchemas: CustomValidator<Table> = (table, joiHelpers) => {
if (table.views && Object.entries(table.views).length) { if (!table.views || Object.keys(table.views).length === 0) {
const requiredFields = Object.entries(table.schema) return table
.filter(([_, v]) => isRequired(v.constraints)) }
const required = Object.keys(table.schema).filter(key =>
isRequired(table.schema[key].constraints)
)
if (required.length === 0) {
return table
}
for (const view of Object.values(table.views)) {
if (!sdk.views.isV2(view) || helpers.views.isCalculationView(view)) {
continue
}
const editable = Object.entries(view.schema || {})
.filter(([_, f]) => f.visible && !f.readonly)
.map(([key]) => key) .map(([key]) => key)
if (requiredFields.length) { const missingField = required.find(f => !editable.includes(f))
for (const view of Object.values(table.views)) { if (missingField) {
if (!sdk.views.isV2(view)) { return joiHelpers.message({
continue custom: `To make field "${missingField}" required, this field must be present and writable in views: ${view.name}.`,
} })
const editableViewFields = Object.entries(view.schema || {})
.filter(([_, f]) => f.visible && !f.readonly)
.map(([key]) => key)
const missingField = requiredFields.find(
f => !editableViewFields.includes(f)
)
if (missingField) {
return helpers.message({
custom: `To make field "${missingField}" required, this field must be present and writable in views: ${view.name}.`,
})
}
}
} }
} }
return table return table

View File

@ -68,9 +68,7 @@ async function buildInternalFieldList(
const { relationships, allowedFields } = opts || {} const { relationships, allowedFields } = opts || {}
let schemaFields: string[] = [] let schemaFields: string[] = []
if (sdk.views.isView(source)) { if (sdk.views.isView(source)) {
schemaFields = Object.keys(helpers.views.basicFields(source)).filter( schemaFields = Object.keys(helpers.views.basicFields(source))
key => source.schema?.[key]?.visible !== false
)
} else { } else {
schemaFields = Object.keys(source.schema).filter( schemaFields = Object.keys(source.schema).filter(
key => source.schema[key].visible !== false key => source.schema[key].visible !== false

View File

@ -1,10 +1,10 @@
import { Row, Table } from "@budibase/types" import { FeatureFlag, Row, Table } from "@budibase/types"
import * as external from "./external" import * as external from "./external"
import * as internal from "./internal" import * as internal from "./internal"
import { isExternal } from "./utils" import { isExternal } from "./utils"
import { setPermissions } from "../permissions" import { setPermissions } from "../permissions"
import { roles } from "@budibase/backend-core" import { features, roles } from "@budibase/backend-core"
export async function create( export async function create(
table: Omit<Table, "_id" | "_rev">, table: Omit<Table, "_id" | "_rev">,
@ -18,10 +18,16 @@ export async function create(
createdTable = await internal.create(table, rows, userId) createdTable = await internal.create(table, rows, userId)
} }
await setPermissions(createdTable._id!, { const setExplicitPermission = await features.flags.isEnabled(
writeRole: roles.BUILTIN_ROLE_IDS.ADMIN, FeatureFlag.TABLES_DEFAULT_ADMIN
readRole: roles.BUILTIN_ROLE_IDS.ADMIN, )
})
if (setExplicitPermission) {
await setPermissions(createdTable._id!, {
writeRole: roles.BUILTIN_ROLE_IDS.ADMIN,
readRole: roles.BUILTIN_ROLE_IDS.ADMIN,
})
}
return createdTable return createdTable
} }

View File

@ -1,6 +1,7 @@
import { import {
CalculationType, CalculationType,
canGroupBy, canGroupBy,
FeatureFlag,
FieldType, FieldType,
isNumeric, isNumeric,
PermissionLevel, PermissionLevel,
@ -13,7 +14,7 @@ import {
ViewV2ColumnEnriched, ViewV2ColumnEnriched,
ViewV2Enriched, ViewV2Enriched,
} from "@budibase/types" } from "@budibase/types"
import { context, docIds, HTTPError } from "@budibase/backend-core" import { context, docIds, features, HTTPError } from "@budibase/backend-core"
import { import {
helpers, helpers,
PROTECTED_EXTERNAL_COLUMNS, PROTECTED_EXTERNAL_COLUMNS,
@ -94,6 +95,13 @@ async function guardCalculationViewSchema(
continue continue
} }
if (!schema.field) {
throw new HTTPError(
`Calculation field "${name}" is missing a "field" property`,
400
)
}
const targetSchema = table.schema[schema.field] const targetSchema = table.schema[schema.field]
if (!targetSchema) { if (!targetSchema) {
throw new HTTPError( throw new HTTPError(
@ -241,12 +249,17 @@ export async function create(
await guardViewSchema(tableId, viewRequest) await guardViewSchema(tableId, viewRequest)
const view = await pickApi(tableId).create(tableId, viewRequest) const view = await pickApi(tableId).create(tableId, viewRequest)
// Set permissions to be the same as the table const setExplicitPermission = await features.flags.isEnabled(
const tablePerms = await sdk.permissions.getResourcePerms(tableId) FeatureFlag.TABLES_DEFAULT_ADMIN
await sdk.permissions.setPermissions(view.id, { )
writeRole: tablePerms[PermissionLevel.WRITE].role, if (setExplicitPermission) {
readRole: tablePerms[PermissionLevel.READ].role, // Set permissions to be the same as the table
}) const tablePerms = await sdk.permissions.getResourcePerms(tableId)
await sdk.permissions.setPermissions(view.id, {
writeRole: tablePerms[PermissionLevel.WRITE].role,
readRole: tablePerms[PermissionLevel.READ].role,
})
}
return view return view
} }
@ -368,7 +381,8 @@ export function syncSchema(
if (view.schema) { if (view.schema) {
for (const fieldName of Object.keys(view.schema)) { for (const fieldName of Object.keys(view.schema)) {
if (!schema[fieldName]) { const viewSchema = view.schema[fieldName]
if (!helpers.views.isCalculationField(viewSchema) && !schema[fieldName]) {
delete view.schema[fieldName] delete view.schema[fieldName]
} }
} }

View File

@ -33,6 +33,13 @@ export function calculationFields(view: UnsavedViewV2) {
return pickBy(view.schema || {}, isCalculationField) return pickBy(view.schema || {}, isCalculationField)
} }
export function basicFields(view: UnsavedViewV2) { export function isVisible(field: ViewFieldMetadata) {
return pickBy(view.schema || {}, field => !isCalculationField(field)) return field.visible !== false
}
export function basicFields(view: UnsavedViewV2, opts?: { visible?: boolean }) {
const { visible = true } = opts || {}
return pickBy(view.schema || {}, field => {
return !isCalculationField(field) && (!visible || isVisible(field))
})
} }

View File

@ -3,6 +3,7 @@ export enum FeatureFlag {
PER_CREATOR_PER_USER_PRICE_ALERT = "PER_CREATOR_PER_USER_PRICE_ALERT", PER_CREATOR_PER_USER_PRICE_ALERT = "PER_CREATOR_PER_USER_PRICE_ALERT",
AI_CUSTOM_CONFIGS = "AI_CUSTOM_CONFIGS", AI_CUSTOM_CONFIGS = "AI_CUSTOM_CONFIGS",
ENRICHED_RELATIONSHIPS = "ENRICHED_RELATIONSHIPS", ENRICHED_RELATIONSHIPS = "ENRICHED_RELATIONSHIPS",
TABLES_DEFAULT_ADMIN = "TABLES_DEFAULT_ADMIN",
} }
export interface TenantFeatureFlags { export interface TenantFeatureFlags {