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;