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