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;