feat(data-grid): improve filter condition actions

This commit is contained in:
zipg 2026-08-02 08:59:47 +08:00 committed by GitHub
parent 64011dff4c
commit b65962d46f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 192 additions and 21 deletions

View File

@ -1,15 +1,20 @@
<script setup lang="ts">
import { nextTick, ref, watch } from "vue";
import { Eye, EyeOff, Plus, Search, Trash2 } from "@lucide/vue";
import { Eye, EyeOff, Plus, Search, Trash2, X } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { filterModeNeedsValue, filterModeUsesList, filterModeUsesRange } from "@/lib/dataGrid/dataGridColumnFilter";
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
import type { DataGridContextFilterMode } from "@/lib/dataGrid/dataGridSql";
import type { DataGridStructuredFilterRule } from "@/composables/useDataGridFilterBuilder";
const { t } = useI18n();
const VALUE_SHORTCUT_HINT_STORAGE_KEY = "dbx-filter-builder-value-shortcut-hint-days";
const VALUE_SHORTCUT_HINT_MAX_DAYS = 3;
const VALUE_SHORTCUT_HINT_MAX_PER_DAY = 2;
type ValueShortcutHintDay = { date: string; count: number };
const props = withDefaults(
defineProps<{
rules: DataGridStructuredFilterRule[];
@ -38,12 +43,43 @@ const pendingValueFocus = new Set<string>();
const pendingKeyboardAddFocus = new Set<string>();
const openColumnSelectIds = ref(new Set<string>());
const activeColumnIndexes = ref<Record<string, number>>({});
const focusedValueRuleId = ref<string>();
const valueShortcutHintRuleId = ref<string>();
const valueShortcutHintShownDays = ref(readValueShortcutHintShownDays());
let ruleIdsBeforeKeyboardAdd: Set<string> | undefined;
function usesExpandedLayout(mode: DataGridContextFilterMode) {
return filterModeUsesList(mode) || filterModeUsesRange(mode);
}
function currentLocalDateKey() {
const now = new Date();
const month = String(now.getMonth() + 1).padStart(2, "0");
const day = String(now.getDate()).padStart(2, "0");
return `${now.getFullYear()}-${month}-${day}`;
}
function readValueShortcutHintShownDays(): ValueShortcutHintDay[] {
try {
const parsed = JSON.parse(safeLocalStorageGet(VALUE_SHORTCUT_HINT_STORAGE_KEY) ?? "[]");
if (!Array.isArray(parsed)) return [];
return parsed
.map((item): ValueShortcutHintDay | undefined => {
if (!item || typeof item !== "object") return undefined;
const date = (item as { date?: unknown }).date;
const count = (item as { count?: unknown }).count;
return typeof date === "string" && typeof count === "number" && Number.isFinite(count) && count > 0 ? { date, count } : undefined;
})
.filter((item): item is ValueShortcutHintDay => !!item);
} catch {
return [];
}
}
function shouldShowValueShortcutHint(rule: DataGridStructuredFilterRule, index: number) {
return focusedValueRuleId.value === rule.id && valueShortcutHintRuleId.value === rule.id && index > 0 && filterModeNeedsValue(rule.mode) && !filterModeUsesList(rule.mode);
}
function updateRuleColumn(rule: DataGridStructuredFilterRule, value: unknown, focusValue = true) {
if (focusValue && filterModeNeedsValue(rule.mode)) pendingValueFocus.add(rule.id);
emit("updateRule", rule.id, { columnName: String(value) });
@ -194,13 +230,32 @@ function handleValueEditorKeydown(event: KeyboardEvent) {
event.stopPropagation();
if (!event.repeat) addRuleAndOpenColumnSelect();
}
function focusValueRule(id: string, index: number, mode: DataGridContextFilterMode) {
focusedValueRuleId.value = id;
valueShortcutHintRuleId.value = undefined;
if (index === 0 || !filterModeNeedsValue(mode) || filterModeUsesList(mode)) return;
const today = currentLocalDateKey();
const existingIndex = valueShortcutHintShownDays.value.findIndex((item) => item.date === today);
if (existingIndex >= 0 && valueShortcutHintShownDays.value[existingIndex].count >= VALUE_SHORTCUT_HINT_MAX_PER_DAY) return;
if (existingIndex < 0 && valueShortcutHintShownDays.value.length >= VALUE_SHORTCUT_HINT_MAX_DAYS) return;
const nextShownDays = existingIndex >= 0 ? valueShortcutHintShownDays.value.map((item, itemIndex) => (itemIndex === existingIndex ? { ...item, count: item.count + 1 } : item)) : [...valueShortcutHintShownDays.value, { date: today, count: 1 }];
valueShortcutHintShownDays.value = nextShownDays;
valueShortcutHintRuleId.value = id;
safeLocalStorageSet(VALUE_SHORTCUT_HINT_STORAGE_KEY, JSON.stringify(nextShownDays));
}
function blurValueRule(id: string) {
if (focusedValueRuleId.value === id) focusedValueRuleId.value = undefined;
if (valueShortcutHintRuleId.value === id) valueShortcutHintRuleId.value = undefined;
}
</script>
<template>
<div class="space-y-3">
<div v-if="props.showHeader !== false" class="flex items-center justify-between gap-3">
<div class="text-xs font-medium text-foreground">{{ t("grid.filter") }}</div>
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" :disabled="props.disabled || !props.columns.length" @click="emit('add')"> <Plus class="mr-1 h-3.5 w-3.5" />{{ t("grid.filterBuilderAddRule") }} </Button>
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" @click="emit('clear')"> <Trash2 class="mr-1 h-3.5 w-3.5" />{{ t("grid.clearFilter") }} </Button>
</div>
<div v-if="props.rules.length" class="space-y-2">
@ -256,9 +311,20 @@ function handleValueEditorKeydown(event: KeyboardEvent) {
:disabled="rule.disabled"
:placeholder="t('grid.filterBuilderRangeStart')"
@update:model-value="(value) => emit('updateRule', rule.id, { rawValue: String(value ?? '') })"
@focus="focusValueRule(rule.id, index, rule.mode)"
@blur="blurValueRule(rule.id)"
@keydown="handleValueEditorKeydown"
/>
<Input
:model-value="rule.rawEndValue"
class="h-8 text-xs"
:disabled="rule.disabled"
:placeholder="t('grid.filterBuilderRangeEnd')"
@update:model-value="(value) => emit('updateRule', rule.id, { rawEndValue: String(value ?? '') })"
@focus="focusValueRule(rule.id, index, rule.mode)"
@blur="blurValueRule(rule.id)"
@keydown="handleValueEditorKeydown"
/>
<Input :model-value="rule.rawEndValue" class="h-8 text-xs" :disabled="rule.disabled" :placeholder="t('grid.filterBuilderRangeEnd')" @update:model-value="(value) => emit('updateRule', rule.id, { rawEndValue: String(value ?? '') })" @keydown="handleValueEditorKeydown" />
</div>
<textarea
v-else-if="filterModeUsesList(rule.mode)"
@ -280,19 +346,24 @@ function handleValueEditorKeydown(event: KeyboardEvent) {
:disabled="rule.disabled"
:placeholder="t('grid.filterBuilderValue')"
@update:model-value="(value) => emit('updateRule', rule.id, { rawValue: String(value ?? '') })"
@focus="focusValueRule(rule.id, index, rule.mode)"
@blur="blurValueRule(rule.id)"
@keydown="handleValueEditorKeydown"
/>
<div v-else class="flex h-8 items-center rounded-md border border-dashed px-2 text-xs text-muted-foreground">{{ t("grid.filterBuilderNoValue") }}</div>
<div class="flex items-center gap-1" :class="usesExpandedLayout(rule.mode) ? 'col-start-3 row-start-1 row-span-2' : ''">
<div v-if="shouldShowValueShortcutHint(rule, index)" class="text-[11px] leading-none text-muted-foreground" :class="usesExpandedLayout(rule.mode) ? 'col-span-2 -mt-1' : 'col-start-3 row-start-2 -mt-1'">
{{ t("grid.filterBuilderValueShortcutHint") }}
</div>
<div class="flex items-center gap-1" :class="usesExpandedLayout(rule.mode) ? 'col-start-3 row-start-1 row-span-2' : 'col-start-4 row-start-1'">
<Button variant="ghost" size="icon" class="h-8 w-8" @click="emit('updateRule', rule.id, { disabled: !rule.disabled })"><EyeOff v-if="rule.disabled" class="h-3.5 w-3.5" /><Eye v-else class="h-3.5 w-3.5" /></Button>
<Button variant="ghost" size="icon" class="h-8 w-8" :disabled="props.rules.length === 1" @click="emit('remove', rule.id)"><Trash2 class="h-3.5 w-3.5" /></Button>
<Button variant="ghost" size="icon" class="h-8 w-8" :disabled="props.rules.length === 1" @click="emit('remove', rule.id)"><X class="h-3.5 w-3.5" /></Button>
</div>
</div>
</template>
</div>
<div v-else class="rounded-md border border-dashed px-3 py-4 text-center text-xs text-muted-foreground">{{ t("grid.filterBuilderEmpty") }}</div>
<div v-if="props.showFooter !== false" class="flex justify-between gap-2 pt-1">
<Button variant="ghost" size="sm" class="h-8 px-2 text-xs" @click="emit('clear')">{{ t("grid.clearFilter") }}</Button>
<Button variant="ghost" size="sm" class="h-8 px-2 text-xs" :disabled="props.disabled || !props.columns.length" @click="emit('add')"><Plus class="mr-1 h-3.5 w-3.5" />{{ t("grid.filterBuilderAddRule") }}</Button>
<div class="flex gap-2">
<Button variant="ghost" size="sm" class="h-8 px-2 text-xs" @click="emit('reset')">{{ t("grid.resetFilterBuilder") }}</Button
><Button size="sm" class="h-8 px-3 text-xs" :disabled="props.disabled" @click="emit('apply')">{{ t("grid.applyFilter") }}</Button>

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onUnmounted, ref } from "vue";
import { Filter, Plus, X } from "@lucide/vue";
import { Filter, Trash2 } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
@ -134,7 +134,7 @@ onUnmounted(onResizeEnd);
<PopoverContent align="start" class="w-[480px] max-w-[calc(100vw-24px)] gap-3 p-3">
<div class="flex items-center justify-between gap-3">
<div class="text-xs font-medium text-foreground">{{ t("grid.filter") }}</div>
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" @click="emit('addRule')"><Plus class="mr-1 h-3.5 w-3.5" />{{ t("grid.filterBuilderAddRule") }}</Button>
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" @click="emit('clearFilters')"><Trash2 class="mr-1 h-3.5 w-3.5" />{{ t("grid.clearFilter") }}</Button>
</div>
<div v-if="hasLocalColumnFilters" class="space-y-2 rounded-md border border-primary/20 bg-primary/5 px-2.5 py-2">

View File

@ -114,8 +114,14 @@ function detail(patch: Partial<DataGridCellDetail> = {}): DataGridCellDetail {
};
}
function localDateKey() {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
}
beforeEach(() => {
vi.clearAllMocks();
localStorage.removeItem("dbx-filter-builder-value-shortcut-hint-days");
});
describe("DataGridSearchBar", () => {
@ -461,29 +467,108 @@ describe("DataGridFilterBuilder", () => {
expect(columnSelects[1].props.open).toBe(true);
});
it("adds a rule instead of applying when shift-enter is pressed in a value editor", () => {
it("shows the value editor shortcut hint from the second rule twice per day for up to three days and adds a rule on shift-enter", async () => {
const onAdd = vi.fn();
const onApply = vi.fn();
const mounted = mountComponent(DataGridFilterBuilder, {
rules: [{ id: "r1", columnName: "id", mode: "equals", rawValue: "1", rawEndValue: "", conjunction: "AND" }],
columns: ["id"],
filteredColumns: ["id"],
modeOptions: [{ value: "equals", labelKey: "equals" }],
columnSearch: "",
onAdd,
onApply,
});
const valueEditor = findOne(mounted.root, (node) => node.props["data-filter-value-editor"] === "");
const mountFilterBuilder = () =>
mountComponent(DataGridFilterBuilder, {
rules: [
{ id: "r1", columnName: "id", mode: "equals", rawValue: "1", rawEndValue: "", conjunction: "AND" },
{ id: "r2", columnName: "name", mode: "equals", rawValue: "n", rawEndValue: "", conjunction: "AND" },
],
columns: ["id"],
filteredColumns: ["id"],
modeOptions: [{ value: "equals", labelKey: "equals" }],
columnSearch: "",
onAdd,
onApply,
});
const mounted = mountFilterBuilder();
const valueEditors = findAll(mounted.root, (node) => node.props["data-filter-value-editor"] === "");
const valueEditor = valueEditors[0];
const secondValueEditor = valueEditors[1];
const shiftEnter = dispatch(valueEditor, "keydown", { key: "Enter", shiftKey: true, repeat: false });
expect(hostText(mounted.root)).not.toContain("grid.filterBuilderValueShortcutHint");
dispatch(valueEditor, "focus");
await nextTick();
expect(hostText(mounted.root)).not.toContain("grid.filterBuilderValueShortcutHint");
dispatch(secondValueEditor, "focus");
await nextTick();
expect(hostText(mounted.root)).toContain("grid.filterBuilderValueShortcutHint");
dispatch(secondValueEditor, "blur");
await nextTick();
expect(hostText(mounted.root)).not.toContain("grid.filterBuilderValueShortcutHint");
expect(JSON.parse(localStorage.getItem("dbx-filter-builder-value-shortcut-hint-days") ?? "[]")).toEqual([{ date: localDateKey(), count: 1 }]);
dispatch(secondValueEditor, "focus");
await nextTick();
expect(hostText(mounted.root)).toContain("grid.filterBuilderValueShortcutHint");
expect(JSON.parse(localStorage.getItem("dbx-filter-builder-value-shortcut-hint-days") ?? "[]")).toEqual([{ date: localDateKey(), count: 2 }]);
dispatch(secondValueEditor, "blur");
dispatch(secondValueEditor, "focus");
await nextTick();
expect(hostText(mounted.root)).not.toContain("grid.filterBuilderValueShortcutHint");
localStorage.setItem(
"dbx-filter-builder-value-shortcut-hint-days",
JSON.stringify([
{ date: "2026-01-01", count: 2 },
{ date: "2026-01-02", count: 2 },
]),
);
const thirdDayMounted = mountFilterBuilder();
const thirdDaySecondValueEditor = findAll(thirdDayMounted.root, (node) => node.props["data-filter-value-editor"] === "")[1];
dispatch(thirdDaySecondValueEditor, "focus");
await nextTick();
expect(hostText(thirdDayMounted.root)).toContain("grid.filterBuilderValueShortcutHint");
expect(JSON.parse(localStorage.getItem("dbx-filter-builder-value-shortcut-hint-days") ?? "[]")).toHaveLength(3);
localStorage.setItem(
"dbx-filter-builder-value-shortcut-hint-days",
JSON.stringify([
{ date: "2026-01-01", count: 2 },
{ date: "2026-01-02", count: 2 },
{ date: "2026-01-03", count: 2 },
]),
);
const exhaustedMounted = mountFilterBuilder();
const exhaustedSecondValueEditor = findAll(exhaustedMounted.root, (node) => node.props["data-filter-value-editor"] === "")[1];
dispatch(exhaustedSecondValueEditor, "focus");
await nextTick();
expect(hostText(exhaustedMounted.root)).not.toContain("grid.filterBuilderValueShortcutHint");
const shiftEnter = dispatch(secondValueEditor, "keydown", { key: "Enter", shiftKey: true, repeat: false });
expect(shiftEnter.defaultPrevented).toBe(true);
expect(shiftEnter.propagationStopped).toBe(true);
expect(onAdd).toHaveBeenCalledOnce();
expect(onApply).not.toHaveBeenCalled();
dispatch(valueEditor, "keydown", { key: "Enter", shiftKey: false });
dispatch(secondValueEditor, "keydown", { key: "Enter", shiftKey: false });
expect(onApply).toHaveBeenCalledOnce();
});
it("does not show the value editor shortcut hint for list value editors", async () => {
const mounted = mountComponent(DataGridFilterBuilder, {
rules: [
{ id: "r1", columnName: "id", mode: "equals", rawValue: "1", rawEndValue: "", conjunction: "AND" },
{ id: "r2", columnName: "name", mode: "in", rawValue: "n", rawEndValue: "", conjunction: "AND" },
],
columns: ["id"],
filteredColumns: ["id"],
modeOptions: [
{ value: "equals", labelKey: "equals" },
{ value: "in", labelKey: "in" },
],
columnSearch: "",
});
dispatch(
findOne(mounted.root, (node) => node.type === "textarea"),
"focus",
);
await nextTick();
expect(hostText(mounted.root)).not.toContain("grid.filterBuilderValueShortcutHint");
});
});
describe("DataGridQueryControls", () => {
@ -518,6 +603,7 @@ describe("DataGridQueryControls", () => {
});
it("keeps filter actions available in the popover", () => {
const addRule = vi.fn();
const clearFilters = vi.fn();
const applyFilters = vi.fn();
const resetFilters = vi.fn();
@ -543,6 +629,7 @@ describe("DataGridQueryControls", () => {
applyWhere: vi.fn(),
applyOrderBy: vi.fn(),
clearOrderBy: vi.fn(),
onAddRule: addRule,
onClearFilters: clearFilters,
onApplyFilters: applyFilters,
onResetFilters: resetFilters,
@ -552,6 +639,10 @@ describe("DataGridQueryControls", () => {
findOne(mounted.root, (node) => node.type === "button" && hostText(node) === "grid.clearFilter"),
"click",
);
dispatch(
findOne(mounted.root, (node) => node.type === "button" && hostText(node) === "grid.filterBuilderAddRule"),
"click",
);
dispatch(
findOne(mounted.root, (node) => node.type === "button" && hostText(node) === "grid.resetFilterBuilder"),
"click",
@ -566,6 +657,7 @@ describe("DataGridQueryControls", () => {
const whereButtons = findAll(whereControl!, (node) => node.type === "button");
dispatch(whereButtons[whereButtons.length - 1], "click");
expect(addRule).toHaveBeenCalledOnce();
expect(clearFilters).toHaveBeenCalledTimes(2);
expect(resetFilters).toHaveBeenCalledOnce();
expect(applyFilters).toHaveBeenCalledOnce();

View File

@ -1155,6 +1155,7 @@ export default {
filterBuilderSearchColumns: "Search columns...",
filterBuilderNoMatchingColumns: "No matching columns",
filterBuilderValue: "Value",
filterBuilderValueShortcutHint: "Shift+Enter can add a rule",
filterBuilderValues: "Values (comma or newline separated)",
filterBuilderRangeStart: "Start value",
filterBuilderRangeEnd: "End value",

View File

@ -1051,6 +1051,7 @@ export default withEnglishFallback({
filterBuilderAddRule: "Agregar regla",
filterBuilderColumn: "Columna",
filterBuilderValue: "Valor",
filterBuilderValueShortcutHint: "Shift+Enter puede agregar una regla",
filterBuilderValues: "Valores (separados por comas o saltos de línea)",
filterBuilderRangeStart: "Valor inicial",
filterBuilderRangeEnd: "Valor final",

View File

@ -1049,6 +1049,7 @@ export default withEnglishFallback({
filterBuilderAddRule: "Aggiungi regola",
filterBuilderColumn: "Colonna",
filterBuilderValue: "Valore",
filterBuilderValueShortcutHint: "Shift+Enter può aggiungere una regola",
filterBuilderValues: "Valori (separati da virgole o nuove righe)",
filterBuilderRangeStart: "Valore iniziale",
filterBuilderRangeEnd: "Valore finale",

View File

@ -1067,6 +1067,7 @@ export default withEnglishFallback({
filterBuilderAddRule: "ルールを追加",
filterBuilderColumn: "列",
filterBuilderValue: "値",
filterBuilderValueShortcutHint: "Shift+Enterで条件を追加できます",
filterBuilderValues: "値(カンマまたは改行で区切る)",
filterBuilderRangeStart: "開始値",
filterBuilderRangeEnd: "終了値",

View File

@ -1138,6 +1138,7 @@ export default withEnglishFallback({
filterBuilderSearchColumns: "컬럼 검색...",
filterBuilderNoMatchingColumns: "일치하는 컬럼이 없습니다",
filterBuilderValue: "값",
filterBuilderValueShortcutHint: "Shift+Enter로 조건을 추가할 수 있습니다",
filterBuilderValues: "값 (쉼표 또는 줄바꿈으로 구분)",
filterBuilderRangeStart: "시작 값",
filterBuilderRangeEnd: "끝 값",

View File

@ -1051,6 +1051,7 @@ export default withEnglishFallback({
filterBuilderAddRule: "Adicionar regra",
filterBuilderColumn: "Coluna",
filterBuilderValue: "Valor",
filterBuilderValueShortcutHint: "Shift+Enter pode adicionar uma regra",
filterBuilderValues: "Valores (separados por vírgulas ou quebras de linha)",
filterBuilderRangeStart: "Valor inicial",
filterBuilderRangeEnd: "Valor final",

View File

@ -1156,6 +1156,7 @@ export default withEnglishFallback({
filterBuilderSearchColumns: "搜索字段...",
filterBuilderNoMatchingColumns: "没有匹配的字段",
filterBuilderValue: "值",
filterBuilderValueShortcutHint: "Shift+Enter 可新增条件",
filterBuilderValues: "多个值(用逗号或换行分隔)",
filterBuilderRangeStart: "起始值",
filterBuilderRangeEnd: "结束值",

View File

@ -1050,6 +1050,7 @@ export default withEnglishFallback({
filterBuilderAddRule: "新增條件",
filterBuilderColumn: "欄位",
filterBuilderValue: "值",
filterBuilderValueShortcutHint: "Shift+Enter 可新增條件",
filterBuilderValues: "多個值(以逗號或換行分隔)",
filterBuilderRangeStart: "起始值",
filterBuilderRangeEnd: "結束值",