@object-ui/plugin-detail 3.1.0 → 3.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/.turbo/turbo-build.log +41 -41
  2. package/CHANGELOG.md +11 -0
  3. package/dist/{AddressField-C07oUOY6.js → AddressField-B1iVr404.js} +1 -1
  4. package/dist/{AvatarField-VThNABzo.js → AvatarField-Duw4xOLZ.js} +1 -1
  5. package/dist/{BooleanField-CGHKBzAi.js → BooleanField-CZ4axVeq.js} +1 -1
  6. package/dist/{CodeField-Co_muhRR.js → CodeField-BSz-mk2v.js} +1 -1
  7. package/dist/{ColorField-DLid_tFz.js → ColorField-B522ad8m.js} +1 -1
  8. package/dist/{CurrencyField-Bw-LqANM.js → CurrencyField-Cwr3_pow.js} +1 -1
  9. package/dist/{DateField-BNHAzMB2.js → DateField-DCo6dxud.js} +1 -1
  10. package/dist/{DateTimeField-DjAyn_DQ.js → DateTimeField-BWfBuANO.js} +1 -1
  11. package/dist/{EmailField-xoNcSppb.js → EmailField-CpwbdVCU.js} +1 -1
  12. package/dist/{FileField-DbNJwjU2.js → FileField-DVAUAJ8e.js} +1 -1
  13. package/dist/{GeolocationField-C1AnS6VV.js → GeolocationField-DNCKitgo.js} +1 -1
  14. package/dist/{GridField-DATAHIKf.js → GridField-DSblZNfp.js} +1 -1
  15. package/dist/{ImageField-CEKJpyJp.js → ImageField-DBAlnMon.js} +1 -1
  16. package/dist/{LocationField-jDWXjlpx.js → LocationField-DsHsXA6R.js} +1 -1
  17. package/dist/{LookupField-DQ08L9UQ.js → LookupField-CsT0QQz2.js} +1 -1
  18. package/dist/{MasterDetailField-Dbk529Ea.js → MasterDetailField-Db8b7Gqs.js} +1 -1
  19. package/dist/{NumberField-BVroN9aV.js → NumberField-0IGp7lcA.js} +1 -1
  20. package/dist/{ObjectField-CT3l_IHW.js → ObjectField-BLApgJtS.js} +1 -1
  21. package/dist/{PasswordField-DweVLEE0.js → PasswordField-pHKyNlmo.js} +1 -1
  22. package/dist/{PercentField-ZpWUK97K.js → PercentField-CwgKmlIb.js} +1 -1
  23. package/dist/{PhoneField-mw-9fqZ_.js → PhoneField-lKtbYOdN.js} +1 -1
  24. package/dist/{QRCodeField-Cbb9ck59.js → QRCodeField-BTTasT3w.js} +1 -1
  25. package/dist/{RatingField-CSqgLS6t.js → RatingField-De2X-l44.js} +1 -1
  26. package/dist/{RichTextField-BpfBOd99.js → RichTextField-B5QnvUOr.js} +1 -1
  27. package/dist/{SelectField-B9Ei-5jl.js → SelectField-C9AZRHWu.js} +1 -1
  28. package/dist/{SignatureField-DgGpHnQ8.js → SignatureField-BgcEmYzd.js} +1 -1
  29. package/dist/{SliderField-C6HvOHd8.js → SliderField-BzrttVOY.js} +1 -1
  30. package/dist/{TextAreaField-BK3RgzY3.js → TextAreaField-DSE_CaU6.js} +1 -1
  31. package/dist/{TextField-Bvzx3atT.js → TextField-DFQ4T9PR.js} +1 -1
  32. package/dist/{TimeField-Cuz9-Uai.js → TimeField-F0cfmsps.js} +1 -1
  33. package/dist/{UrlField-B6XHTV73.js → UrlField-DLXrFIH-.js} +1 -1
  34. package/dist/{UserField-ooTul2d6.js → UserField-PXMmxJY9.js} +1 -1
  35. package/dist/{index-CnlyRfY_.js → index-qQ1C-yUR.js} +21560 -21045
  36. package/dist/index.js +20 -18
  37. package/dist/index.umd.cjs +31 -31
  38. package/dist/plugin-detail.css +1 -1
  39. package/dist/src/DetailSection.d.ts +2 -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 +8 -2
  53. package/src/DetailView.tsx +271 -61
  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 +1 -1
  58. package/src/__tests__/HeaderHighlight.test.tsx +68 -0
  59. package/src/__tests__/RelatedList.test.tsx +101 -7
  60. package/src/__tests__/SectionGroup.test.tsx +101 -0
  61. package/src/__tests__/roadmap-features.test.tsx +478 -0
  62. package/src/index.tsx +4 -0
  63. package/src/useDetailTranslation.ts +11 -0
