Files
eacp_webapp/src/Adminive/Backend_Fields.tsx
T
2026-08-09 18:58:48 +08:00

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>
);
}