list.tsx
1 import { EditOutlined, EyeOutlined, FileOutlined, FilterOutlined, PlusSquareOutlined } from "@ant-design/icons"; 2 import { List, useTable } from "@refinedev/antd"; 3 import { useInvalidate, useNavigation, useTranslate } from "@refinedev/core"; 4 import { Button, Dropdown, Table } from "antd"; 5 import dayjs from "dayjs"; 6 import utc from "dayjs/plugin/utc"; 7 import { useMemo, useState } from "react"; 8 import { useNavigate } from "react-router"; 9 import { 10 ActionsColumn, 11 CustomFieldColumn, 12 DateColumn, 13 FilteredQueryColumn, 14 NumberColumn, 15 RichColumn, 16 SortedColumn, 17 SpoolIconColumn, 18 } from "../../components/column"; 19 import { useLiveify } from "../../components/liveify"; 20 import { 21 useSpoolmanArticleNumbers, 22 useSpoolmanFilamentNames, 23 useSpoolmanMaterials, 24 useSpoolmanVendors, 25 } from "../../components/otherModels"; 26 import { removeUndefined } from "../../utils/filtering"; 27 import { EntityType, useGetFields } from "../../utils/queryFields"; 28 import { TableState, useInitialTableState, useStoreInitialState } from "../../utils/saveload"; 29 import { useCurrencyFormatter } from "../../utils/settings"; 30 import { IFilament } from "./model"; 31 32 dayjs.extend(utc); 33 34 interface IFilamentCollapsed extends Omit<IFilament, "vendor"> { 35 "vendor.name": string | null; 36 } 37 38 function collapseFilament(element: IFilament): IFilamentCollapsed { 39 let vendor_name: string | null; 40 if (element.vendor) { 41 vendor_name = element.vendor.name; 42 } else { 43 vendor_name = null; 44 } 45 return { ...element, "vendor.name": vendor_name }; 46 } 47 48 function translateColumnI18nKey(columnName: string): string { 49 columnName = columnName.replace(".", "_"); 50 return `filament.fields.${columnName}`; 51 } 52 53 const namespace = "filamentList-v2"; 54 55 const allColumns: (keyof IFilamentCollapsed & string)[] = [ 56 "id", 57 "vendor.name", 58 "name", 59 "material", 60 "price", 61 "density", 62 "diameter", 63 "weight", 64 "spool_weight", 65 "article_number", 66 "settings_extruder_temp", 67 "settings_bed_temp", 68 "registered", 69 "comment", 70 ]; 71 const defaultColumns = allColumns.filter( 72 (column_id) => ["registered", "density", "diameter", "spool_weight"].indexOf(column_id) === -1, 73 ); 74 75 export const FilamentList = () => { 76 const t = useTranslate(); 77 const invalidate = useInvalidate(); 78 const navigate = useNavigate(); 79 const extraFields = useGetFields(EntityType.filament); 80 const currencyFormatter = useCurrencyFormatter(); 81 82 const allColumnsWithExtraFields = [...allColumns, ...(extraFields.data?.map((field) => "extra." + field.key) ?? [])]; 83 84 // Load initial state 85 const initialState = useInitialTableState(namespace); 86 87 // Fetch data from the API 88 // To provide the live updates, we use a custom solution (useLiveify) instead of the built-in refine "liveMode" feature. 89 // This is because the built-in feature does not call the liveProvider subscriber with a list of IDs, but instead 90 // calls it with a list of filters, sorters, etc. This means the server-side has to support this, which is quite hard. 91 const { tableProps, sorters, setSorters, filters, setFilters, currentPage, pageSize, setCurrentPage } = 92 useTable<IFilamentCollapsed>({ 93 syncWithLocation: false, 94 pagination: { 95 mode: "server", 96 currentPage: initialState.pagination.currentPage, 97 pageSize: initialState.pagination.pageSize, 98 }, 99 sorters: { 100 mode: "server", 101 initial: initialState.sorters, 102 }, 103 filters: { 104 mode: "server", 105 initial: initialState.filters, 106 }, 107 liveMode: "manual", 108 onLiveEvent(event) { 109 if (event.type === "created" || event.type === "deleted") { 110 // updated is handled by the liveify 111 invalidate({ 112 resource: "filament", 113 invalidates: ["list"], 114 }); 115 } 116 }, 117 queryOptions: { 118 select(data) { 119 return { 120 total: data.total, 121 data: data.data.map(collapseFilament), 122 }; 123 }, 124 }, 125 }); 126 127 // Create state for the columns to show 128 const [showColumns, setShowColumns] = useState<string[]>(initialState.showColumns ?? defaultColumns); 129 130 // Store state in local storage 131 const tableState: TableState = { 132 sorters, 133 filters, 134 pagination: { currentPage: currentPage, pageSize }, 135 showColumns, 136 }; 137 useStoreInitialState(namespace, tableState); 138 139 // Collapse the dataSource to a mutable list 140 const queryDataSource: IFilamentCollapsed[] = useMemo( 141 () => (tableProps.dataSource || []).map((record) => ({ ...record })), 142 [tableProps.dataSource], 143 ); 144 const dataSource = useLiveify("filament", queryDataSource, collapseFilament); 145 146 if (tableProps.pagination) { 147 tableProps.pagination.showSizeChanger = true; 148 } 149 150 const { editUrl, showUrl, cloneUrl } = useNavigation(); 151 const filamentAddSpoolUrl = (id: number): string => `/spool/create?filament_id=${id}`; 152 const actions = (record: IFilamentCollapsed) => [ 153 { name: t("buttons.show"), icon: <EyeOutlined />, link: showUrl("filament", record.id) }, 154 { name: t("buttons.edit"), icon: <EditOutlined />, link: editUrl("filament", record.id) }, 155 { name: t("buttons.clone"), icon: <PlusSquareOutlined />, link: cloneUrl("filament", record.id) }, 156 { name: t("filament.buttons.add_spool"), icon: <FileOutlined />, link: filamentAddSpoolUrl(record.id) }, 157 ]; 158 159 const commonProps = { 160 t, 161 navigate, 162 actions, 163 dataSource, 164 tableState, 165 sorter: true, 166 }; 167 168 return ( 169 <List 170 headerButtons={({ defaultButtons }) => ( 171 <> 172 <Button 173 type="primary" 174 icon={<FilterOutlined />} 175 onClick={() => { 176 setFilters([], "replace"); 177 setSorters([{ field: "id", order: "asc" }]); 178 setCurrentPage(1); 179 }} 180 > 181 {t("buttons.clearFilters")} 182 </Button> 183 <Dropdown 184 trigger={["click"]} 185 menu={{ 186 items: allColumnsWithExtraFields.map((column_id) => { 187 if (column_id.indexOf("extra.") === 0) { 188 const extraField = extraFields.data?.find((field) => "extra." + field.key === column_id); 189 return { 190 key: column_id, 191 label: extraField?.name ?? column_id, 192 }; 193 } 194 195 return { 196 key: column_id, 197 label: t(translateColumnI18nKey(column_id)), 198 }; 199 }), 200 selectedKeys: showColumns, 201 selectable: true, 202 multiple: true, 203 onDeselect: (keys) => { 204 setShowColumns(keys.selectedKeys); 205 }, 206 onSelect: (keys) => { 207 setShowColumns(keys.selectedKeys); 208 }, 209 }} 210 > 211 <Button type="primary" icon={<EditOutlined />}> 212 {t("buttons.hideColumns")} 213 </Button> 214 </Dropdown> 215 {defaultButtons} 216 </> 217 )} 218 > 219 <Table<IFilamentCollapsed> 220 {...tableProps} 221 sticky 222 tableLayout="auto" 223 scroll={{ x: "max-content" }} 224 dataSource={dataSource} 225 rowKey="id" 226 columns={removeUndefined([ 227 SortedColumn({ 228 ...commonProps, 229 id: "id", 230 i18ncat: "filament", 231 width: 70, 232 }), 233 FilteredQueryColumn({ 234 ...commonProps, 235 id: "vendor.name", 236 i18nkey: "filament.fields.vendor_name", 237 filterValueQuery: useSpoolmanVendors(), 238 }), 239 SpoolIconColumn({ 240 ...commonProps, 241 id: "name", 242 i18ncat: "filament", 243 color: (record: IFilamentCollapsed) => 244 record.multi_color_hexes 245 ? { 246 colors: record.multi_color_hexes.split(","), 247 vertical: record.multi_color_direction === "longitudinal", 248 } 249 : record.color_hex, 250 filterValueQuery: useSpoolmanFilamentNames(), 251 }), 252 FilteredQueryColumn({ 253 ...commonProps, 254 id: "material", 255 i18ncat: "filament", 256 filterValueQuery: useSpoolmanMaterials(), 257 width: 110, 258 }), 259 SortedColumn({ 260 ...commonProps, 261 id: "price", 262 i18ncat: "filament", 263 align: "right", 264 width: 80, 265 render: (_, obj: IFilamentCollapsed) => { 266 if (obj.price === undefined) { 267 return ""; 268 } 269 return currencyFormatter.format(obj.price); 270 }, 271 }), 272 NumberColumn({ 273 ...commonProps, 274 id: "density", 275 i18ncat: "filament", 276 unit: "g/cm³", 277 maxDecimals: 2, 278 width: 100, 279 }), 280 NumberColumn({ 281 ...commonProps, 282 id: "diameter", 283 i18ncat: "filament", 284 unit: "mm", 285 maxDecimals: 2, 286 width: 100, 287 }), 288 NumberColumn({ 289 ...commonProps, 290 id: "weight", 291 i18ncat: "filament", 292 unit: "g", 293 maxDecimals: 0, 294 width: 100, 295 }), 296 NumberColumn({ 297 ...commonProps, 298 id: "spool_weight", 299 i18ncat: "filament", 300 unit: "g", 301 maxDecimals: 0, 302 width: 100, 303 }), 304 FilteredQueryColumn({ 305 ...commonProps, 306 id: "article_number", 307 i18ncat: "filament", 308 filterValueQuery: useSpoolmanArticleNumbers(), 309 width: 130, 310 }), 311 NumberColumn({ 312 ...commonProps, 313 id: "settings_extruder_temp", 314 i18ncat: "filament", 315 unit: "°C", 316 maxDecimals: 0, 317 width: 100, 318 }), 319 NumberColumn({ 320 ...commonProps, 321 id: "settings_bed_temp", 322 i18ncat: "filament", 323 unit: "°C", 324 maxDecimals: 0, 325 width: 100, 326 }), 327 DateColumn({ 328 ...commonProps, 329 id: "registered", 330 i18ncat: "filament", 331 }), 332 ...(extraFields.data?.map((field) => { 333 return CustomFieldColumn({ 334 ...commonProps, 335 field, 336 }); 337 }) ?? []), 338 RichColumn({ 339 ...commonProps, 340 id: "comment", 341 i18ncat: "filament", 342 width: 150, 343 }), 344 ActionsColumn(t("table.actions"), actions), 345 ])} 346 /> 347 </List> 348 ); 349 }; 350 351 export default FilamentList;