make non-fillable fields visible but disabled in UI

This commit is contained in:
dswbx
2025-10-24 14:07:37 +02:00
parent 292e4595ea
commit f2aad9caac
11 changed files with 353 additions and 37 deletions

View File

@@ -7,7 +7,9 @@ import v7 from "./samples/v7.json";
import v8 from "./samples/v8.json"; import v8 from "./samples/v8.json";
import v8_2 from "./samples/v8-2.json"; import v8_2 from "./samples/v8-2.json";
import v9 from "./samples/v9.json"; import v9 from "./samples/v9.json";
import v10 from "./samples/v10.json";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
import { CURRENT_VERSION } from "modules/db/migrations";
beforeAll(() => disableConsoleLog()); beforeAll(() => disableConsoleLog());
afterAll(enableConsoleLog); afterAll(enableConsoleLog);
@@ -61,7 +63,7 @@ async function getRawConfig(
return await db return await db
.selectFrom("__bknd") .selectFrom("__bknd")
.selectAll() .selectAll()
.$if(!!opts?.version, (qb) => qb.where("version", "=", opts?.version)) .where("version", "=", opts?.version ?? CURRENT_VERSION)
.$if((opts?.types?.length ?? 0) > 0, (qb) => qb.where("type", "in", opts?.types)) .$if((opts?.types?.length ?? 0) > 0, (qb) => qb.where("type", "in", opts?.types))
.execute(); .execute();
} }
@@ -115,7 +117,6 @@ describe("Migrations", () => {
"^^s3.secret_access_key^^", "^^s3.secret_access_key^^",
); );
const [config, secrets] = (await getRawConfig(app, { const [config, secrets] = (await getRawConfig(app, {
version: 10,
types: ["config", "secrets"], types: ["config", "secrets"],
})) as any; })) as any;
@@ -129,4 +130,15 @@ describe("Migrations", () => {
"^^s3.secret_access_key^^", "^^s3.secret_access_key^^",
); );
}); });
test("migration from 10 to 11", async () => {
expect(v10.version).toBe(10);
expect(v10.data.entities.test.fields.title.config.fillable).toEqual(["read", "update"]);
const app = await createVersionedApp(v10);
expect(app.version()).toBeGreaterThan(10);
const [config] = (await getRawConfig(app, { types: ["config"] })) as any;
expect(config.json.data.entities.test.fields.title.config.fillable).toEqual(true);
});
}); });

View File

@@ -0,0 +1,270 @@
{
"version": 10,
"server": {
"cors": {
"origin": "*",
"allow_methods": ["GET", "POST", "PATCH", "PUT", "DELETE"],
"allow_headers": [
"Content-Type",
"Content-Length",
"Authorization",
"Accept"
],
"allow_credentials": true
},
"mcp": {
"enabled": true,
"path": "/api/system/mcp",
"logLevel": "warning"
}
},
"data": {
"basepath": "/api/data",
"default_primary_format": "integer",
"entities": {
"test": {
"type": "regular",
"fields": {
"id": {
"type": "primary",
"config": {
"format": "integer",
"fillable": false,
"required": false
}
},
"title": {
"type": "text",
"config": {
"required": false,
"fillable": ["read", "update"]
}
},
"status": {
"type": "enum",
"config": {
"default_value": "INACTIVE",
"options": {
"type": "strings",
"values": ["INACTIVE", "SUBSCRIBED", "UNSUBSCRIBED"]
},
"required": true,
"fillable": true
}
},
"created_at": {
"type": "date",
"config": {
"type": "datetime",
"required": true,
"fillable": true
}
},
"schema": {
"type": "jsonschema",
"config": {
"default_from_schema": true,
"schema": {
"type": "object",
"properties": {
"one": {
"type": "number",
"default": 1
}
}
},
"required": true,
"fillable": true
}
},
"text": {
"type": "text",
"config": {
"required": false,
"fillable": true
}
}
},
"config": {
"sort_field": "id",
"sort_dir": "asc"
}
},
"items": {
"type": "regular",
"fields": {
"id": {
"type": "primary",
"config": {
"format": "integer",
"fillable": false,
"required": false
}
},
"title": {
"type": "text",
"config": {
"required": false,
"fillable": true
}
}
},
"config": {
"sort_field": "id",
"sort_dir": "asc"
}
},
"media": {
"type": "system",
"fields": {
"id": {
"type": "primary",
"config": {
"format": "integer",
"fillable": false,
"required": false
}
},
"path": {
"type": "text",
"config": {
"required": true,
"fillable": true
}
},
"folder": {
"type": "boolean",
"config": {
"default_value": false,
"hidden": true,
"fillable": ["create"],
"required": false
}
},
"mime_type": {
"type": "text",
"config": {
"required": false,
"fillable": true
}
},
"size": {
"type": "number",
"config": {
"required": false,
"fillable": true
}
},
"scope": {
"type": "text",
"config": {
"hidden": true,
"fillable": ["create"],
"required": false
}
},
"etag": {
"type": "text",
"config": {
"required": false,
"fillable": true
}
},
"modified_at": {
"type": "date",
"config": {
"type": "datetime",
"required": false,
"fillable": true
}
},
"reference": {
"type": "text",
"config": {
"required": false,
"fillable": true
}
},
"entity_id": {
"type": "text",
"config": {
"required": false,
"fillable": true
}
},
"metadata": {
"type": "json",
"config": {
"required": false,
"fillable": true
}
}
},
"config": {
"sort_field": "id",
"sort_dir": "asc"
}
}
},
"relations": {},
"indices": {
"idx_unique_media_path": {
"entity": "media",
"fields": ["path"],
"unique": true
},
"idx_media_reference": {
"entity": "media",
"fields": ["reference"],
"unique": false
},
"idx_media_entity_id": {
"entity": "media",
"fields": ["entity_id"],
"unique": false
}
}
},
"auth": {
"enabled": false,
"basepath": "/api/auth",
"entity_name": "users",
"allow_register": true,
"jwt": {
"secret": "",
"alg": "HS256",
"fields": ["id", "email", "role"]
},
"cookie": {
"path": "/",
"sameSite": "lax",
"secure": true,
"httpOnly": true,
"expires": 604800,
"partitioned": false,
"renew": true,
"pathSuccess": "/",
"pathLoggedOut": "/"
},
"strategies": {
"password": {
"type": "password",
"enabled": true,
"config": {
"hashing": "sha256"
}
}
},
"guard": {
"enabled": false
},
"roles": {}
},
"media": {
"enabled": false
},
"flows": {
"basepath": "/api/flows",
"flows": {}
}
}

