Files
bknd/app/src/ui/modules/data/components/EntityForm.tsx
T

341 lines
11 KiB
TypeScript

import type { PrimaryFieldType, Entity, EntityData, Field } from "bknd";
import type { FieldApi, ReactFormExtendedApi } from "@tanstack/react-form";
import type { JSX } from "react";
import { useStore } from "@tanstack/react-store";
import { MediaField } from "media/MediaField";
import { type ComponentProps, Suspense, useState } from "react";
import { JsonEditor } from "ui/components/code/JsonEditor";
import * as Formy from "ui/components/form/Formy";
import { FieldLabel } from "ui/components/form/Formy";
import { type FileState, Media } from "ui/elements";
import { useEvent } from "ui/hooks/use-event";
import { EntityJsonSchemaFormField } from "./fields/EntityJsonSchemaFormField";
import { EntityRelationalFormField } from "./fields/EntityRelationalFormField";
import ErrorBoundary from "ui/components/display/ErrorBoundary";
import { Alert } from "ui/components/display/Alert";
import { bkndModals } from "ui/modals";
import type { EnumField, JsonField, JsonSchemaField } from "data/fields";
import type { RelationField } from "data/relations";
import { useEntityAdminOptions } from "ui/options";
// simplify react form types 🤦
export type FormApi = ReactFormExtendedApi<any, any, any, any, any, any, any, any, any, any>;
// biome-ignore format: ...
export type TFieldApi = FieldApi<any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any>;
type EntityFormProps = {
entity: Entity;
entityId?: PrimaryFieldType;
data?: EntityData;
handleSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
fieldsDisabled: boolean;
Form: FormApi;
className?: string;
action: "create" | "update";
};
export function EntityForm({
entity,
entityId,
handleSubmit,
fieldsDisabled,
Form,
data,
className,
action,
}: EntityFormProps) {
const fields = entity.getFields({ virtual: true, primary: false });
const options = useEntityAdminOptions(entity, action);
return (
<form onSubmit={handleSubmit}>
<Form.Subscribe
selector={(state) => {
//console.log("state", state);
return [state.canSubmit, state.isValid, state.errors];
}}
children={([canSubmit, isValid, errors]) => {
//console.log("form:state", { canSubmit, isValid, errors });
return (
!isValid && (
<div className="flex flex-col dark:bg-red-950 bg-red-100 p-4">
<p>Form is invalid.</p>
{Array.isArray(errors) && (
<ul className="list-disc">
{errors.map((error, key) => (
<li className="ml-6" key={key}>
{error}
</li>
))}
</ul>
)}
</div>
)
);
}}
/>
<div className={className}>
{fields.map((field, key) => {
// @todo: tanstack form re-uses the state, causes issues navigating between entities w/ same fields
// media field needs to render outside of the form
// as its value is not stored in the form state
if (field instanceof MediaField) {
return (
<EntityMediaFormField
key={field.name + key}
entity={entity}
entityId={entityId}
formApi={Form}
field={field}
/>
);
}
const _key = `${entity.name}-${field.name}-${key}`;
return (
<ErrorBoundary
key={_key}
fallback={
<Alert.Exception className="font-mono">
Field error: {field.name}
</Alert.Exception>
}
>
<Form.Field
name={field.name}
children={(props) => {
const fieldOptions = options.field(field.name);
if (fieldOptions?.render) {
const custom = fieldOptions.render(action, entity, field, {
handleChange: props.handleChange,
value: props.state.value,
data,
});
if (custom) {
return custom;
}
}
if (field.isHidden(action)) return;
return (
<EntityFormField
field={field}
fieldApi={props}
disabled={fieldsDisabled || !field.isFillable(action)}
tabIndex={key + 1}
action={action}
data={data}
/>
);
}}
/>
</ErrorBoundary>
);
})}
</div>
<div className="hidden">
<button type="submit" />
</div>
</form>
);
}
type EntityFormFieldProps<
T extends keyof JSX.IntrinsicElements = "input",
F extends Field = Field,
> = ComponentProps<T> & {
fieldApi: TFieldApi;
field: F;
action: "create" | "update";
data?: EntityData;
};
type FormInputElement = HTMLInputElement | HTMLTextAreaElement;
function EntityFormField({ fieldApi, field, action, data, ...props }: EntityFormFieldProps) {
const handleUpdate = useEvent((e: React.ChangeEvent<FormInputElement> | any) => {
if (typeof e === "object" && "target" in e) {
//console.log("handleUpdate", e.target.value);
fieldApi.handleChange(e.target.value);
} else {
//console.log("handleUpdate-", e);
fieldApi.handleChange(e);
}
});
//const required = field.isRequired();
//const customFieldProps = { ...props, action, required };
if (field.type === "relation") {
return (
<EntityRelationalFormField
fieldApi={fieldApi}
field={field as RelationField}
data={data}
disabled={props.disabled}
tabIndex={props.tabIndex}
/>
);
}
if (field.type === "json") {
return <EntityJsonFormField fieldApi={fieldApi} field={field as JsonField} {...props} />;
}
if (field.type === "jsonschema") {
return (
<EntityJsonSchemaFormField
fieldApi={fieldApi}
field={field as JsonSchemaField}
data={data}
disabled={props.disabled}
tabIndex={props.tabIndex}
{...props}
/>
);
}
if (field.type === "enum") {
return <EntityEnumFormField fieldApi={fieldApi} field={field as EnumField} {...props} />;
}
const fieldElement = field.getHtmlConfig().element;
const fieldProps = field.getHtmlConfig().props as any;
const Element = Formy.formElementFactory(fieldElement ?? "input", fieldProps);
return (
<Formy.Group>
<FieldLabel htmlFor={fieldApi.name} field={field} />
<Element
{...fieldProps}
name={fieldApi.name}
id={fieldApi.name}
value={fieldApi.state.value}
onBlur={fieldApi.handleBlur}
onChange={handleUpdate}
required={field.isRequired()}
{...props}
/>
</Formy.Group>
);
}
function EntityMediaFormField({
formApi,
field,
entity,
entityId,
disabled,
}: {
formApi: FormApi;
field: MediaField;
entity: Entity;
entityId?: PrimaryFieldType;
disabled?: boolean;
}) {
if (!entityId) return;
const maxLimit = 50;
const maxItems = field.getMaxItems();
const isSingle = maxItems === 1;
const limit = isSingle ? 1 : maxItems && maxItems > maxLimit ? maxLimit : maxItems;
const value = useStore(formApi.store, (state) => {
const val = state.values[field.name];
if (!val || typeof val === "undefined") return [];
if (Array.isArray(val)) return val;
return [val];
});
const key = JSON.stringify([entity, entityId, field.name, value.length]);
const onClick = (file: FileState) => {
bkndModals.open(bkndModals.ids.mediaInfo, {
file,
});
};
return (
<Formy.Group>
<FieldLabel field={field} />
<Media.Dropzone
key={key}
maxItems={maxItems}
allowedMimeTypes={field.getAllowedMimeTypes()}
initialItems={isSingle ? value : undefined}
onClick={onClick}
entity={{
name: entity.name,
id: entityId,
field: field.name,
}}
query={{
sort: "-id",
limit,
}}
/>
</Formy.Group>
);
}
function EntityJsonFormField({
fieldApi,
field,
...props
}: { fieldApi: TFieldApi; field: JsonField }) {
const [error, setError] = useState<any>(null);
const handleUpdate = useEvent((value: any) => {
setError(null);
fieldApi.handleChange(value);
});
return (
<Formy.Group error={!!error}>
<Formy.FieldLabel htmlFor={fieldApi.name} field={field} />
<Suspense>
<JsonEditor
id={fieldApi.name}
value={fieldApi.state.value}
onChange={handleUpdate}
onBlur={fieldApi.handleBlur}
onInvalid={setError}
minHeight="100"
{...props}
/>
</Suspense>
</Formy.Group>
);
}
function EntityEnumFormField({
fieldApi,
field,
...props
}: { fieldApi: TFieldApi; field: EnumField }) {
const handleUpdate = useEvent((e: React.ChangeEvent<HTMLTextAreaElement>) => {
fieldApi.handleChange(e.target.value);
});
return (
<Formy.Group>
<Formy.FieldLabel htmlFor={fieldApi.name} field={field} />
<Formy.Select
name={fieldApi.name}
id={fieldApi.name}
value={fieldApi.state.value}
onBlur={fieldApi.handleBlur}
onChange={handleUpdate as any}
required={field.isRequired()}
{...props}
>
{!field.isRequired() && <option value="">- Select -</option>}
{field.getOptions().map((option, i) => (
<option key={`${option.value}-${i}`} value={option.value}>
{option.label}
</option>
))}
</Formy.Select>
</Formy.Group>
);
}