mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-27 05:36:28 -05:00
Edit widgets fixes (prevent submitting errored)
Closes #2819 Closes #2814
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user