column.tsx
1 import { DateField, TextField } from "@refinedev/antd"; 2 import { UseQueryResult } from "@tanstack/react-query"; 3 import { Button, Col, Dropdown, Row, Space, Spin } from "antd"; 4 import { ColumnFilterItem, ColumnType } from "antd/es/table/interface"; 5 import dayjs from "dayjs"; 6 import utc from "dayjs/plugin/utc"; 7 import { AlignType } from "rc-table/lib/interface"; 8 import { Link } from "react-router"; 9 import { getFiltersForField, typeFilters } from "../utils/filtering"; 10 import { enrichText } from "../utils/parsing"; 11 import { Field, FieldType } from "../utils/queryFields"; 12 import { TableState } from "../utils/saveload"; 13 import { getSortOrderForField, typeSorters } from "../utils/sorting"; 14 import { NumberFieldUnit, NumberFieldUnitRange } from "./numberField"; 15 import SpoolIcon from "./spoolIcon"; 16 17 dayjs.extend(utc); 18 19 const FilterDropdownLoading = () => { 20 return ( 21 <Row justify="center"> 22 <Col> 23 Loading... 24 <Spin style={{ margin: 10 }} /> 25 </Col> 26 </Row> 27 ); 28 }; 29 30 interface Entity { 31 id: number; 32 } 33 34 export interface Action { 35 name: string; 36 icon: React.ReactNode; 37 link?: string; 38 onClick?: () => void; 39 } 40 41 interface BaseColumnProps<Obj extends Entity> { 42 id: string | string[]; 43 dataId?: keyof Obj & string; 44 i18ncat?: string; 45 i18nkey?: string; 46 title?: string; 47 align?: AlignType; 48 sorter?: boolean; 49 t: (key: string) => string; 50 navigate: (link: string) => void; 51 dataSource: Obj[]; 52 tableState: TableState; 53 width?: number; 54 actions?: (record: Obj) => Action[]; 55 transform?: (value: unknown) => unknown; 56 render?: (rawValue: string | undefined, record: Obj) => React.ReactNode; 57 } 58 59 interface FilteredColumnProps { 60 filters?: ColumnFilterItem[]; 61 filteredValue?: string[]; 62 allowMultipleFilters?: boolean; 63 onFilterDropdownOpen?: () => void; 64 loadingFilters?: boolean; 65 } 66 67 interface CustomColumnProps<Obj> { 68 // eslint-disable-next-line @typescript-eslint/no-explicit-any 69 render?: (value: any, record: Obj, index: number) => React.ReactNode; 70 onCell?: ( 71 data: Obj, 72 index?: number, 73 // eslint-disable-next-line @typescript-eslint/no-explicit-any 74 ) => React.HTMLAttributes<any> | React.TdHTMLAttributes<any>; 75 } 76 77 function Column<Obj extends Entity>( 78 props: BaseColumnProps<Obj> & FilteredColumnProps & CustomColumnProps<Obj>, 79 ): ColumnType<Obj> | undefined { 80 const t = props.t; 81 const navigate = props.navigate; 82 83 // Hide if not in showColumns 84 const id = Array.isArray(props.id) ? props.id.join(".") : props.id; 85 if (props.tableState.showColumns && !props.tableState.showColumns.includes(id)) { 86 return undefined; 87 } 88 89 const columnProps: ColumnType<Obj> = { 90 dataIndex: props.id, 91 align: props.align, 92 title: props.title ?? t(props.i18nkey ?? `${props.i18ncat}.fields.${props.id}`), 93 filterMultiple: props.allowMultipleFilters ?? true, 94 width: props.width ?? undefined, 95 onCell: props.onCell ?? undefined, 96 }; 97 98 // Sorting 99 if (props.sorter) { 100 columnProps.sorter = true; 101 columnProps.sortOrder = getSortOrderForField( 102 typeSorters<Obj>(props.tableState.sorters), 103 props.dataId ?? (props.id as keyof Obj), 104 ); 105 } 106 107 // Filter 108 if (props.filters && props.filteredValue) { 109 columnProps.filters = props.filters; 110 columnProps.filteredValue = props.filteredValue; 111 if (props.loadingFilters) { 112 columnProps.filterDropdown = <FilterDropdownLoading />; 113 } 114 columnProps.filterDropdownProps = { 115 onOpenChange: (open) => { 116 if (open && props.onFilterDropdownOpen) { 117 props.onFilterDropdownOpen(); 118 } 119 }, 120 }; 121 if (props.dataId) { 122 columnProps.key = props.dataId; 123 } 124 } 125 126 // Render 127 const render = 128 props.render ?? 129 ((rawValue) => { 130 const value = props.transform ? props.transform(rawValue) : rawValue; 131 return <>{value}</>; 132 }); 133 columnProps.render = (value, record, index) => { 134 if (!props.actions) { 135 return render(value, record, index); 136 } 137 138 const actions = props.actions(record); 139 140 return ( 141 <Dropdown 142 menu={{ 143 items: actions.map((action) => ({ 144 key: action.name, 145 label: action.name, 146 icon: action.icon, 147 })), 148 onClick: (item) => { 149 const action = actions.find((action) => action.name === item.key); 150 if (action) { 151 if (action.link) { 152 navigate(action.link); 153 } else if (action.onClick) { 154 action.onClick(); 155 } 156 } 157 }, 158 }} 159 trigger={["click"]} 160 > 161 <div>{render(value, record, index)}</div> 162 </Dropdown> 163 ); 164 }; 165 166 return columnProps; 167 } 168 169 export function SortedColumn<Obj extends Entity>(props: BaseColumnProps<Obj>) { 170 return Column({ 171 ...props, 172 sorter: true, 173 }); 174 } 175 176 export function RichColumn<Obj extends Entity>( 177 props: Omit<BaseColumnProps<Obj>, "transform"> & { transform?: (value: unknown) => string }, 178 ) { 179 return Column({ 180 ...props, 181 render: (rawValue: string | undefined) => { 182 const value = props.transform ? props.transform(rawValue) : rawValue; 183 return enrichText(value); 184 }, 185 }); 186 } 187 188 interface FilteredQueryColumnProps<Obj extends Entity> extends BaseColumnProps<Obj> { 189 filterValueQuery: UseQueryResult<string[] | ColumnFilterItem[], unknown>; 190 allowMultipleFilters?: boolean; 191 } 192 193 export function FilteredQueryColumn<Obj extends Entity>(props: FilteredQueryColumnProps<Obj>) { 194 const query = props.filterValueQuery; 195 196 let filters: ColumnFilterItem[] = []; 197 if (query.data) { 198 filters = query.data.map((item) => { 199 if (typeof item === "string") { 200 return { 201 text: item, 202 value: '"' + item + '"', 203 }; 204 } 205 return item; 206 }); 207 } 208 filters.push({ 209 text: "<empty>", 210 value: "<empty>", 211 }); 212 213 const typedFilters = typeFilters<Obj>(props.tableState.filters); 214 const filteredValue = getFiltersForField(typedFilters, props.dataId ?? (props.id as keyof Obj)); 215 216 const onFilterDropdownOpen = () => { 217 query.refetch(); 218 }; 219 220 return Column({ ...props, filters, filteredValue, onFilterDropdownOpen, loadingFilters: query.isLoading }); 221 } 222 223 interface NumberColumnProps<Obj extends Entity> extends BaseColumnProps<Obj> { 224 unit: string; 225 maxDecimals?: number; 226 minDecimals?: number; 227 defaultText?: string; 228 } 229 230 export function NumberColumn<Obj extends Entity>(props: NumberColumnProps<Obj>) { 231 return Column({ 232 ...props, 233 align: "right", 234 render: (rawValue) => { 235 const value = props.transform ? props.transform(rawValue) : rawValue; 236 if (value === null || value === undefined) { 237 return <TextField value={props.defaultText ?? ""} />; 238 } 239 return ( 240 <NumberFieldUnit 241 value={value} 242 unit={props.unit} 243 options={{ 244 maximumFractionDigits: props.maxDecimals ?? 0, 245 minimumFractionDigits: props.minDecimals ?? props.maxDecimals ?? 0, 246 }} 247 /> 248 ); 249 }, 250 }); 251 } 252 253 export function DateColumn<Obj extends Entity>(props: BaseColumnProps<Obj>) { 254 return Column({ 255 ...props, 256 render: (rawValue) => { 257 const value = props.transform ? props.transform(rawValue) : rawValue; 258 return ( 259 <DateField 260 hidden={!value} 261 value={dayjs.utc(value).local()} 262 title={dayjs.utc(value).local().format()} 263 format="YYYY-MM-DD HH:mm" 264 /> 265 ); 266 }, 267 }); 268 } 269 270 export function ActionsColumn<Obj extends Entity>( 271 title: string, 272 actionsFn: (record: Obj) => Action[], 273 ): ColumnType<Obj> | undefined { 274 return { 275 title, 276 responsive: ["lg"], 277 render: (_, record) => { 278 const buttons = actionsFn(record).map((action) => { 279 if (action.link) { 280 return ( 281 <Link key={action.name} to={action.link}> 282 <Button icon={action.icon} title={action.name} size="small" /> 283 </Link> 284 ); 285 } else if (action.onClick) { 286 return ( 287 <Button 288 key={action.name} 289 icon={action.icon} 290 title={action.name} 291 size="small" 292 onClick={() => action.onClick!()} 293 /> 294 ); 295 } 296 }); 297 298 return <Space>{buttons}</Space>; 299 }, 300 }; 301 } 302 303 interface SpoolIconColumnProps<Obj extends Entity> extends FilteredQueryColumnProps<Obj> { 304 color: (record: Obj) => string | { colors: string[]; vertical: boolean } | undefined; 305 } 306 307 export function SpoolIconColumn<Obj extends Entity>(props: SpoolIconColumnProps<Obj>) { 308 const query = props.filterValueQuery; 309 310 let filters: ColumnFilterItem[] = []; 311 if (query.data) { 312 filters = query.data.map((item) => { 313 if (typeof item === "string") { 314 return { 315 text: item, 316 value: '"' + item + '"', 317 }; 318 } 319 return item; 320 }); 321 } 322 filters.push({ 323 text: "<empty>", 324 value: "", 325 }); 326 327 const typedFilters = typeFilters<Obj>(props.tableState.filters); 328 const filteredValue = getFiltersForField(typedFilters, props.dataId ?? (props.id as keyof Obj)); 329 330 const onFilterDropdownOpen = () => { 331 query.refetch(); 332 }; 333 334 return Column({ 335 ...props, 336 filters, 337 filteredValue, 338 onFilterDropdownOpen, 339 loadingFilters: query.isLoading, 340 onCell: () => { 341 return { 342 style: { 343 paddingLeft: 0, 344 paddingTop: 0, 345 paddingBottom: 0, 346 }, 347 }; 348 }, 349 render: (rawValue, record: Obj) => { 350 const value = props.transform ? props.transform(rawValue) : rawValue; 351 const colorObj = props.color(record); 352 return ( 353 <Row wrap={false} justify="space-around" align="middle"> 354 {colorObj && ( 355 <Col flex="none"> 356 <SpoolIcon color={colorObj} /> 357 </Col> 358 )} 359 <Col flex="auto">{value}</Col> 360 </Row> 361 ); 362 }, 363 }); 364 } 365 366 export function NumberRangeColumn<Obj extends Entity>(props: NumberColumnProps<Obj>) { 367 return Column({ 368 ...props, 369 render: (rawValue) => { 370 const value = props.transform ? props.transform(rawValue) : rawValue; 371 if (value === null || value === undefined) { 372 return <TextField value={props.defaultText ?? ""} />; 373 } 374 if (!Array.isArray(value) || value.length !== 2) { 375 return <TextField value={props.defaultText ?? ""} />; 376 } 377 378 return ( 379 <NumberFieldUnitRange 380 value={value} 381 unit={props.unit} 382 options={{ 383 maximumFractionDigits: props.maxDecimals ?? 0, 384 minimumFractionDigits: props.minDecimals ?? props.maxDecimals ?? 0, 385 }} 386 /> 387 ); 388 }, 389 }); 390 } 391 392 export function CustomFieldColumn<Obj extends Entity>(props: Omit<BaseColumnProps<Obj>, "id"> & { field: Field }) { 393 const field = props.field; 394 const commonProps = { 395 ...props, 396 id: ["extra", field.key], 397 title: field.name, 398 sorter: false, 399 transform: (value: unknown) => { 400 if (value === null || value === undefined) { 401 return undefined; 402 } 403 return JSON.parse(value as string); 404 }, 405 }; 406 407 if (field.field_type === FieldType.integer) { 408 return NumberColumn({ 409 ...commonProps, 410 unit: field.unit ?? "", 411 maxDecimals: 0, 412 }); 413 } else if (field.field_type === FieldType.float) { 414 return NumberColumn({ 415 ...commonProps, 416 unit: field.unit ?? "", 417 minDecimals: 0, 418 maxDecimals: 3, 419 }); 420 } else if (field.field_type === FieldType.integer_range) { 421 return NumberRangeColumn({ 422 ...commonProps, 423 unit: field.unit ?? "", 424 maxDecimals: 0, 425 }); 426 } else if (field.field_type === FieldType.float_range) { 427 return NumberRangeColumn({ 428 ...commonProps, 429 unit: field.unit ?? "", 430 minDecimals: 0, 431 maxDecimals: 3, 432 }); 433 } else if (field.field_type === FieldType.text) { 434 return RichColumn({ 435 ...commonProps, 436 }); 437 } else if (field.field_type === FieldType.datetime) { 438 return DateColumn({ 439 ...commonProps, 440 }); 441 } else if (field.field_type === FieldType.boolean) { 442 return Column({ 443 ...commonProps, 444 render: (rawValue) => { 445 const value = commonProps.transform ? commonProps.transform(rawValue) : rawValue; 446 let text; 447 if (value === undefined || value === null) { 448 text = ""; 449 } else if (value) { 450 text = props.t("yes"); 451 } else { 452 text = props.t("no"); 453 } 454 return <TextField value={text} />; 455 }, 456 }); 457 } else if (field.field_type === FieldType.choice && !field.multi_choice) { 458 return Column({ 459 ...commonProps, 460 render: (rawValue) => { 461 const value = commonProps.transform ? commonProps.transform(rawValue) : rawValue; 462 return <TextField value={value} />; 463 }, 464 }); 465 } else if (field.field_type === FieldType.choice && field.multi_choice) { 466 return Column({ 467 ...commonProps, 468 render: (rawValue) => { 469 const value = commonProps.transform ? commonProps.transform(rawValue) : rawValue; 470 return <TextField value={(value as string[] | undefined)?.join(", ")} />; 471 }, 472 }); 473 } else { 474 return Column({ 475 ...commonProps, 476 render: (rawValue) => { 477 const value = commonProps.transform ? commonProps.transform(rawValue) : rawValue; 478 return <TextField value={value} />; 479 }, 480 }); 481 } 482 }