View File

@@ -136,8 +136,10 @@ export class Entity<
.map((field) => (alias ? `${alias}.${field.name} as ${field.name}` : field.name)); .map((field) => (alias ? `${alias}.${field.name} as ${field.name}` : field.name));
} }
getFillableFields(context?: TActionContext, include_virtual?: boolean): Field[] { getFillableFields(context?: "create" | "update", include_virtual?: boolean): Field[] {
return this.getFields(include_virtual).filter((field) => field.isFillable(context)); return this.getFields({ virtual: include_virtual }).filter((field) =>
field.isFillable(context),
);
} }
getRequiredFields(): Field[] { getRequiredFields(): Field[] {
@@ -189,9 +191,15 @@ export class Entity<
return this.fields.findIndex((field) => field.name === name) !== -1; return this.fields.findIndex((field) => field.name === name) !== -1;
} }
getFields(include_virtual: boolean = false): Field[] { getFields({
if (include_virtual) return this.fields; virtual = false,
return this.fields.filter((f) => !f.isVirtual()); primary = true,
}: { virtual?: boolean; primary?: boolean } = {}): Field[] {
return this.fields.filter((f) => {
if (!virtual && f.isVirtual()) return false;
if (!primary && f instanceof PrimaryField) return false;
return true;
});
} }
addField(field: Field) { addField(field: Field) {
@@ -231,7 +239,7 @@ export class Entity<
} }
} }
const fields = this.getFillableFields(context, false); const fields = this.getFillableFields(context as any, false);
if (options?.ignoreUnknown !== true) { if (options?.ignoreUnknown !== true) {
const field_names = fields.map((f) => f.name); const field_names = fields.map((f) => f.name);
@@ -275,7 +283,7 @@ export class Entity<
fields = this.getFillableFields(options.context); fields = this.getFillableFields(options.context);
break; break;
default: default:
fields = this.getFields(true); fields = this.getFields({ virtual: true });
} }
const _fields = Object.fromEntries(fields.map((field) => [field.name, field])); const _fields = Object.fromEntries(fields.map((field) => [field.name, field]));

View File

@@ -83,8 +83,10 @@ export class Mutator<
} }
// we should never get here, but just to be sure (why?) // we should never get here, but just to be sure (why?)
if (!field.isFillable(context)) { if (!field.isFillable(context as any)) {
throw new Error(`Field "${key}" is not fillable on entity "${entity.name}"`); throw new Error(
`Field "${key}" of entity "${entity.name}" is not fillable on context "${context}"`,
);
} }
// transform from field // transform from field

View File

