Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions frontend/bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"dependencies": {
"@fontsource/geist-mono": "^5.3.0",
"@fontsource/inter": "^5.3.0",
"@hookform/resolvers": "^7.0.3",
"@monaco-editor/react": "^4.7.0",
"@react-awesome-query-builder/ui": "^6.7.0-alpha.0",
"@react-pdf-viewer/core": "^3.12.0",
Expand Down Expand Up @@ -65,6 +66,7 @@
"tailwind-merge": "^3.6.0",
"tailwindcss-animate": "^1.0.7",
"uuid": "^9.0.1",
"zod": "^4.6.7",
"zustand": "^4.3.8"
},
"scripts": {
Expand Down
39 changes: 39 additions & 0 deletions frontend/src/v1/components/patterns/form/FormField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { Field, type FieldProps } from "@v1/components/ui/form";
import { type JSX, type ReactNode, useEffect } from "react";
import {
type ControllerRenderProps,
type FieldPath,
type FieldValues,
type UseFormReturn,
useController,
} from "react-hook-form";

export type FormFieldProps<
T extends FieldValues,
N extends FieldPath<T>,
> = Omit<FieldProps, "children" | "error" | "invalid"> &
Readonly<{
form: UseFormReturn<T, unknown, FieldValues>;
name: N;
/** Renders the control from the field's value, handlers and ref. */
children: (field: ControllerRenderProps<T, N>) => ReactNode;
}>;

/** A Field bound to one form value: its error shows under the control and marks it invalid. */
export function FormField<T extends FieldValues, N extends FieldPath<T>>({
form,
name,
children,
...fieldProps
}: FormFieldProps<T, N>): JSX.Element {
const { field, fieldState } = useController({ control: form.control, name });
// A hidden field cannot show its error, so drop it; a submit validates again
// and an error that still stands shows in the footer.
const { clearErrors } = form;
useEffect(() => () => clearErrors(name), [clearErrors, name]);
return (
<Field {...fieldProps} error={fieldState.error?.message}>
{children(field)}
</Field>
);
}
111 changes: 111 additions & 0 deletions frontend/src/v1/components/patterns/form/FormFooter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { ErrorText } from "@v1/components/ui/form";
import type { JSX } from "react";
import {
type Control,
type FieldErrors,
type FieldValues,
get,
type UseFormReturn,
useFormState,
} from "react-hook-form";
import { isMounted, SERVER_ERROR } from "./use-app-form";

/** Leaves (true values) in react-hook-form's nested dirtyFields. */
function countDirty(node: unknown): number {
if (node === true) {
return 1;
}
if (node && typeof node === "object") {
return Object.values(node).reduce<number>((n, v) => n + countDirty(v), 0);
}
return 0;
}

/** Every field error as [path, message]; a schema refine with no path sits at "". */
function errorLeaves(node: unknown, path = ""): [string, string][] {
if (!node || typeof node !== "object") {
return [];
}
const error = node as { type?: unknown; message?: unknown };
if (typeof error.type === "string") {
return [[path, typeof error.message === "string" ? error.message : ""]];
}
return Object.entries(node).flatMap(([key, child]) =>
errorLeaves(child, path ? `${path}.${key}` : key),
);
}

function status(
submitting: boolean,
dirty: number,
fieldErrors: boolean,
): string {
if (submitting) {
return "Saving";
}
if (dirty === 0) {
return "No changes";
}
if (fieldErrors) {
return "Cannot save";
}
return `${dirty} unsaved change${dirty === 1 ? "" : "s"}`;
}

const button =
"ui:h-8 ui:cursor-pointer ui:rounded-md ui:px-3 ui:text-body ui:disabled:pointer-events-none ui:disabled:opacity-50 ui:focus-visible:focus-ring";

/**
* The save bar: what changed, Save changes (submits the form) and Discard
* (back to the form's default values). Errors with no field on screen, and the
* server's form-level error, show here while the form has changes; they do not
* disable Save, since only a submit or Discard clears them.
*/
// ponytail: plain buttons; swap to the v1 Button when it exists.
export function FormFooter<T extends FieldValues>({
form,
}: Readonly<{ form: UseFormReturn<T, unknown, FieldValues> }>): JSX.Element {
const { dirtyFields, errors, isSubmitting } = useFormState({
control: form.control,
});
const dirty = countDirty(dirtyFields);
const control = form.control as Control<FieldValues>;
const { root: _root, ...rest } = errors as FieldErrors<FieldValues>;
const leaves = errorLeaves(rest);
const hasFieldErrors = leaves.some(([path]) => isMounted(control, path));
const formLevel = [
...leaves.filter(([path]) => !isMounted(control, path)).map(([, m]) => m),
get(errors, SERVER_ERROR)?.message,
].filter((m): m is string => Boolean(m));
const text = status(isSubmitting, dirty, hasFieldErrors);
return (
<div
data-slot="form-footer"
className="ui:flex ui:items-center ui:gap-3 ui:border-t ui:pt-3"
>
<div className="ui:grid ui:flex-1 ui:gap-1">
<output className="ui:text-caption ui:text-muted-foreground">
{text}
</output>
{dirty > 0 && formLevel.length > 0 ? (
<ErrorText>{formLevel.join(" ")}</ErrorText>
) : null}
</div>
<button
type="button"
className={`${button} ui:border ui:bg-transparent`}
disabled={dirty === 0 || isSubmitting}
onClick={() => form.reset()}
>
Discard
</button>
<button
type="submit"
className={`${button} ui:border-0 ui:bg-primary ui:text-primary-foreground`}
disabled={dirty === 0 || isSubmitting || hasFieldErrors}
>
Save changes
</button>
</div>
);
}
Loading
Loading