feat(history): add execution time range filter
This commit is contained in:
parent
0720a86d25
commit
d1fb9b8445
|
|
@ -2,15 +2,17 @@
|
|||
import { ref, computed, onMounted } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useSqlHighlighter } from "@/composables/useSqlHighlighter";
|
||||
import { Copy, Database, RotateCcw, Search, Sparkles, Trash2, X } from "@lucide/vue";
|
||||
import { CalendarClock, Copy, Database, RotateCcw, Search, Sparkles, Trash2, X } from "@lucide/vue";
|
||||
import { RecycleScroller } from "vue-virtual-scroller";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
|
||||
import { useHistoryStore } from "@/stores/historyStore";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { resolveHistoryActivityKind } from "@/lib/historyActivityKind";
|
||||
import { canRollbackHistoryEntry } from "@/lib/historyAiAnalysis";
|
||||
import { hasHistoryDateRange, historyDateRangeIsValid, historyEntryMatchesDateRange, type HistoryDateRange } from "@/lib/historyTimeRange";
|
||||
import { HISTORY_ROW_HEIGHT, HISTORY_SCROLL_BUFFER, shouldVirtualizeHistory } from "@/lib/historyVirtualList";
|
||||
import type { HistoryEntry } from "@/lib/api";
|
||||
import { copyToClipboard } from "@/lib/clipboard";
|
||||
|
|
@ -31,6 +33,11 @@ type HistoryFilter = "all" | "query" | "data_change" | "schema_change" | "failed
|
|||
|
||||
const searchText = ref("");
|
||||
const activeFilter = ref<HistoryFilter>("all");
|
||||
const dateRange = ref<HistoryDateRange>({ startDate: "", endDate: "" });
|
||||
const dateRangeDraft = ref<HistoryDateRange>({ startDate: "", endDate: "" });
|
||||
const dateRangeOpen = ref(false);
|
||||
const startDateInputRef = ref<HTMLInputElement | null>(null);
|
||||
const endDateInputRef = ref<HTMLInputElement | null>(null);
|
||||
const selectedEntry = ref<HistoryEntry | null>(null);
|
||||
const isRollingBack = ref(false);
|
||||
const showDeleteConfirm = ref(false);
|
||||
|
|
@ -38,6 +45,14 @@ const showClearConfirm = ref(false);
|
|||
const deleteTargetId = ref<string | null>(null);
|
||||
|
||||
const filters: HistoryFilter[] = ["all", "query", "data_change", "schema_change", "failed"];
|
||||
const hasDateFilter = computed(() => hasHistoryDateRange(dateRange.value));
|
||||
const dateRangeDraftValid = computed(() => historyDateRangeIsValid(dateRangeDraft.value));
|
||||
const dateRangeSummary = computed(() => {
|
||||
if (!hasDateFilter.value) return "";
|
||||
const start = dateRange.value.startDate || t("history.dateRange.unboundedStart");
|
||||
const end = dateRange.value.endDate || t("history.dateRange.unboundedEnd");
|
||||
return `${start} -> ${end}`;
|
||||
});
|
||||
|
||||
const filtered = computed(() => {
|
||||
const q = searchText.value.toLowerCase();
|
||||
|
|
@ -46,10 +61,12 @@ const filtered = computed(() => {
|
|||
if (activeFilter.value !== "all" && activeFilter.value !== "failed" && activityKind(entry) !== activeFilter.value) {
|
||||
return false;
|
||||
}
|
||||
if (!historyEntryMatchesDateRange(entry.executed_at, dateRange.value)) return false;
|
||||
if (!q) return true;
|
||||
return [entry.sql, entry.connection_name, entry.database, entry.operation, entry.target].filter(Boolean).some((value) => String(value).toLowerCase().includes(q));
|
||||
});
|
||||
});
|
||||
const emptyMessage = computed(() => (store.entries.length > 0 ? t("history.emptyFilteredRecent") : t("history.empty")));
|
||||
|
||||
function activityKind(entry: HistoryEntry) {
|
||||
return resolveHistoryActivityKind(entry);
|
||||
|
|
@ -122,6 +139,46 @@ function filterLabel(filter: HistoryFilter) {
|
|||
return t(`history.filters.${filter}`);
|
||||
}
|
||||
|
||||
function openDateRangeFilter() {
|
||||
dateRangeDraft.value = { ...dateRange.value };
|
||||
}
|
||||
|
||||
function setDateRangeOpen(value: boolean) {
|
||||
if (value) openDateRangeFilter();
|
||||
dateRangeOpen.value = value;
|
||||
}
|
||||
|
||||
function applyDateRangeFilter() {
|
||||
if (!dateRangeDraftValid.value) return;
|
||||
dateRange.value = { ...dateRangeDraft.value };
|
||||
dateRangeOpen.value = false;
|
||||
}
|
||||
|
||||
function clearDateRangeFilter() {
|
||||
dateRange.value = { startDate: "", endDate: "" };
|
||||
dateRangeDraft.value = { startDate: "", endDate: "" };
|
||||
}
|
||||
|
||||
function cancelDateRangeFilter() {
|
||||
dateRangeDraft.value = { ...dateRange.value };
|
||||
dateRangeOpen.value = false;
|
||||
}
|
||||
|
||||
function dateFieldLabel(value: string) {
|
||||
return value ? value.replaceAll("-", "/") : "yyyy/mm/dd";
|
||||
}
|
||||
|
||||
function openDatePicker(input: HTMLInputElement | null) {
|
||||
if (!input) return;
|
||||
input.focus();
|
||||
const pickerInput = input as HTMLInputElement & { showPicker?: () => void };
|
||||
if (pickerInput.showPicker) {
|
||||
pickerInput.showPicker();
|
||||
} else {
|
||||
input.click();
|
||||
}
|
||||
}
|
||||
|
||||
function kindLabel(entry: HistoryEntry) {
|
||||
return t(`history.kinds.${activityKind(entry)}`);
|
||||
}
|
||||
|
|
@ -220,6 +277,72 @@ onMounted(() => store.load());
|
|||
<div class="relative flex items-center px-2 py-1">
|
||||
<Search class="absolute left-3 w-3 h-3 text-muted-foreground pointer-events-none" />
|
||||
<input v-model="searchText" autocapitalize="off" autocorrect="off" spellcheck="false" class="flex-1 h-5 text-xs bg-transparent border rounded pl-5 pr-1 outline-none placeholder:text-muted-foreground" :placeholder="t('history.search')" />
|
||||
<Popover :open="dateRangeOpen" @update:open="setDateRangeOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="ml-1 flex h-5 w-5 shrink-0 items-center justify-center rounded border transition-colors"
|
||||
:class="hasDateFilter ? 'border-primary/40 bg-primary/10 text-primary hover:bg-primary/15' : 'border-border/70 text-muted-foreground hover:bg-accent hover:text-foreground'"
|
||||
:title="t('history.dateRange.title')"
|
||||
>
|
||||
<CalendarClock class="h-3 w-3" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" class="w-auto max-w-[calc(100vw-24px)] gap-3 p-3" @click.stop @keydown.stop>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="text-xs font-medium text-foreground">{{ t("history.dateRange.title") }}</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-end gap-2">
|
||||
<label class="grid min-w-0 gap-1 text-[11px] text-muted-foreground">
|
||||
<span>{{ t("history.dateRange.start") }}</span>
|
||||
<input ref="startDateInputRef" v-model="dateRangeDraft.startDate" type="date" class="sr-only" tabindex="-1" aria-hidden="true" />
|
||||
<button type="button" class="flex h-8 w-28 min-w-0 items-center gap-1.5 rounded-md border border-input bg-background px-2 text-left text-xs text-foreground outline-none hover:bg-muted/50 focus:border-ring focus:ring-2 focus:ring-ring/30" @click="openDatePicker(startDateInputRef)">
|
||||
<span class="min-w-0 flex-1 truncate tabular-nums" :class="{ 'text-muted-foreground': !dateRangeDraft.startDate }">
|
||||
{{ dateFieldLabel(dateRangeDraft.startDate) }}
|
||||
</span>
|
||||
<CalendarClock class="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
</label>
|
||||
<span class="pb-2 text-xs text-muted-foreground">-></span>
|
||||
<label class="grid min-w-0 gap-1 text-[11px] text-muted-foreground">
|
||||
<span>{{ t("history.dateRange.end") }}</span>
|
||||
<input ref="endDateInputRef" v-model="dateRangeDraft.endDate" type="date" class="sr-only" tabindex="-1" aria-hidden="true" />
|
||||
<button type="button" class="flex h-8 w-28 min-w-0 items-center gap-1.5 rounded-md border border-input bg-background px-2 text-left text-xs text-foreground outline-none hover:bg-muted/50 focus:border-ring focus:ring-2 focus:ring-ring/30" @click="openDatePicker(endDateInputRef)">
|
||||
<span class="min-w-0 flex-1 truncate tabular-nums" :class="{ 'text-muted-foreground': !dateRangeDraft.endDate }">
|
||||
{{ dateFieldLabel(dateRangeDraft.endDate) }}
|
||||
</span>
|
||||
<CalendarClock class="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="!dateRangeDraftValid" class="rounded-md border border-destructive/30 bg-destructive/10 px-2 py-1.5 text-xs text-destructive">
|
||||
{{ t("history.dateRange.invalid") }}
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2 pt-1">
|
||||
<Button variant="ghost" size="sm" class="h-8 px-2 text-xs" @click="clearDateRangeFilter">
|
||||
{{ t("history.dateRange.clear") }}
|
||||
</Button>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" class="h-8 px-2 text-xs" @click="cancelDateRangeFilter">
|
||||
{{ t("dangerDialog.cancel") }}
|
||||
</Button>
|
||||
<Button size="sm" class="h-8 px-3 text-xs" :disabled="!dateRangeDraftValid" @click="applyDateRangeFilter">
|
||||
{{ t("history.dateRange.apply") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<div v-if="hasDateFilter" class="flex items-center gap-1 px-2 pb-2">
|
||||
<button type="button" class="flex min-w-0 items-center gap-1 rounded border border-primary/30 bg-primary/10 px-1.5 py-0.5 text-[11px] font-medium text-primary hover:bg-primary/15" :title="t('history.dateRange.title')" @click="setDateRangeOpen(true)">
|
||||
<CalendarClock class="h-3 w-3 shrink-0" />
|
||||
<span class="shrink-0">{{ t("history.dateRange.label") }}</span>
|
||||
<span class="min-w-0 truncate tabular-nums">{{ dateRangeSummary }}</span>
|
||||
</button>
|
||||
<button type="button" class="flex h-5 w-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground" :title="t('history.dateRange.clear')" @click="clearDateRangeFilter">
|
||||
<X class="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -253,7 +376,7 @@ onMounted(() => store.load());
|
|||
</RecycleScroller>
|
||||
|
||||
<div v-if="filtered.length === 0" class="px-3 py-8 text-center text-muted-foreground text-xs">
|
||||
{{ t("history.empty") }}
|
||||
{{ emptyMessage }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1670,6 +1670,7 @@ export default {
|
|||
title: "History",
|
||||
search: "Search history...",
|
||||
empty: "No history yet",
|
||||
emptyFilteredRecent: "No matching records in the latest 200 execution history entries",
|
||||
failed: "Failed",
|
||||
success: "Succeeded",
|
||||
restore: "Restore to editor",
|
||||
|
|
@ -1696,6 +1697,17 @@ export default {
|
|||
schema_change: "Schema changes",
|
||||
failed: "Failed",
|
||||
},
|
||||
dateRange: {
|
||||
title: "Time range",
|
||||
label: "Execution time:",
|
||||
start: "Start date",
|
||||
end: "End date",
|
||||
apply: "Apply",
|
||||
clear: "Clear",
|
||||
invalid: "Start date cannot be later than end date",
|
||||
unboundedStart: "Start",
|
||||
unboundedEnd: "End",
|
||||
},
|
||||
kinds: {
|
||||
query: "Query",
|
||||
data_change: "Data change",
|
||||
|
|
|
|||
|
|
@ -1376,6 +1376,7 @@ export default {
|
|||
title: "Historial",
|
||||
search: "Buscar en historial...",
|
||||
empty: "Sin historial aún",
|
||||
emptyFilteredRecent: "No hay registros coincidentes en las últimas 200 entradas del historial de ejecución",
|
||||
failed: "Fallida",
|
||||
success: "Exitosa",
|
||||
restore: "Restaurar en el editor",
|
||||
|
|
@ -1401,6 +1402,17 @@ export default {
|
|||
schema_change: "Cambios de esquema",
|
||||
failed: "Fallidas",
|
||||
},
|
||||
dateRange: {
|
||||
title: "Rango de tiempo",
|
||||
label: "Hora de ejecución:",
|
||||
start: "Fecha inicial",
|
||||
end: "Fecha final",
|
||||
apply: "Aplicar",
|
||||
clear: "Limpiar",
|
||||
invalid: "La fecha inicial no puede ser posterior a la fecha final",
|
||||
unboundedStart: "Inicio",
|
||||
unboundedEnd: "Fin",
|
||||
},
|
||||
kinds: {
|
||||
query: "Consulta",
|
||||
data_change: "Cambio de datos",
|
||||
|
|
|
|||
|
|
@ -1496,6 +1496,7 @@ export default {
|
|||
title: "Cronologia",
|
||||
search: "Cerca nella cronologia...",
|
||||
empty: "Ancora nessuna cronologia",
|
||||
emptyFilteredRecent: "Nessun record corrispondente nelle ultime 200 voci della cronologia di esecuzione",
|
||||
failed: "Non riuscita",
|
||||
success: "Riuscita",
|
||||
restore: "Ripristina nell'editor",
|
||||
|
|
@ -1522,6 +1523,17 @@ export default {
|
|||
schema_change: "Modifiche schema",
|
||||
failed: "Non riuscite",
|
||||
},
|
||||
dateRange: {
|
||||
title: "Intervallo di tempo",
|
||||
label: "Ora di esecuzione:",
|
||||
start: "Data iniziale",
|
||||
end: "Data finale",
|
||||
apply: "Applica",
|
||||
clear: "Cancella",
|
||||
invalid: "La data iniziale non può essere successiva alla data finale",
|
||||
unboundedStart: "Inizio",
|
||||
unboundedEnd: "Fine",
|
||||
},
|
||||
kinds: {
|
||||
query: "Query",
|
||||
data_change: "Modifica dati",
|
||||
|
|
|
|||
|
|
@ -1628,6 +1628,7 @@ export default {
|
|||
title: "履歴",
|
||||
search: "履歴を検索...",
|
||||
empty: "まだ履歴がありません",
|
||||
emptyFilteredRecent: "最近 200 件の実行履歴に一致する記録はありません",
|
||||
failed: "失敗",
|
||||
success: "成功",
|
||||
restore: "エディタに復元",
|
||||
|
|
@ -1654,6 +1655,17 @@ export default {
|
|||
schema_change: "スキーマ変更",
|
||||
failed: "失敗",
|
||||
},
|
||||
dateRange: {
|
||||
title: "時間範囲",
|
||||
label: "実行時間:",
|
||||
start: "開始日",
|
||||
end: "終了日",
|
||||
apply: "適用",
|
||||
clear: "クリア",
|
||||
invalid: "開始日は終了日より後にできません",
|
||||
unboundedStart: "開始",
|
||||
unboundedEnd: "終了",
|
||||
},
|
||||
kinds: {
|
||||
query: "クエリ",
|
||||
data_change: "データ変更",
|
||||
|
|
|
|||
|
|
@ -1507,6 +1507,7 @@ export default {
|
|||
title: "Histórico",
|
||||
search: "Pesquisar histórico...",
|
||||
empty: "Nenhum histórico ainda",
|
||||
emptyFilteredRecent: "Nenhum registro correspondente nas 200 entradas mais recentes do histórico de execução",
|
||||
failed: "Falhou",
|
||||
success: "Concluído",
|
||||
restore: "Restaurar no editor",
|
||||
|
|
@ -1533,6 +1534,17 @@ export default {
|
|||
schema_change: "Alterações de schema",
|
||||
failed: "Falhou",
|
||||
},
|
||||
dateRange: {
|
||||
title: "Intervalo de tempo",
|
||||
label: "Hora de execução:",
|
||||
start: "Data inicial",
|
||||
end: "Data final",
|
||||
apply: "Aplicar",
|
||||
clear: "Limpar",
|
||||
invalid: "A data inicial não pode ser posterior à data final",
|
||||
unboundedStart: "Início",
|
||||
unboundedEnd: "Fim",
|
||||
},
|
||||
kinds: {
|
||||
query: "Consulta",
|
||||
data_change: "Alteração de dados",
|
||||
|
|
|
|||
|
|
@ -1669,6 +1669,7 @@ export default {
|
|||
title: "历史",
|
||||
search: "搜索历史...",
|
||||
empty: "暂无历史记录",
|
||||
emptyFilteredRecent: "最近 200 条执行历史中没有匹配记录",
|
||||
failed: "失败",
|
||||
success: "成功",
|
||||
restore: "恢复到编辑器",
|
||||
|
|
@ -1695,6 +1696,17 @@ export default {
|
|||
schema_change: "结构变更",
|
||||
failed: "失败",
|
||||
},
|
||||
dateRange: {
|
||||
title: "时间范围",
|
||||
label: "执行时间:",
|
||||
start: "开始日期",
|
||||
end: "结束日期",
|
||||
apply: "应用",
|
||||
clear: "清除",
|
||||
invalid: "开始日期不能晚于结束日期",
|
||||
unboundedStart: "开始",
|
||||
unboundedEnd: "结束",
|
||||
},
|
||||
kinds: {
|
||||
query: "查询",
|
||||
data_change: "数据变更",
|
||||
|
|
|
|||
|
|
@ -1498,6 +1498,7 @@ export default {
|
|||
title: "歷史",
|
||||
search: "搜尋歷史……",
|
||||
empty: "暫無歷史記錄",
|
||||
emptyFilteredRecent: "最近 200 條執行歷史中沒有相符記錄",
|
||||
failed: "失敗",
|
||||
success: "成功",
|
||||
restore: "復原到編輯器",
|
||||
|
|
@ -1524,6 +1525,17 @@ export default {
|
|||
schema_change: "結構變更",
|
||||
failed: "失敗",
|
||||
},
|
||||
dateRange: {
|
||||
title: "時間範圍",
|
||||
label: "執行時間:",
|
||||
start: "開始日期",
|
||||
end: "結束日期",
|
||||
apply: "套用",
|
||||
clear: "清除",
|
||||
invalid: "開始日期不能晚於結束日期",
|
||||
unboundedStart: "開始",
|
||||
unboundedEnd: "結束",
|
||||
},
|
||||
kinds: {
|
||||
query: "查詢",
|
||||
data_change: "資料變更",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,51 @@
|
|||
export interface HistoryDateRange {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
}
|
||||
|
||||
export function hasHistoryDateRange(range: HistoryDateRange): boolean {
|
||||
return !!range.startDate || !!range.endDate;
|
||||
}
|
||||
|
||||
export function historyEntryMatchesDateRange(executedAt: string, range: HistoryDateRange): boolean {
|
||||
if (!hasHistoryDateRange(range)) return true;
|
||||
|
||||
const executedTime = new Date(executedAt).getTime();
|
||||
if (Number.isNaN(executedTime)) return false;
|
||||
|
||||
const startTime = startOfLocalDate(range.startDate);
|
||||
const endTime = endOfLocalDate(range.endDate);
|
||||
if (startTime !== null && executedTime < startTime) return false;
|
||||
if (endTime !== null && executedTime > endTime) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function historyDateRangeIsValid(range: HistoryDateRange): boolean {
|
||||
const startTime = startOfLocalDate(range.startDate);
|
||||
const endTime = endOfLocalDate(range.endDate);
|
||||
return startTime === null || endTime === null || startTime <= endTime;
|
||||
}
|
||||
|
||||
function startOfLocalDate(value: string): number | null {
|
||||
const parts = parseDateInput(value);
|
||||
if (!parts) return null;
|
||||
return new Date(parts.year, parts.month - 1, parts.day, 0, 0, 0, 0).getTime();
|
||||
}
|
||||
|
||||
function endOfLocalDate(value: string): number | null {
|
||||
const parts = parseDateInput(value);
|
||||
if (!parts) return null;
|
||||
return new Date(parts.year, parts.month - 1, parts.day, 23, 59, 59, 999).getTime();
|
||||
}
|
||||
|
||||
function parseDateInput(value: string): { year: number; month: number; day: number } | null {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value.trim());
|
||||
if (!match) return null;
|
||||
|
||||
const year = Number(match[1]);
|
||||
const month = Number(match[2]);
|
||||
const day = Number(match[3]);
|
||||
const date = new Date(year, month - 1, day);
|
||||
if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) return null;
|
||||
return { year, month, day };
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import { strict as assert } from "node:assert";
|
||||
import { test } from "vitest";
|
||||
import { historyDateRangeIsValid, historyEntryMatchesDateRange } from "../../apps/desktop/src/lib/historyTimeRange.ts";
|
||||
|
||||
test("matches entries on the selected start and end dates inclusively", () => {
|
||||
const range = { startDate: "2026-06-01", endDate: "2026-06-18" };
|
||||
|
||||
assert.equal(historyEntryMatchesDateRange("2026-06-01T00:00:00.000", range), true);
|
||||
assert.equal(historyEntryMatchesDateRange("2026-06-18T23:59:59.999", range), true);
|
||||
assert.equal(historyEntryMatchesDateRange("2026-05-31T23:59:59.999", range), false);
|
||||
assert.equal(historyEntryMatchesDateRange("2026-06-19T00:00:00.000", range), false);
|
||||
});
|
||||
|
||||
test("supports open-ended date ranges", () => {
|
||||
assert.equal(historyEntryMatchesDateRange("2026-06-18T12:00:00.000", { startDate: "2026-06-18", endDate: "" }), true);
|
||||
assert.equal(historyEntryMatchesDateRange("2026-06-17T23:59:59.999", { startDate: "2026-06-18", endDate: "" }), false);
|
||||
assert.equal(historyEntryMatchesDateRange("2026-06-18T23:59:59.999", { startDate: "", endDate: "2026-06-18" }), true);
|
||||
assert.equal(historyEntryMatchesDateRange("2026-06-19T00:00:00.000", { startDate: "", endDate: "2026-06-18" }), false);
|
||||
});
|
||||
|
||||
test("validates date range ordering", () => {
|
||||
assert.equal(historyDateRangeIsValid({ startDate: "2026-06-01", endDate: "2026-06-18" }), true);
|
||||
assert.equal(historyDateRangeIsValid({ startDate: "2026-06-18", endDate: "2026-06-01" }), false);
|
||||
assert.equal(historyDateRangeIsValid({ startDate: "2026-06-18", endDate: "" }), true);
|
||||
});
|
||||
Loading…
Reference in New Issue