224 lines
6.7 KiB
TypeScript
224 lines
6.7 KiB
TypeScript
import { useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
import { Table } from "antd";
|
|
import Icon, { FilterFilled, FilterOutlined } from "@ant-design/icons";
|
|
import type { ColumnGroupType, ColumnsType, ColumnType } from "antd/es/table";
|
|
import type { TableProps } from "antd";
|
|
import { CustomFilterDropdown } from "./CustomFilterDropdown";
|
|
import { Highlight } from "./Highlight";
|
|
import {
|
|
isGroup,
|
|
LeafColumnDef,
|
|
type ColumnFilters,
|
|
type FilterOption,
|
|
type SortState,
|
|
type TableConfig,
|
|
} from "./tableTypes";
|
|
type ExtraColumn<T> = ColumnType<T> & {
|
|
position?: number | "start" | "end";
|
|
afterKey?: Extract<keyof T, string>;
|
|
};
|
|
interface DataTableProps<T extends Record<string, any>> {
|
|
config: TableConfig<T>;
|
|
rows: T[];
|
|
allRows: T[];
|
|
loading?: boolean;
|
|
visibleKeys: Array<Extract<keyof T, string>>;
|
|
globalQuery?: string;
|
|
columnFilters: ColumnFilters<T>;
|
|
onColumnFiltersChange: (next: ColumnFilters<T>) => void;
|
|
sortState: SortState<T>;
|
|
onSortChange: (next: SortState<T>) => void;
|
|
extraColumns?: ColumnsType<T>;
|
|
rowSelection?: TableProps<T>["rowSelection"];
|
|
scrollY?: number | string;
|
|
onRow?: TableProps<T>["onRow"];
|
|
}
|
|
|
|
export function DataTable<T extends Record<string, any>>({
|
|
config,
|
|
rows,
|
|
allRows,
|
|
loading,
|
|
visibleKeys,
|
|
globalQuery = "",
|
|
columnFilters,
|
|
onColumnFiltersChange,
|
|
sortState,
|
|
onSortChange,
|
|
extraColumns = [],
|
|
rowSelection,
|
|
scrollY = 480,
|
|
onRow,
|
|
}: DataTableProps<T>) {
|
|
const { columns: defs, searchableKeys = [], rowKey } = config;
|
|
const normalizedQuery = globalQuery.trim().toLowerCase();
|
|
const hasGlobalSearch =
|
|
searchableKeys.length > 0 && normalizedQuery.length > 0;
|
|
|
|
const getOptions = (key: Extract<keyof T, string>): FilterOption[] => {
|
|
const seen = new Set<string | number>();
|
|
const opts: FilterOption[] = [];
|
|
for (const r of allRows) {
|
|
const v = r[key];
|
|
if (!seen.has(v)) {
|
|
seen.add(v);
|
|
opts.push({ label: String(v), value: v });
|
|
}
|
|
}
|
|
return opts;
|
|
};
|
|
|
|
const buildLeaf = (def: LeafColumnDef<T>): ColumnType<T> => {
|
|
const { key, title, features = {}, render, width, fixed } = def;
|
|
const hasDropdown =
|
|
features.multiselect || features.search || features.sort;
|
|
const isSearchable = searchableKeys.includes(key);
|
|
return {
|
|
key,
|
|
title,
|
|
dataIndex: key,
|
|
width,
|
|
fixed,
|
|
render: (value: T[keyof T], row: T) => {
|
|
if (render) return render(value, row);
|
|
const text = String(value ?? "");
|
|
return isSearchable && hasGlobalSearch ? (
|
|
<Highlight text={text} query={globalQuery.trim()} />
|
|
) : (
|
|
text
|
|
);
|
|
},
|
|
...(hasDropdown
|
|
? {
|
|
filterIcon: () => {
|
|
const active =
|
|
(columnFilters[key]?.length ?? 0) > 0 || sortState?.key === key;
|
|
return (
|
|
<span
|
|
style={{
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
padding: "4px 6px",
|
|
borderRadius: 6,
|
|
background: active
|
|
? "rgba(245, 158, 11, 0.14)"
|
|
: "transparent",
|
|
color: active ? "rgb(22, 101, 52)" : "#bfbfbf",
|
|
transition: "background 0.15s",
|
|
}}
|
|
>
|
|
<FilterOutlined style={{ fontSize: 12 }} />
|
|
</span>
|
|
);
|
|
},
|
|
filterDropdown: ({ confirm, clearFilters }) => (
|
|
<CustomFilterDropdown
|
|
selectedKeys={(columnFilters[key] ?? []) as React.Key[]}
|
|
setSelectedKeys={(keys) =>
|
|
onColumnFiltersChange({
|
|
...columnFilters,
|
|
[key]: keys as (string | number)[],
|
|
})
|
|
}
|
|
confirm={confirm}
|
|
clearFilters={clearFilters}
|
|
features={features}
|
|
options={
|
|
features.multiselect || features.search ? getOptions(key) : []
|
|
}
|
|
sortDirection={sortState?.key === key ? sortState.dir : null}
|
|
onSort={(dir) => onSortChange(dir ? { key, dir } : null)}
|
|
/>
|
|
),
|
|
}
|
|
: {}),
|
|
};
|
|
};
|
|
const columns: ColumnsType<T> = useMemo(() => {
|
|
const built: ColumnsType<T> = [];
|
|
for (const def of defs) {
|
|
if (isGroup(def)) {
|
|
const visibleChildren = def.children.filter((c) =>
|
|
visibleKeys.includes(c.key),
|
|
);
|
|
if (visibleChildren.length === 0) continue;
|
|
built.push({
|
|
key: def.groupKey,
|
|
title: def.title,
|
|
fixed: def.fixed,
|
|
children: visibleChildren.map(buildLeaf),
|
|
} as ColumnGroupType<T>);
|
|
} else {
|
|
if (!visibleKeys.includes(def.key)) continue;
|
|
built.push(buildLeaf(def));
|
|
}
|
|
}
|
|
for (const col of extraColumns) {
|
|
const { position, afterKey, ...rest } = col as ExtraColumn<T> & {
|
|
position?: any;
|
|
afterKey?: any;
|
|
};
|
|
let idx: number;
|
|
if (afterKey) {
|
|
const f = built.findIndex(
|
|
(c: any) => c.key === afterKey || c.dataIndex === afterKey,
|
|
);
|
|
idx = f === -1 ? built.length : f + 1;
|
|
} else if (position === "start") idx = 0;
|
|
else if (position === "end" || position === undefined) idx = built.length;
|
|
else idx = Math.max(0, Math.min(position, built.length));
|
|
built.splice(idx, 0, rest as ColumnsType<T>[number]);
|
|
}
|
|
return built;
|
|
}, [
|
|
defs,
|
|
visibleKeys,
|
|
sortState,
|
|
allRows,
|
|
searchableKeys,
|
|
globalQuery,
|
|
hasGlobalSearch,
|
|
columnFilters,
|
|
onColumnFiltersChange,
|
|
onSortChange,
|
|
extraColumns,
|
|
]);
|
|
|
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
|
const [appliedScrollY, setAppliedScrollY] = useState(scrollY);
|
|
|
|
useLayoutEffect(() => {
|
|
const wrapper = wrapperRef.current;
|
|
if (!wrapper) return;
|
|
|
|
const update = () => {
|
|
const total = wrapper.clientHeight;
|
|
|
|
const header = wrapper.querySelector<HTMLElement>(".ant-table-thead");
|
|
const headerH = header?.offsetHeight ?? 55;
|
|
setAppliedScrollY(Math.max(0, total - headerH));
|
|
};
|
|
|
|
update();
|
|
const ro = new ResizeObserver(update);
|
|
ro.observe(wrapper);
|
|
return () => ro.disconnect();
|
|
}, []);
|
|
|
|
return (
|
|
<div ref={wrapperRef} style={{ flex: 1, height: "100%" }}>
|
|
<Table<T>
|
|
rowKey={rowKey}
|
|
loading={loading}
|
|
columns={columns}
|
|
dataSource={rows}
|
|
rowSelection={rowSelection}
|
|
pagination={false}
|
|
scroll={{ x: "max-content", y: appliedScrollY }}
|
|
onRow={onRow}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|