/* * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * 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][]): 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; function bulkResultSummary( successCount: number, errors: Record | 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(); 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 { if (resolvedSchema.type === 'single') { return resolvedSchema.fields.properties; } const merged: Record = {}; 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 -; 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 {variant.label}; } } 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 (
              {str}
            
); } } 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 ? : ; case 'enum': { const variants = schema.enums[ft.enumName]; const variant = variants?.find((v) => v.name === value); if (variant) { return ( {variant.label} ); } 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); 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 -; } case 'object': { if (value && typeof value === 'object' && !Array.isArray(value)) { const obj = value as Record; if ('@type' in obj && typeof obj['@type'] === 'string') { return obj['@type']; } } return -; } default: if (value && typeof value === 'object') { return -; } 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[]>([]); const [total, setTotal] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(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([]); const [currentAnchor, setCurrentAnchor] = useState(null); const [selectedIds, setSelectedIds] = useState>(new Set()); const [selectAllMode, setSelectAllMode] = useState(false); const [filtersOpen, setFiltersOpen] = useState(false); const [filterValues, setFilterValues] = useState>({}); const [appliedFilters, setAppliedFilters] = useState>({}); const [sort, setSort] = useState(null); const [confirmAction, setConfirmAction] = useState(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 = {}; 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 => { 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[] | 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 = { 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).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[]; }; 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>(); 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 = {}; 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) => { 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 = {}; 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> = {}; 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) => { 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) => { 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 (
); } if (!resolved) { return
{t('errors.viewNotFound', 'View not found')}
; } if (!resolved.list) { return
No list configured
; } 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) => (
{content}
); switch (filterDef.type) { case 'text': return wrapper( handleFilterChange(filterDef.field, e.target.value)} onKeyDown={handleFilterKeyDown} />, ); case 'enum': { const enumVariants = schema!.enums[filterDef.enumName] ?? []; return wrapper( , ); } case 'integer': { if (!isXPrefixed) { return wrapper( handleFilterChange(filterDef.field, e.target.value)} onKeyDown={handleFilterKeyDown} />, ); } const opField = `${filterDef.field}Op`; const opValue = filterValues[opField] ?? 'eq'; return wrapper(
handleFilterChange(filterDef.field, e.target.value)} onKeyDown={handleFilterKeyDown} className="flex-1" />
, ); } case 'date': { if (!isXPrefixed) { return wrapper( handleFilterChange(filterDef.field, e.target.value)} onKeyDown={handleFilterKeyDown} />, ); } const opField = `${filterDef.field}Op`; const opValue = filterValues[opField] ?? 'eq'; return wrapper(
handleFilterChange(filterDef.field, e.target.value)} onKeyDown={handleFilterKeyDown} className="flex-1" />
, ); } case 'objectId': return wrapper( { 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 ( ); } function renderItemActions(item: Record): 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 ( {filteredActions.map(({ action, locked }, idx) => { if (action.type === 'separator') { return ; } const isDestructive = action.type === 'delete'; const needsConfirmation = action.type === 'delete' || action.type === 'setProperty'; return ( { e.stopPropagation(); if (locked) { setUpsellOpen(true); } else if (needsConfirmation) { setConfirmAction({ label: action.label, onConfirm: () => executeItemAction(action, item), }); } else { executeItemAction(action, item); } }} > {action.label} {locked && } ); })} ); } return (

{list.title}

{list.subtitle &&

{list.subtitle}

}
{hasMassActions && selectedIds.size > 0 && ( {effectiveMassActions.map((action, idx) => { if (action.type === 'separator') { return ; } 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 ( { 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} ); })} )} {canCreate && obj.objectType.type === 'object' && ( )}
{list.filters && list.filters.length > 0 && (
{list.filters.map((filterDef) => renderFilter(filterDef))}
)} {error && (
{error}
)} {hasMassActions && selectedIds.size === items.length && items.length > 0 && total !== null && total > items.length && !selectAllMode && (
{t('list.allPageSelected', 'All {{count}} items on this page are selected.', { count: items.length })}{' '}
)} {selectAllMode && (
{t('list.allItemsSelected', 'All {{total}} items matching filters are selected.', { total: total ?? 0 })}{' '}
)}
{hasMassActions && ( )} {list.columns.map((col) => ( ))} {hasItemActions && ( )} {loading && items.length === 0 ? ( ) : items.length === 0 ? ( ) : ( items.map((item) => { const itemId = item.id as string; return ( handleRowClick(item)} > {hasMassActions && ( )} {list.columns.map((col) => ( ))} {hasItemActions && } ); }) )}
0 && selectedIds.size === items.length} onCheckedChange={toggleSelectAll} aria-label={t('list.selectAll', 'Select all')} />
{col.label} {renderSortIndicator(col.name)}
{t('list.actions', 'Actions')}
{t('list.noResults', 'No results found')}
e.stopPropagation()}> toggleSelectItem(itemId)} aria-label={t('list.selectItem', 'Select item')} /> {renderCellValue( item[col.name], fields[col.name], col.name, schema!, resolved.obj.objectName, getDisplayName, )} {renderItemActions(item)}
{items.length > 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, })}
)} {loading && items.length > 0 && (
)} { if (!open) setConfirmAction(null); }} > {t('list.confirmTitle', 'Confirm Action')} {t('list.confirmDescription', 'Are you sure you want to proceed with: {{action}}?', { action: confirmAction?.label ?? '', })} {t('common.cancel', 'Cancel')} { confirmAction?.onConfirm(); setConfirmAction(null); }} > {t('common.confirm', 'Confirm')} setUpsellOpen(false)} />
); }