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
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:
212
client/src/components/filamentCreateModal.tsx
Normal file
212
client/src/components/filamentCreateModal.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
98
client/src/components/vendorCreateModal.tsx
Normal file
98
client/src/components/vendorCreateModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user