@object-ui/plugin-detail 3.1.0 → 3.1.2
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 +41 -41
- package/CHANGELOG.md +21 -0
- package/dist/{AddressField-C07oUOY6.js → AddressField-QBIlXCFl.js} +1 -1
- package/dist/{AvatarField-VThNABzo.js → AvatarField-BEZuQTAH.js} +1 -1
- package/dist/{BooleanField-CGHKBzAi.js → BooleanField-doa93aFX.js} +1 -1
- package/dist/{CodeField-Co_muhRR.js → CodeField-jVV-hIXg.js} +1 -1
- package/dist/{ColorField-DLid_tFz.js → ColorField-B53qKQGW.js} +1 -1
- package/dist/{CurrencyField-Bw-LqANM.js → CurrencyField-og0NJ2ax.js} +1 -1
- package/dist/{DateField-BNHAzMB2.js → DateField-BFx64AtG.js} +1 -1
- package/dist/{DateTimeField-DjAyn_DQ.js → DateTimeField-Cxs2Rx2f.js} +1 -1
- package/dist/{EmailField-xoNcSppb.js → EmailField-BfcpzRe7.js} +1 -1
- package/dist/{FileField-DbNJwjU2.js → FileField-KarqvhYm.js} +1 -1
- package/dist/{GeolocationField-C1AnS6VV.js → GeolocationField-B5SKZaqn.js} +1 -1
- package/dist/{GridField-DATAHIKf.js → GridField-DOotrUTo.js} +1 -1
- package/dist/{ImageField-CEKJpyJp.js → ImageField-Ddotp4u-.js} +1 -1
- package/dist/{LocationField-jDWXjlpx.js → LocationField-tOkQaPIM.js} +1 -1
- package/dist/{LookupField-DQ08L9UQ.js → LookupField-DF36GvIP.js} +1 -1
- package/dist/{MasterDetailField-Dbk529Ea.js → MasterDetailField-CpHw3nTE.js} +1 -1
- package/dist/{NumberField-BVroN9aV.js → NumberField-CzBb2a28.js} +1 -1
- package/dist/{ObjectField-CT3l_IHW.js → ObjectField-BoL-JqE4.js} +1 -1
- package/dist/{PasswordField-DweVLEE0.js → PasswordField-DrTzkYgj.js} +1 -1
- package/dist/{PercentField-ZpWUK97K.js → PercentField-B9ZUQ3zE.js} +1 -1
- package/dist/{PhoneField-mw-9fqZ_.js → PhoneField-Bf9lhpdu.js} +1 -1
- package/dist/{QRCodeField-Cbb9ck59.js → QRCodeField-PzMpdBKd.js} +1 -1
- package/dist/{RatingField-CSqgLS6t.js → RatingField-CeBMFe8o.js} +1 -1
- package/dist/{RichTextField-BpfBOd99.js → RichTextField-Ch7CHSQ0.js} +1 -1
- package/dist/{SelectField-B9Ei-5jl.js → SelectField-f5Nbi02x.js} +1 -1
- package/dist/{SignatureField-DgGpHnQ8.js → SignatureField-CpxTX2tR.js} +1 -1
- package/dist/{SliderField-C6HvOHd8.js → SliderField-BoZtzgcr.js} +1 -1
- package/dist/{TextAreaField-BK3RgzY3.js → TextAreaField-rT1DLnV2.js} +1 -1
- package/dist/{TextField-Bvzx3atT.js → TextField-CflRxusu.js} +1 -1
- package/dist/{TimeField-Cuz9-Uai.js → TimeField-DeVeCpRu.js} +1 -1
- package/dist/{UrlField-B6XHTV73.js → UrlField-UWKfhP9T.js} +1 -1
- package/dist/{UserField-ooTul2d6.js → UserField-Cp2zQDjz.js} +1 -1
- package/dist/index-V_WBvcaA.js +100249 -0
- package/dist/index.js +20 -18
- package/dist/index.umd.cjs +117 -46
- package/dist/plugin-detail.css +1 -1
- package/dist/src/DetailSection.d.ts +11 -0
- package/dist/src/DetailSection.d.ts.map +1 -1
- package/dist/src/DetailView.d.ts.map +1 -1
- package/dist/src/HeaderHighlight.d.ts +18 -0
- package/dist/src/HeaderHighlight.d.ts.map +1 -0
- package/dist/src/RelatedList.d.ts +16 -0
- package/dist/src/RelatedList.d.ts.map +1 -1
- package/dist/src/SectionGroup.d.ts +21 -0
- package/dist/src/SectionGroup.d.ts.map +1 -0
- package/dist/src/index.d.ts +4 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/useDetailTranslation.d.ts.map +1 -1
- package/package.json +6 -6
- package/src/DetailSection.tsx +50 -26
- package/src/DetailView.tsx +286 -69
- package/src/HeaderHighlight.tsx +67 -0
- package/src/RelatedList.tsx +287 -21
- package/src/SectionGroup.tsx +101 -0
- package/src/__tests__/DetailSection.test.tsx +111 -2
- package/src/__tests__/DetailView.test.tsx +31 -0
- package/src/__tests__/HeaderHighlight.test.tsx +68 -0
- package/src/__tests__/RelatedList.test.tsx +101 -7
- package/src/__tests__/SectionGroup.test.tsx +101 -0
- package/src/__tests__/roadmap-features.test.tsx +478 -0
- package/src/index.tsx +4 -0
- package/src/useDetailTranslation.ts +11 -0
- package/dist/index-CnlyRfY_.js +0 -59461
- package/src/registration.test.tsx +0 -18
package/src/RelatedList.tsx
CHANGED
|
@@ -7,11 +7,30 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import * as React from 'react';
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
Card,
|
|
12
|
+
CardHeader,
|
|
13
|
+
CardTitle,
|
|
14
|
+
CardContent,
|
|
15
|
+
Badge,
|
|
16
|
+
Button,
|
|
17
|
+
Input,
|
|
18
|
+
} from '@object-ui/components';
|
|
11
19
|
import { SchemaRenderer } from '@object-ui/react';
|
|
12
|
-
import {
|
|
13
|
-
|
|
20
|
+
import {
|
|
21
|
+
Plus,
|
|
22
|
+
ExternalLink,
|
|
23
|
+
Edit,
|
|
24
|
+
Trash2,
|
|
25
|
+
ChevronLeft,
|
|
26
|
+
ChevronRight,
|
|
27
|
+
ArrowUpDown,
|
|
28
|
+
ChevronDown,
|
|
29
|
+
} from 'lucide-react';
|
|
30
|
+
import type { DataSource, FieldMetadata } from '@object-ui/types';
|
|
31
|
+
import { getCellRenderer } from '@object-ui/fields';
|
|
14
32
|
import { useDetailTranslation } from './useDetailTranslation';
|
|
33
|
+
import { useSafeFieldLabel } from '@object-ui/react';
|
|
15
34
|
|
|
16
35
|
export interface RelatedListProps {
|
|
17
36
|
title: string;
|
|
@@ -22,10 +41,26 @@ export interface RelatedListProps {
|
|
|
22
41
|
columns?: any[];
|
|
23
42
|
className?: string;
|
|
24
43
|
dataSource?: DataSource;
|
|
44
|
+
/** Object name for i18n field label resolution */
|
|
45
|
+
objectName?: string;
|
|
25
46
|
/** Callback when "New" button is clicked */
|
|
26
47
|
onNew?: () => void;
|
|
27
48
|
/** Callback when "View All" button is clicked */
|
|
28
49
|
onViewAll?: () => void;
|
|
50
|
+
/** Callback when a row Edit action is clicked */
|
|
51
|
+
onRowEdit?: (row: any) => void;
|
|
52
|
+
/** Callback when a row Delete action is clicked */
|
|
53
|
+
onRowDelete?: (row: any) => void;
|
|
54
|
+
/** Page size for pagination (enables pagination when set) */
|
|
55
|
+
pageSize?: number;
|
|
56
|
+
/** Enable column sorting */
|
|
57
|
+
sortable?: boolean;
|
|
58
|
+
/** Enable text filtering */
|
|
59
|
+
filterable?: boolean;
|
|
60
|
+
/** Whether the card is collapsible */
|
|
61
|
+
collapsible?: boolean;
|
|
62
|
+
/** Whether the card starts collapsed (requires collapsible=true) */
|
|
63
|
+
defaultCollapsed?: boolean;
|
|
29
64
|
}
|
|
30
65
|
|
|
31
66
|
export const RelatedList: React.FC<RelatedListProps> = ({
|
|
@@ -37,12 +72,41 @@ export const RelatedList: React.FC<RelatedListProps> = ({
|
|
|
37
72
|
columns,
|
|
38
73
|
className,
|
|
39
74
|
dataSource,
|
|
75
|
+
objectName,
|
|
40
76
|
onNew,
|
|
41
77
|
onViewAll,
|
|
78
|
+
onRowEdit,
|
|
79
|
+
onRowDelete,
|
|
80
|
+
pageSize,
|
|
81
|
+
sortable = false,
|
|
82
|
+
filterable = false,
|
|
83
|
+
collapsible = false,
|
|
84
|
+
defaultCollapsed = false,
|
|
42
85
|
}) => {
|
|
43
86
|
const [relatedData, setRelatedData] = React.useState(data);
|
|
44
87
|
const [loading, setLoading] = React.useState(false);
|
|
88
|
+
const [currentPage, setCurrentPage] = React.useState(0);
|
|
89
|
+
const [sortField, setSortField] = React.useState<string | null>(null);
|
|
90
|
+
const [sortDirection, setSortDirection] = React.useState<'asc' | 'desc'>('asc');
|
|
91
|
+
const [filterText, setFilterText] = React.useState('');
|
|
92
|
+
const [objectSchema, setObjectSchema] = React.useState<any>(null);
|
|
93
|
+
const [collapsed, setCollapsed] = React.useState(defaultCollapsed);
|
|
45
94
|
const { t } = useDetailTranslation();
|
|
95
|
+
const { fieldLabel: resolveFieldLabel } = useSafeFieldLabel();
|
|
96
|
+
|
|
97
|
+
// Sync internal state when data prop changes (e.g., parent fetches async data)
|
|
98
|
+
React.useEffect(() => {
|
|
99
|
+
setRelatedData(data);
|
|
100
|
+
}, [data]);
|
|
101
|
+
|
|
102
|
+
// Auto-fetch object schema when api/dataSource available but columns missing
|
|
103
|
+
React.useEffect(() => {
|
|
104
|
+
if (api && dataSource?.getObjectSchema && !columns?.length) {
|
|
105
|
+
dataSource.getObjectSchema(api).then(setObjectSchema).catch((err: unknown) => {
|
|
106
|
+
console.warn(`[RelatedList] Failed to fetch schema for ${api}:`, err);
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}, [api, dataSource, columns]);
|
|
46
110
|
|
|
47
111
|
React.useEffect(() => {
|
|
48
112
|
if (api && !data.length) {
|
|
@@ -75,6 +139,97 @@ export const RelatedList: React.FC<RelatedListProps> = ({
|
|
|
75
139
|
}
|
|
76
140
|
}, [api, data, dataSource]);
|
|
77
141
|
|
|
142
|
+
// Filter data
|
|
143
|
+
const filteredData = React.useMemo(() => {
|
|
144
|
+
if (!filterText) return relatedData;
|
|
145
|
+
const lower = filterText.toLowerCase();
|
|
146
|
+
return relatedData.filter((row) =>
|
|
147
|
+
Object.values(row).some((val) =>
|
|
148
|
+
val !== null && val !== undefined && String(val).toLowerCase().includes(lower)
|
|
149
|
+
)
|
|
150
|
+
);
|
|
151
|
+
}, [relatedData, filterText]);
|
|
152
|
+
|
|
153
|
+
// Sort data
|
|
154
|
+
const sortedData = React.useMemo(() => {
|
|
155
|
+
if (!sortField) return filteredData;
|
|
156
|
+
return [...filteredData].sort((a, b) => {
|
|
157
|
+
const aVal = a[sortField];
|
|
158
|
+
const bVal = b[sortField];
|
|
159
|
+
if (aVal == null && bVal == null) return 0;
|
|
160
|
+
if (aVal == null) return 1;
|
|
161
|
+
if (bVal == null) return -1;
|
|
162
|
+
const cmp = String(aVal).localeCompare(String(bVal), undefined, { numeric: true });
|
|
163
|
+
return sortDirection === 'asc' ? cmp : -cmp;
|
|
164
|
+
});
|
|
165
|
+
}, [filteredData, sortField, sortDirection]);
|
|
166
|
+
|
|
167
|
+
// Paginate data
|
|
168
|
+
const effectivePageSize = pageSize && pageSize > 0 ? pageSize : 0;
|
|
169
|
+
const totalPages = effectivePageSize ? Math.max(1, Math.ceil(sortedData.length / effectivePageSize)) : 1;
|
|
170
|
+
const paginatedData = effectivePageSize
|
|
171
|
+
? sortedData.slice(currentPage * effectivePageSize, (currentPage + 1) * effectivePageSize)
|
|
172
|
+
: sortedData;
|
|
173
|
+
|
|
174
|
+
// Reset to first page when filter/sort changes
|
|
175
|
+
React.useEffect(() => {
|
|
176
|
+
setCurrentPage(0);
|
|
177
|
+
}, [filterText, sortField, sortDirection]);
|
|
178
|
+
|
|
179
|
+
const handleSort = React.useCallback((field: string) => {
|
|
180
|
+
if (sortField === field) {
|
|
181
|
+
setSortDirection((d) => (d === 'asc' ? 'desc' : 'asc'));
|
|
182
|
+
} else {
|
|
183
|
+
setSortField(field);
|
|
184
|
+
setSortDirection('asc');
|
|
185
|
+
}
|
|
186
|
+
}, [sortField]);
|
|
187
|
+
|
|
188
|
+
const handleDeleteRow = React.useCallback((row: any) => {
|
|
189
|
+
if (window.confirm(t('detail.deleteRowConfirmation'))) {
|
|
190
|
+
onRowDelete?.(row);
|
|
191
|
+
}
|
|
192
|
+
}, [onRowDelete, t]);
|
|
193
|
+
|
|
194
|
+
// Generate effective columns from explicit prop or object schema fields
|
|
195
|
+
const effectiveColumns = React.useMemo(() => {
|
|
196
|
+
if (columns && columns.length > 0) return columns;
|
|
197
|
+
if (!objectSchema?.fields) return [];
|
|
198
|
+
const resolvedObjectName = objectName || api || '';
|
|
199
|
+
return Object.entries(objectSchema.fields)
|
|
200
|
+
.filter(([key]) => !key.startsWith('_'))
|
|
201
|
+
.map(([key, def]: [string, any]) => {
|
|
202
|
+
const col: any = {
|
|
203
|
+
accessorKey: key,
|
|
204
|
+
header: resolveFieldLabel(resolvedObjectName, key, def.label || key),
|
|
205
|
+
};
|
|
206
|
+
// Add type-aware cell renderer for typed fields
|
|
207
|
+
if (def.type) {
|
|
208
|
+
const CellRenderer = getCellRenderer(def.type);
|
|
209
|
+
if (CellRenderer) {
|
|
210
|
+
const fieldMeta: FieldMetadata = {
|
|
211
|
+
name: key,
|
|
212
|
+
label: def.label || key,
|
|
213
|
+
type: def.type,
|
|
214
|
+
...(def.options && { options: def.options }),
|
|
215
|
+
...(def.currency && { currency: def.currency }),
|
|
216
|
+
...(def.precision !== undefined && { precision: def.precision }),
|
|
217
|
+
...(def.format && { format: def.format }),
|
|
218
|
+
...((def.reference_to || def.reference) && { reference_to: def.reference_to || def.reference }),
|
|
219
|
+
...(def.reference_field && { reference_field: def.reference_field }),
|
|
220
|
+
};
|
|
221
|
+
col.cell = (value: any) => {
|
|
222
|
+
if (value === null || value === undefined) {
|
|
223
|
+
return React.createElement('span', { className: 'text-muted-foreground/50 text-xs italic' }, '—');
|
|
224
|
+
}
|
|
225
|
+
return React.createElement(CellRenderer, { value, field: fieldMeta });
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return col;
|
|
230
|
+
});
|
|
231
|
+
}, [columns, objectSchema, objectName, api, resolveFieldLabel]);
|
|
232
|
+
|
|
78
233
|
const viewSchema = React.useMemo(() => {
|
|
79
234
|
if (schema) return schema;
|
|
80
235
|
|
|
@@ -84,44 +239,50 @@ export const RelatedList: React.FC<RelatedListProps> = ({
|
|
|
84
239
|
case 'table':
|
|
85
240
|
return {
|
|
86
241
|
type: 'data-table',
|
|
87
|
-
data:
|
|
88
|
-
columns:
|
|
89
|
-
pagination:
|
|
90
|
-
pageSize: 10,
|
|
242
|
+
data: paginatedData,
|
|
243
|
+
columns: effectiveColumns,
|
|
244
|
+
pagination: false, // We handle pagination ourselves
|
|
245
|
+
pageSize: effectivePageSize || 10,
|
|
91
246
|
};
|
|
92
247
|
case 'list':
|
|
93
248
|
return {
|
|
94
249
|
type: 'data-list',
|
|
95
|
-
data:
|
|
250
|
+
data: paginatedData,
|
|
96
251
|
};
|
|
97
252
|
default:
|
|
98
253
|
return { type: 'div', children: 'No view configured' };
|
|
99
254
|
}
|
|
100
|
-
}, [type,
|
|
255
|
+
}, [type, paginatedData, effectiveColumns, schema, effectivePageSize]);
|
|
101
256
|
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
257
|
+
const hasRowActions = !!onRowEdit || !!onRowDelete;
|
|
258
|
+
|
|
259
|
+
const headerClassName = collapsible ? 'cursor-pointer select-none' : undefined;
|
|
260
|
+
const handleHeaderClick = collapsible ? () => setCollapsed((c) => !c) : undefined;
|
|
105
261
|
|
|
106
262
|
return (
|
|
107
263
|
<Card className={className}>
|
|
108
|
-
<CardHeader>
|
|
264
|
+
<CardHeader className={headerClassName} onClick={handleHeaderClick}>
|
|
109
265
|
<CardTitle className="flex items-center justify-between">
|
|
110
266
|
<div className="flex items-center gap-2">
|
|
267
|
+
{collapsible && (
|
|
268
|
+
collapsed
|
|
269
|
+
? (<ChevronRight className="h-4 w-4 text-muted-foreground" />)
|
|
270
|
+
: (<ChevronDown className="h-4 w-4 text-muted-foreground" />)
|
|
271
|
+
)}
|
|
111
272
|
<span>{title}</span>
|
|
112
|
-
<
|
|
113
|
-
{
|
|
114
|
-
</
|
|
273
|
+
<Badge variant="secondary" className="text-xs font-normal" aria-label={`${relatedData.length} records`}>
|
|
274
|
+
{relatedData.length}
|
|
275
|
+
</Badge>
|
|
115
276
|
</div>
|
|
116
277
|
<div className="flex items-center gap-1">
|
|
117
278
|
{onNew && (
|
|
118
|
-
<Button variant="ghost" size="sm" onClick={onNew} className="gap-1 h-7 text-xs">
|
|
279
|
+
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); onNew(); }} className="gap-1 h-7 text-xs">
|
|
119
280
|
<Plus className="h-3.5 w-3.5" />
|
|
120
281
|
{t('detail.new')}
|
|
121
282
|
</Button>
|
|
122
283
|
)}
|
|
123
284
|
{onViewAll && (
|
|
124
|
-
<Button variant="ghost" size="sm" onClick={onViewAll} className="gap-1 h-7 text-xs">
|
|
285
|
+
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); onViewAll(); }} className="gap-1 h-7 text-xs">
|
|
125
286
|
{t('detail.viewAll')}
|
|
126
287
|
<ExternalLink className="h-3 w-3" />
|
|
127
288
|
</Button>
|
|
@@ -129,7 +290,44 @@ export const RelatedList: React.FC<RelatedListProps> = ({
|
|
|
129
290
|
</div>
|
|
130
291
|
</CardTitle>
|
|
131
292
|
</CardHeader>
|
|
132
|
-
<CardContent>
|
|
293
|
+
{!collapsed && <CardContent>
|
|
294
|
+
{/* Filter bar */}
|
|
295
|
+
{filterable && relatedData.length > 0 && (
|
|
296
|
+
<div className="mb-3">
|
|
297
|
+
<Input
|
|
298
|
+
placeholder={t('detail.filterPlaceholder')}
|
|
299
|
+
value={filterText}
|
|
300
|
+
onChange={(e) => setFilterText(e.target.value)}
|
|
301
|
+
className="h-8 text-sm"
|
|
302
|
+
/>
|
|
303
|
+
</div>
|
|
304
|
+
)}
|
|
305
|
+
|
|
306
|
+
{/* Sortable column headers */}
|
|
307
|
+
{sortable && effectiveColumns && effectiveColumns.length > 0 && relatedData.length > 0 && (
|
|
308
|
+
<div className="flex flex-wrap gap-1 mb-3">
|
|
309
|
+
{effectiveColumns.map((col: any) => {
|
|
310
|
+
const field = col.accessorKey || col.field || col.name;
|
|
311
|
+
if (!field) return null;
|
|
312
|
+
const label = col.header || col.label || field;
|
|
313
|
+
const isActive = sortField === field;
|
|
314
|
+
return (
|
|
315
|
+
<Button
|
|
316
|
+
key={field}
|
|
317
|
+
variant={isActive ? 'secondary' : 'ghost'}
|
|
318
|
+
size="sm"
|
|
319
|
+
className="gap-1 h-7 text-xs"
|
|
320
|
+
onClick={() => handleSort(field)}
|
|
321
|
+
>
|
|
322
|
+
<ArrowUpDown className="h-3 w-3" />
|
|
323
|
+
{label}
|
|
324
|
+
{isActive && (sortDirection === 'asc' ? ' ↑' : ' ↓')}
|
|
325
|
+
</Button>
|
|
326
|
+
);
|
|
327
|
+
})}
|
|
328
|
+
</div>
|
|
329
|
+
)}
|
|
330
|
+
|
|
133
331
|
{loading ? (
|
|
134
332
|
<div className="flex items-center justify-center py-8 text-muted-foreground">
|
|
135
333
|
{t('detail.loading')}
|
|
@@ -139,9 +337,77 @@ export const RelatedList: React.FC<RelatedListProps> = ({
|
|
|
139
337
|
{t('detail.noRelatedRecords')}
|
|
140
338
|
</div>
|
|
141
339
|
) : (
|
|
142
|
-
|
|
340
|
+
<>
|
|
341
|
+
<SchemaRenderer schema={viewSchema} />
|
|
342
|
+
|
|
343
|
+
{/* Row-level actions (rendered as a simple action list below data) */}
|
|
344
|
+
{hasRowActions && paginatedData.length > 0 && (
|
|
345
|
+
<div className="mt-2 space-y-1" data-testid="row-actions">
|
|
346
|
+
{paginatedData.map((row, idx) => (
|
|
347
|
+
<div key={row.id || idx} className="flex items-center justify-between px-2 py-1 text-xs border-b last:border-b-0">
|
|
348
|
+
<span className="truncate text-muted-foreground">
|
|
349
|
+
{row.name || row.title || row.id || `Row ${idx + 1}`}
|
|
350
|
+
</span>
|
|
351
|
+
<div className="flex items-center gap-1">
|
|
352
|
+
{onRowEdit && (
|
|
353
|
+
<Button
|
|
354
|
+
variant="ghost"
|
|
355
|
+
size="sm"
|
|
356
|
+
className="h-6 text-xs gap-1 px-2"
|
|
357
|
+
onClick={() => onRowEdit(row)}
|
|
358
|
+
>
|
|
359
|
+
<Edit className="h-3 w-3" />
|
|
360
|
+
{t('detail.editRow')}
|
|
361
|
+
</Button>
|
|
362
|
+
)}
|
|
363
|
+
{onRowDelete && (
|
|
364
|
+
<Button
|
|
365
|
+
variant="ghost"
|
|
366
|
+
size="sm"
|
|
367
|
+
className="h-6 text-xs gap-1 px-2 text-destructive hover:text-destructive"
|
|
368
|
+
onClick={() => handleDeleteRow(row)}
|
|
369
|
+
>
|
|
370
|
+
<Trash2 className="h-3 w-3" />
|
|
371
|
+
{t('detail.deleteRow')}
|
|
372
|
+
</Button>
|
|
373
|
+
)}
|
|
374
|
+
</div>
|
|
375
|
+
</div>
|
|
376
|
+
))}
|
|
377
|
+
</div>
|
|
378
|
+
)}
|
|
379
|
+
</>
|
|
380
|
+
)}
|
|
381
|
+
|
|
382
|
+
{/* Pagination controls */}
|
|
383
|
+
{effectivePageSize > 0 && sortedData.length > effectivePageSize && (
|
|
384
|
+
<div className="flex items-center justify-between mt-3 pt-3 border-t">
|
|
385
|
+
<Button
|
|
386
|
+
variant="outline"
|
|
387
|
+
size="sm"
|
|
388
|
+
className="h-7 text-xs gap-1"
|
|
389
|
+
disabled={currentPage === 0}
|
|
390
|
+
onClick={() => setCurrentPage((p) => Math.max(0, p - 1))}
|
|
391
|
+
>
|
|
392
|
+
<ChevronLeft className="h-3 w-3" />
|
|
393
|
+
{t('detail.previousPage')}
|
|
394
|
+
</Button>
|
|
395
|
+
<span className="text-xs text-muted-foreground">
|
|
396
|
+
{t('detail.pageOf', { current: currentPage + 1, total: totalPages })}
|
|
397
|
+
</span>
|
|
398
|
+
<Button
|
|
399
|
+
variant="outline"
|
|
400
|
+
size="sm"
|
|
401
|
+
className="h-7 text-xs gap-1"
|
|
402
|
+
disabled={currentPage >= totalPages - 1}
|
|
403
|
+
onClick={() => setCurrentPage((p) => Math.min(totalPages - 1, p + 1))}
|
|
404
|
+
>
|
|
405
|
+
{t('detail.nextPage')}
|
|
406
|
+
<ChevronRight className="h-3 w-3" />
|
|
407
|
+
</Button>
|
|
408
|
+
</div>
|
|
143
409
|
)}
|
|
144
|
-
</CardContent>
|
|
410
|
+
</CardContent>}
|
|
145
411
|
</Card>
|
|
146
412
|
);
|
|
147
413
|
};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as React from 'react';
|
|
10
|
+
import {
|
|
11
|
+
cn,
|
|
12
|
+
Collapsible,
|
|
13
|
+
CollapsibleTrigger,
|
|
14
|
+
CollapsibleContent,
|
|
15
|
+
} from '@object-ui/components';
|
|
16
|
+
import { ChevronDown, ChevronRight } from 'lucide-react';
|
|
17
|
+
import { DetailSection } from './DetailSection';
|
|
18
|
+
import type { SectionGroup as SectionGroupType } from '@object-ui/types';
|
|
19
|
+
|
|
20
|
+
export interface SectionGroupProps {
|
|
21
|
+
group: SectionGroupType;
|
|
22
|
+
data?: any;
|
|
23
|
+
className?: string;
|
|
24
|
+
objectSchema?: any;
|
|
25
|
+
/** Object name for i18n field label resolution */
|
|
26
|
+
objectName?: string;
|
|
27
|
+
isEditing?: boolean;
|
|
28
|
+
onFieldChange?: (field: string, value: any) => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const SectionGroup: React.FC<SectionGroupProps> = ({
|
|
32
|
+
group,
|
|
33
|
+
data,
|
|
34
|
+
className,
|
|
35
|
+
objectSchema,
|
|
36
|
+
objectName,
|
|
37
|
+
isEditing = false,
|
|
38
|
+
onFieldChange,
|
|
39
|
+
}) => {
|
|
40
|
+
const collapsible = group.collapsible ?? true;
|
|
41
|
+
const [isCollapsed, setIsCollapsed] = React.useState(group.defaultCollapsed ?? false);
|
|
42
|
+
|
|
43
|
+
const sectionsContent = (
|
|
44
|
+
<div className="space-y-3 sm:space-y-4">
|
|
45
|
+
{group.sections.map((section, index) => (
|
|
46
|
+
<DetailSection
|
|
47
|
+
key={index}
|
|
48
|
+
section={section}
|
|
49
|
+
data={data}
|
|
50
|
+
objectSchema={objectSchema}
|
|
51
|
+
objectName={objectName}
|
|
52
|
+
isEditing={isEditing}
|
|
53
|
+
onFieldChange={onFieldChange}
|
|
54
|
+
/>
|
|
55
|
+
))}
|
|
56
|
+
</div>
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
if (!collapsible) {
|
|
60
|
+
return (
|
|
61
|
+
<div className={cn('space-y-3', className)}>
|
|
62
|
+
<div className="flex items-center gap-2 pb-2 border-b">
|
|
63
|
+
{group.icon && <span className="text-muted-foreground">{group.icon}</span>}
|
|
64
|
+
<h3 className="text-lg font-semibold">{group.title}</h3>
|
|
65
|
+
</div>
|
|
66
|
+
{group.description && (
|
|
67
|
+
<p className="text-sm text-muted-foreground">{group.description}</p>
|
|
68
|
+
)}
|
|
69
|
+
{sectionsContent}
|
|
70
|
+
</div>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<Collapsible
|
|
76
|
+
open={!isCollapsed}
|
|
77
|
+
onOpenChange={(open) => setIsCollapsed(!open)}
|
|
78
|
+
className={className}
|
|
79
|
+
>
|
|
80
|
+
<CollapsibleTrigger asChild>
|
|
81
|
+
<div className="flex items-center gap-2 pb-2 border-b cursor-pointer hover:bg-muted/50 transition-colors rounded-t-md px-2 py-1.5">
|
|
82
|
+
{isCollapsed ? (
|
|
83
|
+
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
|
|
84
|
+
) : (
|
|
85
|
+
<ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" />
|
|
86
|
+
)}
|
|
87
|
+
{group.icon && <span className="text-muted-foreground">{group.icon}</span>}
|
|
88
|
+
<h3 className="text-lg font-semibold">{group.title}</h3>
|
|
89
|
+
</div>
|
|
90
|
+
</CollapsibleTrigger>
|
|
91
|
+
{group.description && !isCollapsed && (
|
|
92
|
+
<p className="text-sm text-muted-foreground mt-1">{group.description}</p>
|
|
93
|
+
)}
|
|
94
|
+
<CollapsibleContent>
|
|
95
|
+
<div className="mt-3">
|
|
96
|
+
{sectionsContent}
|
|
97
|
+
</div>
|
|
98
|
+
</CollapsibleContent>
|
|
99
|
+
</Collapsible>
|
|
100
|
+
);
|
|
101
|
+
};
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import { describe, it, expect } from 'vitest';
|
|
10
10
|
import { render, screen } from '@testing-library/react';
|
|
11
|
-
import { DetailSection } from '../DetailSection';
|
|
11
|
+
import { DetailSection, getResponsiveSpanClass } from '../DetailSection';
|
|
12
12
|
|
|
13
13
|
describe('DetailSection', () => {
|
|
14
14
|
it('should render text fields as plain text', () => {
|
|
@@ -112,7 +112,7 @@ describe('DetailSection', () => {
|
|
|
112
112
|
const { container } = render(
|
|
113
113
|
<DetailSection section={section} data={{}} />
|
|
114
114
|
);
|
|
115
|
-
// The grid container should have the
|
|
115
|
+
// The grid container should have the md:grid-cols-2 class
|
|
116
116
|
const grid = container.querySelector('.grid');
|
|
117
117
|
expect(grid).toBeTruthy();
|
|
118
118
|
expect(grid!.className).toContain('md:grid-cols-2');
|
|
@@ -317,4 +317,113 @@ describe('DetailSection', () => {
|
|
|
317
317
|
// Should use 'text' renderer, not 'number'
|
|
318
318
|
expect(screen.getByText('Alice')).toBeInTheDocument();
|
|
319
319
|
});
|
|
320
|
+
|
|
321
|
+
it('should use responsive span classes for wide fields in 3-column layout', () => {
|
|
322
|
+
const section = {
|
|
323
|
+
title: 'Wide Fields',
|
|
324
|
+
fields: Array.from({ length: 12 }, (_, i) => ({
|
|
325
|
+
name: `field_${i}`,
|
|
326
|
+
label: `Field ${i}`,
|
|
327
|
+
type: i === 5 ? 'textarea' : 'text',
|
|
328
|
+
})),
|
|
329
|
+
};
|
|
330
|
+
const { container } = render(
|
|
331
|
+
<DetailSection section={section} data={{}} />
|
|
332
|
+
);
|
|
333
|
+
const grid = container.querySelector('.grid');
|
|
334
|
+
expect(grid).toBeTruthy();
|
|
335
|
+
expect(grid!.className).toContain('lg:grid-cols-3');
|
|
336
|
+
// Wide field (textarea) should have responsive span, not bare col-span-3
|
|
337
|
+
const fields = container.querySelectorAll('[class*="col-span"]');
|
|
338
|
+
fields.forEach((field) => {
|
|
339
|
+
// No bare col-span-3 at base level — must be lg: prefixed
|
|
340
|
+
const classes = field.className.split(/\s+/);
|
|
341
|
+
const hasBareSpan3 = classes.some((c: string) => c === 'col-span-3');
|
|
342
|
+
expect(hasBareSpan3).toBe(false);
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('should use responsive span classes for wide fields in 2-column layout', () => {
|
|
347
|
+
const section = {
|
|
348
|
+
title: 'Wide Fields',
|
|
349
|
+
fields: [
|
|
350
|
+
{ name: 'a', label: 'A', type: 'text' },
|
|
351
|
+
{ name: 'b', label: 'B', type: 'text' },
|
|
352
|
+
{ name: 'c', label: 'C', type: 'text' },
|
|
353
|
+
{ name: 'd', label: 'D', type: 'text' },
|
|
354
|
+
{ name: 'notes', label: 'Notes', type: 'textarea' },
|
|
355
|
+
],
|
|
356
|
+
};
|
|
357
|
+
const { container } = render(
|
|
358
|
+
<DetailSection section={section} data={{}} />
|
|
359
|
+
);
|
|
360
|
+
const grid = container.querySelector('.grid');
|
|
361
|
+
expect(grid!.className).toContain('md:grid-cols-2');
|
|
362
|
+
// Wide field should have md:col-span-2, not bare col-span-2
|
|
363
|
+
const fields = container.querySelectorAll('[class*="col-span"]');
|
|
364
|
+
fields.forEach((field) => {
|
|
365
|
+
const classes = field.className.split(/\s+/);
|
|
366
|
+
const hasBareSpan2 = classes.some((c: string) => c === 'col-span-2');
|
|
367
|
+
expect(hasBareSpan2).toBe(false);
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it('should not apply col-span at base breakpoint to prevent implicit grid columns on mobile', () => {
|
|
372
|
+
const section = {
|
|
373
|
+
title: 'Mobile Safe',
|
|
374
|
+
fields: Array.from({ length: 15 }, (_, i) => ({
|
|
375
|
+
name: `field_${i}`,
|
|
376
|
+
label: `Field ${i}`,
|
|
377
|
+
type: i === 0 ? 'textarea' : 'text',
|
|
378
|
+
})),
|
|
379
|
+
};
|
|
380
|
+
const { container } = render(
|
|
381
|
+
<DetailSection section={section} data={{}} />
|
|
382
|
+
);
|
|
383
|
+
// Ensure no bare col-span-N (N>1) classes without responsive prefix
|
|
384
|
+
const allElements = container.querySelectorAll('*');
|
|
385
|
+
allElements.forEach((el) => {
|
|
386
|
+
const classes = el.className?.split?.(/\s+/) || [];
|
|
387
|
+
classes.forEach((cls: string) => {
|
|
388
|
+
if (cls.match(/^col-span-[2-9]$/)) {
|
|
389
|
+
throw new Error(`Found bare "${cls}" class without responsive prefix — would break mobile single-column layout`);
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
describe('getResponsiveSpanClass', () => {
|
|
397
|
+
it('should return empty string for no span', () => {
|
|
398
|
+
expect(getResponsiveSpanClass(undefined, 2)).toBe('');
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it('should return empty string for span=1', () => {
|
|
402
|
+
expect(getResponsiveSpanClass(1, 3)).toBe('');
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it('should return empty string for 1-column layout', () => {
|
|
406
|
+
expect(getResponsiveSpanClass(3, 1)).toBe('');
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it('should return md:col-span-2 for span=2 in 2-column layout', () => {
|
|
410
|
+
expect(getResponsiveSpanClass(2, 2)).toBe('md:col-span-2');
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it('should cap span to 2 in 2-column layout', () => {
|
|
414
|
+
expect(getResponsiveSpanClass(3, 2)).toBe('md:col-span-2');
|
|
415
|
+
expect(getResponsiveSpanClass(6, 2)).toBe('md:col-span-2');
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it('should return md:col-span-2 for span=2 in 3-column layout', () => {
|
|
419
|
+
expect(getResponsiveSpanClass(2, 3)).toBe('md:col-span-2');
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it('should return responsive classes for span=3 in 3-column layout', () => {
|
|
423
|
+
expect(getResponsiveSpanClass(3, 3)).toBe('md:col-span-2 lg:col-span-3');
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it('should cap span to 3 in 3-column layout', () => {
|
|
427
|
+
expect(getResponsiveSpanClass(6, 3)).toBe('md:col-span-2 lg:col-span-3');
|
|
428
|
+
});
|
|
320
429
|
});
|
|
@@ -539,6 +539,37 @@ describe('DetailView', () => {
|
|
|
539
539
|
expect(onBack).toHaveBeenCalled();
|
|
540
540
|
});
|
|
541
541
|
|
|
542
|
+
it('should try fallback with alternate ID when first findOne throws an error', async () => {
|
|
543
|
+
let callCount = 0;
|
|
544
|
+
const mockDataSource = {
|
|
545
|
+
findOne: vi.fn().mockImplementation((_obj: string, id: string) => {
|
|
546
|
+
callCount++;
|
|
547
|
+
if (callCount === 1) {
|
|
548
|
+
// First call throws (simulate server error)
|
|
549
|
+
return Promise.reject(new Error('Server error'));
|
|
550
|
+
}
|
|
551
|
+
// Second call (fallback) succeeds
|
|
552
|
+
return Promise.resolve({ name: 'Alice' });
|
|
553
|
+
}),
|
|
554
|
+
} as any;
|
|
555
|
+
|
|
556
|
+
const schema: DetailViewSchema = {
|
|
557
|
+
type: 'detail-view',
|
|
558
|
+
title: 'Contact Details',
|
|
559
|
+
objectName: 'contact',
|
|
560
|
+
resourceId: 'contact-123',
|
|
561
|
+
fields: [{ name: 'name', label: 'Name' }],
|
|
562
|
+
};
|
|
563
|
+
|
|
564
|
+
const { findByText } = render(<DetailView schema={schema} dataSource={mockDataSource} />);
|
|
565
|
+
// The fallback should find the record using the stripped ID
|
|
566
|
+
expect(await findByText('Alice')).toBeInTheDocument();
|
|
567
|
+
// findOne should be called twice: first with original ID, then with stripped prefix
|
|
568
|
+
expect(mockDataSource.findOne).toHaveBeenCalledTimes(2);
|
|
569
|
+
expect(mockDataSource.findOne).toHaveBeenNthCalledWith(1, 'contact', 'contact-123');
|
|
570
|
+
expect(mockDataSource.findOne).toHaveBeenNthCalledWith(2, 'contact', '123');
|
|
571
|
+
});
|
|
572
|
+
|
|
542
573
|
it('should call findOne with $expand when objectSchema has lookup fields', async () => {
|
|
543
574
|
const mockDataSource = {
|
|
544
575
|
getObjectSchema: vi.fn().mockResolvedValue({
|