@@ -26,11 +26,19 @@ export const baseFieldConfigSchema = s
.strictObject({ .strictObject({
label: s.string(), label: s.string(),
description: s.string(), description: s.string(),
required: s.boolean({ default: false }), required: s.boolean({ default: DEFAULT_REQUIRED }),
fillable: s.anyOf([ fillable: s.anyOf(
s.boolean({ title: "Boolean" }), [
s.array(s.string({ enum: ActionContext }), { title: "Context", uniqueItems: true }), s.boolean({ title: "Boolean" }),
]), s.array(s.string({ enum: ["create", "update"] }), {
title: "Context",
uniqueItems: true,
}),
],
{
default: DEFAULT_FILLABLE,
},
),
hidden: s.anyOf([ hidden: s.anyOf([
s.boolean({ title: "Boolean" }), s.boolean({ title: "Boolean" }),
// @todo: tmp workaround // @todo: tmp workaround
@@ -103,7 +111,7 @@ export abstract class Field<
return this.config?.default_value; return this.config?.default_value;
} }
isFillable(context?: TActionContext): boolean { isFillable(context?: "create" | "update"): boolean {
if (Array.isArray(this.config.fillable)) { if (Array.isArray(this.config.fillable)) {
return context ? this.config.fillable.includes(context) : DEFAULT_FILLABLE; return context ? this.config.fillable.includes(context) : DEFAULT_FILLABLE;
} }
@@ -165,7 +173,7 @@ export abstract class Field<
// @todo: add field level validation // @todo: add field level validation
isValid(value: any, context: TActionContext): boolean { isValid(value: any, context: TActionContext): boolean {
if (typeof value !== "undefined") { if (typeof value !== "undefined") {
return this.isFillable(context); return this.isFillable(context as any);
} else if (context === "create") { } else if (context === "create") {
return !this.isRequired(); return !this.isRequired();
} }

View File

@@ -99,6 +99,7 @@ export function fieldTestSuite(
const _config = { const _config = {
..._requiredConfig, ..._requiredConfig,
required: false, required: false,
fillable: true,
}; };
function fieldJson(field: Field) { function fieldJson(field: Field) {
@@ -116,10 +117,7 @@ export function fieldTestSuite(
expect(fieldJson(fillable)).toEqual({ expect(fieldJson(fillable)).toEqual({
type: noConfigField.type, type: noConfigField.type,
config: { config: _config,
..._config,
fillable: true,
},
}); });
expect(fieldJson(required)).toEqual({ expect(fieldJson(required)).toEqual({
@@ -150,7 +148,6 @@ export function fieldTestSuite(
type: requiredAndDefault.type, type: requiredAndDefault.type,
config: { config: {
..._config, ..._config,
fillable: true,
required: true, required: true,
default_value: config.defaultValue, default_value: config.defaultValue,
}, },

View File

@@ -77,7 +77,7 @@ export class SchemaManager {
} }
getIntrospectionFromEntity(entity: Entity): IntrospectedTable { getIntrospectionFromEntity(entity: Entity): IntrospectedTable {
const fields = entity.getFields(false); const fields = entity.getFields({ virtual: false });
const indices = this.em.getIndicesOf(entity); const indices = this.em.getIndicesOf(entity);
// this is intentionally setting values to defaults, like "nullable" and "default" // this is intentionally setting values to defaults, like "nullable" and "default"

View File

@@ -1,6 +1,7 @@
import { transformObject } from "bknd/utils"; import { transformObject } from "bknd/utils";
import type { Kysely } from "kysely"; import type { Kysely } from "kysely";
import { set } from "lodash-es"; import { set } from "lodash-es";
import type { InitialModuleConfigs } from "modules/ModuleManager";
export type MigrationContext = { export type MigrationContext = {
db: Kysely<any>; db: Kysely<any>;
@@ -107,6 +108,29 @@ export const migrations: Migration[] = [
return config; return config;
}, },
}, },
{
// change field.config.fillable to only "create" and "update"
version: 11,
up: async (config: InitialModuleConfigs) => {
const { data, ...rest } = config;
return {
...rest,
data: {
...data,
entities: transformObject(data?.entities ?? {}, (entity) => {
return {
...entity,
fields: transformObject(entity?.fields ?? {}, (field) => {
const fillable = field!.config?.fillable;
if (!fillable || typeof fillable === "boolean") return field;
return { ...field, config: { ...field!.config, fillable: true } };
}),
};
}),
},
};
},
},
]; ];
export const CURRENT_VERSION = migrations[migrations.length - 1]?.version ?? 0; export const CURRENT_VERSION = migrations[migrations.length - 1]?.version ?? 0;

View File

@@ -29,7 +29,7 @@ export const Group = <E extends ElementType = "div">({
<Tag <Tag
{...props} {...props}
className={twMerge( className={twMerge(
"flex flex-col gap-1.5", "flex flex-col gap-1.5 has-disabled:cursor-not-allowed",
as === "fieldset" && "border border-primary/10 p-3 rounded-md", as === "fieldset" && "border border-primary/10 p-3 rounded-md",
as === "fieldset" && error && "border-red-500", as === "fieldset" && error && "border-red-500",
error && "text-red-500", error && "text-red-500",
@@ -96,7 +96,7 @@ export const Input = forwardRef<HTMLInputElement, React.ComponentProps<"input">>
ref={ref} ref={ref}
className={twMerge( className={twMerge(
"bg-muted/40 h-11 rounded-md py-2.5 px-4 outline-none w-full disabled:cursor-not-allowed", "bg-muted/40 h-11 rounded-md py-2.5 px-4 outline-none w-full disabled:cursor-not-allowed",
disabledOrReadonly && "bg-muted/50 text-primary/50", disabledOrReadonly && "bg-muted/50 text-primary/50 cursor-not-allowed",
!disabledOrReadonly && !disabledOrReadonly &&
"focus:bg-muted focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all", "focus:bg-muted focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all",
props.className, props.className,
@@ -153,7 +153,7 @@ export const Textarea = forwardRef<HTMLTextAreaElement, React.ComponentProps<"te
{...props} {...props}
ref={ref} ref={ref}
className={twMerge( className={twMerge(
"bg-muted/40 min-h-11 rounded-md py-2.5 px-4 focus:bg-muted outline-none focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all disabled:bg-muted/50 disabled:text-primary/50", "bg-muted/40 min-h-11 rounded-md py-2.5 px-4 focus:bg-muted outline-none focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all disabled:bg-muted/50 disabled:text-primary/50 disabled:cursor-not-allowed",
props.className, props.className,
)} )}
/> />
@@ -213,7 +213,7 @@ export const BooleanInput = forwardRef<HTMLInputElement, React.ComponentProps<"i
{...props} {...props}
type="checkbox" type="checkbox"
ref={ref} ref={ref}
className="outline-none focus:outline-none focus:ring-2 focus:ring-zinc-500 transition-all disabled:opacity-70 scale-150 ml-1" className="outline-none focus:outline-none focus:ring-2 focus:ring-zinc-500 transition-all disabled:opacity-70 disabled:cursor-not-allowed scale-150 ml-1"
checked={checked} checked={checked}
onChange={handleCheck} onChange={handleCheck}
disabled={props.disabled} disabled={props.disabled}
@@ -259,7 +259,6 @@ export const Switch = forwardRef<
props.disabled && "opacity-50 !cursor-not-allowed", props.disabled && "opacity-50 !cursor-not-allowed",
)} )}
onCheckedChange={(bool) => { onCheckedChange={(bool) => {
console.log("setting", bool);
props.onChange?.({ target: { value: bool } }); props.onChange?.({ target: { value: bool } });
}} }}
{...(props as any)} {...(props as any)}
@@ -293,7 +292,7 @@ export const Select = forwardRef<
{...props} {...props}
ref={ref} ref={ref}
className={twMerge( className={twMerge(
"bg-muted/40 focus:bg-muted rounded-md py-2.5 px-4 outline-none focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all disabled:bg-muted/50 disabled:text-primary/50", "bg-muted/40 focus:bg-muted rounded-md py-2.5 px-4 outline-none focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all disabled:bg-muted/50 disabled:text-primary/50 disabled:cursor-not-allowed",
"appearance-none h-11 w-full", "appearance-none h-11 w-full",
!props.multiple && "border-r-8 border-r-transparent", !props.multiple && "border-r-8 border-r-transparent",
props.className, props.className,

View File

@@ -44,7 +44,7 @@ export function EntityForm({
className, className,
action, action,
}: EntityFormProps) { }: EntityFormProps) {
const fields = entity.getFillableFields(action, true); const fields = entity.getFields({ virtual: true, primary: false });
const options = useEntityAdminOptions(entity, action); const options = useEntityAdminOptions(entity, action);
return ( return (
@@ -92,10 +92,6 @@ export function EntityForm({
); );
} }
if (!field.isFillable(action)) {
return;
}
const _key = `${entity.name}-${field.name}-${key}`; const _key = `${entity.name}-${field.name}-${key}`;
return ( return (
@@ -127,7 +123,7 @@ export function EntityForm({
<EntityFormField <EntityFormField
field={field} field={field}
fieldApi={props} fieldApi={props}
disabled={fieldsDisabled} disabled={fieldsDisabled || !field.isFillable(action)}
tabIndex={key + 1} tabIndex={key + 1}
action={action} action={action}
data={data} data={data}

View File

@@ -17,7 +17,7 @@ export function useEntityForm({
}: EntityFormProps) { }: EntityFormProps) {
const data = initialData ?? {}; const data = initialData ?? {};
// @todo: check if virtual must be filtered // @todo: check if virtual must be filtered
const fields = entity.getFillableFields(action, true); const fields = entity.getFields({ virtual: true, primary: false });
// filter defaultValues to only contain fillable fields // filter defaultValues to only contain fillable fields
const defaultValues = getDefaultValues(fields, data); const defaultValues = getDefaultValues(fields, data);