Merge pull request #264 from bknd-io/fix/admin-ui-form-improvements

ui: improve form field components, add support for custom fields, export components
This commit is contained in:
dswbx
2025-09-18 10:04:19 +02:00
committed by GitHub
8 changed files with 87 additions and 36 deletions

View File

@@ -23,7 +23,7 @@ interface IconButtonProps extends ComponentPropsWithoutRef<"button"> {
}
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
({ Icon, size, variant = "ghost", onClick, disabled, iconProps, ...rest }, ref) => {
({ Icon, size, variant = "ghost", onClick, disabled, iconProps, tabIndex, ...rest }, ref) => {
const style = styles[size ?? "md"];
return (
@@ -36,6 +36,7 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
className={twMerge(style.className, rest.className)}
onClick={onClick}
disabled={disabled}
tabIndex={tabIndex}
/>
);
},

View File

@@ -1,3 +1,4 @@
import { Tooltip } from "@mantine/core";
import clsx from "clsx";
import { getBrowser } from "core/utils";
import type { Field } from "data/fields";
@@ -6,6 +7,7 @@ import {
type ComponentPropsWithoutRef,
type ElementType,
forwardRef,
Fragment,
useEffect,
useImperativeHandle,
useRef,
@@ -66,17 +68,22 @@ export const ErrorMessage: React.FC<React.ComponentProps<"div">> = ({ className,
<div {...props} className={twMerge("text-sm text-red-500", className)} />
);
export const FieldLabel: React.FC<React.ComponentProps<"label"> & { field: Field }> = ({
field,
...props
}) => {
export const FieldLabel: React.FC<
React.ComponentProps<"label"> & { field: Field; label?: string }
> = ({ field, label, ...props }) => {
const desc = field.getDescription();
const Wrapper = desc
? (p) => <Tooltip position="right" label={desc} {...p} />
: (p) => <Fragment {...p} />;
return (
<Label {...props} title={desc} className="flex flex-row gap-1 items-center">
{field.getLabel()}
<Wrapper>
<Label {...props} className="flex flex-row gap-1 items-center self-start">
{label ?? field.getLabel()}
{field.isRequired() && <span className="font-medium opacity-30">*</span>}
{desc && <TbInfoCircle className="opacity-50" />}
</Label>
</Wrapper>
);
};
@@ -103,6 +110,12 @@ export const TypeAwareInput = forwardRef<HTMLInputElement, React.ComponentProps<
if (props.type === "password") {
return <Password {...props} ref={ref} />;
}
if ("data-type" in props) {
if (props["data-type"] === "textarea") {
// @ts-ignore
return <Textarea {...props} ref={ref} />;
}
}
return <Input {...props} ref={ref} />;
},

View File

@@ -12,7 +12,7 @@ import * as Formy from "ui/components/form/Formy";
import { useEvent } from "ui/hooks/use-event";
import { ArrayField } from "./ArrayField";
import { FieldWrapper, type FieldwrapperProps } from "./FieldWrapper";
import { useDerivedFieldContext, useFormValue } from "./Form";
import { useDerivedFieldContext, useFormValue, type DeriveFn } from "./Form";
import { ObjectField } from "./ObjectField";
import { coerce, firstDefined, isType, isTypeSchema } from "./utils";
@@ -108,13 +108,13 @@ const FieldImpl = ({
return (
<FieldWrapper name={name} required={required} schema={schema} fieldId={id} {...props}>
<FieldComponent
placeholder={placeholder}
{...inputProps}
id={id}
schema={schema}
name={name}
required={required}
disabled={disabled}
placeholder={placeholder}
onChange={onChange ?? handleChange}
/>
</FieldWrapper>
@@ -131,7 +131,7 @@ export type FieldComponentProps = {
schema: JsonSchema;
render?: (props: Omit<FieldComponentProps, "render">) => ReactNode;
"data-testId"?: string;
} & ComponentPropsWithoutRef<"input">;
} & ComponentPropsWithoutRef<"input"> & { [key: `data-${string}`]: string };
export const FieldComponent = ({ schema, render, ..._props }: FieldComponentProps) => {
const { value } = useFormValue(_props.name!, { strict: true });
@@ -140,8 +140,9 @@ export const FieldComponent = ({ schema, render, ..._props }: FieldComponentProp
..._props,
// allow override
value: typeof _props.value !== "undefined" ? _props.value : value,
placeholder:
(_props.placeholder ?? typeof schema.default !== "undefined")
placeholder: _props.placeholder
? _props.placeholder
: typeof schema.default !== "undefined"
? String(schema.default)
: "",
};
@@ -202,3 +203,28 @@ export const FieldComponent = ({ schema, render, ..._props }: FieldComponentProp
return <Formy.TypeAwareInput {...props} value={props.value ?? ""} {...additional} />;
};
export type CustomFieldProps<Data = any> = {
path: string;
valueStrict?: boolean;
deriveFn?: DeriveFn<Data>;
children: (
props: Omit<ReturnType<typeof useDerivedFieldContext<Data>>, "setValue"> &
ReturnType<typeof useFormValue> & {
setValue: (value: any) => void;
_setValue: (path: string, value: any) => void;
},
) => React.ReactNode;
};
export const CustomField = <Data = any>({
path: _path,
valueStrict = true,
deriveFn,
children,
}: CustomFieldProps<Data>) => {
const ctx = useDerivedFieldContext(_path, deriveFn);
const $value = useFormValue(ctx.path, { strict: valueStrict });
const setValue = (value: any) => ctx.setValue(ctx.path, value);
return children({ ...ctx, ...$value, setValue, _setValue: ctx.setValue });
};

View File

@@ -130,7 +130,7 @@ const FieldDebug = ({
/>
)}
>
<IconButton Icon={IconBug} size="xs" className="opacity-30" />
<IconButton Icon={IconBug} size="xs" className="opacity-30" tabIndex={-1} />
</Popover>
</div>
);

View File

@@ -89,7 +89,7 @@ export function Form<
validateOn?: "change" | "submit";
initialOpts?: LibTemplateOptions;
ignoreKeys?: string[];
onChange?: (data: Partial<Data>, name: string, value: any) => void;
onChange?: (data: Partial<Data>, name: string, value: any, context: FormContext<Data>) => void;
onSubmit?: (data: Data) => void | Promise<void>;
onInvalidSubmit?: (errors: JsonError[], data: Partial<Data>) => void;
hiddenSubmit?: boolean;
@@ -147,7 +147,7 @@ export function Form<
setFormState((state) => {
const prev = state.data;
const changed = immutable.set(prev, path, value);
onChange?.(changed, path, value);
onChange?.(changed, path, value, context);
return { ...state, data: changed };
});
check();
@@ -157,7 +157,7 @@ export function Form<
setFormState((state) => {
const prev = state.data;
const changed = immutable.del(prev, path);
onChange?.(changed, path, undefined);
onChange?.(changed, path, undefined, context);
return { ...state, data: changed };
});
check();
@@ -300,11 +300,8 @@ export function useFormStateSelector<Data = any, Reduced = Data>(
return useAtom(selected)[0];
}
type SelectorFn<Ctx = any, Refined = any> = (state: Ctx) => Refined;
export function useDerivedFieldContext<Data = any, Reduced = undefined>(
path,
deriveFn?: SelectorFn<
export type SelectorFn<Ctx = any, Refined = any> = (state: Ctx) => Refined;
export type DeriveFn<Data = any, Reduced = undefined> = SelectorFn<
FormContext<Data> & {
pointer: string;
required: boolean;
@@ -312,7 +309,11 @@ export function useDerivedFieldContext<Data = any, Reduced = undefined>(
path: string;
},
Reduced
>,
>;
export function useDerivedFieldContext<Data = any, Reduced = undefined>(
path,
deriveFn?: DeriveFn<Data, Reduced>,
_schema?: JSONSchema,
): FormContext<Data> & {
value: Reduced;

View File

@@ -1,3 +1,11 @@
export { default as Admin, type BkndAdminProps } from "./Admin";
export * from "./components/form/json-schema-form";
export { JsonViewer } from "./components/code/JsonViewer";
// bknd admin ui
export { Button } from "./components/buttons/Button";
export { IconButton } from "./components/buttons/IconButton";
export * as Formy from "./components/form/Formy";
export * as AppShell from "./layouts/AppShell/AppShell";
export { Logo } from "./components/display/Logo";
export * as Form from "./components/form/json-schema-form";

View File

@@ -270,7 +270,7 @@ function EntityJsonFormField({
return (
<Formy.Group>
<Formy.Label htmlFor={fieldApi.name}>{field.getLabel()}</Formy.Label>
<Formy.FieldLabel htmlFor={fieldApi.name} field={field} />
<Suspense>
<JsonEditor
id={fieldApi.name}
@@ -306,7 +306,7 @@ function EntityEnumFormField({
return (
<Formy.Group>
<Formy.Label htmlFor={fieldApi.name}>{field.getLabel()}</Formy.Label>
<Formy.FieldLabel htmlFor={fieldApi.name} field={field} />
<Formy.Select
name={fieldApi.name}
id={fieldApi.name}

View File

@@ -93,9 +93,11 @@ export function EntityRelationalFormField({
return (
<Formy.Group>
<Formy.Label htmlFor={fieldApi.name}>
{field.getLabel({ fallback: false }) ?? entity.label}
</Formy.Label>
<Formy.FieldLabel
htmlFor={fieldApi.name}
field={field}
label={field.getLabel({ fallback: false }) ?? entity.label}
/>
<div
data-disabled={fetching || disabled ? 1 : undefined}
className="data-[disabled]:opacity-70 data-[disabled]:pointer-events-none"