@@ -0,0 +1,67 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+
9
+ import * as React from 'react';
10
+ import { cn, Card, CardContent } from '@object-ui/components';
11
+ import type { HighlightField } from '@object-ui/types';
12
+ import { useSafeFieldLabel } from '@object-ui/react';
13
+
14
+ export interface HeaderHighlightProps {
15
+ fields: HighlightField[];
16
+ data?: any;
17
+ className?: string;
18
+ /** Object name for i18n field label resolution */
19
+ objectName?: string;
20
+ }
21
+
22
+ export const HeaderHighlight: React.FC<HeaderHighlightProps> = ({
23
+ fields,
24
+ data,
25
+ className,
26
+ objectName,
27
+ }) => {
28
+ const { fieldLabel } = useSafeFieldLabel();
29
+ if (!fields.length || !data) return null;
30
+
31
+ // Filter to only fields with values
32
+ const visibleFields = fields.filter((f) => {
33
+ const val = data?.[f.name];
34
+ return val !== null && val !== undefined && val !== '';
35
+ });
36
+
37
+ if (visibleFields.length === 0) return null;
38
+
39
+ return (
40
+ <Card className={cn('bg-muted/30 border-dashed', className)}>
41
+ <CardContent className="py-3 px-4">
42
+ <div className={cn(
43
+ 'grid gap-4',
44
+ visibleFields.length === 1 ? 'grid-cols-1' :
45
+ visibleFields.length === 2 ? 'grid-cols-2' :
46
+ visibleFields.length === 3 ? 'grid-cols-3' :
47
+ 'grid-cols-2 md:grid-cols-4'
48
+ )}>
49
+ {visibleFields.map((field) => {
50
+ const value = data[field.name];
51
+ return (
52
+ <div key={field.name} className="flex flex-col gap-0.5">
53
+ <span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
54
+ {field.icon && <span className="mr-1">{field.icon}</span>}
55
+ {fieldLabel(objectName || '', field.name, field.label)}
56
+ </span>
57
+ <span className="text-sm font-semibold truncate">
58
+ {String(value)}
59
+ </span>
60
+ </div>
61
+ );
62
+ })}
63
+ </div>
64
+ </CardContent>
65
+ </Card>
66
+ );
67
+ };
@@ -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
+ };
@@ -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');
@@ -0,0 +1,68 @@
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 { describe, it, expect } from 'vitest';
10
+ import { render, screen } from '@testing-library/react';
11
+ import { HeaderHighlight } from '../HeaderHighlight';
12
+ import type { HighlightField } from '@object-ui/types';
13
+
14
+ describe('HeaderHighlight', () => {
15
+ const fields: HighlightField[] = [
16
+ { name: 'revenue', label: 'Annual Revenue' },
17
+ { name: 'employees', label: 'Employees' },
18
+ { name: 'industry', label: 'Industry' },
19
+ ];
20
+
21
+ const data = {
22
+ revenue: '$5M',
23
+ employees: 150,
24
+ industry: 'Technology',
25
+ };
26
+
27
+ it('should render highlight fields with labels and values', () => {
28
+ render(<HeaderHighlight fields={fields} data={data} />);
29
+ expect(screen.getByText('Annual Revenue')).toBeInTheDocument();
30
+ expect(screen.getByText('$5M')).toBeInTheDocument();
31
+ expect(screen.getByText('Employees')).toBeInTheDocument();
32
+ expect(screen.getByText('150')).toBeInTheDocument();
33
+ expect(screen.getByText('Industry')).toBeInTheDocument();
34
+ expect(screen.getByText('Technology')).toBeInTheDocument();
35
+ });
36
+
37
+ it('should not render when no data is provided', () => {
38
+ const { container } = render(<HeaderHighlight fields={fields} />);
39
+ expect(container.innerHTML).toBe('');
40
+ });
41
+
42
+ it('should not render when fields array is empty', () => {
43
+ const { container } = render(<HeaderHighlight fields={[]} data={data} />);
44
+ expect(container.innerHTML).toBe('');
45
+ });
46
+
47
+ it('should hide fields with null or empty values', () => {
48
+ const sparseData = { revenue: '$5M', employees: null, industry: '' };
49
+ render(<HeaderHighlight fields={fields} data={sparseData} />);
50
+ expect(screen.getByText('$5M')).toBeInTheDocument();
51
+ expect(screen.queryByText('Employees')).not.toBeInTheDocument();
52
+ expect(screen.queryByText('Industry')).not.toBeInTheDocument();
53
+ });
54
+
55
+ it('should not render when all field values are empty', () => {
56
+ const emptyData = { revenue: null, employees: undefined, industry: '' };
57
+ const { container } = render(<HeaderHighlight fields={fields} data={emptyData} />);
58
+ expect(container.innerHTML).toBe('');
59
+ });
60
+
61
+ it('should render icon when provided', () => {
62
+ const fieldsWithIcon: HighlightField[] = [
63
+ { name: 'revenue', label: 'Revenue', icon: '💰' },
64
+ ];
65
+ render(<HeaderHighlight fields={fieldsWithIcon} data={{ revenue: '$5M' }} />);
66
+ expect(screen.getByText('💰')).toBeInTheDocument();
67
+ });
68
+ });