/ client / src / pages / spools / list.tsx
list.tsx
  1  import {
  2    EditOutlined,
  3    EyeOutlined,
  4    FilterOutlined,
  5    InboxOutlined,
  6    PlusSquareOutlined,
  7    PrinterOutlined,
  8    ToolOutlined,
  9    ToTopOutlined,
 10  } from "@ant-design/icons";
 11  import { List, useTable } from "@refinedev/antd";
 12  import { useInvalidate, useNavigation, useTranslate } from "@refinedev/core";
 13  import { Button, Dropdown, Modal, Table } from "antd";
 14  import dayjs from "dayjs";
 15  import utc from "dayjs/plugin/utc";
 16  import { useCallback, useMemo, useState } from "react";
 17  import { useNavigate } from "react-router";
 18  import {
 19    Action,
 20    ActionsColumn,
 21    CustomFieldColumn,
 22    DateColumn,
 23    FilteredQueryColumn,
 24    NumberColumn,
 25    RichColumn,
 26    SortedColumn,
 27    SpoolIconColumn,
 28  } from "../../components/column";
 29  import { useLiveify } from "../../components/liveify";
 30  import {
 31    useSpoolmanFilamentFilter,
 32    useSpoolmanLocations,
 33    useSpoolmanLotNumbers,
 34    useSpoolmanMaterials,
 35  } from "../../components/otherModels";
 36  import { removeUndefined } from "../../utils/filtering";
 37  import { EntityType, useGetFields } from "../../utils/queryFields";
 38  import { TableState, useInitialTableState, useSavedState, useStoreInitialState } from "../../utils/saveload";
 39  import { useCurrencyFormatter } from "../../utils/settings";
 40  import { setSpoolArchived, useSpoolAdjustModal } from "./functions";
 41  import { ISpool } from "./model";
 42  
 43  dayjs.extend(utc);
 44  
 45  const { confirm } = Modal;
 46  
 47  interface ISpoolCollapsed extends ISpool {
 48    "filament.combined_name": string; // Eg. "Prusa - PLA Red"
 49    "filament.id": number;
 50    "filament.material"?: string;
 51  }
 52  
 53  function collapseSpool(element: ISpool): ISpoolCollapsed {
 54    let filament_name: string;
 55    if (element.filament.vendor && "name" in element.filament.vendor) {
 56      filament_name = `${element.filament.vendor.name} - ${element.filament.name}`;
 57    } else {
 58      filament_name = element.filament.name ?? element.filament.id.toString();
 59    }
 60    if (element.price === undefined) {
 61      element.price = element.filament.price;
 62    }
 63    return {
 64      ...element,
 65      "filament.combined_name": filament_name,
 66      "filament.id": element.filament.id,
 67      "filament.material": element.filament.material,
 68    };
 69  }
 70  
 71  function translateColumnI18nKey(columnName: string): string {
 72    columnName = columnName.replace(".", "_");
 73    if (columnName === "filament_combined_name") columnName = "filament_name";
 74    else if (columnName === "filament_material") columnName = "material";
 75    return `spool.fields.${columnName}`;
 76  }
 77  
 78  const namespace = "spoolList-v2";
 79  
 80  const allColumns: (keyof ISpoolCollapsed & string)[] = [
 81    "id",
 82    "filament.combined_name",
 83    "filament.material",
 84    "price",
 85    "used_weight",
 86    "remaining_weight",
 87    "used_length",
 88    "remaining_length",
 89    "location",
 90    "lot_nr",
 91    "first_used",
 92    "last_used",
 93    "registered",
 94    "comment",
 95  ];
 96  const defaultColumns = allColumns.filter(
 97    (column_id) => ["registered", "used_length", "remaining_length", "lot_nr"].indexOf(column_id) === -1,
 98  );
 99  
