/ client / src / components / column.tsx
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  }