v3: code quality and visual consistency pass (refactored all forms across the app), fixed filename-related upload bug
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 47s
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 47s
This commit is contained in:
56
src/components/form/FileField.tsx
Normal file
56
src/components/form/FileField.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
Controller,
|
||||
type FieldValues,
|
||||
type Path,
|
||||
type RegisterOptions,
|
||||
useFormContext,
|
||||
} from "react-hook-form";
|
||||
|
||||
import { FileDropZone } from "../FileDropZone.tsx";
|
||||
|
||||
interface FileFieldProps<T extends FieldValues> {
|
||||
name: Path<T>;
|
||||
label?: string;
|
||||
hint?: string;
|
||||
showLimit?: boolean;
|
||||
disabled?: boolean;
|
||||
rules?: RegisterOptions<T, Path<T>>;
|
||||
}
|
||||
|
||||
/** `Controller`-wrapped {@link FileDropZone} bound to react-hook-form. */
|
||||
export function FileField<T extends FieldValues>({
|
||||
name,
|
||||
label,
|
||||
hint,
|
||||
showLimit,
|
||||
disabled,
|
||||
rules,
|
||||
}: FileFieldProps<T>) {
|
||||
const { control, formState: { errors } } = useFormContext<T>();
|
||||
const error = errors[name];
|
||||
|
||||
return (
|
||||
<div className="form-field">
|
||||
<Controller
|
||||
name={name}
|
||||
control={control}
|
||||
rules={rules}
|
||||
render={({ field }) => (
|
||||
<FileDropZone
|
||||
file={(field.value as File | null) ?? null}
|
||||
onChange={field.onChange}
|
||||
label={label}
|
||||
hint={hint}
|
||||
showLimit={showLimit}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{error?.message && (
|
||||
<span className="form-hint form-hint--error">
|
||||
{String(error.message)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
39
src/components/form/FormActions.tsx
Normal file
39
src/components/form/FormActions.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
|
||||
interface FormActionsProps {
|
||||
children: ReactNode;
|
||||
onCancel?: () => void;
|
||||
cancelLabel?: ReactNode;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Right-aligned form action row: an optional Cancel button followed by the
|
||||
* submit control(s). Standardizes the `.form-actions > .form-actions-right`
|
||||
* markup duplicated across forms.
|
||||
*/
|
||||
export function FormActions({
|
||||
children,
|
||||
onCancel,
|
||||
cancelLabel,
|
||||
disabled,
|
||||
}: FormActionsProps) {
|
||||
return (
|
||||
<div className="form-actions">
|
||||
<div className="form-actions-right">
|
||||
{onCancel && (
|
||||
<button
|
||||
type="button"
|
||||
className="form-cancel"
|
||||
onClick={onCancel}
|
||||
disabled={disabled}
|
||||
>
|
||||
{cancelLabel ?? <Trans>Cancel</Trans>}
|
||||
</button>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
src/components/form/FormError.tsx
Normal file
16
src/components/form/FormError.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useFormState } from "react-hook-form";
|
||||
|
||||
import { ErrorCard } from "../ErrorCard.tsx";
|
||||
|
||||
/**
|
||||
* Renders the form's root error (set by {@link useApiForm}'s `submit` on a
|
||||
* thrown/failed request) through the shared {@link ErrorCard}. One consistent
|
||||
* place for server/submit errors across every form.
|
||||
*/
|
||||
export function FormError({ title }: { title?: string }) {
|
||||
const { errors } = useFormState();
|
||||
const message = errors.root?.message;
|
||||
if (!message) return null;
|
||||
return <ErrorCard title={title ?? t`Something went wrong`} message={message} />;
|
||||
}
|
||||
81
src/components/form/RichTextField.tsx
Normal file
81
src/components/form/RichTextField.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { useId } from "react";
|
||||
import {
|
||||
Controller,
|
||||
type FieldValues,
|
||||
type Path,
|
||||
type RegisterOptions,
|
||||
useFormContext,
|
||||
} from "react-hook-form";
|
||||
|
||||
import { TextEditor, type TextEditorHandle } from "../TextEditor.tsx";
|
||||
|
||||
interface RichTextFieldProps<T extends FieldValues> {
|
||||
name: Path<T>;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
rows?: number;
|
||||
maxLength?: number;
|
||||
autoResize?: boolean;
|
||||
disabled?: boolean;
|
||||
rules?: RegisterOptions<T, Path<T>>;
|
||||
/** Forwarded to the underlying TextEditor so callers can focus it. */
|
||||
editorRef?: React.Ref<TextEditorHandle>;
|
||||
/** Ctrl/Cmd+Enter handler (e.g. submit the comment). */
|
||||
onSubmit?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rich-text field: a `Controller`-wrapped {@link TextEditor} (mentions, emoji,
|
||||
* image upload, char count) bound to react-hook-form via the surrounding
|
||||
* `FormProvider`.
|
||||
*/
|
||||
export function RichTextField<T extends FieldValues>({
|
||||
name,
|
||||
label,
|
||||
placeholder,
|
||||
rows,
|
||||
maxLength,
|
||||
autoResize,
|
||||
disabled,
|
||||
rules,
|
||||
editorRef,
|
||||
onSubmit,
|
||||
}: RichTextFieldProps<T>) {
|
||||
const id = useId();
|
||||
const { control, formState: { errors } } = useFormContext<T>();
|
||||
const error = errors[name];
|
||||
|
||||
return (
|
||||
<div className="form-field">
|
||||
{label && (
|
||||
<label className="form-label" htmlFor={id}>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<Controller
|
||||
name={name}
|
||||
control={control}
|
||||
rules={rules}
|
||||
render={({ field }) => (
|
||||
<TextEditor
|
||||
ref={editorRef}
|
||||
id={id}
|
||||
value={field.value ?? ""}
|
||||
onChange={field.onChange}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
maxLength={maxLength}
|
||||
autoResize={autoResize}
|
||||
disabled={disabled}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{error?.message && (
|
||||
<span className="form-hint form-hint--error">
|
||||
{String(error.message)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
82
src/components/form/SegmentedField.tsx
Normal file
82
src/components/form/SegmentedField.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import {
|
||||
Controller,
|
||||
type FieldValues,
|
||||
type Path,
|
||||
useFormContext,
|
||||
} from "react-hook-form";
|
||||
|
||||
interface SegmentedOption<V> {
|
||||
value: V;
|
||||
label: ReactNode;
|
||||
}
|
||||
|
||||
interface SegmentedFieldProps<T extends FieldValues, V> {
|
||||
name: Path<T>;
|
||||
options: SegmentedOption<V>[];
|
||||
label?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Segmented control (the `.visibility-toggle` style) bound to react-hook-form.
|
||||
* Generic over the field value, so it works for booleans, string unions, etc.
|
||||
*/
|
||||
export function SegmentedField<T extends FieldValues, V>({
|
||||
name,
|
||||
options,
|
||||
label,
|
||||
disabled,
|
||||
}: SegmentedFieldProps<T, V>) {
|
||||
const { control } = useFormContext<T>();
|
||||
|
||||
return (
|
||||
<div className="form-field">
|
||||
{label && <span className="form-label">{label}</span>}
|
||||
<Controller
|
||||
name={name}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<div className="visibility-toggle">
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
key={String(opt.value)}
|
||||
type="button"
|
||||
className={field.value === opt.value ? "active" : ""}
|
||||
disabled={disabled}
|
||||
onClick={() => field.onChange(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Public/Private toggle on a boolean field (default `isPublic`). Forms that
|
||||
* persist `isPrivate` should still bind this to an `isPublic` field and invert
|
||||
* at submit time.
|
||||
*/
|
||||
export function VisibilityToggle<T extends FieldValues>({
|
||||
name,
|
||||
disabled,
|
||||
}: {
|
||||
name: Path<T>;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<SegmentedField<T, boolean>
|
||||
name={name}
|
||||
disabled={disabled}
|
||||
options={[
|
||||
{ value: true, label: <Trans>Public</Trans> },
|
||||
{ value: false, label: <Trans>Private</Trans> },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
30
src/components/form/SubmitButton.tsx
Normal file
30
src/components/form/SubmitButton.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useFormState } from "react-hook-form";
|
||||
|
||||
interface SubmitButtonProps {
|
||||
children: ReactNode;
|
||||
/** Replaces the label while the form is submitting (e.g. "Saving…"). */
|
||||
pendingLabel?: ReactNode;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit button that reflects react-hook-form's `isSubmitting` from the
|
||||
* surrounding `FormProvider`: it disables itself while submitting and swaps in
|
||||
* `pendingLabel`. Standardizes the hand-rolled "Save → Saving…" pattern.
|
||||
*/
|
||||
export function SubmitButton({
|
||||
children,
|
||||
pendingLabel,
|
||||
disabled,
|
||||
className = "btn-primary",
|
||||
}: SubmitButtonProps) {
|
||||
const { isSubmitting } = useFormState();
|
||||
|
||||
return (
|
||||
<button type="submit" className={className} disabled={isSubmitting || disabled}>
|
||||
{isSubmitting && pendingLabel != null ? pendingLabel : children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
90
src/components/form/TextField.tsx
Normal file
90
src/components/form/TextField.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useId } from "react";
|
||||
import {
|
||||
type FieldValues,
|
||||
type Path,
|
||||
type RegisterOptions,
|
||||
useFormContext,
|
||||
useWatch,
|
||||
} from "react-hook-form";
|
||||
|
||||
import { charCountClass } from "../../utils/charCount.ts";
|
||||
|
||||
interface TextFieldProps<T extends FieldValues> {
|
||||
name: Path<T>;
|
||||
label?: string;
|
||||
type?: "text" | "email" | "password" | "url" | "search";
|
||||
placeholder?: string;
|
||||
autoComplete?: string;
|
||||
autoFocus?: boolean;
|
||||
disabled?: boolean;
|
||||
rules?: RegisterOptions<T, Path<T>>;
|
||||
/** Cap input length at `max` (native `maxLength`). */
|
||||
maxLength?: number;
|
||||
/** Show a live `n / maxLength` counter. Requires `maxLength`. */
|
||||
showCount?: boolean;
|
||||
}
|
||||
|
||||
/** Live `n / max` counter; isolated so only counted fields subscribe to value. */
|
||||
function CharCounter<T extends FieldValues>(
|
||||
{ name, max }: { name: Path<T>; max: number },
|
||||
) {
|
||||
const value = useWatch({ name }) as string | undefined;
|
||||
const len = value?.length ?? 0;
|
||||
return (
|
||||
<span className={`text-editor-count${charCountClass(len, max)}`}>
|
||||
{len} / {max}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Native text input wired to react-hook-form via the surrounding `FormProvider`.
|
||||
* Renders a label, the input (with an optional character counter), and a
|
||||
* validation hint sourced from `formState.errors[name]`.
|
||||
*/
|
||||
export function TextField<T extends FieldValues>({
|
||||
name,
|
||||
label,
|
||||
type = "text",
|
||||
placeholder,
|
||||
autoComplete,
|
||||
autoFocus,
|
||||
disabled,
|
||||
rules,
|
||||
maxLength,
|
||||
showCount = false,
|
||||
}: TextFieldProps<T>) {
|
||||
const id = useId();
|
||||
const { register, formState: { errors } } = useFormContext<T>();
|
||||
const error = errors[name];
|
||||
const counted = showCount && maxLength != null;
|
||||
|
||||
return (
|
||||
<div className="form-field">
|
||||
{label && (
|
||||
<label className="form-label" htmlFor={id}>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<div className={counted ? "input-with-count" : undefined}>
|
||||
<input
|
||||
id={id}
|
||||
type={type}
|
||||
placeholder={placeholder}
|
||||
autoComplete={autoComplete}
|
||||
autoFocus={autoFocus}
|
||||
disabled={disabled}
|
||||
maxLength={maxLength}
|
||||
aria-invalid={error ? true : undefined}
|
||||
{...register(name, rules)}
|
||||
/>
|
||||
{counted && <CharCounter<T> name={name} max={maxLength} />}
|
||||
</div>
|
||||
{error?.message && (
|
||||
<span className="form-hint form-hint--error">
|
||||
{String(error.message)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
9
src/components/form/index.ts
Normal file
9
src/components/form/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export { expectOk, useApiForm } from "./useApiForm.ts";
|
||||
export { TextField } from "./TextField.tsx";
|
||||
export { RichTextField } from "./RichTextField.tsx";
|
||||
export { SegmentedField, VisibilityToggle } from "./SegmentedField.tsx";
|
||||
export { FileField } from "./FileField.tsx";
|
||||
export { SubmitButton } from "./SubmitButton.tsx";
|
||||
export { FormActions } from "./FormActions.tsx";
|
||||
export { FormError } from "./FormError.tsx";
|
||||
export { FormProvider } from "react-hook-form";
|
||||
59
src/components/form/useApiForm.ts
Normal file
59
src/components/form/useApiForm.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useCallback } from "react";
|
||||
import {
|
||||
type FieldValues,
|
||||
type SubmitHandler,
|
||||
useForm,
|
||||
type UseFormProps,
|
||||
type UseFormReturn,
|
||||
} from "react-hook-form";
|
||||
|
||||
import { friendlyFetchError } from "../../utils/apiError.ts";
|
||||
import { parseAPIResponse } from "../../model.ts";
|
||||
|
||||
export interface UseApiFormReturn<T extends FieldValues> extends UseFormReturn<T> {
|
||||
/**
|
||||
* Wraps an async submit handler with the shared API-form lifecycle: clears the
|
||||
* previous root error, runs the handler while RHF keeps `formState.isSubmitting`
|
||||
* true, and on a thrown error surfaces a single root error via
|
||||
* `friendlyFetchError`. Pair it with {@link expectOk} so API-level failures and
|
||||
* network failures both become root errors through one code path. Returns the
|
||||
* `onSubmit` handler to spread onto `<form onSubmit={...}>`.
|
||||
*/
|
||||
submit: (
|
||||
handler: SubmitHandler<T>,
|
||||
) => (e?: React.BaseSyntheticEvent) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useApiForm<T extends FieldValues>(
|
||||
options?: UseFormProps<T>,
|
||||
): UseApiFormReturn<T> {
|
||||
const form = useForm<T>(options);
|
||||
const { handleSubmit, setError, clearErrors } = form;
|
||||
|
||||
const submit = useCallback(
|
||||
(handler: SubmitHandler<T>) =>
|
||||
handleSubmit(async (values, event) => {
|
||||
clearErrors("root");
|
||||
try {
|
||||
await handler(values, event);
|
||||
} catch (err) {
|
||||
setError("root", { message: friendlyFetchError(err) });
|
||||
}
|
||||
}),
|
||||
[handleSubmit, setError, clearErrors],
|
||||
);
|
||||
|
||||
return { ...form, submit };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a fetch `Response` through the standard API envelope and returns the
|
||||
* payload, throwing the server's message on `success: false`. A thrown error
|
||||
* here (or a network `TypeError` from the `fetch` itself) is caught by
|
||||
* {@link useApiForm}'s `submit` and rendered as the form's root error.
|
||||
*/
|
||||
export async function expectOk<T>(res: Response): Promise<T> {
|
||||
const body = parseAPIResponse<T>(await res.json());
|
||||
if (!body.success) throw new Error(body.error.message);
|
||||
return body.data;
|
||||
}
|
||||
Reference in New Issue
Block a user