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