import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import styled from 'styled-components'; import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/24/outline'; import type { IFilterField, IFilterRule, IFieldStats, IMutField, IComputationFunction, IKeyWord } from '../../interfaces'; import { useCompututaion } from '../../store'; import LoadingLayer from '../../components/loadingLayer'; import { fieldStat, getTemporalRange, withComputedField } from '../../computation'; import Slider from './slider'; import { getFilterMeaAggKey, formatDate, classNames, isNotEmpty, _unstable_encodeRuleValue } from '../../utils'; import { useVirtualizer } from '@tanstack/react-virtual'; import { newOffsetDate, parsedOffsetDate } from '../../lib/op/offset'; import { createStreamedValueHook } from '../../hooks'; import { debounce } from 'lodash-es'; import { GLOBAL_CONFIG } from '../../config'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; import { Label } from '@/components/ui/label'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Skeleton } from '@/components/ui/skeleton'; import { Checkbox } from '@/components/ui/checkbox'; import Tooltip from '@/components/tooltip'; import Spinner from '@/components/spinner'; import { SparseArray } from './array'; export type RuleFormProps = { allFields: IMutField[]; field: IFilterField; onChange: (rule: IFilterRule) => void; displayOffset?: number; }; const Container = styled.div` margin-block: 1em; > .btn-grp { display: flex; flex-direction: row; margin-block: 1em; > * { margin-inline-start: 0.6em; &:first-child { margin-inline-start: 0; } } } `; const Table = styled.div` display: flex; flex-direction: column; max-height: 30vh; overflow-y: scroll; `; const TableRow = styled.div` display: flex; & > input, & > *[for] { cursor: pointer; } & > * { padding-block: 0.6em; padding-inline: 0.2em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; user-select: none; border-bottom: 0.8px solid hsl(var(--border)); flex-shink: 0; } & > *:first-child { width: 4em; } & > *:last-child { width: max-content; } & > *:first-child + * { flex: 1; } `; const TabsContainer = styled.div` display: flex; flex-direction: column; align-items: stretch; justify-content: stretch; `; const CalendarInputContainer = styled.div` display: flex; padding-block: 1em; width: 100%; > .calendar-input { width: 100%; } > .calendar-input:first-child { margin-right: 0.5em; } > .calendar-input:last-child { margin-left: 0.5em; } `; const TabPanel = styled.div``; const TabItem = styled.div``; const StatusCheckbox: React.FC<{ currentNum: number; totalNum: number; onChange: () => void; disabled?: boolean; loading?: boolean }> = (props) => { const { currentNum, totalNum, onChange } = props; let checked: boolean | 'indeterminate'; if (currentNum === totalNum) { checked = true; } else if (currentNum < totalNum && currentNum > 0) { checked = 'indeterminate'; } else { checked = false; } if (props.loading) { return ; } return onChange()} />; }; // TODO: refactor this function export const useFieldStats = ( field: IFilterField, attributes: { values: boolean; range: boolean; valuesMeta?: boolean; selectedCount?: any[]; displayOffset?: number; keyword?: IKeyWord; }, sortBy: 'value' | 'value_dsc' | 'count' | 'count_dsc' | 'none', computation: IComputationFunction, allFields: IMutField[] ): [IFieldStats | null, IFieldStats | null] => { const { values, range, valuesMeta, selectedCount, displayOffset, keyword } = attributes; const [loading, setLoading] = React.useState(true); const [stats, setStats] = React.useState(null); const fieldStatKey = getFilterMeaAggKey(field); React.useEffect(() => { setLoading(true); let isCancelled = false; fieldStat(computation, field, { values, range, valuesMeta, sortBy, selectedCount, timezoneDisplayOffset: displayOffset, keyword }, allFields) .then((stats) => { if (isCancelled) { return; } setStats(stats); setLoading(false); }) .catch((reason) => { console.warn(reason); if (isCancelled) { return; } setStats(null); setLoading(false); }); return () => { isCancelled = true; }; }, [fieldStatKey, computation, values, range, valuesMeta, sortBy, selectedCount, keyword, displayOffset]); return [loading ? null : stats, stats]; }; const PAGE_SIZE = 20; const emptyArray = SparseArray.create(); type RowCount = { value: string | number; count: number; }; export const useVisualCount = ( field: IFilterField, sortBy: 'value' | 'value_dsc' | 'count' | 'count_dsc' | 'none', computation: IComputationFunction, onChange: (rule: IFilterRule) => void, allFields: IMutField[], options: { displayOffset: number | undefined; keyword?: IKeyWord; } ) => { // fetch metaData of filter field, only fetch once per fid. const initRuleValue = useMemo(() => (field.rule?.type === 'not in' || field.rule?.type === 'one of' ? field.rule.value : []), [field.fid, computation]); const [metaData] = useFieldStats( field, { values: false, range: false, selectedCount: initRuleValue, valuesMeta: true, displayOffset: options.displayOffset }, 'none', computation, allFields ); const [currentMeta, lastCurrentMeta] = useFieldStats( field, { values: false, range: false, valuesMeta: true, keyword: options.keyword }, 'none', computation, allFields ); // sum of count of rule. const [selectedValueSum, setSelectedValueSum] = useState(0); useEffect(() => { if (metaData) { setSelectedValueSum(metaData.selectedCount); } }, [metaData]); // unfetched RowCount will be null const [loadedPageData, setLoadedPageDataRaw] = useState>(emptyArray); const loadedRef = useRef(loadedPageData); const setLoadedPageData = useCallback((data: SparseArray | ((prev: SparseArray) => SparseArray)) => { if (typeof data === 'function') { loadedRef.current = data(loadedRef.current); } else { loadedRef.current = data; } setLoadedPageDataRaw(loadedRef.current); }, []); const loadingRef = useRef>>({}); // load actual RowCount when encounter null in data, fetch by its page. const loadData = useCallback( (index: number) => { const page = Math.floor(index / PAGE_SIZE); if (loadedRef.current.get(index) === null) { if (loadingRef.current[page] === undefined) { const promise = fieldStat( computation, field, { range: false, values: true, valuesMeta: false, sortBy, valuesLimit: PAGE_SIZE, valuesOffset: PAGE_SIZE * page, timezoneDisplayOffset: options.displayOffset, keyword: options.keyword, }, allFields ); loadingRef.current[page] = promise; promise.then((stats) => { // check that the list is not cleared if (loadingRef.current[page] === promise) { const { values } = stats; setLoadedPageData((data) => { return data.putIn(page * PAGE_SIZE, values); }); } }); } // already fetching, skip } }, [computation, field.fid, sortBy, allFields, options.displayOffset, options.keyword] ); // clear data when field or sort changes useEffect(() => { loadingRef.current = {}; setLoadedPageData(SparseArray.create()); loadData(0); }, [loadData]); const loadingPageData = loadedPageData === emptyArray || !currentMeta; const data = useMemo(() => { if (!currentMeta?.valuesMeta.distinctTotal) return emptyArray; return loadedPageData; }, [loadedPageData, currentMeta?.valuesMeta.distinctTotal]); const currentCount = field.rule?.type === 'one of' ? field.rule.value.length : metaData && field.rule?.type === 'not in' ? metaData.valuesMeta.distinctTotal - field.rule.value.length : 0; const currentSum = field.rule?.type === 'one of' ? selectedValueSum : metaData && field.rule?.type === 'not in' ? metaData.valuesMeta.total - selectedValueSum : 0; const handleToggleFullOrEmptySet = useCallback(() => { if (!field.rule || (field.rule.type !== 'one of' && field.rule.type !== 'not in') || !metaData) return; setSelectedValueSum(0); onChange({ type: currentCount === metaData.valuesMeta.distinctTotal ? 'one of' : 'not in', value: [], }); }, [field.rule, onChange, metaData, currentCount]); // Add new state for loading select all button const [loadingSelectAll, setLoadingSelectAll] = useState(false); // New function to handle selecting all filtered results const handleToggleCurrentFullSet = useCallback(async () => { if (!field.rule || (field.rule.type !== 'one of' && field.rule.type !== 'not in') || !currentMeta) return; if (!options.keyword) { // If no keyword is set, just toggle the full set handleToggleFullOrEmptySet(); return; } try { setLoadingSelectAll(true); // Fetch all values that match the current search keyword const result = await fieldStat( computation, field, { range: false, values: true, valuesMeta: false, sortBy, keyword: options.keyword, timezoneDisplayOffset: options.displayOffset, }, allFields ); const { values } = result; if (!values || values.length === 0) return; const existingValues = new Set(field.rule.value.map((v) => _unstable_encodeRuleValue(v))); let totalCount = 0; values.forEach((item) => { if (existingValues.has(_unstable_encodeRuleValue(item.value))) return; // Skip if already selected existingValues.add(_unstable_encodeRuleValue(item.value)); totalCount += item.count; }); if (totalCount === 0) { // all values are already selected // deselect all values values.forEach((item) => { existingValues.delete(_unstable_encodeRuleValue(item.value)); totalCount += item.count; }); setSelectedValueSum((x) => x - totalCount); } else { setSelectedValueSum((x) => x + totalCount); } onChange({ type: field.rule.type, value: Array.from(existingValues), }); } finally { setLoadingSelectAll(false); } }, [field.rule, onChange, currentMeta, currentCount, computation, sortBy, options.keyword, options.displayOffset, allFields, loadedPageData]); const handleToggleReverseSet = useCallback(() => { if (!field.rule || (field.rule.type !== 'one of' && field.rule.type !== 'not in') || !metaData) return; onChange({ type: field.rule.type === 'one of' ? 'not in' : 'one of', value: field.rule.value, }); }, [field.rule, onChange, metaData]); const handleSelect = useCallback( (value: string | number, checked: boolean, itemNum: number) => { if (!field.rule || (field.rule.type !== 'one of' && field.rule.type !== 'not in')) return; const newValue = new Set(field.rule.value); if (checked === (field.rule.type === 'one of')) { setSelectedValueSum((x) => x + itemNum); newValue.add(value); } else { setSelectedValueSum((x) => x - itemNum); newValue.delete(value); } onChange({ type: field.rule.type, value: Array.from(newValue), }); }, [field.rule, onChange] ); return { total: metaData?.valuesMeta.total, distinctTotal: metaData?.valuesMeta.distinctTotal, currentRows: lastCurrentMeta?.valuesMeta.distinctTotal, currentCount, currentSum, handleToggleFullOrEmptySet, handleToggleReverseSet, handleToggleCurrentFullSet, handleSelect, data, loadData, loading: !metaData, loadingPageData, loadingSelectAll, }; }; const Effecter = (props: { effect: () => void; effectId: any }) => { useEffect(() => { props.effect(); }, [props.effectId]); return null; }; interface SortConfig { key: 'value' | 'count'; ascending: boolean; } function Toggle(props: { children?: React.ReactNode; value: boolean; onChange?: (v: boolean) => void; label?: string }) { return (
{ props.onChange?.(!props.value); }} className={classNames( props.value ? 'bg-primary text-primary-foreground' : 'hover:bg-accent', 'rounded cursor-pointer p-1 w-6 h-6 flex items-center justify-center' )} > {props.children}
); } const SortButton: React.FC<{ id: string; config: SortConfig; currentKey: SortConfig['key']; setSortConfig: (value: SortConfig) => void }> = ({ config: { key, ascending }, currentKey, setSortConfig, id, }) => { const isCurrentKey = key === currentKey; return ( ); }; export const FilterOneOfRule: React.FC = ({ active, field, onChange, allFields, displayOffset }) => { const [sortConfig, setSortConfig] = useState({ key: 'count', ascending: true, }); const { t } = useTranslation('translation'); const computation = useCompututaion(); React.useEffect(() => { if (active && field.rule?.type !== 'one of' && field.rule?.type !== 'not in') { onChange({ type: 'not in', value: [], }); } }, [active, onChange, field]); const [keywordValue, setKeyword] = useState(''); const [isCaseSenstive, setIsCaseSenstive] = useState(false); const [isWord, setIsWord] = useState(false); const [isRegexp, setIsRegexp] = useState(false); const keyword = useMemo( () => keywordValue ? { value: keywordValue, caseSenstive: isCaseSenstive, word: isWord, regexp: isRegexp, } : undefined, [keywordValue, isCaseSenstive, isWord, isRegexp] ); const debouncer = useMemo(() => { const { timeout, ...options } = GLOBAL_CONFIG.KEYWORD_DEBOUNCE_SETTING; return function any>(f: T) { return debounce(f, timeout, options); }; }, []); const debouncedKeyword = createStreamedValueHook(debouncer)(keyword); const enableKeyword = field.semanticType === 'nominal'; const searchKeyword = enableKeyword ? debouncedKeyword : undefined; const { currentCount, currentSum, distinctTotal, data, currentRows, handleSelect, handleToggleFullOrEmptySet, handleToggleCurrentFullSet, handleToggleReverseSet, loadData, loading, loadingPageData, loadingSelectAll, } = useVisualCount(field, `${sortConfig.key}${sortConfig.ascending ? '' : '_dsc'}`, computation, onChange, allFields, { displayOffset, keyword: searchKeyword, }); const showLoadingList = loadingPageData || keyword !== debouncedKeyword; const parentRef = React.useRef(null); const rowVirtualizer = useVirtualizer({ count: currentRows ?? 0, getScrollElement: () => parentRef.current, estimateSize: () => 32, overscan: 10, }); const ruleSet = useMemo( () => field.rule && (field.rule.type === 'not in' || field.rule?.type === 'one of') ? new Set(field.rule.value.map((x) => _unstable_encodeRuleValue(x))) : null, [field] ); return field.rule?.type === 'one of' || field.rule?.type === 'not in' ? (
{t('constant.filter_type.one_of')}
{t('constant.filter_type.one_of_desc')}
{enableKeyword && (
{ setKeyword(e.target.value); }} />
)}
{loading && (
)} {/*
*/} {!loading && ( {showLoadingList && rowVirtualizer.getTotalSize() === 0 && } {showLoadingList && (
{rowVirtualizer.getVirtualItems().map((vItem) => (
))}
)} {!showLoadingList && (
{rowVirtualizer.getVirtualItems().map((vItem) => { const idx = vItem.index; const item = data?.get(idx); if (!item) { return (
loadData(idx)} effectId={`${field.fid}_${sortConfig.key}${sortConfig.ascending ? '' : '_dsc'}_${idx}`} />
); } const { value, count } = item; const id = `rule_checkbox_${idx}`; const checked = (!!ruleSet && field.rule?.type === 'one of' && ruleSet.has(_unstable_encodeRuleValue(value))) || (!!ruleSet && field.rule?.type === 'not in' && !ruleSet.has(_unstable_encodeRuleValue(value))); const displayValue = field.semanticType === 'temporal' ? formatDate(parsedOffsetDate(displayOffset, field.offset)(value)) : `${value}`; return (
handleSelect(value, !!checked, count)} />
); })}
)}
)} {isNotEmpty(distinctTotal) && (
)}
) : null; }; interface CalendarInputProps { min: number; max: number; displayOffset?: number; value: number; onChange: (value: number) => void; className?: string; } export const CalendarInput: React.FC = (props) => { const { min, max, value, onChange, displayOffset } = props; const dateStringFormatter = (timestamp: number) => { const date = newOffsetDate(displayOffset)(timestamp); if (Number.isNaN(date.getTime())) return ''; return formatDate(date); }; const handleSubmitDate = (value: string) => { const timestamp = newOffsetDate(displayOffset)(value).getTime(); if (timestamp <= max && timestamp >= min) { onChange(timestamp); } }; return ( handleSubmitDate(e.target.value)} /> ); }; export const FilterTemporalRangeRule: React.FC = ({ active, field, allFields, onChange, displayOffset }) => { const { t } = useTranslation('translation'); const computationFunction = useCompututaion(); const [res, setRes] = useState<[number, number, string, boolean]>(() => [0, 0, '', false]); React.useEffect(() => { withComputedField(field, allFields, computationFunction, { timezoneDisplayOffset: displayOffset })((service) => getTemporalRange(service, field.fid, field.offset) ).then(([min, max, format]) => setRes([min, max, format, true])); }, [field.fid]); const [min, max, format, loaded] = res; const offset = field.offset ?? new Date().getTimezoneOffset(); React.useEffect(() => { if (active && field.rule?.type !== 'temporal range' && loaded) { onChange({ type: 'temporal range', value: [min, max], format, offset, }); } }, [onChange, field, min, max, format, offset, active]); const handleChange = React.useCallback( (value: [number, number]) => { onChange({ type: 'temporal range', value, format, offset, }); }, [format, offset] ); if (!loaded) { return (
); } return field.rule?.type === 'temporal range' ? (
{t('constant.filter_type.temporal_range')}
{t('constant.filter_type.temporal_range_desc')}
{t('filters.range.start_value')}
handleChange([value, field.rule?.value[1]])} />
{t('filters.range.end_value')}
handleChange([field.rule?.value[0], value])} />
) : null; }; export const FilterRangeRule: React.FC = ({ active, field, onChange, allFields, displayOffset }) => { const { t } = useTranslation('translation', { keyPrefix: 'constant.filter_type' }); const computation = useCompututaion(); const [stats] = useFieldStats(field, { values: false, range: true, valuesMeta: false, displayOffset }, 'none', computation, allFields); const range = stats?.range; React.useEffect(() => { if (range && active && field.rule?.type !== 'range') { onChange({ type: 'range', value: range, }); } }, [onChange, field, range, active]); const handleChange = React.useCallback((value: [number, number]) => { onChange({ type: 'range', value, }); }, []); if (!range) { return (
); } return field.rule?.type === 'range' ? (
{t('range')}
{t('range_desc')}
) : null; }; const filterTabs: Partial>> = { 'one of': FilterOneOfRule, 'not in': FilterOneOfRule, range: FilterRangeRule, 'temporal range': FilterTemporalRangeRule, }; const tabOptionDict = { 'one of': { key: 'one_of', descKey: 'one_of_desc', }, range: { key: 'range', descKey: 'range_desc', }, 'temporal range': { key: 'temporal_range', descKey: 'temporal_range_desc', }, }; export interface TabsProps extends RuleFormProps { tabs: IFilterRule['type'][]; } const getType = (type?: 'range' | 'temporal range' | 'one of' | 'not in' | 'regexp') => { if (type === 'not in') { return 'one of'; } return type; }; const Tabs: React.FC = ({ allFields, field, onChange, tabs, displayOffset }) => { const { t } = useTranslation('translation', { keyPrefix: 'constant.filter_type' }); const [which, setWhich] = React.useState(getType(field.rule?.type) ?? tabs[0]!); React.useEffect(() => { if (!tabs.includes(which)) setWhich(tabs[0]); }, [tabs]); return ( setWhich(s as (typeof tabs)[number])}> {tabs.map((option) => { return (
{t(tabOptionDict[option].descKey)}
); })}

{tabs.map((tab, i) => { const Component = filterTabs[tab]; if (!Component) return null; return ( ); })}
); }; export default Tabs;