Edit widgets fixes (prevent submitting errored)

Closes #2819
Closes #2814
This commit is contained in:
Kalle
2026-05-30 09:56:46 +03:00
parent 3b0bd509cd
commit 874cd6eb7c
3 changed files with 70 additions and 14 deletions

View File

@@ -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,

View File

@@ -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<Tables["UserWidget"]["widget"]>
>(data.currentWidgets);
const [expandedWidgetId, setExpandedWidgetId] = useState<string | null>(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<string, string>,
{ 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<HTMLDivElement>(
`[data-widget-settings="${pendingScrollWidgetId}"]`,
);
scrollToFirstWidgetError(panel);
setPendingScrollWidgetId(null);
}, [pendingScrollWidgetId, expandedWidgetId]);
if (!isHydrated) {
return <Placeholder />;
}
@@ -444,7 +466,7 @@ function DraggableWidgetItem({
</div>
{isExpanded && hasSettings ? (
<div className={styles.widgetSettings}>
<div data-widget-settings={widget.id} className={styles.widgetSettings}>
<WidgetSettingsForm
widget={widget}
onSettingsChange={onSettingsChange}
@@ -463,3 +485,22 @@ const WIDGET_DESCRIPTION_PARAMS: Record<string, Record<string, unknown>> = {
function widgetDescriptionParams(widgetId: string) {
return WIDGET_DESCRIPTION_PARAMS[widgetId];
}
function scrollToFirstWidgetError(container: HTMLDivElement | null) {
const target = container?.querySelector<HTMLElement>('[id$="-error"]');
target?.scrollIntoView({ behavior: "smooth", block: "center" });
}
function computeInvalidWidgetIds(
widgets: Array<Tables["UserWidget"]["widget"]>,
): Set<string> {
const invalid = new Set<string>();
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;
}

View File

@@ -103,15 +103,15 @@ export function SendouForm<T extends z.ZodRawShape>({
const { t } = useTranslation(["forms"]);
const fetcher = useFetcher<{ fieldErrors?: Record<string, string> }>();
const [hasSubmitted, setHasSubmitted] = React.useState(false);
const initialValues = buildInitialValues(schema, defaultValues);
const [clientErrors, setClientErrors] = React.useState<
Partial<Record<string, string>>
>({});
>(() => (autoApply ? computeInitialErrors(schema, initialValues) : {}));
const [visibleServerErrors, setVisibleServerErrors] = React.useState<
Partial<Record<string, string>>
>(fetcher.data?.fieldErrors ?? {});
const [fallbackError, setFallbackError] = React.useState<string | null>(null);
const initialValues = buildInitialValues(schema, defaultValues);
const [values, setValues] =
React.useState<Record<string, unknown>>(initialValues);
@@ -279,7 +279,11 @@ export function SendouForm<T extends z.ZodRawShape>({
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<string, string> = {};
for (const key of Object.keys(schema.shape)) {
@@ -289,14 +293,12 @@ export function SendouForm<T extends z.ZodRawShape>({
}
}
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<z.ZodObject<T>>);
} else if (autoSubmit) {
} else if (autoSubmit && !hasFieldErrors) {
fetcher.submit(
addRevalidateRoot(updatedValues) as Record<string, string>,
{
@@ -430,6 +432,18 @@ function buildFieldPath(path: PropertyKey[]): string | null {
.join("");
}
function computeInitialErrors<T extends z.ZodRawShape>(
schema: z.ZodObject<T>,
values: Record<string, unknown>,
): Partial<Record<string, string>> {
const errors: Record<string, string> = {};
for (const key of Object.keys(schema.shape)) {
const error = validateField(schema, key, values[key]);
if (error) errors[key] = error;
}
return errors;
}
function buildInitialValues<T extends z.ZodRawShape>(
schema: z.ZodObject<T>,
defaultValues?: Partial<z.input<z.ZodObject<T>>> | null,