feat: add client-side Level/Event filters to Log Entries
The backend rejects `level`/`event` as x:Log/query filter conditions (unsupportedFilter), even though both are already returned per row. Since a real fix requires backend changes outside this repo, the two filters are injected into the schema client-side (clientOnly flag) and applied entirely in the browser: excluded from the JMAP filter sent to the server, and used to narrow an eagerly-fetched, locally-paginated result set instead. Also makes large enum filters (Event has 634 values) render as a searchable Combobox instead of a plain Select, generically for any list with more than 15 enum options. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
da2c99990f
commit
21b183327c
@@ -25,6 +25,7 @@ import {
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select';
|
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select';
|
||||||
|
import { Combobox } from '@/components/ui/combobox';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { formatSize as fmtSize, formatDuration as fmtDuration } from '@/lib/durationFormat';
|
import { formatSize as fmtSize, formatDuration as fmtDuration } from '@/lib/durationFormat';
|
||||||
@@ -60,7 +61,14 @@ import { useAuthStore } from '@/stores/authStore';
|
|||||||
import { useAccountStore } from '@/stores/accountStore';
|
import { useAccountStore } from '@/stores/accountStore';
|
||||||
import { useCacheStore } from '@/stores/cacheStore';
|
import { useCacheStore } from '@/stores/cacheStore';
|
||||||
import { resolveObject, resolveSchema, resolveList, getDisplayProperty } from '@/lib/schemaResolver';
|
import { resolveObject, resolveSchema, resolveList, getDisplayProperty } from '@/lib/schemaResolver';
|
||||||
import { jmapGetBatched, jmapQueryAll, jmapQueryAndGet, jmapQueryAllAndGet, jmapSet, getAccountId } from '@/services/jmap/client';
|
import {
|
||||||
|
jmapGetBatched,
|
||||||
|
jmapQueryAll,
|
||||||
|
jmapQueryAndGet,
|
||||||
|
jmapQueryAllAndGet,
|
||||||
|
jmapSet,
|
||||||
|
getAccountId,
|
||||||
|
} from '@/services/jmap/client';
|
||||||
|
|
||||||
import type { Schema, Field, MassAction, ItemAction, Filter as FilterDef } from '@/types/schema';
|
import type { Schema, Field, MassAction, ItemAction, Filter as FilterDef } from '@/types/schema';
|
||||||
import type { JmapSetResponse, JmapSetError } from '@/types/jmap';
|
import type { JmapSetResponse, JmapSetError } from '@/types/jmap';
|
||||||
@@ -68,6 +76,13 @@ import type { ResolvedSchema } from '@/lib/schemaResolver';
|
|||||||
|
|
||||||
const PAGE_SIZE = 25;
|
const PAGE_SIZE = 25;
|
||||||
const MAX_REPORTED_ERRORS = 3;
|
const MAX_REPORTED_ERRORS = 3;
|
||||||
|
// Combobox threshold: plain <Select> is fine for a handful of options, but
|
||||||
|
// unusable (no search) once an enum has dozens of entries.
|
||||||
|
const ENUM_COMBOBOX_THRESHOLD = 15;
|
||||||
|
|
||||||
|
function isClientOnlyFilter(f: FilterDef): boolean {
|
||||||
|
return f.type === 'enum' && f.clientOnly === true;
|
||||||
|
}
|
||||||
|
|
||||||
function parseSetResponse(raw: [string, Record<string, unknown>, string][]): JmapSetResponse | null {
|
function parseSetResponse(raw: [string, Record<string, unknown>, string][]): JmapSetResponse | null {
|
||||||
const entry = raw.find(([name]) => name.endsWith('/set'));
|
const entry = raw.find(([name]) => name.endsWith('/set'));
|
||||||
@@ -424,6 +439,24 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
|
|
||||||
const [sort, setSort] = useState<SortState | null>(readUrlSort);
|
const [sort, setSort] = useState<SortState | null>(readUrlSort);
|
||||||
|
|
||||||
|
// Filters marked `clientOnly` (currently Level/Event on the Logs list) are
|
||||||
|
// not supported by the server's query engine, so they narrow an
|
||||||
|
// already-fetched result set in the browser instead of being sent as a
|
||||||
|
// JMAP filter. That switches pagination to a client-held array.
|
||||||
|
const clientFilterDefs = useMemo(
|
||||||
|
() => (resolved?.list?.filters ?? []).filter(isClientOnlyFilter),
|
||||||
|
[resolved?.list?.filters],
|
||||||
|
);
|
||||||
|
const activeClientFilters = useMemo(
|
||||||
|
() =>
|
||||||
|
clientFilterDefs
|
||||||
|
.map((f) => ({ field: f.field, value: appliedFilters[f.field] ?? '' }))
|
||||||
|
.filter((f) => f.value !== ''),
|
||||||
|
[clientFilterDefs, appliedFilters],
|
||||||
|
);
|
||||||
|
const [clientAllItems, setClientAllItems] = useState<Record<string, unknown>[] | null>(null);
|
||||||
|
const [clientPage, setClientPage] = useState(0);
|
||||||
|
|
||||||
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
|
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
|
||||||
const [activeWebApp, setActiveWebApp] = useState<Record<string, unknown> | null>(null);
|
const [activeWebApp, setActiveWebApp] = useState<Record<string, unknown> | null>(null);
|
||||||
|
|
||||||
@@ -461,6 +494,8 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
setSelectedIds(new Set());
|
setSelectedIds(new Set());
|
||||||
setSelectAllMode(false);
|
setSelectAllMode(false);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setClientAllItems(null);
|
||||||
|
setClientPage(0);
|
||||||
|
|
||||||
const initialFilters = readUrlFilters();
|
const initialFilters = readUrlFilters();
|
||||||
setFilterValues(initialFilters);
|
setFilterValues(initialFilters);
|
||||||
@@ -470,13 +505,20 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const buildFilter = useCallback((): Record<string, unknown> => {
|
const buildFilter = useCallback((): Record<string, unknown> => {
|
||||||
|
const clientFields = new Set(clientFilterDefs.map((f) => f.field));
|
||||||
|
const serverAppliedFilters: Record<string, string> = {};
|
||||||
|
for (const [key, val] of Object.entries(appliedFilters)) {
|
||||||
|
const baseKey = key.endsWith('Op') ? key.slice(0, -2) : key;
|
||||||
|
if (clientFields.has(baseKey)) continue;
|
||||||
|
serverAppliedFilters[key] = val;
|
||||||
|
}
|
||||||
return buildJmapFilter({
|
return buildJmapFilter({
|
||||||
appliedFilters,
|
appliedFilters: serverAppliedFilters,
|
||||||
filters: resolved?.list?.filters,
|
filters: resolved?.list?.filters,
|
||||||
filtersStatic: resolved?.list?.filtersStatic,
|
filtersStatic: resolved?.list?.filtersStatic,
|
||||||
isXPrefixed: objectName?.startsWith('x:') ?? false,
|
isXPrefixed: objectName?.startsWith('x:') ?? false,
|
||||||
});
|
});
|
||||||
}, [appliedFilters, resolved?.list, objectName]);
|
}, [appliedFilters, resolved?.list, objectName, clientFilterDefs]);
|
||||||
|
|
||||||
const buildSort = useCallback((): Record<string, unknown>[] | undefined => {
|
const buildSort = useCallback((): Record<string, unknown>[] | undefined => {
|
||||||
if (!sort) return undefined;
|
if (!sort) return undefined;
|
||||||
@@ -500,6 +542,28 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
const filter = buildFilter();
|
const filter = buildFilter();
|
||||||
const sortArr = buildSort();
|
const sortArr = buildSort();
|
||||||
|
|
||||||
|
if (activeClientFilters.length > 0) {
|
||||||
|
// No server-side pagination possible once a client-only filter is
|
||||||
|
// active: fetch every server-matching row up front, narrow it in
|
||||||
|
// the browser, then paginate the in-memory result locally.
|
||||||
|
const { list: fullList } = await jmapQueryAllAndGet(
|
||||||
|
obj.objectName,
|
||||||
|
accountId,
|
||||||
|
{ filter: Object.keys(filter).length > 0 ? filter : undefined, sort: sortArr },
|
||||||
|
properties,
|
||||||
|
);
|
||||||
|
const matched = fullList.filter((item) =>
|
||||||
|
activeClientFilters.every((f) => String(item[f.field] ?? '') === f.value),
|
||||||
|
);
|
||||||
|
setClientAllItems(matched);
|
||||||
|
setClientPage(0);
|
||||||
|
setTotal(matched.length);
|
||||||
|
setItems(matched.slice(0, PAGE_SIZE));
|
||||||
|
setSelectedIds(new Set());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setClientAllItems(null);
|
||||||
|
|
||||||
const queryOptions: Record<string, unknown> = {
|
const queryOptions: Record<string, unknown> = {
|
||||||
filter: Object.keys(filter).length > 0 ? filter : undefined,
|
filter: Object.keys(filter).length > 0 ? filter : undefined,
|
||||||
sort: sortArr,
|
sort: sortArr,
|
||||||
@@ -546,7 +610,7 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[resolved, schema, buildFilter, buildSort],
|
[resolved, schema, buildFilter, buildSort, isWebApplications, activeClientFilters],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -637,6 +701,13 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
}, [filterValues, sort]);
|
}, [filterValues, sort]);
|
||||||
|
|
||||||
const handleNextPage = useCallback(() => {
|
const handleNextPage = useCallback(() => {
|
||||||
|
if (clientAllItems !== null) {
|
||||||
|
const nextPage = clientPage + 1;
|
||||||
|
setClientPage(nextPage);
|
||||||
|
setItems(clientAllItems.slice(nextPage * PAGE_SIZE, nextPage * PAGE_SIZE + PAGE_SIZE));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (items.length === 0) return;
|
if (items.length === 0) return;
|
||||||
const lastItem = items[items.length - 1];
|
const lastItem = items[items.length - 1];
|
||||||
const lastId = lastItem?.id as string;
|
const lastId = lastItem?.id as string;
|
||||||
@@ -648,9 +719,16 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
}
|
}
|
||||||
setCurrentAnchor(lastId);
|
setCurrentAnchor(lastId);
|
||||||
fetchData(lastId, 1);
|
fetchData(lastId, 1);
|
||||||
}, [items, fetchData]);
|
}, [items, fetchData, clientAllItems, clientPage]);
|
||||||
|
|
||||||
const handlePrevPage = useCallback(() => {
|
const handlePrevPage = useCallback(() => {
|
||||||
|
if (clientAllItems !== null) {
|
||||||
|
const prevPage = Math.max(0, clientPage - 1);
|
||||||
|
setClientPage(prevPage);
|
||||||
|
setItems(clientAllItems.slice(prevPage * PAGE_SIZE, prevPage * PAGE_SIZE + PAGE_SIZE));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (anchorStack.length === 0) {
|
if (anchorStack.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -666,7 +744,7 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
setCurrentAnchor(prevFirstId);
|
setCurrentAnchor(prevFirstId);
|
||||||
fetchData(prevFirstId, 0);
|
fetchData(prevFirstId, 0);
|
||||||
}
|
}
|
||||||
}, [anchorStack, fetchData]);
|
}, [anchorStack, fetchData, clientAllItems, clientPage]);
|
||||||
|
|
||||||
const toggleSelectAll = useCallback(() => {
|
const toggleSelectAll = useCallback(() => {
|
||||||
if (selectedIds.size === items.length) {
|
if (selectedIds.size === items.length) {
|
||||||
@@ -730,12 +808,18 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
|
|
||||||
let targetIds: string[];
|
let targetIds: string[];
|
||||||
if (selectAllMode) {
|
if (selectAllMode) {
|
||||||
const filter = buildFilter();
|
if (clientAllItems !== null) {
|
||||||
const sortArr = buildSort();
|
// A client-only filter is active: the full server-matching set
|
||||||
targetIds = await jmapQueryAll(obj.objectName, accountId, {
|
// would include rows it excludes, so use the already-narrowed list.
|
||||||
filter: Object.keys(filter).length > 0 ? filter : undefined,
|
targetIds = clientAllItems.map((item) => item.id as string);
|
||||||
sort: sortArr,
|
} else {
|
||||||
});
|
const filter = buildFilter();
|
||||||
|
const sortArr = buildSort();
|
||||||
|
targetIds = await jmapQueryAll(obj.objectName, accountId, {
|
||||||
|
filter: Object.keys(filter).length > 0 ? filter : undefined,
|
||||||
|
sort: sortArr,
|
||||||
|
});
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
targetIds = Array.from(selectedIds);
|
targetIds = Array.from(selectedIds);
|
||||||
}
|
}
|
||||||
@@ -790,7 +874,7 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[resolved, selectedIds, selectAllMode, buildFilter, buildSort, fetchData, currentAnchor, t],
|
[resolved, selectedIds, selectAllMode, buildFilter, buildSort, fetchData, currentAnchor, t, clientAllItems],
|
||||||
);
|
);
|
||||||
|
|
||||||
const executeItemAction = useCallback(
|
const executeItemAction = useCallback(
|
||||||
@@ -916,11 +1000,11 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
const hasMassActions = effectiveMassActions.length > 0;
|
const hasMassActions = effectiveMassActions.length > 0;
|
||||||
const hasItemActions = (list.itemActions?.length ?? 0) > 0;
|
const hasItemActions = (list.itemActions?.length ?? 0) > 0;
|
||||||
|
|
||||||
const pageStart = anchorStack.length * PAGE_SIZE;
|
const pageStart = clientAllItems !== null ? clientPage * PAGE_SIZE : anchorStack.length * PAGE_SIZE;
|
||||||
const rangeStart = pageStart + 1;
|
const rangeStart = pageStart + 1;
|
||||||
const rangeEnd = pageStart + items.length;
|
const rangeEnd = pageStart + items.length;
|
||||||
const hasNextPage = total !== null && rangeEnd < total;
|
const hasNextPage = clientAllItems !== null ? rangeEnd < clientAllItems.length : total !== null && rangeEnd < total;
|
||||||
const hasPrevPage = anchorStack.length > 0;
|
const hasPrevPage = clientAllItems !== null ? clientPage > 0 : anchorStack.length > 0;
|
||||||
|
|
||||||
function renderFilter(filterDef: FilterDef): React.ReactNode {
|
function renderFilter(filterDef: FilterDef): React.ReactNode {
|
||||||
const value = filterValues[filterDef.field] ?? '';
|
const value = filterValues[filterDef.field] ?? '';
|
||||||
@@ -945,6 +1029,22 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
|
|
||||||
case 'enum': {
|
case 'enum': {
|
||||||
const enumVariants = schema!.enums[filterDef.enumName] ?? [];
|
const enumVariants = schema!.enums[filterDef.enumName] ?? [];
|
||||||
|
|
||||||
|
if (enumVariants.length > ENUM_COMBOBOX_THRESHOLD) {
|
||||||
|
return wrapper(
|
||||||
|
<Combobox
|
||||||
|
options={enumVariants.map((v) => ({ value: v.name, label: v.label }))}
|
||||||
|
value={value}
|
||||||
|
onValueChange={(v) => handleFilterChange(filterDef.field, v)}
|
||||||
|
placeholder={filterDef.label}
|
||||||
|
searchPlaceholder={t('list.comboboxSearchPlaceholder', 'Search...')}
|
||||||
|
emptyText={t('list.comboboxEmptyText', 'No matches.')}
|
||||||
|
nullable
|
||||||
|
nullLabel={t('filters.all', 'All')}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return wrapper(
|
return wrapper(
|
||||||
<Select value={value || '__all__'} onValueChange={(v) => handleFilterSelectChange(filterDef.field, v)}>
|
<Select value={value || '__all__'} onValueChange={(v) => handleFilterSelectChange(filterDef.field, v)}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
|
|||||||
@@ -289,6 +289,8 @@
|
|||||||
"bulkSuccessUpdate_one": "{{count}} item updated successfully.",
|
"bulkSuccessUpdate_one": "{{count}} item updated successfully.",
|
||||||
"bulkSuccessUpdate_other": "{{count}} items updated successfully.",
|
"bulkSuccessUpdate_other": "{{count}} items updated successfully.",
|
||||||
"clearSelection": "Clear selection",
|
"clearSelection": "Clear selection",
|
||||||
|
"comboboxEmptyText": "No matches.",
|
||||||
|
"comboboxSearchPlaceholder": "Search...",
|
||||||
"confirmDescription": "Are you sure you want to proceed with: {{action}}?",
|
"confirmDescription": "Are you sure you want to proceed with: {{action}}?",
|
||||||
"confirmTitle": "Confirm Action",
|
"confirmTitle": "Confirm Action",
|
||||||
"create": "Create {{name}}",
|
"create": "Create {{name}}",
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Schema } from '@/types/schema';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Stalwart JMAP backend rejects `level`/`event` as filter conditions on
|
||||||
|
* `x:Log/query` (`unsupportedFilter`), even though both properties are
|
||||||
|
* already returned per row. Until the backend adds real support, these two
|
||||||
|
* filters are appended client-side and applied entirely in the browser
|
||||||
|
* (see the `clientOnly` flag consumed by DynamicList) instead of being sent
|
||||||
|
* to the server.
|
||||||
|
*/
|
||||||
|
export function withClientLogFilters(schema: Schema): Schema {
|
||||||
|
const logList = schema.lists['x:Log'];
|
||||||
|
if (!logList || !schema.enums['TracingLevel'] || !schema.enums['EventType']) return schema;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...schema,
|
||||||
|
lists: {
|
||||||
|
...schema.lists,
|
||||||
|
'x:Log': {
|
||||||
|
...logList,
|
||||||
|
filters: [
|
||||||
|
...(logList.filters ?? []),
|
||||||
|
{ type: 'enum', field: 'level', enumName: 'TracingLevel', label: 'Level', clientOnly: true },
|
||||||
|
{ type: 'enum', field: 'event', enumName: 'EventType', label: 'Event', clientOnly: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import { useSchemaStore } from '@/stores/schemaStore';
|
|||||||
import { useAccountStore } from '@/stores/accountStore';
|
import { useAccountStore } from '@/stores/accountStore';
|
||||||
import { useUIStore } from '@/stores/uiStore';
|
import { useUIStore } from '@/stores/uiStore';
|
||||||
import { fetchSession, fetchSchema, fetchAccountInfo } from '@/services/jmap/client';
|
import { fetchSession, fetchSchema, fetchAccountInfo } from '@/services/jmap/client';
|
||||||
|
import { withClientLogFilters } from '@/lib/logFilters';
|
||||||
import { setLocale } from '@/i18n';
|
import { setLocale } from '@/i18n';
|
||||||
import { TopBar } from '@/components/layout/TopBar';
|
import { TopBar } from '@/components/layout/TopBar';
|
||||||
import { Sidebar } from '@/components/layout/Sidebar';
|
import { Sidebar } from '@/components/layout/Sidebar';
|
||||||
@@ -147,7 +148,7 @@ export default function AdminPanel() {
|
|||||||
|
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
setSchema(schemaData);
|
setSchema(withClientLogFilters(schemaData));
|
||||||
|
|
||||||
setAccountInfo(accountData.permissions, accountData.edition, accountData.locale);
|
setAccountInfo(accountData.permissions, accountData.edition, accountData.locale);
|
||||||
setLocale(accountData.locale);
|
setLocale(accountData.locale);
|
||||||
|
|||||||
@@ -303,6 +303,10 @@ export interface FilterEnum {
|
|||||||
field: string;
|
field: string;
|
||||||
enumName: string;
|
enumName: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
/** Applied client-side after fetch instead of sent to the server as a JMAP
|
||||||
|
* filter condition. Used for properties the backend query engine doesn't
|
||||||
|
* (yet) support filtering on, even though it returns them per row. */
|
||||||
|
clientOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FilterInteger {
|
export interface FilterInteger {
|
||||||
|
|||||||
Reference in New Issue
Block a user