Publishing the source is what asks for it: a modified version has to carry prominent notices saying it was modified, with a date. These files already added a Coffey Labs copyright line beside upstream's, which implies as much without saying it; now they say it. 38 files, found by diffing against the merge base with upstream rather than by guessing. Upstream's own notices are untouched. The build is unchanged.
1410 lines
49 KiB
TypeScript
1410 lines
49 KiB
TypeScript
/*
|
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
|
*
|
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
|
*
|
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
|
*/
|
|
|
|
import { EmptyState } from '@/components/common/EmptyState';
|
|
import { PageHeader } from '@/components/common/PageHeader';
|
|
import { HelpPanel } from '@/help/HelpPanel';
|
|
import { ObjectHoverCard } from '@/features/hovercards/ObjectHoverCard';
|
|
import { iconForView } from '@/lib/viewIcon';
|
|
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 { 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 {
|
|
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 { 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, 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 ENUM_FILTER_COMBOBOX_THRESHOLD = 15;
|
|
|
|
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 (
|
|
<ObjectHoverCard objectName={ft.objectName} id={id}>
|
|
{display ?? id}
|
|
</ObjectHoverCard>
|
|
);
|
|
}
|
|
|
|
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 typeName = (value as Record<string, unknown>)['@type'];
|
|
if (typeof typeName === 'string') {
|
|
const objSchema = schema.schemas[ft.objectName];
|
|
const variant = objSchema?.type === 'multiple' ? objSchema.variants.find((v) => v.name === typeName) : null;
|
|
return <Badge variant="secondary">{variant?.label ?? typeName}</Badge>;
|
|
}
|
|
}
|
|
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;
|
|
}
|
|
|
|
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(() => 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);
|
|
|
|
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
|
|
|
|
useResetOnChange(viewName, () => {
|
|
setItems([]);
|
|
setTotal(null);
|
|
setAnchorStack([]);
|
|
setCurrentAnchor(null);
|
|
setSelectedIds(new Set());
|
|
setSelectAllMode(false);
|
|
setError(null);
|
|
|
|
const initialFilters = readUrlFilters();
|
|
setFilterValues(initialFilters);
|
|
setAppliedFilters(initialFilters);
|
|
setFiltersOpen(Object.keys(initialFilters).length > 0);
|
|
setSort(readUrlSort());
|
|
});
|
|
|
|
const objectName = resolved?.obj.objectName;
|
|
const buildFilter = useCallback((): Record<string, unknown> => {
|
|
return buildJmapFilter({
|
|
appliedFilters,
|
|
filters: resolved?.list?.filters,
|
|
filtersStatic: resolved?.list?.filtersStatic,
|
|
isXPrefixed: objectName?.startsWith('x:') ?? false,
|
|
});
|
|
}, [appliedFilters, resolved?.list, 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;
|
|
// 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]);
|
|
|
|
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] ?? [];
|
|
if (enumVariants.length > ENUM_FILTER_COMBOBOX_THRESHOLD) {
|
|
return wrapper(
|
|
<Combobox
|
|
options={[
|
|
{ value: '__all__', label: t('filters.all', 'All') },
|
|
...enumVariants.map((v) => ({ value: v.name, label: v.label })),
|
|
]}
|
|
value={value || '__all__'}
|
|
onValueChange={(v) => handleFilterSelectChange(filterDef.field, v)}
|
|
searchPlaceholder={t('common.searchPlaceholder', 'Search...')}
|
|
emptyText={t('field.noMatches', 'No matches')}
|
|
/>,
|
|
);
|
|
}
|
|
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">></SelectItem>
|
|
<SelectItem value="lt"><</SelectItem>
|
|
<SelectItem value="gte">>=</SelectItem>
|
|
<SelectItem value="lte"><=</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-5">
|
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
|
<PageHeader icon={iconForView(schema, viewName)} title={list.title} subtitle={list.subtitle} />
|
|
<div className="flex items-center gap-2">
|
|
<HelpPanel viewName={viewName} title={list.title} />
|
|
{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-xl border bg-card shadow-soft">
|
|
<div className="overflow-x-auto rounded-[calc(var(--radius-xl)-1px)]">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b bg-muted/60 text-xs uppercase tracking-wide text-muted-foreground">
|
|
{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"
|
|
>
|
|
<EmptyState
|
|
title={t('list.emptyTitle', 'Nothing here yet')}
|
|
hint={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, colIndex) => {
|
|
const cell = renderCellValue(
|
|
item[col.name],
|
|
fields[col.name],
|
|
col.name,
|
|
schema!,
|
|
resolved.obj.objectName,
|
|
getDisplayName,
|
|
);
|
|
return (
|
|
<td key={col.name} className="px-3 py-2">
|
|
{colIndex === 0 && item[col.name] != null ? (
|
|
<ObjectHoverCard objectName={resolved.obj.objectName} id={itemId}>
|
|
{cell}
|
|
</ObjectHoverCard>
|
|
) : (
|
|
cell
|
|
)}
|
|
</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>
|
|
);
|
|
}
|