Files
Stalwart-webui/src/components/lists/DynamicList.tsx
T

1357 lines
47 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 } 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,
} 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 { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import { formatSize as fmtSize, formatDuration as fmtDuration } from '@/lib/durationFormat';
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 { 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 { 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, 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';
const PAGE_SIZE = 25;
const MAX_REPORTED_ERRORS = 3;
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();
}
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') {
return 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;
}
interface ConfirmAction {
label: string;
onConfirm: () => void;
}
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);
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 [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(false);
const [filterValues, setFilterValues] = useState<Record<string, string>>({});
const [appliedFilters, setAppliedFilters] = useState<Record<string, string>>({});
const [sort, setSort] = useState<SortState | null>(null);
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
useEffect(() => {
setItems([]);
setTotal(null);
setAnchorStack([]);
setCurrentAnchor(null);
setSelectedIds(new Set());
setSelectAllMode(false);
setError(null);
const params = new URLSearchParams(window.location.search);
const initialFilters: Record<string, string> = {};
params.forEach((value, key) => {
if (key.startsWith('f.')) {
initialFilters[key.slice(2)] = value;
}
});
setFilterValues(initialFilters);
setAppliedFilters(initialFilters);
setFiltersOpen(Object.keys(initialFilters).length > 0);
const sortParam = params.get('sort');
const sortDir = params.get('sortDir');
setSort(sortParam ? { field: sortParam, ascending: sortDir !== 'desc' } : null);
}, [viewName]);
const buildFilter = useCallback((): Record<string, unknown> => {
return buildJmapFilter({
appliedFilters,
filters: resolved?.list?.filters,
filtersStatic: resolved?.list?.filtersStatic,
isXPrefixed: resolved?.obj.objectName.startsWith('x:') ?? false,
});
}, [appliedFilters, resolved?.list, resolved?.obj.objectName]);
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)];
const filter = buildFilter();
const sortArr = buildSort();
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, t],
);
useEffect(() => {
if (!resolved?.list) return;
setAnchorStack([]);
setCurrentAnchor(null);
fetchData(null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [viewName, sort, resolved?.list, appliedFilters]);
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({});
}, []);
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 (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]);
const handlePrevPage = useCallback(() => {
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]);
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) {
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],
);
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 = anchorStack.length * PAGE_SIZE;
const rangeStart = pageStart + 1;
const rangeEnd = pageStart + items.length;
const hasNextPage = total !== null && rangeEnd < total;
const hasPrevPage = 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] ?? [];
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 space-y-4">
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">{list.title}</h1>
{list.subtitle && <p className="text-sm text-muted-foreground mt-1">{list.subtitle}</p>}
</div>
<div className="flex items-center 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}>
<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>
<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>
)}
<div className="rounded-lg border bg-background shadow-sm">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted">
{hasMassActions && (
<th className="w-10 px-3 py-3">
<Checkbox
checked={items.length > 0 && selectedIds.size === items.length}
onCheckedChange={toggleSelectAll}
aria-label={t('list.selectAll', 'Select all')}
/>
</th>
)}
{list.columns.map((col) => (
<th key={col.name} className="px-3 py-3 text-left font-medium text-muted-foreground">
<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">
{t('list.actions', 'Actions')}
</th>
)}
</tr>
</thead>
<tbody>
{loading && items.length === 0 ? (
<tr>
<td
colSpan={list.columns.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={list.columns.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" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={selectedIds.has(itemId)}
onCheckedChange={() => toggleSelectItem(itemId)}
aria-label={t('list.selectItem', 'Select item')}
/>
</td>
)}
{list.columns.map((col) => (
<td key={col.name} className="px-3 py-2">
{renderCellValue(
item[col.name],
fields[col.name],
col.name,
schema!,
resolved.obj.objectName,
getDisplayName,
)}
</td>
))}
{hasItemActions && <td className="px-3 py-2 text-right">{renderItemActions(item)}</td>}
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
{items.length > 0 && (
<div className="flex items-center justify-between text-sm text-muted-foreground">
<div>
{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 items-center gap-2">
<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>
);
}