diff --git a/frontend/src/ts/components/common/AnimatedModal.tsx b/frontend/src/ts/components/common/AnimatedModal.tsx index e7af3d3d599d..b0711d08c3e6 100644 --- a/frontend/src/ts/components/common/AnimatedModal.tsx +++ b/frontend/src/ts/components/common/AnimatedModal.tsx @@ -73,6 +73,7 @@ export function AnimatedModal(props: AnimatedModalProps): JSXElement { const showModal = async (isChained: boolean): Promise => { if (dialogEl() === undefined || modalEl() === undefined) return; + if (dialogEl()?.native.open) return; await props.beforeShow?.(); @@ -265,7 +266,7 @@ export function AnimatedModal(props: AnimatedModalProps): JSXElement { if (modalEl() === undefined || dialogEl() === undefined) return; if (props.focusFirstInput === undefined) return; - const input = modalEl()?.qs("input:not(.hidden)"); + const input = modalEl()?.qsa("input:not(.hidden)")[0]; if (input) { if (props.focusFirstInput === true) { input.focus(); diff --git a/frontend/src/ts/components/modals/Modals.tsx b/frontend/src/ts/components/modals/Modals.tsx index 97660402c556..0c8a5c21c784 100644 --- a/frontend/src/ts/components/modals/Modals.tsx +++ b/frontend/src/ts/components/modals/Modals.tsx @@ -2,6 +2,7 @@ import { JSXElement } from "solid-js"; import { ContactModal } from "./ContactModal"; import { RegisterCaptchaModal } from "./RegisterCaptchaModal"; +import { SimpleModal } from "./SimpleModal"; import { SupportModal } from "./SupportModal"; import { VersionHistoryModal } from "./VersionHistoryModal"; @@ -12,6 +13,7 @@ export function Modals(): JSXElement { + ); } diff --git a/frontend/src/ts/components/modals/SimpleModal.tsx b/frontend/src/ts/components/modals/SimpleModal.tsx new file mode 100644 index 000000000000..cd53d9a62432 --- /dev/null +++ b/frontend/src/ts/components/modals/SimpleModal.tsx @@ -0,0 +1,300 @@ +import { AnyFieldApi, createForm } from "@tanstack/solid-form"; +import { format as dateFormat } from "date-fns/format"; +import { + Accessor, + For, + JSXElement, + Match, + Show, + Switch, + untrack, +} from "solid-js"; + +import { showNoticeNotification } from "../../states/notifications"; +import { + simpleModalConfig, + SimpleModalInput, + executeSimpleModal, +} from "../../states/simple-modal"; +import { cn } from "../../utils/cn"; +import { AnimatedModal } from "../common/AnimatedModal"; +import { Checkbox } from "../ui/form/Checkbox"; +import { InputField } from "../ui/form/InputField"; +import { SubmitButton } from "../ui/form/SubmitButton"; +import { fromSchema, fieldMandatory, handleResult } from "../ui/form/utils"; + +type FormValues = Record; + +type SyncValidator = (opts: { + value: string | boolean; +}) => string | string[] | undefined; + +type AsyncValidator = (opts: { + value: string | boolean; + fieldApi: AnyFieldApi; +}) => Promise; + +type SimpleModalValidators = { + onChange?: SyncValidator; + onChangeAsyncDebounceMs?: number; + onChangeAsync?: AsyncValidator; +}; + +function inputKey(input: SimpleModalInput, index: number): string { + return input.name ?? index.toString(); +} + +function getDefaultValues(inputs: SimpleModalInput[] | undefined): FormValues { + if (inputs === undefined || inputs.length === 0) { + return {}; + } + const entries: [string, string | boolean][] = inputs.map((input, i) => { + const key = inputKey(input, i); + if (input.type === "checkbox") { + return [key, input.initVal ?? false]; + } + if (input.type === "datetime-local" && input.initVal !== undefined) { + return [key, dateFormat(input.initVal, "yyyy-MM-dd'T'HH:mm:ss")]; + } + if (input.type === "date" && input.initVal !== undefined) { + return [key, dateFormat(input.initVal, "yyyy-MM-dd")]; + } + return [key, input.initVal?.toString() ?? ""]; + }); + return Object.fromEntries(entries) as FormValues; +} + +function getValidators( + input: SimpleModalInput, +): SimpleModalValidators | undefined { + const required = + !input.hidden && !input.optional && input.type !== "checkbox"; + + const schema = input.validation?.schema; + const isValid = input.validation?.isValid; + + if (schema === undefined && isValid === undefined && !required) { + return undefined; + } + + const validators: SimpleModalValidators = {}; + + if (schema !== undefined) { + validators.onChange = fromSchema(schema) as SyncValidator; + } else if (required) { + validators.onChange = fieldMandatory() as SyncValidator; + } + + if (isValid !== undefined) { + validators.onChangeAsyncDebounceMs = input.validation?.debounceDelay ?? 100; + validators.onChangeAsync = async ({ value, fieldApi }) => { + const result = await isValid(String(value)); + if (result === true) { + return undefined; + } + if (typeof result === "string") { + return result; + } + return handleResult(fieldApi, [ + { type: "warning", message: result.warning }, + ]); + }; + } + + return validators; +} + +function FieldInput(props: { + field: Accessor; + input: SimpleModalInput; +}): JSXElement { + return ( + + } + > + + + + + + + +
+ { + props.field().handleChange(e.currentTarget.value); + props.input.oninput?.(e); + }} + onBlur={() => props.field().handleBlur()} + /> + {props.field().state.value as string} +
+
+ + { + props.field().handleChange(e.currentTarget.value); + props.input.oninput?.(e); + }} + onBlur={() => props.field().handleBlur()} + /> + +
+ ); +} + +export function SimpleModal(): JSXElement { + const config = simpleModalConfig; + + // untrack prevents tanstack's internal createComputed from + // re-running api.update() when config changes, which would + // cause a re-render cascade during the modal's show animation. + const form = createForm(() => ({ + defaultValues: untrack(() => getDefaultValues(config()?.inputs)), + onSubmit: async ({ value }) => { + const inputs = config()?.inputs ?? []; + const values = inputs.map((input, i) => { + const val = value[inputKey(input, i)]; + if (typeof val === "boolean") { + return val ? "true" : "false"; + } + return val?.toString() ?? ""; + }); + await executeSimpleModal(values); + }, + onSubmitInvalid: () => { + showNoticeNotification("Please fill in all fields"); + }, + })); + + const resetForm = (): void => { + const defaults = getDefaultValues(config()?.inputs); + form.update({ ...form.options, defaultValues: defaults }); + form.reset(); + }; + + return ( + +
{ + e.preventDefault(); + e.stopPropagation(); + void form.handleSubmit(); + }} + > + + {(text) => ( +
+ )} +
+ 0}> +
+ + {(input, i) => ( + + ( + } + > + + + )} + /> + + )} + +
+
+ + + +
+
+ ); +} diff --git a/frontend/src/ts/components/pages/leaderboard/Navigation.tsx b/frontend/src/ts/components/pages/leaderboard/Navigation.tsx index 58518603fad6..67b6bca974df 100644 --- a/frontend/src/ts/components/pages/leaderboard/Navigation.tsx +++ b/frontend/src/ts/components/pages/leaderboard/Navigation.tsx @@ -1,39 +1,10 @@ import { JSXElement, Setter, Show } from "solid-js"; -import { ExecReturn, SimpleModal } from "../../../elements/simple-modal"; import { setPage } from "../../../states/leaderboard-selection"; +import { showSimpleModal } from "../../../states/simple-modal"; import { cn } from "../../../utils/cn"; import { Button } from "../../common/Button"; import { LoadingCircle } from "../../common/LoadingCircle"; - -const goToPageModal = new SimpleModal({ - id: "lbGoToPage", - title: "Go to page", - inputs: [ - { - type: "number", - placeholder: "Page number", - }, - ], - buttonText: "Go", - execFn: async (_thisPopup, pageNumber): Promise => { - const page = parseInt(pageNumber, 10); - if (isNaN(page) || page < 1) { - return { - status: "notice", - message: "Invalid page number", - }; - } - - setPage(page - 1); - - return { - status: "success", - message: "Navigating to page " + page, - showNotification: false, - }; - }, -}); export function Navigation(props: { lastPage: number; userPage?: number; @@ -84,7 +55,30 @@ export function Navigation(props: { class={buttonClass} />