169 lines
7.4 KiB
TypeScript
169 lines
7.4 KiB
TypeScript
import React from "react";
|
|
import {Card, Checkbox, ColorPicker, Input, InputNumber, Select, Space} from "antd";
|
|
|
|
export type Backend_Field_Descriptor = {
|
|
name: string
|
|
value_type?: string
|
|
readable?: boolean
|
|
editable?: boolean
|
|
children?: Backend_Field_Descriptor[]
|
|
presentation?: {
|
|
label?: string
|
|
control?: string
|
|
options?: {label: string, value: unknown}[]
|
|
}
|
|
}
|
|
|
|
export type Backend_Form_Section = {
|
|
title?: string
|
|
object_path?: string
|
|
descriptor_path?: string
|
|
fields?: string[]
|
|
layout?: Backend_Field_Layout[]
|
|
}
|
|
|
|
export type Backend_Field_Layout = {
|
|
kind: "cards" | "horizontal" | "vertical" | "list" | "grid" | string
|
|
fields: string[]
|
|
columns?: number
|
|
minimum_width?: number
|
|
}
|
|
|
|
export type Backend_Disabled_Rule = {
|
|
field: string
|
|
disabled_when: {
|
|
field: string
|
|
equals: unknown
|
|
}
|
|
}
|
|
|
|
type Json_Object = Record<string, any>
|
|
|
|
export function get_path(root: Json_Object, path = ""): any {
|
|
if (!path) return root;
|
|
return path.split(".").reduce((value: any, key) => value?.[key], root);
|
|
}
|
|
|
|
export function fields_at_path(fields: Backend_Field_Descriptor[], path = ""): Backend_Field_Descriptor[] {
|
|
if (!path) return fields;
|
|
let current = fields;
|
|
for (const name of path.split(".")) {
|
|
const field = current.find(item => item.name === name);
|
|
if (!field) return [];
|
|
current = field.children || [];
|
|
}
|
|
return current;
|
|
}
|
|
|
|
function field_disabled(root: Json_Object, field: Backend_Field_Descriptor, rules: Backend_Disabled_Rule[]): boolean {
|
|
if (field.editable === false) return true;
|
|
const rule = rules.find(item => item.field === field.name);
|
|
return Boolean(rule && get_path(root, rule.disabled_when.field) === rule.disabled_when.equals);
|
|
}
|
|
|
|
function Backend_Field_Control(props: {
|
|
descriptor: Backend_Field_Descriptor
|
|
value: any
|
|
disabled: boolean
|
|
onChange: (value: any) => void
|
|
}) {
|
|
const {descriptor, value, disabled, onChange} = props;
|
|
const control = descriptor.presentation?.control || descriptor.value_type || "text";
|
|
const label = descriptor.presentation?.label || descriptor.name;
|
|
if (control === "boolean" || descriptor.value_type === "boolean") {
|
|
return <Checkbox checked={Boolean(value)} disabled={disabled}
|
|
onChange={event => onChange(event.target.checked)}>{label}</Checkbox>;
|
|
}
|
|
if (control === "color") {
|
|
return <Space><span>{label}</span><ColorPicker value={typeof value === "string" ? value : undefined}
|
|
disabled={disabled}
|
|
onChange={color => onChange(color.toHexString())}/></Space>;
|
|
}
|
|
if (control === "select") {
|
|
return <Select value={value} disabled={disabled} options={descriptor.presentation?.options || []}
|
|
onChange={onChange} style={{width: 260}}/>;
|
|
}
|
|
if (control === "number" || descriptor.value_type === "integer" || descriptor.value_type === "number") {
|
|
return <InputNumber addonBefore={label} value={value ?? null} disabled={disabled}
|
|
precision={descriptor.value_type === "integer" ? 0 : undefined}
|
|
onChange={onChange} style={{width: 280}}/>;
|
|
}
|
|
return <Input addonBefore={label} value={value == null ? "" : String(value)} readOnly={disabled}
|
|
onChange={event => onChange(event.target.value)} style={{width: 420, maxWidth: "100%"}}/>;
|
|
}
|
|
|
|
export function Backend_Fields(props: {
|
|
root: Json_Object
|
|
descriptor_fields: Backend_Field_Descriptor[]
|
|
section: Backend_Form_Section
|
|
rules?: Backend_Disabled_Rule[]
|
|
onChange?: (path: string, value: unknown, descriptor: Backend_Field_Descriptor) => void
|
|
}) {
|
|
const object = get_path(props.root, props.section.object_path) as Json_Object | undefined;
|
|
const descriptor_path = props.section.descriptor_path ?? props.section.object_path;
|
|
const available_fields = fields_at_path(props.descriptor_fields, descriptor_path);
|
|
const layout = props.section.layout?.length
|
|
? props.section.layout
|
|
: [{kind: "vertical", fields: props.section.fields || []}];
|
|
const arranged_names = layout.flatMap(group => group.fields);
|
|
const remaining_names = (props.section.fields || []).filter(name => !arranged_names.includes(name));
|
|
const groups = remaining_names.length > 0
|
|
? [...layout, {kind: "vertical", fields: remaining_names}]
|
|
: layout;
|
|
const fields_by_name = new Map(available_fields
|
|
.filter(field => field.readable !== false)
|
|
.map(field => [field.name, field]));
|
|
const update = (field: Backend_Field_Descriptor, value: unknown) => {
|
|
if (!object) return;
|
|
object[field.name] = value;
|
|
const path = [props.section.object_path, field.name].filter(Boolean).join(".");
|
|
props.onChange?.(path, value, field);
|
|
};
|
|
const render_control = (field: Backend_Field_Descriptor) => (
|
|
<Backend_Field_Control key={field.name} descriptor={field}
|
|
value={object?.[field.name]}
|
|
disabled={field_disabled(props.root, field, props.rules || [])}
|
|
onChange={value => update(field, value)}/>
|
|
);
|
|
const render_group = (group: Backend_Field_Layout, index: number) => {
|
|
const fields = group.fields.map(name => fields_by_name.get(name)).filter((field): field is Backend_Field_Descriptor => Boolean(field));
|
|
if (fields.length === 0) return null;
|
|
if (group.kind === "cards") {
|
|
return <div key={index} style={{width: "100%", minWidth: 0, contain: "inline-size"}}>
|
|
<div style={{display: "flex", flexWrap: "wrap", gap: 8, width: "100%"}}>
|
|
{fields.map(field => {
|
|
const active = Boolean(object?.[field.name]);
|
|
const disabled = field_disabled(props.root, field, props.rules || []);
|
|
return <Card key={field.name} size="small" styles={{body: {padding: "6px 10px"}}}
|
|
style={{
|
|
flex: "0 0 auto",
|
|
background: active ? "#e6f4ff" : "#fafafa",
|
|
borderColor: active ? "#91caff" : undefined,
|
|
opacity: disabled ? 0.62 : 1
|
|
}}>
|
|
<div style={{whiteSpace: "nowrap"}}>{render_control(field)}</div>
|
|
</Card>;
|
|
})}
|
|
</div>
|
|
</div>;
|
|
}
|
|
if (group.kind === "horizontal") {
|
|
return <div key={index} style={{display: "flex", flexWrap: "wrap", alignItems: "center", gap: 8}}>
|
|
{fields.map(render_control)}
|
|
</div>;
|
|
}
|
|
if (group.kind === "grid") {
|
|
const columns = group.columns ? `repeat(${group.columns}, minmax(0, 1fr))` : `repeat(auto-fit, minmax(${group.minimum_width || 280}px, 1fr))`;
|
|
return <div key={index} style={{display: "grid", gridTemplateColumns: columns, gap: 8, width: "100%"}}>
|
|
{fields.map(render_control)}
|
|
</div>;
|
|
}
|
|
return <Space key={index} direction="vertical" size={8}>{fields.map(render_control)}</Space>;
|
|
};
|
|
return (
|
|
<Space direction="vertical" size={8} style={{width: "100%"}}>
|
|
{groups.map(render_group)}
|
|
</Space>
|
|
);
|
|
}
|