mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-15 20:17:22 +00:00
make non-fillable fields visible but disabled in UI
This commit is contained in:
@@ -7,7 +7,9 @@ import v7 from "./samples/v7.json";
|
||||
import v8 from "./samples/v8.json";
|
||||
import v8_2 from "./samples/v8-2.json";
|
||||
import v9 from "./samples/v9.json";
|
||||
import v10 from "./samples/v10.json";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||
import { CURRENT_VERSION } from "modules/db/migrations";
|
||||
|
||||
beforeAll(() => disableConsoleLog());
|
||||
afterAll(enableConsoleLog);
|
||||
@@ -61,7 +63,7 @@ async function getRawConfig(
|
||||
return await db
|
||||
.selectFrom("__bknd")
|
||||
.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))
|
||||
.execute();
|
||||
}
|
||||
@@ -115,7 +117,6 @@ describe("Migrations", () => {
|
||||
"^^s3.secret_access_key^^",
|
||||
);
|
||||
const [config, secrets] = (await getRawConfig(app, {
|
||||
version: 10,
|
||||
types: ["config", "secrets"],
|
||||
})) as any;
|
||||
|
||||
@@ -129,4 +130,15 @@ describe("Migrations", () => {
|
||||
"^^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);
|
||||
});
|
||||
});
|
||||
|
||||
270
app/__test__/modules/migrations/samples/v10.json
Normal file
270
app/__test__/modules/migrations/samples/v10.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
@@ -136,8 +136,10 @@ export class Entity<
|
||||
.map((field) => (alias ? `${alias}.${field.name} as ${field.name}` : field.name));
|
||||
}
|
||||
|
||||
getFillableFields(context?: TActionContext, include_virtual?: boolean): Field[] {
|
||||
return this.getFields(include_virtual).filter((field) => field.isFillable(context));
|
||||
getFillableFields(context?: "create" | "update", include_virtual?: boolean): Field[] {
|
||||
return this.getFields({ virtual: include_virtual }).filter((field) =>
|
||||
field.isFillable(context),
|
||||
);
|
||||
}
|
||||
|
||||
getRequiredFields(): Field[] {
|
||||
@@ -189,9 +191,15 @@ export class Entity<
|
||||
return this.fields.findIndex((field) => field.name === name) !== -1;
|
||||
}
|
||||
|
||||
getFields(include_virtual: boolean = false): Field[] {
|
||||
if (include_virtual) return this.fields;
|
||||
return this.fields.filter((f) => !f.isVirtual());
|
||||
getFields({
|
||||
virtual = false,
|
||||
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) {
|
||||
@@ -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) {
|
||||
const field_names = fields.map((f) => f.name);
|
||||
@@ -275,7 +283,7 @@ export class Entity<
|
||||
fields = this.getFillableFields(options.context);
|
||||
break;
|
||||
default:
|
||||
fields = this.getFields(true);
|
||||
fields = this.getFields({ virtual: true });
|
||||
}
|
||||
|
||||
const _fields = Object.fromEntries(fields.map((field) => [field.name, field]));
|
||||
|
||||
@@ -83,8 +83,10 @@ export class Mutator<
|
||||
}
|
||||
|
||||
// we should never get here, but just to be sure (why?)
|
||||
if (!field.isFillable(context)) {
|
||||
throw new Error(`Field "${key}" is not fillable on entity "${entity.name}"`);
|
||||
if (!field.isFillable(context as any)) {
|
||||
throw new Error(
|
||||
`Field "${key}" of entity "${entity.name}" is not fillable on context "${context}"`,
|
||||
);
|
||||
}
|
||||
|
||||
// transform from field
|
||||
|
||||
@@ -26,11 +26,19 @@ export const baseFieldConfigSchema = s
|
||||
.strictObject({
|
||||
label: s.string(),
|
||||
description: s.string(),
|
||||
required: s.boolean({ default: false }),
|
||||
fillable: s.anyOf([
|
||||
s.boolean({ title: "Boolean" }),
|
||||
s.array(s.string({ enum: ActionContext }), { title: "Context", uniqueItems: true }),
|
||||
]),
|
||||
required: s.boolean({ default: DEFAULT_REQUIRED }),
|
||||
fillable: s.anyOf(
|
||||
[
|
||||
s.boolean({ title: "Boolean" }),
|
||||
s.array(s.string({ enum: ["create", "update"] }), {
|
||||
title: "Context",
|
||||
uniqueItems: true,
|
||||
}),
|
||||
],
|
||||
{
|
||||
default: DEFAULT_FILLABLE,
|
||||
},
|
||||
),
|
||||
hidden: s.anyOf([
|
||||
s.boolean({ title: "Boolean" }),
|
||||
// @todo: tmp workaround
|
||||
@@ -103,7 +111,7 @@ export abstract class Field<
|
||||
return this.config?.default_value;
|
||||
}
|
||||
|
||||
isFillable(context?: TActionContext): boolean {
|
||||
isFillable(context?: "create" | "update"): boolean {
|
||||
if (Array.isArray(this.config.fillable)) {
|
||||
return context ? this.config.fillable.includes(context) : DEFAULT_FILLABLE;
|
||||
}
|
||||
@@ -165,7 +173,7 @@ export abstract class Field<
|
||||
// @todo: add field level validation
|
||||
isValid(value: any, context: TActionContext): boolean {
|
||||
if (typeof value !== "undefined") {
|
||||
return this.isFillable(context);
|
||||
return this.isFillable(context as any);
|
||||
} else if (context === "create") {
|
||||
return !this.isRequired();
|
||||
}
|
||||
|
||||
@@ -99,6 +99,7 @@ export function fieldTestSuite(
|
||||
const _config = {
|
||||
..._requiredConfig,
|
||||
required: false,
|
||||
fillable: true,
|
||||
};
|
||||
|
||||
function fieldJson(field: Field) {
|
||||
@@ -116,10 +117,7 @@ export function fieldTestSuite(
|
||||
|
||||
expect(fieldJson(fillable)).toEqual({
|
||||
type: noConfigField.type,
|
||||
config: {
|
||||
..._config,
|
||||
fillable: true,
|
||||
},
|
||||
config: _config,
|
||||
});
|
||||
|
||||
expect(fieldJson(required)).toEqual({
|
||||
@@ -150,7 +148,6 @@ export function fieldTestSuite(
|
||||
type: requiredAndDefault.type,
|
||||
config: {
|
||||
..._config,
|
||||
fillable: true,
|
||||
required: true,
|
||||
default_value: config.defaultValue,
|
||||
},
|
||||
|
||||
@@ -77,7 +77,7 @@ export class SchemaManager {
|
||||
}
|
||||
|
||||
getIntrospectionFromEntity(entity: Entity): IntrospectedTable {
|
||||
const fields = entity.getFields(false);
|
||||
const fields = entity.getFields({ virtual: false });
|
||||
const indices = this.em.getIndicesOf(entity);
|
||||
|
||||
// this is intentionally setting values to defaults, like "nullable" and "default"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { transformObject } from "bknd/utils";
|
||||
import type { Kysely } from "kysely";
|
||||
import { set } from "lodash-es";
|
||||
import type { InitialModuleConfigs } from "modules/ModuleManager";
|
||||
|
||||
export type MigrationContext = {
|
||||
db: Kysely<any>;
|
||||
@@ -107,6 +108,29 @@ export const migrations: Migration[] = [
|
||||
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;
|
||||
|
||||
@@ -29,7 +29,7 @@ export const Group = <E extends ElementType = "div">({
|
||||
<Tag
|
||||
{...props}
|
||||
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" && error && "border-red-500",
|
||||
error && "text-red-500",
|
||||
@@ -96,7 +96,7 @@ export const Input = forwardRef<HTMLInputElement, React.ComponentProps<"input">>
|
||||
ref={ref}
|
||||
className={twMerge(
|
||||
"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 &&
|
||||
"focus:bg-muted focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all",
|
||||
props.className,
|
||||
@@ -153,7 +153,7 @@ export const Textarea = forwardRef<HTMLTextAreaElement, React.ComponentProps<"te
|
||||
{...props}
|
||||
ref={ref}
|
||||
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,
|
||||
)}
|
||||
/>
|
||||
@@ -213,7 +213,7 @@ export const BooleanInput = forwardRef<HTMLInputElement, React.ComponentProps<"i
|
||||
{...props}
|
||||
type="checkbox"
|
||||
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}
|
||||
onChange={handleCheck}
|
||||
disabled={props.disabled}
|
||||
@@ -259,7 +259,6 @@ export const Switch = forwardRef<
|
||||
props.disabled && "opacity-50 !cursor-not-allowed",
|
||||
)}
|
||||
onCheckedChange={(bool) => {
|
||||
console.log("setting", bool);
|
||||
props.onChange?.({ target: { value: bool } });
|
||||
}}
|
||||
{...(props as any)}
|
||||
@@ -293,7 +292,7 @@ export const Select = forwardRef<
|
||||
{...props}
|
||||
ref={ref}
|
||||
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",
|
||||
!props.multiple && "border-r-8 border-r-transparent",
|
||||
props.className,
|
||||
|
||||
@@ -44,7 +44,7 @@ export function EntityForm({
|
||||
className,
|
||||
action,
|
||||
}: EntityFormProps) {
|
||||
const fields = entity.getFillableFields(action, true);
|
||||
const fields = entity.getFields({ virtual: true, primary: false });
|
||||
const options = useEntityAdminOptions(entity, action);
|
||||
|
||||
return (
|
||||
@@ -92,10 +92,6 @@ export function EntityForm({
|
||||
);
|
||||
}
|
||||
|
||||
if (!field.isFillable(action)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const _key = `${entity.name}-${field.name}-${key}`;
|
||||
|
||||
return (
|
||||
@@ -127,7 +123,7 @@ export function EntityForm({
|
||||
<EntityFormField
|
||||
field={field}
|
||||
fieldApi={props}
|
||||
disabled={fieldsDisabled}
|
||||
disabled={fieldsDisabled || !field.isFillable(action)}
|
||||
tabIndex={key + 1}
|
||||
action={action}
|
||||
data={data}
|
||||
|
||||
@@ -17,7 +17,7 @@ export function useEntityForm({
|
||||
}: EntityFormProps) {
|
||||
const data = initialData ?? {};
|
||||
// @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
|
||||
const defaultValues = getDefaultValues(fields, data);
|
||||
|
||||
Reference in New Issue
Block a user