100  export const SpoolList = () => {
101    const t = useTranslate();
102    const invalidate = useInvalidate();
103    const navigate = useNavigate();
104    const extraFields = useGetFields(EntityType.spool);
105    const currencyFormatter = useCurrencyFormatter();
106    const { openSpoolAdjustModal, spoolAdjustModal } = useSpoolAdjustModal();
107  
108    const allColumnsWithExtraFields = [...allColumns, ...(extraFields.data?.map((field) => "extra." + field.key) ?? [])];
109  
110    // Load initial state
111    const initialState = useInitialTableState(namespace);
112  
113    // State for the switch to show archived spools
114    const [showArchived, setShowArchived] = useSavedState("spoolList-showArchived", false);
115  
116    // Fetch data from the API
117    // To provide the live updates, we use a custom solution (useLiveify) instead of the built-in refine "liveMode" feature.
118    // This is because the built-in feature does not call the liveProvider subscriber with a list of IDs, but instead
119    // calls it with a list of filters, sorters, etc. This means the server-side has to support this, which is quite hard.
120    const { tableProps, sorters, setSorters, filters, setFilters, currentPage, pageSize, setCurrentPage } =
121      useTable<ISpoolCollapsed>({
122        meta: {
123          queryParams: {
124            ["allow_archived"]: showArchived,
125          },
126        },
127        syncWithLocation: false,
128        pagination: {
129          mode: "server",
130          currentPage: initialState.pagination.currentPage,
131          pageSize: initialState.pagination.pageSize,
132        },
133        sorters: {
134          mode: "server",
135          initial: initialState.sorters,
136        },
137        filters: {
138          mode: "server",
139          initial: initialState.filters,
140        },
141        liveMode: "manual",
142        onLiveEvent(event) {
143          if (event.type === "created" || event.type === "deleted") {
144            // updated is handled by the liveify
145            invalidate({
146              resource: "spool",
147              invalidates: ["list"],
148            });
149          }
150        },
151        queryOptions: {
152          select(data) {
153            return {
154              total: data.total,
155              data: data.data.map(collapseSpool),
156            };
157          },
158        },
159      });
160  
161    // Create state for the columns to show
162    const [showColumns, setShowColumns] = useState<string[]>(initialState.showColumns ?? defaultColumns);
163  
164    // Store state in local storage
165    const tableState: TableState = {
166      sorters,
167      filters,
168      pagination: { currentPage: currentPage, pageSize },
169      showColumns,
170    };
171    useStoreInitialState(namespace, tableState);
172  
173    // Collapse the dataSource to a mutable list
174    const queryDataSource: ISpoolCollapsed[] = useMemo(
175      () => (tableProps.dataSource || []).map((record) => ({ ...record })),
176      [tableProps.dataSource],
177    );
178    const dataSource = useLiveify("spool", queryDataSource, collapseSpool);
179  
180    // Function for opening an ant design modal that asks for confirmation for archiving a spool
181    const archiveSpool = async (spool: ISpoolCollapsed, archive: boolean) => {
182      await setSpoolArchived(spool, archive);
183      invalidate({
184        resource: "spool",
185        id: spool.id,
186        invalidates: ["list", "detail"],
187      });
188    };
189  
190    const archiveSpoolPopup = async (spool: ISpoolCollapsed) => {
191      // If the spool has no remaining weight, archive it immediately since it's likely not a mistake
192      if (spool.remaining_weight != undefined && spool.remaining_weight <= 0) {
193        await archiveSpool(spool, true);
194      } else {
195        confirm({
196          title: t("spool.titles.archive"),
197          content: t("spool.messages.archive"),
198          okText: t("buttons.archive"),
199          okType: "primary",
200          cancelText: t("buttons.cancel"),
201          onOk() {
202            return archiveSpool(spool, true);
203          },
204        });
205      }
206    };
207  
208    if (tableProps.pagination) {
209      tableProps.pagination.showSizeChanger = true;
210    }
211  
212    const { editUrl, showUrl, cloneUrl } = useNavigation();
213    const actions = useCallback(
214      (record: ISpoolCollapsed) => {
215        const actions: Action[] = [
216          { name: t("buttons.show"), icon: <EyeOutlined />, link: showUrl("spool", record.id) },
217          { name: t("buttons.edit"), icon: <EditOutlined />, link: editUrl("spool", record.id) },
218          { name: t("buttons.clone"), icon: <PlusSquareOutlined />, link: cloneUrl("spool", record.id) },
219          { name: t("spool.titles.adjust"), icon: <ToolOutlined />, onClick: () => openSpoolAdjustModal(record) },
220        ];
221        if (record.archived) {
222          actions.push({
223            name: t("buttons.unArchive"),
224            icon: <ToTopOutlined />,
225            onClick: () => archiveSpool(record, false),
226          });
227        } else {
228          actions.push({ name: t("buttons.archive"), icon: <InboxOutlined />, onClick: () => archiveSpoolPopup(record) });
229        }
230        return actions;
231      },
232      [t, editUrl, showUrl, cloneUrl, openSpoolAdjustModal, archiveSpool, archiveSpoolPopup],
233    );
234  
235    const originalOnChange = tableProps.onChange;
236    tableProps.onChange = (pagination, filters, sorter, extra) => {
237      // Rename any key called "filament.combined_name" in filters to "filament.id"
238      // This is because we want to use combined_name for sorting, but id for filtering,
239      // and Ant Design and Refine only supports specifying a single field for both.
240      Object.keys(filters).forEach((key) => {
241        if (key === "filament.combined_name") {
242          filters["filament.id"] = filters[key];
243          delete filters[key];
244        }
245      });
246  
247      originalOnChange?.(pagination, filters, sorter, extra);
248    };
249  
250    const commonProps = {
251      t,
252      navigate,
253      actions,
254      dataSource,
255      tableState,
256      sorter: true,
257    };
258  
259    return (
260      <List
261        headerButtons={({ defaultButtons }) => (
262          <>
263            <Button
264              type="primary"
265              icon={<PrinterOutlined />}
266              onClick={() => {
267                navigate("print");
268              }}
269            >
270              {t("printing.qrcode.button")}
271            </Button>
272            <Button
273              type="primary"
274              icon={<InboxOutlined />}
275              onClick={() => {
276                setShowArchived(!showArchived);
277              }}
278            >
279              {showArchived ? t("buttons.hideArchived") : t("buttons.showArchived")}
280            </Button>
281            <Button
282              type="primary"
283              icon={<FilterOutlined />}
284              onClick={() => {
285                setFilters([], "replace");
286                setSorters([{ field: "id", order: "asc" }]);
287                setCurrentPage(1);
288              }}
289            >
290              {t("buttons.clearFilters")}
291            </Button>
292            <Dropdown
293              trigger={["click"]}
294              menu={{
295                items: allColumnsWithExtraFields.map((column_id) => {
296                  if (column_id.indexOf("extra.") === 0) {
297                    const extraField = extraFields.data?.find((field) => "extra." + field.key === column_id);
298                    return {
299                      key: column_id,
300                      label: extraField?.name ?? column_id,
301                    };
302                  }
303  
304                  return {
305                    key: column_id,
306                    label: t(translateColumnI18nKey(column_id)),
307                  };
308                }),
309                selectedKeys: showColumns,
310                selectable: true,
311                multiple: true,
312                onDeselect: (keys) => {
313                  setShowColumns(keys.selectedKeys);
314                },
315                onSelect: (keys) => {
316                  setShowColumns(keys.selectedKeys);
317                },
318              }}
319            >
320              <Button type="primary" icon={<EditOutlined />}>
321                {t("buttons.hideColumns")}
322              </Button>
323            </Dropdown>
324            {defaultButtons}
325          </>
326        )}
327      >
328        {spoolAdjustModal}
329        <Table
330          {...tableProps}
331          sticky
332          tableLayout="auto"
333          scroll={{ x: "max-content" }}
334          dataSource={dataSource}
335          rowKey="id"
336          // Make archived rows greyed out
337          onRow={(record) => {
338            if (record.archived) {
339              return {
340                style: {
341                  fontStyle: "italic",
342                  color: "#999",
343                },
344              };
345            } else {
346              return {};
347            }
348          }}
349          columns={removeUndefined([
350            SortedColumn({
351              ...commonProps,
352              id: "id",
353              i18ncat: "spool",
354              width: 70,
355            }),
356            SpoolIconColumn({
357              ...commonProps,
358              id: "filament.combined_name",
359              i18nkey: "spool.fields.filament_name",
360              color: (record: ISpoolCollapsed) =>
361                record.filament.multi_color_hexes
362                  ? {
363                      colors: record.filament.multi_color_hexes.split(","),
364                      vertical: record.filament.multi_color_direction === "longitudinal",
365                    }
366                  : record.filament.color_hex,
367              dataId: "filament.combined_name",
368              filterValueQuery: useSpoolmanFilamentFilter(),
369            }),
370            FilteredQueryColumn({
371              ...commonProps,
372              id: "filament.material",
373              i18nkey: "spool.fields.material",
374              filterValueQuery: useSpoolmanMaterials(),
375              width: 120,
376            }),
377            SortedColumn({
378              ...commonProps,
379              id: "price",
380              i18ncat: "spool",
381              align: "right",
382              width: 80,
383              render: (_, obj: ISpoolCollapsed) => {
384                if (obj.price === undefined) {
385                  return "";
386                }
387                return currencyFormatter.format(obj.price);
388              },
389            }),
390            NumberColumn({
391              ...commonProps,
392              id: "used_weight",
393              i18ncat: "spool",
394              align: "right",
395              unit: "g",
396              maxDecimals: 0,
397              width: 110,
398            }),
399            NumberColumn({
400              ...commonProps,
401              id: "remaining_weight",
402              i18ncat: "spool",
403              unit: "g",
404              maxDecimals: 0,
405              defaultText: t("unknown"),
406              width: 110,
407            }),
408            NumberColumn({
409              ...commonProps,
410              id: "used_length",
411              i18ncat: "spool",
412              unit: "mm",
413              maxDecimals: 0,
414              width: 120,
415            }),
416            NumberColumn({
417              ...commonProps,
418              id: "remaining_length",
419              i18ncat: "spool",
420              unit: "mm",
421              maxDecimals: 0,
422              defaultText: t("unknown"),
423              width: 120,
424            }),
425            FilteredQueryColumn({
426              ...commonProps,
427              id: "location",
428              i18ncat: "spool",
429              filterValueQuery: useSpoolmanLocations(),
430              width: 120,
431            }),
432            FilteredQueryColumn({
433              ...commonProps,
434              id: "lot_nr",
435              i18ncat: "spool",
436              filterValueQuery: useSpoolmanLotNumbers(),
437              width: 120,
438            }),
439            DateColumn({
440              ...commonProps,
441              id: "first_used",
442              i18ncat: "spool",
443            }),
444            DateColumn({
445              ...commonProps,
446              id: "last_used",
447              i18ncat: "spool",
448            }),
449            DateColumn({
450              ...commonProps,
451              id: "registered",
452              i18ncat: "spool",
453            }),
454            ...(extraFields.data?.map((field) => {
455              return CustomFieldColumn({
456                ...commonProps,
457                field,
458              });
459            }) ?? []),
460            RichColumn({
461              ...commonProps,
462              id: "comment",
463              i18ncat: "spool",
464              width: 150,
465            }),
466            ActionsColumn(t("table.actions"), actions),
467          ])}
468        />
469      </List>
470    );
471  };
472  
473  export default SpoolList;