diff --git a/client/src/pages/filaments/edit.tsx b/client/src/pages/filaments/edit.tsx index bea70e63d..414e5c925 100644 --- a/client/src/pages/filaments/edit.tsx +++ b/client/src/pages/filaments/edit.tsx @@ -3,14 +3,15 @@ import { HttpError, useTranslate } from "@refinedev/core"; import { Alert, ColorPicker, DatePicker, Form, Input, InputNumber, message, Radio, Select, Typography } from "antd"; import TextArea from "antd/es/input/TextArea"; import dayjs from "dayjs"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { ExtraFieldFormItem, ParsedExtras, StringifiedExtras } from "../../components/extraFields"; import { MultiColorPicker } from "../../components/multiColorPicker"; +import { toComparableState } from "../../utils/formState"; import { formatNumberOnUserInput, numberParser, numberParserAllowEmpty } from "../../utils/parsing"; import { EntityType, useGetFields } from "../../utils/queryFields"; import { getCurrencySymbol, useCurrency } from "../../utils/settings"; import { IVendor } from "../vendors/model"; -import { IFilament, IFilamentParsedExtras } from "./model"; +import { IFilament, IFilamentEditForm, IFilamentParsedExtras } from "./model"; /* The API returns the extra fields as JSON values, but we need to parse them into their real types @@ -19,6 +20,29 @@ We also need to stringify them again before sending them back to the API, which the form's onFinish method. Form.Item's normalize should do this, but it doesn't seem to work. */ +// comparableDefaults is typed against IFilamentEditForm so TypeScript will report a compile +// error here if a new editable field is added to the model without updating this list. +const comparableDefaults: Record = { + name: "", + vendor_id: null, + material: "", + price: null, + density: null, + diameter: null, + weight: null, + spool_weight: null, + settings_extruder_temp: null, + settings_bed_temp: null, + article_number: "", + external_id: "", + comment: "", + color_hex: "", + multi_color_direction: "", + multi_color_hexes: "", + extra: {}, +}; +// This list is the source of truth for which inputs participate in the Save-button dirty check. + export const FilamentEdit = () => { const t = useTranslate(); const [messageApi, contextHolder] = message.useMessage(); @@ -42,14 +66,18 @@ export const FilamentEdit = () => { optionLabel: "name", pagination: { mode: "off" }, }); + const watchedAllValues = Form.useWatch([], formProps.form); - // Add the vendor_id field to the form - if (formProps.initialValues) { - formProps.initialValues["vendor_id"] = formProps.initialValues["vendor"]?.id; - - // Parse the extra fields from string values into real types - formProps.initialValues = ParsedExtras(formProps.initialValues); - } + // Initialize form fields and parse extra fields + useEffect(() => { + if (formProps.initialValues && formProps.form) { + const parsed = ParsedExtras(formProps.initialValues); + formProps.form.setFieldsValue({ + ...parsed, + vendor_id: formProps.initialValues.vendor?.id, + } as unknown as IFilament); + } + }, [formProps.form, formProps.initialValues?.id, formProps.initialValues?.vendor?.id]); // Update colorType state useEffect(() => { @@ -76,8 +104,46 @@ export const FilamentEdit = () => { } }; + const initialComparableState = useMemo( + // ParsedExtras normalizes extra-field values from raw API JSON strings to their actual + // types so the initial snapshot matches the form state that setFieldsValue produces. + () => + toComparableState( + formProps.initialValues + ? { + ...ParsedExtras(formProps.initialValues), + vendor_id: formProps.initialValues.vendor?.id ?? null, + } + : formProps.initialValues, + comparableDefaults, + { + // Single-color mode should ignore any dormant multi-color payload when deciding whether Save is needed. + multi_color_hexes: (normalized: Record) => + colorType === "single" ? "" : ((normalized.multi_color_hexes as string | undefined) ?? ""), + }, + ), + [formProps.initialValues, formProps.initialValues?.vendor?.id, colorType], + ); + const watchedComparableState = useMemo( + () => + toComparableState(watchedAllValues, comparableDefaults, { + multi_color_hexes: (normalized: Record) => + colorType === "single" ? "" : ((normalized.multi_color_hexes as string | undefined) ?? ""), + }), + [watchedAllValues, colorType], + ); + const hasFormChanges = + initialComparableState !== null && + watchedComparableState !== null && + initialComparableState !== watchedComparableState; + const saveButtonState = { + ...saveButtonProps, + type: hasFormChanges ? ("primary" as const) : ("default" as const), + disabled: saveButtonProps.disabled || !hasFormChanges, + }; + return ( - + {contextHolder}
{ tableState, sorter: true, }; + // Ignore empty filter shells so the Clear Filters button only lights up for filters that would affect results. + const hasActiveFilters = hasMeaningfulFilters(filters); return ( ( <> diff --git a/client/src/pages/spools/edit.tsx b/client/src/pages/spools/edit.tsx index 79ad5d301..05aabd72a 100644 --- a/client/src/pages/spools/edit.tsx +++ b/client/src/pages/spools/edit.tsx @@ -8,6 +8,7 @@ import { useEffect, useMemo, useState } from "react"; import { useNavigate, useSearchParams } from "react-router"; import { ExtraFieldFormItem, ParsedExtras, StringifiedExtras } from "../../components/extraFields"; import { useSpoolmanLocations } from "../../components/otherModels"; +import { toComparableState } from "../../utils/formState"; import { searchMatches } from "../../utils/filtering"; import { formatNumberOnUserInput, numberParser, numberParserAllowEmpty } from "../../utils/parsing"; import { EntityType, useGetFields } from "../../utils/queryFields"; @@ -15,7 +16,7 @@ import { getCurrencySymbol, useCurrency } from "../../utils/settings"; import { createFilamentFromExternal } from "../filaments/functions"; import { useLocations } from "../locations/functions"; import { useGetFilamentSelectOptions } from "./functions"; -import { ISpool, ISpoolParsedExtras, WeightToEnter } from "./model"; +import { ISpool, ISpoolEditForm, WeightToEnter } from "./model"; /* The API returns the extra fields as JSON values, but we need to parse them into their real types @@ -24,9 +25,22 @@ We also need to stringify them again before sending them back to the API, which the form's onFinish method. Form.Item's normalize should do this, but it doesn't seem to work. */ -type ISpoolRequest = ISpoolParsedExtras & { - filament_id: number | string; +// comparableDefaults is typed against ISpoolEditForm so TypeScript will report a compile +// error here if a new editable field is added to the model without updating this list. +const comparableDefaults: Record = { + first_used: null, + last_used: null, + filament_id: null, + price: null, + initial_weight: null, + spool_weight: null, + used_weight: null, + location: "", + lot_nr: "", + comment: "", + extra: {}, }; +// This list is the source of truth for which inputs participate in the Save-button dirty check. export const SpoolEdit = () => { const t = useTranslate(); @@ -37,7 +51,7 @@ export const SpoolEdit = () => { const [searchParams] = useSearchParams(); const navigate = useNavigate(); - const { form, formProps, saveButtonProps } = useForm({ + const { form, formProps, saveButtonProps } = useForm({ liveMode: "manual", onLiveEvent() { // Warn the user if the spool has been updated since the form was opened @@ -60,14 +74,6 @@ export const SpoolEdit = () => { const initialWeightValue = Form.useWatch("initial_weight", form); const spoolWeightValue = Form.useWatch("spool_weight", form); - // Add the filament_id field to the form - if (formProps.initialValues) { - formProps.initialValues["filament_id"] = formProps.initialValues["filament"].id; - - // Parse the extra fields from string values into real types - formProps.initialValues = ParsedExtras(formProps.initialValues); - } - // // Set up the filament selection options // @@ -97,13 +103,25 @@ export const SpoolEdit = () => { return null; } }, [selectedFilamentID, internalSelectOptions, externalSelectOptions]); + const watchedAllValues = Form.useWatch([], form); + + // Initialize form fields and parse extra fields + useEffect(() => { + if (formProps.initialValues && form) { + const parsed = ParsedExtras(formProps.initialValues); + form.setFieldsValue({ + ...parsed, + filament_id: formProps.initialValues.filament?.id, + } as unknown as ISpool); + } + }, [form, formProps.initialValues?.id, formProps.initialValues?.filament?.id]); // Override the form's onFinish method to stringify the extra fields const originalOnFinish = formProps.onFinish; - formProps.onFinish = (allValues: ISpoolRequest) => { + formProps.onFinish = (allValues: ISpoolEditForm) => { if (allValues !== undefined && allValues !== null) { // Lot of stupidity here to make types work - const values = StringifiedExtras(allValues); + const values = StringifiedExtras(allValues); if (selectedFilament?.is_internal === false) { // Filament ID being a string indicates its an external filament. // If so, we should first create the internal filament version, then edit the spool @@ -230,8 +248,37 @@ export const SpoolEdit = () => { } }, [initialUsedWeight]); + const initialComparableState = useMemo( + // ParsedExtras normalizes extra-field values from raw API JSON strings to their actual + // types so the initial snapshot matches the form state that setFieldsValue produces. + () => + toComparableState( + formProps.initialValues + ? { + ...ParsedExtras(formProps.initialValues), + filament_id: formProps.initialValues.filament?.id ?? null, + } + : formProps.initialValues, + comparableDefaults, + ), + [formProps.initialValues, formProps.initialValues?.filament?.id], + ); + const watchedComparableState = useMemo( + () => toComparableState(watchedAllValues, comparableDefaults), + [watchedAllValues], + ); + const hasFormChanges = + initialComparableState !== null && + watchedComparableState !== null && + initialComparableState !== watchedComparableState; + const saveButtonState = { + ...saveButtonProps, + type: hasFormChanges ? ("primary" as const) : ("default" as const), + disabled: saveButtonProps.disabled || !hasFormChanges, + }; + return ( - + {contextHolder} { tableState, sorter: true, }; + // Ignore empty filter shells so the Clear Filters button only lights up for filters that would affect results. + const hasActiveFilters = hasMeaningfulFilters(filters); return ( { {showArchived ? t("buttons.hideArchived") : t("buttons.showArchived")}