diff --git a/client/public/locales/en/common.json b/client/public/locales/en/common.json
index 6aa86cbb6..01a4a2523 100644
--- a/client/public/locales/en/common.json
+++ b/client/public/locales/en/common.json
@@ -28,7 +28,7 @@
"hideArchived": "Hide Archived",
"showArchived": "Show Archived",
"notAccessTitle": "You don't have permission to access",
- "hideColumns": "Hide Columns",
+ "hideColumns": "Columns",
"clearFilters": "Clear Filters",
"selectAll": "Select All",
"selectNone": "Select None"
@@ -362,7 +362,9 @@
"fields_help": {
"empty_spool_weight": "The weight of an empty spool from this manufacturer.",
"logo_url": "Optional custom logo used in the UI. Supports absolute URLs or local paths like /vendor-logos/web/bambu-lab-web.png.",
- "print_logo_url": "Optional custom logo used for label rendering. Supports absolute URLs or local paths like /vendor-logos/print/bambu-lab.png."
+ "print_logo_url": "Optional custom logo used for label rendering. Supports absolute URLs or local paths like /vendor-logos/print/bambu-lab.png.",
+ "logo_suggestions": "Checks the logo database for files with names similar to this manufacturer.",
+ "print_logo_suggestions": "Checks the print-logo database for files with names similar to this manufacturer."
},
"titles": {
"create": "Create Manufacturer",
@@ -374,14 +376,19 @@
},
"buttons": {
"sync_logos": "Sync Logos",
- "clear_logo_url": "Clear URL"
+ "clear_logo_url": "Clear URL",
+ "convert_logo_to_print": "Convert Logo to Print",
+ "convert_logo_to_print_help": "Creates a black-and-white print logo from the current Logo URL and stores it as a separate local print logo file."
},
"form": {
"vendor_updated": "This manufacturer has been updated by someone/something else since you opened this page. Saving will overwrite those changes!",
"logo_sync_no_match": "No matching logos found for this manufacturer name.",
"logo_sync_applied": "Suggested logo paths applied.",
"logo_preview_auto_notice": "using auto-matched logo from bundled logo pack",
- "logo_preview_default_notice": "no logo defined, using default generated text logo"
+ "logo_preview_default_notice": "no logo defined, using default generated text logo",
+ "logo_convert_requires_web_logo": "Set a Logo URL first.",
+ "logo_convert_success": "Generated print logo from web logo.",
+ "logo_convert_error": "Could not generate a print logo from this Logo URL."
}
},
"home": {
diff --git a/client/src/components/colorHexPreview.tsx b/client/src/components/colorHexPreview.tsx
index b5e4d57a9..1931795cb 100644
--- a/client/src/components/colorHexPreview.tsx
+++ b/client/src/components/colorHexPreview.tsx
@@ -38,8 +38,8 @@ export default function ColorHexPreview({ colorHex, multiColorHexes, multiColorD
if (isLongitudinal) {
return (
- {colors.map((hex) => (
-
+ {colors.map((hex, index) => (
+
+ return (
+
{colors.map((hex, index) => (
-
+
+
+ {hex}
+
))}
);
-
- if (colors.length === 2) {
- return (
-
- {colors[0]}
- {strip}
- {colors[1]}
-
- );
- }
-
- const middle = colors.slice(1, -1);
- return (
-
-
- {colors[0]}
- {strip}
- {colors[colors.length - 1]}
-
-
- {middle.map((hex, index) => (
-
- {hex}
-
- ))}
-
-
- );
}
diff --git a/client/src/components/column.tsx b/client/src/components/column.tsx
index 78425ef62..e017cd5f4 100644
--- a/client/src/components/column.tsx
+++ b/client/src/components/column.tsx
@@ -46,6 +46,41 @@ function valueKey(value: Key): string {
return String(value);
}
+function normalizeSearchableValue(value: unknown): string {
+ if (value === null || value === undefined) {
+ return "";
+ }
+ if (Array.isArray(value)) {
+ return value.map((entry) => String(entry)).join(", ");
+ }
+ return String(value);
+}
+
+function getRecordValue(record: unknown, dataIndex: string | string[]): unknown {
+ if (Array.isArray(dataIndex)) {
+ return dataIndex.reduce
((current, part) => {
+ if (current === null || current === undefined || typeof current !== "object") {
+ return undefined;
+ }
+ return (current as Record)[part];
+ }, record);
+ }
+
+ if (record !== null && record !== undefined && typeof record === "object") {
+ const recordObject = record as Record;
+ if (Object.prototype.hasOwnProperty.call(recordObject, dataIndex)) {
+ return recordObject[dataIndex];
+ }
+ }
+
+ return dataIndex.split(".").reduce((current, part) => {
+ if (current === null || current === undefined || typeof current !== "object") {
+ return undefined;
+ }
+ return (current as Record)[part];
+ }, record);
+}
+
function FilterDropdownContent(props: {
items: ColumnFilterItem[];
selectedKeys: Key[];
@@ -207,6 +242,56 @@ function FilterDropdownContent(props: {
);
}
+
+function SearchFilterDropdownContent(props: {
+ selectedKeys: Key[];
+ setSelectedKeys: (keys: Key[]) => void;
+ confirm: () => void;
+ clearFilters?: () => void;
+ t: (key: string) => string;
+ placeholder: string;
+}) {
+ const { selectedKeys, setSelectedKeys, confirm, clearFilters, t, placeholder } = props;
+ const currentValue = selectedKeys.length > 0 ? String(selectedKeys[0]) : "";
+
+ return (
+
+ {
+ const value = event.target.value;
+ setSelectedKeys(value ? [value] : []);
+ }}
+ onPressEnter={() => confirm()}
+ />
+
+ {
+ confirm();
+ }}
+ >
+ {t("buttons.filter")}
+
+ {
+ setSelectedKeys([]);
+ clearFilters?.();
+ confirm();
+ }}
+ >
+ {t("buttons.clear")}
+
+
+
+ );
+}
+
interface Entity {
id: number;
}
@@ -226,6 +311,9 @@ interface BaseColumnProps {
title?: string;
align?: AlignType;
sorter?: boolean;
+ searchable?: boolean;
+ searchPlaceholder?: string;
+ searchValueFormatter?: (rawValue: unknown, record: Obj) => string;
t: (key: string) => string;
navigate: (link: string) => void;
dataSource: Obj[];
@@ -314,6 +402,69 @@ function Column(
if (props.dataId) {
columnProps.key = props.dataId;
}
+ } else if (props.searchable) {
+ const filterField = props.dataId ?? (Array.isArray(props.id) ? undefined : (props.id as keyof Obj));
+ if (filterField) {
+ const typedFilters = typeFilters(props.tableState.filters);
+ const filteredValue = getFiltersForField(typedFilters, filterField);
+ const searchableValues = new Map();
+ const searchValueDataIndex = props.dataId ?? props.id;
+
+ props.dataSource.forEach((record) => {
+ const rawValue = getRecordValue(record, searchValueDataIndex);
+ const displayValue = props.searchValueFormatter
+ ? props.searchValueFormatter(rawValue, record)
+ : normalizeSearchableValue(rawValue);
+ const normalizedDisplayValue = displayValue ?? "";
+ const filterValue = normalizedDisplayValue === "" ? "" : normalizedDisplayValue;
+ if (!searchableValues.has(filterValue)) {
+ searchableValues.set(filterValue, normalizedDisplayValue);
+ }
+ });
+
+ const searchableFilters: ColumnFilterItem[] = Array.from(searchableValues.entries())
+ .map(([value, label]) => ({ value, text: label }))
+ .sort((left, right) =>
+ filterSearchTerm(left).localeCompare(filterSearchTerm(right), undefined, {
+ numeric: true,
+ sensitivity: "base",
+ }),
+ );
+
+ columnProps.filteredValue = filteredValue;
+
+ if (searchableFilters.length > 0) {
+ columnProps.filters = searchableFilters;
+ columnProps.filterMultiple = true;
+ columnProps.filterDropdown = ({ selectedKeys, setSelectedKeys, confirm, clearFilters }) => (
+
+ );
+ } else {
+ columnProps.filterMultiple = false;
+ columnProps.filterDropdown = ({ selectedKeys, setSelectedKeys, confirm, clearFilters }) => (
+
+ );
+ }
+
+ if (props.dataId) {
+ columnProps.key = props.dataId;
+ }
+ }
}
// Render
@@ -363,6 +514,7 @@ export function SortedColumn(props: BaseColumnProps) {
return Column({
...props,
sorter: true,
+ searchable: props.searchable ?? true,
});
}
@@ -371,6 +523,7 @@ export function RichColumn(
) {
return Column({
...props,
+ searchable: props.searchable ?? true,
render: (rawValue: string | undefined) => {
const value = props.transform ? props.transform(rawValue) : rawValue;
return enrichText(value);
@@ -382,6 +535,7 @@ interface FilteredQueryColumnProps extends BaseColumnProps;
allowMultipleFilters?: boolean;
includeEmptyFilter?: boolean;
+ emptyFilterLabel?: string;
}
export function FilteredQueryColumn(props: FilteredQueryColumnProps) {
@@ -401,7 +555,7 @@ export function FilteredQueryColumn(props: FilteredQueryColu
}
if (props.includeEmptyFilter !== false) {
filters.push({
- text: "",
+ text: props.emptyFilterLabel ?? "",
value: "",
});
}
@@ -435,6 +589,7 @@ export function NumberColumn(props: NumberColumnProps)
return Column({
...props,
align: "right",
+ searchable: props.searchable ?? true,
render: (rawValue) => {
const value = props.transform ? props.transform(rawValue) : rawValue;
if (value === null || value === undefined) {
@@ -457,6 +612,14 @@ export function NumberColumn(props: NumberColumnProps)
export function DateColumn(props: BaseColumnProps) {
return Column({
...props,
+ searchable: props.searchable ?? true,
+ searchValueFormatter: (rawValue) => {
+ const value = props.transform ? props.transform(rawValue) : rawValue;
+ if (!value) {
+ return "";
+ }
+ return dayjs.utc(value as string).local().format("YYYY-MM-DD HH:mm");
+ },
render: (rawValue) => {
const value = props.transform ? props.transform(rawValue) : rawValue;
return (
@@ -477,7 +640,10 @@ export function ActionsColumn(
): ColumnType | undefined {
return {
title,
+ key: "actions",
responsive: ["lg"],
+ fixed: "right",
+ width: 190,
render: (_, record) => {
const buttons = actionsFn(record).map((action) => {
if (action.link) {
@@ -570,6 +736,7 @@ export function SpoolIconColumn(props: SpoolIconColumnProps<
export function NumberRangeColumn(props: NumberColumnProps) {
return Column({
...props,
+ searchable: props.searchable ?? true,
render: (rawValue) => {
const value = props.transform ? props.transform(rawValue) : rawValue;
if (value === null || value === undefined) {
diff --git a/client/src/components/otherModels.tsx b/client/src/components/otherModels.tsx
index 1eb6c9b75..f2cbfe707 100644
--- a/client/src/components/otherModels.tsx
+++ b/client/src/components/otherModels.tsx
@@ -86,29 +86,18 @@ export function useSpoolmanFilamentFilter(enabled: boolean = false) {
}
export function useSpoolmanFilamentNames(enabled: boolean = false) {
- return useQuery({
- enabled: enabled,
- queryKey: ["filaments"],
+ return useQuery({
+ enabled,
+ queryKey: ["filamentNames"],
queryFn: async () => {
- const response = await fetch(getAPIURL() + "/filament");
+ const response = await fetch(getAPIURL() + "/filament/name");
if (!response.ok) {
throw new Error("Network response was not ok");
}
return response.json();
},
select: (data) => {
- // Concatenate vendor name and filament name
- let names = data
- .filter((filament) => {
- return filament.name !== null && filament.name !== undefined && filament.name !== "";
- })
- .map((filament) => {
- return filament.name ?? "";
- })
- .sort();
- // Remove duplicates
- names = [...new Set(names)];
- return names;
+ return data.filter((name) => name !== "").sort();
},
});
}
diff --git a/client/src/components/qrCodeScanner.tsx b/client/src/components/qrCodeScanner.tsx
index e683a7812..556f5a18e 100644
--- a/client/src/components/qrCodeScanner.tsx
+++ b/client/src/components/qrCodeScanner.tsx
@@ -45,7 +45,13 @@ const QRCodeScannerModal = () => {
return (
<>
- setVisible(true)} icon={ } shape="circle" />
+ setVisible(true)}
+ icon={ }
+ shape="circle"
+ style={{ right: "var(--camera-button-right)", bottom: "var(--camera-button-bottom)" }}
+ />
setVisible(false)} footer={null} title={t("scanner.title")}>
{t("scanner.description")}
diff --git a/client/src/components/resizableTable.tsx b/client/src/components/resizableTable.tsx
new file mode 100644
index 000000000..da9e759ad
--- /dev/null
+++ b/client/src/components/resizableTable.tsx
@@ -0,0 +1,309 @@
+import { Table } from "antd";
+import type { TableProps } from "antd";
+import type { AnyObject } from "antd/es/_util/type";
+import type { ColumnType, ColumnsType } from "antd/es/table";
+import { useMemo } from "react";
+import type { HTMLAttributes, MouseEvent as ReactMouseEvent, ThHTMLAttributes } from "react";
+import { useSavedState } from "../utils/saveload";
+
+interface ResizableHeaderCellProps extends ThHTMLAttributes {
+ onResizeStart?: (event: ReactMouseEvent) => void;
+ onResizeAutoFit?: (event: ReactMouseEvent) => void;
+ resizable?: boolean;
+}
+
+function ResizableHeaderCell({ className, onResizeStart, onResizeAutoFit, resizable, children, ...restProps }: ResizableHeaderCellProps) {
+ return (
+
+ {children}
+ {resizable && (
+ {
+ event.preventDefault();
+ event.stopPropagation();
+ onResizeStart?.(event);
+ }}
+ onClick={(event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ }}
+ onDoubleClick={(event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ onResizeAutoFit?.(event);
+ }}
+ />
+ )}
+
+ );
+}
+
+function serializeDataIndex(dataIndex: unknown): string | undefined {
+ if (Array.isArray(dataIndex)) {
+ return dataIndex.map((part) => String(part)).join(".");
+ }
+ if (typeof dataIndex === "string" || typeof dataIndex === "number") {
+ return String(dataIndex);
+ }
+ return undefined;
+}
+
+function columnIdentifier(column: ColumnType, index: number, parentId: string): string {
+ const key = column.key != null ? String(column.key) : undefined;
+ const dataIndex = serializeDataIndex(column.dataIndex);
+ return key ?? dataIndex ?? `${parentId}-${index}`;
+}
+
+function measureIntrinsicElementWidth(element: HTMLElement): number {
+ const clone = element.cloneNode(true) as HTMLElement;
+ clone.querySelectorAll(".resizable-table-header-handle").forEach((handle) => handle.remove());
+ clone.style.position = "fixed";
+ clone.style.left = "-99999px";
+ clone.style.top = "-99999px";
+ clone.style.width = "max-content";
+ clone.style.maxWidth = "none";
+ clone.style.minWidth = "0";
+ clone.style.display = "inline-block";
+ clone.style.whiteSpace = "nowrap";
+ clone.style.visibility = "hidden";
+ document.body.appendChild(clone);
+ const measuredWidth = clone.getBoundingClientRect().width;
+ clone.remove();
+ return measuredWidth;
+}
+
+function measureCellAutoFitWidth(cell: HTMLTableCellElement): number {
+ const style = window.getComputedStyle(cell);
+ const paddingLeft = Number.parseFloat(style.paddingLeft || "0") || 0;
+ const paddingRight = Number.parseFloat(style.paddingRight || "0") || 0;
+ const horizontalPadding = paddingLeft + paddingRight;
+
+ const childElements = Array.from(cell.children).filter((child): child is HTMLElement => child instanceof HTMLElement);
+ if (childElements.length > 0) {
+ const widestChild = childElements.reduce((maxWidth, child) => {
+ return Math.max(maxWidth, measureIntrinsicElementWidth(child));
+ }, 0);
+ return Math.ceil(widestChild + horizontalPadding);
+ }
+
+ const text = cell.textContent?.trim() ?? "";
+ if (text.length === 0) {
+ return Math.ceil(horizontalPadding);
+ }
+
+ const textProbe = document.createElement("span");
+ textProbe.textContent = text;
+ textProbe.style.position = "fixed";
+ textProbe.style.left = "-99999px";
+ textProbe.style.top = "-99999px";
+ textProbe.style.whiteSpace = "nowrap";
+ textProbe.style.font = style.font;
+ textProbe.style.fontSize = style.fontSize;
+ textProbe.style.fontWeight = style.fontWeight;
+ textProbe.style.letterSpacing = style.letterSpacing;
+ textProbe.style.visibility = "hidden";
+ document.body.appendChild(textProbe);
+ const textWidth = textProbe.getBoundingClientRect().width;
+ textProbe.remove();
+
+ return Math.ceil(textWidth + horizontalPadding);
+}
+
+function hasCellOverflow(cell: HTMLTableCellElement): boolean {
+ if (cell.scrollWidth > cell.clientWidth + 1) {
+ return true;
+ }
+
+ return Array.from(cell.children).some((child) => {
+ if (!(child instanceof HTMLElement)) {
+ return false;
+ }
+ return child.scrollWidth > child.clientWidth + 1;
+ });
+}
+
+export interface ResizableTableProps extends TableProps {
+ columnResizeKey: string;
+ minColumnWidth?: number;
+}
+
+function ResizableTable(props: ResizableTableProps) {
+ const { columns, components, columnResizeKey, minColumnWidth = 72, scroll, sticky, ...tableProps } = props;
+ const [columnWidths, setColumnWidths] = useSavedState>(
+ `table-column-widths-${columnResizeKey}`,
+ {},
+ );
+
+ const { resolvedColumns, requiredMinWidth } = useMemo(() => {
+ if (!columns) {
+ return { resolvedColumns: columns, requiredMinWidth: minColumnWidth };
+ }
+
+ let minWidthSum = 0;
+
+ const withResize = (input: ColumnsType, parentId: string): ColumnsType => {
+ return input.map((column, index) => {
+ const id = columnIdentifier(column, index, parentId);
+ const columnWidth =
+ typeof columnWidths[id] === "number"
+ ? columnWidths[id]
+ : typeof column.width === "number"
+ ? column.width
+ : undefined;
+
+ const columnChildren = (column as { children?: ColumnsType }).children;
+ const hasChildren = Array.isArray(columnChildren) && columnChildren.length > 0;
+ const nextColumn: ColumnsType[number] = {
+ ...column,
+ };
+
+ if (hasChildren) {
+ (nextColumn as { children?: ColumnsType }).children = withResize(columnChildren, id);
+ return nextColumn;
+ }
+
+ const leafColumn = nextColumn as ColumnType;
+ const existingMinWidth =
+ typeof (leafColumn as { minWidth?: number }).minWidth === "number"
+ ? (leafColumn as { minWidth?: number }).minWidth
+ : undefined;
+ const effectiveMinWidth = Math.max(minColumnWidth, existingMinWidth ?? 0);
+
+ if (typeof columnWidth === "number") {
+ leafColumn.width = Math.max(effectiveMinWidth, columnWidth);
+ }
+ (leafColumn as { minWidth?: number }).minWidth = effectiveMinWidth;
+
+ minWidthSum += typeof leafColumn.width === "number" ? leafColumn.width : effectiveMinWidth;
+
+ const originalOnHeaderCell = (column as ColumnType).onHeaderCell;
+ leafColumn.onHeaderCell = (col) => {
+ const existingProps = (originalOnHeaderCell?.(col as never) ?? {}) as Record;
+ return {
+ ...existingProps,
+ resizable: true,
+ onResizeStart: (event: ReactMouseEvent) => {
+ const startX = event.clientX;
+ const currentHeaderCell = event.currentTarget.closest("th");
+ const baseWidth = Math.max(
+ minColumnWidth,
+ currentHeaderCell?.getBoundingClientRect().width ?? columnWidth ?? minColumnWidth,
+ );
+ const originalCursor = document.body.style.cursor;
+ const originalUserSelect = document.body.style.userSelect;
+ document.body.style.cursor = "col-resize";
+ document.body.style.userSelect = "none";
+
+ const onMouseMove = (moveEvent: MouseEvent) => {
+ const nextWidth = Math.max(minColumnWidth, baseWidth + moveEvent.clientX - startX);
+ setColumnWidths((previous) => {
+ if (previous[id] === nextWidth) {
+ return previous;
+ }
+ return {
+ ...previous,
+ [id]: nextWidth,
+ };
+ });
+ };
+
+ const onMouseUp = () => {
+ document.removeEventListener("mousemove", onMouseMove);
+ document.removeEventListener("mouseup", onMouseUp);
+ document.body.style.cursor = originalCursor;
+ document.body.style.userSelect = originalUserSelect;
+ };
+
+ document.addEventListener("mousemove", onMouseMove);
+ document.addEventListener("mouseup", onMouseUp);
+ },
+ onResizeAutoFit: (event: ReactMouseEvent) => {
+ const currentHeaderCell = event.currentTarget.closest("th");
+ const headerRow = currentHeaderCell?.parentElement;
+ if (!(currentHeaderCell instanceof HTMLTableCellElement) || !(headerRow instanceof HTMLTableRowElement)) {
+ return;
+ }
+
+ const headerCells = Array.from(headerRow.cells);
+ const columnIndex = headerCells.indexOf(currentHeaderCell);
+ if (columnIndex === -1) {
+ return;
+ }
+
+ const tableContainer = currentHeaderCell.closest(".ant-table-container");
+ const currentWidth = Math.ceil(currentHeaderCell.getBoundingClientRect().width);
+ let nextWidth = measureCellAutoFitWidth(currentHeaderCell);
+ let hasOverflow = hasCellOverflow(currentHeaderCell);
+
+ if (tableContainer) {
+ const bodyRows = Array.from(tableContainer.querySelectorAll("tbody tr"));
+ bodyRows.forEach((row) => {
+ if (!(row instanceof HTMLTableRowElement)) {
+ return;
+ }
+ const cell = row.cells.item(columnIndex);
+ if (cell) {
+ nextWidth = Math.max(nextWidth, measureCellAutoFitWidth(cell));
+ hasOverflow = hasOverflow || hasCellOverflow(cell);
+ }
+ });
+ }
+
+ const autoFitWidth = Math.max(minColumnWidth, nextWidth);
+ const finalWidth = hasOverflow ? autoFitWidth : Math.max(minColumnWidth, Math.min(currentWidth, autoFitWidth));
+ setColumnWidths((previous) => {
+ if (previous[id] === finalWidth) {
+ return previous;
+ }
+ return {
+ ...previous,
+ [id]: finalWidth,
+ };
+ });
+ },
+ } as unknown as HTMLAttributes;
+ };
+
+ return nextColumn;
+ });
+ };
+
+ return {
+ resolvedColumns: withResize(columns as ColumnsType, "root"),
+ requiredMinWidth: minWidthSum,
+ };
+ }, [columns, columnWidths, minColumnWidth, setColumnWidths]);
+
+ const mergedComponents = useMemo(() => {
+ return {
+ ...components,
+ header: {
+ ...components?.header,
+ cell: ResizableHeaderCell,
+ },
+ };
+ }, [components]);
+
+ const resolvedScroll = useMemo(() => {
+ const nextScroll = { ...(scroll ?? {}) };
+ if (nextScroll.x === undefined) {
+ nextScroll.x = requiredMinWidth;
+ } else if (typeof nextScroll.x === "number") {
+ nextScroll.x = Math.max(nextScroll.x, requiredMinWidth);
+ }
+ return nextScroll;
+ }, [requiredMinWidth, scroll]);
+
+ return (
+
+ {...tableProps}
+ columns={resolvedColumns}
+ components={mergedComponents}
+ scroll={resolvedScroll}
+ sticky={sticky ?? true}
+ />
+ );
+}
+
+export default ResizableTable;
diff --git a/client/src/pages/filaments/edit.tsx b/client/src/pages/filaments/edit.tsx
index b465e6ce3..ab1b54b1b 100644
--- a/client/src/pages/filaments/edit.tsx
+++ b/client/src/pages/filaments/edit.tsx
@@ -66,6 +66,7 @@ export const FilamentEdit = () => {
extra: selectedVendor?.extra ?? {},
}
: undefined;
+ const watchedAllValues = Form.useWatch([], formProps.form);
// Add the vendor_id field to the form
if (formProps.initialValues) {
@@ -100,8 +101,79 @@ export const FilamentEdit = () => {
}
};
+
+ const normalizeForCompare = (value: unknown): unknown => {
+ if (dayjs.isDayjs(value)) {
+ return value.toISOString();
+ }
+ if (Array.isArray(value)) {
+ return value.map(normalizeForCompare);
+ }
+ if (value && typeof value === "object") {
+ const objectValue = value as Record;
+ return Object.keys(objectValue)
+ .sort()
+ .reduce>((acc, key) => {
+ const normalizedValue = normalizeForCompare(objectValue[key]);
+ if (normalizedValue !== undefined) {
+ acc[key] = normalizedValue;
+ }
+ return acc;
+ }, {});
+ }
+ return value;
+ };
+
+ const toComparableState = (value: unknown): string => {
+ const normalized = normalizeForCompare(value) as Record | undefined;
+ const normalizedExtra = { ...(normalized?.extra as Record | undefined) };
+
+ return JSON.stringify({
+ name: normalized?.name ?? "",
+ vendor_id: normalized?.vendor_id ?? null,
+ material: normalized?.material ?? "",
+ price: normalized?.price ?? null,
+ density: normalized?.density ?? null,
+ diameter: normalized?.diameter ?? null,
+ weight: normalized?.weight ?? null,
+ spool_weight: normalized?.spool_weight ?? null,
+ settings_extruder_temp: normalized?.settings_extruder_temp ?? null,
+ settings_bed_temp: normalized?.settings_bed_temp ?? null,
+ article_number: normalized?.article_number ?? "",
+ external_id: normalized?.external_id ?? "",
+ comment: normalized?.comment ?? "",
+ color_hex: normalized?.color_hex ?? "",
+ multi_color_direction: normalized?.multi_color_direction ?? "",
+ multi_color_hexes: colorType === "single" ? "" : (normalized?.multi_color_hexes ?? ""),
+ extra: normalizedExtra,
+ });
+ };
+
+ const initialComparableState = useMemo(
+ () => (formProps.initialValues ? toComparableState(formProps.initialValues) : null),
+ [formProps.initialValues, colorType],
+ );
+ const watchedComparableState = useMemo(
+ () => (watchedAllValues ? toComparableState(watchedAllValues) : null),
+ [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 (
-
+ (
+
+ {defaultButtons}
+
+ )}
+ >
{contextHolder}