diff --git a/.claude/settings.json b/.claude/settings.json index 5b5d98b06..7e05cd744 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -6,5 +6,8 @@ "PreCompact": [ { "hooks": [{ "type": "command", "command": "beans prime" }] } ] + }, + "enabledPlugins": { + "code-review@claude-plugins-official": true } } diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 544eb0564..8e7304398 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -22,6 +22,22 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Start MinIO + run: docker compose up -d minio + + - name: Wait for MinIO to be ready + run: | + for i in {1..30}; do + if curl -sf http://127.0.0.1:9000/minio/health/live; then + echo "MinIO is ready" + exit 0 + fi + echo "Waiting for MinIO... ($i/30)" + sleep 2 + done + echo "MinIO failed to start" + exit 1 + - uses: actions/setup-node@v4 with: node-version-file: '.nvmrc' @@ -36,6 +52,10 @@ jobs: - name: Run E2E tests run: npm run test:e2e + - name: Stop MinIO + if: always() + run: docker compose down + - uses: actions/upload-artifact@v4 if: failure() with: diff --git a/.gitignore b/.gitignore index 00032c0c3..b967093a7 100644 --- a/.gitignore +++ b/.gitignore @@ -30,5 +30,6 @@ dump # Vitest auto-captured failure screenshots (numbered, without browser info) # Real baselines have pattern: *-chromium-darwin.png **/__screenshots__/**/*-[0-9].png +.e2e-minio-started notepad.txt diff --git a/AGENTS.md b/AGENTS.md index 76656c4ff..6cabdd4d7 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 @@ -74,3 +74,10 @@ ## 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/Catcher.tsx b/app/components/Catcher.tsx index 013d7ad60..3e0a946be 100644 --- a/app/components/Catcher.tsx +++ b/app/components/Catcher.tsx @@ -7,6 +7,7 @@ import { } from "react-router"; import { useLocation } from "react-use"; import { useUser } from "~/features/auth/core/user"; +import { getSessionId } from "~/utils/session-id"; import { ERROR_GIRL_IMAGE_PATH, LOG_IN_URL, @@ -52,10 +53,11 @@ export function Catcher() { } if (!isRouteErrorResponse(error)) { + const sessionId = getSessionId(); const errorText = (() => { if (!(error instanceof Error)) return; - return `Time: ${new Date().toISOString()}\nURL: ${location.href}\nUser ID: ${user?.id ?? "Not logged in"}\n${error.stack ?? error.message}`; + return `Session ID: ${sessionId}\nTime: ${new Date().toISOString()}\nURL: ${location.href}\nUser ID: ${user?.id ?? "Not logged in"}\n${error.stack ?? error.message}`; })(); return ( @@ -124,12 +126,15 @@ export function Catcher() {

Error {error.status}

- Please include the message below if any and an explanation on what - you were doing: + Please include the session ID and message below if any and an + explanation on what you were doing:
- {error.data ? ( -
{JSON.stringify(JSON.parse(error.data), null, 2)}
- ) : null} +
+						Session ID: {getSessionId()}
+						{error.data
+							? `\n${JSON.stringify(JSON.parse(error.data), null, 2)}`
+							: null}
+					
); } diff --git a/app/components/Draggable.tsx b/app/components/Draggable.tsx deleted file mode 100644 index 6cf427563..000000000 --- a/app/components/Draggable.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { useSortable } from "@dnd-kit/sortable"; -import { CSS } from "@dnd-kit/utilities"; -import type * as React from "react"; - -export function Draggable({ - id, - disabled, - liClassName, - children, - testId, -}: { - id: number; - disabled: boolean; - liClassName: string; - children: React.ReactNode; - testId?: string; -}) { - const { attributes, listeners, setNodeRef, transform, transition } = - useSortable({ id, disabled }); - - const style = { - transform: CSS.Transform.toString(transform), - transition, - }; - - return ( -
  • - {children} -
  • - ); -} diff --git a/app/components/FormMessage.module.css b/app/components/FormMessage.module.css index 36e79e71c..c21016ea2 100644 --- a/app/components/FormMessage.module.css +++ b/app/components/FormMessage.module.css @@ -11,3 +11,7 @@ font-size: var(--fonts-xs); margin-block-start: var(--label-margin); } + +.noMargin { + margin-block-start: 0; +} diff --git a/app/components/FormMessage.tsx b/app/components/FormMessage.tsx index 9693e4e67..54562586a 100644 --- a/app/components/FormMessage.tsx +++ b/app/components/FormMessage.tsx @@ -6,15 +6,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 f76f663ff..ee4092d3b 100644 --- a/app/components/WeaponSelect.module.css +++ b/app/components/WeaponSelect.module.css @@ -1,3 +1,7 @@ +.selectWidthWider { + --select-width: 100%; +} + .item { display: flex; gap: var(--s-2); diff --git a/app/components/WeaponSelect.tsx b/app/components/WeaponSelect.tsx index f8f2f433d..3ec0ebd3f 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"), }} @@ -215,9 +227,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(""); @@ -227,6 +241,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, @@ -238,7 +257,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 c2800fa59..f7951a9cd 100644 --- a/app/components/elements/DatePicker.tsx +++ b/app/components/elements/DatePicker.tsx @@ -12,6 +12,7 @@ import { } from "react-aria-components"; import { SendouBottomTexts } from "~/components/elements/BottomTexts"; import { SendouCalendar } from "~/components/elements/Calendar"; +import { useIsMounted } from "~/hooks/useIsMounted"; import styles from "./DatePicker.module.css"; import { SendouLabel } from "./Label"; @@ -20,15 +21,33 @@ interface SendouDatePickerProps label: string; bottomText?: string; errorText?: string; + errorId?: string; } export function SendouDatePicker({ label, errorText, + errorId, bottomText, isRequired, ...rest }: SendouDatePickerProps) { + const isMounted = useIsMounted(); + + if (!isMounted) { + return ( +
    + {label} + + +
    + ); + } + return ( ({ - + 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/UserSearch.tsx b/app/components/elements/UserSearch.tsx index 5913cc923..597f344be 100644 --- a/app/components/elements/UserSearch.tsx +++ b/app/components/elements/UserSearch.tsx @@ -83,7 +83,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 d0f08a68b..000000000 --- a/app/components/form/AddFieldButton.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { Plus } from "lucide-react"; -import { useTranslation } from "react-i18next"; -import { SendouButton } from "../elements/Button"; - -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 475d4dd61..000000000 --- a/app/components/form/DateFormField.tsx +++ /dev/null @@ -1,88 +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"; - -export function DateFormField({ - label, - name, - bottomText, - required, - granularity = "day", -}: { - label: string; - name: FieldPath; - bottomText?: string; - required?: boolean; - 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 109fc0e70..000000000 --- a/app/components/form/InputFormField.tsx +++ /dev/null @@ -1,50 +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"; - -export function InputFormField({ - label, - name, - bottomText, - placeholder, - required, - type, -}: { - label: string; - name: FieldPath; - bottomText?: string; - placeholder?: string; - required?: boolean; - 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 2d75f9bd4..000000000 --- a/app/components/form/RemoveFieldButton.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { Trash } from "lucide-react"; -import { SendouButton } from "../elements/Button"; - -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 e7dc898e3..000000000 --- a/app/components/form/SelectFormField.tsx +++ /dev/null @@ -1,49 +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"; - -export function SelectFormField({ - label, - name, - values, - bottomText, - required, -}: { - label: string; - name: FieldPath; - values: Array<{ value: string | number; label: string }>; - bottomText?: string; - 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 ( -
    - -