From 42f34a36f40ef6b792c448351d879937ab02eb30 Mon Sep 17 00:00:00 2001 From: Kunghim Date: Sat, 1 Aug 2026 19:32:20 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(datagrid):=20=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=88=97=E5=80=BC=E5=A4=9A=E9=80=89=E7=AD=9B=E9=80=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/DataGrid.tsx | 21 +++- .../components/DataGridColumnTitle.test.tsx | 46 +++++--- .../src/components/DataGridColumnTitle.tsx | 108 +++++++++++++----- frontend/src/components/DataViewer.tsx | 5 + .../src/components/useDataGridFilters.tsx | 4 +- .../src/utils/dataGridClientFilter.test.ts | 10 ++ frontend/src/utils/dataGridClientFilter.ts | 7 ++ frontend/src/utils/sql.test.ts | 12 +- frontend/src/utils/sql.ts | 20 ++++ shared/i18n/de-DE.json | 1 + shared/i18n/en-US.json | 1 + shared/i18n/ja-JP.json | 1 + shared/i18n/ru-RU.json | 1 + shared/i18n/zh-CN.json | 1 + shared/i18n/zh-TW.json | 1 + 15 files changed, 187 insertions(+), 52 deletions(-) diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index bc48e634..5022637b 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -1612,14 +1612,18 @@ const DataGrid: React.FC = ({ const getCurrentColumnValueCounts = useMemo(() => { const cache = new Map>(); return (columnName: string) => { - if (exportScope !== 'queryResult') return undefined; const cached = cache.get(columnName); if (cached) return cached; - const counts = countGridColumnValues(rowsBeforeClientFilter, columnName); + const rowsForValueCounts = exportScope === 'queryResult' + ? filterRowsByGridConditions(rowsBeforeClientFilter, filterConditions.filter((condition) => ( + String(condition?.column || '') !== columnName + ))) + : rowsBeforeClientFilter; + const counts = countGridColumnValues(rowsForValueCounts, columnName); cache.set(columnName, counts); return counts; }; - }, [exportScope, rowsBeforeClientFilter]); + }, [exportScope, filterConditions, rowsBeforeClientFilter]); const columnHeaderFilterEnabled = !!onApplyFilter || exportScope === 'queryResult'; const columnHeaderFilterOpOptions = useMemo( () => filterOpOptions.filter((option) => option.value !== 'CUSTOM'), @@ -1638,6 +1642,7 @@ const DataGrid: React.FC = ({ initialOperator: String(firstCondition?.op || defaultOperator), initialValue: String(firstCondition?.value ?? ''), initialValue2: String(firstCondition?.value2 ?? ''), + initialValueSelection: firstCondition?.valueSelection, }; }, [filterConditions, getColumnFilterType]); @@ -1647,6 +1652,7 @@ const DataGrid: React.FC = ({ op: draft.op, value: draft.value, value2: draft.value2, + valueSelection: draft.valueSelection, }); }, [applyColumnFilter]); @@ -1679,6 +1685,7 @@ const DataGrid: React.FC = ({ initialOperator: columnFilterState.initialOperator, initialValue: columnFilterState.initialValue, initialValue2: columnFilterState.initialValue2, + initialValueSelection: columnFilterState.initialValueSelection, filterLabel: translateDataGrid('data_grid.toolbar.filter'), applyLabel: translateDataGrid('data_grid.filter.apply'), clearLabel: translateDataGrid('data_grid.filter.clear'), @@ -5505,7 +5512,13 @@ const DataGrid: React.FC = ({ onOpenErTable: openTableByName, onPageChange, onLastPage, - onReload, + onReload: exportScope === 'queryResult' + ? () => { + setFilterConditions([]); + clearQuickWhereCondition(); + onReload?.(); + } + : onReload, onRequestTotalCount, onSort, onToggleFilter, diff --git a/frontend/src/components/DataGridColumnTitle.test.tsx b/frontend/src/components/DataGridColumnTitle.test.tsx index f9bdc169..025e0a4d 100644 --- a/frontend/src/components/DataGridColumnTitle.test.tsx +++ b/frontend/src/components/DataGridColumnTitle.test.tsx @@ -35,6 +35,12 @@ vi.mock('antd', () => ({ ))} ), + Checkbox: ({ children, checked, onChange, ...props }: { children?: React.ReactNode; checked?: boolean; onChange?: () => void }) => ( + + ), Tooltip: ({ children, title, rootClassName }: { children: React.ReactNode; title?: React.ReactNode; rootClassName?: string }) => ( <>
{title}
@@ -315,7 +321,7 @@ describe('DataGridColumnTitle', () => { expect(onClear).toHaveBeenCalledTimes(1); }); - it('searches current value counts and fills drafts before explicit apply', () => { + it('searches and multi-selects current values before explicit apply', () => { const onApply = vi.fn(() => true); const translate = (key: string) => ({ 'data_grid.filter.value_counts.title': 'Current values', @@ -367,34 +373,42 @@ describe('DataGridColumnTitle', () => { .find((input) => input.props.placeholder === 'Search values'); expect(searchInput).toBeTruthy(); act(() => searchInput!.props.onChange({ target: { value: 'act' } })); - expect(renderer.root.findAll((node) => node.props['data-grid-column-value-count-kind']).map((node) => node.props.title)).toEqual(['active']); + expect(renderer.root.findAllByType('label') + .filter((node) => node.props['data-grid-column-value-count-kind']) + .map((node) => node.props.title)).toEqual(['active']); - const clickValueCount = (kind: string) => { - const button = renderer.root.findAllByType('button') - .find((node) => node.props['data-grid-column-value-count-kind'] === kind); - expect(button).toBeTruthy(); - act(() => button!.props.onClick()); + const toggleValueCount = (kind: string) => { + const checkbox = renderer.root.findAllByType('label') + .filter((node) => node.props['data-grid-column-value-count-kind'] === kind); + expect(checkbox).toHaveLength(1); + const input = checkbox[0].findByType('input'); + act(() => input.props.onChange()); }; const apply = () => { const button = renderer.root.findAllByType('button').find((node) => node.children.includes('Apply')); act(() => button!.props.onClick({ preventDefault: vi.fn(), stopPropagation: vi.fn() })); }; - clickValueCount('value'); + toggleValueCount('value'); expect(onApply).not.toHaveBeenCalled(); apply(); - expect(onApply).toHaveBeenLastCalledWith({ op: '=', value: 'active', value2: '' }); + expect(onApply).toHaveBeenLastCalledWith({ + op: 'IN', + value: '', + value2: '', + valueSelection: { values: ['active'] }, + }); act(() => searchInput!.props.onChange({ target: { value: '' } })); - clickValueCount('nullish'); + toggleValueCount('nullish'); expect(onApply).toHaveBeenCalledTimes(1); apply(); - expect(onApply).toHaveBeenLastCalledWith({ op: 'IS_NULL', value: '', value2: '' }); - - clickValueCount('empty'); - expect(onApply).toHaveBeenCalledTimes(2); - apply(); - expect(onApply).toHaveBeenLastCalledWith({ op: 'IS_EMPTY', value: '', value2: '' }); + expect(onApply).toHaveBeenLastCalledWith({ + op: 'IN', + value: '', + value2: '', + valueSelection: { values: ['active'], includeNull: true }, + }); }); it('uses translated tooltip wrappers while preserving raw metadata values', () => { diff --git a/frontend/src/components/DataGridColumnTitle.tsx b/frontend/src/components/DataGridColumnTitle.tsx index 1a5debd5..a2a9908a 100644 --- a/frontend/src/components/DataGridColumnTitle.tsx +++ b/frontend/src/components/DataGridColumnTitle.tsx @@ -1,8 +1,9 @@ import React from 'react'; -import { Button, Input, Popover, Select, Tooltip } from 'antd'; +import { Button, Checkbox, Input, Popover, Select, Tooltip } from 'antd'; import { FilterOutlined, LinkOutlined, PushpinOutlined, SearchOutlined } from '@ant-design/icons'; import { t as defaultTranslate, type I18nParams } from '../i18n'; import type { DataGridColumnValueCount } from '../utils/dataGridClientFilter'; +import type { FilterValueSelection } from '../utils/sql'; export type DataGridColumnTitleTranslate = (key: string, params?: I18nParams) => string; @@ -10,6 +11,7 @@ export type DataGridColumnFilterDraft = { op: string; value: string; value2?: string; + valueSelection?: FilterValueSelection; }; export interface DataGridColumnFilterConfig { @@ -19,6 +21,7 @@ export interface DataGridColumnFilterConfig { initialOperator?: string; initialValue?: string; initialValue2?: string; + initialValueSelection?: FilterValueSelection; filterLabel: string; applyLabel: string; clearLabel: string; @@ -63,6 +66,12 @@ const stopColumnHeaderInteraction = (event: React.SyntheticEvent) = event.stopPropagation(); }; +const normalizeValueSelection = (selection?: FilterValueSelection): FilterValueSelection => ({ + values: Array.from(new Set((selection?.values || []).map((value) => String(value)))), + ...(selection?.includeNull ? { includeNull: true } : {}), + ...(selection?.includeEmpty ? { includeEmpty: true } : {}), +}); + const DataGridColumnTitle: React.FC = ({ columnName, columnMeta, @@ -107,6 +116,9 @@ const DataGridColumnTitle: React.FC = ({ const [draftFilterValue, setDraftFilterValue] = React.useState(columnFilter?.initialValue || ''); const [draftFilterValue2, setDraftFilterValue2] = React.useState(columnFilter?.initialValue2 || ''); const [valueCountSearch, setValueCountSearch] = React.useState(''); + const [draftValueSelection, setDraftValueSelection] = React.useState( + normalizeValueSelection(columnFilter?.initialValueSelection), + ); React.useEffect(() => { if (!filterPopoverOpen || !columnFilter) return; @@ -114,11 +126,13 @@ const DataGridColumnTitle: React.FC = ({ setDraftFilterValue(columnFilter.initialValue || ''); setDraftFilterValue2(columnFilter.initialValue2 || ''); setValueCountSearch(''); + setDraftValueSelection(normalizeValueSelection(columnFilter.initialValueSelection)); }, [ columnFilter?.defaultOperator, columnFilter?.initialOperator, columnFilter?.initialValue, columnFilter?.initialValue2, + columnFilter?.initialValueSelection, filterPopoverOpen, ]); @@ -289,26 +303,54 @@ const DataGridColumnTitle: React.FC = ({ !normalizedValueCountSearch || getValueCountDisplay(item).toLocaleLowerCase().includes(normalizedValueCountSearch) )); - const selectValueCount = (item: DataGridColumnValueCount) => { - if (item.kind === 'nullish') { - setDraftFilterOperator('IS_NULL'); - setDraftFilterValue(''); - } else if (item.kind === 'empty') { - setDraftFilterOperator('IS_EMPTY'); - setDraftFilterValue(''); - } else { - setDraftFilterOperator('='); - setDraftFilterValue(item.display); - } - setDraftFilterValue2(''); + const isValueCountSelected = (item: DataGridColumnValueCount) => { + if (item.kind === 'nullish') return !!draftValueSelection.includeNull; + if (item.kind === 'empty') return !!draftValueSelection.includeEmpty; + return draftValueSelection.values.includes(item.display); + }; + const toggleValueCount = (item: DataGridColumnValueCount) => { + setDraftValueSelection((current) => { + if (item.kind === 'nullish') return { ...current, includeNull: !current.includeNull }; + if (item.kind === 'empty') return { ...current, includeEmpty: !current.includeEmpty }; + const values = current.values.includes(item.display) + ? current.values.filter((value) => value !== item.display) + : [...current.values, item.display]; + return { ...current, values }; + }); + }; + const selectedValueCount = draftValueSelection.values.length + + (draftValueSelection.includeNull ? 1 : 0) + + (draftValueSelection.includeEmpty ? 1 : 0); + const areAllVisibleValueCountsSelected = filteredValueCounts.length > 0 + && filteredValueCounts.every((item) => isValueCountSelected(item)); + const hasVisibleValueCountSelection = filteredValueCounts.some((item) => isValueCountSelected(item)); + const toggleVisibleValueCounts = () => { + setDraftValueSelection((current) => { + const nextValues = new Set(current.values); + let includeNull = !!current.includeNull; + let includeEmpty = !!current.includeEmpty; + filteredValueCounts.forEach((item) => { + if (item.kind === 'nullish') includeNull = !areAllVisibleValueCountsSelected; + else if (item.kind === 'empty') includeEmpty = !areAllVisibleValueCountsSelected; + else if (areAllVisibleValueCountsSelected) nextValues.delete(item.display); + else nextValues.add(item.display); + }); + return { + values: Array.from(nextValues), + ...(includeNull ? { includeNull: true } : {}), + ...(includeEmpty ? { includeEmpty: true } : {}), + }; + }); }; const submitColumnFilter = (event?: React.SyntheticEvent) => { event?.preventDefault(); event?.stopPropagation(); + const appliesValueSelection = selectedValueCount > 0; const applied = columnFilter.onApply({ - op: draftFilterOperator, - value: draftFilterValue, - value2: draftFilterValue2, + op: appliesValueSelection ? 'IN' : draftFilterOperator, + value: appliesValueSelection ? '' : draftFilterValue, + value2: appliesValueSelection ? '' : draftFilterValue2, + valueSelection: appliesValueSelection ? draftValueSelection : undefined, }); if (applied !== false) setFilterPopoverOpen(false); }; @@ -417,18 +459,27 @@ const DataGridColumnTitle: React.FC = ({ placeholder={translate('data_grid.filter.value_counts.search_placeholder')} onChange={(event) => setValueCountSearch(event.target.value)} /> + + {translate('data_grid.filter.value_counts.select_all')} +
{filteredValueCounts.map((item) => ( - + ))} {filteredValueCounts.length === 0 && ( diff --git a/frontend/src/components/DataViewer.tsx b/frontend/src/components/DataViewer.tsx index 1c4e1aa7..fcf7e1a2 100644 --- a/frontend/src/components/DataViewer.tsx +++ b/frontend/src/components/DataViewer.tsx @@ -335,6 +335,11 @@ const normalizeViewerFilterConditions = (conditions: FilterCondition[] | undefin op: String(cond?.op || '='), value: String(cond?.value ?? ''), value2: String(cond?.value2 ?? ''), + valueSelection: cond?.valueSelection ? { + values: Array.from(new Set((cond.valueSelection.values || []).map((value) => String(value)))), + ...(cond.valueSelection.includeNull ? { includeNull: true } : {}), + ...(cond.valueSelection.includeEmpty ? { includeEmpty: true } : {}), + } : undefined, })); }; diff --git a/frontend/src/components/useDataGridFilters.tsx b/frontend/src/components/useDataGridFilters.tsx index d6854646..3216ab81 100644 --- a/frontend/src/components/useDataGridFilters.tsx +++ b/frontend/src/components/useDataGridFilters.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import type { FilterCondition } from '../utils/sql'; +import type { FilterCondition, FilterValueSelection } from '../utils/sql'; import { applyNoAutoCapAttributesWithin } from '../utils/inputAutoCap'; import { normalizeQuickWhereCondition, @@ -20,6 +20,7 @@ export type GridColumnFilterDraft = { op: string; value?: string; value2?: string; + valueSelection?: FilterValueSelection; }; type GridSortInfo = { @@ -378,6 +379,7 @@ export const useDataGridFilters = ({ op, value: isNoValueOp(op) ? '' : String(draft?.value ?? ''), value2: isNoValueOp(op) || !isBetweenOp(op) ? '' : String(draft?.value2 ?? ''), + valueSelection: draft.valueSelection, }; const nextConditions = [ ...filterConditions.filter((cond) => !( diff --git a/frontend/src/utils/dataGridClientFilter.test.ts b/frontend/src/utils/dataGridClientFilter.test.ts index ac97dc9b..cb3a9136 100644 --- a/frontend/src/utils/dataGridClientFilter.test.ts +++ b/frontend/src/utils/dataGridClientFilter.test.ts @@ -73,6 +73,16 @@ describe('filterRowsByGridConditions', () => { ]).map((row) => row.id)).toEqual([1]); }); + it('filters a structured value selection including null and empty values', () => { + const rowsWithEmpty = [...rows, { id: 4, name: 'Dora', status: '' }]; + expect(filterRowsByGridConditions(rowsWithEmpty, [{ + column: 'status', + op: 'IN', + valueSelection: { values: ['active'], includeNull: true, includeEmpty: true }, + enabled: true, + }]).map((row) => row.id)).toEqual([1, 3, 4]); + }); + it('supports null checks and OR logic', () => { expect(filterRowsByGridConditions(rows, [ { column: 'status', op: 'IS_NULL', enabled: true }, diff --git a/frontend/src/utils/dataGridClientFilter.ts b/frontend/src/utils/dataGridClientFilter.ts index 4724e5b9..c48b5d7c 100644 --- a/frontend/src/utils/dataGridClientFilter.ts +++ b/frontend/src/utils/dataGridClientFilter.ts @@ -124,6 +124,13 @@ const matchSingleCondition = (row: Record, condition: FilterConditi const cell = row?.[column]; const cellText = normalizeCellText(cell); + const valueSelection = condition?.valueSelection; + if (valueSelection) { + return (valueSelection.values || []).some((item) => cellText === String(item)) + || (!!valueSelection.includeNull && isNullishCell(cell)) + || (!!valueSelection.includeEmpty && !isNullishCell(cell) && isEmptyCell(cell)); + } + switch (op) { case 'IS_NULL': return isNullishCell(cell); diff --git a/frontend/src/utils/sql.test.ts b/frontend/src/utils/sql.test.ts index 4368c962..3baf4edf 100644 --- a/frontend/src/utils/sql.test.ts +++ b/frontend/src/utils/sql.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { buildOrderBySQL, buildPaginatedSelectSQL, quoteQualifiedIdent, reverseOrderBySQL } from './sql'; +import { buildOrderBySQL, buildPaginatedSelectSQL, buildWhereSQL, quoteQualifiedIdent, reverseOrderBySQL } from './sql'; describe('buildOrderBySQL', () => { it('does not add fallback ORDER BY for DuckDB without explicit sort', () => { @@ -12,6 +12,16 @@ describe('buildOrderBySQL', () => { }); }); +describe('buildWhereSQL', () => { + it('builds one grouped expression for a structured value selection', () => { + expect(buildWhereSQL('mysql', [{ + column: 'status', + op: 'IN', + valueSelection: { values: ['active', 'waiting,review'], includeNull: true, includeEmpty: true }, + }])).toBe("WHERE ((`status` IN ('active', 'waiting,review') OR `status` IS NULL OR `status` = ''))"); + }); +}); + describe('buildPaginatedSelectSQL', () => { it('uses SQL Server TOP for the first page to support old compatibility levels', () => { const sql = buildPaginatedSelectSQL('sqlserver', 'SELECT * FROM [Users]', ' ORDER BY [ID] ASC', 101, 0); diff --git a/frontend/src/utils/sql.ts b/frontend/src/utils/sql.ts index 6d9449c2..1449e6ba 100644 --- a/frontend/src/utils/sql.ts +++ b/frontend/src/utils/sql.ts @@ -1,5 +1,11 @@ import { splitQualifiedNameSegments, stripIdentifierQuotes } from './qualifiedName'; +export type FilterValueSelection = { + values: string[]; + includeNull?: boolean; + includeEmpty?: boolean; +}; + export type FilterCondition = { id?: number; enabled?: boolean; @@ -8,6 +14,7 @@ export type FilterCondition = { op?: string; value?: string; value2?: string; + valueSelection?: FilterValueSelection; }; const normalizeIdentPart = (ident: string) => stripIdentifierQuotes(ident); @@ -369,6 +376,19 @@ export const buildWhereSQL = (dbType: string, conditions: FilterCondition[]) => if (!column) return; const col = quoteIdentPart(dbType, column); + const valueSelection = cond?.valueSelection; + if (valueSelection) { + const selectedValues = Array.from(new Set((valueSelection.values || []).map((item) => String(item)))); + const selectionParts: string[] = []; + if (selectedValues.length > 0) { + selectionParts.push(`${col} IN (${selectedValues.map((item) => `'${escapeLiteral(item)}'`).join(', ')})`); + } + if (valueSelection.includeNull) selectionParts.push(`${col} IS NULL`); + if (valueSelection.includeEmpty) selectionParts.push(`${col} = ''`); + if (selectionParts.length === 0) return; + appendWherePart(selectionParts.length === 1 ? selectionParts[0] : `(${selectionParts.join(' OR ')})`); + return; + } switch (op) { case 'IS_NULL': diff --git a/shared/i18n/de-DE.json b/shared/i18n/de-DE.json index 76b16250..6f2a1941 100644 --- a/shared/i18n/de-DE.json +++ b/shared/i18n/de-DE.json @@ -4140,6 +4140,7 @@ "data_grid.filter.value_counts.no_matches": "Keine passenden Werte im aktuellen Ergebnis", "data_grid.filter.value_counts.nullish": "(NULL)", "data_grid.filter.value_counts.search_placeholder": "Werte im aktuellen Ergebnis suchen", + "data_grid.filter.value_counts.select_all": "Alle auswählen", "data_grid.filter.value_counts.title": "Wertanzahl im aktuellen Ergebnis", "data_grid.filter.op.between": "Zwischen", "data_grid.filter.op.contains": "Enthält", diff --git a/shared/i18n/en-US.json b/shared/i18n/en-US.json index db1efa1e..f549d819 100644 --- a/shared/i18n/en-US.json +++ b/shared/i18n/en-US.json @@ -4140,6 +4140,7 @@ "data_grid.filter.value_counts.no_matches": "No matching values in the current result", "data_grid.filter.value_counts.nullish": "(NULL)", "data_grid.filter.value_counts.search_placeholder": "Search current result values", + "data_grid.filter.value_counts.select_all": "Select all", "data_grid.filter.value_counts.title": "Current result value counts", "data_grid.filter.op.between": "Between", "data_grid.filter.op.contains": "Contains", diff --git a/shared/i18n/ja-JP.json b/shared/i18n/ja-JP.json index e4f22c8a..56a7ed30 100644 --- a/shared/i18n/ja-JP.json +++ b/shared/i18n/ja-JP.json @@ -4140,6 +4140,7 @@ "data_grid.filter.value_counts.no_matches": "現在の結果に一致する値はありません", "data_grid.filter.value_counts.nullish": "(NULL)", "data_grid.filter.value_counts.search_placeholder": "現在の結果の値を検索", + "data_grid.filter.value_counts.select_all": "すべて選択", "data_grid.filter.value_counts.title": "現在の結果の値集計", "data_grid.filter.op.between": "範囲内", "data_grid.filter.op.contains": "含む", diff --git a/shared/i18n/ru-RU.json b/shared/i18n/ru-RU.json index 907dcebd..f43b15e5 100644 --- a/shared/i18n/ru-RU.json +++ b/shared/i18n/ru-RU.json @@ -4140,6 +4140,7 @@ "data_grid.filter.value_counts.no_matches": "В текущем результате нет совпадающих значений", "data_grid.filter.value_counts.nullish": "(NULL)", "data_grid.filter.value_counts.search_placeholder": "Поиск значений в текущем результате", + "data_grid.filter.value_counts.select_all": "Выбрать все", "data_grid.filter.value_counts.title": "Количество значений в текущем результате", "data_grid.filter.op.between": "Между", "data_grid.filter.op.contains": "Содержит", diff --git a/shared/i18n/zh-CN.json b/shared/i18n/zh-CN.json index 92b2c482..f993c666 100644 --- a/shared/i18n/zh-CN.json +++ b/shared/i18n/zh-CN.json @@ -4140,6 +4140,7 @@ "data_grid.filter.value_counts.no_matches": "当前结果中没有匹配值", "data_grid.filter.value_counts.nullish": "(NULL)", "data_grid.filter.value_counts.search_placeholder": "搜索当前结果值", + "data_grid.filter.value_counts.select_all": "全选", "data_grid.filter.value_counts.title": "当前结果值统计", "data_grid.filter.op.between": "介于", "data_grid.filter.op.contains": "包含", diff --git a/shared/i18n/zh-TW.json b/shared/i18n/zh-TW.json index a57b9041..3e7dbf16 100644 --- a/shared/i18n/zh-TW.json +++ b/shared/i18n/zh-TW.json @@ -4140,6 +4140,7 @@ "data_grid.filter.value_counts.no_matches": "目前結果中沒有符合的值", "data_grid.filter.value_counts.nullish": "(NULL)", "data_grid.filter.value_counts.search_placeholder": "搜尋目前結果值", + "data_grid.filter.value_counts.select_all": "全選", "data_grid.filter.value_counts.title": "目前結果值統計", "data_grid.filter.op.between": "介於", "data_grid.filter.op.contains": "包含",