From c20701d98c59615c1a4337dac73085ee4f0b0e73 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sun, 18 Jan 2026 18:21:19 +0200 Subject: [PATCH] Form system refactor from react-hook-form to one schema per form across the stack (#2735) Co-authored-by: Claude Opus 4.5 --- .beans.yml | 6 + .claude/settings.json | 6 +- AGENTS.md | 14 +- app/components/FormMessage.tsx | 6 + app/components/WeaponSelect.module.css | 2 +- app/components/WeaponSelect.tsx | 23 +- app/components/elements/BottomTexts.tsx | 6 +- app/components/elements/DatePicker.tsx | 43 +- app/components/elements/FieldError.tsx | 10 +- app/components/elements/Select.module.css | 5 +- app/components/elements/UserSearch.tsx | 2 +- app/components/form/AddFieldButton.tsx | 21 - app/components/form/DateFormField.tsx | 92 -- app/components/form/FormFieldset.tsx | 25 - app/components/form/InputFormField.tsx | 54 - app/components/form/InputGroupFormField.tsx | 126 -- app/components/form/RemoveFieldButton.tsx | 14 - app/components/form/SelectFormField.tsx | 56 - app/components/form/SendouForm.tsx | 80 -- app/components/form/TextAreaFormField.tsx | 46 - app/components/form/TextArrayFormField.tsx | 81 -- app/components/form/ToggleFormField.tsx | 49 - app/components/form/UserSearchFormField.tsx | 47 - app/components/form/form-utils.ts | 11 - app/features/admin/routes/admin.test.ts | 4 - app/features/api-private/routes/seed.ts | 18 +- .../AssociationRepository.server.ts | 18 + .../actions/associations.new.server.ts | 35 +- .../associations/associations-schemas.ts | 8 +- .../associations/routes/associations.new.tsx | 20 +- .../badges/components/BadgesSelector.tsx | 40 +- app/features/builds/BuildRepository.server.ts | 38 +- app/features/builds/builds-constants.ts | 4 - .../calendar/actions/calendar.new.server.ts | 1 - app/features/calendar/calendar-schemas.ts | 117 +- .../calendar/components/FiltersDialog.tsx | 215 +--- .../calendar/components/TagsFormField.tsx | 101 -- .../img-upload/ImageRepository.server.test.ts | 6 +- app/features/mmr/core/Seasons.ts | 4 +- .../scrims/actions/scrims.new.server.ts | 38 +- .../scrims/components/LutiDivsFormField.tsx | 104 -- .../scrims/components/ScrimFiltersDialog.tsx | 160 +-- .../scrims/components/ScrimRequestModal.tsx | 60 +- .../scrims/components/WithFormField.tsx | 182 +-- app/features/scrims/routes/scrims.$id.tsx | 17 +- .../scrims/routes/scrims.new.module.css | 4 + app/features/scrims/routes/scrims.new.test.ts | 6 +- app/features/scrims/routes/scrims.new.tsx | 292 +++-- app/features/scrims/scrims-schemas.ts | 223 ++-- .../QSettingsRepository.server.ts | 19 +- .../actions/q.settings.server.ts | 7 - .../q-settings-schemas.server.ts | 45 +- .../sendouq-settings/q-settings-schemas.ts | 38 + .../sendouq-settings/routes/q.settings.tsx | 300 +---- .../settings/actions/settings.server.ts | 6 +- app/features/settings/routes/settings.tsx | 211 ++-- app/features/settings/settings-schemas.ts | 58 +- app/features/team/TeamRepository.server.ts | 25 +- .../actions/t.$customUrl.edit.server.test.ts | 5 +- .../team/actions/t.$customUrl.edit.server.ts | 18 +- app/features/team/actions/t.server.test.ts | 16 +- app/features/team/actions/t.server.ts | 36 +- .../team/routes/t.$customUrl.edit.test.ts | 7 +- app/features/team/routes/t.$customUrl.test.ts | 5 +- app/features/team/routes/t.tsx | 28 +- app/features/team/team-schemas.server.ts | 31 +- app/features/team/team-schemas.ts | 17 + .../actions/org.$slug.edit.server.ts | 23 +- .../actions/org.$slug.server.ts | 3 +- .../components/BanUserModal.tsx | 45 +- .../routes/org.$slug.edit.tsx | 260 +--- .../routes/org.$slug.tsx | 33 +- .../routes/org.new.tsx | 22 +- .../tournament-organization-schemas.ts | 197 ++- .../u.$identifier.builds.new.server.ts | 157 +-- ...u.$identifier.results.highlights.server.ts | 4 +- .../components/NewBuildForm.browser.test.tsx | 206 +++ .../user-page/components/NewBuildForm.tsx | 164 +++ .../user-page/components/UserResultsTable.tsx | 7 +- .../u.$identifier.builds.new.server.ts | 58 +- .../user-page/routes/u.$identifier.admin.tsx | 22 +- .../routes/u.$identifier.builds.new.tsx | 316 +---- app/features/user-page/user-page-constants.ts | 3 + .../user-page/user-page-schemas.server.ts | 17 + app/features/user-page/user-page-schemas.ts | 150 ++- app/features/vods/VodRepository.server.ts | 2 +- app/features/vods/actions/vods.new.server.ts | 96 +- app/features/vods/routes/vods.$id.tsx | 2 +- .../vods/routes/vods.new.browser.test.tsx | 219 ++++ app/features/vods/routes/vods.new.module.css | 13 + app/features/vods/routes/vods.new.tsx | 759 +++++------ app/features/vods/vods-schemas.server.ts | 21 + app/features/vods/vods-schemas.ts | 110 +- app/form/FormField.tsx | 418 ++++++ app/form/SendouForm.browser.test.tsx | 1121 +++++++++++++++++ app/form/SendouForm.module.css | 13 + app/form/SendouForm.tsx | 399 ++++++ app/form/fields.ts | 710 +++++++++++ app/form/fields/ArrayFormField.module.css | 40 + app/form/fields/ArrayFormField.tsx | 138 ++ app/form/fields/BadgesFormField.tsx | 43 + app/form/fields/DatetimeFormField.tsx | 73 ++ app/form/fields/DualSelectFormField.tsx | 49 + app/form/fields/FieldsetFormField.tsx | 46 + app/form/fields/FormFieldWrapper.module.css | 14 + app/form/fields/FormFieldWrapper.tsx | 103 ++ app/form/fields/InputFormField.tsx | 54 + app/form/fields/InputGroupFormField.tsx | 164 +++ app/form/fields/SelectFormField.tsx | 83 ++ .../fields/StageSelectFormField.module.css | 3 + app/form/fields/StageSelectFormField.tsx | 38 + app/form/fields/SwitchFormField.tsx | 39 + app/form/fields/TextareaFormField.tsx | 47 + app/form/fields/TimeRangeFormField.tsx | 87 ++ .../fields/UserSearchFormField.module.css | 7 + app/form/fields/UserSearchFormField.tsx | 39 + .../fields/WeaponPoolFormField.module.css | 70 + app/form/fields/WeaponPoolFormField.tsx | 315 +++++ .../fields/WeaponSelectFormField.module.css | 3 + app/form/fields/WeaponSelectFormField.tsx | 39 + app/form/index.ts | 4 + app/form/parse.server.ts | 36 + app/form/types.ts | 270 ++++ app/form/utils.test.ts | 126 ++ app/form/utils.ts | 183 +++ app/modules/i18n/config.ts | 1 + app/modules/i18n/resources.browser.ts | 2 + app/modules/i18n/resources.server.ts | 32 + app/root.tsx | 2 +- app/styles/common.css | 4 + app/styles/elements.css | 1 + app/utils/dates.ts | 17 +- app/utils/e2e.ts | 6 +- app/utils/errors.ts | 6 + app/utils/form.ts | 21 - app/utils/i18n.ts | 1 + app/utils/kysely.server.ts | 13 - app/utils/playwright-form.ts | 258 ++++ app/utils/playwright.ts | 69 +- app/utils/remix.server.ts | 28 +- app/utils/zod.ts | 29 +- db-test.sqlite3 | Bin 1122304 -> 1122304 bytes docs/dev/forms.md | 753 +++++++++++ e2e/analyzer.spec.ts | 5 +- e2e/ban.spec.ts | 8 +- e2e/builds.spec.ts | 39 +- e2e/org.spec.ts | 47 +- e2e/scrims.spec.ts | 23 +- e2e/seeds/db-seed-DEFAULT.sqlite3 | Bin 6901760 -> 6905856 bytes e2e/seeds/db-seed-NO_SCRIMS.sqlite3 | Bin 6901760 -> 6885376 bytes e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 | Bin 6889472 -> 6893568 bytes e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 | Bin 6856704 -> 6864896 bytes e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 | Bin 6893568 -> 6897664 bytes e2e/seeds/db-seed-REG_OPEN.sqlite3 | Bin 6868992 -> 6868992 bytes e2e/seeds/db-seed-SMALL_SOS.sqlite3 | Bin 6893568 -> 6885376 bytes e2e/settings.spec.ts | 17 +- e2e/team.spec.ts | 8 +- e2e/tournament-bracket.spec.ts | 5 +- e2e/vods.spec.ts | 76 +- locales/da/builds.json | 4 +- locales/da/calendar.json | 1 - locales/da/common.json | 1 + locales/da/forms.json | 170 +++ locales/da/team.json | 1 + locales/de/builds.json | 4 +- locales/de/calendar.json | 1 - locales/de/common.json | 1 + locales/de/forms.json | 170 +++ locales/de/team.json | 1 + locales/en/builds.json | 4 +- locales/en/calendar.json | 1 - locales/en/common.json | 1 + locales/en/forms.json | 170 +++ locales/en/team.json | 1 + locales/es-ES/builds.json | 4 +- locales/es-ES/calendar.json | 1 - locales/es-ES/common.json | 1 + locales/es-ES/forms.json | 170 +++ locales/es-ES/team.json | 1 + locales/es-US/builds.json | 9 +- locales/es-US/calendar.json | 1 - locales/es-US/common.json | 1 + locales/es-US/forms.json | 170 +++ locales/es-US/team.json | 1 + locales/fr-CA/builds.json | 4 +- locales/fr-CA/calendar.json | 1 - locales/fr-CA/common.json | 1 + locales/fr-CA/forms.json | 170 +++ locales/fr-CA/team.json | 1 + locales/fr-EU/builds.json | 10 +- locales/fr-EU/calendar.json | 1 - locales/fr-EU/common.json | 1 + locales/fr-EU/forms.json | 170 +++ locales/fr-EU/team.json | 1 + locales/he/builds.json | 4 +- locales/he/calendar.json | 1 - locales/he/common.json | 1 + locales/he/forms.json | 170 +++ locales/he/team.json | 1 + locales/it/builds.json | 10 +- locales/it/calendar.json | 1 - locales/it/common.json | 1 + locales/it/forms.json | 170 +++ locales/it/team.json | 1 + locales/ja/builds.json | 10 +- locales/ja/calendar.json | 1 - locales/ja/common.json | 1 + locales/ja/forms.json | 170 +++ locales/ja/team.json | 1 + locales/ko/builds.json | 4 +- locales/ko/calendar.json | 1 - locales/ko/common.json | 1 + locales/ko/forms.json | 170 +++ locales/ko/team.json | 1 + locales/nl/builds.json | 4 +- locales/nl/calendar.json | 1 - locales/nl/common.json | 1 + locales/nl/forms.json | 170 +++ locales/nl/team.json | 1 + locales/pl/builds.json | 4 +- locales/pl/calendar.json | 1 - locales/pl/common.json | 1 + locales/pl/forms.json | 170 +++ locales/pl/team.json | 1 + locales/pt-BR/builds.json | 4 +- locales/pt-BR/calendar.json | 1 - locales/pt-BR/common.json | 1 + locales/pt-BR/forms.json | 170 +++ locales/pt-BR/team.json | 1 + locales/ru/builds.json | 10 +- locales/ru/calendar.json | 1 - locales/ru/common.json | 1 + locales/ru/forms.json | 170 +++ locales/ru/team.json | 1 + locales/zh/builds.json | 4 +- locales/zh/calendar.json | 1 - locales/zh/common.json | 1 + locales/zh/forms.json | 170 +++ locales/zh/team.json | 1 + package-lock.json | 36 - package.json | 2 - vitest.browser.config.ts | 3 + 242 files changed, 11673 insertions(+), 3795 deletions(-) create mode 100644 .beans.yml delete mode 100644 app/components/form/AddFieldButton.tsx delete mode 100644 app/components/form/DateFormField.tsx delete mode 100644 app/components/form/FormFieldset.tsx delete mode 100644 app/components/form/InputFormField.tsx delete mode 100644 app/components/form/InputGroupFormField.tsx delete mode 100644 app/components/form/RemoveFieldButton.tsx delete mode 100644 app/components/form/SelectFormField.tsx delete mode 100644 app/components/form/SendouForm.tsx delete mode 100644 app/components/form/TextAreaFormField.tsx delete mode 100644 app/components/form/TextArrayFormField.tsx delete mode 100644 app/components/form/ToggleFormField.tsx delete mode 100644 app/components/form/UserSearchFormField.tsx delete mode 100644 app/components/form/form-utils.ts delete mode 100644 app/features/calendar/components/TagsFormField.tsx delete mode 100644 app/features/scrims/components/LutiDivsFormField.tsx create mode 100644 app/features/scrims/routes/scrims.new.module.css create mode 100644 app/features/sendouq-settings/q-settings-schemas.ts create mode 100644 app/features/team/team-schemas.ts create mode 100644 app/features/user-page/components/NewBuildForm.browser.test.tsx create mode 100644 app/features/user-page/components/NewBuildForm.tsx create mode 100644 app/features/user-page/user-page-schemas.server.ts create mode 100644 app/features/vods/routes/vods.new.browser.test.tsx create mode 100644 app/features/vods/vods-schemas.server.ts create mode 100644 app/form/FormField.tsx create mode 100644 app/form/SendouForm.browser.test.tsx create mode 100644 app/form/SendouForm.module.css create mode 100644 app/form/SendouForm.tsx create mode 100644 app/form/fields.ts create mode 100644 app/form/fields/ArrayFormField.module.css create mode 100644 app/form/fields/ArrayFormField.tsx create mode 100644 app/form/fields/BadgesFormField.tsx create mode 100644 app/form/fields/DatetimeFormField.tsx create mode 100644 app/form/fields/DualSelectFormField.tsx create mode 100644 app/form/fields/FieldsetFormField.tsx create mode 100644 app/form/fields/FormFieldWrapper.module.css create mode 100644 app/form/fields/FormFieldWrapper.tsx create mode 100644 app/form/fields/InputFormField.tsx create mode 100644 app/form/fields/InputGroupFormField.tsx create mode 100644 app/form/fields/SelectFormField.tsx create mode 100644 app/form/fields/StageSelectFormField.module.css create mode 100644 app/form/fields/StageSelectFormField.tsx create mode 100644 app/form/fields/SwitchFormField.tsx create mode 100644 app/form/fields/TextareaFormField.tsx create mode 100644 app/form/fields/TimeRangeFormField.tsx create mode 100644 app/form/fields/UserSearchFormField.module.css create mode 100644 app/form/fields/UserSearchFormField.tsx create mode 100644 app/form/fields/WeaponPoolFormField.module.css create mode 100644 app/form/fields/WeaponPoolFormField.tsx create mode 100644 app/form/fields/WeaponSelectFormField.module.css create mode 100644 app/form/fields/WeaponSelectFormField.tsx create mode 100644 app/form/index.ts create mode 100644 app/form/parse.server.ts create mode 100644 app/form/types.ts create mode 100644 app/form/utils.test.ts create mode 100644 app/form/utils.ts create mode 100644 app/utils/errors.ts delete mode 100644 app/utils/form.ts create mode 100644 app/utils/playwright-form.ts create mode 100644 docs/dev/forms.md create mode 100644 locales/da/forms.json create mode 100644 locales/de/forms.json create mode 100644 locales/en/forms.json create mode 100644 locales/es-ES/forms.json create mode 100644 locales/es-US/forms.json create mode 100644 locales/fr-CA/forms.json create mode 100644 locales/fr-EU/forms.json create mode 100644 locales/he/forms.json create mode 100644 locales/it/forms.json create mode 100644 locales/ja/forms.json create mode 100644 locales/ko/forms.json create mode 100644 locales/nl/forms.json create mode 100644 locales/pl/forms.json create mode 100644 locales/pt-BR/forms.json create mode 100644 locales/ru/forms.json create mode 100644 locales/zh/forms.json diff --git a/.beans.yml b/.beans.yml new file mode 100644 index 000000000..3c66f5549 --- /dev/null +++ b/.beans.yml @@ -0,0 +1,6 @@ +beans: + path: .beans + prefix: sendou.ink-2- + id_length: 4 + default_status: todo + default_type: task diff --git a/.claude/settings.json b/.claude/settings.json index 22479b435..7e05cd744 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,7 +1,4 @@ { - "enabledPlugins": { - "code-review@claude-plugins-official": true - }, "hooks": { "SessionStart": [ { "hooks": [{ "type": "command", "command": "beans prime" }] } @@ -9,5 +6,8 @@ "PreCompact": [ { "hooks": [{ "type": "command", "command": "beans prime" }] } ] + }, + "enabledPlugins": { + "code-review@claude-plugins-official": true } } diff --git a/AGENTS.md b/AGENTS.md index de029e770..3f6b7896b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,7 @@ - `npm run test:unit:browser` runs all unit tests and browser tests - `npm run test:e2e` runs all e2e tests - `npm run test:e2e:flaky-detect` runs all e2e tests and repeats each 10 times -- `npm run i18n:sync` syncs translation jsons with English and should always be run after adding new text to an English translation file +- `npm run i18n:sync` syncs translation jsons with English ## Typescript @@ -68,3 +68,15 @@ ## Unit testing - library used for unit testing is Vitest +- Vitest browser mode can be used to write tests for components + +## Testing in Chrome + +- some pages need authentication, you should impersonate "Sendou" user which can be done on the /admin page + +## i18n + +- by default everything should be translated via i18next +- some a11y labels or text that should not normally be encountered by user (example given, error message by server) can be english +- before adding a new translation, check that one doesn't already exist you can reuse (particularly in the common.json) +- add only English translation and use `npm run i18n:sync` to initialize other jsons with empty string ready for translators diff --git a/app/components/FormMessage.tsx b/app/components/FormMessage.tsx index 4e90444f0..f1d6397cc 100644 --- a/app/components/FormMessage.tsx +++ b/app/components/FormMessage.tsx @@ -5,15 +5,21 @@ export function FormMessage({ children, type, className, + spaced = true, + id, }: { children: React.ReactNode; type: "error" | "info"; className?: string; + spaced?: boolean; + id?: string; }) { return (
diff --git a/app/components/WeaponSelect.module.css b/app/components/WeaponSelect.module.css index bdb895e8d..ee4092d3b 100644 --- a/app/components/WeaponSelect.module.css +++ b/app/components/WeaponSelect.module.css @@ -1,5 +1,5 @@ .selectWidthWider { - --select-width: 250px; + --select-width: 100%; } .item { diff --git a/app/components/WeaponSelect.tsx b/app/components/WeaponSelect.tsx index 3d501f2fa..39004bc41 100644 --- a/app/components/WeaponSelect.tsx +++ b/app/components/WeaponSelect.tsx @@ -46,6 +46,8 @@ interface WeaponSelectProps< isRequired?: boolean; /** If set, selection of weapons that user sees when search input is empty allowing for quick select for e.g. previous selections */ quickSelectWeaponsIds?: Array; + isDisabled?: boolean; + placeholder?: string; } export function WeaponSelect< @@ -62,11 +64,20 @@ export function WeaponSelect< testId = "weapon-select", isRequired, quickSelectWeaponsIds, + isDisabled, + placeholder, }: WeaponSelectProps) { const { t } = useTranslation(["common"]); + const selectedWeaponId: MainWeaponId | null = + typeof value === "number" + ? (value as MainWeaponId) + : value && typeof value === "object" && value.type === "MAIN" + ? (value.id as MainWeaponId) + : null; const { items, filterValue, setFilterValue } = useWeaponItems({ includeSubSpecial, quickSelectWeaponsIds, + selectedWeaponId, }); const filter = useWeaponFilter(); @@ -97,9 +108,10 @@ export function WeaponSelect< aria-label={ !label ? t("common:forms.weaponSearch.placeholder") : undefined } + isDisabled={isDisabled} items={items} label={label} - placeholder={t("common:forms.weaponSearch.placeholder")} + placeholder={placeholder ?? t("common:forms.weaponSearch.placeholder")} search={{ placeholder: t("common:forms.weaponSearch.search.placeholder"), }} @@ -217,9 +229,11 @@ function useWeaponFilter() { function useWeaponItems({ includeSubSpecial, quickSelectWeaponsIds, + selectedWeaponId, }: { includeSubSpecial: boolean | undefined; quickSelectWeaponsIds?: Array; + selectedWeaponId?: MainWeaponId | null; }) { const items = useAllWeaponCategories(includeSubSpecial); const [filterValue, setFilterValue] = React.useState(""); @@ -229,6 +243,11 @@ function useWeaponItems({ filterValue === "" && quickSelectWeaponsIds?.length; if (showQuickSelectWeapons) { + const weaponIdsToInclude = new Set(quickSelectWeaponsIds); + if (typeof selectedWeaponId === "number") { + weaponIdsToInclude.add(selectedWeaponId); + } + const quickSelectCategory = { idx: 0, key: "quick-select" as const, @@ -240,7 +259,7 @@ function useWeaponItems({ .filter((val) => val !== null), ) .filter((item) => - quickSelectWeaponsIds.includes(item.weapon.id as MainWeaponId), + weaponIdsToInclude.has(item.weapon.id as MainWeaponId), ) .sort((a, b) => { const aIdx = quickSelectWeaponsIds.indexOf( diff --git a/app/components/elements/BottomTexts.tsx b/app/components/elements/BottomTexts.tsx index 72a75984c..4837cf91a 100644 --- a/app/components/elements/BottomTexts.tsx +++ b/app/components/elements/BottomTexts.tsx @@ -4,18 +4,20 @@ import { SendouFieldMessage } from "~/components/elements/FieldMessage"; export function SendouBottomTexts({ bottomText, errorText, + errorId, }: { bottomText?: string; errorText?: string; + errorId?: string; }) { return ( <> {errorText ? ( - {errorText} + {errorText} ) : ( )} - {bottomText && !errorText ? ( + {bottomText ? ( {bottomText} ) : null} diff --git a/app/components/elements/DatePicker.tsx b/app/components/elements/DatePicker.tsx index 050103795..1fa69deeb 100644 --- a/app/components/elements/DatePicker.tsx +++ b/app/components/elements/DatePicker.tsx @@ -1,4 +1,3 @@ -import clsx from "clsx"; import { Button, DateInput, @@ -12,10 +11,7 @@ import { } from "react-aria-components"; import { SendouBottomTexts } from "~/components/elements/BottomTexts"; import { SendouCalendar } from "~/components/elements/Calendar"; -import { - type FormFieldSize, - formFieldSizeToClassName, -} from "../form/form-utils"; +import { useIsMounted } from "~/hooks/useIsMounted"; import { CalendarIcon } from "../icons/Calendar"; import { SendouLabel } from "./Label"; @@ -24,29 +20,52 @@ interface SendouDatePickerProps label: string; bottomText?: string; errorText?: string; - size?: FormFieldSize; + errorId?: string; } export function SendouDatePicker({ label, errorText, + errorId, bottomText, - size, isRequired, ...rest }: SendouDatePickerProps) { + const isMounted = useIsMounted(); + + if (!isMounted) { + return ( +
+ {label} + + +
+ ); + } + return ( - + {label} - + {(segment) => } - + diff --git a/app/components/elements/FieldError.tsx b/app/components/elements/FieldError.tsx index 2162de14e..7ea32e06e 100644 --- a/app/components/elements/FieldError.tsx +++ b/app/components/elements/FieldError.tsx @@ -1,8 +1,14 @@ import { FieldError as ReactAriaFieldError } from "react-aria-components"; -export function SendouFieldError({ children }: { children?: React.ReactNode }) { +export function SendouFieldError({ + children, + id, +}: { + children?: React.ReactNode; + id?: string; +}) { return ( - + {children} ); diff --git a/app/components/elements/Select.module.css b/app/components/elements/Select.module.css index 2903e73f5..9e61f19cc 100644 --- a/app/components/elements/Select.module.css +++ b/app/components/elements/Select.module.css @@ -12,7 +12,7 @@ align-items: center; justify-content: space-between; gap: var(--s-1-5); - min-width: var(--select-width); + width: var(--select-width); font-size: var(--fonts-xs); font-weight: var(--semi-bold); @@ -50,8 +50,7 @@ .popover { padding: var(--s-1); - min-width: var(--select-width); - max-width: var(--select-width); + width: var(--trigger-width); border: 2px solid var(--border); border-radius: var(--rounded); background-color: var(--bg-darker); diff --git a/app/components/elements/UserSearch.tsx b/app/components/elements/UserSearch.tsx index a0572dc97..abc081286 100644 --- a/app/components/elements/UserSearch.tsx +++ b/app/components/elements/UserSearch.tsx @@ -82,7 +82,7 @@ export const UserSearch = React.forwardRef(function UserSearch< placeholder="" selectedKey={selectedKey} onSelectionChange={onSelectionChange as (key: Key | null) => void} - aria-label="User search" + {...(label ? {} : { "aria-label": "User search" })} {...rest} > {label ? ( diff --git a/app/components/form/AddFieldButton.tsx b/app/components/form/AddFieldButton.tsx deleted file mode 100644 index b228c8c23..000000000 --- a/app/components/form/AddFieldButton.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { SendouButton } from "../elements/Button"; -import { PlusIcon } from "../icons/Plus"; - -export function AddFieldButton({ onClick }: { onClick: () => void }) { - const { t } = useTranslation(["common"]); - - return ( - } - aria-label="Add form field" - size="small" - variant="minimal" - onPress={onClick} - className="self-start" - data-testid="add-field-button" - > - {t("common:actions.add")} - - ); -} diff --git a/app/components/form/DateFormField.tsx b/app/components/form/DateFormField.tsx deleted file mode 100644 index 01e5158b8..000000000 --- a/app/components/form/DateFormField.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import type { CalendarDateTime } from "@internationalized/date"; -import { - Controller, - type FieldPath, - type FieldValues, - useFormContext, -} from "react-hook-form"; -import { dateToDateValue, dayMonthYearToDateValue } from "../../utils/dates"; -import type { DayMonthYear } from "../../utils/zod"; -import { SendouDatePicker } from "../elements/DatePicker"; -import type { FormFieldSize } from "./form-utils"; - -export function DateFormField({ - label, - name, - bottomText, - required, - size, - granularity = "day", -}: { - label: string; - name: FieldPath; - bottomText?: string; - required?: boolean; - size?: FormFieldSize; - granularity?: "day" | "minute"; -}) { - const methods = useFormContext(); - - return ( - { - const getValue = () => { - const originalValue = value as DayMonthYear | Date | null; - - if (!originalValue) return null; - - if (originalValue instanceof Date) { - return dateToDateValue(originalValue); - } - - return dayMonthYearToDateValue(originalValue as DayMonthYear); - }; - - return ( - { - if (value) { - if (granularity === "minute") { - onChange( - new Date( - value.year, - value.month - 1, - value.day, - (value as CalendarDateTime).hour, - (value as CalendarDateTime).minute, - ), - ); - } else { - onChange({ - day: value.day, - month: value.month - 1, - year: value.year, - }); - } - } - - if (!value) { - onChange(null); - } - }} - bottomText={bottomText} - /> - ); - }} - /> - ); -} diff --git a/app/components/form/FormFieldset.tsx b/app/components/form/FormFieldset.tsx deleted file mode 100644 index ac84a82be..000000000 --- a/app/components/form/FormFieldset.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import type * as React from "react"; -import { RemoveFieldButton } from "./RemoveFieldButton"; - -export function FormFieldset({ - title, - children, - onRemove, -}: { - title: string; - children: React.ReactNode; - onRemove: () => void; -}) { - return ( -
- {title} -
- {children} - -
- -
-
-
- ); -} diff --git a/app/components/form/InputFormField.tsx b/app/components/form/InputFormField.tsx deleted file mode 100644 index 9353f650e..000000000 --- a/app/components/form/InputFormField.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import * as React from "react"; -import { - type FieldPath, - type FieldValues, - get, - useFormContext, -} from "react-hook-form"; -import { FormMessage } from "~/components/FormMessage"; -import { Label } from "~/components/Label"; -import { type FormFieldSize, formFieldSizeToClassName } from "./form-utils"; - -export function InputFormField({ - label, - name, - bottomText, - placeholder, - required, - size = "small", - type, -}: { - label: string; - name: FieldPath; - bottomText?: string; - placeholder?: string; - required?: boolean; - size?: FormFieldSize; - type?: React.HTMLInputTypeAttribute; -}) { - const methods = useFormContext(); - const id = React.useId(); - - const error = get(methods.formState.errors, name); - - return ( -
- - - {error && ( - {error.message as string} - )} - {bottomText && !error ? ( - {bottomText} - ) : null} -
- ); -} diff --git a/app/components/form/InputGroupFormField.tsx b/app/components/form/InputGroupFormField.tsx deleted file mode 100644 index 07c0ed7a1..000000000 --- a/app/components/form/InputGroupFormField.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import clsx from "clsx"; -import * as React from "react"; -import { - Controller, - type FieldPath, - type FieldValues, - useFormContext, -} from "react-hook-form"; -import { FormMessage } from "~/components/FormMessage"; - -interface InputGroupFormFieldProps { - label: string; - name: FieldPath; - bottomText?: string; - direction?: "horizontal" | "vertical"; - type: "checkbox" | "radio"; - values: Array<{ - label: string; - value: string; - }>; -} - -export function InputGroupFormField({ - label, - name, - bottomText, - values, - type, - direction = "vertical", -}: InputGroupFormFieldProps) { - const methods = useFormContext(); - - return ( - { - const handleCheckboxChange = - (name: string) => (newChecked: boolean) => { - const newValue = newChecked - ? [...(value || []), name] - : value?.filter((v: string) => v !== name); - - onChange(newValue); - }; - - const handleRadioChange = (name: string) => () => { - onChange(name); - }; - - return ( -
-
- {label} - - {values.map((checkbox) => { - const isChecked = value?.includes(checkbox.value); - - return ( - - {checkbox.label} - - ); - })} -
- {error && ( - {error.message as string} - )} - {bottomText && !error ? ( - {bottomText} - ) : null} -
- ); - }} - /> - ); -} - -function GroupInput({ - children, - name, - checked, - onChange, - type, -}: { - children: React.ReactNode; - name: string; - checked: boolean; - onChange: (newChecked: boolean) => void; - type: "checkbox" | "radio"; -}) { - const id = React.useId(); - - return ( -
- onChange(e.target.checked)} - /> - -
- ); -} diff --git a/app/components/form/RemoveFieldButton.tsx b/app/components/form/RemoveFieldButton.tsx deleted file mode 100644 index 9cb3a1831..000000000 --- a/app/components/form/RemoveFieldButton.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { SendouButton } from "../elements/Button"; -import { TrashIcon } from "../icons/Trash"; - -export function RemoveFieldButton({ onClick }: { onClick: () => void }) { - return ( - } - aria-label="Remove form field" - size="small" - variant="minimal-destructive" - onPress={onClick} - /> - ); -} diff --git a/app/components/form/SelectFormField.tsx b/app/components/form/SelectFormField.tsx deleted file mode 100644 index ed45b25e8..000000000 --- a/app/components/form/SelectFormField.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import * as React from "react"; -import { - type FieldPath, - type FieldValues, - get, - useFormContext, -} from "react-hook-form"; -import { FormMessage } from "~/components/FormMessage"; -import { Label } from "~/components/Label"; -import { type FormFieldSize, formFieldSizeToClassName } from "./form-utils"; - -export function SelectFormField({ - label, - name, - values, - bottomText, - size, - required, -}: { - label: string; - name: FieldPath; - values: Array<{ value: string | number; label: string }>; - bottomText?: string; - size?: FormFieldSize; - required?: boolean; -}) { - const methods = useFormContext(); - const id = React.useId(); - - const error = get(methods.formState.errors, name); - - return ( -
- - - {error && ( - {error.message as string} - )} - {bottomText && !error ? ( - {bottomText} - ) : null} -
- ); -} diff --git a/app/components/form/SendouForm.tsx b/app/components/form/SendouForm.tsx deleted file mode 100644 index 3f46dad97..000000000 --- a/app/components/form/SendouForm.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { standardSchemaResolver } from "@hookform/resolvers/standard-schema"; -import * as React from "react"; -import { type DefaultValues, FormProvider, useForm } from "react-hook-form"; -import { useTranslation } from "react-i18next"; -import { useFetcher } from "react-router"; -import type { z } from "zod"; -import { logger } from "~/utils/logger"; -import type { ActionError } from "~/utils/remix.server"; -import { LinkButton } from "../elements/Button"; -import { SubmitButton } from "../SubmitButton"; - -export function SendouForm({ - schema, - defaultValues, - heading, - children, - cancelLink, - submitButtonTestId, -}: { - schema: T; - defaultValues?: DefaultValues>; - heading?: string; - children: React.ReactNode; - cancelLink?: string; - submitButtonTestId?: string; -}) { - const { t } = useTranslation(["common"]); - const fetcher = useFetcher(); - const methods = useForm({ - resolver: standardSchemaResolver(schema as any), - defaultValues, - }); - - if (methods.formState.isSubmitted && methods.formState.errors) { - logger.error(methods.formState.errors); - } - - React.useEffect(() => { - if (!fetcher.data?.isError) return; - - const error = fetcher.data as ActionError; - - methods.setError(error.field as any, { - message: error.msg, - }); - }, [fetcher.data, methods.setError]); - - const onSubmit = React.useCallback( - methods.handleSubmit((values) => - fetcher.submit(values as Parameters[0], { - method: "post", - encType: "application/json", - }), - ), - [], - ); - - return ( - - - {heading ?

{heading}

: null} - {children} -
- - {t("common:actions.submit")} - - {cancelLink ? ( - - {t("common:actions.cancel")} - - ) : null} -
-
-
- ); -} diff --git a/app/components/form/TextAreaFormField.tsx b/app/components/form/TextAreaFormField.tsx deleted file mode 100644 index 772194e20..000000000 --- a/app/components/form/TextAreaFormField.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import * as React from "react"; -import { - type FieldPath, - type FieldValues, - get, - useFormContext, - useWatch, -} from "react-hook-form"; -import { FormMessage } from "~/components/FormMessage"; -import { Label } from "~/components/Label"; - -export function TextAreaFormField({ - label, - name, - bottomText, - maxLength, -}: { - label: string; - name: FieldPath; - bottomText?: string; - maxLength: number; -}) { - const methods = useFormContext(); - const value = useWatch({ name }) ?? ""; - const id = React.useId(); - - const error = get(methods.formState.errors, name); - - return ( -
- -