Skip to content

Commit 1cbeb68

Browse files
authored
impr: add solidjs simple modal (@Miodec) (#7681)
!nuf
1 parent b67443e commit 1cbeb68

8 files changed

Lines changed: 468 additions & 35 deletions

File tree

frontend/src/ts/components/common/AnimatedModal.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ export function AnimatedModal(props: AnimatedModalProps): JSXElement {
7373

7474
const showModal = async (isChained: boolean): Promise<void> => {
7575
if (dialogEl() === undefined || modalEl() === undefined) return;
76+
if (dialogEl()?.native.open) return;
7677

7778
await props.beforeShow?.();
7879

@@ -265,7 +266,7 @@ export function AnimatedModal(props: AnimatedModalProps): JSXElement {
265266
if (modalEl() === undefined || dialogEl() === undefined) return;
266267
if (props.focusFirstInput === undefined) return;
267268

268-
const input = modalEl()?.qs<HTMLInputElement>("input:not(.hidden)");
269+
const input = modalEl()?.qsa<HTMLInputElement>("input:not(.hidden)")[0];
269270
if (input) {
270271
if (props.focusFirstInput === true) {
271272
input.focus();

frontend/src/ts/components/modals/Modals.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { JSXElement } from "solid-js";
22

33
import { ContactModal } from "./ContactModal";
44
import { RegisterCaptchaModal } from "./RegisterCaptchaModal";
5+
import { SimpleModal } from "./SimpleModal";
56
import { SupportModal } from "./SupportModal";
67
import { VersionHistoryModal } from "./VersionHistoryModal";
78

@@ -12,6 +13,7 @@ export function Modals(): JSXElement {
1213
<ContactModal />
1314
<RegisterCaptchaModal />
1415
<SupportModal />
16+
<SimpleModal />
1517
</>
1618
);
1719
}
Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
1+
import { AnyFieldApi, createForm } from "@tanstack/solid-form";
2+
import { format as dateFormat } from "date-fns/format";
3+
import {
4+
Accessor,
5+
For,
6+
JSXElement,
7+
Match,
8+
Show,
9+
Switch,
10+
untrack,
11+
} from "solid-js";
12+
13+
import { showNoticeNotification } from "../../states/notifications";
14+
import {
15+
simpleModalConfig,
16+
SimpleModalInput,
17+
executeSimpleModal,
18+
} from "../../states/simple-modal";
19+
import { cn } from "../../utils/cn";
20+
import { AnimatedModal } from "../common/AnimatedModal";
21+
import { Checkbox } from "../ui/form/Checkbox";
22+
import { InputField } from "../ui/form/InputField";
23+
import { SubmitButton } from "../ui/form/SubmitButton";
24+
import { fromSchema, fieldMandatory, handleResult } from "../ui/form/utils";
25+
26+
type FormValues = Record<string, string | boolean>;
27+
28+
type SyncValidator = (opts: {
29+
value: string | boolean;
30+
}) => string | string[] | undefined;
31+
32+
type AsyncValidator = (opts: {
33+
value: string | boolean;
34+
fieldApi: AnyFieldApi;
35+
}) => Promise<string | string[] | undefined>;
36+
37+
type SimpleModalValidators = {
38+
onChange?: SyncValidator;
39+
onChangeAsyncDebounceMs?: number;
40+
onChangeAsync?: AsyncValidator;
41+
};
42+
43+
function inputKey(input: SimpleModalInput, index: number): string {
44+
return input.name ?? index.toString();
45+
}
46+
47+
function getDefaultValues(inputs: SimpleModalInput[] | undefined): FormValues {
48+
if (inputs === undefined || inputs.length === 0) {
49+
return {};
50+
}
51+
const entries: [string, string | boolean][] = inputs.map((input, i) => {
52+
const key = inputKey(input, i);
53+
if (input.type === "checkbox") {
54+
return [key, input.initVal ?? false];
55+
}
56+
if (input.type === "datetime-local" && input.initVal !== undefined) {
57+
return [key, dateFormat(input.initVal, "yyyy-MM-dd'T'HH:mm:ss")];
58+
}
59+
if (input.type === "date" && input.initVal !== undefined) {
60+
return [key, dateFormat(input.initVal, "yyyy-MM-dd")];
61+
}
62+
return [key, input.initVal?.toString() ?? ""];
63+
});
64+
return Object.fromEntries(entries) as FormValues;
65+
}
66+
67+
function getValidators(
68+
input: SimpleModalInput,
69+
): SimpleModalValidators | undefined {
70+
const required =
71+
!input.hidden && !input.optional && input.type !== "checkbox";
72+
73+
const schema = input.validation?.schema;
74+
const isValid = input.validation?.isValid;
75+
76+
if (schema === undefined && isValid === undefined && !required) {
77+
return undefined;
78+
}
79+
80+
const validators: SimpleModalValidators = {};
81+
82+
if (schema !== undefined) {
83+
validators.onChange = fromSchema(schema) as SyncValidator;
84+
} else if (required) {
85+
validators.onChange = fieldMandatory() as SyncValidator;
86+
}
87+
88+
if (isValid !== undefined) {
89+
validators.onChangeAsyncDebounceMs = input.validation?.debounceDelay ?? 100;
90+
validators.onChangeAsync = async ({ value, fieldApi }) => {
91+
const result = await isValid(String(value));
92+
if (result === true) {
93+
return undefined;
94+
}
95+
if (typeof result === "string") {
96+
return result;
97+
}
98+
return handleResult(fieldApi, [
99+
{ type: "warning", message: result.warning },
100+
]);
101+
};
102+
}
103+
104+
return validators;
105+
}
106+
107+
function FieldInput(props: {
108+
field: Accessor<AnyFieldApi>;
109+
input: SimpleModalInput;
110+
}): JSXElement {
111+
return (
112+
<Switch
113+
fallback={
114+
<InputField
115+
field={props.field}
116+
type={props.input.type}
117+
placeholder={props.input.placeholder}
118+
disabled={props.input.disabled}
119+
autocomplete="off"
120+
/>
121+
}
122+
>
123+
<Match when={props.input.type === "checkbox"}>
124+
<Checkbox
125+
field={props.field}
126+
label={(props.input as { label: string }).label}
127+
disabled={props.input.disabled}
128+
/>
129+
</Match>
130+
<Match when={props.input.type === "textarea"}>
131+
<textarea
132+
class="w-full"
133+
placeholder={props.input.placeholder}
134+
value={props.field().state.value as string}
135+
disabled={props.input.disabled}
136+
autocomplete="off"
137+
onInput={(e) => {
138+
props.field().handleChange(e.currentTarget.value);
139+
props.input.oninput?.(e);
140+
}}
141+
onBlur={() => props.field().handleBlur()}
142+
></textarea>
143+
</Match>
144+
<Match when={props.input.type === "range"}>
145+
<div class="flex items-center gap-2">
146+
<input
147+
type="range"
148+
class={cn(props.input.hidden && "hidden", "w-full")}
149+
min={(props.input as { min: number }).min}
150+
max={(props.input as { max: number }).max}
151+
step={(props.input as { step?: number }).step}
152+
value={props.field().state.value as string}
153+
disabled={props.input.disabled}
154+
onInput={(e) => {
155+
props.field().handleChange(e.currentTarget.value);
156+
props.input.oninput?.(e);
157+
}}
158+
onBlur={() => props.field().handleBlur()}
159+
/>
160+
<span class="text-sub">{props.field().state.value as string}</span>
161+
</div>
162+
</Match>
163+
<Match
164+
when={
165+
props.input.type === "datetime-local" || props.input.type === "date"
166+
}
167+
>
168+
<input
169+
type={props.input.type}
170+
class="w-full"
171+
value={props.field().state.value as string}
172+
disabled={props.input.disabled}
173+
min={
174+
(props.input as { min?: Date }).min !== undefined
175+
? dateFormat(
176+
(props.input as { min: Date }).min,
177+
props.input.type === "date"
178+
? "yyyy-MM-dd"
179+
: "yyyy-MM-dd'T'HH:mm:ss",
180+
)
181+
: undefined
182+
}
183+
max={
184+
(props.input as { max?: Date }).max !== undefined
185+
? dateFormat(
186+
(props.input as { max: Date }).max,
187+
props.input.type === "date"
188+
? "yyyy-MM-dd"
189+
: "yyyy-MM-dd'T'HH:mm:ss",
190+
)
191+
: undefined
192+
}
193+
onInput={(e) => {
194+
props.field().handleChange(e.currentTarget.value);
195+
props.input.oninput?.(e);
196+
}}
197+
onBlur={() => props.field().handleBlur()}
198+
/>
199+
</Match>
200+
</Switch>
201+
);
202+
}
203+
204+
export function SimpleModal(): JSXElement {
205+
const config = simpleModalConfig;
206+
207+
// untrack prevents tanstack's internal createComputed from
208+
// re-running api.update() when config changes, which would
209+
// cause a re-render cascade during the modal's show animation.
210+
const form = createForm(() => ({
211+
defaultValues: untrack(() => getDefaultValues(config()?.inputs)),
212+
onSubmit: async ({ value }) => {
213+
const inputs = config()?.inputs ?? [];
214+
const values = inputs.map((input, i) => {
215+
const val = value[inputKey(input, i)];
216+
if (typeof val === "boolean") {
217+
return val ? "true" : "false";
218+
}
219+
return val?.toString() ?? "";
220+
});
221+
await executeSimpleModal(values);
222+
},
223+
onSubmitInvalid: () => {
224+
showNoticeNotification("Please fill in all fields");
225+
},
226+
}));
227+
228+
const resetForm = (): void => {
229+
const defaults = getDefaultValues(config()?.inputs);
230+
form.update({ ...form.options, defaultValues: defaults });
231+
form.reset();
232+
};
233+
234+
return (
235+
<AnimatedModal
236+
id="SimpleModal"
237+
title={config()?.title}
238+
focusFirstInput={true}
239+
beforeShow={resetForm}
240+
>
241+
<form
242+
class="grid gap-4"
243+
onSubmit={(e) => {
244+
e.preventDefault();
245+
e.stopPropagation();
246+
void form.handleSubmit();
247+
}}
248+
>
249+
<Show when={config()?.text}>
250+
{(text) => (
251+
<div
252+
class="text-sub"
253+
{...(config()?.textAllowHtml === true
254+
? { innerHTML: text() }
255+
: { textContent: text() })}
256+
></div>
257+
)}
258+
</Show>
259+
<Show when={(config()?.inputs?.length ?? 0) > 0}>
260+
<div class="grid gap-2">
261+
<For each={config()?.inputs}>
262+
{(input, i) => (
263+
<Show when={!input.hidden}>
264+
<form.Field
265+
name={inputKey(input, i())}
266+
validators={getValidators(input)}
267+
children={(field) => (
268+
<Show
269+
when={
270+
input.type !== "checkbox" &&
271+
input.label !== undefined &&
272+
input.label !== ""
273+
}
274+
fallback={<FieldInput field={field} input={input} />}
275+
>
276+
<label class="grid w-full grid-cols-[1fr_2fr] items-center gap-2 text-sub">
277+
<div>{input.label}</div>
278+
<FieldInput field={field} input={input} />
279+
</label>
280+
</Show>
281+
)}
282+
/>
283+
</Show>
284+
)}
285+
</For>
286+
</div>
287+
</Show>
288+
<Show when={config()?.buttonText !== undefined}>
289+
<SubmitButton
290+
form={form}
291+
variant="button"
292+
class="w-full"
293+
text={config()?.buttonText}
294+
skipDirtyCheck={(config()?.inputs?.length ?? 0) === 0}
295+
/>
296+
</Show>
297+
</form>
298+
</AnimatedModal>
299+
);
300+
}

0 commit comments

Comments
 (0)