mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-16 04:27:21 +00:00
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:
@@ -23,7 +23,7 @@ interface IconButtonProps extends ComponentPropsWithoutRef<"button"> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
|
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"];
|
const style = styles[size ?? "md"];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -36,6 +36,7 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
|
|||||||
className={twMerge(style.className, rest.className)}
|
className={twMerge(style.className, rest.className)}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
tabIndex={tabIndex}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Tooltip } from "@mantine/core";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { getBrowser } from "core/utils";
|
import { getBrowser } from "core/utils";
|
||||||
import type { Field } from "data/fields";
|
import type { Field } from "data/fields";
|
||||||
@@ -6,6 +7,7 @@ import {
|
|||||||
type ComponentPropsWithoutRef,
|
type ComponentPropsWithoutRef,
|
||||||
type ElementType,
|
type ElementType,
|
||||||
forwardRef,
|
forwardRef,
|
||||||
|
Fragment,
|
||||||
useEffect,
|
useEffect,
|
||||||
useImperativeHandle,
|
useImperativeHandle,
|
||||||
useRef,
|
useRef,
|
||||||
@@ -66,17 +68,22 @@ export const ErrorMessage: React.FC<React.ComponentProps<"div">> = ({ className,
|
|||||||
<div {...props} className={twMerge("text-sm text-red-500", className)} />
|
<div {...props} className={twMerge("text-sm text-red-500", className)} />
|
||||||
);
|
);
|
||||||
|
|
||||||
export const FieldLabel: React.FC<React.ComponentProps<"label"> & { field: Field }> = ({
|
export const FieldLabel: React.FC<
|
||||||
field,
|
React.ComponentProps<"label"> & { field: Field; label?: string }
|
||||||
...props
|
> = ({ field, label, ...props }) => {
|
||||||
}) => {
|
|
||||||
const desc = field.getDescription();
|
const desc = field.getDescription();
|
||||||
|
const Wrapper = desc
|
||||||
|
? (p) => <Tooltip position="right" label={desc} {...p} />
|
||||||
|
: (p) => <Fragment {...p} />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Label {...props} title={desc} className="flex flex-row gap-1 items-center">
|
<Wrapper>
|
||||||
{field.getLabel()}
|
<Label {...props} className="flex flex-row gap-1 items-center self-start">
|
||||||
{field.isRequired() && <span className="font-medium opacity-30">*</span>}
|
{label ?? field.getLabel()}
|
||||||
{desc && <TbInfoCircle className="opacity-50" />}
|
{field.isRequired() && <span className="font-medium opacity-30">*</span>}
|
||||||
</Label>
|
{desc && <TbInfoCircle className="opacity-50" />}
|
||||||
|
</Label>
|
||||||
|
</Wrapper>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -103,6 +110,12 @@ export const TypeAwareInput = forwardRef<HTMLInputElement, React.ComponentProps<
|
|||||||
if (props.type === "password") {
|
if (props.type === "password") {
|
||||||
return <Password {...props} ref={ref} />;
|
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} />;
|
return <Input {...props} ref={ref} />;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import * as Formy from "ui/components/form/Formy";
|
|||||||
import { useEvent } from "ui/hooks/use-event";
|
import { useEvent } from "ui/hooks/use-event";
|
||||||
import { ArrayField } from "./ArrayField";
|
import { ArrayField } from "./ArrayField";
|
||||||
import { FieldWrapper, type FieldwrapperProps } from "./FieldWrapper";
|
import { FieldWrapper, type FieldwrapperProps } from "./FieldWrapper";
|
||||||
import { useDerivedFieldContext, useFormValue } from "./Form";
|
import { useDerivedFieldContext, useFormValue, type DeriveFn } from "./Form";
|
||||||
import { ObjectField } from "./ObjectField";
|
import { ObjectField } from "./ObjectField";
|
||||||
import { coerce, firstDefined, isType, isTypeSchema } from "./utils";
|
import { coerce, firstDefined, isType, isTypeSchema } from "./utils";
|
||||||
|
|
||||||
@@ -108,13 +108,13 @@ const FieldImpl = ({
|
|||||||
return (
|
return (
|
||||||
<FieldWrapper name={name} required={required} schema={schema} fieldId={id} {...props}>
|
<FieldWrapper name={name} required={required} schema={schema} fieldId={id} {...props}>
|
||||||
<FieldComponent
|
<FieldComponent
|
||||||
|
placeholder={placeholder}
|
||||||
{...inputProps}
|
{...inputProps}
|
||||||
id={id}
|
id={id}
|
||||||
schema={schema}
|
schema={schema}
|
||||||
name={name}
|
name={name}
|
||||||
required={required}
|
required={required}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
placeholder={placeholder}
|
|
||||||
onChange={onChange ?? handleChange}
|
onChange={onChange ?? handleChange}
|
||||||
/>
|
/>
|
||||||
</FieldWrapper>
|
</FieldWrapper>
|
||||||
@@ -131,7 +131,7 @@ export type FieldComponentProps = {
|
|||||||
schema: JsonSchema;
|
schema: JsonSchema;
|
||||||
render?: (props: Omit<FieldComponentProps, "render">) => ReactNode;
|
render?: (props: Omit<FieldComponentProps, "render">) => ReactNode;
|
||||||
"data-testId"?: string;
|
"data-testId"?: string;
|
||||||
} & ComponentPropsWithoutRef<"input">;
|
} & ComponentPropsWithoutRef<"input"> & { [key: `data-${string}`]: string };
|
||||||
|
|
||||||
export const FieldComponent = ({ schema, render, ..._props }: FieldComponentProps) => {
|
export const FieldComponent = ({ schema, render, ..._props }: FieldComponentProps) => {
|
||||||
const { value } = useFormValue(_props.name!, { strict: true });
|
const { value } = useFormValue(_props.name!, { strict: true });
|
||||||
@@ -140,10 +140,11 @@ export const FieldComponent = ({ schema, render, ..._props }: FieldComponentProp
|
|||||||
..._props,
|
..._props,
|
||||||
// allow override
|
// allow override
|
||||||
value: typeof _props.value !== "undefined" ? _props.value : value,
|
value: typeof _props.value !== "undefined" ? _props.value : value,
|
||||||
placeholder:
|
placeholder: _props.placeholder
|
||||||
(_props.placeholder ?? typeof schema.default !== "undefined")
|
? _props.placeholder
|
||||||
? String(schema.default)
|
: typeof schema.default !== "undefined"
|
||||||
: "",
|
? String(schema.default)
|
||||||
|
: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
if (render) return render({ schema, ...props });
|
if (render) return render({ schema, ...props });
|
||||||
@@ -202,3 +203,28 @@ export const FieldComponent = ({ schema, render, ..._props }: FieldComponentProp
|
|||||||
|
|
||||||
return <Formy.TypeAwareInput {...props} value={props.value ?? ""} {...additional} />;
|
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 });
|
||||||
|
};
|
||||||
|
|||||||
@@ -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>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export function Form<
|
|||||||
validateOn?: "change" | "submit";
|
validateOn?: "change" | "submit";
|
||||||
initialOpts?: LibTemplateOptions;
|
initialOpts?: LibTemplateOptions;
|
||||||
ignoreKeys?: string[];
|
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>;
|
onSubmit?: (data: Data) => void | Promise<void>;
|
||||||
onInvalidSubmit?: (errors: JsonError[], data: Partial<Data>) => void;
|
onInvalidSubmit?: (errors: JsonError[], data: Partial<Data>) => void;
|
||||||
hiddenSubmit?: boolean;
|
hiddenSubmit?: boolean;
|
||||||
@@ -147,7 +147,7 @@ export function Form<
|
|||||||
setFormState((state) => {
|
setFormState((state) => {
|
||||||
const prev = state.data;
|
const prev = state.data;
|
||||||
const changed = immutable.set(prev, path, value);
|
const changed = immutable.set(prev, path, value);
|
||||||
onChange?.(changed, path, value);
|
onChange?.(changed, path, value, context);
|
||||||
return { ...state, data: changed };
|
return { ...state, data: changed };
|
||||||
});
|
});
|
||||||
check();
|
check();
|
||||||
@@ -157,7 +157,7 @@ export function Form<
|
|||||||
setFormState((state) => {
|
setFormState((state) => {
|
||||||
const prev = state.data;
|
const prev = state.data;
|
||||||
const changed = immutable.del(prev, path);
|
const changed = immutable.del(prev, path);
|
||||||
onChange?.(changed, path, undefined);
|
onChange?.(changed, path, undefined, context);
|
||||||
return { ...state, data: changed };
|
return { ...state, data: changed };
|
||||||
});
|
});
|
||||||
check();
|
check();
|
||||||
@@ -300,19 +300,20 @@ export function useFormStateSelector<Data = any, Reduced = Data>(
|
|||||||
return useAtom(selected)[0];
|
return useAtom(selected)[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
type SelectorFn<Ctx = any, Refined = any> = (state: Ctx) => Refined;
|
export type SelectorFn<Ctx = any, Refined = any> = (state: Ctx) => Refined;
|
||||||
|
export type DeriveFn<Data = any, Reduced = undefined> = SelectorFn<
|
||||||
|
FormContext<Data> & {
|
||||||
|
pointer: string;
|
||||||
|
required: boolean;
|
||||||
|
value: any;
|
||||||
|
path: string;
|
||||||
|
},
|
||||||
|
Reduced
|
||||||
|
>;
|
||||||
|
|
||||||
export function useDerivedFieldContext<Data = any, Reduced = undefined>(
|
export function useDerivedFieldContext<Data = any, Reduced = undefined>(
|
||||||
path,
|
path,
|
||||||
deriveFn?: SelectorFn<
|
deriveFn?: DeriveFn<Data, Reduced>,
|
||||||
FormContext<Data> & {
|
|
||||||
pointer: string;
|
|
||||||
required: boolean;
|
|
||||||
value: any;
|
|
||||||
path: string;
|
|
||||||
},
|
|
||||||
Reduced
|
|
||||||
>,
|
|
||||||
_schema?: JSONSchema,
|
_schema?: JSONSchema,
|
||||||
): FormContext<Data> & {
|
): FormContext<Data> & {
|
||||||
value: Reduced;
|
value: Reduced;
|
||||||
|
|||||||
@@ -1,3 +1,11 @@
|
|||||||
export { default as Admin, type BkndAdminProps } from "./Admin";
|
export { default as Admin, type BkndAdminProps } from "./Admin";
|
||||||
export * from "./components/form/json-schema-form";
|
export * from "./components/form/json-schema-form";
|
||||||
export { JsonViewer } from "./components/code/JsonViewer";
|
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";
|
||||||
|
|||||||
@@ -270,7 +270,7 @@ function EntityJsonFormField({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Formy.Group>
|
<Formy.Group>
|
||||||
<Formy.Label htmlFor={fieldApi.name}>{field.getLabel()}</Formy.Label>
|
<Formy.FieldLabel htmlFor={fieldApi.name} field={field} />
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<JsonEditor
|
<JsonEditor
|
||||||
id={fieldApi.name}
|
id={fieldApi.name}
|
||||||
@@ -306,7 +306,7 @@ function EntityEnumFormField({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Formy.Group>
|
<Formy.Group>
|
||||||
<Formy.Label htmlFor={fieldApi.name}>{field.getLabel()}</Formy.Label>
|
<Formy.FieldLabel htmlFor={fieldApi.name} field={field} />
|
||||||
<Formy.Select
|
<Formy.Select
|
||||||
name={fieldApi.name}
|
name={fieldApi.name}
|
||||||
id={fieldApi.name}
|
id={fieldApi.name}
|
||||||
|
|||||||
@@ -93,9 +93,11 @@ export function EntityRelationalFormField({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Formy.Group>
|
<Formy.Group>
|
||||||
<Formy.Label htmlFor={fieldApi.name}>
|
<Formy.FieldLabel
|
||||||
{field.getLabel({ fallback: false }) ?? entity.label}
|
htmlFor={fieldApi.name}
|
||||||
</Formy.Label>
|
field={field}
|
||||||
|
label={field.getLabel({ fallback: false }) ?? entity.label}
|
||||||
|
/>
|
||||||
<div
|
<div
|
||||||
data-disabled={fetching || disabled ? 1 : undefined}
|
data-disabled={fetching || disabled ? 1 : undefined}
|
||||||
className="data-[disabled]:opacity-70 data-[disabled]:pointer-events-none"
|
className="data-[disabled]:opacity-70 data-[disabled]:pointer-events-none"
|
||||||
|
|||||||
Reference in New Issue
Block a user