@object-ui/plugin-list 3.0.3 → 3.1.0

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,47 @@ 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
+ setData(dataConfig.items);
561
+ setLoading(false);
562
+ setDataLimitReached(false);
563
+ return;
564
+ }
565
+ }
566
+ // Also support schema.data as a plain array (shorthand for value provider)
567
+ if (Array.isArray(schema.data)) {
568
+ setData(schema.data as any[]);
569
+ setLoading(false);
570
+ setDataLimitReached(false);
571
+ return;
572
+ }
573
+
574
+ // Wait for objectDef to load before fetching data so that $expand is computed
575
+ if (!objectDefLoaded) return;
152
576
 
153
577
  const fetchData = async () => {
154
578
  if (!dataSource || !schema.objectName) return;
@@ -160,13 +584,31 @@ export const ListView: React.FC<ListViewProps> = ({
160
584
  const baseFilter = schema.filters || [];
161
585
  const userFilter = convertFilterGroupToAST(currentFilters);
162
586
 
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;
587
+ // Collect active quick filter conditions
588
+ const quickFilterConditions: any[] = [];
589
+ if (normalizedQuickFilters && activeQuickFilters.size > 0) {
590
+ normalizedQuickFilters.forEach((qf: any) => {
591
+ if (activeQuickFilters.has(qf.id) && qf.filters && qf.filters.length > 0) {
592
+ quickFilterConditions.push(qf.filters);
593
+ }
594
+ });
595
+ }
596
+
597
+ // Normalize userFilter conditions (convert `in` to `or` of `=`)
598
+ const normalizedUserFilterConditions = normalizeFilters(userFilterConditions);
599
+
600
+ // Merge all filter sources with consistent structure
601
+ const allFilters = [
602
+ ...(baseFilter.length > 0 ? [baseFilter] : []),
603
+ ...(userFilter.length > 0 ? [userFilter] : []),
604
+ ...quickFilterConditions,
605
+ ...normalizedUserFilterConditions,
606
+ ].filter(f => Array.isArray(f) && f.length > 0);
607
+
608
+ if (allFilters.length > 1) {
609
+ finalFilter = ['and', ...allFilters];
610
+ } else if (allFilters.length === 1) {
611
+ finalFilter = allFilters[0];
170
612
  }
171
613
 
172
614
  // Convert sort to query format
@@ -180,8 +622,18 @@ export const ListView: React.FC<ListViewProps> = ({
180
622
  const results = await dataSource.find(schema.objectName, {
181
623
  $filter: finalFilter,
182
624
  $orderby: sort,
183
- $top: 100 // Default pagination limit
625
+ $top: effectivePageSize,
626
+ ...(expandFields.length > 0 ? { $expand: expandFields } : {}),
627
+ ...(searchTerm ? {
628
+ $search: searchTerm,
629
+ ...(schema.searchableFields && schema.searchableFields.length > 0
630
+ ? { $searchFields: schema.searchableFields }
631
+ : {}),
632
+ } : {}),
184
633
  });
634
+
635
+ // Stale request guard: only apply the latest request's results
636
+ if (!isMounted || requestId !== fetchRequestIdRef.current) return;
185
637
 
186
638
  let items: any[] = [];
187
639
  if (Array.isArray(results)) {
@@ -194,47 +646,58 @@ export const ListView: React.FC<ListViewProps> = ({
194
646
  }
195
647
  }
196
648
 
197
- if (isMounted) {
198
- setData(items);
199
- }
649
+ setData(items);
650
+ setDataLimitReached(items.length >= effectivePageSize);
200
651
  } catch (err) {
201
- console.error("ListView data fetch error:", err);
652
+ // Only log errors from the latest request
653
+ if (requestId === fetchRequestIdRef.current) {
654
+ console.error("ListView data fetch error:", err);
655
+ }
202
656
  } finally {
203
- if (isMounted) setLoading(false);
657
+ if (isMounted && requestId === fetchRequestIdRef.current) {
658
+ setLoading(false);
659
+ }
204
660
  }
205
661
  };
206
662
 
207
663
  fetchData();
208
664
 
209
665
  return () => { isMounted = false; };
210
- }, [schema.objectName, dataSource, schema.filters, currentSort, currentFilters, refreshKey]); // Re-fetch on filter/sort change
666
+ }, [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
667
 
212
668
  // Available view types based on schema configuration
213
669
  const availableViews = React.useMemo(() => {
670
+ // If appearance.allowedVisualizations is set, use it as whitelist
671
+ if (schema.appearance?.allowedVisualizations && schema.appearance.allowedVisualizations.length > 0) {
672
+ return schema.appearance.allowedVisualizations.filter(v =>
673
+ ['grid', 'kanban', 'gallery', 'calendar', 'timeline', 'gantt', 'map'].includes(v)
674
+ ) as ViewType[];
675
+ }
676
+
214
677
  const views: ViewType[] = ['grid'];
215
678
 
216
- // Check for Kanban capabilities
217
- if (schema.options?.kanban?.groupField) {
679
+ // Check for Kanban capabilities (spec config takes precedence)
680
+ if (schema.kanban?.groupField || schema.options?.kanban?.groupField) {
218
681
  views.push('kanban');
219
682
  }
220
683
 
221
- // Check for Gallery capabilities
222
- if (schema.options?.gallery?.imageField) {
684
+ // Check for Gallery capabilities (spec config takes precedence)
685
+ if (schema.gallery?.coverField || schema.gallery?.imageField || schema.options?.gallery?.imageField) {
223
686
  views.push('gallery');
224
687
  }
225
688
 
226
- // Check for Calendar capabilities
227
- if (schema.options?.calendar?.startDateField) {
689
+ // Check for Calendar capabilities (spec config takes precedence)
690
+ if (schema.calendar?.startDateField || schema.options?.calendar?.startDateField) {
228
691
  views.push('calendar');
229
692
  }
230
693
 
231
- // Check for Timeline capabilities
232
- if (schema.options?.timeline?.dateField || schema.options?.calendar?.startDateField) {
694
+ // Check for Timeline capabilities (spec config takes precedence)
695
+ if (schema.timeline?.startDateField || schema.options?.timeline?.startDateField || schema.options?.timeline?.dateField || schema.options?.calendar?.startDateField) {
233
696
  views.push('timeline');
234
697
  }
235
698
 
236
- // Check for Gantt capabilities
237
- if (schema.options?.gantt?.startDateField) {
699
+ // Check for Gantt capabilities (spec config takes precedence)
700
+ if (schema.gantt?.startDateField || schema.options?.gantt?.startDateField) {
238
701
  views.push('gantt');
239
702
  }
240
703
 
@@ -244,14 +707,13 @@ export const ListView: React.FC<ListViewProps> = ({
244
707
  }
245
708
 
246
709
  // 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
710
  if (schema.viewType && !views.includes(schema.viewType as ViewType) &&
249
711
  ['grid', 'kanban', 'calendar', 'timeline', 'gantt', 'map', 'gallery'].includes(schema.viewType)) {
250
712
  views.push(schema.viewType as ViewType);
251
713
  }
252
714
 
253
715
  return views;
254
- }, [schema.options, schema.viewType]);
716
+ }, [schema.options, schema.viewType, schema.kanban, schema.calendar, schema.gantt, schema.gallery, schema.timeline, schema.appearance?.allowedVisualizations]);
255
717
 
256
718
  // Sync view from props
257
719
  React.useEffect(() => {
@@ -297,11 +759,43 @@ export const ListView: React.FC<ListViewProps> = ({
297
759
  onRowClick,
298
760
  });
299
761
 
762
+ // Apply hiddenFields and fieldOrder to produce effective fields
763
+ const effectiveFields = React.useMemo(() => {
764
+ let fields = schema.fields || [];
765
+
766
+ // Defensive: ensure fields is an array of strings/objects
767
+ if (!Array.isArray(fields)) {
768
+ fields = [];
769
+ }
770
+
771
+ // Remove hidden fields
772
+ if (hiddenFields.size > 0) {
773
+ fields = fields.filter((f: any) => {
774
+ const fieldName = typeof f === 'string' ? f : (f?.name || f?.fieldName || f?.field);
775
+ return fieldName != null && !hiddenFields.has(fieldName);
776
+ });
777
+ }
778
+
779
+ // Apply field order
780
+ if (schema.fieldOrder && schema.fieldOrder.length > 0) {
781
+ const orderMap = new Map(schema.fieldOrder.map((f, i) => [f, i]));
782
+ fields = [...fields].sort((a: any, b: any) => {
783
+ const nameA = typeof a === 'string' ? a : (a?.name || a?.fieldName || a?.field);
784
+ const nameB = typeof b === 'string' ? b : (b?.name || b?.fieldName || b?.field);
785
+ const orderA = orderMap.get(nameA) ?? Infinity;
786
+ const orderB = orderMap.get(nameB) ?? Infinity;
787
+ return orderA - orderB;
788
+ });
789
+ }
790
+
791
+ return fields;
792
+ }, [schema.fields, hiddenFields, schema.fieldOrder]);
793
+
300
794
  // Generate the appropriate view component schema
301
795
  const viewComponentSchema = React.useMemo(() => {
302
796
  const baseProps = {
303
797
  objectName: schema.objectName,
304
- fields: schema.fields,
798
+ fields: effectiveFields,
305
799
  filters: schema.filters,
306
800
  sort: currentSort,
307
801
  className: "h-full w-full",
@@ -309,6 +803,9 @@ export const ListView: React.FC<ListViewProps> = ({
309
803
  showSearch: false,
310
804
  // Pass navigation click handler to child views
311
805
  onRowClick: navigation.handleClick,
806
+ // Forward display properties to child views
807
+ ...(schema.striped != null ? { striped: schema.striped } : {}),
808
+ ...(schema.bordered != null ? { bordered: schema.bordered } : {}),
312
809
  };
313
810
 
314
811
  switch (currentView) {
@@ -316,54 +813,92 @@ export const ListView: React.FC<ListViewProps> = ({
316
813
  return {
317
814
  type: 'object-grid',
318
815
  ...baseProps,
319
- columns: schema.fields,
816
+ columns: effectiveFields,
817
+ ...(schema.conditionalFormatting ? { conditionalFormatting: schema.conditionalFormatting } : {}),
818
+ ...(schema.inlineEdit != null ? { editable: schema.inlineEdit } : {}),
819
+ ...(schema.wrapHeaders != null ? { wrapHeaders: schema.wrapHeaders } : {}),
820
+ ...(schema.virtualScroll != null ? { virtualScroll: schema.virtualScroll } : {}),
821
+ ...(schema.resizable != null ? { resizable: schema.resizable } : {}),
822
+ ...(schema.selection ? { selection: schema.selection } : {}),
823
+ ...(schema.pagination ? { pagination: schema.pagination } : {}),
824
+ ...(groupingConfig ? { grouping: groupingConfig } : {}),
825
+ ...(rowColorConfig ? { rowColor: rowColorConfig } : {}),
826
+ ...(schema.rowActions ? { rowActions: schema.rowActions } : {}),
827
+ ...(schema.bulkActions ? { batchActions: schema.bulkActions } : {}),
320
828
  ...(schema.options?.grid || {}),
321
829
  };
322
830
  case 'kanban':
323
831
  return {
324
832
  type: 'object-kanban',
325
833
  ...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 || [],
834
+ groupBy: schema.kanban?.groupField || schema.options?.kanban?.groupField || 'status',
835
+ groupField: schema.kanban?.groupField || schema.options?.kanban?.groupField || 'status',
836
+ titleField: schema.kanban?.titleField || schema.options?.kanban?.titleField || 'name',
837
+ cardFields: schema.kanban?.cardFields || effectiveFields || [],
838
+ ...(groupingConfig ? { grouping: groupingConfig } : {}),
330
839
  ...(schema.options?.kanban || {}),
840
+ ...(schema.kanban || {}),
331
841
  };
332
842
  case 'calendar':
333
843
  return {
334
844
  type: 'object-calendar',
335
845
  ...baseProps,
336
- startDateField: schema.options?.calendar?.startDateField || 'start_date',
337
- endDateField: schema.options?.calendar?.endDateField || 'end_date',
338
- titleField: schema.options?.calendar?.titleField || 'name',
846
+ startDateField: schema.calendar?.startDateField || schema.options?.calendar?.startDateField || 'start_date',
847
+ endDateField: schema.calendar?.endDateField || schema.options?.calendar?.endDateField || 'end_date',
848
+ titleField: schema.calendar?.titleField || schema.options?.calendar?.titleField || 'name',
849
+ ...(schema.calendar?.defaultView ? { defaultView: schema.calendar.defaultView } : {}),
339
850
  ...(schema.options?.calendar || {}),
851
+ ...(schema.calendar || {}),
852
+ };
853
+ case 'gallery': {
854
+ // Merge spec config over legacy options into nested gallery prop
855
+ const mergedGallery = {
856
+ ...(schema.options?.gallery || {}),
857
+ ...(schema.gallery || {}),
340
858
  };
341
- case 'gallery':
342
859
  return {
343
860
  type: 'object-gallery',
344
861
  ...baseProps,
345
- imageField: schema.options?.gallery?.imageField,
346
- titleField: schema.options?.gallery?.titleField || 'name',
347
- subtitleField: schema.options?.gallery?.subtitleField,
348
- ...(schema.options?.gallery || {}),
862
+ // Nested gallery config (spec-compliant, used by ObjectGallery)
863
+ gallery: Object.keys(mergedGallery).length > 0 ? mergedGallery : undefined,
864
+ // Deprecated top-level props for backward compat
865
+ imageField: schema.gallery?.coverField || schema.gallery?.imageField || schema.options?.gallery?.imageField,
866
+ titleField: schema.gallery?.titleField || schema.options?.gallery?.titleField || 'name',
867
+ subtitleField: schema.gallery?.subtitleField || schema.options?.gallery?.subtitleField,
868
+ ...(groupingConfig ? { grouping: groupingConfig } : {}),
869
+ };
870
+ }
871
+ case 'timeline': {
872
+ // Merge spec config over legacy options into nested timeline prop
873
+ const mergedTimeline = {
874
+ ...(schema.options?.timeline || {}),
875
+ ...(schema.timeline || {}),
349
876
  };
350
- case 'timeline':
351
877
  return {
352
878
  type: 'object-timeline',
353
879
  ...baseProps,
354
- dateField: schema.options?.timeline?.dateField || 'created_at',
355
- titleField: schema.options?.timeline?.titleField || 'name',
356
- ...(schema.options?.timeline || {}),
880
+ // Nested timeline config (spec-compliant, used by ObjectTimeline)
881
+ timeline: Object.keys(mergedTimeline).length > 0 ? mergedTimeline : undefined,
882
+ // Deprecated top-level props for backward compat
883
+ startDateField: schema.timeline?.startDateField || schema.options?.timeline?.startDateField || schema.options?.timeline?.dateField || 'created_at',
884
+ titleField: schema.timeline?.titleField || schema.options?.timeline?.titleField || 'name',
885
+ ...(schema.timeline?.endDateField ? { endDateField: schema.timeline.endDateField } : {}),
886
+ ...(schema.timeline?.groupByField ? { groupByField: schema.timeline.groupByField } : {}),
887
+ ...(schema.timeline?.colorField ? { colorField: schema.timeline.colorField } : {}),
888
+ ...(schema.timeline?.scale ? { scale: schema.timeline.scale } : {}),
357
889
  };
890
+ }
358
891
  case 'gantt':
359
892
  return {
360
893
  type: 'object-gantt',
361
894
  ...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',
895
+ startDateField: schema.gantt?.startDateField || schema.options?.gantt?.startDateField || 'start_date',
896
+ endDateField: schema.gantt?.endDateField || schema.options?.gantt?.endDateField || 'end_date',
897
+ progressField: schema.gantt?.progressField || schema.options?.gantt?.progressField || 'progress',
898
+ dependenciesField: schema.gantt?.dependenciesField || schema.options?.gantt?.dependenciesField || 'dependencies',
899
+ ...(schema.gantt?.titleField ? { titleField: schema.gantt.titleField } : {}),
366
900
  ...(schema.options?.gantt || {}),
901
+ ...(schema.gantt || {}),
367
902
  };
368
903
  case 'map':
369
904
  return {
@@ -375,42 +910,138 @@ export const ListView: React.FC<ListViewProps> = ({
375
910
  default:
376
911
  return baseProps;
377
912
  }
378
- }, [currentView, schema, currentSort]);
913
+ }, [currentView, schema, currentSort, effectiveFields, groupingConfig, rowColorConfig, navigation.handleClick]);
379
914
 
380
915
  const hasFilters = currentFilters.conditions && currentFilters.conditions.length > 0;
381
916
 
382
917
  const filterFields = React.useMemo(() => {
918
+ let fields: Array<{ value: string; label: string; type: string; options?: any }>;
919
+
383
920
  if (!objectDef?.fields) {
384
921
  // Fallback to schema fields if objectDef not loaded yet
385
- return (schema.fields || []).map((f: any) => {
922
+ fields = (schema.fields || []).map((f: any) => {
386
923
  if (typeof f === 'string') return { value: f, label: f, type: 'text' };
924
+ const fieldName = f.name || f.fieldName;
387
925
  return {
388
- value: f.name || f.fieldName,
389
- label: f.label || f.name,
926
+ value: fieldName,
927
+ label: tFieldLabel(fieldName, f.label || f.name),
390
928
  type: f.type || 'text',
391
929
  options: f.options
392
930
  };
393
931
  });
932
+ } else {
933
+ fields = Object.entries(objectDef.fields).map(([key, field]: [string, any]) => ({
934
+ value: key,
935
+ label: tFieldLabel(key, field.label || key),
936
+ type: field.type || 'text',
937
+ options: field.options
938
+ }));
394
939
  }
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
940
 
404
- const [searchExpanded, setSearchExpanded] = React.useState(false);
941
+ // Apply filterableFields whitelist restriction
942
+ if (schema.filterableFields && schema.filterableFields.length > 0) {
943
+ const allowed = new Set(schema.filterableFields);
944
+ fields = fields.filter(f => allowed.has(f.value));
945
+ }
946
+
947
+ return fields;
948
+ }, [objectDef, schema.fields, schema.filterableFields]);
949
+
950
+ // Quick filter toggle handler
951
+ const toggleQuickFilter = React.useCallback((id: string) => {
952
+ setActiveQuickFilters(prev => {
953
+ const next = new Set(prev);
954
+ if (next.has(id)) {
955
+ next.delete(id);
956
+ } else {
957
+ next.add(id);
958
+ }
959
+ return next;
960
+ });
961
+ }, []);
962
+
963
+ // Export handler
964
+ const handleExport = React.useCallback((format: 'csv' | 'xlsx' | 'json' | 'pdf') => {
965
+ const exportConfig = resolvedExportOptions;
966
+ const maxRecords = exportConfig?.maxRecords || 0;
967
+ const includeHeaders = exportConfig?.includeHeaders !== false;
968
+ const prefix = exportConfig?.fileNamePrefix || schema.objectName || 'export';
969
+ const exportData = maxRecords > 0 ? data.slice(0, maxRecords) : data;
970
+
971
+ if (format === 'csv') {
972
+ const fields = effectiveFields.map((f: any) => typeof f === 'string' ? f : (f.name || f.fieldName || f.field));
973
+ const rows: string[] = [];
974
+ if (includeHeaders) {
975
+ rows.push(fields.join(','));
976
+ }
977
+ exportData.forEach(record => {
978
+ rows.push(fields.map((f: string) => {
979
+ const val = record[f];
980
+ // Type-safe serialization: handle arrays, objects, null/undefined
981
+ let str: string;
982
+ if (val == null) {
983
+ str = '';
984
+ } else if (Array.isArray(val)) {
985
+ str = val.map(v =>
986
+ (v != null && typeof v === 'object') ? JSON.stringify(v) : String(v ?? ''),
987
+ ).join('; ');
988
+ } else if (typeof val === 'object') {
989
+ str = JSON.stringify(val);
990
+ } else {
991
+ str = String(val);
992
+ }
993
+ // Escape CSV special characters
994
+ const needsQuoting = str.includes(',') || str.includes('"')
995
+ || str.includes('\n') || str.includes('\r');
996
+ return needsQuoting ? `"${str.replace(/"/g, '""')}"` : str;
997
+ }).join(','));
998
+ });
999
+ const blob = new Blob([rows.join('\n')], { type: 'text/csv;charset=utf-8;' });
1000
+ const url = URL.createObjectURL(blob);
1001
+ const a = document.createElement('a');
1002
+ a.href = url;
1003
+ a.download = `${prefix}.csv`;
1004
+ a.click();
1005
+ URL.revokeObjectURL(url);
1006
+ } else if (format === 'json') {
1007
+ const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
1008
+ const url = URL.createObjectURL(blob);
1009
+ const a = document.createElement('a');
1010
+ a.href = url;
1011
+ a.download = `${prefix}.json`;
1012
+ a.click();
1013
+ URL.revokeObjectURL(url);
1014
+ }
1015
+ setShowExport(false);
1016
+ }, [data, effectiveFields, resolvedExportOptions, schema.objectName]);
1017
+
1018
+ // All available fields for hide/show (with i18n)
1019
+ const allFields = React.useMemo(() => {
1020
+ return (schema.fields || []).map((f: any) => {
1021
+ if (typeof f === 'string') {
1022
+ return { name: f, label: tFieldLabel(f, f) };
1023
+ }
1024
+ const name = f.name || f.fieldName || f.field;
1025
+ const rawLabel = f.label || f.name || f.field;
1026
+ return { name, label: tFieldLabel(name, rawLabel) };
1027
+ });
1028
+ }, [schema.fields, tFieldLabel]);
405
1029
 
406
1030
  return (
407
- <div ref={pullRef} className={cn('flex flex-col h-full bg-background relative', className)}>
1031
+ <div
1032
+ ref={pullRef}
1033
+ className={cn('flex flex-col h-full bg-background relative min-w-0 overflow-hidden', className)}
1034
+ {...(schema.aria?.label ? { 'aria-label': schema.aria.label } : {})}
1035
+ {...(schema.aria?.describedBy ? { 'aria-describedby': schema.aria.describedBy } : {})}
1036
+ {...(schema.aria?.live ? { 'aria-live': schema.aria.live } : {})}
1037
+ role="region"
1038
+ >
408
1039
  {pullDistance > 0 && (
409
1040
  <div
410
1041
  className="flex items-center justify-center text-xs text-muted-foreground"
411
1042
  style={{ height: pullDistance }}
412
1043
  >
413
- {isRefreshing ? 'Refreshing…' : 'Pull to refresh'}
1044
+ {isRefreshing ? t('list.refreshing') : t('list.pullToRefresh')}
414
1045
  </div>
415
1046
  )}
416
1047
  {/* Airtable-style Toolbar — Row 1: View tabs */}
@@ -424,33 +1055,119 @@ export const ListView: React.FC<ListViewProps> = ({
424
1055
  </div>
425
1056
  )}
426
1057
 
427
- {/* Airtable-style Toolbar — Row 2: Tool buttons */}
1058
+ {/* View Tabs */}
1059
+ {schema.tabs && schema.tabs.length > 0 && (
1060
+ <TabBar
1061
+ tabs={schema.tabs}
1062
+ activeTab={activeTab}
1063
+ onTabChange={handleTabChange}
1064
+ />
1065
+ )}
1066
+
1067
+ {/* View Description */}
1068
+ {schema.description && (schema.appearance?.showDescription !== false) && (
1069
+ <div className="border-b px-4 py-1.5 text-xs text-muted-foreground bg-background" data-testid="view-description">
1070
+ {typeof schema.description === 'string' ? schema.description : ''}
1071
+ </div>
1072
+ )}
1073
+
1074
+ {/* Airtable-style Toolbar — Merged: UserFilter badges (left) + Tool buttons (right) */}
428
1075
  <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">
1076
+ <div className="flex items-center gap-0.5 overflow-x-auto flex-1 min-w-0">
1077
+ {/* User Filters — inline in toolbar (Airtable Interfaces-style) */}
1078
+ {resolvedUserFilters && (
1079
+ <>
1080
+ <div className="shrink-0 min-w-0" data-testid="user-filters">
1081
+ <UserFilters
1082
+ config={resolvedUserFilters}
1083
+ objectDef={objectDef}
1084
+ data={data}
1085
+ onFilterChange={setUserFilterConditions}
1086
+ maxVisible={3}
1087
+ />
1088
+ </div>
1089
+ <div className="h-4 w-px bg-border/60 mx-0.5 shrink-0" />
1090
+ </>
1091
+ )}
1092
+
430
1093
  {/* 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>
1094
+ {toolbarFlags.showHideFields && (
1095
+ <Popover open={showHideFields} onOpenChange={setShowHideFields}>
1096
+ <PopoverTrigger asChild>
1097
+ <Button
1098
+ variant="ghost"
1099
+ size="sm"
1100
+ className={cn(
1101
+ "h-7 px-2 text-muted-foreground hover:text-primary text-xs transition-colors duration-150",
1102
+ hiddenFields.size > 0 && "text-primary"
1103
+ )}
1104
+ >
1105
+ <EyeOff className="h-3.5 w-3.5 mr-1.5" />
1106
+ <span className="hidden sm:inline">{t('list.hideFields')}</span>
1107
+ {hiddenFields.size > 0 && (
1108
+ <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">
1109
+ {hiddenFields.size}
1110
+ </span>
1111
+ )}
1112
+ </Button>
1113
+ </PopoverTrigger>
1114
+ <PopoverContent align="start" className="w-64 p-3">
1115
+ <div className="space-y-2">
1116
+ <div className="flex items-center justify-between border-b pb-2">
1117
+ <h4 className="font-medium text-sm">{t('list.hideFieldsTitle')}</h4>
1118
+ {hiddenFields.size > 0 && (
1119
+ <Button variant="ghost" size="sm" className="h-6 px-2 text-xs" onClick={() => setHiddenFields(new Set())}>
1120
+ {t('list.showAll')}
1121
+ </Button>
1122
+ )}
1123
+ </div>
1124
+ <div className="max-h-60 overflow-y-auto space-y-1">
1125
+ {allFields.map(field => (
1126
+ <label key={field.name} className="flex items-center gap-2 text-sm py-1 px-1 rounded hover:bg-muted cursor-pointer">
1127
+ <input
1128
+ type="checkbox"
1129
+ checked={!hiddenFields.has(field.name)}
1130
+ onChange={() => {
1131
+ setHiddenFields(prev => {
1132
+ const next = new Set(prev);
1133
+ if (next.has(field.name)) {
1134
+ next.delete(field.name);
1135
+ } else {
1136
+ next.add(field.name);
1137
+ }
1138
+ return next;
1139
+ });
1140
+ }}
1141
+ className="rounded border-input"
1142
+ />
1143
+ <span className="truncate">{field.label}</span>
1144
+ </label>
1145
+ ))}
1146
+ </div>
1147
+ </div>
1148
+ </PopoverContent>
1149
+ </Popover>
1150
+ )}
1151
+
1152
+ {/* --- Separator: Hide Fields | Data Manipulation --- */}
1153
+ {toolbarFlags.showHideFields && (toolbarFlags.showFilters || toolbarFlags.showSort || toolbarFlags.showGroup) && (
1154
+ <div className="h-4 w-px bg-border/60 mx-0.5 shrink-0" />
1155
+ )}
440
1156
 
441
1157
  {/* Filter */}
1158
+ {toolbarFlags.showFilters && (
442
1159
  <Popover open={showFilters} onOpenChange={setShowFilters}>
443
1160
  <PopoverTrigger asChild>
444
1161
  <Button
445
1162
  variant="ghost"
446
1163
  size="sm"
447
1164
  className={cn(
448
- "h-7 px-2 text-muted-foreground hover:text-primary text-xs",
449
- hasFilters && "text-primary"
1165
+ "h-7 px-2 text-muted-foreground hover:text-primary text-xs transition-colors duration-150",
1166
+ hasFilters && "bg-primary/10 border border-primary/20 text-primary"
450
1167
  )}
451
1168
  >
452
1169
  <SlidersHorizontal className="h-3.5 w-3.5 mr-1.5" />
453
- <span className="hidden sm:inline">Filter</span>
1170
+ <span className="hidden sm:inline">{t('list.filter')}</span>
454
1171
  {hasFilters && (
455
1172
  <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
1173
  {currentFilters.conditions?.length || 0}
@@ -461,7 +1178,7 @@ export const ListView: React.FC<ListViewProps> = ({
461
1178
  <PopoverContent align="start" className="w-[calc(100vw-2rem)] sm:w-[600px] max-w-[600px] p-3 sm:p-4">
462
1179
  <div className="space-y-4">
463
1180
  <div className="flex items-center justify-between border-b pb-2">
464
- <h4 className="font-medium text-sm">Filter Records</h4>
1181
+ <h4 className="font-medium text-sm">{t('list.filterRecords')}</h4>
465
1182
  </div>
466
1183
  <FilterBuilder
467
1184
  fields={filterFields}
@@ -474,31 +1191,82 @@ export const ListView: React.FC<ListViewProps> = ({
474
1191
  </div>
475
1192
  </PopoverContent>
476
1193
  </Popover>
1194
+ )}
477
1195
 
478
1196
  {/* 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>
1197
+ {toolbarFlags.showGroup && (
1198
+ <Popover open={showGroupPopover} onOpenChange={setShowGroupPopover}>
1199
+ <PopoverTrigger asChild>
1200
+ <Button
1201
+ variant="ghost"
1202
+ size="sm"
1203
+ className={cn(
1204
+ "h-7 px-2 text-muted-foreground hover:text-primary text-xs transition-colors duration-150",
1205
+ groupingConfig && "bg-primary/10 border border-primary/20 text-primary"
1206
+ )}
1207
+ >
1208
+ <Group className="h-3.5 w-3.5 mr-1.5" />
1209
+ <span className="hidden sm:inline">{t('list.group')}</span>
1210
+ {groupingConfig && groupingConfig.fields?.length > 0 && (
1211
+ <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">
1212
+ {groupingConfig.fields.length}
1213
+ </span>
1214
+ )}
1215
+ </Button>
1216
+ </PopoverTrigger>
1217
+ <PopoverContent align="start" className="w-64 p-3">
1218
+ <div className="space-y-2">
1219
+ <div className="flex items-center justify-between border-b pb-2">
1220
+ <h4 className="font-medium text-sm">{t('list.groupBy')}</h4>
1221
+ {groupingConfig && (
1222
+ <Button variant="ghost" size="sm" className="h-6 px-2 text-xs" onClick={() => setGroupingConfig(undefined)} data-testid="clear-grouping">
1223
+ {t('list.clear')}
1224
+ </Button>
1225
+ )}
1226
+ </div>
1227
+ <div className="max-h-60 overflow-y-auto space-y-1" data-testid="group-field-list">
1228
+ {allFields.map(field => {
1229
+ const isGrouped = groupingConfig?.fields?.some(f => f.field === field.name);
1230
+ return (
1231
+ <label key={field.name} className="flex items-center gap-2 text-sm py-1 px-1 rounded hover:bg-muted cursor-pointer">
1232
+ <input
1233
+ type="checkbox"
1234
+ checked={!!isGrouped}
1235
+ onChange={() => {
1236
+ if (isGrouped) {
1237
+ const newFields = (groupingConfig?.fields || []).filter(f => f.field !== field.name);
1238
+ setGroupingConfig(newFields.length > 0 ? { fields: newFields } : undefined);
1239
+ } else {
1240
+ const existing = groupingConfig?.fields || [];
1241
+ setGroupingConfig({ fields: [...existing, { field: field.name, order: 'asc', collapsed: false }] });
1242
+ }
1243
+ }}
1244
+ className="rounded border-input"
1245
+ />
1246
+ <span className="truncate">{field.label}</span>
1247
+ </label>
1248
+ );
1249
+ })}
1250
+ </div>
1251
+ </div>
1252
+ </PopoverContent>
1253
+ </Popover>
1254
+ )}
488
1255
 
489
1256
  {/* Sort */}
1257
+ {toolbarFlags.showSort && (
490
1258
  <Popover open={showSort} onOpenChange={setShowSort}>
491
1259
  <PopoverTrigger asChild>
492
1260
  <Button
493
1261
  variant="ghost"
494
1262
  size="sm"
495
1263
  className={cn(
496
- "h-7 px-2 text-muted-foreground hover:text-primary text-xs",
497
- currentSort.length > 0 && "text-primary"
1264
+ "h-7 px-2 text-muted-foreground hover:text-primary text-xs transition-colors duration-150",
1265
+ currentSort.length > 0 && "bg-primary/10 border border-primary/20 text-primary"
498
1266
  )}
499
1267
  >
500
1268
  <ArrowUpDown className="h-3.5 w-3.5 mr-1.5" />
501
- <span className="hidden sm:inline">Sort</span>
1269
+ <span className="hidden sm:inline">{t('list.sort')}</span>
502
1270
  {currentSort.length > 0 && (
503
1271
  <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
1272
  {currentSort.length}
@@ -509,7 +1277,7 @@ export const ListView: React.FC<ListViewProps> = ({
509
1277
  <PopoverContent align="start" className="w-[calc(100vw-2rem)] sm:w-[600px] max-w-[600px] p-3 sm:p-4">
510
1278
  <div className="space-y-4">
511
1279
  <div className="flex items-center justify-between border-b pb-2">
512
- <h4 className="font-medium text-sm">Sort Records</h4>
1280
+ <h4 className="font-medium text-sm">{t('list.sortRecords')}</h4>
513
1281
  </div>
514
1282
  <SortBuilder
515
1283
  fields={filterFields}
@@ -522,66 +1290,211 @@ export const ListView: React.FC<ListViewProps> = ({
522
1290
  </div>
523
1291
  </PopoverContent>
524
1292
  </Popover>
1293
+ )}
1294
+
1295
+ {/* --- Separator: Data Manipulation | Appearance --- */}
1296
+ {(toolbarFlags.showFilters || toolbarFlags.showSort || toolbarFlags.showGroup) && (toolbarFlags.showColor || toolbarFlags.showDensity) && (
1297
+ <div className="h-4 w-px bg-border/60 mx-0.5 shrink-0" />
1298
+ )}
525
1299
 
526
1300
  {/* 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>
1301
+ {toolbarFlags.showColor && (
1302
+ <Popover open={showColorPopover} onOpenChange={setShowColorPopover}>
1303
+ <PopoverTrigger asChild>
1304
+ <Button
1305
+ variant="ghost"
1306
+ size="sm"
1307
+ className={cn(
1308
+ "h-7 px-2 text-muted-foreground hover:text-primary text-xs transition-colors duration-150",
1309
+ rowColorConfig && "bg-primary/10 border border-primary/20 text-primary"
1310
+ )}
1311
+ >
1312
+ <Paintbrush className="h-3.5 w-3.5 mr-1.5" />
1313
+ <span className="hidden sm:inline">{t('list.color')}</span>
1314
+ </Button>
1315
+ </PopoverTrigger>
1316
+ <PopoverContent align="start" className="w-64 p-3">
1317
+ <div className="space-y-2">
1318
+ <div className="flex items-center justify-between border-b pb-2">
1319
+ <h4 className="font-medium text-sm">{t('list.rowColor')}</h4>
1320
+ {rowColorConfig && (
1321
+ <Button variant="ghost" size="sm" className="h-6 px-2 text-xs" onClick={() => setRowColorConfig(undefined)} data-testid="clear-row-color">
1322
+ {t('list.clear')}
1323
+ </Button>
1324
+ )}
1325
+ </div>
1326
+ <div className="space-y-2" data-testid="color-field-list">
1327
+ <label className="text-xs text-muted-foreground">{t('list.colorByField')}</label>
1328
+ <select
1329
+ className="w-full h-8 rounded border border-input bg-background px-2 text-xs"
1330
+ value={rowColorConfig?.field || ''}
1331
+ onChange={(e) => {
1332
+ const field = e.target.value;
1333
+ if (!field) {
1334
+ setRowColorConfig(undefined);
1335
+ } else {
1336
+ setRowColorConfig({ field, colors: rowColorConfig?.colors || {} });
1337
+ }
1338
+ }}
1339
+ data-testid="color-field-select"
1340
+ >
1341
+ <option value="">{t('list.none')}</option>
1342
+ {allFields.map(field => (
1343
+ <option key={field.name} value={field.name}>{field.label}</option>
1344
+ ))}
1345
+ </select>
1346
+ </div>
1347
+ </div>
1348
+ </PopoverContent>
1349
+ </Popover>
1350
+ )}
536
1351
 
537
- {/* Row Height */}
1352
+ {/* Row Height / Density Mode */}
1353
+ {toolbarFlags.showDensity && (
538
1354
  <Button
539
1355
  variant="ghost"
540
1356
  size="sm"
541
- className="h-7 px-2 text-muted-foreground hover:text-primary text-xs hidden lg:flex"
542
- disabled
1357
+ className={cn(
1358
+ "h-7 px-2 text-muted-foreground hover:text-primary text-xs hidden lg:flex transition-colors duration-150",
1359
+ density.mode !== 'compact' && "bg-primary/10 border border-primary/20 text-primary"
1360
+ )}
1361
+ onClick={density.cycle}
1362
+ title={`Density: ${density.mode}`}
543
1363
  >
544
- <Ruler className="h-3.5 w-3.5 mr-1.5" />
545
- <span className="hidden sm:inline">Row height</span>
1364
+ <AlignJustify className="h-3.5 w-3.5 mr-1.5" />
1365
+ <span className="hidden sm:inline capitalize">{density.mode}</span>
546
1366
  </Button>
1367
+ )}
1368
+
1369
+ {/* --- Separator: Appearance | Export --- */}
1370
+ {(toolbarFlags.showColor || toolbarFlags.showDensity) && resolvedExportOptions && schema.allowExport !== false && (
1371
+ <div className="h-4 w-px bg-border/60 mx-0.5 shrink-0" />
1372
+ )}
1373
+
1374
+ {/* Export */}
1375
+ {resolvedExportOptions && schema.allowExport !== false && (
1376
+ <Popover open={showExport} onOpenChange={setShowExport}>
1377
+ <PopoverTrigger asChild>
1378
+ <Button
1379
+ variant="ghost"
1380
+ size="sm"
1381
+ className="h-7 px-2 text-muted-foreground hover:text-primary text-xs transition-colors duration-150"
1382
+ >
1383
+ <Download className="h-3.5 w-3.5 mr-1.5" />
1384
+ <span className="hidden sm:inline">{t('list.export')}</span>
1385
+ </Button>
1386
+ </PopoverTrigger>
1387
+ <PopoverContent align="start" className="w-48 p-2">
1388
+ <div className="space-y-1">
1389
+ {(resolvedExportOptions.formats || ['csv', 'json']).map(format => (
1390
+ <Button
1391
+ key={format}
1392
+ variant="ghost"
1393
+ size="sm"
1394
+ className="w-full justify-start h-8 text-xs"
1395
+ onClick={() => handleExport(format)}
1396
+ >
1397
+ <Download className="h-3.5 w-3.5 mr-2" />
1398
+ {t('list.exportAs', { format: format.toUpperCase() })}
1399
+ </Button>
1400
+ ))}
1401
+ </div>
1402
+ </PopoverContent>
1403
+ </Popover>
1404
+ )}
1405
+
1406
+ {/* Share — supports both ObjectUI visibility model and spec personal/collaborative model */}
1407
+ {(schema.sharing?.enabled || schema.sharing?.type) && (
1408
+ <Button
1409
+ variant="ghost"
1410
+ size="sm"
1411
+ className="h-7 px-2 text-muted-foreground hover:text-primary text-xs transition-colors duration-150"
1412
+ title={`Sharing: ${schema.sharing?.visibility || schema.sharing?.type || 'private'}`}
1413
+ data-testid="share-button"
1414
+ >
1415
+ <Share2 className="h-3.5 w-3.5 mr-1.5" />
1416
+ <span className="hidden sm:inline">{t('list.share')}</span>
1417
+ </Button>
1418
+ )}
1419
+
1420
+ {/* Print */}
1421
+ {schema.allowPrinting && (
1422
+ <Button
1423
+ variant="ghost"
1424
+ size="sm"
1425
+ className="h-7 px-2 text-muted-foreground hover:text-primary text-xs transition-colors duration-150"
1426
+ onClick={() => window.print()}
1427
+ data-testid="print-button"
1428
+ >
1429
+ <Printer className="h-3.5 w-3.5 mr-1.5" />
1430
+ <span className="hidden sm:inline">{t('list.print')}</span>
1431
+ </Button>
1432
+ )}
1433
+
1434
+ {/* --- Separator: Print/Share/Export | Search --- */}
1435
+ {(() => {
1436
+ const hasLeftSideItems = schema.allowPrinting || (schema.sharing?.enabled || schema.sharing?.type) || (resolvedExportOptions && schema.allowExport !== false);
1437
+ return toolbarFlags.showSearch && hasLeftSideItems ? (
1438
+ <div className="h-4 w-px bg-border/60 mx-0.5 shrink-0" />
1439
+ ) : null;
1440
+ })()}
1441
+
1442
+ {/* Search (icon button + popover) */}
1443
+ {toolbarFlags.showSearch && (
1444
+ <Popover open={showSearchPopover} onOpenChange={setShowSearchPopover}>
1445
+ <PopoverTrigger asChild>
1446
+ <Button
1447
+ variant="ghost"
1448
+ size="sm"
1449
+ className={cn(
1450
+ "h-7 w-7 p-0 text-muted-foreground hover:text-primary text-xs transition-colors duration-150",
1451
+ searchTerm && "bg-primary/10 border border-primary/20 text-primary"
1452
+ )}
1453
+ data-testid="search-icon-button"
1454
+ title={t('list.search')}
1455
+ >
1456
+ <Search className="h-3.5 w-3.5" />
1457
+ </Button>
1458
+ </PopoverTrigger>
1459
+ <PopoverContent align="end" className="w-64 p-2" data-testid="search-popover">
1460
+ <div className="relative">
1461
+ <Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
1462
+ <Input
1463
+ placeholder={t('list.search') + '...'}
1464
+ value={searchTerm}
1465
+ onChange={(e) => handleSearchChange(e.target.value)}
1466
+ className="pl-7 h-8 text-xs"
1467
+ autoFocus
1468
+ />
1469
+ {searchTerm && (
1470
+ <Button
1471
+ variant="ghost"
1472
+ size="sm"
1473
+ className="absolute right-0.5 top-1/2 -translate-y-1/2 h-5 w-5 p-0 hover:bg-muted-foreground/20"
1474
+ onClick={() => handleSearchChange('')}
1475
+ >
1476
+ <X className="h-3 w-3" />
1477
+ </Button>
1478
+ )}
1479
+ </div>
1480
+ </PopoverContent>
1481
+ </Popover>
1482
+ )}
547
1483
  </div>
548
1484
 
549
- {/* Right: Search */}
1485
+ {/* Right: Add Record */}
550
1486
  <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
- ) : (
1487
+ {/* Add Record (top position) */}
1488
+ {toolbarFlags.showAddRecord && toolbarFlags.addRecordPosition === 'top' && (
577
1489
  <Button
578
1490
  variant="ghost"
579
1491
  size="sm"
580
- className="h-7 px-2 text-muted-foreground hover:text-primary text-xs"
581
- onClick={() => setSearchExpanded(true)}
1492
+ className="h-7 px-2 text-muted-foreground hover:text-primary text-xs transition-colors duration-150"
1493
+ data-testid="add-record-button"
1494
+ onClick={() => props.onAddRecord?.()}
582
1495
  >
583
- <Search className="h-3.5 w-3.5 mr-1.5" />
584
- <span className="hidden sm:inline">Search</span>
1496
+ <Plus className="h-3.5 w-3.5 mr-1.5" />
1497
+ <span className="hidden sm:inline">{t('list.addRecord')}</span>
585
1498
  </Button>
586
1499
  )}
587
1500
  </div>
@@ -590,16 +1503,144 @@ export const ListView: React.FC<ListViewProps> = ({
590
1503
 
591
1504
  {/* Filters Panel - Removed as it is now in Popover */}
592
1505
 
1506
+ {/* Quick Filters Row */}
1507
+ {normalizedQuickFilters && normalizedQuickFilters.length > 0 && (
1508
+ <div className="border-b px-2 sm:px-4 py-1 flex items-center gap-1 flex-wrap bg-background" data-testid="quick-filters">
1509
+ {normalizedQuickFilters.map((qf: any) => {
1510
+ const isActive = activeQuickFilters.has(qf.id);
1511
+ const QfIcon: LucideIcon | null = qf.icon
1512
+ ? ((icons as Record<string, LucideIcon>)[
1513
+ qf.icon.split('-').map((w: string) => w.charAt(0).toUpperCase() + w.slice(1)).join('')
1514
+ ] ?? null)
1515
+ : null;
1516
+ return (
1517
+ <Button
1518
+ key={qf.id}
1519
+ variant={isActive ? 'default' : 'outline'}
1520
+ size="sm"
1521
+ className="h-7 px-3 text-xs"
1522
+ onClick={() => toggleQuickFilter(qf.id)}
1523
+ >
1524
+ {QfIcon && <QfIcon className="h-3 w-3 mr-1.5" />}
1525
+ {qf.label}
1526
+ </Button>
1527
+ );
1528
+ })}
1529
+ </div>
1530
+ )}
1531
+
593
1532
  {/* View Content */}
594
1533
  <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
- />
1534
+ {!loading && data.length === 0 ? (
1535
+ (() => {
1536
+ const iconName = schema.emptyState?.icon;
1537
+ const ResolvedIcon: LucideIcon = iconName
1538
+ ? ((icons as Record<string, LucideIcon>)[
1539
+ iconName.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join('')
1540
+ ] ?? Inbox)
1541
+ : Inbox;
1542
+ return (
1543
+ <div className="flex flex-col items-center justify-center h-full min-h-[200px] text-center p-8" data-testid="empty-state">
1544
+ <ResolvedIcon className="h-12 w-12 text-muted-foreground/50 mb-4" />
1545
+ <h3 className="text-lg font-medium text-foreground mb-1">
1546
+ {(typeof schema.emptyState?.title === 'string' ? schema.emptyState.title : undefined) ?? 'No items found'}
1547
+ </h3>
1548
+ <p className="text-sm text-muted-foreground max-w-md">
1549
+ {(typeof schema.emptyState?.message === 'string' ? schema.emptyState.message : undefined) ?? 'There are no records to display. Try adjusting your filters or adding new data.'}
1550
+ </p>
1551
+ </div>
1552
+ );
1553
+ })()
1554
+ ) : (
1555
+ <SchemaRenderer
1556
+ schema={viewComponentSchema}
1557
+ {...props}
1558
+ data={data}
1559
+ loading={loading}
1560
+ onRowSelect={setSelectedRows}
1561
+ />
1562
+ )}
601
1563
  </div>
602
1564
 
1565
+ {/* Add Record (bottom position) */}
1566
+ {toolbarFlags.showAddRecord && toolbarFlags.addRecordPosition === 'bottom' && (
1567
+ <div className="border-t px-2 sm:px-4 py-1 bg-background shrink-0">
1568
+ <Button
1569
+ variant="ghost"
1570
+ size="sm"
1571
+ className="h-7 px-2 text-muted-foreground hover:text-primary text-xs transition-colors duration-150"
1572
+ data-testid="add-record-button"
1573
+ onClick={() => props.onAddRecord?.()}
1574
+ >
1575
+ <Plus className="h-3.5 w-3.5 mr-1.5" />
1576
+ <span className="hidden sm:inline">{t('list.addRecord')}</span>
1577
+ </Button>
1578
+ </div>
1579
+ )}
1580
+
1581
+ {/* Bulk Actions Bar — skip for grid view since ObjectGrid renders its own BulkActionBar */}
1582
+ {schema.bulkActions && schema.bulkActions.length > 0 && selectedRows.length > 0 && currentView !== 'grid' && (
1583
+ <div
1584
+ className="border-t px-4 py-1.5 flex items-center gap-2 text-xs bg-primary/5 shrink-0"
1585
+ data-testid="bulk-actions-bar"
1586
+ >
1587
+ <span className="text-muted-foreground font-medium">{selectedRows.length} selected</span>
1588
+ <div className="flex items-center gap-1 ml-2">
1589
+ {schema.bulkActions.map(action => (
1590
+ <Button
1591
+ key={action}
1592
+ variant="outline"
1593
+ size="sm"
1594
+ className="h-6 px-2 text-xs"
1595
+ onClick={() => props.onBulkAction?.(action, selectedRows)}
1596
+ data-testid={`bulk-action-${action}`}
1597
+ >
1598
+ {formatActionLabel(action)}
1599
+ </Button>
1600
+ ))}
1601
+ </div>
1602
+ <Button
1603
+ variant="ghost"
1604
+ size="sm"
1605
+ className="h-6 px-2 text-xs ml-auto"
1606
+ onClick={() => setSelectedRows([])}
1607
+ >
1608
+ Clear
1609
+ </Button>
1610
+ </div>
1611
+ )}
1612
+
1613
+ {/* Record count status bar (Airtable-style) */}
1614
+ {!loading && data.length > 0 && schema.showRecordCount !== false && (
1615
+ <div
1616
+ className="border-t px-4 py-1.5 flex items-center gap-2 text-xs text-muted-foreground bg-background shrink-0"
1617
+ data-testid="record-count-bar"
1618
+ >
1619
+ <span>{data.length === 1 ? t('list.recordCountOne', { count: data.length }) : t('list.recordCount', { count: data.length })}</span>
1620
+ {dataLimitReached && (
1621
+ <span className="text-amber-600" data-testid="data-limit-warning">
1622
+ {t('list.dataLimitReached', { limit: effectivePageSize })}
1623
+ </span>
1624
+ )}
1625
+ {schema.pagination?.pageSizeOptions && schema.pagination.pageSizeOptions.length > 0 && (
1626
+ <select
1627
+ className="ml-auto h-6 rounded border border-input bg-background px-1 text-xs"
1628
+ value={effectivePageSize}
1629
+ onChange={(e) => {
1630
+ const newSize = Number(e.target.value);
1631
+ setDynamicPageSize(newSize);
1632
+ if (props.onPageSizeChange) props.onPageSizeChange(newSize);
1633
+ }}
1634
+ data-testid="page-size-selector"
1635
+ >
1636
+ {schema.pagination.pageSizeOptions.map(size => (
1637
+ <option key={size} value={size}>{size} / page</option>
1638
+ ))}
1639
+ </select>
1640
+ )}
1641
+ </div>
1642
+ )}
1643
+
603
1644
  {/* Navigation Overlay (drawer/modal/popover) */}
604
1645
  {navigation.isOverlay && (
605
1646
  <NavigationOverlay