Files
spoolman2/client/src/pages/filaments/edit.tsx
tonym 23ef41270c
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
feat(#19): Add filamentcolors.xyz color lookup integration
- Backend proxy endpoints: GET /filamentcolors/manufacturers, GET /filamentcolors/search
- Frontend: FilamentColorLookup modal with manufacturer filter + color search
- Color grid shows swatches with hex codes, click to apply
- Added to both filament create and edit forms next to ColorPicker

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 00:21:13 -06:00

491 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { LeftOutlined, RightOutlined, ThunderboltOutlined } from "@ant-design/icons";
import { Edit, useForm, useSelect } from "@refinedev/antd";
import { HttpError, IResourceComponentsProps, useList, useTranslate } from "@refinedev/core";
import { Alert, Button, ColorPicker, DatePicker, Form, Input, InputNumber, message, Radio, Select, Space, Typography } from "antd";
import TextArea from "antd/es/input/TextArea";
import dayjs from "dayjs";
import React, { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router";
import { ExtraFieldFormItem, ParsedExtras, StringifiedExtras } from "../../components/extraFields";
import { MultiColorPicker } from "../../components/multiColorPicker";
import { FilamentColorLookup } from "../../components/filamentColorLookup";
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";
/**
* Parse temperature ranges from a comment string.
* Handles patterns like:
* "extrude 190-230 / bed 50-70"
* "nozzle: 200-220°C, bed: 60-70°C"
* "hotend 200 bed 60"
*/
function parseTempsFromComment(comment: string): {
extruderMin?: number;
extruderMax?: number;
bedMin?: number;
bedMax?: number;
} {
const result: { extruderMin?: number; extruderMax?: number; bedMin?: number; bedMax?: number } = {};
// Patterns for extruder temperature
const extruderPatterns = [
/(?:extrud(?:e|er)|nozzle|hotend|print\s*temp)[:\s]*(\d{2,3})\s*[-~to]+\s*(\d{2,3})/i,
/(?:extrud(?:e|er)|nozzle|hotend|print\s*temp)[:\s]*(\d{2,3})\s*°?C?/i,
];
// Patterns for bed temperature
const bedPatterns = [
/(?:bed|plate|build\s*plate)[:\s]*(\d{2,3})\s*[-~to]+\s*(\d{2,3})/i,
/(?:bed|plate|build\s*plate)[:\s]*(\d{2,3})\s*°?C?/i,
];
for (const pattern of extruderPatterns) {
const match = comment.match(pattern);
if (match) {
result.extruderMin = parseInt(match[1]);
result.extruderMax = match[2] ? parseInt(match[2]) : parseInt(match[1]);
break;
}
}
for (const pattern of bedPatterns) {
const match = comment.match(pattern);
if (match) {
result.bedMin = parseInt(match[1]);
result.bedMax = match[2] ? parseInt(match[2]) : parseInt(match[1]);
break;
}
}
return result;
}
/*
The API returns the extra fields as JSON values, but we need to parse them into their real types
in order for Ant design's form to work properly. ParsedExtras does this for us.
We also need to stringify them again before sending them back to the API, which is done by overriding
the form's onFinish method. Form.Item's normalize should do this, but it doesn't seem to work.
*/
export const FilamentEdit: React.FC<IResourceComponentsProps> = () => {
const t = useTranslate();
const navigate = useNavigate();
const [messageApi, contextHolder] = message.useMessage();
const [hasChanged, setHasChanged] = useState(false);
const extraFields = useGetFields(EntityType.filament);
const currency = useCurrency();
const [colorType, setColorType] = useState<"single" | "multi">("single");
// Get all filament IDs for prev/next navigation
const { result: allFilaments } = useList<IFilament>({
resource: "filament",
pagination: { mode: "off" },
sorters: [{ field: "id", order: "asc" }],
});
const { formProps, saveButtonProps, id, form } = useForm<IFilament, HttpError, IFilament, IFilament>({
liveMode: "manual",
onLiveEvent() {
// Warn the user if the filament has been updated since the form was opened
messageApi.warning(t("filament.form.filament_updated"));
setHasChanged(true);
},
});
// Calculate prev/next IDs for navigation
const { prevId, nextId } = useMemo(() => {
if (!allFilaments?.data || !id) return { prevId: null, nextId: null };
const ids = allFilaments.data.map((f: IFilament) => f.id);
const currentIndex = ids.indexOf(Number(id));
return {
prevId: currentIndex > 0 ? ids[currentIndex - 1] : null,
nextId: currentIndex < ids.length - 1 ? ids[currentIndex + 1] : null,
};
}, [allFilaments, id]);
// Get vendor selection options
const { selectProps } = useSelect<IVendor>({
resource: "vendor",
optionLabel: "name",
});
// 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);
}
// Update colorType state
useEffect(() => {
if (formProps.initialValues?.multi_color_hexes) {
setColorType("multi");
} else {
setColorType("single");
}
}, [formProps.initialValues?.multi_color_hexes]);
// Override the form's onFinish method to stringify the extra fields
const originalOnFinish = formProps.onFinish;
formProps.onFinish = (allValues: IFilamentParsedExtras) => {
if (allValues !== undefined && allValues !== null) {
if (colorType == "single") {
allValues.multi_color_hexes = "";
}
// Lot of stupidity here to make types work
const stringifiedAllValues = StringifiedExtras<IFilamentParsedExtras>(allValues);
originalOnFinish?.({
extra: {},
...stringifiedAllValues,
});
}
};
return (
<Edit
saveButtonProps={saveButtonProps}
headerButtons={({ defaultButtons }) => (
<>
<Button
icon={<LeftOutlined />}
disabled={!prevId}
onClick={() => prevId && navigate(`/filament/edit/${prevId}`)}
>
{t("buttons.prev")}
</Button>
<Button
icon={<RightOutlined />}
disabled={!nextId}
onClick={() => nextId && navigate(`/filament/edit/${nextId}`)}
>
{t("buttons.next")}
</Button>
{defaultButtons}
</>
)}
>
{contextHolder}
<Form {...formProps} layout="vertical">
<Form.Item
label={t("filament.fields.id")}
name={["id"]}
rules={[
{
required: true,
},
]}
>
<Input readOnly disabled />
</Form.Item>
<Form.Item
label={t("filament.fields.registered")}
name={["registered"]}
rules={[
{
required: true,
},
]}
getValueProps={(value) => ({
value: value ? dayjs(value) : undefined,
})}
>
<DatePicker disabled showTime format="YYYY-MM-DD HH:mm:ss" />
</Form.Item>
<Form.Item
label={t("filament.fields.name")}
help={t("filament.fields_help.name")}
name={["name"]}
rules={[
{
required: false,
},
]}
>
<Input maxLength={64} />
</Form.Item>
<Form.Item
label={t("filament.fields.vendor")}
name={["vendor_id"]}
rules={[
{
required: false,
},
]}
// Applying this to Form.Item Select's causes a cleared select to send null
normalize={(value) => {
if (value === undefined) {
return null;
}
return value;
}}
>
<Select
{...selectProps}
allowClear
filterSort={(a, b) => {
return a?.label && b?.label
? (a.label as string).localeCompare(b.label as string, undefined, { sensitivity: "base" })
: 0;
}}
filterOption={(input, option) =>
typeof option?.label === "string" && option?.label.toLowerCase().includes(input.toLowerCase())
}
/>
</Form.Item>
<Form.Item label={t("filament.fields.color_hex")}>
<Radio.Group
onChange={(value) => {
setColorType(value.target.value);
}}
defaultValue={colorType}
value={colorType}
>
<Radio.Button value={"single"}>{t("filament.fields.single_color")}</Radio.Button>
<Radio.Button value={"multi"}>{t("filament.fields.multi_color")}</Radio.Button>
</Radio.Group>
</Form.Item>
{colorType == "single" && (
<div style={{ display: "flex", gap: 8, alignItems: "start" }}>
<Form.Item
name={"color_hex"}
rules={[
{
required: false,
},
]}
getValueFromEvent={(e) => {
return e?.toHex();
}}
>
<ColorPicker />
</Form.Item>
<FilamentColorLookup onSelect={(hex) => form.setFieldValue("color_hex", hex)} />
</div>
)}
{colorType == "multi" && (
<Form.Item
name={"multi_color_direction"}
help={t("filament.fields_help.multi_color_direction")}
rules={[
{
required: true,
},
]}
initialValue={"coaxial"}
>
<Radio.Group>
<Radio.Button value={"coaxial"}>{t("filament.fields.coaxial")}</Radio.Button>
<Radio.Button value={"longitudinal"}>{t("filament.fields.longitudinal")}</Radio.Button>
</Radio.Group>
</Form.Item>
)}
{colorType == "multi" && (
<Form.Item
name={"multi_color_hexes"}
rules={[
{
required: false,
},
]}
>
<MultiColorPicker min={2} max={14} />
</Form.Item>
)}
<Form.Item
label={t("filament.fields.material")}
help={t("filament.fields_help.material")}
name={["material"]}
rules={[
{
required: false,
},
]}
>
<Input maxLength={64} />
</Form.Item>
<Form.Item
label={t("filament.fields.price")}
help={t("filament.fields_help.price")}
name={["price"]}
rules={[
{
required: false,
type: "number",
min: 0,
},
]}
>
<InputNumber
addonAfter={getCurrencySymbol(undefined, currency)}
precision={2}
formatter={formatNumberOnUserInput}
parser={numberParserAllowEmpty}
/>
</Form.Item>
<Form.Item
label={t("filament.fields.density")}
name={["density"]}
rules={[
{
required: true,
type: "number",
min: 0,
max: 100,
},
]}
>
<InputNumber addonAfter="g/cm³" precision={2} formatter={formatNumberOnUserInput} parser={numberParser} />
</Form.Item>
<Form.Item
label={t("filament.fields.diameter")}
name={["diameter"]}
rules={[
{
required: true,
type: "number",
min: 0,
max: 10,
},
]}
>
<InputNumber addonAfter="mm" precision={2} formatter={formatNumberOnUserInput} parser={numberParser} />
</Form.Item>
<Form.Item
label={t("filament.fields.weight")}
help={t("filament.fields_help.weight")}
name={["weight"]}
rules={[
{
required: false,
type: "number",
min: 0,
},
]}
>
<InputNumber addonAfter="g" precision={1} />
</Form.Item>
<Form.Item
label={t("filament.fields.spool_weight")}
help={t("filament.fields_help.spool_weight")}
name={["spool_weight"]}
rules={[
{
required: false,
type: "number",
min: 0,
},
]}
>
<InputNumber addonAfter="g" precision={1} />
</Form.Item>
<div style={{ marginBottom: 16 }}>
<Button
size="small"
icon={<ThunderboltOutlined />}
onClick={() => {
const comment = form.getFieldValue("comment") || "";
const temps = parseTempsFromComment(comment);
if (!temps.extruderMin && !temps.bedMin) {
messageApi.warning(t("filament.form.no_temps_found"));
return;
}
const fields: Record<string, number | undefined> = {};
if (temps.extruderMin) fields.settings_extruder_temp_min = temps.extruderMin;
if (temps.extruderMax) fields.settings_extruder_temp_max = temps.extruderMax;
if (temps.bedMin) fields.settings_bed_temp_min = temps.bedMin;
if (temps.bedMax) fields.settings_bed_temp_max = temps.bedMax;
form.setFieldsValue(fields);
messageApi.success(t("filament.form.temps_parsed"));
}}
>
{t("filament.form.parse_temps")}
</Button>
</div>
<Form.Item label={t("filament.fields.settings_extruder_temp")}>
<Space.Compact>
<Form.Item
name={["settings_extruder_temp_min"]}
noStyle
rules={[{ type: "number", min: 0 }]}
>
<InputNumber placeholder={t("filament.fields.temp_min")} addonAfter="°C" precision={0} style={{ width: 140 }} />
</Form.Item>
<span style={{ padding: "4px 8px", background: "#fafafa", border: "1px solid #d9d9d9", borderLeft: 0, borderRight: 0 }}>-</span>
<Form.Item
name={["settings_extruder_temp_max"]}
noStyle
rules={[{ type: "number", min: 0 }]}
>
<InputNumber placeholder={t("filament.fields.temp_max")} addonAfter="°C" precision={0} style={{ width: 140 }} />
</Form.Item>
</Space.Compact>
</Form.Item>
<Form.Item label={t("filament.fields.settings_bed_temp")}>
<Space.Compact>
<Form.Item
name={["settings_bed_temp_min"]}
noStyle
rules={[{ type: "number", min: 0 }]}
>
<InputNumber placeholder={t("filament.fields.temp_min")} addonAfter="°C" precision={0} style={{ width: 140 }} />
</Form.Item>
<span style={{ padding: "4px 8px", background: "#fafafa", border: "1px solid #d9d9d9", borderLeft: 0, borderRight: 0 }}>-</span>
<Form.Item
name={["settings_bed_temp_max"]}
noStyle
rules={[{ type: "number", min: 0 }]}
>
<InputNumber placeholder={t("filament.fields.temp_max")} addonAfter="°C" precision={0} style={{ width: 140 }} />
</Form.Item>
</Space.Compact>
</Form.Item>
<Form.Item
label={t("filament.fields.article_number")}
help={t("filament.fields_help.article_number")}
name={["article_number"]}
rules={[
{
required: false,
},
]}
>
<Input maxLength={64} />
</Form.Item>
<Form.Item
label={t("filament.fields.external_id")}
name={["external_id"]}
rules={[
{
required: false,
},
]}
>
<Input maxLength={64} />
</Form.Item>
<Form.Item
label={t("filament.fields.comment")}
name={["comment"]}
rules={[
{
required: false,
},
]}
>
<TextArea maxLength={1024} />
</Form.Item>
<Typography.Title level={5}>{t("settings.extra_fields.tab")}</Typography.Title>
{extraFields.data?.map((field, index) => (
<ExtraFieldFormItem key={index} field={field} />
))}
</Form>
{hasChanged && <Alert description={t("filament.form.filament_updated")} type="warning" showIcon />}
</Edit>
);
};
export default FilamentEdit;