feat: Add inline creation modals for filament and vendor (#1)
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

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 <noreply@anthropic.com>
This commit is contained in:
2026-01-14 19:33:25 -06:00
parent a55194ba16
commit ea5b06493c
5 changed files with 386 additions and 4 deletions

View File

@@ -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<IVendor>({
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 (
<>
<VendorCreateModal
isOpen={isVendorModalOpen}
onSuccess={handleVendorCreated}
onClose={() => setIsVendorModalOpen(false)}
/>
<Modal
title={t("filament.titles.create")}
open={props.isOpen}
onOk={handleSubmit}
onCancel={handleCancel}
confirmLoading={isSubmitting}
afterOpenChange={handleAfterOpenChange}
destroyOnClose
width={500}
>
<Form form={form} layout="vertical">
{/* Vendor with inline create option */}
<Form.Item
label={t("filament.fields.vendor")}
name="vendor_id"
>
<Select
{...vendorSelectProps}
allowClear
placeholder={t("filament.form.vendor_placeholder")}
dropdownRender={(menu) => (
<>
{menu}
<Divider style={{ margin: "8px 0" }} />
<Button
type="text"
icon={<PlusOutlined />}
onClick={() => setIsVendorModalOpen(true)}
style={{ width: "100%", textAlign: "left" }}
>
{t("vendor.titles.create")}
</Button>
</>
)}
/>
</Form.Item>
<Form.Item
label={t("filament.fields.name")}
name="name"
>
<Input maxLength={64} placeholder={t("filament.form.name_placeholder")} />
</Form.Item>
<Form.Item
label={t("filament.fields.material")}
name="material"
>
<Input maxLength={64} placeholder="PLA, PETG, ABS, etc." />
</Form.Item>
<Form.Item
label={t("filament.fields.color_hex")}
name="color_hex"
>
<ColorPicker format="hex" />
</Form.Item>
<Form.Item
label={t("filament.fields.density")}
name="density"
rules={[{ required: true, type: "number", min: 0.01, max: 100 }]}
help={t("filament.fields_help.density")}
>
<InputNumber addonAfter="g/cm³" precision={2} style={{ width: "100%" }} />
</Form.Item>
<Form.Item
label={t("filament.fields.diameter")}
name="diameter"
rules={[{ required: true, type: "number", min: 0.01, max: 10 }]}
>
<InputNumber addonAfter="mm" precision={2} style={{ width: "100%" }} />
</Form.Item>
<Form.Item
label={t("filament.fields.weight")}
name="weight"
help={t("filament.fields_help.weight")}
>
<InputNumber addonAfter="g" precision={0} min={0} style={{ width: "100%" }} />
</Form.Item>
</Form>
</Modal>
</>
);
}

View File

@@ -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<IVendor, "id" | "registered" | "extra"> = {
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 (
<Modal
title={t("vendor.titles.create")}
open={props.isOpen}
onOk={handleSubmit}
onCancel={handleCancel}
confirmLoading={isSubmitting}
destroyOnClose
>
<Form form={form} layout="vertical">
<Form.Item
label={t("vendor.fields.name")}
name="name"
rules={[{ required: true, message: t("vendor.form.name_required") }]}
>
<Input maxLength={64} autoFocus />
</Form.Item>
<Form.Item
label={t("vendor.fields.comment")}
name="comment"
>
<TextArea maxLength={1024} rows={2} />
</Form.Item>
<Form.Item
label={t("vendor.fields.empty_spool_weight")}
name="empty_spool_weight"
help={t("vendor.fields_help.empty_spool_weight")}
>
<InputNumber min={0} addonAfter="g" precision={1} />
</Form.Item>
</Form>
</Modal>
);
}