From ea5b06493cf0bc339ccf90adae2cccedb7e4e1f7 Mon Sep 17 00:00:00 2001 From: tonym Date: Wed, 14 Jan 2026 19:33:25 -0600 Subject: [PATCH] feat: Add inline creation modals for filament and vendor (#1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When creating a spool, users can now create a new filament directly from the filament dropdown without leaving the page. Similarly, when creating a filament, users can create a new vendor inline. New components: - VendorCreateModal: Simple modal for quick vendor creation - FilamentCreateModal: Modal with essential fields + nested vendor creation Changes: - Added dropdownRender to filament Select in spool create page - Added dropdownRender to vendor Select in filament create page - Added translation keys for success messages and placeholders - Pre-fills sensible defaults (diameter 1.75mm, density 1.24 g/cm³) Closes #1 Co-Authored-By: Claude Opus 4.5 --- client/public/locales/en/common.json | 10 +- client/src/components/filamentCreateModal.tsx | 212 ++++++++++++++++++ client/src/components/vendorCreateModal.tsx | 98 ++++++++ client/src/pages/filaments/create.tsx | 34 ++- client/src/pages/spools/create.tsx | 36 ++- 5 files changed, 386 insertions(+), 4 deletions(-) create mode 100644 client/src/components/filamentCreateModal.tsx create mode 100644 client/src/components/vendorCreateModal.tsx diff --git a/client/public/locales/en/common.json b/client/public/locales/en/common.json index 88ff2ae..1931964 100644 --- a/client/public/locales/en/common.json +++ b/client/public/locales/en/common.json @@ -243,6 +243,7 @@ "name": "Filament name, to distinguish this filament type among others from the same manufacturer. Should contain the color for example.", "material": "E.g. PLA, ABS, PETG, etc.", "price": "Price of a full spool.", + "density": "Auto-filled based on material. Standard: PLA 1.24, ABS 1.04, PETG 1.27 g/cm³.", "weight": "The filament weight of a full spool (net weight). This should not include the weight of the spool itself, only the filament. It is what is usually written on the packaging.", "spool_weight": "The weight of an empty spool. Used to determine measured weight of a spool.", "article_number": "E.g. EAN, UPC, etc.", @@ -259,7 +260,10 @@ "form": { "filament_updated": "This filament has been updated by someone/something else since you opened this page. Saving will overwrite those changes!", "import_external": "Import from External", - "import_external_description": "Select a filament in the list to automatically populate its details in the filament creation form. This will overwrite any data you have entered in the form.

A Manufacturer object will be created automatically when you click Ok, if necessary." + "import_external_description": "Select a filament in the list to automatically populate its details in the filament creation form. This will overwrite any data you have entered in the form.

A Manufacturer object will be created automatically when you click Ok, if necessary.", + "created_success": "Created filament \"{{name}}\"", + "vendor_placeholder": "Select a manufacturer", + "name_placeholder": "E.g. Matte Black" }, "buttons": { "add_spool": "Add Spool" @@ -287,7 +291,9 @@ "show_title": "[Manufacturer #{{id}}] {{name}}" }, "form": { - "vendor_updated": "This manufacturer has been updated by someone/something else since you opened this page. Saving will overwrite those changes!" + "vendor_updated": "This manufacturer has been updated by someone/something else since you opened this page. Saving will overwrite those changes!", + "created_success": "Created manufacturer \"{{name}}\"", + "name_required": "Name is required" } }, "home": { diff --git a/client/src/components/filamentCreateModal.tsx b/client/src/components/filamentCreateModal.tsx new file mode 100644 index 0000000..1e763ba --- /dev/null +++ b/client/src/components/filamentCreateModal.tsx @@ -0,0 +1,212 @@ +import { useSelect } from "@refinedev/antd"; +import { useInvalidate, useTranslate } from "@refinedev/core"; +import { Button, ColorPicker, Divider, Form, Input, InputNumber, Modal, Select, message } from "antd"; +import { PlusOutlined } from "@ant-design/icons"; +import { useState } from "react"; +import { Color } from "antd/es/color-picker"; +import { IFilament } from "../pages/filaments/model"; +import { IVendor } from "../pages/vendors/model"; +import { getAPIURL } from "../utils/url"; +import { VendorCreateModal } from "./vendorCreateModal"; + +export interface FilamentCreateModalProps { + isOpen: boolean; + onSuccess: (filament: IFilament) => void; + onClose: () => void; +} + +// Default values for common filament properties +const DEFAULT_DIAMETER = 1.75; +const DEFAULT_DENSITY = 1.24; // PLA + +/** + * Modal for quickly creating a filament inline without navigating away from the current page. + * Includes essential fields and the ability to create a vendor inline. + */ +export function FilamentCreateModal(props: FilamentCreateModalProps) { + const [form] = Form.useForm(); + const t = useTranslate(); + const invalidate = useInvalidate(); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isVendorModalOpen, setIsVendorModalOpen] = useState(false); + + // Vendor select + const { selectProps: vendorSelectProps } = useSelect({ + resource: "vendor", + optionLabel: "name", + }); + + const handleVendorCreated = async (vendor: IVendor) => { + // Invalidate vendor cache so select options refresh + await invalidate({ + resource: "vendor", + invalidates: ["list"], + }); + form.setFieldValue("vendor_id", vendor.id); + setIsVendorModalOpen(false); + }; + + const handleSubmit = async () => { + try { + const values = await form.validateFields(); + setIsSubmitting(true); + + // Convert ColorPicker value to hex string if present + let colorHex: string | undefined = undefined; + if (values.color_hex) { + const color = values.color_hex as Color; + colorHex = typeof color === "string" ? color : color.toHexString(); + } + + const body = { + name: values.name || undefined, + vendor_id: values.vendor_id || undefined, + material: values.material || undefined, + density: values.density, + diameter: values.diameter, + weight: values.weight || undefined, + color_hex: colorHex, + }; + + const response = await fetch(getAPIURL() + "/filament", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.message || "Failed to create filament"); + } + + const filament: IFilament = await response.json(); + + // Invalidate filament cache so lists refresh + await invalidate({ + resource: "filament", + invalidates: ["list"], + }); + + message.success(t("filament.form.created_success", { name: filament.name || `#${filament.id}` })); + + props.onSuccess(filament); + form.resetFields(); + props.onClose(); + } catch (error) { + message.error(error instanceof Error ? error.message : "Failed to create filament"); + } finally { + setIsSubmitting(false); + } + }; + + const handleCancel = () => { + form.resetFields(); + props.onClose(); + }; + + // Set default values when modal opens + const handleAfterOpenChange = (open: boolean) => { + if (open) { + form.setFieldsValue({ + diameter: DEFAULT_DIAMETER, + density: DEFAULT_DENSITY, + }); + } + }; + + return ( + <> + setIsVendorModalOpen(false)} + /> + +
+ {/* Vendor with inline create option */} + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + ); +} diff --git a/client/src/components/vendorCreateModal.tsx b/client/src/components/vendorCreateModal.tsx new file mode 100644 index 0000000..7c62d54 --- /dev/null +++ b/client/src/components/vendorCreateModal.tsx @@ -0,0 +1,98 @@ +import { useTranslate } from "@refinedev/core"; +import { Form, Input, InputNumber, Modal, message } from "antd"; +import TextArea from "antd/es/input/TextArea"; +import { useState } from "react"; +import { IVendor } from "../pages/vendors/model"; +import { getAPIURL } from "../utils/url"; + +export interface VendorCreateModalProps { + isOpen: boolean; + onSuccess: (vendor: IVendor) => void; + onClose: () => void; +} + +/** + * Modal for quickly creating a vendor inline without navigating away from the current page. + * Only requires the vendor name - all other fields are optional. + */ +export function VendorCreateModal(props: VendorCreateModalProps) { + const [form] = Form.useForm(); + const t = useTranslate(); + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleSubmit = async () => { + try { + const values = await form.validateFields(); + setIsSubmitting(true); + + const body: Omit = { + name: values.name, + comment: values.comment || undefined, + empty_spool_weight: values.empty_spool_weight || undefined, + }; + + const response = await fetch(getAPIURL() + "/vendor", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.message || "Failed to create vendor"); + } + + const vendor: IVendor = await response.json(); + message.success(t("vendor.form.created_success", { name: vendor.name })); + + props.onSuccess(vendor); + form.resetFields(); + props.onClose(); + } catch (error) { + message.error(error instanceof Error ? error.message : "Failed to create vendor"); + } finally { + setIsSubmitting(false); + } + }; + + const handleCancel = () => { + form.resetFields(); + props.onClose(); + }; + + return ( + +
+ + + + +