Merge pull request #13844 from Budibase/BUDI-8282/dont-treat-display-column-as-required
Frontend validation
This commit is contained in:
commit
5fdd660647
7 changed files with 151 additions and 62 deletions
|
@ -3,6 +3,7 @@
|
||||||
import { ActionButton, Popover, Icon, notifications } from "@budibase/bbui"
|
import { ActionButton, Popover, Icon, notifications } from "@budibase/bbui"
|
||||||
import { getColumnIcon } from "../lib/utils"
|
import { getColumnIcon } from "../lib/utils"
|
||||||
import ToggleActionButtonGroup from "./ToggleActionButtonGroup.svelte"
|
import ToggleActionButtonGroup from "./ToggleActionButtonGroup.svelte"
|
||||||
|
import { helpers } from "@budibase/shared-core"
|
||||||
|
|
||||||
export let allowViewReadonlyColumns = false
|
export let allowViewReadonlyColumns = false
|
||||||
|
|
||||||
|
@ -11,7 +12,9 @@
|
||||||
let open = false
|
let open = false
|
||||||
let anchor
|
let anchor
|
||||||
|
|
||||||
$: restrictedColumns = $columns.filter(col => !col.visible || col.readonly)
|
$: allColumns = $stickyColumn ? [$stickyColumn, ...$columns] : $columns
|
||||||
|
|
||||||
|
$: restrictedColumns = allColumns.filter(col => !col.visible || col.readonly)
|
||||||
$: anyRestricted = restrictedColumns.length
|
$: anyRestricted = restrictedColumns.length
|
||||||
$: text = anyRestricted ? `Columns (${anyRestricted} restricted)` : "Columns"
|
$: text = anyRestricted ? `Columns (${anyRestricted} restricted)` : "Columns"
|
||||||
|
|
||||||
|
@ -40,36 +43,50 @@
|
||||||
HIDDEN: "hidden",
|
HIDDEN: "hidden",
|
||||||
}
|
}
|
||||||
|
|
||||||
const EDIT_OPTION = {
|
$: displayColumns = allColumns.map(c => {
|
||||||
icon: "Edit",
|
const isRequired = helpers.schema.isRequired(c.schema.constraints)
|
||||||
value: PERMISSION_OPTIONS.WRITABLE,
|
const isDisplayColumn = $stickyColumn === c
|
||||||
tooltip: "Writable",
|
|
||||||
}
|
|
||||||
$: READONLY_OPTION = {
|
|
||||||
icon: "Visibility",
|
|
||||||
value: PERMISSION_OPTIONS.READONLY,
|
|
||||||
tooltip: allowViewReadonlyColumns
|
|
||||||
? "Read only"
|
|
||||||
: "Read only (premium feature)",
|
|
||||||
disabled: !allowViewReadonlyColumns,
|
|
||||||
}
|
|
||||||
const HIDDEN_OPTION = {
|
|
||||||
icon: "VisibilityOff",
|
|
||||||
value: PERMISSION_OPTIONS.HIDDEN,
|
|
||||||
tooltip: "Hidden",
|
|
||||||
}
|
|
||||||
|
|
||||||
$: options =
|
const requiredTooltip = isRequired && "Required columns must be writable"
|
||||||
$datasource.type === "viewV2"
|
|
||||||
? [EDIT_OPTION, READONLY_OPTION, HIDDEN_OPTION]
|
const options = [
|
||||||
: [EDIT_OPTION, HIDDEN_OPTION]
|
{
|
||||||
|
icon: "Edit",
|
||||||
|
value: PERMISSION_OPTIONS.WRITABLE,
|
||||||
|
tooltip: requiredTooltip || "Writable",
|
||||||
|
disabled: isRequired,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
if ($datasource.type === "viewV2") {
|
||||||
|
options.push({
|
||||||
|
icon: "Visibility",
|
||||||
|
value: PERMISSION_OPTIONS.READONLY,
|
||||||
|
tooltip: allowViewReadonlyColumns
|
||||||
|
? requiredTooltip || "Read only"
|
||||||
|
: "Read only (premium feature)",
|
||||||
|
disabled: !allowViewReadonlyColumns || isRequired,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
options.push({
|
||||||
|
icon: "VisibilityOff",
|
||||||
|
value: PERMISSION_OPTIONS.HIDDEN,
|
||||||
|
disabled: isDisplayColumn || isRequired,
|
||||||
|
tooltip:
|
||||||
|
(isDisplayColumn && "Display column cannot be hidden") ||
|
||||||
|
requiredTooltip ||
|
||||||
|
"Hidden",
|
||||||
|
})
|
||||||
|
|
||||||
|
return { ...c, options }
|
||||||
|
})
|
||||||
|
|
||||||
function columnToPermissionOptions(column) {
|
function columnToPermissionOptions(column) {
|
||||||
if (!column.visible) {
|
if (!column.schema.visible) {
|
||||||
return PERMISSION_OPTIONS.HIDDEN
|
return PERMISSION_OPTIONS.HIDDEN
|
||||||
}
|
}
|
||||||
|
|
||||||
if (column.readonly) {
|
if (column.schema.readonly) {
|
||||||
return PERMISSION_OPTIONS.READONLY
|
return PERMISSION_OPTIONS.READONLY
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -93,19 +110,7 @@
|
||||||
<Popover bind:open {anchor} align="left">
|
<Popover bind:open {anchor} align="left">
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<div class="columns">
|
<div class="columns">
|
||||||
{#if $stickyColumn}
|
{#each displayColumns as column}
|
||||||
<div class="column">
|
|
||||||
<Icon size="S" name={getColumnIcon($stickyColumn)} />
|
|
||||||
{$stickyColumn.label}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ToggleActionButtonGroup
|
|
||||||
disabled
|
|
||||||
value={PERMISSION_OPTIONS.WRITABLE}
|
|
||||||
options={options.map(o => ({ ...o, disabled: true }))}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
{#each $columns as column}
|
|
||||||
<div class="column">
|
<div class="column">
|
||||||
<Icon size="S" name={getColumnIcon(column)} />
|
<Icon size="S" name={getColumnIcon(column)} />
|
||||||
{column.label}
|
{column.label}
|
||||||
|
@ -113,7 +118,7 @@
|
||||||
<ToggleActionButtonGroup
|
<ToggleActionButtonGroup
|
||||||
on:click={e => toggleColumn(column, e.detail)}
|
on:click={e => toggleColumn(column, e.detail)}
|
||||||
value={columnToPermissionOptions(column)}
|
value={columnToPermissionOptions(column)}
|
||||||
{options}
|
options={column.options}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -134,7 +134,7 @@ describe.each([
|
||||||
const newView: Required<CreateViewRequest> = {
|
const newView: Required<CreateViewRequest> = {
|
||||||
name: generator.name(),
|
name: generator.name(),
|
||||||
tableId: table._id!,
|
tableId: table._id!,
|
||||||
primaryDisplay: generator.word(),
|
primaryDisplay: "id",
|
||||||
query: [
|
query: [
|
||||||
{
|
{
|
||||||
operator: SearchFilterOperator.EQUAL,
|
operator: SearchFilterOperator.EQUAL,
|
||||||
|
@ -244,7 +244,7 @@ describe.each([
|
||||||
const newView: CreateViewRequest = {
|
const newView: CreateViewRequest = {
|
||||||
name: generator.name(),
|
name: generator.name(),
|
||||||
tableId: table._id!,
|
tableId: table._id!,
|
||||||
primaryDisplay: generator.word(),
|
primaryDisplay: "id",
|
||||||
schema: {
|
schema: {
|
||||||
id: { visible: true },
|
id: { visible: true },
|
||||||
Price: { visible: true },
|
Price: { visible: true },
|
||||||
|
@ -451,6 +451,78 @@ describe.each([
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("display fields must be visible", async () => {
|
||||||
|
const table = await config.api.table.save(
|
||||||
|
saveTableRequest({
|
||||||
|
schema: {
|
||||||
|
name: {
|
||||||
|
name: "name",
|
||||||
|
type: FieldType.STRING,
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
name: "description",
|
||||||
|
type: FieldType.STRING,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const newView: CreateViewRequest = {
|
||||||
|
name: generator.name(),
|
||||||
|
tableId: table._id!,
|
||||||
|
primaryDisplay: "name",
|
||||||
|
schema: {
|
||||||
|
id: { visible: true },
|
||||||
|
name: {
|
||||||
|
visible: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
await config.api.viewV2.create(newView, {
|
||||||
|
status: 400,
|
||||||
|
body: {
|
||||||
|
message: 'You can\'t hide "name" because it is the display column.',
|
||||||
|
status: 400,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("display fields can be readonly", async () => {
|
||||||
|
mocks.licenses.useViewReadonlyColumns()
|
||||||
|
const table = await config.api.table.save(
|
||||||
|
saveTableRequest({
|
||||||
|
schema: {
|
||||||
|
name: {
|
||||||
|
name: "name",
|
||||||
|
type: FieldType.STRING,
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
name: "description",
|
||||||
|
type: FieldType.STRING,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const newView: CreateViewRequest = {
|
||||||
|
name: generator.name(),
|
||||||
|
tableId: table._id!,
|
||||||
|
primaryDisplay: "name",
|
||||||
|
schema: {
|
||||||
|
id: { visible: true },
|
||||||
|
name: {
|
||||||
|
visible: true,
|
||||||
|
readonly: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
await config.api.viewV2.create(newView, {
|
||||||
|
status: 201,
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("update", () => {
|
describe("update", () => {
|
||||||
|
@ -499,7 +571,7 @@ describe.each([
|
||||||
id: view.id,
|
id: view.id,
|
||||||
tableId,
|
tableId,
|
||||||
name: view.name,
|
name: view.name,
|
||||||
primaryDisplay: generator.word(),
|
primaryDisplay: "Price",
|
||||||
query: [
|
query: [
|
||||||
{
|
{
|
||||||
operator: SearchFilterOperator.EQUAL,
|
operator: SearchFilterOperator.EQUAL,
|
||||||
|
|
|
@ -2,10 +2,11 @@ import { auth, permissions } from "@budibase/backend-core"
|
||||||
import { DataSourceOperation } from "../../../constants"
|
import { DataSourceOperation } from "../../../constants"
|
||||||
import { Table, WebhookActionType } from "@budibase/types"
|
import { Table, WebhookActionType } from "@budibase/types"
|
||||||
import Joi, { CustomValidator } from "joi"
|
import Joi, { CustomValidator } from "joi"
|
||||||
import { ValidSnippetNameRegex } from "@budibase/shared-core"
|
import { ValidSnippetNameRegex, helpers } from "@budibase/shared-core"
|
||||||
import { isRequired } from "../../../utilities/schema"
|
|
||||||
import sdk from "../../../sdk"
|
import sdk from "../../../sdk"
|
||||||
|
|
||||||
|
const { isRequired } = helpers.schema
|
||||||
|
|
||||||
const OPTIONAL_STRING = Joi.string().optional().allow(null).allow("")
|
const OPTIONAL_STRING = Joi.string().optional().allow(null).allow("")
|
||||||
const OPTIONAL_NUMBER = Joi.number().optional().allow(null)
|
const OPTIONAL_NUMBER = Joi.number().optional().allow(null)
|
||||||
const OPTIONAL_BOOLEAN = Joi.boolean().optional().allow(null)
|
const OPTIONAL_BOOLEAN = Joi.boolean().optional().allow(null)
|
||||||
|
|
|
@ -8,6 +8,7 @@ import {
|
||||||
} from "@budibase/types"
|
} from "@budibase/types"
|
||||||
import { HTTPError, db as dbCore } from "@budibase/backend-core"
|
import { HTTPError, db as dbCore } from "@budibase/backend-core"
|
||||||
import { features } from "@budibase/pro"
|
import { features } from "@budibase/pro"
|
||||||
|
import { helpers } from "@budibase/shared-core"
|
||||||
import { cloneDeep } from "lodash"
|
import { cloneDeep } from "lodash"
|
||||||
|
|
||||||
import * as utils from "../../../db/utils"
|
import * as utils from "../../../db/utils"
|
||||||
|
@ -16,7 +17,6 @@ import { isExternalTableID } from "../../../integrations/utils"
|
||||||
import * as internal from "./internal"
|
import * as internal from "./internal"
|
||||||
import * as external from "./external"
|
import * as external from "./external"
|
||||||
import sdk from "../../../sdk"
|
import sdk from "../../../sdk"
|
||||||
import { isRequired } from "../../../utilities/schema"
|
|
||||||
|
|
||||||
function pickApi(tableId: any) {
|
function pickApi(tableId: any) {
|
||||||
if (isExternalTableID(tableId)) {
|
if (isExternalTableID(tableId)) {
|
||||||
|
@ -37,9 +37,9 @@ export async function getEnriched(viewId: string): Promise<ViewV2Enriched> {
|
||||||
|
|
||||||
async function guardViewSchema(
|
async function guardViewSchema(
|
||||||
tableId: string,
|
tableId: string,
|
||||||
viewSchema?: Record<string, ViewUIFieldMetadata>
|
view: Omit<ViewV2, "id" | "version">
|
||||||
) {
|
) {
|
||||||
viewSchema ??= {}
|
const viewSchema = view.schema || {}
|
||||||
const table = await sdk.tables.getTable(tableId)
|
const table = await sdk.tables.getTable(tableId)
|
||||||
|
|
||||||
for (const field of Object.keys(viewSchema)) {
|
for (const field of Object.keys(viewSchema)) {
|
||||||
|
@ -69,7 +69,7 @@ async function guardViewSchema(
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const field of Object.values(table.schema)) {
|
for (const field of Object.values(table.schema)) {
|
||||||
if (!isRequired(field.constraints)) {
|
if (!helpers.schema.isRequired(field.constraints)) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -89,19 +89,30 @@ async function guardViewSchema(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (view.primaryDisplay) {
|
||||||
|
const viewSchemaField = viewSchema[view.primaryDisplay]
|
||||||
|
|
||||||
|
if (!viewSchemaField?.visible) {
|
||||||
|
throw new HTTPError(
|
||||||
|
`You can't hide "${view.primaryDisplay}" because it is the display column.`,
|
||||||
|
400
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function create(
|
export async function create(
|
||||||
tableId: string,
|
tableId: string,
|
||||||
viewRequest: Omit<ViewV2, "id" | "version">
|
viewRequest: Omit<ViewV2, "id" | "version">
|
||||||
): Promise<ViewV2> {
|
): Promise<ViewV2> {
|
||||||
await guardViewSchema(tableId, viewRequest.schema)
|
await guardViewSchema(tableId, viewRequest)
|
||||||
|
|
||||||
return pickApi(tableId).create(tableId, viewRequest)
|
return pickApi(tableId).create(tableId, viewRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function update(tableId: string, view: ViewV2): Promise<ViewV2> {
|
export async function update(tableId: string, view: ViewV2): Promise<ViewV2> {
|
||||||
await guardViewSchema(tableId, view.schema)
|
await guardViewSchema(tableId, view)
|
||||||
|
|
||||||
return pickApi(tableId).update(tableId, view)
|
return pickApi(tableId).update(tableId, view)
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,9 +4,8 @@ import {
|
||||||
TableSchema,
|
TableSchema,
|
||||||
FieldSchema,
|
FieldSchema,
|
||||||
Row,
|
Row,
|
||||||
FieldConstraints,
|
|
||||||
} from "@budibase/types"
|
} from "@budibase/types"
|
||||||
import { ValidColumnNameRegex, utils } from "@budibase/shared-core"
|
import { ValidColumnNameRegex, helpers, utils } from "@budibase/shared-core"
|
||||||
import { db } from "@budibase/backend-core"
|
import { db } from "@budibase/backend-core"
|
||||||
import { parseCsvExport } from "../api/controllers/view/exporters"
|
import { parseCsvExport } from "../api/controllers/view/exporters"
|
||||||
|
|
||||||
|
@ -41,15 +40,6 @@ export function isRows(rows: any): rows is Rows {
|
||||||
return Array.isArray(rows) && rows.every(row => typeof row === "object")
|
return Array.isArray(rows) && rows.every(row => typeof row === "object")
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isRequired(constraints: FieldConstraints | undefined) {
|
|
||||||
const isRequired =
|
|
||||||
!!constraints &&
|
|
||||||
((typeof constraints.presence !== "boolean" &&
|
|
||||||
constraints.presence?.allowEmpty === false) ||
|
|
||||||
constraints.presence === true)
|
|
||||||
return isRequired
|
|
||||||
}
|
|
||||||
|
|
||||||
export function validate(rows: Rows, schema: TableSchema): ValidationResults {
|
export function validate(rows: Rows, schema: TableSchema): ValidationResults {
|
||||||
const results: ValidationResults = {
|
const results: ValidationResults = {
|
||||||
schemaValidation: {},
|
schemaValidation: {},
|
||||||
|
@ -109,7 +99,7 @@ export function validate(rows: Rows, schema: TableSchema): ValidationResults {
|
||||||
columnData,
|
columnData,
|
||||||
columnType,
|
columnType,
|
||||||
columnSubtype,
|
columnSubtype,
|
||||||
isRequired(constraints)
|
helpers.schema.isRequired(constraints)
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
results.schemaValidation[columnName] = false
|
results.schemaValidation[columnName] = false
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import {
|
import {
|
||||||
BBReferenceFieldSubType,
|
BBReferenceFieldSubType,
|
||||||
|
FieldConstraints,
|
||||||
FieldSchema,
|
FieldSchema,
|
||||||
FieldType,
|
FieldType,
|
||||||
} from "@budibase/types"
|
} from "@budibase/types"
|
||||||
|
@ -16,3 +17,12 @@ export function isDeprecatedSingleUserColumn(
|
||||||
schema.constraints?.type !== "array"
|
schema.constraints?.type !== "array"
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isRequired(constraints: FieldConstraints | undefined) {
|
||||||
|
const isRequired =
|
||||||
|
!!constraints &&
|
||||||
|
((typeof constraints.presence !== "boolean" &&
|
||||||
|
constraints.presence?.allowEmpty === false) ||
|
||||||
|
constraints.presence === true)
|
||||||
|
return isRequired
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in a new issue