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