/* * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * 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 handleFilterChange(filterDef.field, e.target.value)} onKeyDown={handleFilterKeyDown} />, ); case 'enum': { const enumVariants = schema!.enums[filterDef.enumName] ?? []; if (enumVariants.length > ENUM_COMBOBOX_THRESHOLD) { return wrapper( ({ 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( , ); } 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 && (
{isLogEntries && ( )}
{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 })}{' '}
)} {isWebApplications && activeWebApp && ( {t('webApplications.activeWebUI', 'Active WebUI')}

{String(activeWebApp.description ?? '-')}

{t('webApplications.version', 'Version')}: {__APP_VERSION__}
{typeof activeWebApp.resourceUrl === 'string' && activeWebApp.resourceUrl && (
{t('webApplications.resourceUrl', 'Source')}: {activeWebApp.resourceUrl}
)}
)}
{/* 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. */}
{hasMassActions && ( )} {displayColumns.map((col) => ( ))} {hasItemActions && ( )} {loading && items.length === 0 ? ( ) : items.length === 0 ? ( ) : ( items.map((item) => { const itemId = item.id as string; return ( handleRowClick(item)} > {hasMassActions && ( )} {displayColumns.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')} /> {isWebApplications && col.name === 'enabled' && !fields[col.name] ? ( item.enabled === true ? ( ) : ( ) ) : 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 (
{depth > 0 && ( )} {String(item.name ?? '')}
); })() ) : ( 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)} />
); }