Skip to content
86 changes: 76 additions & 10 deletions client/src/pages/filaments/edit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<keyof IFilamentEditForm, unknown> = {
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();
Expand All @@ -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(() => {
Expand All @@ -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<string, unknown>) =>
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<string, unknown>) =>
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 (
<Edit saveButtonProps={saveButtonProps}>
<Edit saveButtonProps={saveButtonState}>
{contextHolder}
<Form {...formProps} layout="vertical">
<Form.Item
Expand Down
6 changes: 4 additions & 2 deletions client/src/pages/filaments/list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import {
useSpoolmanMaterials,
useSpoolmanVendors,
} from "../../components/otherModels";
import { removeUndefined } from "../../utils/filtering";
import { hasMeaningfulFilters, removeUndefined } from "../../utils/filtering";
import { EntityType, useGetFields } from "../../utils/queryFields";
import { TableState, useInitialTableState, useStoreInitialState } from "../../utils/saveload";
import { useCurrencyFormatter } from "../../utils/settings";
Expand Down Expand Up @@ -164,13 +164,15 @@ export const FilamentList = () => {
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 (
<List
headerButtons={({ defaultButtons }) => (
<>
<Button
type="primary"
type={hasActiveFilters ? "primary" : "default"}
icon={<FilterOutlined />}
onClick={() => {
setFilters([], "replace");
Expand Down
8 changes: 8 additions & 0 deletions client/src/pages/filaments/model.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,11 @@ export interface IFilament {

// IFilamentParsedExtras is the same as IFilament, but with the extra field parsed into its real types
export type IFilamentParsedExtras = Omit<IFilament, "extra"> & { extra?: { [key: string]: unknown } };

// IFilamentEditForm is the shape of the filament edit form — only user-editable fields.
// id and registered are system-managed; vendor is replaced by vendor_id.
// Adding a new editable field to IFilament should also be added here;
// comparableDefaults in filaments/edit.tsx is typed against this.
export type IFilamentEditForm = Omit<IFilamentParsedExtras, "id" | "registered" | "vendor"> & {
vendor_id: number | null;
};
27 changes: 14 additions & 13 deletions client/src/pages/printing/printing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,21 +35,22 @@ export function useGetPrintSettings(): SpoolQRCodePrintSettings[] | undefined {
if (!data) return;
const parsed: SpoolQRCodePrintSettings[] =
data && data.value ? JSON.parse(data.value) : ([] as SpoolQRCodePrintSettings[]);
// Loop through all parsed and generate a new ID field if it's not set
return parsed.map((settings) => {
if (!settings.labelSettings.printSettings.id) {
settings.labelSettings.printSettings.id = uuidv4();
}
return settings;
});
// Keep preset loading immutable so dirty-state comparisons are not affected by
// IDs being injected directly into parsed settings objects.
return parsed.map((settings) => ({
...settings,
labelSettings: {
...settings.labelSettings,
printSettings: {
...settings.labelSettings.printSettings,
id: settings.labelSettings.printSettings.id || uuidv4(),
},
},
}));
}

export function useSetPrintSettings(): (spoolQRCodePrintSettings: SpoolQRCodePrintSettings[]) => void {
const mut = useSetSetting("print_presets");

return (spoolQRCodePrintSettings: SpoolQRCodePrintSettings[]) => {
mut.mutate(spoolQRCodePrintSettings);
};
export function useSetPrintSettings() {
return useSetSetting<SpoolQRCodePrintSettings[]>("print_presets");
}

interface GenericObject {
Expand Down
Loading