/ client / src / pages / spools / edit.tsx
edit.tsx
  1  import { Edit, useForm } from "@refinedev/antd";
  2  import { HttpError, useTranslate } from "@refinedev/core";
  3  import { Alert, DatePicker, Divider, Form, Input, InputNumber, Radio, Select, Typography } from "antd";
  4  import TextArea from "antd/es/input/TextArea";
  5  import { message } from "antd/lib";
  6  import dayjs from "dayjs";
  7  import { useEffect, useMemo, useState } from "react";
  8  import { useNavigate, useSearchParams } from "react-router";
  9  import { ExtraFieldFormItem, ParsedExtras, StringifiedExtras } from "../../components/extraFields";
 10  import { useSpoolmanLocations } from "../../components/otherModels";
 11  import { searchMatches } from "../../utils/filtering";
 12  import { formatNumberOnUserInput, numberParser, numberParserAllowEmpty } from "../../utils/parsing";
 13  import { EntityType, useGetFields } from "../../utils/queryFields";
 14  import { getCurrencySymbol, useCurrency } from "../../utils/settings";
 15  import { createFilamentFromExternal } from "../filaments/functions";
 16  import { useLocations } from "../locations/functions";
 17  import { useGetFilamentSelectOptions } from "./functions";
 18  import { ISpool, ISpoolParsedExtras, WeightToEnter } from "./model";
 19  
 20  /*
 21  The API returns the extra fields as JSON values, but we need to parse them into their real types
 22  in order for Ant design's form to work properly. ParsedExtras does this for us.
 23  We also need to stringify them again before sending them back to the API, which is done by overriding
 24  the form's onFinish method. Form.Item's normalize should do this, but it doesn't seem to work.
 25  */
 26  
 27  type ISpoolRequest = ISpoolParsedExtras & {
 28    filament_id: number | string;
 29  };
 30  
 31  export const SpoolEdit = () => {
 32    const t = useTranslate();
 33    const [messageApi, contextHolder] = message.useMessage();
 34    const [hasChanged, setHasChanged] = useState(false);
 35    const extraFields = useGetFields(EntityType.spool);
 36    const currency = useCurrency();
 37    const [searchParams] = useSearchParams();
 38    const navigate = useNavigate();
 39  
 40    const { form, formProps, saveButtonProps } = useForm<ISpool, HttpError, ISpoolRequest, ISpool>({
 41      liveMode: "manual",
 42      onLiveEvent() {
 43        // Warn the user if the spool has been updated since the form was opened
 44        messageApi.warning(t("spool.form.spool_updated"));
 45        setHasChanged(true);
 46      },
 47  
 48      // Custom redirect logic
 49      redirect: false,
 50      onMutationSuccess: () => {
 51        const returnUrl = searchParams.get("return");
 52        if (returnUrl) {
 53          navigate(returnUrl, { relative: "path" });
 54        } else {
 55          navigate("/spool");
 56        }
 57      },
 58    });
 59  
 60    const initialWeightValue = Form.useWatch("initial_weight", form);
 61    const spoolWeightValue = Form.useWatch("spool_weight", form);
 62  
 63    // Add the filament_id field to the form
 64    if (formProps.initialValues) {
 65      formProps.initialValues["filament_id"] = formProps.initialValues["filament"].id;
 66  
 67      // Parse the extra fields from string values into real types
 68      formProps.initialValues = ParsedExtras(formProps.initialValues);
 69    }
 70  
 71    //
 72    // Set up the filament selection options
 73    //
 74    const {
 75      options: filamentOptions,
 76      internalSelectOptions,
 77      externalSelectOptions,
 78      allExternalFilaments,
 79    } = useGetFilamentSelectOptions();
 80  
 81    const selectedFilamentID = Form.useWatch("filament_id", form);
 82    const selectedFilament = useMemo(() => {
 83      // id is a number of it's an internal filament, and a string of it's an external filament.
 84      if (typeof selectedFilamentID === "number") {
 85        return (
 86          internalSelectOptions?.find((obj) => {
 87            return obj.value === selectedFilamentID;
 88          }) ?? null
 89        );
 90      } else if (typeof selectedFilamentID === "string") {
 91        return (
 92          externalSelectOptions?.find((obj) => {
 93            return obj.value === selectedFilamentID;
 94          }) ?? null
 95        );
 96      } else {
 97        return null;
 98      }
 99    }, [selectedFilamentID, internalSelectOptions, externalSelectOptions]);
100  
101    // Override the form's onFinish method to stringify the extra fields
102    const originalOnFinish = formProps.onFinish;
103    formProps.onFinish = (allValues: ISpoolRequest) => {
104      if (allValues !== undefined && allValues !== null) {
105        // Lot of stupidity here to make types work
106        const values = StringifiedExtras<ISpoolRequest>(allValues);
107        if (selectedFilament?.is_internal === false) {
108          // Filament ID being a string indicates its an external filament.
109          // If so, we should first create the internal filament version, then edit the spool
110          const externalFilament = allExternalFilaments?.find((f) => f.id === values.filament_id);
111          if (!externalFilament) {
112            throw new Error("Unknown external filament");
113          }
114          createFilamentFromExternal(externalFilament).then((internalFilament) => {
115            values.filament_id = internalFilament.id;
116            originalOnFinish?.({
117              extra: {},
118              ...values,
119            });
120          });
121        } else {
122          originalOnFinish?.({
123            extra: {},
124            ...values,
125          });
126        }
127      }
128    };
129  
130    const [weightToEnter, setWeightToEnter] = useState(1);
131    const [usedWeight, setUsedWeight] = useState(0);
132  
133    useEffect(() => {
134      const newFilamentWeight = getFilamentWeight();
135      const newSpoolWeight = getSpoolWeight();
136      if (newFilamentWeight > 0) {
137        form.setFieldValue("initial_weight", newFilamentWeight);
138      }
139      if (newSpoolWeight > 0) {
140        form.setFieldValue("spool_weight", newSpoolWeight);
141      }
142    }, [selectedFilament]);
143  
144    const weightChange = (weight: number) => {
145      setUsedWeight(weight);
146      form.setFieldsValue({
147        used_weight: weight,
148      });
149    };
150  
151    const locations = useSpoolmanLocations(true);
152    const settingsLocation = useLocations();
153    const [newLocation, setNewLocation] = useState("");
154  
155    const allLocations = [...(settingsLocation || [])];
156    locations?.data?.forEach((loc) => {
157      if (!allLocations.includes(loc)) {
158        allLocations.push(loc);
159      }
160    });
161    if (newLocation.trim() && !allLocations.includes(newLocation)) {
162      allLocations.push(newLocation.trim());
163    }
164  
165    const getSpoolWeight = (): number => {
166      return spoolWeightValue ?? selectedFilament?.spool_weight ?? 0;
167    };
168  
169    const getFilamentWeight = (): number => {
170      return initialWeightValue ?? selectedFilament?.weight ?? 0;
171    };
172  
173    const getGrossWeight = (): number => {
174      const net_weight = getFilamentWeight();
175      const spool_weight = getSpoolWeight();
176      return net_weight + spool_weight;
177    };
178  
179    const getMeasuredWeight = (): number => {
180      const grossWeight = getGrossWeight();
181  
182      return grossWeight - usedWeight;
183    };
184  
185    const getRemainingWeight = (): number => {
186      const initial_weight = getFilamentWeight();
187  
188      return initial_weight - usedWeight;
189    };
190  
191    const isMeasuredWeightEnabled = (): boolean => {
192      if (!isRemainingWeightEnabled()) {
193        return false;
194      }
195  
196      const spool_weight = spoolWeightValue;
197  
198      return spool_weight || selectedFilament?.spool_weight ? true : false;
199    };
200  
201    const isRemainingWeightEnabled = (): boolean => {
202      const initial_weight = initialWeightValue;
203  
204      if (initial_weight) {
205        return true;
206      }
207  
208      return selectedFilament?.weight ? true : false;
209    };
210  
211    useEffect(() => {
212      if (weightToEnter >= WeightToEnter.measured_weight) {
213        if (!isMeasuredWeightEnabled()) {
214          setWeightToEnter(WeightToEnter.remaining_weight);
215          return;
216        }
217      }
218      if (weightToEnter >= WeightToEnter.remaining_weight) {
219        if (!isRemainingWeightEnabled()) {
220          setWeightToEnter(WeightToEnter.used_weight);
221          return;
222        }
223      }
224    }, [selectedFilament]);
225  
226    const initialUsedWeight = formProps.initialValues?.used_weight || 0;
227    useEffect(() => {
228      if (initialUsedWeight) {
229        setUsedWeight(initialUsedWeight);
230      }
231    }, [initialUsedWeight]);
232  
233    return (
234      <Edit saveButtonProps={saveButtonProps}>
235        {contextHolder}
236        <Form {...formProps} layout="vertical">
237          <Form.Item
238            label={t("spool.fields.id")}
239            name={["id"]}
240            rules={[
241              {
242                required: true,
243              },
244            ]}
245          >
246            <Input readOnly disabled />
247          </Form.Item>
248          <Form.Item
249            label={t("spool.fields.registered")}
250            name={["registered"]}
251            rules={[
252              {
253                required: true,
254              },
255            ]}
256            getValueProps={(value) => ({
257              value: value ? dayjs(value) : undefined,
258            })}
259          >
260            <DatePicker disabled showTime format="YYYY-MM-DD HH:mm:ss" />
261          </Form.Item>
262          <Form.Item
263            label={t("spool.fields.first_used")}
264            name={["first_used"]}
265            rules={[
266              {
267                required: false,
268              },
269            ]}
270            getValueProps={(value) => ({
271              value: value ? dayjs(value) : undefined,
272            })}
273          >
274            <DatePicker showTime format="YYYY-MM-DD HH:mm:ss" />
275          </Form.Item>
276          <Form.Item
277            label={t("spool.fields.last_used")}
278            name={["last_used"]}
279            rules={[
280              {
281                required: false,
282              },
283            ]}
284            getValueProps={(value) => ({
285              value: value ? dayjs(value) : undefined,
286            })}
287          >
288            <DatePicker showTime format="YYYY-MM-DD HH:mm:ss" />
289          </Form.Item>
290          <Form.Item
291            label={t("spool.fields.filament")}
292            name={["filament_id"]}
293            rules={[
294              {
295                required: true,
296              },
297            ]}
298          >
299            <Select
300              options={filamentOptions}
301              showSearch
302              filterOption={(input, option) => typeof option?.label === "string" && searchMatches(input, option?.label)}
303            />
304          </Form.Item>
305          {selectedFilament?.is_internal === false && (
306            <Alert message={t("spool.fields_help.external_filament")} type="info" />
307          )}
308          <Form.Item
309            label={t("spool.fields.price")}
310            help={t("spool.fields_help.price")}
311            name={["price"]}
312            rules={[
313              {
314                required: false,
315                type: "number",
316                min: 0,
317              },
318            ]}
319          >
320            <InputNumber
321              addonAfter={getCurrencySymbol(undefined, currency)}
322              precision={2}
323              formatter={formatNumberOnUserInput}
324              parser={numberParserAllowEmpty}
325            />
326          </Form.Item>
327          <Form.Item
328            label={t("spool.fields.initial_weight")}
329            help={t("spool.fields_help.initial_weight")}
330            name={["initial_weight"]}
331            rules={[
332              {
333                required: false,
334                type: "number",
335                min: 0,
336              },
337            ]}
338          >
339            <InputNumber addonAfter="g" precision={1} />
340          </Form.Item>
341  
342          <Form.Item
343            label={t("spool.fields.spool_weight")}
344            help={t("spool.fields_help.spool_weight")}
345            name={["spool_weight"]}
346            rules={[
347              {
348                required: false,
349                type: "number",
350                min: 0,
351              },
352            ]}
353          >
354            <InputNumber addonAfter="g" precision={1} />
355          </Form.Item>
356  
357          <Form.Item hidden={true} name={["used_weight"]} initialValue={0}>
358            <InputNumber value={usedWeight} />
359          </Form.Item>
360  
361          <Form.Item label={t("spool.fields.weight_to_use")} help={t("spool.fields_help.weight_to_use")}>
362            <Radio.Group
363              onChange={(value) => {
364                setWeightToEnter(value.target.value);
365              }}
366              defaultValue={WeightToEnter.used_weight}
367              value={weightToEnter}
368            >
369              <Radio.Button value={WeightToEnter.used_weight}>{t("spool.fields.used_weight")}</Radio.Button>
370              <Radio.Button value={WeightToEnter.remaining_weight} disabled={!isRemainingWeightEnabled()}>
371                {t("spool.fields.remaining_weight")}
372              </Radio.Button>
373              <Radio.Button value={WeightToEnter.measured_weight} disabled={!isMeasuredWeightEnabled()}>
374                {t("spool.fields.measured_weight")}
375              </Radio.Button>
376            </Radio.Group>
377          </Form.Item>
378          <Form.Item label={t("spool.fields.used_weight")} help={t("spool.fields_help.used_weight")}>
379            <InputNumber
380              min={0}
381              addonAfter="g"
382              precision={1}
383              formatter={formatNumberOnUserInput}
384              parser={numberParser}
385              disabled={weightToEnter != WeightToEnter.used_weight}
386              value={usedWeight}
387              onChange={(value) => {
388                weightChange(value ?? 0);
389              }}
390            />
391          </Form.Item>
392          <Form.Item label={t("spool.fields.remaining_weight")} help={t("spool.fields_help.remaining_weight")}>
393            <InputNumber
394              min={0}
395              addonAfter="g"
396              precision={1}
397              formatter={formatNumberOnUserInput}
398              parser={numberParser}
399              disabled={weightToEnter != WeightToEnter.remaining_weight}
400              value={getRemainingWeight()}
401              onChange={(value) => {
402                weightChange(getFilamentWeight() - (value ?? 0));
403              }}
404            />
405          </Form.Item>
406          <Form.Item label={t("spool.fields.measured_weight")} help={t("spool.fields_help.measured_weight")}>
407            <InputNumber
408              min={0}
409              addonAfter="g"
410              precision={1}
411              formatter={formatNumberOnUserInput}
412              parser={numberParser}
413              disabled={weightToEnter != WeightToEnter.measured_weight}
414              value={getMeasuredWeight()}
415              onChange={(value) => {
416                const totalWeight = getGrossWeight();
417                weightChange(totalWeight - (value ?? 0));
418              }}
419            />
420          </Form.Item>
421          <Form.Item
422            label={t("spool.fields.location")}
423            help={t("spool.fields_help.location")}
424            name={["location"]}
425            rules={[
426              {
427                required: false,
428              },
429            ]}
430          >
431            <Select
432              dropdownRender={(menu) => (
433                <>
434                  {menu}
435                  <Divider style={{ margin: "8px 0" }} />
436                  <Input
437                    placeholder={t("spool.form.new_location_prompt")}
438                    value={newLocation}
439                    onChange={(event) => setNewLocation(event.target.value)}
440                  />
441                </>
442              )}
443              loading={locations.isLoading}
444              options={allLocations.map((item) => ({ label: item, value: item }))}
445            />
446          </Form.Item>
447          <Form.Item
448            label={t("spool.fields.lot_nr")}
449            help={t("spool.fields_help.lot_nr")}
450            name={["lot_nr"]}
451            rules={[
452              {
453                required: false,
454              },
455            ]}
456          >
457            <Input maxLength={64} />
458          </Form.Item>
459          <Form.Item
460            label={t("spool.fields.comment")}
461            name={["comment"]}
462            rules={[
463              {
464                required: false,
465              },
466            ]}
467          >
468            <TextArea maxLength={1024} />
469          </Form.Item>
470          <Typography.Title level={5}>{t("settings.extra_fields.tab")}</Typography.Title>
471          {extraFields.data?.map((field, index) => (
472            <ExtraFieldFormItem key={index} field={field} />
473          ))}
474        </Form>
475        {hasChanged && <Alert description={t("spool.form.spool_updated")} type="warning" showIcon />}
476      </Edit>
477    );
478  };
479  
480  export default SpoolEdit;