import React, { useMemo, useState, useRef, useEffect, useCallback, forwardRef, useImperativeHandle, ForwardedRef } from 'react'; import { Trans, useTranslation } from 'react-i18next'; import { XMarkIcon } from '@heroicons/react/16/solid'; import type { IMutField, IRow, IComputationFunction, IFilterRule, IFilterField, IFilterWorkflowStep, IVisFilter, ISemanticType } from '../../interfaces'; import { dataReadRaw } from '../../computation'; import Pagination from './pagination'; import { getHeaderKey, getHeaders } from './headers'; import { PureFilterEditDialog } from '../../fields/filterField/filterEditDialog'; import { ComputationContext } from '../../store'; import { parsedOffsetDate } from '../../lib/op/offset'; import { _unstable_encodeRuleValue, cn, formatDate } from '../../utils'; import { FieldProfiling, formatNumber } from './profiling'; import { ColumnHeader, type ISortDirection } from './columnHeader'; import { addFilterForQuery, createFilter } from '../../utils/workflow'; import { Button } from '../ui/button'; import { HoverCard, HoverCardContent, HoverCardTrigger } from '../ui/hover-card'; import Spinner from '../spinner'; interface DataTableProps { /** page limit */ size?: number; metas: IMutField[]; computation: IComputationFunction; onMetaChange?: (fid: string, fIndex: number, meta: Partial) => void; disableFilter?: boolean; disableSorting?: boolean; hideSemanticType?: boolean; hideProfiling?: boolean; hidePaginationAtOnepage?: boolean; displayOffset?: number; } const SEMANTIC_TYPE_LIST: ISemanticType[] = ['nominal', 'ordinal', 'quantitative', 'temporal']; const formatCount = (n: number) => n.toLocaleString(); function useFilters(metas: IMutField[]) { const [filters, setFilters] = useState([]); const [editingFilterIdx, setEditingFilterIdx] = useState(null); const options = useMemo(() => { return metas.map((x) => ({ label: x.name ?? x.fid, value: x.fid })); }, [metas]); const onSelectFilter = useCallback( (fid: string) => { const i = filters.findIndex((x) => x.fid === fid); if (i > -1) { setEditingFilterIdx(i); } else { const meta = metas.find((x) => x.fid === fid); if (!meta) return; const newFilter: IFilterField = { fid, rule: null, analyticType: meta.analyticType, name: meta.name ?? meta.fid, semanticType: meta.semanticType, }; if (editingFilterIdx === null || !filters[editingFilterIdx]) { setFilters(filters.concat(newFilter)); setEditingFilterIdx(filters.length); } else { setFilters(filters.map((x, i) => (i === editingFilterIdx ? newFilter : x))); } } }, [metas, filters, editingFilterIdx] ); const onWriteFilter = useCallback((index: number, rule: IFilterRule | null) => { setFilters((f) => f.map((x, i) => (i === index ? { ...x, rule } : x))); }, []); const onClose = useCallback(() => { setEditingFilterIdx(null); // a filter that was opened but never given a rule should not linger as an empty chip setFilters((f) => (f.every((x) => x.rule) ? f : f.filter((x) => x.rule))); }, []); /** creates, replaces or (with `null`) removes the filter of a field */ const setFieldRule = useCallback( (fid: string, rule: IFilterRule | null) => { setFilters((fs) => { const i = fs.findIndex((x) => x.fid === fid); if (!rule) { return i > -1 ? fs.filter((_, j) => j !== i) : fs; } if (i > -1) { return fs.map((x, j) => (j === i ? { ...x, rule } : x)); } const meta = metas.find((x) => x.fid === fid); if (!meta) return fs; return fs.concat({ fid, rule, analyticType: meta.analyticType, name: meta.name ?? meta.fid, semanticType: meta.semanticType }); }); }, [metas] ); const clearFilters = useCallback(() => setFilters([]), []); return { filters, options, editingFilterIdx, onSelectFilter, onWriteFilter, onClose, setFieldRule, clearFilters }; } function fieldValue(props: { field: IMutField; item: IRow; displayOffset?: number }) { const { field, item } = props; if (field.semanticType === 'temporal') { return formatDate(parsedOffsetDate(props.displayOffset, field.offset)(item[field.fid])); } return `${item[field.fid]}`; } function CopyButton(props: { value: string }) { const [copied, setCopied] = useState(false); useEffect(() => { if (copied) { const timer = setTimeout(() => { setCopied(false); }, 2000); return () => { clearTimeout(timer); }; } }, [copied]); return ( ); } function TruncateDector(props: { value: string }) { const ref = useRef(null); const [isTruncate, setIsTruncate] = useState(false); const [open, setOpen] = useState(false); useEffect(() => { if (ref.current) { setIsTruncate(ref.current.offsetWidth < ref.current.scrollWidth); } }, [ref.current]); return ( {props.value}

{props.value}

); } function useRuleDescription(displayOffset?: number) { const { t } = useTranslation('translation', { keyPrefix: 'data_table' }); return useCallback( (rule: IFilterRule) => { switch (rule.type) { case 'one of': case 'not in': { const values = rule.value.map((x) => `${x}`); const text = values.length > 2 ? t('n_values', { count: values.length }) : values.join(', '); return rule.type === 'not in' ? `≠ ${text}` : text; } case 'range': return `${rule.value[0] === null ? '…' : formatNumber(rule.value[0])} – ${rule.value[1] === null ? '…' : formatNumber(rule.value[1])}`; case 'temporal range': { const format = (x: number | null) => (x === null ? '…' : formatDate(parsedOffsetDate(displayOffset, rule.offset)(x))); return `${format(rule.value[0])} – ${format(rule.value[1])}`; } case 'regexp': return `/${rule.value}/`; } }, [t, displayOffset] ); } function FilterChip(props: { name: string; description: string; analyticType: IMutField['analyticType']; onEdit: () => void; onRemove: () => void }) { const { t } = useTranslation('translation', { keyPrefix: 'data_table' }); return ( ); } const strong = ; const DataTable = forwardRef( ( props: DataTableProps, ref: ForwardedRef<{ getFilters: () => IVisFilter[]; }> ) => { const { size = 10, onMetaChange, metas, computation, disableFilter, disableSorting, hideSemanticType, displayOffset, hidePaginationAtOnepage, hideProfiling, } = props; const [pageIndex, setPageIndex] = useState(0); const { t } = useTranslation(); const computationFunction = computation; const semanticTypeList = useMemo(() => { return SEMANTIC_TYPE_LIST.map((st) => ({ value: st, label: t(`constant.semantic_type.${st}`), })); }, [t]); const [rows, setRows] = useState([]); const [dataLoading, setDataLoading] = useState(false); const taskIdRef = useRef(0); const [sorting, setSorting] = useState<{ fid: string; sort: ISortDirection } | undefined>(); const { filters, editingFilterIdx, onClose, onSelectFilter, onWriteFilter, setFieldRule, clearFilters, options } = useFilters(metas); const filtersRef = useRef(filters); filtersRef.current = filters; useImperativeHandle(ref, () => ({ getFilters: () => filtersRef.current.filter((x) => x.rule) as IVisFilter[], })); const activeFilters = useMemo(() => (disableFilter ? [] : filters.filter((x) => x.rule)), [disableFilter, filters]); const filterRules = useMemo(() => activeFilters.map(createFilter), [activeFilters]); const describeRule = useRuleDescription(displayOffset); const [total, setTotal] = useState(0); const [unfilteredTotal, setUnfilteredTotal] = useState(0); const [statLoading, setStatLoading] = useState(false); // Get count when filter changed useEffect(() => { setStatLoading(true); const countOf = (f: IVisFilter[]) => computation({ workflow: [ ...(f.length > 0 ? [ { type: 'filter', filters: f, } as IFilterWorkflowStep, ] : []), { type: 'view', query: [ { op: 'aggregate', groupBy: [], measures: [ { field: '*', agg: 'count', asFieldKey: 'count', }, ], }, ], }, ], }).then((v) => (v[0]?.count ?? 0) as number); let alive = true; countOf(filterRules).then((count) => { if (!alive) return; setTotal(count); setStatLoading(false); }); if (filterRules.length > 0) { countOf([]).then((count) => alive && setUnfilteredTotal(count)); } return () => { alive = false; }; }, [filterRules, computation]); // a new filter or sort order starts from the first page useEffect(() => { setPageIndex(0); }, [filterRules, sorting]); const from = pageIndex * size; const to = Math.min((pageIndex + 1) * size - 1, total - 1); useEffect(() => { if (from > total) { setPageIndex(0); } }, [from, total]); useEffect(() => { setDataLoading(true); const taskId = ++taskIdRef.current; dataReadRaw(computationFunction, size, pageIndex, { sorting: disableSorting ? undefined : sorting, filters: filterRules, }) .then((data) => { if (taskId === taskIdRef.current) { setDataLoading(false); setRows(data); } }) .catch((err) => { if (taskId === taskIdRef.current) { console.error(err); setDataLoading(false); setRows([]); } }); return () => { taskIdRef.current++; }; }, [computationFunction, pageIndex, size, sorting, filterRules, disableSorting]); // Each column is profiled against every filter except its own, so a filtered column keeps showing its whole // distribution with the selection highlighted. Functions are reused while a column's filters are unchanged, // which keeps its profile from being fetched again. const profileCache = useRef(new Map()); const profileComputations = useMemo(() => { const next = new Map(); for (const meta of metas) { const rules = filterRules.filter((x) => x.fid !== meta.fid); const key = JSON.stringify(rules); const prev = profileCache.current.get(meta.fid); if (prev && prev.key === key && prev.base === computation) { next.set(meta.fid, prev); } else { next.set(meta.fid, { key, base: computation, fn: rules.length > 0 ? (query) => computation(addFilterForQuery(query, rules)) : computation, }); } } profileCache.current = next; return next; }, [computation, filterRules, metas]); const loading = statLoading || dataLoading; const headers = useMemo(() => getHeaders(metas), [metas]); const [isSticky, setIsSticky] = useState(false); const obRef = useRef(null); const stickyDector = useCallback((node: HTMLDivElement) => { obRef.current?.disconnect(); if (node) { const observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { setIsSticky(!entry.isIntersecting); }); }); observer.observe(node); obRef.current = observer; } }, []); const filterByFid = useMemo(() => new Map(activeFilters.map((x) => [x.fid, x])), [activeFilters]); const toggleValue = useCallback( (fid: string, value: any) => { const rule = filterByFid.get(fid)?.rule; const values = rule?.type === 'one of' ? [...rule.value] : []; const encoded = _unstable_encodeRuleValue(value); const i = values.findIndex((x) => _unstable_encodeRuleValue(x) === encoded); if (i > -1) { values.splice(i, 1); } else { values.push(value); } setFieldRule(fid, values.length > 0 ? { type: 'one of', value: values } : null); }, [filterByFid, setFieldRule] ); const selectRange = useCallback( (fid: string, [lo, hi]: [number, number], extend: boolean) => { const rule = filterByFid.get(fid)?.rule; if (rule?.type === 'range') { if (extend) { setFieldRule(fid, { type: 'range', value: [Math.min(rule.value[0] ?? lo, lo), Math.max(rule.value[1] ?? hi, hi)] }); return; } if (rule.value[0] === lo && rule.value[1] === hi) { setFieldRule(fid, null); return; } } setFieldRule(fid, { type: 'range', value: [lo, hi] }); }, [filterByFid, setFieldRule] ); const toggleSort = useCallback((fid: string) => { setSorting((s) => { if (s?.fid !== fid) return { fid, sort: 'descending' }; if (s.sort === 'descending') return { fid, sort: 'ascending' }; return undefined; }); }, []); const showFooter = !(hidePaginationAtOnepage && total <= size); const headerRowCount = headers.length + (hideProfiling ? 0 : 1); return (
{activeFilters.length > 0 ? ( ) : ( )} {activeFilters.length > 0 && ( <>
{activeFilters.map((x) => ( onSelectFilter(x.fid)} onRemove={() => setFieldRule(x.fid, null)} /> ))}
)} {loading && }
{headers.map((row, rowIndex) => ( {rowIndex === 0 && ( )} {row.map((f, i) => { if (f.type === 'name') { return ( ); } const field = f.value; const sort = sorting?.fid === field.fid ? sorting.sort : undefined; return ( ); })} ))} {!hideProfiling && ( {metas.map((field) => ( ))} )} 0 && 'opacity-60')}> {rows.map((row, index) => ( {metas.map((field) => { const value = fieldValue({ field, item: row, displayOffset }); return ( ); })} ))}
# {f.value} toggleSort(field.fid)} onSort={disableSorting ? undefined : (s) => setSorting(s ? { fid: field.fid, sort: s } : undefined)} onFilter={disableFilter ? undefined : () => onSelectFilter(field.fid)} onClearFilter={disableFilter ? undefined : () => setFieldRule(field.fid, null)} onChangeSemanticType={ onMetaChange ? (semanticType) => onMetaChange(field.fid, f.fIndex, { semanticType }) : undefined } />
toggleValue(field.fid, value)} onSelectRange={disableFilter ? undefined : (range, extend) => selectRange(field.fid, range, extend)} />
{formatCount(from + index + 1)}
{!loading && rows.length === 0 && (
{activeFilters.length > 0 ? t('data_table.no_matching_rows') : t('data_table.no_rows')} {activeFilters.length > 0 && ( )}
)}
{showFooter && (
{total > 0 && ( )} { setPageIndex(Math.min(Math.ceil(total / size) - 1, pageIndex + 1)); }} onPrev={() => { setPageIndex(Math.max(0, pageIndex - 1)); }} onPageChange={(index) => { setPageIndex(Math.max(0, Math.min(Math.ceil(total / size) - 1, index))); }} />
)} {!disableFilter && (
)}
); } ); export default DataTable;