@object-ui/plugin-list 3.3.0 → 3.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ListView.tsx DELETED
@@ -1,1725 +0,0 @@
1
- /**
2
- * ObjectUI
3
- * Copyright (c) 2024-present ObjectStack Inc.
4
- *
5
- * This source code is licensed under the MIT license found in the
6
- * LICENSE file in the root directory of this source tree.
7
- */
8
-
9
- import * as React from 'react';
10
- import { cn, Button, Input, Popover, PopoverContent, PopoverTrigger, FilterBuilder, SortBuilder, NavigationOverlay } from '@object-ui/components';
11
- import type { SortItem } from '@object-ui/components';
12
- import { Search, SlidersHorizontal, ArrowUpDown, X, EyeOff, Group, Paintbrush, Ruler, Inbox, Download, AlignJustify, Share2, Printer, Plus, icons, type LucideIcon } from 'lucide-react';
13
- import type { FilterGroup } from '@object-ui/components';
14
- import { ViewSwitcher, ViewType } from './ViewSwitcher';
15
- import { TabBar } from './components/TabBar';
16
- import type { ViewTab } from './components/TabBar';
17
- import { UserFilters } from './UserFilters';
18
- import { SchemaRenderer, useNavigationOverlay } from '@object-ui/react';
19
- import { useDensityMode } from '@object-ui/react';
20
- import type { ListViewSchema } from '@object-ui/types';
21
- import { usePullToRefresh } from '@object-ui/mobile';
22
- import { evaluatePlainCondition, normalizeQuickFilter, normalizeQuickFilters, buildExpandFields } from '@object-ui/core';
23
- import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n';
24
-
25
- export interface ListViewProps {
26
- schema: ListViewSchema;
27
- className?: string;
28
- onViewChange?: (view: ViewType) => void;
29
- onFilterChange?: (filters: any) => void;
30
- onSortChange?: (sort: any) => void;
31
- onSearchChange?: (search: string) => void;
32
- /** Callback when a row/item is clicked (overrides NavigationConfig) */
33
- onRowClick?: (record: Record<string, unknown>) => void;
34
- /** Show view type switcher (Grid/Kanban/etc). Default: false (view type is fixed) */
35
- showViewSwitcher?: boolean;
36
- [key: string]: any;
37
- }
38
-
39
- // Helper to convert FilterBuilder group to ObjectStack AST
40
- function mapOperator(op: string) {
41
- switch (op) {
42
- case 'equals': case 'eq': return '=';
43
- case 'notEquals': case 'ne': case 'neq': return '!=';
44
- case 'contains': return 'contains';
45
- case 'notContains': return 'notcontains';
46
- case 'greaterThan': case 'gt': return '>';
47
- case 'greaterOrEqual': case 'gte': return '>=';
48
- case 'lessThan': case 'lt': return '<';
49
- case 'lessOrEqual': case 'lte': return '<=';
50
- case 'in': return 'in';
51
- case 'notIn': return 'not in';
52
- case 'before': return '<';
53
- case 'after': return '>';
54
- default: return op;
55
- }
56
- }
57
-
58
- /**
59
- * Normalize a single filter condition: convert `in`/`not in` operators
60
- * into backend-compatible `or`/`and` of equality conditions.
61
- * E.g., ['status', 'in', ['a','b']] → ['or', ['status','=','a'], ['status','=','b']]
62
- */
63
- export function normalizeFilterCondition(condition: any[]): any[] {
64
- if (!Array.isArray(condition) || condition.length < 3) return condition;
65
-
66
- const [field, op, value] = condition;
67
-
68
- // Recurse into logical groups
69
- if (typeof field === 'string' && (field === 'and' || field === 'or')) {
70
- return [field, ...condition.slice(1).map((c: any) =>
71
- Array.isArray(c) ? normalizeFilterCondition(c) : c
72
- )];
73
- }
74
-
75
- if (op === 'in' && Array.isArray(value)) {
76
- if (value.length === 0) return [];
77
- if (value.length === 1) return [field, '=', value[0]];
78
- return ['or', ...value.map((v: any) => [field, '=', v])];
79
- }
80
-
81
- if (op === 'not in' && Array.isArray(value)) {
82
- if (value.length === 0) return [];
83
- if (value.length === 1) return [field, '!=', value[0]];
84
- return ['and', ...value.map((v: any) => [field, '!=', v])];
85
- }
86
-
87
- return condition;
88
- }
89
-
90
- /**
91
- * Format an action identifier string into a human-readable label.
92
- * e.g., 'send_email' → 'Send Email'
93
- */
94
- function formatActionLabel(action: string): string {
95
- return action.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
96
- }
97
-
98
- /**
99
- * Normalize an array of filter conditions, expanding `in`/`not in` operators
100
- * and ensuring consistent AST structure.
101
- */
102
- export function normalizeFilters(filters: any[]): any[] {
103
- if (!Array.isArray(filters) || filters.length === 0) return [];
104
- return filters
105
- .map(f => Array.isArray(f) ? normalizeFilterCondition(f) : f)
106
- .filter(f => Array.isArray(f) && f.length > 0);
107
- }
108
-
109
- function convertFilterGroupToAST(group: FilterGroup): any[] {
110
- if (!group || !group.conditions || group.conditions.length === 0) return [];
111
-
112
- const conditions = group.conditions.map(c => {
113
- if (c.operator === 'isEmpty') return [c.field, '=', null];
114
- if (c.operator === 'isNotEmpty') return [c.field, '!=', null];
115
- return [c.field, mapOperator(c.operator), c.value];
116
- });
117
-
118
- // Normalize in/not-in conditions for backend compatibility
119
- const normalized = normalizeFilters(conditions);
120
- if (normalized.length === 0) return [];
121
- if (normalized.length === 1) return normalized[0];
122
-
123
- return [group.logic, ...normalized];
124
- }
125
-
126
- /**
127
- * Evaluate conditional formatting rules against a record.
128
- * Returns a CSSProperties object for the first matching rule, or empty object.
129
- * Supports both field/operator/value rules and expression-based rules.
130
- *
131
- * Exported for use by child view renderers (e.g., ObjectGrid) and consumers
132
- * who need to evaluate formatting rules outside the ListView component.
133
- */
134
- export function evaluateConditionalFormatting(
135
- record: Record<string, unknown>,
136
- rules?: ListViewSchema['conditionalFormatting']
137
- ): React.CSSProperties {
138
- if (!rules || rules.length === 0) return {};
139
- for (const rule of rules) {
140
- let match = false;
141
-
142
- // Determine expression: spec uses 'condition', ObjectUI uses 'expression'
143
- const expression =
144
- ('condition' in rule ? rule.condition : undefined)
145
- || ('expression' in rule ? rule.expression : undefined)
146
- || undefined;
147
-
148
- // Expression-based evaluation using safe ExpressionEvaluator
149
- // Supports both template expressions (${data.field > value}) and
150
- // plain Spec expressions (field == 'value').
151
- if (expression) {
152
- match = evaluatePlainCondition(expression, record as Record<string, any>);
153
- } else if ('field' in rule && 'operator' in rule && rule.field && rule.operator) {
154
- // Standard field/operator/value evaluation (ObjectUI format)
155
- const fieldValue = record[rule.field];
156
- switch (rule.operator) {
157
- case 'equals':
158
- match = fieldValue === rule.value;
159
- break;
160
- case 'not_equals':
161
- match = fieldValue !== rule.value;
162
- break;
163
- case 'contains':
164
- match = typeof fieldValue === 'string' && typeof rule.value === 'string' && fieldValue.includes(rule.value);
165
- break;
166
- case 'greater_than':
167
- match = typeof fieldValue === 'number' && typeof rule.value === 'number' && fieldValue > rule.value;
168
- break;
169
- case 'less_than':
170
- match = typeof fieldValue === 'number' && typeof rule.value === 'number' && fieldValue < rule.value;
171
- break;
172
- case 'in':
173
- match = Array.isArray(rule.value) && rule.value.includes(fieldValue);
174
- break;
175
- }
176
- }
177
-
178
- if (match) {
179
- // Build style: spec 'style' object is base, individual properties override
180
- const style: React.CSSProperties = {};
181
- if ('style' in rule && rule.style) Object.assign(style, rule.style);
182
- if ('backgroundColor' in rule && rule.backgroundColor) style.backgroundColor = rule.backgroundColor;
183
- if ('textColor' in rule && rule.textColor) style.color = rule.textColor;
184
- if ('borderColor' in rule && rule.borderColor) style.borderColor = rule.borderColor;
185
- return style;
186
- }
187
- }
188
- return {};
189
- }
190
-
191
- // Default English translations for fallback when I18nProvider is not available
192
- const LIST_DEFAULT_TRANSLATIONS: Record<string, string> = {
193
- 'list.recordCount': '{{count}} records',
194
- 'list.recordCountOne': '{{count}} record',
195
- 'list.noItems': 'No items found',
196
- 'list.noItemsMessage': 'There are no records to display. Try adjusting your filters or adding new data.',
197
- 'list.search': 'Search',
198
- 'list.filter': 'Filter',
199
- 'list.filterRecords': 'Filter Records',
200
- 'list.sort': 'Sort',
201
- 'list.sortRecords': 'Sort Records',
202
- 'list.group': 'Group',
203
- 'list.groupBy': 'Group By',
204
- 'list.export': 'Export',
205
- 'list.exportAs': 'Export as {{format}}',
206
- 'list.color': 'Color',
207
- 'list.rowColor': 'Row Color',
208
- 'list.colorByField': 'Color by field',
209
- 'list.clear': 'Clear',
210
- 'list.none': 'None',
211
- 'list.hideFields': 'Hide fields',
212
- 'list.showAll': 'Show all',
213
- 'list.pullToRefresh': 'Pull to refresh',
214
- 'list.refreshing': 'Refreshing…',
215
- 'list.dataLimitReached': 'Showing first {{limit}} records. More data may be available.',
216
- 'list.addRecord': 'Add record',
217
- 'list.tabs': 'Tabs',
218
- 'list.allRecords': 'All Records',
219
- 'list.share': 'Share',
220
- 'list.print': 'Print',
221
- 'list.hideFieldsTitle': 'Hide Fields',
222
- };
223
-
224
- /**
225
- * Safe wrapper for useObjectTranslation that falls back to English defaults
226
- * when I18nProvider is not available (e.g., standalone usage outside console).
227
- */
228
- function useListViewTranslation() {
229
- try {
230
- const result = useObjectTranslation();
231
- const testValue = result.t('list.recordCount');
232
- if (testValue === 'list.recordCount') {
233
- // i18n returned the key itself — not initialized
234
- return {
235
- t: (key: string, options?: Record<string, unknown>) => {
236
- let value = LIST_DEFAULT_TRANSLATIONS[key] || key;
237
- if (options) {
238
- for (const [k, v] of Object.entries(options)) {
239
- value = value.replace(`{{${k}}}`, String(v));
240
- }
241
- }
242
- return value;
243
- },
244
- };
245
- }
246
- return { t: result.t };
247
- } catch {
248
- return {
249
- t: (key: string, options?: Record<string, unknown>) => {
250
- let value = LIST_DEFAULT_TRANSLATIONS[key] || key;
251
- if (options) {
252
- for (const [k, v] of Object.entries(options)) {
253
- value = value.replace(`{{${k}}}`, String(v));
254
- }
255
- }
256
- return value;
257
- },
258
- };
259
- }
260
- }
261
-
262
- /**
263
- * Safe wrapper for useObjectLabel that falls back to identity when I18nProvider is unavailable.
264
- */
265
- function useListFieldLabel() {
266
- try {
267
- const { fieldLabel } = useObjectLabel();
268
- return { fieldLabel };
269
- } catch {
270
- return {
271
- fieldLabel: (_objectName: string, _fieldName: string, fallback: string) => fallback,
272
- };
273
- }
274
- }
275
-
276
- /**
277
- * Imperative handle exposed by ListView via React.forwardRef.
278
- * Allows parent components to trigger a data refresh programmatically.
279
- *
280
- * @example
281
- * ```tsx
282
- * const listRef = React.useRef<ListViewHandle>(null);
283
- * <ListView ref={listRef} schema={schema} />
284
- * // After a mutation:
285
- * listRef.current?.refresh();
286
- * ```
287
- */
288
- export interface ListViewHandle {
289
- /** Force the ListView to re-fetch data from the DataSource */
290
- refresh(): void;
291
- }
292
-
293
- export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
294
- schema: propSchema,
295
- className,
296
- onViewChange,
297
- onFilterChange,
298
- onSortChange,
299
- onSearchChange,
300
- onRowClick,
301
- showViewSwitcher = false,
302
- ...props
303
- }, ref) => {
304
- // i18n support for record count and other labels
305
- const { t } = useListViewTranslation();
306
- const { fieldLabel: resolveFieldLabel } = useListFieldLabel();
307
-
308
- // Kernel level default: Ensure viewType is always defined (default to 'grid')
309
- const schema = React.useMemo(() => ({
310
- ...propSchema,
311
- viewType: propSchema.viewType || 'grid'
312
- }), [propSchema]);
313
-
314
- // Convenience: resolve field label with schema.objectName pre-bound
315
- const tFieldLabel = React.useCallback(
316
- (fieldName: string, fallback: string) =>
317
- schema.objectName ? resolveFieldLabel(schema.objectName, fieldName, fallback) : fallback,
318
- [schema.objectName, resolveFieldLabel],
319
- );
320
-
321
- // Resolve toolbar visibility flags: userActions overrides showX flags
322
- const toolbarFlags = React.useMemo(() => {
323
- const ua = schema.userActions;
324
- const addRecordEnabled = schema.addRecord?.enabled === true && ua?.addRecordForm !== false;
325
- return {
326
- showSearch: ua?.search !== undefined ? ua.search : schema.showSearch !== false,
327
- showSort: ua?.sort !== undefined ? ua.sort : schema.showSort !== false,
328
- showFilters: ua?.filter !== undefined ? ua.filter : schema.showFilters !== false,
329
- showDensity: ua?.rowHeight !== undefined ? ua.rowHeight : schema.showDensity === true,
330
- showHideFields: schema.showHideFields === true,
331
- showGroup: schema.showGroup !== false,
332
- showColor: schema.showColor === true,
333
- showAddRecord: addRecordEnabled,
334
- addRecordPosition: (schema.addRecord?.position === 'bottom' ? 'bottom' : 'top') as 'top' | 'bottom',
335
- };
336
- }, [schema.userActions, schema.showSearch, schema.showSort, schema.showFilters, schema.showDensity, schema.showHideFields, schema.showGroup, schema.showColor, schema.addRecord, schema.userActions?.addRecordForm]);
337
-
338
- const [currentView, setCurrentView] = React.useState<ViewType>(
339
- (schema.viewType as ViewType)
340
- );
341
- const [searchTerm, setSearchTerm] = React.useState('');
342
- const [showSearchPopover, setShowSearchPopover] = React.useState(false);
343
-
344
- // Sort State
345
- const [showSort, setShowSort] = React.useState(false);
346
- const [currentSort, setCurrentSort] = React.useState<SortItem[]>(() => {
347
- if (schema.sort && schema.sort.length > 0) {
348
- return schema.sort.map(s => {
349
- // Support legacy string format "field desc"
350
- if (typeof s === 'string') {
351
- const parts = s.trim().split(/\s+/);
352
- return {
353
- id: crypto.randomUUID(),
354
- field: parts[0],
355
- order: (parts[1]?.toLowerCase() === 'desc' ? 'desc' : 'asc') as 'asc' | 'desc',
356
- };
357
- }
358
- return {
359
- id: crypto.randomUUID(),
360
- field: s.field,
361
- order: (s.order as 'asc' | 'desc') || 'asc',
362
- };
363
- });
364
- }
365
- return [];
366
- });
367
-
368
- const [showFilters, setShowFilters] = React.useState(false);
369
-
370
- const [currentFilters, setCurrentFilters] = React.useState<FilterGroup>({
371
- id: 'root',
372
- logic: 'and',
373
- conditions: []
374
- });
375
-
376
- // Tab State
377
- const [activeTab, setActiveTab] = React.useState<string | undefined>(() => {
378
- if (!schema.tabs || schema.tabs.length === 0) return undefined;
379
- const defaultTab = schema.tabs.find(t => t.isDefault);
380
- return defaultTab?.name ?? schema.tabs[0]?.name;
381
- });
382
-
383
- const handleTabChange = React.useCallback(
384
- (tab: ViewTab) => {
385
- setActiveTab(tab.name);
386
- // Apply tab filter if defined
387
- if (tab.filter) {
388
- const tabFilters: FilterGroup = {
389
- id: `tab-filter-${tab.name}`,
390
- logic: tab.filter.logic || 'and',
391
- conditions: tab.filter.conditions || [],
392
- };
393
- setCurrentFilters(tabFilters);
394
- onFilterChange?.(tabFilters);
395
- } else {
396
- const emptyFilters: FilterGroup = { id: 'root', logic: 'and', conditions: [] };
397
- setCurrentFilters(emptyFilters);
398
- onFilterChange?.(emptyFilters);
399
- }
400
- },
401
- [onFilterChange],
402
- );
403
-
404
- // Data State
405
- const dataSource = props.dataSource;
406
- const [data, setData] = React.useState<any[]>([]);
407
- const [loading, setLoading] = React.useState(false);
408
- const [objectDef, setObjectDef] = React.useState<any>(null);
409
- const [objectDefLoaded, setObjectDefLoaded] = React.useState(false);
410
- const [refreshKey, setRefreshKey] = React.useState(0);
411
- const [dataLimitReached, setDataLimitReached] = React.useState(false);
412
-
413
- // --- P1: Imperative refresh API ---
414
- React.useImperativeHandle(ref, () => ({
415
- refresh: () => setRefreshKey(k => k + 1),
416
- }), []);
417
-
418
- // --- P2: Auto-subscribe to DataSource mutation events ---
419
- // When an external refreshTrigger is provided, rely on that instead of
420
- // subscribing to dataSource mutations to avoid double refreshes.
421
- React.useEffect(() => {
422
- if (!dataSource?.onMutation || !schema.objectName || schema.refreshTrigger) return;
423
- const unsub = dataSource.onMutation((event) => {
424
- if (event.resource === schema.objectName) {
425
- setRefreshKey(k => k + 1);
426
- }
427
- });
428
- return unsub;
429
- }, [dataSource, schema.objectName, schema.refreshTrigger]);
430
-
431
- // Dynamic page size state (wired from pageSizeOptions selector)
432
- const [dynamicPageSize, setDynamicPageSize] = React.useState<number | undefined>(undefined);
433
- const effectivePageSize = dynamicPageSize ?? schema.pagination?.pageSize ?? 100;
434
-
435
- // Grouping state (initialized from schema, user can add/remove via popover)
436
- const [groupingConfig, setGroupingConfig] = React.useState(schema.grouping);
437
- const [showGroupPopover, setShowGroupPopover] = React.useState(false);
438
-
439
- // Row color state (initialized from schema, user can configure via popover)
440
- const [rowColorConfig, setRowColorConfig] = React.useState(schema.rowColor);
441
- const [showColorPopover, setShowColorPopover] = React.useState(false);
442
-
443
- // Bulk action state
444
- const [selectedRows, setSelectedRows] = React.useState<any[]>([]);
445
-
446
- // Request counter for debounce — only the latest request writes data
447
- const fetchRequestIdRef = React.useRef(0);
448
-
449
- // Quick Filters State
450
- const [activeQuickFilters, setActiveQuickFilters] = React.useState<Set<string>>(() => {
451
- const defaults = new Set<string>();
452
- schema.quickFilters?.forEach(qf => {
453
- const normalized = normalizeQuickFilter(qf);
454
- if (normalized.defaultActive) defaults.add(normalized.id);
455
- });
456
- return defaults;
457
- });
458
-
459
- // User Filters State (Airtable Interfaces-style)
460
- const [userFilterConditions, setUserFilterConditions] = React.useState<any[]>([]);
461
-
462
- // Auto-derive userFilters from objectDef when not explicitly configured
463
- const resolvedUserFilters = React.useMemo<ListViewSchema['userFilters'] | undefined>(() => {
464
- // If explicitly configured, use as-is
465
- if (schema.userFilters) return schema.userFilters;
466
-
467
- // Auto-derive from objectDef for select/multi-select/boolean fields
468
- if (!objectDef?.fields) return undefined;
469
-
470
- const FILTERABLE_FIELD_TYPES = new Set(['select', 'multi-select', 'boolean']);
471
- const derivedFields: NonNullable<NonNullable<ListViewSchema['userFilters']>['fields']> = [];
472
-
473
- const fieldsEntries: Array<[string, any]> = Array.isArray(objectDef.fields)
474
- ? objectDef.fields.map((f: any) => [f.name, f])
475
- : Object.entries(objectDef.fields);
476
-
477
- for (const [key, field] of fieldsEntries) {
478
- // Include fields with a filterable type, or fields that have options without an explicit type
479
- if (FILTERABLE_FIELD_TYPES.has(field.type) || (field.options && !field.type)) {
480
- derivedFields.push({
481
- field: key,
482
- label: tFieldLabel(key, field.label || key),
483
- type: field.type === 'boolean' ? 'boolean' : field.type === 'multi-select' ? 'multi-select' : 'select',
484
- });
485
- }
486
- }
487
-
488
- if (derivedFields.length === 0) return undefined;
489
-
490
- return { element: 'dropdown', fields: derivedFields };
491
- }, [schema.userFilters, objectDef]);
492
-
493
- // Hidden Fields State (initialized from schema)
494
- const [hiddenFields, setHiddenFields] = React.useState<Set<string>>(
495
- () => new Set(schema.hiddenFields || [])
496
- );
497
- const [showHideFields, setShowHideFields] = React.useState(false);
498
-
499
- // Export State
500
- const [showExport, setShowExport] = React.useState(false);
501
-
502
- // Normalize quickFilters: support both ObjectUI format { id, label, filters[] }
503
- // and spec format { field, operator, value }. Spec items are auto-converted.
504
- const normalizedQuickFilters = React.useMemo(
505
- () => normalizeQuickFilters(schema.quickFilters),
506
- [schema.quickFilters],
507
- );
508
-
509
- // Normalize exportOptions: support both ObjectUI object format and spec string[] format
510
- const resolvedExportOptions = React.useMemo(() => {
511
- if (!schema.exportOptions) return undefined;
512
- // Spec format: simple string[] like ['csv', 'xlsx']
513
- if (Array.isArray(schema.exportOptions)) {
514
- return { formats: schema.exportOptions as Array<'csv' | 'xlsx' | 'json' | 'pdf'> };
515
- }
516
- // ObjectUI format: already an object
517
- return schema.exportOptions;
518
- }, [schema.exportOptions]);
519
-
520
- // Density Mode — rowHeight maps to density if densityMode not explicitly set
521
- const resolvedDensity = React.useMemo(() => {
522
- if (schema.densityMode) return schema.densityMode;
523
- if (schema.rowHeight) {
524
- const map: Record<string, 'compact' | 'comfortable' | 'spacious'> = {
525
- compact: 'compact',
526
- short: 'compact',
527
- medium: 'comfortable',
528
- tall: 'spacious',
529
- extra_tall: 'spacious',
530
- };
531
- return map[schema.rowHeight] || 'comfortable';
532
- }
533
- return 'compact';
534
- }, [schema.densityMode, schema.rowHeight]);
535
- const density = useDensityMode(resolvedDensity);
536
-
537
- const handlePullRefresh = React.useCallback(async () => {
538
- setRefreshKey(k => k + 1);
539
- }, []);
540
-
541
- const { ref: pullRef, isRefreshing, pullDistance } = usePullToRefresh<HTMLDivElement>({
542
- onRefresh: handlePullRefresh,
543
- enabled: !!dataSource && !!schema.objectName,
544
- });
545
-
546
- const storageKey = React.useMemo(() => {
547
- return schema.id
548
- ? `listview-${schema.objectName}-${schema.id}-view`
549
- : `listview-${schema.objectName}-view`;
550
- }, [schema.objectName, schema.id]);
551
-
552
- // Fetch object definition
553
- React.useEffect(() => {
554
- let isMounted = true;
555
- // Reset loaded flag so data fetch waits for the new schema
556
- setObjectDefLoaded(false);
557
- setObjectDef(null);
558
- const fetchObjectDef = async () => {
559
- if (!dataSource || !schema.objectName) {
560
- setObjectDefLoaded(true);
561
- return;
562
- }
563
- try {
564
- const def = await dataSource.getObjectSchema(schema.objectName);
565
- if (isMounted) {
566
- setObjectDef(def);
567
- }
568
- } catch (err) {
569
- console.warn("Failed to fetch object schema for ListView:", err);
570
- } finally {
571
- if (isMounted) {
572
- setObjectDefLoaded(true);
573
- }
574
- }
575
- };
576
- fetchObjectDef();
577
- return () => { isMounted = false; };
578
- }, [schema.objectName, dataSource]);
579
-
580
- // Auto-compute $expand fields from objectDef (lookup / master_detail)
581
- const expandFields = React.useMemo(
582
- () => buildExpandFields(objectDef?.fields, schema.fields),
583
- [objectDef?.fields, schema.fields],
584
- );
585
-
586
- // Fetch data effect — supports schema.data (ViewDataSchema) provider modes
587
- React.useEffect(() => {
588
- let isMounted = true;
589
- const requestId = ++fetchRequestIdRef.current;
590
-
591
- // Check for inline data via schema.data provider: 'value'
592
- if (schema.data && typeof schema.data === 'object' && !Array.isArray(schema.data)) {
593
- const dataConfig = schema.data as any;
594
- if (dataConfig.provider === 'value' && Array.isArray(dataConfig.items)) {
595
- let items = dataConfig.items;
596
- if (searchTerm) {
597
- const q = searchTerm.toLowerCase();
598
- items = items.filter((row: any) =>
599
- Object.values(row).some(
600
- (v) => v != null && String(v).toLowerCase().includes(q),
601
- ),
602
- );
603
- }
604
- setData(items);
605
- setLoading(false);
606
- setDataLimitReached(false);
607
- return;
608
- }
609
- }
610
- // Also support schema.data as a plain array (shorthand for value provider)
611
- if (Array.isArray(schema.data)) {
612
- let items = schema.data as any[];
613
- if (searchTerm) {
614
- const q = searchTerm.toLowerCase();
615
- items = items.filter((row: any) =>
616
- Object.values(row).some(
617
- (v) => v != null && String(v).toLowerCase().includes(q),
618
- ),
619
- );
620
- }
621
- setData(items);
622
- setLoading(false);
623
- setDataLimitReached(false);
624
- return;
625
- }
626
-
627
- // Wait for objectDef to load before fetching data so that $expand is computed
628
- if (!objectDefLoaded) return;
629
-
630
- const fetchData = async () => {
631
- if (!dataSource || !schema.objectName) return;
632
-
633
- setLoading(true);
634
- try {
635
- // Construct filter
636
- let finalFilter: any = [];
637
- const baseFilter = schema.filters || [];
638
- const userFilter = convertFilterGroupToAST(currentFilters);
639
-
640
- // Collect active quick filter conditions
641
- const quickFilterConditions: any[] = [];
642
- if (normalizedQuickFilters && activeQuickFilters.size > 0) {
643
- normalizedQuickFilters.forEach((qf: any) => {
644
- if (activeQuickFilters.has(qf.id) && qf.filters && qf.filters.length > 0) {
645
- quickFilterConditions.push(qf.filters);
646
- }
647
- });
648
- }
649
-
650
- // Normalize userFilter conditions (convert `in` to `or` of `=`)
651
- const normalizedUserFilterConditions = normalizeFilters(userFilterConditions);
652
-
653
- // Merge all filter sources with consistent structure
654
- const allFilters = [
655
- ...(baseFilter.length > 0 ? [baseFilter] : []),
656
- ...(userFilter.length > 0 ? [userFilter] : []),
657
- ...quickFilterConditions,
658
- ...normalizedUserFilterConditions,
659
- ].filter(f => Array.isArray(f) && f.length > 0);
660
-
661
- if (allFilters.length > 1) {
662
- finalFilter = ['and', ...allFilters];
663
- } else if (allFilters.length === 1) {
664
- finalFilter = allFilters[0];
665
- }
666
-
667
- // Convert sort to query format
668
- // Use array format to ensure order is preserved (Object keys are not guaranteed ordered)
669
- const sort: any = currentSort.length > 0
670
- ? currentSort
671
- .filter(item => item.field) // Ensure field is selected
672
- .map(item => ({ field: item.field, order: item.order }))
673
- : undefined;
674
-
675
- const results = await dataSource.find(schema.objectName, {
676
- $filter: finalFilter,
677
- $orderby: sort,
678
- $top: effectivePageSize,
679
- ...(expandFields.length > 0 ? { $expand: expandFields } : {}),
680
- ...(searchTerm ? {
681
- $search: searchTerm,
682
- ...(schema.searchableFields && schema.searchableFields.length > 0
683
- ? { $searchFields: schema.searchableFields }
684
- : {}),
685
- } : {}),
686
- });
687
-
688
- // Stale request guard: only apply the latest request's results
689
- if (!isMounted || requestId !== fetchRequestIdRef.current) return;
690
-
691
- let items: any[] = [];
692
- if (Array.isArray(results)) {
693
- items = results;
694
- } else if (results && typeof results === 'object') {
695
- if (Array.isArray((results as any).data)) {
696
- items = (results as any).data;
697
- } else if (Array.isArray((results as any).records)) {
698
- items = (results as any).records;
699
- } else if (Array.isArray((results as any).value)) {
700
- items = (results as any).value;
701
- }
702
- }
703
-
704
- setData(items);
705
- setDataLimitReached(items.length >= effectivePageSize);
706
- } catch (err) {
707
- // Only log errors from the latest request
708
- if (requestId === fetchRequestIdRef.current) {
709
- console.error("ListView data fetch error:", err);
710
- }
711
- } finally {
712
- if (isMounted && requestId === fetchRequestIdRef.current) {
713
- setLoading(false);
714
- }
715
- }
716
- };
717
-
718
- fetchData();
719
-
720
- return () => { isMounted = false; };
721
- }, [schema.objectName, schema.data, dataSource, schema.filters, effectivePageSize, currentSort, currentFilters, activeQuickFilters, normalizedQuickFilters, userFilterConditions, refreshKey, searchTerm, schema.searchableFields, expandFields, objectDefLoaded, schema.refreshTrigger]); // Re-fetch on filter/sort/search/refreshTrigger change
722
-
723
- // Available view types based on schema configuration
724
- const availableViews = React.useMemo(() => {
725
- // If appearance.allowedVisualizations is set, use it as whitelist
726
- if (schema.appearance?.allowedVisualizations && schema.appearance.allowedVisualizations.length > 0) {
727
- return schema.appearance.allowedVisualizations.filter(v =>
728
- ['grid', 'kanban', 'gallery', 'calendar', 'timeline', 'gantt', 'map'].includes(v)
729
- ) as ViewType[];
730
- }
731
-
732
- const views: ViewType[] = ['grid'];
733
-
734
- // Check for Kanban capabilities (spec config takes precedence)
735
- if (schema.kanban?.groupField || schema.options?.kanban?.groupField) {
736
- views.push('kanban');
737
- }
738
-
739
- // Check for Gallery capabilities (spec config takes precedence)
740
- if (schema.gallery?.coverField || schema.gallery?.imageField || schema.options?.gallery?.imageField) {
741
- views.push('gallery');
742
- }
743
-
744
- // Check for Calendar capabilities (spec config takes precedence)
745
- if (schema.calendar?.startDateField || schema.options?.calendar?.startDateField) {
746
- views.push('calendar');
747
- }
748
-
749
- // Check for Timeline capabilities (spec config takes precedence)
750
- if (schema.timeline?.startDateField || schema.options?.timeline?.startDateField || schema.options?.timeline?.dateField || schema.options?.calendar?.startDateField) {
751
- views.push('timeline');
752
- }
753
-
754
- // Check for Gantt capabilities (spec config takes precedence)
755
- if (schema.gantt?.startDateField || schema.options?.gantt?.startDateField) {
756
- views.push('gantt');
757
- }
758
-
759
- // Check for Map capabilities
760
- if (schema.options?.map?.locationField || (schema.options?.map?.latitudeField && schema.options?.map?.longitudeField)) {
761
- views.push('map');
762
- }
763
-
764
- // Always allow switching back to the viewType defined in schema if it's one of the supported types
765
- if (schema.viewType && !views.includes(schema.viewType as ViewType) &&
766
- ['grid', 'kanban', 'calendar', 'timeline', 'gantt', 'map', 'gallery'].includes(schema.viewType)) {
767
- views.push(schema.viewType as ViewType);
768
- }
769
-
770
- return views;
771
- }, [schema.options, schema.viewType, schema.kanban, schema.calendar, schema.gantt, schema.gallery, schema.timeline, schema.appearance?.allowedVisualizations]);
772
-
773
- // Sync view from props
774
- React.useEffect(() => {
775
- if (schema.viewType) {
776
- setCurrentView(schema.viewType as ViewType);
777
- }
778
- }, [schema.viewType]);
779
-
780
- // Load saved view preference (DISABLED: interfering with schema-defined views)
781
- /*
782
- React.useEffect(() => {
783
- try {
784
- const savedView = localStorage.getItem(storageKey);
785
- if (savedView && ['grid', 'kanban', 'calendar', 'timeline', 'gantt', 'map', 'gallery'].includes(savedView) && availableViews.includes(savedView as ViewType)) {
786
- setCurrentView(savedView as ViewType);
787
- }
788
- } catch (error) {
789
- console.warn('Failed to load view preference from localStorage:', error);
790
- }
791
- }, [storageKey, availableViews]);
792
- */
793
-
794
- const handleViewChange = React.useCallback((view: ViewType) => {
795
- setCurrentView(view);
796
- try {
797
- localStorage.setItem(storageKey, view);
798
- } catch (error) {
799
- console.warn('Failed to save view preference to localStorage:', error);
800
- }
801
- onViewChange?.(view);
802
- }, [storageKey, onViewChange]);
803
-
804
- const handleSearchChange = React.useCallback((value: string) => {
805
- setSearchTerm(value);
806
- onSearchChange?.(value);
807
- }, [onSearchChange]);
808
-
809
- // --- NavigationConfig support ---
810
- const navigation = useNavigationOverlay({
811
- navigation: schema.navigation,
812
- objectName: schema.objectName,
813
- onNavigate: schema.onNavigate,
814
- onRowClick,
815
- });
816
-
817
- // Apply hiddenFields and fieldOrder to produce effective fields
818
- const effectiveFields = React.useMemo(() => {
819
- let fields = schema.fields || [];
820
-
821
- // Defensive: ensure fields is an array of strings/objects
822
- if (!Array.isArray(fields)) {
823
- fields = [];
824
- }
825
-
826
- // Remove hidden fields
827
- if (hiddenFields.size > 0) {
828
- fields = fields.filter((f: any) => {
829
- const fieldName = typeof f === 'string' ? f : (f?.name || f?.fieldName || f?.field);
830
- return fieldName != null && !hiddenFields.has(fieldName);
831
- });
832
- }
833
-
834
- // Apply field order
835
- if (schema.fieldOrder && schema.fieldOrder.length > 0) {
836
- const orderMap = new Map(schema.fieldOrder.map((f, i) => [f, i]));
837
- fields = [...fields].sort((a: any, b: any) => {
838
- const nameA = typeof a === 'string' ? a : (a?.name || a?.fieldName || a?.field);
839
- const nameB = typeof b === 'string' ? b : (b?.name || b?.fieldName || b?.field);
840
- const orderA = orderMap.get(nameA) ?? Infinity;
841
- const orderB = orderMap.get(nameB) ?? Infinity;
842
- return orderA - orderB;
843
- });
844
- }
845
-
846
- return fields;
847
- }, [schema.fields, hiddenFields, schema.fieldOrder]);
848
-
849
- // Generate the appropriate view component schema
850
- const viewComponentSchema = React.useMemo(() => {
851
- const baseProps = {
852
- objectName: schema.objectName,
853
- fields: effectiveFields,
854
- filters: schema.filters,
855
- sort: currentSort,
856
- className: "h-full w-full",
857
- // Disable internal controls that clash with ListView toolbar
858
- showSearch: false,
859
- // Pass navigation click handler to child views
860
- onRowClick: navigation.handleClick,
861
- // Forward display properties to child views
862
- ...(schema.striped != null ? { striped: schema.striped } : {}),
863
- ...(schema.bordered != null ? { bordered: schema.bordered } : {}),
864
- };
865
-
866
- switch (currentView) {
867
- case 'grid':
868
- return {
869
- type: 'object-grid',
870
- ...baseProps,
871
- columns: effectiveFields,
872
- ...(schema.conditionalFormatting ? { conditionalFormatting: schema.conditionalFormatting } : {}),
873
- ...(schema.inlineEdit != null ? { editable: schema.inlineEdit } : {}),
874
- ...(schema.wrapHeaders != null ? { wrapHeaders: schema.wrapHeaders } : {}),
875
- ...(schema.virtualScroll != null ? { virtualScroll: schema.virtualScroll } : {}),
876
- ...(schema.resizable != null ? { resizable: schema.resizable } : {}),
877
- ...(schema.selection ? { selection: schema.selection } : {}),
878
- ...(schema.pagination ? { pagination: schema.pagination } : {}),
879
- ...(groupingConfig ? { grouping: groupingConfig } : {}),
880
- ...(rowColorConfig ? { rowColor: rowColorConfig } : {}),
881
- ...(schema.rowActions ? { rowActions: schema.rowActions } : {}),
882
- ...(schema.bulkActions ? { batchActions: schema.bulkActions } : {}),
883
- ...(schema.options?.grid || {}),
884
- };
885
- case 'kanban':
886
- return {
887
- type: 'object-kanban',
888
- ...baseProps,
889
- groupBy: schema.kanban?.groupField || schema.options?.kanban?.groupField || 'status',
890
- groupField: schema.kanban?.groupField || schema.options?.kanban?.groupField || 'status',
891
- titleField: schema.kanban?.titleField || schema.options?.kanban?.titleField || 'name',
892
- cardFields: schema.kanban?.cardFields || effectiveFields || [],
893
- ...(groupingConfig ? { grouping: groupingConfig } : {}),
894
- ...(schema.options?.kanban || {}),
895
- ...(schema.kanban || {}),
896
- };
897
- case 'calendar':
898
- return {
899
- type: 'object-calendar',
900
- ...baseProps,
901
- startDateField: schema.calendar?.startDateField || schema.options?.calendar?.startDateField || 'start_date',
902
- endDateField: schema.calendar?.endDateField || schema.options?.calendar?.endDateField || 'end_date',
903
- titleField: schema.calendar?.titleField || schema.options?.calendar?.titleField || 'name',
904
- ...(schema.calendar?.defaultView ? { defaultView: schema.calendar.defaultView } : {}),
905
- ...(schema.options?.calendar || {}),
906
- ...(schema.calendar || {}),
907
- };
908
- case 'gallery': {
909
- // Merge spec config over legacy options into nested gallery prop
910
- const mergedGallery = {
911
- ...(schema.options?.gallery || {}),
912
- ...(schema.gallery || {}),
913
- };
914
- return {
915
- type: 'object-gallery',
916
- ...baseProps,
917
- // Nested gallery config (spec-compliant, used by ObjectGallery)
918
- gallery: Object.keys(mergedGallery).length > 0 ? mergedGallery : undefined,
919
- // Deprecated top-level props for backward compat
920
- imageField: schema.gallery?.coverField || schema.gallery?.imageField || schema.options?.gallery?.imageField,
921
- titleField: schema.gallery?.titleField || schema.options?.gallery?.titleField || 'name',
922
- subtitleField: schema.gallery?.subtitleField || schema.options?.gallery?.subtitleField,
923
- ...(groupingConfig ? { grouping: groupingConfig } : {}),
924
- };
925
- }
926
- case 'timeline': {
927
- // Merge spec config over legacy options into nested timeline prop
928
- const mergedTimeline = {
929
- ...(schema.options?.timeline || {}),
930
- ...(schema.timeline || {}),
931
- };
932
- return {
933
- type: 'object-timeline',
934
- ...baseProps,
935
- // Nested timeline config (spec-compliant, used by ObjectTimeline)
936
- timeline: Object.keys(mergedTimeline).length > 0 ? mergedTimeline : undefined,
937
- // Deprecated top-level props for backward compat
938
- startDateField: schema.timeline?.startDateField || schema.options?.timeline?.startDateField || schema.options?.timeline?.dateField || 'created_at',
939
- titleField: schema.timeline?.titleField || schema.options?.timeline?.titleField || 'name',
940
- ...(schema.timeline?.endDateField ? { endDateField: schema.timeline.endDateField } : {}),
941
- ...(schema.timeline?.groupByField ? { groupByField: schema.timeline.groupByField } : {}),
942
- ...(schema.timeline?.colorField ? { colorField: schema.timeline.colorField } : {}),
943
- ...(schema.timeline?.scale ? { scale: schema.timeline.scale } : {}),
944
- };
945
- }
946
- case 'gantt':
947
- return {
948
- type: 'object-gantt',
949
- ...baseProps,
950
- startDateField: schema.gantt?.startDateField || schema.options?.gantt?.startDateField || 'start_date',
951
- endDateField: schema.gantt?.endDateField || schema.options?.gantt?.endDateField || 'end_date',
952
- progressField: schema.gantt?.progressField || schema.options?.gantt?.progressField || 'progress',
953
- dependenciesField: schema.gantt?.dependenciesField || schema.options?.gantt?.dependenciesField || 'dependencies',
954
- ...(schema.gantt?.titleField ? { titleField: schema.gantt.titleField } : {}),
955
- ...(schema.options?.gantt || {}),
956
- ...(schema.gantt || {}),
957
- };
958
- case 'map':
959
- return {
960
- type: 'object-map',
961
- ...baseProps,
962
- locationField: schema.options?.map?.locationField || 'location',
963
- ...(schema.options?.map || {}),
964
- };
965
- default:
966
- return baseProps;
967
- }
968
- }, [currentView, schema, currentSort, effectiveFields, groupingConfig, rowColorConfig, navigation.handleClick]);
969
-
970
- const hasFilters = currentFilters.conditions && currentFilters.conditions.length > 0;
971
-
972
- const filterFields = React.useMemo(() => {
973
- let fields: Array<{ value: string; label: string; type: string; options?: any }>;
974
-
975
- if (!objectDef?.fields) {
976
- // Fallback to schema fields if objectDef not loaded yet
977
- fields = (schema.fields || []).map((f: any) => {
978
- if (typeof f === 'string') return { value: f, label: f, type: 'text' };
979
- const fieldName = f.name || f.fieldName;
980
- return {
981
- value: fieldName,
982
- label: tFieldLabel(fieldName, f.label || f.name),
983
- type: f.type || 'text',
984
- options: f.options
985
- };
986
- });
987
- } else {
988
- fields = Object.entries(objectDef.fields).map(([key, field]: [string, any]) => ({
989
- value: key,
990
- label: tFieldLabel(key, field.label || key),
991
- type: field.type || 'text',
992
- options: field.options
993
- }));
994
- }
995
-
996
- // Apply filterableFields whitelist restriction
997
- if (schema.filterableFields && schema.filterableFields.length > 0) {
998
- const allowed = new Set(schema.filterableFields);
999
- fields = fields.filter(f => allowed.has(f.value));
1000
- }
1001
-
1002
- return fields;
1003
- }, [objectDef, schema.fields, schema.filterableFields]);
1004
-
1005
- // Quick filter toggle handler
1006
- const toggleQuickFilter = React.useCallback((id: string) => {
1007
- setActiveQuickFilters(prev => {
1008
- const next = new Set(prev);
1009
- if (next.has(id)) {
1010
- next.delete(id);
1011
- } else {
1012
- next.add(id);
1013
- }
1014
- return next;
1015
- });
1016
- }, []);
1017
-
1018
- // Export handler
1019
- const handleExport = React.useCallback((format: 'csv' | 'xlsx' | 'json' | 'pdf') => {
1020
- const exportConfig = resolvedExportOptions;
1021
- const maxRecords = exportConfig?.maxRecords || 0;
1022
- const includeHeaders = exportConfig?.includeHeaders !== false;
1023
- const prefix = exportConfig?.fileNamePrefix || schema.objectName || 'export';
1024
- const exportData = maxRecords > 0 ? data.slice(0, maxRecords) : data;
1025
-
1026
- if (format === 'csv') {
1027
- const fields = effectiveFields.map((f: any) => typeof f === 'string' ? f : (f.name || f.fieldName || f.field));
1028
- const rows: string[] = [];
1029
- if (includeHeaders) {
1030
- rows.push(fields.join(','));
1031
- }
1032
- exportData.forEach(record => {
1033
- rows.push(fields.map((f: string) => {
1034
- const val = record[f];
1035
- // Type-safe serialization: handle arrays, objects, null/undefined
1036
- let str: string;
1037
- if (val == null) {
1038
- str = '';
1039
- } else if (Array.isArray(val)) {
1040
- str = val.map(v =>
1041
- (v != null && typeof v === 'object') ? JSON.stringify(v) : String(v ?? ''),
1042
- ).join('; ');
1043
- } else if (typeof val === 'object') {
1044
- str = JSON.stringify(val);
1045
- } else {
1046
- str = String(val);
1047
- }
1048
- // Escape CSV special characters
1049
- const needsQuoting = str.includes(',') || str.includes('"')
1050
- || str.includes('\n') || str.includes('\r');
1051
- return needsQuoting ? `"${str.replace(/"/g, '""')}"` : str;
1052
- }).join(','));
1053
- });
1054
- const blob = new Blob([rows.join('\n')], { type: 'text/csv;charset=utf-8;' });
1055
- const url = URL.createObjectURL(blob);
1056
- const a = document.createElement('a');
1057
- a.href = url;
1058
- a.download = `${prefix}.csv`;
1059
- a.click();
1060
- URL.revokeObjectURL(url);
1061
- } else if (format === 'json') {
1062
- const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
1063
- const url = URL.createObjectURL(blob);
1064
- const a = document.createElement('a');
1065
- a.href = url;
1066
- a.download = `${prefix}.json`;
1067
- a.click();
1068
- URL.revokeObjectURL(url);
1069
- }
1070
- setShowExport(false);
1071
- }, [data, effectiveFields, resolvedExportOptions, schema.objectName]);
1072
-
1073
- // All available fields for hide/show (with i18n)
1074
- const allFields = React.useMemo(() => {
1075
- return (schema.fields || []).map((f: any) => {
1076
- if (typeof f === 'string') {
1077
- return { name: f, label: tFieldLabel(f, f) };
1078
- }
1079
- const name = f.name || f.fieldName || f.field;
1080
- const rawLabel = f.label || f.name || f.field;
1081
- return { name, label: tFieldLabel(name, rawLabel) };
1082
- });
1083
- }, [schema.fields, tFieldLabel]);
1084
-
1085
- return (
1086
- <div
1087
- ref={pullRef}
1088
- className={cn('flex flex-col h-full bg-background relative min-w-0 overflow-hidden', className)}
1089
- {...(schema.aria?.label ? { 'aria-label': schema.aria.label } : {})}
1090
- {...(schema.aria?.describedBy ? { 'aria-describedby': schema.aria.describedBy } : {})}
1091
- {...(schema.aria?.live ? { 'aria-live': schema.aria.live } : {})}
1092
- role="region"
1093
- >
1094
- {pullDistance > 0 && (
1095
- <div
1096
- className="flex items-center justify-center text-xs text-muted-foreground"
1097
- style={{ height: pullDistance }}
1098
- >
1099
- {isRefreshing ? t('list.refreshing') : t('list.pullToRefresh')}
1100
- </div>
1101
- )}
1102
- {/* Airtable-style Toolbar — Row 1: View tabs */}
1103
- {showViewSwitcher && (
1104
- <div className="border-b px-4 py-1 flex items-center bg-background">
1105
- <ViewSwitcher
1106
- currentView={currentView}
1107
- availableViews={availableViews}
1108
- onViewChange={handleViewChange}
1109
- />
1110
- </div>
1111
- )}
1112
-
1113
- {/* View Tabs */}
1114
- {schema.tabs && schema.tabs.length > 0 && (
1115
- <TabBar
1116
- tabs={schema.tabs}
1117
- activeTab={activeTab}
1118
- onTabChange={handleTabChange}
1119
- />
1120
- )}
1121
-
1122
- {/* View Description */}
1123
- {schema.description && (schema.appearance?.showDescription !== false) && (
1124
- <div className="border-b px-4 py-1.5 text-xs text-muted-foreground bg-background" data-testid="view-description">
1125
- {typeof schema.description === 'string' ? schema.description : ''}
1126
- </div>
1127
- )}
1128
-
1129
- {/* Airtable-style Toolbar — UserFilter badges (left) + Tool buttons (right) */}
1130
- <div className="border-b px-2 sm:px-4 py-1 flex items-center justify-between gap-1 sm:gap-2 bg-background">
1131
- <div className="flex items-center gap-0.5 overflow-x-auto min-w-0">
1132
- {/* User Filters — inline in toolbar (Airtable Interfaces-style) */}
1133
- {resolvedUserFilters && (
1134
- <div className="shrink-0 min-w-0" data-testid="user-filters">
1135
- <UserFilters
1136
- config={resolvedUserFilters}
1137
- objectDef={objectDef}
1138
- data={data}
1139
- onFilterChange={setUserFilterConditions}
1140
- maxVisible={3}
1141
- />
1142
- </div>
1143
- )}
1144
- </div>
1145
-
1146
- <div className="flex items-center gap-0.5 shrink-0">
1147
- {/* Hide Fields */}
1148
- {toolbarFlags.showHideFields && (
1149
- <Popover open={showHideFields} onOpenChange={setShowHideFields}>
1150
- <PopoverTrigger asChild>
1151
- <Button
1152
- variant="ghost"
1153
- size="sm"
1154
- className={cn(
1155
- "h-7 px-2 text-muted-foreground hover:text-primary text-xs transition-colors duration-150",
1156
- hiddenFields.size > 0 && "text-primary"
1157
- )}
1158
- >
1159
- <EyeOff className="h-3.5 w-3.5 mr-1.5" />
1160
- <span className="hidden sm:inline">{t('list.hideFields')}</span>
1161
- {hiddenFields.size > 0 && (
1162
- <span className="ml-1 flex h-4 min-w-[16px] items-center justify-center rounded-full bg-primary/10 text-[10px] font-medium text-primary">
1163
- {hiddenFields.size}
1164
- </span>
1165
- )}
1166
- </Button>
1167
- </PopoverTrigger>
1168
- <PopoverContent align="start" className="w-64 p-3">
1169
- <div className="space-y-2">
1170
- <div className="flex items-center justify-between border-b pb-2">
1171
- <h4 className="font-medium text-sm">{t('list.hideFieldsTitle')}</h4>
1172
- {hiddenFields.size > 0 && (
1173
- <Button variant="ghost" size="sm" className="h-6 px-2 text-xs" onClick={() => setHiddenFields(new Set())}>
1174
- {t('list.showAll')}
1175
- </Button>
1176
- )}
1177
- </div>
1178
- <div className="max-h-60 overflow-y-auto space-y-1">
1179
- {allFields.map(field => (
1180
- <label key={field.name} className="flex items-center gap-2 text-sm py-1 px-1 rounded hover:bg-muted cursor-pointer">
1181
- <input
1182
- type="checkbox"
1183
- checked={!hiddenFields.has(field.name)}
1184
- onChange={() => {
1185
- setHiddenFields(prev => {
1186
- const next = new Set(prev);
1187
- if (next.has(field.name)) {
1188
- next.delete(field.name);
1189
- } else {
1190
- next.add(field.name);
1191
- }
1192
- return next;
1193
- });
1194
- }}
1195
- className="rounded border-input"
1196
- />
1197
- <span className="truncate">{field.label}</span>
1198
- </label>
1199
- ))}
1200
- </div>
1201
- </div>
1202
- </PopoverContent>
1203
- </Popover>
1204
- )}
1205
-
1206
- {/* --- Separator: Hide Fields | Data Manipulation --- */}
1207
- {toolbarFlags.showHideFields && (toolbarFlags.showFilters || toolbarFlags.showSort || toolbarFlags.showGroup) && (
1208
- <div className="h-4 w-px bg-border/60 mx-0.5 shrink-0" />
1209
- )}
1210
-
1211
- {/* Filter */}
1212
- {toolbarFlags.showFilters && (
1213
- <Popover open={showFilters} onOpenChange={setShowFilters}>
1214
- <PopoverTrigger asChild>
1215
- <Button
1216
- variant="ghost"
1217
- size="sm"
1218
- className={cn(
1219
- "h-7 px-2 text-muted-foreground hover:text-primary text-xs transition-colors duration-150",
1220
- hasFilters && "bg-primary/10 border border-primary/20 text-primary"
1221
- )}
1222
- >
1223
- <SlidersHorizontal className="h-3.5 w-3.5 mr-1.5" />
1224
- <span className="hidden sm:inline">{t('list.filter')}</span>
1225
- {hasFilters && (
1226
- <span className="ml-1 flex h-4 min-w-[16px] items-center justify-center rounded-full bg-primary/10 text-[10px] font-medium text-primary">
1227
- {currentFilters.conditions?.length || 0}
1228
- </span>
1229
- )}
1230
- </Button>
1231
- </PopoverTrigger>
1232
- <PopoverContent align="start" className="w-[calc(100vw-2rem)] sm:w-[600px] max-w-[600px] p-3 sm:p-4">
1233
- <div className="space-y-4">
1234
- <div className="flex items-center justify-between border-b pb-2">
1235
- <h4 className="font-medium text-sm">{t('list.filterRecords')}</h4>
1236
- </div>
1237
- <FilterBuilder
1238
- fields={filterFields}
1239
- value={currentFilters}
1240
- onChange={(newFilters) => {
1241
- setCurrentFilters(newFilters);
1242
- if (onFilterChange) onFilterChange(newFilters);
1243
- }}
1244
- />
1245
- </div>
1246
- </PopoverContent>
1247
- </Popover>
1248
- )}
1249
-
1250
- {/* Group */}
1251
- {toolbarFlags.showGroup && (
1252
- <Popover open={showGroupPopover} onOpenChange={setShowGroupPopover}>
1253
- <PopoverTrigger asChild>
1254
- <Button
1255
- variant="ghost"
1256
- size="sm"
1257
- className={cn(
1258
- "h-7 px-2 text-muted-foreground hover:text-primary text-xs transition-colors duration-150",
1259
- groupingConfig && "bg-primary/10 border border-primary/20 text-primary"
1260
- )}
1261
- >
1262
- <Group className="h-3.5 w-3.5 mr-1.5" />
1263
- <span className="hidden sm:inline">{t('list.group')}</span>
1264
- {groupingConfig && groupingConfig.fields?.length > 0 && (
1265
- <span className="ml-1 flex h-4 min-w-[16px] items-center justify-center rounded-full bg-primary/10 text-[10px] font-medium text-primary">
1266
- {groupingConfig.fields.length}
1267
- </span>
1268
- )}
1269
- </Button>
1270
- </PopoverTrigger>
1271
- <PopoverContent align="start" className="w-64 p-3">
1272
- <div className="space-y-2">
1273
- <div className="flex items-center justify-between border-b pb-2">
1274
- <h4 className="font-medium text-sm">{t('list.groupBy')}</h4>
1275
- {groupingConfig && (
1276
- <Button variant="ghost" size="sm" className="h-6 px-2 text-xs" onClick={() => setGroupingConfig(undefined)} data-testid="clear-grouping">
1277
- {t('list.clear')}
1278
- </Button>
1279
- )}
1280
- </div>
1281
- <div className="max-h-60 overflow-y-auto space-y-1" data-testid="group-field-list">
1282
- {allFields.map(field => {
1283
- const isGrouped = groupingConfig?.fields?.some(f => f.field === field.name);
1284
- return (
1285
- <label key={field.name} className="flex items-center gap-2 text-sm py-1 px-1 rounded hover:bg-muted cursor-pointer">
1286
- <input
1287
- type="checkbox"
1288
- checked={!!isGrouped}
1289
- onChange={() => {
1290
- if (isGrouped) {
1291
- const newFields = (groupingConfig?.fields || []).filter(f => f.field !== field.name);
1292
- setGroupingConfig(newFields.length > 0 ? { fields: newFields } : undefined);
1293
- } else {
1294
- const existing = groupingConfig?.fields || [];
1295
- setGroupingConfig({ fields: [...existing, { field: field.name, order: 'asc', collapsed: false }] });
1296
- }
1297
- }}
1298
- className="rounded border-input"
1299
- />
1300
- <span className="truncate">{field.label}</span>
1301
- </label>
1302
- );
1303
- })}
1304
- </div>
1305
- </div>
1306
- </PopoverContent>
1307
- </Popover>
1308
- )}
1309
-
1310
- {/* Sort */}
1311
- {toolbarFlags.showSort && (
1312
- <Popover open={showSort} onOpenChange={setShowSort}>
1313
- <PopoverTrigger asChild>
1314
- <Button
1315
- variant="ghost"
1316
- size="sm"
1317
- className={cn(
1318
- "h-7 px-2 text-muted-foreground hover:text-primary text-xs transition-colors duration-150",
1319
- currentSort.length > 0 && "bg-primary/10 border border-primary/20 text-primary"
1320
- )}
1321
- >
1322
- <ArrowUpDown className="h-3.5 w-3.5 mr-1.5" />
1323
- <span className="hidden sm:inline">{t('list.sort')}</span>
1324
- {currentSort.length > 0 && (
1325
- <span className="ml-1 flex h-4 min-w-[16px] items-center justify-center rounded-full bg-primary/10 text-[10px] font-medium text-primary">
1326
- {currentSort.length}
1327
- </span>
1328
- )}
1329
- </Button>
1330
- </PopoverTrigger>
1331
- <PopoverContent align="start" className="w-[calc(100vw-2rem)] sm:w-[600px] max-w-[600px] p-3 sm:p-4">
1332
- <div className="space-y-4">
1333
- <div className="flex items-center justify-between border-b pb-2">
1334
- <h4 className="font-medium text-sm">{t('list.sortRecords')}</h4>
1335
- </div>
1336
- <SortBuilder
1337
- fields={filterFields}
1338
- value={currentSort}
1339
- onChange={(newSort) => {
1340
- setCurrentSort(newSort);
1341
- if (onSortChange) onSortChange(newSort);
1342
- }}
1343
- />
1344
- </div>
1345
- </PopoverContent>
1346
- </Popover>
1347
- )}
1348
-
1349
- {/* --- Separator: Data Manipulation | Appearance --- */}
1350
- {(toolbarFlags.showFilters || toolbarFlags.showSort || toolbarFlags.showGroup) && (toolbarFlags.showColor || toolbarFlags.showDensity) && (
1351
- <div className="h-4 w-px bg-border/60 mx-0.5 shrink-0" />
1352
- )}
1353
-
1354
- {/* Color */}
1355
- {toolbarFlags.showColor && (
1356
- <Popover open={showColorPopover} onOpenChange={setShowColorPopover}>
1357
- <PopoverTrigger asChild>
1358
- <Button
1359
- variant="ghost"
1360
- size="sm"
1361
- className={cn(
1362
- "h-7 px-2 text-muted-foreground hover:text-primary text-xs transition-colors duration-150",
1363
- rowColorConfig && "bg-primary/10 border border-primary/20 text-primary"
1364
- )}
1365
- >
1366
- <Paintbrush className="h-3.5 w-3.5 mr-1.5" />
1367
- <span className="hidden sm:inline">{t('list.color')}</span>
1368
- </Button>
1369
- </PopoverTrigger>
1370
- <PopoverContent align="start" className="w-64 p-3">
1371
- <div className="space-y-2">
1372
- <div className="flex items-center justify-between border-b pb-2">
1373
- <h4 className="font-medium text-sm">{t('list.rowColor')}</h4>
1374
- {rowColorConfig && (
1375
- <Button variant="ghost" size="sm" className="h-6 px-2 text-xs" onClick={() => setRowColorConfig(undefined)} data-testid="clear-row-color">
1376
- {t('list.clear')}
1377
- </Button>
1378
- )}
1379
- </div>
1380
- <div className="space-y-2" data-testid="color-field-list">
1381
- <label className="text-xs text-muted-foreground">{t('list.colorByField')}</label>
1382
- <select
1383
- className="w-full h-8 rounded border border-input bg-background px-2 text-xs"
1384
- value={rowColorConfig?.field || ''}
1385
- onChange={(e) => {
1386
- const field = e.target.value;
1387
- if (!field) {
1388
- setRowColorConfig(undefined);
1389
- } else {
1390
- setRowColorConfig({ field, colors: rowColorConfig?.colors || {} });
1391
- }
1392
- }}
1393
- data-testid="color-field-select"
1394
- >
1395
- <option value="">{t('list.none')}</option>
1396
- {allFields.map(field => (
1397
- <option key={field.name} value={field.name}>{field.label}</option>
1398
- ))}
1399
- </select>
1400
- </div>
1401
- </div>
1402
- </PopoverContent>
1403
- </Popover>
1404
- )}
1405
-
1406
- {/* Row Height / Density Mode */}
1407
- {toolbarFlags.showDensity && (
1408
- <Button
1409
- variant="ghost"
1410
- size="sm"
1411
- className={cn(
1412
- "h-7 px-2 text-muted-foreground hover:text-primary text-xs hidden lg:flex transition-colors duration-150",
1413
- density.mode !== 'compact' && "bg-primary/10 border border-primary/20 text-primary"
1414
- )}
1415
- onClick={density.cycle}
1416
- title={`Density: ${density.mode}`}
1417
- >
1418
- <AlignJustify className="h-3.5 w-3.5 mr-1.5" />
1419
- <span className="hidden sm:inline capitalize">{density.mode}</span>
1420
- </Button>
1421
- )}
1422
-
1423
- {/* --- Separator: Appearance | Export --- */}
1424
- {(toolbarFlags.showColor || toolbarFlags.showDensity) && resolvedExportOptions && schema.allowExport !== false && (
1425
- <div className="h-4 w-px bg-border/60 mx-0.5 shrink-0" />
1426
- )}
1427
-
1428
- {/* Export */}
1429
- {resolvedExportOptions && schema.allowExport !== false && (
1430
- <Popover open={showExport} onOpenChange={setShowExport}>
1431
- <PopoverTrigger asChild>
1432
- <Button
1433
- variant="ghost"
1434
- size="sm"
1435
- className="h-7 px-2 text-muted-foreground hover:text-primary text-xs transition-colors duration-150"
1436
- >
1437
- <Download className="h-3.5 w-3.5 mr-1.5" />
1438
- <span className="hidden sm:inline">{t('list.export')}</span>
1439
- </Button>
1440
- </PopoverTrigger>
1441
- <PopoverContent align="start" className="w-48 p-2">
1442
- <div className="space-y-1">
1443
- {(resolvedExportOptions.formats || ['csv', 'json']).map(format => (
1444
- <Button
1445
- key={format}
1446
- variant="ghost"
1447
- size="sm"
1448
- className="w-full justify-start h-8 text-xs"
1449
- onClick={() => handleExport(format)}
1450
- >
1451
- <Download className="h-3.5 w-3.5 mr-2" />
1452
- {t('list.exportAs', { format: format.toUpperCase() })}
1453
- </Button>
1454
- ))}
1455
- </div>
1456
- </PopoverContent>
1457
- </Popover>
1458
- )}
1459
-
1460
- {/* Share — supports both ObjectUI visibility model and spec personal/collaborative model */}
1461
- {(schema.sharing?.enabled || schema.sharing?.type) && (
1462
- <Button
1463
- variant="ghost"
1464
- size="sm"
1465
- className="h-7 px-2 text-muted-foreground hover:text-primary text-xs transition-colors duration-150"
1466
- title={`Sharing: ${schema.sharing?.visibility || schema.sharing?.type || 'private'}`}
1467
- data-testid="share-button"
1468
- >
1469
- <Share2 className="h-3.5 w-3.5 mr-1.5" />
1470
- <span className="hidden sm:inline">{t('list.share')}</span>
1471
- </Button>
1472
- )}
1473
-
1474
- {/* Print */}
1475
- {schema.allowPrinting && (
1476
- <Button
1477
- variant="ghost"
1478
- size="sm"
1479
- className="h-7 px-2 text-muted-foreground hover:text-primary text-xs transition-colors duration-150"
1480
- onClick={() => window.print()}
1481
- data-testid="print-button"
1482
- >
1483
- <Printer className="h-3.5 w-3.5 mr-1.5" />
1484
- <span className="hidden sm:inline">{t('list.print')}</span>
1485
- </Button>
1486
- )}
1487
-
1488
- {/* --- Separator: Print/Share/Export | Search --- */}
1489
- {(() => {
1490
- const hasLeftSideItems = schema.allowPrinting || (schema.sharing?.enabled || schema.sharing?.type) || (resolvedExportOptions && schema.allowExport !== false);
1491
- return toolbarFlags.showSearch && hasLeftSideItems ? (
1492
- <div className="h-4 w-px bg-border/60 mx-0.5 shrink-0" />
1493
- ) : null;
1494
- })()}
1495
-
1496
- {/* Search (icon button + popover) */}
1497
- {toolbarFlags.showSearch && (
1498
- <Popover open={showSearchPopover} onOpenChange={setShowSearchPopover}>
1499
- <PopoverTrigger asChild>
1500
- <Button
1501
- variant="ghost"
1502
- size="sm"
1503
- className={cn(
1504
- "h-7 w-7 p-0 text-muted-foreground hover:text-primary text-xs transition-colors duration-150",
1505
- searchTerm && "bg-primary/10 border border-primary/20 text-primary"
1506
- )}
1507
- data-testid="search-icon-button"
1508
- title={t('list.search')}
1509
- >
1510
- <Search className="h-3.5 w-3.5" />
1511
- </Button>
1512
- </PopoverTrigger>
1513
- <PopoverContent align="end" className="w-[calc(100vw-2rem)] sm:w-64 p-2" data-testid="search-popover">
1514
- <div className="relative">
1515
- <Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
1516
- <Input
1517
- placeholder={t('list.search') + '...'}
1518
- value={searchTerm}
1519
- onChange={(e) => handleSearchChange(e.target.value)}
1520
- className="pl-7 h-8 text-xs"
1521
- autoFocus
1522
- />
1523
- {searchTerm && (
1524
- <Button
1525
- variant="ghost"
1526
- size="sm"
1527
- className="absolute right-0.5 top-1/2 -translate-y-1/2 h-5 w-5 p-0 hover:bg-muted-foreground/20"
1528
- onClick={() => handleSearchChange('')}
1529
- >
1530
- <X className="h-3 w-3" />
1531
- </Button>
1532
- )}
1533
- </div>
1534
- </PopoverContent>
1535
- </Popover>
1536
- )}
1537
-
1538
- {/* Add Record (top position) */}
1539
- {toolbarFlags.showAddRecord && toolbarFlags.addRecordPosition === 'top' && (
1540
- <Button
1541
- variant="ghost"
1542
- size="sm"
1543
- className="h-7 px-2 text-muted-foreground hover:text-primary text-xs transition-colors duration-150"
1544
- data-testid="add-record-button"
1545
- onClick={() => props.onAddRecord?.()}
1546
- >
1547
- <Plus className="h-3.5 w-3.5 mr-1.5" />
1548
- <span className="hidden sm:inline">{t('list.addRecord')}</span>
1549
- </Button>
1550
- )}
1551
- </div>
1552
- </div>
1553
-
1554
-
1555
- {/* Filters Panel - Removed as it is now in Popover */}
1556
-
1557
- {/* Quick Filters Row */}
1558
- {normalizedQuickFilters && normalizedQuickFilters.length > 0 && (
1559
- <div className="border-b px-2 sm:px-4 py-1 flex items-center gap-1 flex-wrap bg-background" data-testid="quick-filters">
1560
- {normalizedQuickFilters.map((qf: any) => {
1561
- const isActive = activeQuickFilters.has(qf.id);
1562
- const QfIcon: LucideIcon | null = qf.icon
1563
- ? ((icons as Record<string, LucideIcon>)[
1564
- qf.icon.split('-').map((w: string) => w.charAt(0).toUpperCase() + w.slice(1)).join('')
1565
- ] ?? null)
1566
- : null;
1567
- return (
1568
- <Button
1569
- key={qf.id}
1570
- variant={isActive ? 'default' : 'outline'}
1571
- size="sm"
1572
- className="h-7 px-3 text-xs"
1573
- onClick={() => toggleQuickFilter(qf.id)}
1574
- >
1575
- {QfIcon && <QfIcon className="h-3 w-3 mr-1.5" />}
1576
- {qf.label}
1577
- </Button>
1578
- );
1579
- })}
1580
- </div>
1581
- )}
1582
-
1583
- {/* View Content */}
1584
- <div key={currentView} className="flex-1 min-h-0 bg-background relative overflow-hidden animate-in fade-in-0 duration-200">
1585
- {!loading && data.length === 0 ? (
1586
- (() => {
1587
- const iconName = schema.emptyState?.icon;
1588
- const ResolvedIcon: LucideIcon = iconName
1589
- ? ((icons as Record<string, LucideIcon>)[
1590
- iconName.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join('')
1591
- ] ?? Inbox)
1592
- : Inbox;
1593
- return (
1594
- <div className="flex flex-col items-center justify-center h-full min-h-[200px] text-center p-8" data-testid="empty-state">
1595
- <ResolvedIcon className="h-12 w-12 text-muted-foreground/50 mb-4" />
1596
- <h3 className="text-lg font-medium text-foreground mb-1">
1597
- {(typeof schema.emptyState?.title === 'string' ? schema.emptyState.title : undefined) ?? 'No items found'}
1598
- </h3>
1599
- <p className="text-sm text-muted-foreground max-w-md">
1600
- {(typeof schema.emptyState?.message === 'string' ? schema.emptyState.message : undefined) ?? 'There are no records to display. Try adjusting your filters or adding new data.'}
1601
- </p>
1602
- </div>
1603
- );
1604
- })()
1605
- ) : (
1606
- <SchemaRenderer
1607
- schema={viewComponentSchema}
1608
- {...props}
1609
- data={data}
1610
- loading={loading}
1611
- onRowSelect={setSelectedRows}
1612
- />
1613
- )}
1614
- </div>
1615
-
1616
- {/* Add Record (bottom position) */}
1617
- {toolbarFlags.showAddRecord && toolbarFlags.addRecordPosition === 'bottom' && (
1618
- <div className="border-t px-2 sm:px-4 py-1 bg-background shrink-0">
1619
- <Button
1620
- variant="ghost"
1621
- size="sm"
1622
- className="h-7 px-2 text-muted-foreground hover:text-primary text-xs transition-colors duration-150"
1623
- data-testid="add-record-button"
1624
- onClick={() => props.onAddRecord?.()}
1625
- >
1626
- <Plus className="h-3.5 w-3.5 mr-1.5" />
1627
- <span className="hidden sm:inline">{t('list.addRecord')}</span>
1628
- </Button>
1629
- </div>
1630
- )}
1631
-
1632
- {/* Bulk Actions Bar — skip for grid view since ObjectGrid renders its own BulkActionBar */}
1633
- {schema.bulkActions && schema.bulkActions.length > 0 && selectedRows.length > 0 && currentView !== 'grid' && (
1634
- <div
1635
- className="border-t px-4 py-1.5 flex items-center gap-2 text-xs bg-primary/5 shrink-0"
1636
- data-testid="bulk-actions-bar"
1637
- >
1638
- <span className="text-muted-foreground font-medium">{selectedRows.length} selected</span>
1639
- <div className="flex items-center gap-1 ml-2">
1640
- {schema.bulkActions.map(action => (
1641
- <Button
1642
- key={action}
1643
- variant="outline"
1644
- size="sm"
1645
- className="h-6 px-2 text-xs"
1646
- onClick={() => props.onBulkAction?.(action, selectedRows)}
1647
- data-testid={`bulk-action-${action}`}
1648
- >
1649
- {formatActionLabel(action)}
1650
- </Button>
1651
- ))}
1652
- </div>
1653
- <Button
1654
- variant="ghost"
1655
- size="sm"
1656
- className="h-6 px-2 text-xs ml-auto"
1657
- onClick={() => setSelectedRows([])}
1658
- >
1659
- Clear
1660
- </Button>
1661
- </div>
1662
- )}
1663
-
1664
- {/* Record count status bar (Airtable-style) */}
1665
- {!loading && data.length > 0 && schema.showRecordCount !== false && (
1666
- <div
1667
- className="border-t px-4 py-1.5 flex items-center gap-2 text-xs text-muted-foreground bg-background shrink-0"
1668
- data-testid="record-count-bar"
1669
- >
1670
- <span>{data.length === 1 ? t('list.recordCountOne', { count: data.length }) : t('list.recordCount', { count: data.length })}</span>
1671
- {dataLimitReached && (
1672
- <span className="text-amber-600" data-testid="data-limit-warning">
1673
- {t('list.dataLimitReached', { limit: effectivePageSize })}
1674
- </span>
1675
- )}
1676
- {schema.pagination?.pageSizeOptions && schema.pagination.pageSizeOptions.length > 0 && (
1677
- <select
1678
- className="ml-auto h-6 rounded border border-input bg-background px-1 text-xs"
1679
- value={effectivePageSize}
1680
- onChange={(e) => {
1681
- const newSize = Number(e.target.value);
1682
- setDynamicPageSize(newSize);
1683
- if (props.onPageSizeChange) props.onPageSizeChange(newSize);
1684
- }}
1685
- data-testid="page-size-selector"
1686
- >
1687
- {schema.pagination.pageSizeOptions.map(size => (
1688
- <option key={size} value={size}>{size} / page</option>
1689
- ))}
1690
- </select>
1691
- )}
1692
- </div>
1693
- )}
1694
-
1695
- {/* Navigation Overlay (drawer/modal/popover) */}
1696
- {navigation.isOverlay && (
1697
- <NavigationOverlay
1698
- {...navigation}
1699
- title={
1700
- schema.label
1701
- ? `${schema.label} Detail`
1702
- : schema.objectName
1703
- ? `${schema.objectName.charAt(0).toUpperCase() + schema.objectName.slice(1)} Detail`
1704
- : 'Record Detail'
1705
- }
1706
- >
1707
- {(record) => (
1708
- <div className="space-y-3">
1709
- {Object.entries(record).map(([key, value]) => (
1710
- <div key={key} className="flex flex-col">
1711
- <span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
1712
- {key.replace(/_/g, ' ')}
1713
- </span>
1714
- <span className="text-sm">{String(value ?? '—')}</span>
1715
- </div>
1716
- ))}
1717
- </div>
1718
- )}
1719
- </NavigationOverlay>
1720
- )}
1721
- </div>
1722
- );
1723
- });
1724
-
1725
- ListView.displayName = 'ListView';