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