added local range requests, fix mime type detection, improve uploading using FormData, correctly use mime type allow list, added previews for audio, pdf and text
350 lines
11 KiB
TypeScript
350 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 } 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.getFillableFields(action, true);
|
|
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}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (!field.isFillable(action)) {
|
|
return;
|
|
}
|
|
|
|
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}
|
|
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 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={value} @todo: test if better be omitted, so it fetches */
|
|
onClick={onClick}
|
|
entity={{
|
|
name: entity.name,
|
|
id: entityId,
|
|
field: field.name,
|
|
}}
|
|
query={{
|
|
sort: "-id",
|
|
limit: maxItems && maxItems > maxLimit ? maxLimit : maxItems,
|
|
}}
|
|
/>
|
|
</Formy.Group>
|
|
);
|
|
}
|
|
|
|
function EntityJsonFormField({
|
|
fieldApi,
|
|
field,
|
|
...props
|
|
}: { fieldApi: TFieldApi; field: JsonField }) {
|
|
const handleUpdate = useEvent((value: any) => {
|
|
fieldApi.handleChange(value);
|
|
});
|
|
|
|
return (
|
|
<Formy.Group>
|
|
<Formy.FieldLabel htmlFor={fieldApi.name} field={field} />
|
|
<Suspense>
|
|
<JsonEditor
|
|
id={fieldApi.name}
|
|
value={fieldApi.state.value}
|
|
onChange={handleUpdate}
|
|
onBlur={fieldApi.handleBlur}
|
|
minHeight="100"
|
|
/*required={field.isRequired()}*/
|
|
{...props}
|
|
/>
|
|
</Suspense>
|
|
{/*<Formy.Textarea
|
|
name={fieldApi.name}
|
|
id={fieldApi.name}
|
|
value={fieldApi.state.value}
|
|
onBlur={fieldApi.handleBlur}
|
|
onChange={handleUpdate}
|
|
required={field.isRequired()}
|
|
{...props}
|
|
/>*/}
|
|
</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) => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</Formy.Select>
|
|
</Formy.Group>
|
|
);
|
|
}
|