Files
Stalwart-webui/src/components/lists/DynamicList.tsx
T
Steven RYDELL 0aa92c394c feat(accounts): add Usage/Quota column to Groups, show unlimited as ∞
Groups have real usedDiskQuota/quotas fields, same as Users, but the
Groups list schema doesn't expose usage as a column any more than the
Accounts list did — extend the existing account-quota-usage-column
deviation to x:Account/Group too (SCHEMA_DEVIATIONS.md updated).

The isAccountsList gate that resolved/rendered quotaUsage was hardcoded
to viewName === 'x:Account/User', so it silently no-oped for Groups.
Replaced with hasQuotaUsageColumn, derived from whether the resolved
list's own columns include the synthetic quotaUsage column — works for
any list withAccountListColumns patches, not just a hardcoded pair of
view names.

Also: "Unlimited" (no quota configured) now renders as "∞" instead of
the word, matching the size formatting style used elsewhere in the
column.
2026-08-01 20:02:10 +02:00

1783 lines
65 KiB
TypeScript

/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
ChevronDown,
ChevronUp,
MoreHorizontal,
Plus,
Check,
X,
ArrowUpDown,
Filter,
Loader2,
Lock,
Search,
RotateCcw,
RefreshCw,
CornerDownRight,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select';
import { Combobox } from '@/components/ui/combobox';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import { formatSize as fmtSize, formatDuration as fmtDuration } from '@/lib/durationFormat';
import { SizeDisplay } from '@/components/common/SizeDisplay';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ObjectPicker } from '@/components/common/ObjectPicker';
import { EnterpriseUpsell } from '@/components/common/EnterpriseUpsell';
import { toast } from '@/hooks/use-toast';
import { friendlySetError } from '@/lib/jmapErrors';
import { coerceLabel } from '@/lib/objectOptions';
import { buildJmapFilter } from '@/lib/listFilter';
import { useResetOnChange } from '@/hooks/useBufferedValue';
import { useSchemaStore } from '@/stores/schemaStore';
import { useAuthStore } from '@/stores/authStore';
import { useAccountStore } from '@/stores/accountStore';
import { useCacheStore } from '@/stores/cacheStore';
import { resolveObject, resolveSchema, resolveList, getDisplayProperty } from '@/lib/schemaResolver';
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 { JmapSetResponse, JmapSetError } from '@/types/jmap';
import type { ResolvedSchema } from '@/lib/schemaResolver';
import { isClientOnlyFilterEnum } from '@/lib/schemaDeviationTypes';
const PAGE_SIZE = 25;
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;
// Manual refresh on the Logs list is rate-limited to avoid hammering the
// server if someone leaves it clicked repeatedly.
const REFRESH_COOLDOWN_MS = 5000;
function isClientOnlyFilter(f: FilterDef): boolean {
return f.type === 'enum' && isClientOnlyFilterEnum(f);
}
function parseSetResponse(raw: [string, Record<string, unknown>, string][]): JmapSetResponse | null {
const entry = raw.find(([name]) => name.endsWith('/set'));
return entry ? (entry[1] as unknown as JmapSetResponse) : null;
}
type BulkActionType = 'delete' | 'update';
type TFn = (key: string, fallback: string, options?: Record<string, unknown>) => string;
function bulkResultSummary(
successCount: number,
errors: Record<string, JmapSetError> | null,
actionType: BulkActionType,
t: TFn,
): { title: string; description?: string; variant: 'success' | 'destructive' } | null {
const errorEntries = errors ? Object.entries(errors) : [];
const totalErrors = errorEntries.length;
if (totalErrors === 0 && successCount > 0) {
const title =
actionType === 'delete'
? successCount === 1
? t('list.bulkSuccessDelete_one', '{{count}} item deleted successfully.', { count: successCount })
: t('list.bulkSuccessDelete_other', '{{count}} items deleted successfully.', { count: successCount })
: successCount === 1
? t('list.bulkSuccessUpdate_one', '{{count}} item updated successfully.', { count: successCount })
: t('list.bulkSuccessUpdate_other', '{{count}} items updated successfully.', { count: successCount });
return {
title,
variant: 'success',
};
}
const msgCounts = new Map<string, number>();
for (const [, e] of errorEntries) {
const msg = friendlySetError(e);
msgCounts.set(msg, (msgCounts.get(msg) ?? 0) + 1);
}
const lines: string[] = [];
let accounted = 0;
for (const [msg, count] of msgCounts) {
if (lines.length >= MAX_REPORTED_ERRORS) break;
lines.push(count > 1 ? t('list.bulkErrorGrouped', '{{count}} items: {{message}}', { count, message: msg }) : msg);
accounted += count;
}
if (accounted < totalErrors) {
lines.push(t('list.bulkErrorMore', '…and {{count}} more.', { count: totalErrors - accounted }));
}
const description = lines.join('\n');
if (successCount === 0) {
const title =
actionType === 'delete'
? totalErrors === 1
? t('list.bulkFailDelete_one', 'Failed to delete {{count}} item.', { count: totalErrors })
: t('list.bulkFailDelete_other', 'Failed to delete {{count}} items.', { count: totalErrors })
: totalErrors === 1
? t('list.bulkFailUpdate_one', 'Failed to update {{count}} item.', { count: totalErrors })
: t('list.bulkFailUpdate_other', 'Failed to update {{count}} items.', { count: totalErrors });
return {
title,
description,
variant: 'destructive',
};
}
const title =
actionType === 'delete'
? t('list.bulkMixedDelete', '{{success}} deleted, {{failed}} failed.', {
success: successCount,
failed: totalErrors,
})
: t('list.bulkMixedUpdate', '{{success}} updated, {{failed}} failed.', {
success: successCount,
failed: totalErrors,
});
return {
title,
description,
variant: 'destructive',
};
}
function formatSize(bytes: unknown): string {
if (bytes == null || typeof bytes !== 'number') return '';
return fmtSize(bytes);
}
function formatDuration(ms: unknown): string {
if (ms == null || typeof ms !== 'number') return '';
return fmtDuration(ms);
}
function formatNumber(value: unknown): string {
if (value == null || typeof value !== 'number') return '';
return value.toLocaleString();
}
// SCHEMA-DEVIATION: mailbox-client-hierarchy-sort (see SCHEMA_DEVIATIONS.md)
//
// Orders mailboxes so each parent is immediately followed by its
// descendants (siblings alphabetical), and records each row's depth.
// Requires the full set (not just one page) since a mailbox's parent
// could be on a different page than the mailbox itself.
function sortMailboxesByHierarchy(items: Record<string, unknown>[]): {
items: Record<string, unknown>[];
depths: Map<string, number>;
} {
const byId = new Set(items.map((item) => item.id as string));
const childrenOf = new Map<string, Record<string, unknown>[]>();
const roots: Record<string, unknown>[] = [];
for (const item of items) {
const parentId = item.parentId as string | null | undefined;
if (parentId && byId.has(parentId)) {
const siblings = childrenOf.get(parentId) ?? [];
siblings.push(item);
childrenOf.set(parentId, siblings);
} else {
roots.push(item);
}
}
const byName = (a: Record<string, unknown>, b: Record<string, unknown>) =>
String(a.name ?? '').localeCompare(String(b.name ?? ''));
roots.sort(byName);
for (const siblings of childrenOf.values()) siblings.sort(byName);
const ordered: Record<string, unknown>[] = [];
const depths = new Map<string, number>();
function visit(item: Record<string, unknown>, depth: number) {
ordered.push(item);
depths.set(item.id as string, depth);
for (const child of childrenOf.get(item.id as string) ?? []) {
visit(child, depth + 1);
}
}
for (const root of roots) visit(root, 0);
return { items: ordered, depths };
}
function formatUserRole(item: Record<string, unknown>, schema: Schema): React.ReactNode {
// The x:Account list merges field definitions across its User/Group
// variants (see getFieldsRecord), and Group's `roles` property points to
// a different object (`x:Roles`) that overrides User's (`x:UserRoles`)
// in that merge. This list only ever shows Users, so resolve the label
// directly against x:UserRoles instead of the ambiguous merged field.
const roles = item.roles as Record<string, unknown> | undefined;
const type = roles && typeof roles['@type'] === 'string' ? roles['@type'] : undefined;
if (!type) return <span className="text-muted-foreground">-</span>;
const variantSchema = schema.schemas['x:UserRoles'];
if (variantSchema?.type === 'multiple') {
const variant = variantSchema.variants.find((v) => v.name === type);
if (variant) return <Badge variant="secondary">{variant.label}</Badge>;
}
return type;
}
function renderQuotaUsage(item: Record<string, unknown>, t: TFn): React.ReactNode {
const rawUsed = typeof item.usedDiskQuota === 'number' ? item.usedDiskQuota : 0;
const used = Number.isFinite(rawUsed) ? rawUsed : 0;
const quotas = item.quotas as Record<string, unknown> | undefined;
const rawLimit = quotas && typeof quotas.maxDiskQuota === 'number' ? quotas.maxDiskQuota : 0;
const limit = Number.isFinite(rawLimit) && rawLimit > 0 ? rawLimit : 0;
const limitLabel = limit ? formatSize(limit) : t('list.unlimitedQuota', '∞');
if (used >= 0) {
return `${formatSize(used)} / ${limitLabel}`;
}
return (
<span className="inline-flex items-center gap-1.5">
<SizeDisplay bytes={used} />
<span className="text-muted-foreground">/</span>
<span>{limitLabel}</span>
</span>
);
}
function getFieldsRecord(resolvedSchema: ResolvedSchema): Record<string, Field> {
if (resolvedSchema.type === 'single') {
return resolvedSchema.fields.properties;
}
const merged: Record<string, Field> = {};
for (const variant of resolvedSchema.variants) {
if (variant.fields) {
Object.assign(merged, variant.fields.properties);
}
}
return merged;
}
function renderCellValue(
value: unknown,
field: Field | undefined,
colName: string,
schema: Schema,
objectName: string,
getDisplayName: (objectType: string, id: string) => string | undefined,
): React.ReactNode {
if (value == null) return <span className="text-muted-foreground">-</span>;
if (colName === '@type' && typeof value === 'string') {
const objSchema = schema.schemas[objectName];
if (objSchema?.type === 'multiple') {
const variant = objSchema.variants.find((v) => v.name === value);
if (variant) {
return <Badge variant="secondary">{variant.label}</Badge>;
}
}
return String(value);
}
if (!field) return String(value);
const ft = field.type;
switch (ft.type) {
case 'string':
if ((ft.format === 'text' || ft.format === 'html') && value) {
const str = String(value);
if (str) {
return (
<pre className="whitespace-pre-wrap break-words text-xs font-mono bg-muted/50 rounded px-1.5 py-1 max-w-md">
{str}
</pre>
);
}
}
return String(value);
case 'number': {
switch (ft.format) {
case 'size':
return formatSize(value);
case 'duration':
return formatDuration(value);
default:
return formatNumber(value);
}
}
case 'utcDateTime': {
if (typeof value === 'string' || typeof value === 'number') {
try {
return new Date(value).toLocaleString();
} catch {
return String(value);
}
}
return String(value);
}
case 'boolean':
return value ? <Check className="h-4 w-4 text-green-600" /> : <X className="h-4 w-4 text-red-500" />;
case 'enum': {
const variants = schema.enums[ft.enumName];
const variant = variants?.find((v) => v.name === value);
if (variant) {
return (
<Badge
variant="secondary"
className={variant.color ? undefined : undefined}
style={variant.color ? { backgroundColor: variant.color, color: '#fff' } : undefined}
>
{variant.label}
</Badge>
);
}
return String(value);
}
case 'objectId': {
const id = String(value);
const display = getDisplayName(ft.objectName, id);
return display ?? id;
}
case 'set': {
if (value && typeof value === 'object' && !Array.isArray(value)) {
const keys = Object.keys(value as Record<string, boolean>);
if (ft.class.type === 'enum' && ft.class.enumName) {
const variants = schema.enums[ft.class.enumName];
if (variants) {
const labels = keys.map((k) => {
const variant = variants.find((v) => v.name === k);
return variant?.label ?? k;
});
return labels.join(', ');
}
}
return keys.join(', ');
}
return <span className="text-muted-foreground">-</span>;
}
case 'object': {
if (value && typeof value === 'object' && !Array.isArray(value)) {
const obj = value as Record<string, unknown>;
if ('@type' in obj && typeof obj['@type'] === 'string') {
const variantSchema = schema.schemas[ft.objectName];
if (variantSchema?.type === 'multiple') {
const variant = variantSchema.variants.find((v) => v.name === obj['@type']);
if (variant) {
return <Badge variant="secondary">{variant.label}</Badge>;
}
}
return String(obj['@type']);
}
}
return <span className="text-muted-foreground">-</span>;
}
default:
if (value && typeof value === 'object') {
return <span className="text-muted-foreground">-</span>;
}
return String(value);
}
}
interface SortState {
field: string;
ascending: boolean;
}
function readUrlFilters(): Record<string, string> {
const params = new URLSearchParams(window.location.search);
const filters: Record<string, string> = {};
params.forEach((value, key) => {
if (key.startsWith('f.')) {
filters[key.slice(2)] = value;
}
});
return filters;
}
function readUrlSort(): SortState | null {
const params = new URLSearchParams(window.location.search);
const sortParam = params.get('sort');
const sortDir = params.get('sortDir');
return sortParam ? { field: sortParam, ascending: sortDir !== 'desc' } : null;
}
interface ConfirmAction {
label: string;
onConfirm: () => void;
}
function isActiveWebApplication(item: Record<string, unknown>): boolean {
const prefixes = item.urlPrefix;
const values: string[] = [];
if (Array.isArray(prefixes)) {
values.push(...prefixes.map(String));
} else if (typeof prefixes === 'string') {
values.push(...prefixes.split(',').map((s) => s.trim()));
} else if (prefixes && typeof prefixes === 'object') {
// JMAP exposes urlPrefix as a set object such as { "/admin": true, "/account": true }
for (const [key, value] of Object.entries(prefixes)) {
if (value === true) {
values.push(key);
}
}
} else if (prefixes != null) {
values.push(String(prefixes));
}
return values.some((p) => p === '/admin' || p === '/account');
}
interface DynamicListProps {
viewName: string;
}
export function DynamicList({ viewName }: DynamicListProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const [, setSearchParams] = useSearchParams();
const schema = useSchemaStore((s) => s.schema);
const viewToSection = useSchemaStore((s) => s.viewToSection);
const hasObjectPermission = useAccountStore((s) => s.hasObjectPermission);
const edition = useAccountStore((s) => s.edition);
// Reactive, unlike the getAccountId() snapshot read inside fetchData: needed
// so switching accounts from the profile dropdown (a pure store update with
// no navigation) re-triggers the fetch effect below for account-scoped
// views (Mailboxes, Calendars, Sieve Scripts, ...) even when viewName
// itself doesn't change.
const activeAccountId = useAuthStore((s) => s.activeAccountId);
const [upsellOpen, setUpsellOpen] = useState(false);
const resolved = useMemo(() => {
if (!schema) return null;
const obj = resolveObject(schema, viewName);
if (!obj) return null;
const schem = resolveSchema(schema, obj.objectName);
if (!schem) return null;
const list = resolveList(schema, viewName, obj.objectName);
return { obj, schema: schem, list };
}, [schema, viewName]);
const objectName = resolved?.obj.objectName;
const isWebApplications = viewName === 'x:Application' || objectName === 'x:Application';
const isLogEntries = viewName === 'x:Log' || objectName === 'x:Log';
const isAccountsList = viewName === 'x:Account/User';
const isMailboxList = viewName === 'Mailbox';
// Not tied to a specific viewName: any list whose schema-driven columns
// (see withAccountListColumns, account-quota-usage-column deviation)
// include the synthetic `quotaUsage` column gets it resolved and rendered.
const hasQuotaUsageColumn = (resolved?.list?.columns ?? []).some((c) => c.name === 'quotaUsage');
const displayColumns = useMemo(() => {
const columns = resolved?.list?.columns ?? [];
if (!isWebApplications) return columns;
// For Web Applications, present Description first and Enabled second
// to match the layout of other tables such as Domains. Reordering real
// schema columns is fine, but the synthetic fallback below (when the
// schema doesn't list an Enabled column at all) is a tracked deviation.
// SCHEMA-DEVIATION: webapp-enabled-column-fallback (see SCHEMA_DEVIATIONS.md)
const ordered = ['description', 'enabled'];
const rest = columns.filter((c) => !ordered.includes(c.name));
const descriptionCol = columns.find((c) => c.name === 'description');
const enabledCol = columns.find((c) => c.name === 'enabled');
const result: Array<{ name: string; label: string }> = [];
if (descriptionCol) result.push(descriptionCol);
if (enabledCol) {
result.push(enabledCol);
} else {
result.push({ name: 'enabled', label: t('webApplications.enabled', 'Enabled') });
}
result.push(...rest);
return result;
}, [resolved?.list?.columns, isWebApplications]);
const [items, setItems] = useState<Record<string, unknown>[]>([]);
const [total, setTotal] = useState<number | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const displayNames = useCacheStore((s) => s.displayNames);
const setDisplayNames = useCacheStore((s) => s.setDisplayNames);
const getDisplayName = useCallback(
(objectType: string, id: string): string | undefined => displayNames[objectType]?.[id],
[displayNames],
);
const [anchorStack, setAnchorStack] = useState<string[]>([]);
const [currentAnchor, setCurrentAnchor] = useState<string | null>(null);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [selectAllMode, setSelectAllMode] = useState(false);
const [filtersOpen, setFiltersOpen] = useState(() => Object.keys(readUrlFilters()).length > 0);
const [filterValues, setFilterValues] = useState<Record<string, string>>(readUrlFilters);
const [appliedFilters, setAppliedFilters] = useState<Record<string, string>>(readUrlFilters);
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 [mailboxDepths, setMailboxDepths] = useState<Map<string, number>>(new Map());
const [refreshOnCooldown, setRefreshOnCooldown] = useState(false);
const refreshCooldownTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (refreshCooldownTimer.current) clearTimeout(refreshCooldownTimer.current);
};
}, []);
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
const [activeWebApp, setActiveWebApp] = useState<Record<string, unknown> | null>(null);
useEffect(() => {
if (!isWebApplications || !schema) return;
let cancelled = false;
void (async () => {
try {
const accountId = getAccountId(objectName ?? 'x:Application');
const result = await jmapQueryAllAndGet(
objectName ?? 'x:Application',
accountId,
{},
['id', 'description', 'enabled', 'urlPrefix', 'resourceUrl'],
);
if (cancelled) return;
const active = result.list.find((item) => isActiveWebApplication(item));
setActiveWebApp(active ?? null);
} catch (err) {
console.error('Failed to fetch active web application:', err);
}
})();
return () => {
cancelled = true;
};
}, [isWebApplications, schema]);
useResetOnChange(viewName, () => {
setItems([]);
setTotal(null);
setAnchorStack([]);
setCurrentAnchor(null);
setSelectedIds(new Set());
setSelectAllMode(false);
setError(null);
setClientAllItems(null);
setClientPage(0);
setMailboxDepths(new Map());
if (refreshCooldownTimer.current) clearTimeout(refreshCooldownTimer.current);
setRefreshOnCooldown(false);
const initialFilters = readUrlFilters();
setFilterValues(initialFilters);
setAppliedFilters(initialFilters);
setFiltersOpen(Object.keys(initialFilters).length > 0);
setSort(readUrlSort());
});
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({
appliedFilters: serverAppliedFilters,
filters: resolved?.list?.filters,
filtersStatic: resolved?.list?.filtersStatic,
isXPrefixed: objectName?.startsWith('x:') ?? false,
});
}, [appliedFilters, resolved?.list, objectName, clientFilterDefs]);
const buildSort = useCallback((): Record<string, unknown>[] | undefined => {
if (!sort) return undefined;
return [{ property: sort.field, isAscending: sort.ascending }];
}, [sort]);
const fetchData = useCallback(
async (anchor: string | null, anchorOffset: number = 1) => {
if (!resolved || !resolved.list || !schema) return;
const { obj, list } = resolved;
setLoading(true);
setError(null);
try {
const accountId = getAccountId(obj.objectName);
const properties = ['id', ...list.columns.map((c) => c.name)];
if (isWebApplications && !properties.includes('enabled')) {
properties.push('enabled');
}
if (hasQuotaUsageColumn) {
const quotaIdx = properties.indexOf('quotaUsage');
if (quotaIdx !== -1) {
properties.splice(quotaIdx, 1, 'usedDiskQuota', 'quotas');
}
}
if (isMailboxList && !properties.includes('parentId')) {
properties.push('parentId');
}
const filter = buildFilter();
const sortArr = buildSort();
if (activeClientFilters.length > 0 || isMailboxList) {
// No server-side pagination possible once a client-only filter is
// active (SCHEMA-DEVIATION: log-client-filters): fetch every
// server-matching row up front, narrow it in the browser, then
// paginate the in-memory result locally. Mailbox hierarchy needs
// this too (SCHEMA-DEVIATION: mailbox-client-hierarchy-sort) — a
// mailbox's parent can land on a different server page than the
// mailbox itself, so the full set is required to place each row
// under its parent correctly.
const { list: fullList } = await jmapQueryAllAndGet(
obj.objectName,
accountId,
{ filter: Object.keys(filter).length > 0 ? filter : undefined, sort: sortArr },
properties,
);
let matched = fullList.filter((item) =>
activeClientFilters.every((f) => String(item[f.field] ?? '') === f.value),
);
if (isMailboxList) {
const { items: ordered, depths } = sortMailboxesByHierarchy(matched);
matched = ordered;
setMailboxDepths(depths);
}
setClientAllItems(matched);
setClientPage(0);
setTotal(matched.length);
setItems(matched.slice(0, PAGE_SIZE));
setSelectedIds(new Set());
return;
}
setClientAllItems(null);
const queryOptions: Record<string, unknown> = {
filter: Object.keys(filter).length > 0 ? filter : undefined,
sort: sortArr,
limit: PAGE_SIZE,
};
if (anchor === null) {
queryOptions.position = 0;
queryOptions.calculateTotal = true;
} else {
queryOptions.anchor = anchor;
queryOptions.anchorOffset = anchorOffset;
}
const responses = await jmapQueryAndGet(obj.objectName, accountId, queryOptions, properties);
const queryResp = responses[0];
const getResp = responses[1];
if (queryResp[0].includes('/error') || getResp[0].includes('/error')) {
const errData = queryResp[0].includes('/error') ? queryResp[1] : getResp[1];
setError(String((errData as Record<string, unknown>).type ?? t('list.unknownError', 'Unknown error')));
return;
}
const queryData = queryResp[1] as {
ids: string[];
total?: number;
position?: number;
};
const getData = getResp[1] as {
list: Record<string, unknown>[];
};
if (queryData.total != null) {
setTotal(queryData.total);
}
setItems(getData.list ?? []);
setSelectedIds(new Set());
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
},
[resolved, schema, buildFilter, buildSort, isWebApplications, isAccountsList, hasQuotaUsageColumn, isMailboxList, activeClientFilters],
);
useEffect(() => {
if (!resolved?.list) return;
// eslint-disable-next-line react-hooks/set-state-in-effect
setAnchorStack([]);
setCurrentAnchor(null);
fetchData(null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [viewName, sort, resolved?.list, appliedFilters, activeAccountId]);
useEffect(() => {
if (!schema || !resolved?.list || items.length === 0) return;
const fieldMap = getFieldsRecord(resolved.schema);
const missingByType = new Map<string, Set<string>>();
for (const col of resolved.list.columns) {
const field = fieldMap[col.name];
if (field?.type.type !== 'objectId') continue;
const refType = field.type.objectName;
for (const item of items) {
const value = item[col.name];
if (typeof value !== 'string' || !value) continue;
if (displayNames[refType]?.[value] !== undefined) continue;
let set = missingByType.get(refType);
if (!set) {
set = new Set();
missingByType.set(refType, set);
}
set.add(value);
}
}
if (missingByType.size === 0) return;
let cancelled = false;
(async () => {
for (const [refType, idSet] of missingByType) {
try {
const accountId = getAccountId(refType);
const displayProp = getDisplayProperty(schema, refType);
const ids = Array.from(idSet);
const list = await jmapGetBatched(refType, accountId, ids, ['id', displayProp]);
if (cancelled) return;
const entries: Record<string, string> = {};
for (const obj of list) {
const id = obj.id as string;
if (!id) continue;
entries[id] = coerceLabel(obj[displayProp], id);
}
if (Object.keys(entries).length > 0) {
setDisplayNames(refType, entries);
}
} catch (err) {
console.error('Failed to resolve display names for', refType, err);
}
}
})();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [items, resolved, schema, setDisplayNames]);
const applyFilters = useCallback(() => {
setAppliedFilters({ ...filterValues });
}, [filterValues]);
const resetFilters = useCallback(() => {
setFilterValues({});
setAppliedFilters({});
}, []);
const handleRefresh = useCallback(() => {
if (refreshOnCooldown) return;
setRefreshOnCooldown(true);
fetchData(currentAnchor, 0);
refreshCooldownTimer.current = setTimeout(() => setRefreshOnCooldown(false), REFRESH_COOLDOWN_MS);
}, [refreshOnCooldown, fetchData, currentAnchor]);
useEffect(() => {
const params = new URLSearchParams();
for (const [key, val] of Object.entries(appliedFilters)) {
if (val !== '' && val != null) {
params.set(`f.${key}`, val);
}
}
if (sort) {
params.set('sort', sort.field);
params.set('sortDir', sort.ascending ? 'asc' : 'desc');
}
setSearchParams(params, { replace: true });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filterValues, sort]);
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;
const lastItem = items[items.length - 1];
const lastId = lastItem?.id as string;
if (!lastId) return;
const firstId = (items[0]?.id as string) ?? null;
if (firstId) {
setAnchorStack((prev) => [...prev, firstId]);
}
setCurrentAnchor(lastId);
fetchData(lastId, 1);
}, [items, fetchData, clientAllItems, clientPage]);
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) {
return;
}
const newStack = [...anchorStack];
const prevFirstId = newStack.pop()!;
setAnchorStack(newStack);
if (newStack.length === 0) {
setCurrentAnchor(null);
fetchData(null);
} else {
setCurrentAnchor(prevFirstId);
fetchData(prevFirstId, 0);
}
}, [anchorStack, fetchData, clientAllItems, clientPage]);
const toggleSelectAll = useCallback(() => {
if (selectedIds.size === items.length) {
setSelectedIds(new Set());
setSelectAllMode(false);
} else {
setSelectedIds(new Set(items.map((item) => item.id as string)));
setSelectAllMode(false);
}
}, [items, selectedIds]);
const toggleSelectItem = useCallback((id: string) => {
setSelectAllMode(false);
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}, []);
const toggleSort = useCallback((field: string) => {
setSort((prev) => {
if (prev?.field === field) {
return { field, ascending: !prev.ascending };
}
return { field, ascending: true };
});
}, []);
const handleFilterChange = useCallback((field: string, value: string) => {
setFilterValues((prev) => ({ ...prev, [field]: value }));
}, []);
const handleFilterSelectChange = useCallback((field: string, value: string) => {
const actualValue = value === '__all__' ? '' : value;
setFilterValues((prev) => ({ ...prev, [field]: actualValue }));
}, []);
const handleFilterKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
applyFilters();
}
},
[applyFilters],
);
const executeMassAction = useCallback(
async (action: MassAction) => {
if (!resolved || selectedIds.size === 0) return;
const { obj } = resolved;
try {
setLoading(true);
const accountId = getAccountId(obj.objectName);
let targetIds: string[];
if (selectAllMode) {
if (clientAllItems !== null) {
// A client-only filter is active: the full server-matching set
// would include rows it excludes, so use the already-narrowed list.
targetIds = clientAllItems.map((item) => item.id as string);
} else {
const filter = buildFilter();
const sortArr = buildSort();
targetIds = await jmapQueryAll(obj.objectName, accountId, {
filter: Object.keys(filter).length > 0 ? filter : undefined,
sort: sortArr,
});
}
} else {
targetIds = Array.from(selectedIds);
}
const batchSize = useAuthStore.getState().maxObjectsInSet;
let totalSuccess = 0;
const allErrors: Record<string, JmapSetError> = {};
if (action.type === 'delete') {
for (let i = 0; i < targetIds.length; i += batchSize) {
const batch = targetIds.slice(i, i + batchSize);
const raw = await jmapSet(obj.objectName, accountId, { destroy: batch });
const resp = parseSetResponse(raw);
if (resp) {
totalSuccess += resp.destroyed?.length ?? 0;
if (resp.notDestroyed) Object.assign(allErrors, resp.notDestroyed);
}
}
} else if (action.type === 'setProperty') {
for (let i = 0; i < targetIds.length; i += batchSize) {
const batch = targetIds.slice(i, i + batchSize);
const update: Record<string, Record<string, unknown>> = {};
for (const id of batch) {
update[id] = action.properties;
}
const raw = await jmapSet(obj.objectName, accountId, { update });
const resp = parseSetResponse(raw);
if (resp) {
totalSuccess += resp.updated ? Object.keys(resp.updated).length : 0;
if (resp.notUpdated) Object.assign(allErrors, resp.notUpdated);
}
}
}
const summary = bulkResultSummary(
totalSuccess,
Object.keys(allErrors).length > 0 ? allErrors : null,
action.type === 'delete' ? 'delete' : 'update',
t,
);
if (summary) {
toast({ title: summary.title, description: summary.description, variant: summary.variant });
}
setSelectedIds(new Set());
setSelectAllMode(false);
fetchData(currentAnchor, 0);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
},
[resolved, selectedIds, selectAllMode, buildFilter, buildSort, fetchData, currentAnchor, t, clientAllItems],
);
const executeItemAction = useCallback(
async (action: ItemAction, item: Record<string, unknown>) => {
if (!resolved) return;
const { obj } = resolved;
const itemId = item.id as string;
switch (action.type) {
case 'view': {
const targetSection = viewToSection[action.objectName] ?? viewToSection[viewName];
if (targetSection) {
navigate(`/${targetSection}/${action.objectName}/${itemId}`);
}
break;
}
case 'query': {
const targetSection = viewToSection[action.objectName] ?? viewToSection[viewName];
if (targetSection) {
const params = new URLSearchParams();
params.set(`f.${action.fieldName}`, itemId);
navigate(`/${targetSection}/${action.objectName}?${params.toString()}`);
}
break;
}
case 'setProperty': {
try {
setLoading(true);
const accountId = getAccountId(obj.objectName);
const raw = await jmapSet(obj.objectName, accountId, {
update: { [itemId]: action.properties },
});
const resp = parseSetResponse(raw);
if (resp?.notUpdated?.[itemId]) {
setError(friendlySetError(resp.notUpdated[itemId]));
} else {
fetchData(currentAnchor, 0);
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
break;
}
case 'delete': {
try {
setLoading(true);
const accountId = getAccountId(obj.objectName);
const raw = await jmapSet(obj.objectName, accountId, {
destroy: [itemId],
});
const resp = parseSetResponse(raw);
if (resp?.notDestroyed?.[itemId]) {
setError(friendlySetError(resp.notDestroyed[itemId]));
} else if (resp?.destroyed?.includes(itemId)) {
fetchData(currentAnchor, 0);
} else {
setError(
t('list.deleteNotConfirmed', 'Delete failed: item was not confirmed as destroyed by the server.'),
);
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
break;
}
case 'separator':
break;
}
},
[resolved, viewName, viewToSection, navigate, fetchData, currentAnchor, t],
);
const handleRowClick = useCallback(
(item: Record<string, unknown>) => {
const section = viewToSection[viewName];
if (section && item.id) {
navigate(`/${section}/${viewName}/${item.id}`);
}
},
[viewName, viewToSection, navigate],
);
const canCreate = resolved ? hasObjectPermission(resolved.obj.permissionPrefix, 'Create') : false;
const canUpdate = resolved ? hasObjectPermission(resolved.obj.permissionPrefix, 'Update') : false;
const canDelete = resolved ? hasObjectPermission(resolved.obj.permissionPrefix, 'Destroy') : false;
if (!schema) {
return (
<div className="flex items-center justify-center p-8">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
);
}
if (!resolved) {
return <div className="p-8 text-center text-muted-foreground">{t('errors.viewNotFound', 'View not found')}</div>;
}
if (!resolved.list) {
return <div className="p-8 text-center text-muted-foreground">No list configured</div>;
}
const { obj, schema: resolvedSchema, list } = resolved;
const fields = getFieldsRecord(resolvedSchema);
const sortableFields = new Set(list.sort ?? []);
const isXPrefixed = obj.objectName.startsWith('x:');
const effectiveMassActions: MassAction[] = (() => {
const schemaActions = list.massActions ?? [];
const canDestroy = obj.objectType.type === 'object' && hasObjectPermission(obj.permissionPrefix, 'Destroy');
if (!canDestroy) return schemaActions;
const hasSchemaDelete = schemaActions.some((a) => a.type === 'delete');
if (hasSchemaDelete) return schemaActions;
return [...schemaActions, { type: 'delete', label: t('list.delete', 'Delete') } satisfies MassAction];
})();
const hasMassActions = effectiveMassActions.length > 0;
const hasItemActions = (list.itemActions?.length ?? 0) > 0;
const pageStart = clientAllItems !== null ? clientPage * PAGE_SIZE : anchorStack.length * PAGE_SIZE;
const rangeStart = pageStart + 1;
const rangeEnd = pageStart + items.length;
const hasNextPage = clientAllItems !== null ? rangeEnd < clientAllItems.length : total !== null && rangeEnd < total;
const hasPrevPage = clientAllItems !== null ? clientPage > 0 : anchorStack.length > 0;
function renderFilter(filterDef: FilterDef): React.ReactNode {
const value = filterValues[filterDef.field] ?? '';
const wrapper = (content: React.ReactNode) => (
<div key={filterDef.field} className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-muted-foreground">{filterDef.label}</label>
{content}
</div>
);
switch (filterDef.type) {
case 'text':
return wrapper(
<Input
placeholder={t('list.filterPlaceholder', 'Search {{label}}...', { label: filterDef.label.toLowerCase() })}
value={value}
onChange={(e) => handleFilterChange(filterDef.field, e.target.value)}
onKeyDown={handleFilterKeyDown}
/>,
);
case 'enum': {
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(
<Select value={value || '__all__'} onValueChange={(v) => handleFilterSelectChange(filterDef.field, v)}>
<SelectTrigger>
<SelectValue placeholder={filterDef.label} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">{t('filters.all', 'All')}</SelectItem>
{enumVariants.map((v) => (
<SelectItem key={v.name} value={v.name}>
{v.label}
</SelectItem>
))}
</SelectContent>
</Select>,
);
}
case 'integer': {
if (!isXPrefixed) {
return wrapper(
<Input
type="number"
placeholder={filterDef.label}
value={value}
onChange={(e) => handleFilterChange(filterDef.field, e.target.value)}
onKeyDown={handleFilterKeyDown}
/>,
);
}
const opField = `${filterDef.field}Op`;
const opValue = filterValues[opField] ?? 'eq';
return wrapper(
<div className="flex gap-2">
<Select value={opValue} onValueChange={(v) => handleFilterSelectChange(opField, v)}>
<SelectTrigger className="w-20 shrink-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="eq">=</SelectItem>
<SelectItem value="gt">&gt;</SelectItem>
<SelectItem value="lt">&lt;</SelectItem>
<SelectItem value="gte">&gt;=</SelectItem>
<SelectItem value="lte">&lt;=</SelectItem>
</SelectContent>
</Select>
<Input
type="number"
placeholder={filterDef.label}
value={value}
onChange={(e) => handleFilterChange(filterDef.field, e.target.value)}
onKeyDown={handleFilterKeyDown}
className="flex-1"
/>
</div>,
);
}
case 'date': {
if (!isXPrefixed) {
return wrapper(
<Input
type="date"
value={value}
onChange={(e) => handleFilterChange(filterDef.field, e.target.value)}
onKeyDown={handleFilterKeyDown}
/>,
);
}
const opField = `${filterDef.field}Op`;
const opValue = filterValues[opField] ?? 'eq';
return wrapper(
<div className="flex gap-2">
<Select value={opValue} onValueChange={(v) => handleFilterSelectChange(opField, v)}>
<SelectTrigger className="w-24 shrink-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="eq">=</SelectItem>
<SelectItem value="gt">{t('filters.after', 'After')}</SelectItem>
<SelectItem value="lt">{t('filters.before', 'Before')}</SelectItem>
</SelectContent>
</Select>
<Input
type="date"
value={value}
onChange={(e) => handleFilterChange(filterDef.field, e.target.value)}
onKeyDown={handleFilterKeyDown}
className="flex-1"
/>
</div>,
);
}
case 'objectId':
return wrapper(
<ObjectPicker
schema={schema!}
objectName={filterDef.objectName}
value={value}
onChange={(id) => {
setFilterValues((prev) => ({ ...prev, [filterDef.field]: id }));
setAppliedFilters((prev) => ({ ...prev, [filterDef.field]: id }));
}}
onClear={() => {
setFilterValues((prev) => {
const next = { ...prev };
delete next[filterDef.field];
return next;
});
setAppliedFilters((prev) => {
const next = { ...prev };
delete next[filterDef.field];
return next;
});
}}
placeholder={t('list.selectFilterPlaceholder', 'Select {{label}}...', {
label: filterDef.label.toLowerCase(),
})}
/>,
);
default:
return null;
}
}
function renderSortIndicator(colName: string): React.ReactNode {
if (!sortableFields.has(colName)) return null;
const isActive = sort?.field === colName;
return (
<button
onClick={(e) => {
e.stopPropagation();
toggleSort(colName);
}}
className="ml-1 inline-flex items-center"
title={t('list.sort', 'Sort')}
>
{isActive ? (
sort.ascending ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)
) : (
<ArrowUpDown className="h-3 w-3 text-muted-foreground" />
)}
</button>
);
}
function renderItemActions(item: Record<string, unknown>): React.ReactNode {
if (!hasItemActions || !list.itemActions) return null;
const filteredActions = list.itemActions.flatMap((action): { action: ItemAction; locked: boolean }[] => {
if (action.type === 'separator') return [{ action, locked: false }];
if (action.type === 'delete') return canDelete ? [{ action, locked: false }] : [];
if (action.type === 'setProperty') return canUpdate ? [{ action, locked: false }] : [];
if (action.type === 'view' || action.type === 'query') {
const targetObj = resolveObject(schema!, action.objectName);
if (!targetObj) return [];
if (targetObj.enterprise) {
if (edition === 'oss') return [];
if (edition === 'community') return [{ action, locked: true }];
}
if (!hasObjectPermission(targetObj.permissionPrefix, 'Get')) return [];
}
return [{ action, locked: false }];
});
if (filteredActions.length === 0) return null;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={(e) => e.stopPropagation()}>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{filteredActions.map(({ action, locked }, idx) => {
if (action.type === 'separator') {
return <DropdownMenuSeparator key={`sep-${idx}`} />;
}
const isDestructive = action.type === 'delete';
const needsConfirmation = action.type === 'delete' || action.type === 'setProperty';
return (
<DropdownMenuItem
key={`${action.type}-${idx}`}
className={isDestructive ? 'text-destructive' : undefined}
onClick={(e) => {
e.stopPropagation();
if (locked) {
setUpsellOpen(true);
} else if (needsConfirmation) {
setConfirmAction({
label: action.label,
onConfirm: () => executeItemAction(action, item),
});
} else {
executeItemAction(action, item);
}
}}
>
{action.label}
{locked && <Lock className="ml-auto h-3 w-3 text-muted-foreground" />}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}
return (
<div className="relative min-w-0 space-y-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<h1 className="text-2xl font-bold tracking-tight truncate">{list.title}</h1>
{list.subtitle && <p className="text-sm text-muted-foreground mt-1 line-clamp-2">{list.subtitle}</p>}
</div>
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
{hasMassActions && selectedIds.size > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
{t('list.massAction', 'Actions')} ({selectAllMode ? (total ?? selectedIds.size) : selectedIds.size})
<ChevronDown className="ml-1 h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{effectiveMassActions.map((action, idx) => {
if (action.type === 'separator') {
return <DropdownMenuSeparator key={`mass-sep-${idx}`} />;
}
const isDestructive = action.type === 'delete';
if (isDestructive && !canDelete) return null;
if (action.type === 'setProperty' && !canUpdate) return null;
const actionCount = selectAllMode ? (total ?? selectedIds.size) : selectedIds.size;
return (
<DropdownMenuItem
key={`mass-${idx}`}
className={isDestructive ? 'text-destructive' : undefined}
onClick={() => {
setConfirmAction({
label: t('list.actionWithCount', '{{action}} ({{count}} {{name}})', {
action: action.label,
count: actionCount,
name: actionCount === 1 ? list.singularName : list.pluralName,
}),
onConfirm: () => executeMassAction(action),
});
}}
>
{action.label}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
)}
{canCreate && obj.objectType.type === 'object' && (
<Button
onClick={() => {
const section = viewToSection[viewName];
if (section) {
navigate(`/${section}/${viewName}/new`);
}
}}
>
<Plus className="mr-2 h-4 w-4" />
{t('list.create', 'Create {{name}}', { name: list.singularName })}
</Button>
)}
</div>
</div>
{list.filters && list.filters.length > 0 && (
<Collapsible open={filtersOpen} onOpenChange={setFiltersOpen}>
<div className="flex items-center justify-between">
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="gap-2">
<Filter className="h-4 w-4" />
{t('list.filters', 'Filters')}
{filtersOpen ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</Button>
</CollapsibleTrigger>
{isLogEntries && (
<Button
type="button"
variant="outline"
size="sm"
className="gap-2"
onClick={handleRefresh}
disabled={refreshOnCooldown || loading}
title={
refreshOnCooldown
? t('list.refreshCooldown', 'Please wait a few seconds before refreshing again')
: undefined
}
>
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
{t('list.refresh', 'Refresh')}
</Button>
)}
</div>
<CollapsibleContent>
<div className="mt-2 rounded-lg border bg-background shadow-sm">
<div className="grid gap-4 p-5 sm:grid-cols-2 lg:grid-cols-3">
{list.filters.map((filterDef) => renderFilter(filterDef))}
</div>
<div className="flex items-center justify-end gap-2 border-t px-5 py-3">
<Button type="button" variant="ghost" size="sm" onClick={resetFilters} disabled={loading}>
<RotateCcw className="mr-2 h-4 w-4" />
{t('list.resetFilters', 'Reset')}
</Button>
<Button type="button" size="sm" onClick={applyFilters} disabled={loading}>
<Search className="mr-2 h-4 w-4" />
{t('list.searchFilters', 'Search')}
</Button>
</div>
</div>
</CollapsibleContent>
</Collapsible>
)}
{error && (
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
{hasMassActions &&
selectedIds.size === items.length &&
items.length > 0 &&
total !== null &&
total > items.length &&
!selectAllMode && (
<div className="rounded-md border bg-muted/50 px-4 py-2 text-sm text-center">
{t('list.allPageSelected', 'All {{count}} items on this page are selected.', { count: items.length })}{' '}
<button className="text-primary underline hover:text-primary/80" onClick={() => setSelectAllMode(true)}>
{t('list.selectAllMatching', 'Select all {{total}} items matching filters', { total })}
</button>
</div>
)}
{selectAllMode && (
<div className="rounded-md border border-primary/50 bg-primary/10 px-4 py-2 text-sm text-center">
{t('list.allItemsSelected', 'All {{total}} items matching filters are selected.', { total: total ?? 0 })}{' '}
<button
className="text-primary underline hover:text-primary/80"
onClick={() => {
setSelectAllMode(false);
setSelectedIds(new Set());
}}
>
{t('list.clearSelection', 'Clear selection')}
</button>
</div>
)}
{isWebApplications && activeWebApp && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">
{t('webApplications.activeWebUI', 'Active WebUI')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-2 pt-0">
<p className="text-sm text-muted-foreground">
{String(activeWebApp.description ?? '-')}
</p>
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">{t('webApplications.version', 'Version')}:</span>
<Badge variant="secondary">{__APP_VERSION__}</Badge>
</div>
{typeof activeWebApp.resourceUrl === 'string' && activeWebApp.resourceUrl && (
<div className="flex items-start gap-2 text-sm">
<span className="shrink-0 text-muted-foreground">{t('webApplications.resourceUrl', 'Source')}:</span>
<a
href={activeWebApp.resourceUrl}
target="_blank"
rel="noreferrer"
className="break-all text-primary hover:underline"
>
{activeWebApp.resourceUrl}
</a>
</div>
)}
</CardContent>
</Card>
)}
<div className="min-w-0 rounded-lg border bg-background shadow-sm">
{/* The scroll container must clip with the parent's inner radius
(outer radius minus the 1px border), otherwise filled header rows
paint square corners behind the rounded border.
`w-max min-w-full` keeps the table at least as wide as the card,
but lets wide column sets scroll horizontally inside this wrapper. */}
<div className="overflow-x-auto overscroll-x-contain rounded-[calc(var(--radius-lg)-1px)] [-webkit-overflow-scrolling:touch]">
<table className="w-max min-w-full text-sm">
<thead>
<tr className="border-b bg-muted">
{hasMassActions && (
<th className="w-10 px-3 py-3 whitespace-nowrap">
<Checkbox
checked={items.length > 0 && selectedIds.size === items.length}
onCheckedChange={toggleSelectAll}
aria-label={t('list.selectAll', 'Select all')}
/>
</th>
)}
{displayColumns.map((col) => (
<th
key={col.name}
className="px-3 py-3 text-left font-medium text-muted-foreground whitespace-nowrap"
>
<div className="flex items-center">
{col.label}
{renderSortIndicator(col.name)}
</div>
</th>
))}
{hasItemActions && (
<th className="w-12 px-3 py-3 text-right font-medium text-muted-foreground whitespace-nowrap">
{t('list.actions', 'Actions')}
</th>
)}
</tr>
</thead>
<tbody>
{loading && items.length === 0 ? (
<tr>
<td
colSpan={displayColumns.length + (hasMassActions ? 1 : 0) + (hasItemActions ? 1 : 0)}
className="px-3 py-12 text-center"
>
<Loader2 className="mx-auto h-6 w-6 animate-spin" />
</td>
</tr>
) : items.length === 0 ? (
<tr>
<td
colSpan={displayColumns.length + (hasMassActions ? 1 : 0) + (hasItemActions ? 1 : 0)}
className="px-3 py-12 text-center text-muted-foreground"
>
{t('list.noResults', 'No results found')}
</td>
</tr>
) : (
items.map((item) => {
const itemId = item.id as string;
return (
<tr
key={itemId}
className="border-b cursor-pointer transition-colors hover:bg-muted/50"
onClick={() => handleRowClick(item)}
>
{hasMassActions && (
<td className="px-3 py-2 whitespace-nowrap" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={selectedIds.has(itemId)}
onCheckedChange={() => toggleSelectItem(itemId)}
aria-label={t('list.selectItem', 'Select item')}
/>
</td>
)}
{displayColumns.map((col) => (
<td key={col.name} className="px-3 py-2 whitespace-nowrap">
{isWebApplications && col.name === 'enabled' && !fields[col.name] ? (
item.enabled === true ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<X className="h-4 w-4 text-red-500" />
)
) : isAccountsList && col.name === 'roles' ? (
formatUserRole(item, schema!)
) : hasQuotaUsageColumn && col.name === 'quotaUsage' ? (
renderQuotaUsage(item, t)
) : isMailboxList && col.name === 'name' ? (
(() => {
const depth = mailboxDepths.get(item.id as string) ?? 0;
return (
<div style={{ paddingLeft: depth * 20 }} className="flex items-center gap-1.5">
{depth > 0 && (
<CornerDownRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
)}
<span>{String(item.name ?? '')}</span>
</div>
);
})()
) : (
renderCellValue(
item[col.name],
fields[col.name],
col.name,
schema!,
resolved.obj.objectName,
getDisplayName,
)
)}
</td>
))}
{hasItemActions && (
<td className="px-3 py-2 text-right whitespace-nowrap">{renderItemActions(item)}</td>
)}
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
{items.length > 0 && (
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between text-sm text-muted-foreground">
<div className="min-w-0">
{total !== null
? t('list.showing', 'Showing {{from}}-{{to}} of {{total}} {{name}}', {
from: rangeStart,
to: rangeEnd,
total,
name: list.pluralName,
})
: t('list.showingItems', 'Showing {{count}} items', {
count: items.length,
})}
</div>
<div className="flex shrink-0 items-center gap-2 self-end sm:self-auto">
<Button variant="outline" size="sm" disabled={!hasPrevPage || loading} onClick={handlePrevPage}>
{t('list.previous', 'Previous')}
</Button>
<Button variant="outline" size="sm" disabled={!hasNextPage || loading} onClick={handleNextPage}>
{t('list.next', 'Next')}
</Button>
</div>
</div>
)}
{loading && items.length > 0 && (
<div className="absolute inset-0 flex items-center justify-center bg-background/50">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
)}
<AlertDialog
open={confirmAction !== null}
onOpenChange={(open) => {
if (!open) setConfirmAction(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('list.confirmTitle', 'Confirm Action')}</AlertDialogTitle>
<AlertDialogDescription>
{t('list.confirmDescription', 'Are you sure you want to proceed with: {{action}}?', {
action: confirmAction?.label ?? '',
})}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('common.cancel', 'Cancel')}</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
confirmAction?.onConfirm();
setConfirmAction(null);
}}
>
{t('common.confirm', 'Confirm')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<EnterpriseUpsell open={upsellOpen} onClose={() => setUpsellOpen(false)} />
</div>
);
}