-
-
-
-
+
@@ -227,41 +193,3 @@ function SensFields() {
);
}
-
-function TierListField({ value, onChange }: CustomFieldRenderProps
) {
- const { t } = useTranslation(["user"]);
-
- const handleChange = (e: React.ChangeEvent) => {
- const inputValue = e.target.value;
-
- if (inputValue.includes("/tier-list-maker")) {
- try {
- const url = new URL(inputValue, "https://sendou.ink");
- const extractedSearchParams = url.search.substring(1);
- onChange(extractedSearchParams);
- return;
- } catch {
- // not a valid URL, just use the value as-is
- }
- }
-
- onChange(inputValue);
- };
-
- return (
-
-
-
-
- );
-}
diff --git a/app/features/user-page/core/widgets/widget-form-schemas.ts b/app/features/user-page/core/widgets/widget-form-schemas.ts
index af149a2e4..cfab696fe 100644
--- a/app/features/user-page/core/widgets/widget-form-schemas.ts
+++ b/app/features/user-page/core/widgets/widget-form-schemas.ts
@@ -8,8 +8,8 @@ import {
select,
selectDynamic,
stageSelect,
- textAreaRequired,
- textFieldRequired,
+ textArea,
+ textField,
weaponSelect,
} from "~/form/fields";
import type { SelectOption } from "~/form/types";
@@ -17,34 +17,34 @@ import { GAME_BADGE_IDS } from "~/modules/in-game-lists/game-badge-ids";
import { USER } from "../../user-page-constants";
export const bioSchema = z.object({
- bio: textAreaRequired({
+ bio: textArea({
label: "labels.bio",
maxLength: USER.BIO_MAX_LENGTH,
}),
});
export const bioMdSchema = z.object({
- bio: textAreaRequired({
+ bio: textArea({
label: "labels.bio",
- bottomText: "bottomTexts.bioMarkdown" as never,
+ bottomText: "bottomTexts.bioMarkdown",
maxLength: USER.BIO_MD_MAX_LENGTH,
}),
});
export const xRankPeaksSchema = z.object({
division: select({
- label: "labels.division" as never,
+ label: "labels.division",
items: [
- { value: "both", label: "options.division.both" as never },
- { value: "tentatek", label: "options.division.tentatek" as never },
- { value: "takoroka", label: "options.division.takoroka" as never },
+ { value: "both", label: "options.division.both" },
+ { value: "tentatek", label: "options.division.tentatek" },
+ { value: "takoroka", label: "options.division.takoroka" },
],
}),
});
export const timezoneSchema = z.object({
timezone: selectDynamic({
- label: "labels.timezone" as never,
+ label: "labels.timezone",
}),
});
@@ -55,50 +55,52 @@ export const TIMEZONE_OPTIONS: SelectOption[] = TIMEZONES.map((tz) => ({
export const favoriteStageSchema = z.object({
stageId: stageSelect({
- label: "labels.favoriteStage" as never,
+ label: "labels.favoriteStage",
}),
});
export const peakXpUnverifiedSchema = z.object({
peakXp: numberField({
- label: "labels.peakXp" as never,
+ label: "labels.peakXp",
minLength: 4,
maxLength: 4,
}),
division: select({
- label: "labels.division" as never,
+ label: "labels.division",
items: [
- { value: "tentatek", label: "options.division.tentatek" as never },
- { value: "takoroka", label: "options.division.takoroka" as never },
+ { value: "tentatek", label: "options.division.tentatek" },
+ { value: "takoroka", label: "options.division.takoroka" },
],
}),
});
export const peakXpWeaponSchema = z.object({
weaponSplId: weaponSelect({
- label: "labels.weapon" as never,
+ label: "labels.weapon",
}),
});
-export const CONTROLLERS = [
- "s1-pro-con",
- "s2-pro-con",
- "grip",
- "handheld",
-] as const;
+const CONTROLLERS = ["s1-pro-con", "s2-pro-con", "grip", "handheld"] as const;
export const sensSchema = z.object({
- controller: customField({ initialValue: "s2-pro-con" }, z.enum(CONTROLLERS)),
+ controller: select({
+ label: "labels.controller",
+ items: CONTROLLERS.map((controller) => ({
+ value: controller,
+ label: `options.controller.${controller}` as const,
+ })),
+ initialValue: "s2-pro-con",
+ }),
motionSens: customField({ initialValue: null }, z.number().nullable()),
stickSens: customField({ initialValue: null }, z.number().nullable()),
});
export const artSchema = z.object({
source: select({
- label: "labels.artSource" as never,
+ label: "labels.artSource",
items: ART_SOURCES.map((source) => ({
value: source,
- label: `options.artSource.${source}` as never,
+ label: `options.artSource.${source}`,
})),
}),
});
@@ -108,7 +110,7 @@ export const linksSchema = z.object({
label: "labels.urls",
min: 1,
max: 10,
- field: textFieldRequired({
+ field: textField({
maxLength: 150,
validate: "url",
}),
@@ -116,10 +118,11 @@ export const linksSchema = z.object({
});
export const tierListSchema = z.object({
- searchParams: textFieldRequired({
- label: "labels.tierListUrl" as never,
+ searchParams: textField({
+ label: "labels.tierListUrl",
leftAddon: "/tier-list-maker?",
maxLength: 500,
+ transformValue: pastedTierListUrlToSearchParams,
}),
});
@@ -160,3 +163,14 @@ const WIDGET_FORM_SCHEMAS: Record> = {
export function getWidgetFormSchema(widgetId: string) {
return WIDGET_FORM_SCHEMAS[widgetId];
}
+
+/** Lets the user paste a whole tier list maker URL instead of only its query string. */
+function pastedTierListUrlToSearchParams(value: string) {
+ if (!value.includes("/tier-list-maker")) return value;
+
+ try {
+ return new URL(value, "https://sendou.ink").search.substring(1);
+ } catch {
+ return value;
+ }
+}
diff --git a/app/features/user-page/user-page-schemas.ts b/app/features/user-page/user-page-schemas.ts
index 58821fc92..89a190d0a 100644
--- a/app/features/user-page/user-page-schemas.ts
+++ b/app/features/user-page/user-page-schemas.ts
@@ -15,10 +15,10 @@ import {
inGameName,
selectDynamicOptional,
stringConstant,
+ textArea,
textAreaOptional,
- textAreaRequired,
+ textField,
textFieldOptional,
- textFieldRequired,
toggle,
weaponPool,
} from "~/form/fields";
@@ -180,7 +180,7 @@ export const editHighlightsActionSchema = z.object({
export const addModNoteSchema = z.object({
_action: stringConstant("ADD_MOD_NOTE"),
- value: textAreaRequired({
+ value: textArea({
label: "labels.text",
bottomText: "bottomTexts.modNote",
maxLength: USER.MOD_NOTE_MAX_LENGTH,
@@ -318,7 +318,7 @@ export const newBuildBaseSchema = z.object({
},
abilitiesSchema,
),
- title: textFieldRequired({
+ title: textField({
label: "labels.buildTitle",
maxLength: 50,
}),
diff --git a/app/features/user-report/user-report-schemas.ts b/app/features/user-report/user-report-schemas.ts
index eaab7fa0c..7fa88895a 100644
--- a/app/features/user-report/user-report-schemas.ts
+++ b/app/features/user-report/user-report-schemas.ts
@@ -1,5 +1,5 @@
import { z } from "zod";
-import { select, textAreaRequired, textFieldOptional } from "~/form/fields";
+import { select, textArea, textFieldOptional } from "~/form/fields";
import { id } from "~/utils/zod";
import { USER_REPORT } from "./user-report-constants";
@@ -17,7 +17,7 @@ export const reportUserSchema = z.object({
{ label: "options.userReportCategory.OTHER", value: "OTHER" },
],
}),
- description: textAreaRequired({
+ description: textArea({
label: "labels.description",
maxLength: USER_REPORT.DESCRIPTION_MAX_LENGTH,
}),
diff --git a/app/features/vods/routes/vods.new.browser.test.tsx b/app/features/vods/routes/vods.new.browser.test.tsx
index 2eb8f9d91..d20b16d32 100644
--- a/app/features/vods/routes/vods.new.browser.test.tsx
+++ b/app/features/vods/routes/vods.new.browser.test.tsx
@@ -80,15 +80,11 @@ function renderForm(options?: {
schema={vodFormBaseSchema}
defaultValues={createDefaultValues(options?.defaultValues)}
>
- {({ names }) => (
- <>
- {Object.keys(names)
- .filter((name) => name !== "pov")
- .map((name) => (
-
- ))}
- >
- )}
+ {Object.keys(vodFormBaseSchema.shape)
+ .filter((name) => name !== "pov")
+ .map((name) => (
+
+ ))}
),
},
diff --git a/app/features/vods/routes/vods.new.tsx b/app/features/vods/routes/vods.new.tsx
index 1a87a48ec..b126c1378 100644
--- a/app/features/vods/routes/vods.new.tsx
+++ b/app/features/vods/routes/vods.new.tsx
@@ -10,7 +10,6 @@ import { Main } from "~/components/Main";
import { WeaponSelect } from "~/components/WeaponSelect";
import { YouTubeEmbed } from "~/components/YouTubeEmbed";
import type { ArrayItemRenderContext, CustomFieldRenderProps } from "~/form";
-import { FormFieldWrapper } from "~/form/fields/FormFieldWrapper";
import type { WeaponPoolItem } from "~/form/fields/WeaponPoolFormField";
import type { FormRenderProps } from "~/form/SendouForm";
import { SendouForm, useFormFieldContext } from "~/form/SendouForm";
@@ -240,46 +239,21 @@ function VodFormFields({
}
function TeamSizeField({ FormField }: { FormField: VodFormFieldComponent }) {
- const { values, setValue } = useFormFieldContext();
- const matches = values.matches as Array>;
+ const { setValueFromPrev } = useFormFieldContext();
- const handleTeamSizeChange = (newValue: string | null) => {
- setValue("teamSize", newValue);
-
- if (matches && Array.isArray(matches)) {
- const clearedMatches = matches.map((match) => ({
+ // The weapon count per match is tied to the team size, so any already picked
+ // weapons would no longer fit the new size.
+ const clearMatchWeapons = () => {
+ setValueFromPrev("matches", (prev) =>
+ ((prev ?? []) as Array>).map((match) => ({
...match,
weaponsTeamOne: [],
weaponsTeamTwo: [],
- }));
- setValue("matches", clearedMatches);
- }
+ })),
+ );
};
- return (
-
- {({ name, error, value }: CustomFieldRenderProps) => (
-
-
-
- )}
-
- );
+ return ;
}
function PovFormField({ FormField }: { FormField: VodFormFieldComponent }) {
@@ -420,33 +394,19 @@ function MatchFieldsetContent({
-
- {(props: CustomFieldRenderProps) => (
-
+
+ {currentTime ? (
+ setItemField("startsAt", currentTime)}
+ className="mt-2"
>
- setItemField("startsAt", e.target.value)}
- placeholder="10:22"
- />
- {currentTime ? (
- setItemField("startsAt", currentTime)}
- className="mt-2"
- >
- {t("vods:forms.action.setAsCurrent", { time: currentTime })}
-
- ) : null}
-
- )}
-
+ {t("vods:forms.action.setAsCurrent", { time: currentTime })}
+
+ ) : null}
+
diff --git a/app/features/vods/vods-schemas.ts b/app/features/vods/vods-schemas.ts
index cd79e2919..1659c5ffd 100644
--- a/app/features/vods/vods-schemas.ts
+++ b/app/features/vods/vods-schemas.ts
@@ -3,14 +3,14 @@ import { z } from "zod";
import {
array,
customField,
- dayMonthYearRequired,
+ dayMonthYear as dayMonthYearField,
fieldset,
idConstantOptional,
radioGroup,
select,
selectOptional,
stageSelect,
- textFieldRequired,
+ textField,
weaponPool,
weaponSelectOptional,
} from "~/form/fields";
@@ -116,8 +116,9 @@ const povSchema = z.union([
]);
const matchFieldsetSchema = z.object({
- startsAt: textFieldRequired({
+ startsAt: textField({
label: "labels.vodStartTimestamp",
+ placeholder: "placeholders.vodStartTimestamp",
maxLength: 10,
regExp: {
pattern: HOURS_MINUTES_SECONDS_REGEX,
@@ -151,7 +152,7 @@ const matchFieldsetSchema = z.object({
export const vodFormBaseSchema = z.object({
vodToEditId: idConstantOptional(),
- youtubeUrl: textFieldRequired({
+ youtubeUrl: textField({
label: "labels.vodYoutubeUrl",
maxLength: 200,
validate: {
@@ -159,11 +160,11 @@ export const vodFormBaseSchema = z.object({
message: "Invalid YouTube URL",
},
}),
- title: textFieldRequired({
+ title: textField({
label: "labels.vodTitle",
maxLength: 100,
}),
- date: dayMonthYearRequired({
+ date: dayMonthYearField({
label: "labels.vodDate",
max: () => add(new Date(), { days: 1 }),
maxMessage: "errors.dateMustNotBeFuture",
diff --git a/app/form/FormField.tsx b/app/form/FormField.tsx
index 83bb33bc7..16c54cbef 100644
--- a/app/form/FormField.tsx
+++ b/app/form/FormField.tsx
@@ -55,6 +55,8 @@ interface FormFieldProps {
name: string;
label?: string;
disabled?: boolean;
+ /** Focuses the field on mount. Only `text-field` and `text-area` support it. */
+ autoFocus?: boolean;
maxCount?: number;
field?: z.ZodType;
children?:
@@ -64,17 +66,27 @@ interface FormFieldProps {
options?: unknown;
/** For `array` fields: hide the remove button for items where this returns false. */
canRemoveItem?: (itemValue: unknown, index: number) => boolean;
+ /**
+ * Runs after the new value has been stored. For side effects on other fields;
+ * to change what gets stored use the field schema's own options instead.
+ */
+ onValueChange?: (newValue: unknown) => void;
}
+/** Field types that render `children`. Any other type would silently discard it. */
+const FIELD_TYPES_WITH_RENDER_PROP = ["custom", "array"];
+
export function FormField({
name,
label,
disabled,
+ autoFocus,
maxCount,
field,
children,
options,
canRemoveItem,
+ onValueChange,
}: FormFieldProps) {
const context = useOptionalFormFieldContext();
const isDisabled = disabled ?? context?.readOnly ?? false;
@@ -153,11 +165,24 @@ export function FormField({
context.setClientError(name, validationError);
};
+ // After the first submit, changes revalidate the whole form — except array
+ // appends, which stay silent so a freshly added empty item doesn't error
+ // immediately. Blur is the moment the user leaves such an item, so
+ // revalidating here surfaces its error without waiting for the next submit.
const handleBlur = (latestValue?: unknown) => {
- if (hasSubmitted) return;
+ if (!context) return;
+ if (hasSubmitted) {
+ context.revalidateAll(context.store.values);
+ return;
+ }
runValidation(latestValue ?? value);
};
+ // Read through a ref so an inline `onValueChange` does not destabilize
+ // `handleChange`, which fields rely on to skip re-rendering.
+ const latestOnValueChange = React.useRef(onValueChange);
+ latestOnValueChange.current = onValueChange;
+
const handleChange = React.useCallback(
(newValue: unknown) => {
if (!context) return;
@@ -171,12 +196,22 @@ export function FormField({
context.revalidateAll(context.store.values);
}
context.onFieldChange?.(name, newValue);
+ latestOnValueChange.current?.(newValue);
},
[context, name],
);
const displayedError = serverError ?? clientError;
+ if (
+ typeof children === "function" &&
+ !FIELD_TYPES_WITH_RENDER_PROP.includes(formField.type)
+ ) {
+ throw new Error(
+ `Field "${name}" is of type "${formField.type}" which renders itself, so its render function child would never run. Remove the child or change the field to customField().`,
+ );
+ }
+
const commonProps = { name, error: displayedError, onBlur: handleBlur };
if (formField.type === "text-field") {
@@ -185,6 +220,7 @@ export function FormField({
{...commonProps}
{...formField}
disabled={isDisabled}
+ autoFocus={autoFocus}
value={value as string}
onChange={handleChange as (v: string) => void}
/>
@@ -221,6 +257,7 @@ export function FormField({
{...commonProps}
{...formField}
disabled={isDisabled}
+ autoFocus={autoFocus}
value={value as string}
onChange={handleChange as (v: string) => void}
/>
@@ -232,6 +269,7 @@ export function FormField({
void}
/>
@@ -247,6 +285,7 @@ export function FormField({
({
value: opt.value,
label: opt.label,
@@ -262,6 +301,7 @@ export function FormField({
void}
/>
@@ -273,6 +313,7 @@ export function FormField({
void}
/>
@@ -288,6 +329,7 @@ export function FormField({
void}
@@ -300,6 +342,7 @@ export function FormField({
void}
/>
@@ -311,6 +354,7 @@ export function FormField({
void}
@@ -323,6 +367,7 @@ export function FormField({
void
@@ -336,6 +381,7 @@ export function FormField({
void}
/>
@@ -365,15 +411,13 @@ export function FormField({
error: displayedError,
value,
onChange: handleChange,
+ disabled: isDisabled,
})}
>
);
}
- if (
- formField.type === "string-constant" ||
- formField.type === "id-constant"
- ) {
+ if (formField.type === "hidden") {
return null;
}
@@ -393,6 +437,7 @@ export function FormField({
void}
isObjectArray={isObjectArray}
@@ -433,7 +478,12 @@ export function FormField({
}
return (
-
+
);
}}
/>
@@ -441,7 +491,9 @@ export function FormField({
}
if (formField.type === "fieldset") {
- return ;
+ return (
+
+ );
}
if (formField.type === "user-search") {
@@ -450,6 +502,7 @@ export function FormField({
void}
onUserSelected={userOptions?.onUserSelected}
@@ -465,6 +518,7 @@ export function FormField({
void}
pastOnly={tournamentOptions?.pastOnly}
@@ -478,6 +532,7 @@ export function FormField({
void}
onTeamSelected={teamOptions?.onTeamSelected}
initialTeam={teamOptions?.initialTeam}
@@ -493,6 +548,7 @@ export function FormField({
void}
options={options as BadgeOption[]}
@@ -506,6 +562,7 @@ export function FormField({
void}
/>
@@ -517,6 +574,7 @@ export function FormField({
void}
/>
diff --git a/app/form/SendouForm.browser.test.tsx b/app/form/SendouForm.browser.test.tsx
index fa3bb6abd..df1710735 100644
--- a/app/form/SendouForm.browser.test.tsx
+++ b/app/form/SendouForm.browser.test.tsx
@@ -13,10 +13,10 @@ import {
radioGroup,
select,
selectOptional,
+ textArea,
textAreaOptional,
- textAreaRequired,
+ textField,
textFieldOptional,
- textFieldRequired,
timeRangeOptional,
toggle as toggleField,
userSearch,
@@ -46,7 +46,7 @@ function renderForm(
defaultValues?: Record;
title?: string;
submitButtonText?: string;
- autoSubmit?: boolean;
+ mode?: "autoSubmit";
},
) {
const props: ComponentProps> = {
@@ -54,10 +54,10 @@ function renderForm(
defaultValues: options?.defaultValues,
title: options?.title,
submitButtonText: options?.submitButtonText,
- autoSubmit: options?.autoSubmit,
- children: ({ names }) => (
+ mode: options?.mode,
+ children: (
<>
- {Object.keys(names).map((name) => (
+ {Object.keys(schema.shape).map((name) => (
))}
>
@@ -85,7 +85,7 @@ describe("SendouForm", () => {
describe("basic form rendering", () => {
test("renders form with title", async () => {
const schema = z.object({
- name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
+ name: textField({ label: "labels.name", maxLength: 100 }),
});
const screen = await renderForm(schema, { title: "Test Form" });
@@ -95,7 +95,7 @@ describe("SendouForm", () => {
test("renders submit button with default text", async () => {
const schema = z.object({
- name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
+ name: textField({ label: "labels.name", maxLength: 100 }),
});
const screen = await renderForm(schema);
@@ -107,7 +107,7 @@ describe("SendouForm", () => {
test("renders submit button with custom text", async () => {
const schema = z.object({
- name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
+ name: textField({ label: "labels.name", maxLength: 100 }),
});
const screen = await renderForm(schema, {
@@ -119,12 +119,12 @@ describe("SendouForm", () => {
.toBeVisible();
});
- test("hides submit button when autoSubmit is true", async () => {
+ test("hides submit button in autoSubmit mode", async () => {
const schema = z.object({
- name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
+ name: textField({ label: "labels.name", maxLength: 100 }),
});
- const screen = await renderForm(schema, { autoSubmit: true });
+ const screen = await renderForm(schema, { mode: "autoSubmit" });
const submitButton = screen.container.querySelector(
'button[type="submit"]',
@@ -136,7 +136,7 @@ describe("SendouForm", () => {
describe("text field", () => {
test("renders with label", async () => {
const schema = z.object({
- name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
+ name: textField({ label: "labels.name", maxLength: 100 }),
});
const screen = await renderForm(schema);
@@ -146,7 +146,7 @@ describe("SendouForm", () => {
test("typing updates value", async () => {
const schema = z.object({
- name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
+ name: textField({ label: "labels.name", maxLength: 100 }),
});
const screen = await renderForm(schema);
@@ -159,7 +159,7 @@ describe("SendouForm", () => {
test("shows error on blur when required field is empty", async () => {
const schema = z.object({
- name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
+ name: textField({ label: "labels.name", maxLength: 100 }),
});
const screen = await renderForm(schema);
@@ -175,7 +175,7 @@ describe("SendouForm", () => {
test("shows error on submit when required field is empty", async () => {
const schema = z.object({
- name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
+ name: textField({ label: "labels.name", maxLength: 100 }),
});
const screen = await renderForm(schema);
@@ -189,7 +189,7 @@ describe("SendouForm", () => {
test("clears error when valid value is entered after submit", async () => {
const schema = z.object({
- name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
+ name: textField({ label: "labels.name", maxLength: 100 }),
});
const screen = await renderForm(schema);
@@ -221,7 +221,7 @@ describe("SendouForm", () => {
test("initializes with default value", async () => {
const schema = z.object({
- name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
+ name: textField({ label: "labels.name", maxLength: 100 }),
});
const screen = await renderForm(schema, {
@@ -312,7 +312,7 @@ describe("SendouForm", () => {
test("required text area shows error when empty", async () => {
const schema = z.object({
- bio: textAreaRequired({ label: "labels.bio", maxLength: 500 }),
+ bio: textArea({ label: "labels.bio", maxLength: 500 }),
});
const screen = await renderForm(schema);
@@ -599,8 +599,8 @@ describe("SendouForm", () => {
describe("validation", () => {
test("validates multiple fields on submit", async () => {
const schema = z.object({
- name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
- bio: textAreaRequired({ label: "labels.bio", maxLength: 500 }),
+ name: textField({ label: "labels.name", maxLength: 100 }),
+ bio: textArea({ label: "labels.bio", maxLength: 500 }),
});
const screen = await renderForm(schema);
@@ -618,7 +618,7 @@ describe("SendouForm", () => {
describe("default values", () => {
test("initializes multiple fields with default values", async () => {
const schema = z.object({
- name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
+ name: textField({ label: "labels.name", maxLength: 100 }),
bio: textAreaOptional({ label: "labels.bio", maxLength: 500 }),
});
@@ -659,7 +659,7 @@ describe("SendouForm", () => {
describe("server error fallback", () => {
test("shows fallback error when server returns error for field without DOM element", async () => {
const schema = z.object({
- name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
+ name: textField({ label: "labels.name", maxLength: 100 }),
});
mockFetcherData = {
@@ -677,7 +677,7 @@ describe("SendouForm", () => {
test("does not show fallback error when server error has corresponding DOM element", async () => {
const schema = z.object({
- name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
+ name: textField({ label: "labels.name", maxLength: 100 }),
});
mockFetcherData = {
@@ -741,7 +741,7 @@ describe("SendouForm", () => {
test("calls onApply with form values instead of fetcher.submit", async () => {
const onApply = vi.fn();
const schema = z.object({
- name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
+ name: textField({ label: "labels.name", maxLength: 100 }),
});
const router = createMemoryRouter(
@@ -754,7 +754,7 @@ describe("SendouForm", () => {
defaultValues={{ name: "Test Value" }}
onApply={onApply}
>
- {({ names }) => }
+
),
},
@@ -771,7 +771,7 @@ describe("SendouForm", () => {
test("does not call onApply when validation fails", async () => {
const onApply = vi.fn();
const schema = z.object({
- name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
+ name: textField({ label: "labels.name", maxLength: 100 }),
});
const router = createMemoryRouter(
@@ -780,7 +780,7 @@ describe("SendouForm", () => {
path: "/",
element: (
- {({ names }) => }
+
),
},
@@ -804,7 +804,7 @@ describe("SendouForm", () => {
member: fieldset({
label: "labels.member",
fields: z.object({
- name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
+ name: textField({ label: "labels.name", maxLength: 100 }),
}),
}),
});
@@ -819,7 +819,7 @@ describe("SendouForm", () => {
member: fieldset({
label: "labels.member",
fields: z.object({
- name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
+ name: textField({ label: "labels.name", maxLength: 100 }),
bio: textAreaOptional({ label: "labels.bio", maxLength: 500 }),
}),
}),
@@ -836,7 +836,7 @@ describe("SendouForm", () => {
member: fieldset({
label: "labels.member",
fields: z.object({
- name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
+ name: textField({ label: "labels.name", maxLength: 100 }),
}),
}),
});
@@ -854,7 +854,7 @@ describe("SendouForm", () => {
member: fieldset({
label: "labels.member",
fields: z.object({
- name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
+ name: textField({ label: "labels.name", maxLength: 100 }),
}),
}),
});
@@ -876,7 +876,7 @@ describe("SendouForm", () => {
label: "labels.urls",
min: 0,
max: 5,
- field: textFieldRequired({ maxLength: 100 }),
+ field: textField({ maxLength: 100 }),
}),
});
@@ -893,7 +893,7 @@ describe("SendouForm", () => {
label: "labels.urls",
min: 0,
max: 5,
- field: textFieldRequired({ maxLength: 100 }),
+ field: textField({ maxLength: 100 }),
}),
});
@@ -914,7 +914,7 @@ describe("SendouForm", () => {
label: "labels.urls",
min: 0,
max: 5,
- field: textFieldRequired({ maxLength: 100 }),
+ field: textField({ maxLength: 100 }),
}),
});
@@ -939,7 +939,7 @@ describe("SendouForm", () => {
label: "labels.urls",
min: 0,
max: 5,
- field: textFieldRequired({ maxLength: 100 }),
+ field: textField({ maxLength: 100 }),
}),
});
@@ -959,7 +959,7 @@ describe("SendouForm", () => {
label: "labels.urls",
min: 0,
max: 5,
- field: textFieldRequired({ maxLength: 100 }),
+ field: textField({ maxLength: 100 }),
}),
});
@@ -985,7 +985,7 @@ describe("SendouForm", () => {
label: "labels.urls",
min: 0,
max: 2,
- field: textFieldRequired({ maxLength: 100 }),
+ field: textField({ maxLength: 100 }),
}),
});
@@ -1007,7 +1007,7 @@ describe("SendouForm", () => {
max: 10,
field: fieldset({
fields: z.object({
- name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
+ name: textField({ label: "labels.name", maxLength: 100 }),
}),
}),
}),
@@ -1029,7 +1029,7 @@ describe("SendouForm", () => {
max: 10,
field: fieldset({
fields: z.object({
- name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
+ name: textField({ label: "labels.name", maxLength: 100 }),
}),
}),
}),
@@ -1056,7 +1056,7 @@ describe("SendouForm", () => {
max: 10,
field: fieldset({
fields: z.object({
- name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
+ name: textField({ label: "labels.name", maxLength: 100 }),
}),
}),
}),
@@ -1078,7 +1078,7 @@ describe("SendouForm", () => {
max: 10,
field: fieldset({
fields: z.object({
- name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
+ name: textField({ label: "labels.name", maxLength: 100 }),
}),
}),
}),
@@ -1109,7 +1109,7 @@ describe("SendouForm", () => {
sortable: true,
field: fieldset({
fields: z.object({
- name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
+ name: textField({ label: "labels.name", maxLength: 100 }),
}),
}),
}),
@@ -1147,7 +1147,7 @@ describe("SendouForm", () => {
max: 10,
field: fieldset({
fields: z.object({
- name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
+ name: textField({ label: "labels.name", maxLength: 100 }),
}),
}),
}),
@@ -1173,7 +1173,7 @@ describe("SendouForm", () => {
max: 10,
field: fieldset({
fields: z.object({
- name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
+ name: textField({ label: "labels.name", maxLength: 100 }),
role: select({
label: "labels.staffRole",
items: [
@@ -1222,7 +1222,7 @@ describe("SendouForm", () => {
max: 10,
field: fieldset({
fields: z.object({
- name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
+ name: textField({ label: "labels.name", maxLength: 100 }),
}),
}),
}),
@@ -1251,7 +1251,7 @@ describe("SendouForm", () => {
max: 10,
field: fieldset({
fields: z.object({
- name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
+ name: textField({ label: "labels.name", maxLength: 100 }),
role: select({
label: "labels.staffRole",
items: [
@@ -1270,7 +1270,7 @@ describe("SendouForm", () => {
path: "/",
element: (
- {({ names }) => }
+
),
},
@@ -1296,7 +1296,7 @@ describe("SendouForm", () => {
max: 10,
field: fieldset({
fields: z.object({
- name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
+ name: textField({ label: "labels.name", maxLength: 100 }),
description: textAreaOptional({
label: "labels.description",
maxLength: 500,
@@ -1324,7 +1324,7 @@ describe("SendouForm", () => {
max: 10,
field: fieldset({
fields: z.object({
- name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
+ name: textField({ label: "labels.name", maxLength: 100 }),
}),
}),
}),
@@ -1416,12 +1416,8 @@ describe("SendouForm", () => {
path: "/",
element: (
- {({ names }) => (
- <>
-
-
- >
- )}
+
+
),
},
@@ -1459,7 +1455,7 @@ describe("SendouForm", () => {
describe("render isolation", () => {
test("typing in one field does not re-render sibling fields", async () => {
const schema = z.object({
- name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
+ name: textField({ label: "labels.name", maxLength: 100 }),
bio: textFieldOptional({ label: "labels.bio", maxLength: 100 }),
});
diff --git a/app/form/SendouForm.tsx b/app/form/SendouForm.tsx
index a7ba62125..f6246eea8 100644
--- a/app/form/SendouForm.tsx
+++ b/app/form/SendouForm.tsx
@@ -6,12 +6,14 @@ import type { FetcherWithComponents } from "react-router";
import { useFetcher, useLocation } from "react-router";
import { isPlainObject } from "remeda";
import type { z } from "zod";
+import type { SendouButtonProps } from "~/components/elements/Button";
import { FormMessage } from "~/components/FormMessage";
import { SubmitButton } from "~/components/SubmitButton";
import { FormField as FormFieldComponent } from "./FormField";
import { getFormFieldMetadata } from "./fields";
import styles from "./SendouForm.module.css";
import type { TypedFormFieldComponent } from "./types";
+import { useUnsavedChangesChecker } from "./UnsavedChangesGuard";
import {
buildFieldPath,
errorMessageId,
@@ -37,7 +39,6 @@ export interface FormContextValue {
setClientError: (name: string, error: string | undefined) => void;
clearServerError: (name: string) => void;
onFieldChange?: (name: string, newValue: unknown) => void;
- hideRequiredIndicator: boolean;
readOnly: boolean;
values: Record;
setValue: (name: string, value: unknown) => void;
@@ -56,9 +57,12 @@ export interface FormContextValue {
interface FormStore {
values: Record;
clientErrors: Partial>;
+ /** Has the user edited any field since mount / the last successful submit? */
+ dirty: boolean;
subscribe: (listener: () => void) => () => void;
setValues: (values: Record) => void;
setClientErrors: (errors: Partial>) => void;
+ setDirty: (dirty: boolean) => void;
}
type FormFieldContextValue = Omit<
@@ -72,27 +76,27 @@ const FormContext = React.createContext(null);
export const EMPTY_FORM_STORE = createFormStore({}, {});
-type FormNames = {
- [K in keyof T]: K;
-};
-
export interface FormRenderProps {
- names: FormNames;
FormField: TypedFormFieldComponent;
}
+export type FormMode = "submit" | "autoSubmit" | "client";
+
type BaseFormProps = {
children: React.ReactNode | ((props: FormRenderProps) => React.ReactNode);
schema: z.ZodObject;
title?: React.ReactNode;
submitButtonText?: React.ReactNode;
action?: string;
- method?: "post" | "get";
- _action?: string;
submitButtonTestId?: string;
- autoSubmit?: boolean;
- autoApply?: boolean;
+ /** Styling of the submit button, for forms embedded somewhere the default button is too heavy. */
+ submitButtonVariant?: SendouButtonProps["variant"];
+ submitButtonSize?: SendouButtonProps["size"];
revalidateRoot?: boolean;
+ /**
+ * Replaces the default form layout classes entirely (it does not merge with
+ * them), so `fullWidth` has no effect when this is set.
+ */
className?: string;
/**
* When true, opts out of the default centered, max-width layout so the form
@@ -100,18 +104,11 @@ type BaseFormProps = {
* layout that already controls width/alignment.
*/
fullWidth?: boolean;
- /**
- * When true, fields don't show the red `*` required indicator next to their
- * label. Useful on pages where every field is required and the asterisk only
- * adds noise (e.g. the settings page).
- */
- hideRequiredIndicator?: boolean;
/**
* When true, renders the form for viewing only: every field is disabled and
* the submit button is hidden.
*/
readOnly?: boolean;
- onApply?: (values: z.infer>) => void;
secondarySubmit?: React.ReactNode;
/**
* Called once after a server submission completes successfully (the action
@@ -121,22 +118,43 @@ type BaseFormProps = {
onSuccess?: () => void;
};
+/**
+ * How submitting works:
+ * - `"submit"` (default): the user submits via the submit button. Values go to
+ * the server, or to `onApply` when provided.
+ * - `"autoSubmit"`: no submit button; every change that passes validation is
+ * sent to the server.
+ * - `"client"`: no submit button and no `
diff --git a/app/form/fields/UserSearchFormField.tsx b/app/form/fields/UserSearchFormField.tsx
index f30b9d410..a9bf1fb18 100644
--- a/app/form/fields/UserSearchFormField.tsx
+++ b/app/form/fields/UserSearchFormField.tsx
@@ -7,6 +7,7 @@ type UserSearchFormFieldProps = FormFieldProps<"user-search"> &
UserSearchFieldOptions & {
value: number | null;
onChange: (value: number | null) => void;
+ disabled?: boolean;
};
export function UserSearchFormField({
@@ -19,6 +20,7 @@ export function UserSearchFormField({
onChange,
onUserSelected,
onBlur,
+ disabled,
}: UserSearchFormFieldProps) {
const { translatedLabel } = useTranslatedTexts({
label,
@@ -36,6 +38,7 @@ export function UserSearchFormField({
onBlur={() => onBlur?.()}
label={translatedLabel}
isRequired={required}
+ isDisabled={disabled}
/>