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 d33687786..2601e9794 100644 --- a/app/features/user-page/core/widgets/widget-form-schemas.ts +++ b/app/features/user-page/core/widgets/widget-form-schemas.ts @@ -114,6 +114,7 @@ export const artSchema = z.object({ export const linksSchema = z.object({ links: array({ label: "labels.urls", + min: 1, max: 10, field: textFieldRequired({ maxLength: 150, diff --git a/app/features/user-page/routes/u.$identifier.edit-widgets.tsx b/app/features/user-page/routes/u.$identifier.edit-widgets.tsx index 23d3379bd..368a490a0 100644 --- a/app/features/user-page/routes/u.$identifier.edit-widgets.tsx +++ b/app/features/user-page/routes/u.$identifier.edit-widgets.tsx @@ -15,7 +15,7 @@ import { } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { Search as SearchIcon } from "lucide-react"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useFetcher, useLoaderData } from "react-router"; import { SendouButton } from "~/components/elements/Button"; @@ -29,6 +29,7 @@ import { defaultStoredWidget, findWidgetById, } from "~/features/user-page/core/widgets/portfolio"; +import { getWidgetFormSchema } from "~/features/user-page/core/widgets/widget-form-schemas"; import { USER } from "~/features/user-page/user-page-constants"; import { useHydrated } from "~/hooks/useHydrated"; import { action } from "../actions/u.$identifier.edit-widgets.server"; @@ -48,6 +49,9 @@ export default function EditWidgetsPage() { Array >(data.currentWidgets); const [expandedWidgetId, setExpandedWidgetId] = useState(null); + const [pendingScrollWidgetId, setPendingScrollWidgetId] = useState< + string | null + >(null); const mainWidgets = selectedWidgets.filter((w) => { const def = findWidgetById(w.id); @@ -112,12 +116,20 @@ export default function EditWidgetsPage() { const removeWidget = (widgetId: string) => { setSelectedWidgets(selectedWidgets.filter((w) => w.id !== widgetId)); - if (expandedWidgetId === widgetId) { - setExpandedWidgetId(null); - } + setExpandedWidgetId((prev) => (prev === widgetId ? null : prev)); }; const handleSubmit = () => { + const invalidWidgetIds = computeInvalidWidgetIds(selectedWidgets); + const firstInvalid = selectedWidgets.find((w) => + invalidWidgetIds.has(w.id), + ); + if (firstInvalid) { + setExpandedWidgetId(firstInvalid.id); + setPendingScrollWidgetId(firstInvalid.id); + return; + } + fetcher.submit( { widgets: selectedWidgets } as unknown as Record, { method: "post", encType: "application/json" }, @@ -131,9 +143,19 @@ export default function EditWidgetsPage() { }; const toggleExpanded = (widgetId: string) => { - setExpandedWidgetId(expandedWidgetId === widgetId ? null : widgetId); + setExpandedWidgetId((prev) => (prev === widgetId ? null : widgetId)); }; + useEffect(() => { + if (!pendingScrollWidgetId) return; + if (expandedWidgetId !== pendingScrollWidgetId) return; + const panel = document.querySelector( + `[data-widget-settings="${pendingScrollWidgetId}"]`, + ); + scrollToFirstWidgetError(panel); + setPendingScrollWidgetId(null); + }, [pendingScrollWidgetId, expandedWidgetId]); + if (!isHydrated) { return ; } @@ -444,7 +466,7 @@ function DraggableWidgetItem({ {isExpanded && hasSettings ? ( -
+
> = { function widgetDescriptionParams(widgetId: string) { return WIDGET_DESCRIPTION_PARAMS[widgetId]; } + +function scrollToFirstWidgetError(container: HTMLDivElement | null) { + const target = container?.querySelector('[id$="-error"]'); + target?.scrollIntoView({ behavior: "smooth", block: "center" }); +} + +function computeInvalidWidgetIds( + widgets: Array, +): Set { + const invalid = new Set(); + for (const widget of widgets) { + const schema = getWidgetFormSchema(widget.id); + if (!schema) continue; + if (!schema.safeParse(widget.settings ?? {}).success) { + invalid.add(widget.id); + } + } + return invalid; +} diff --git a/app/form/SendouForm.tsx b/app/form/SendouForm.tsx index 636c1228f..dc717651e 100644 --- a/app/form/SendouForm.tsx +++ b/app/form/SendouForm.tsx @@ -103,15 +103,15 @@ export function SendouForm({ const { t } = useTranslation(["forms"]); const fetcher = useFetcher<{ fieldErrors?: Record }>(); const [hasSubmitted, setHasSubmitted] = React.useState(false); + const initialValues = buildInitialValues(schema, defaultValues); const [clientErrors, setClientErrors] = React.useState< Partial> - >({}); + >(() => (autoApply ? computeInitialErrors(schema, initialValues) : {})); const [visibleServerErrors, setVisibleServerErrors] = React.useState< Partial> >(fetcher.data?.fieldErrors ?? {}); const [fallbackError, setFallbackError] = React.useState(null); - const initialValues = buildInitialValues(schema, defaultValues); const [values, setValues] = React.useState>(initialValues); @@ -279,7 +279,11 @@ export function SendouForm({ const onFieldChange = autoSubmit || autoApply ? (changedName: string, changedValue: unknown) => { - const updatedValues = { ...values, [changedName]: changedValue }; + const isNestedPath = + changedName.includes(".") || changedName.includes("["); + const updatedValues = isNestedPath + ? setNestedValue(values, changedName, changedValue) + : { ...values, [changedName]: changedValue }; const newErrors: Record = {}; for (const key of Object.keys(schema.shape)) { @@ -289,14 +293,12 @@ export function SendouForm({ } } - if (Object.keys(newErrors).length > 0) { - setClientErrors(newErrors); - return; - } + setClientErrors(newErrors); + const hasFieldErrors = Object.keys(newErrors).length > 0; if (autoApply && onApply) { onApply(updatedValues as z.infer>); - } else if (autoSubmit) { + } else if (autoSubmit && !hasFieldErrors) { fetcher.submit( addRevalidateRoot(updatedValues) as Record, { @@ -430,6 +432,18 @@ function buildFieldPath(path: PropertyKey[]): string | null { .join(""); } +function computeInitialErrors( + schema: z.ZodObject, + values: Record, +): Partial> { + const errors: Record = {}; + for (const key of Object.keys(schema.shape)) { + const error = validateField(schema, key, values[key]); + if (error) errors[key] = error; + } + return errors; +} + function buildInitialValues( schema: z.ZodObject, defaultValues?: Partial>> | null,