@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.
Files changed (66) hide show
  1. package/.turbo/turbo-build.log +41 -41
  2. package/CHANGELOG.md +21 -0
  3. package/dist/{AddressField-C07oUOY6.js → AddressField-QBIlXCFl.js} +1 -1
  4. package/dist/{AvatarField-VThNABzo.js → AvatarField-BEZuQTAH.js} +1 -1
  5. package/dist/{BooleanField-CGHKBzAi.js → BooleanField-doa93aFX.js} +1 -1
  6. package/dist/{CodeField-Co_muhRR.js → CodeField-jVV-hIXg.js} +1 -1
  7. package/dist/{ColorField-DLid_tFz.js → ColorField-B53qKQGW.js} +1 -1
  8. package/dist/{CurrencyField-Bw-LqANM.js → CurrencyField-og0NJ2ax.js} +1 -1
  9. package/dist/{DateField-BNHAzMB2.js → DateField-BFx64AtG.js} +1 -1
  10. package/dist/{DateTimeField-DjAyn_DQ.js → DateTimeField-Cxs2Rx2f.js} +1 -1
  11. package/dist/{EmailField-xoNcSppb.js → EmailField-BfcpzRe7.js} +1 -1
  12. package/dist/{FileField-DbNJwjU2.js → FileField-KarqvhYm.js} +1 -1
  13. package/dist/{GeolocationField-C1AnS6VV.js → GeolocationField-B5SKZaqn.js} +1 -1
  14. package/dist/{GridField-DATAHIKf.js → GridField-DOotrUTo.js} +1 -1
  15. package/dist/{ImageField-CEKJpyJp.js → ImageField-Ddotp4u-.js} +1 -1
  16. package/dist/{LocationField-jDWXjlpx.js → LocationField-tOkQaPIM.js} +1 -1
  17. package/dist/{LookupField-DQ08L9UQ.js → LookupField-DF36GvIP.js} +1 -1
  18. package/dist/{MasterDetailField-Dbk529Ea.js → MasterDetailField-CpHw3nTE.js} +1 -1
  19. package/dist/{NumberField-BVroN9aV.js → NumberField-CzBb2a28.js} +1 -1
  20. package/dist/{ObjectField-CT3l_IHW.js → ObjectField-BoL-JqE4.js} +1 -1
  21. package/dist/{PasswordField-DweVLEE0.js → PasswordField-DrTzkYgj.js} +1 -1
  22. package/dist/{PercentField-ZpWUK97K.js → PercentField-B9ZUQ3zE.js} +1 -1
  23. package/dist/{PhoneField-mw-9fqZ_.js → PhoneField-Bf9lhpdu.js} +1 -1
  24. package/dist/{QRCodeField-Cbb9ck59.js → QRCodeField-PzMpdBKd.js} +1 -1
  25. package/dist/{RatingField-CSqgLS6t.js → RatingField-CeBMFe8o.js} +1 -1
  26. package/dist/{RichTextField-BpfBOd99.js → RichTextField-Ch7CHSQ0.js} +1 -1
  27. package/dist/{SelectField-B9Ei-5jl.js → SelectField-f5Nbi02x.js} +1 -1
  28. package/dist/{SignatureField-DgGpHnQ8.js → SignatureField-CpxTX2tR.js} +1 -1
  29. package/dist/{SliderField-C6HvOHd8.js → SliderField-BoZtzgcr.js} +1 -1
  30. package/dist/{TextAreaField-BK3RgzY3.js → TextAreaField-rT1DLnV2.js} +1 -1
  31. package/dist/{TextField-Bvzx3atT.js → TextField-CflRxusu.js} +1 -1
  32. package/dist/{TimeField-Cuz9-Uai.js → TimeField-DeVeCpRu.js} +1 -1
  33. package/dist/{UrlField-B6XHTV73.js → UrlField-UWKfhP9T.js} +1 -1
  34. package/dist/{UserField-ooTul2d6.js → UserField-Cp2zQDjz.js} +1 -1
  35. package/dist/index-V_WBvcaA.js +100249 -0
  36. package/dist/index.js +20 -18
  37. package/dist/index.umd.cjs +117 -46
  38. package/dist/plugin-detail.css +1 -1
  39. package/dist/src/DetailSection.d.ts +11 -0
  40. package/dist/src/DetailSection.d.ts.map +1 -1
  41. package/dist/src/DetailView.d.ts.map +1 -1
  42. package/dist/src/HeaderHighlight.d.ts +18 -0
  43. package/dist/src/HeaderHighlight.d.ts.map +1 -0
  44. package/dist/src/RelatedList.d.ts +16 -0
  45. package/dist/src/RelatedList.d.ts.map +1 -1
  46. package/dist/src/SectionGroup.d.ts +21 -0
  47. package/dist/src/SectionGroup.d.ts.map +1 -0
  48. package/dist/src/index.d.ts +4 -0
  49. package/dist/src/index.d.ts.map +1 -1
  50. package/dist/src/useDetailTranslation.d.ts.map +1 -1
  51. package/package.json +6 -6
  52. package/src/DetailSection.tsx +50 -26
  53. package/src/DetailView.tsx +286 -69
  54. package/src/HeaderHighlight.tsx +67 -0
  55. package/src/RelatedList.tsx +287 -21
  56. package/src/SectionGroup.tsx +101 -0
  57. package/src/__tests__/DetailSection.test.tsx +111 -2
  58. package/src/__tests__/DetailView.test.tsx +31 -0
  59. package/src/__tests__/HeaderHighlight.test.tsx +68 -0
  60. package/src/__tests__/RelatedList.test.tsx +101 -7
  61. package/src/__tests__/SectionGroup.test.tsx +101 -0
  62. package/src/__tests__/roadmap-features.test.tsx +478 -0
  63. package/src/index.tsx +4 -0
  64. package/src/useDetailTranslation.ts +11 -0
  65. package/dist/index-CnlyRfY_.js +0 -59461
  66. package/src/registration.test.tsx +0 -18
@@ -7,11 +7,30 @@
7
7
  */
8
8
 
9
9
  import * as React from 'react';
10
- import { Card, CardHeader, CardTitle, CardContent, Button } from '@object-ui/components';
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 { Plus, ExternalLink } from 'lucide-react';
13
- import type { DataSource } from '@object-ui/types';
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: relatedData,
88
- columns: columns || [],
89
- pagination: relatedData.length > 10,
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: relatedData,
250
+ data: paginatedData,
96
251
  };
97
252
  default:
98
253
  return { type: 'div', children: 'No view configured' };
99
254
  }
100
- }, [type, relatedData, columns, schema]);
255
+ }, [type, paginatedData, effectiveColumns, schema, effectivePageSize]);
101
256
 
102
- const recordCountText = relatedData.length === 1
103
- ? t('detail.relatedRecordOne', { count: relatedData.length })
104
- : t('detail.relatedRecords', { count: relatedData.length });
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
- <span className="text-sm font-normal text-muted-foreground">
113
- {recordCountText}
114
- </span>
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
- <SchemaRenderer schema={viewSchema} />
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 sm:grid-cols-2 class
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({