From 1ed66d33746b8f5cbc15d5c67cec5207dbc82c7c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Mar 2026 08:55:33 +0000 Subject: [PATCH 1/4] Initial plan From ccd7cdaebcc56bab747871201988d11b4c686d53 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Mar 2026 09:13:06 +0000 Subject: [PATCH 2/4] feat(plugin-detail): implement all 6 roadmap features - Auto-discover related lists from objectSchema reference fields - Tab layout (Details/Related/Activity) for detail page - Related list row-level Edit/Delete quick actions - Related list pagination, sorting, filtering - Collapsible section groups (SectionGroup component) - Header highlight area with key fields (HeaderHighlight component) - Add SectionGroup and HighlightField types to @object-ui/types - Add i18n translation keys for new features - Add 36 new tests covering all features - Update ROADMAP.md to reflect completed items Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- ROADMAP.md | 14 +- packages/plugin-detail/src/DetailView.tsx | 285 +++++++++-- .../plugin-detail/src/HeaderHighlight.tsx | 62 +++ packages/plugin-detail/src/RelatedList.tsx | 209 +++++++- packages/plugin-detail/src/SectionGroup.tsx | 97 ++++ .../src/__tests__/HeaderHighlight.test.tsx | 68 +++ .../src/__tests__/SectionGroup.test.tsx | 101 ++++ .../src/__tests__/roadmap-features.test.tsx | 478 ++++++++++++++++++ packages/plugin-detail/src/index.tsx | 4 + .../plugin-detail/src/useDetailTranslation.ts | 11 + packages/types/src/index.ts | 2 + packages/types/src/views.ts | 68 +++ 12 files changed, 1329 insertions(+), 70 deletions(-) create mode 100644 packages/plugin-detail/src/HeaderHighlight.tsx create mode 100644 packages/plugin-detail/src/SectionGroup.tsx create mode 100644 packages/plugin-detail/src/__tests__/HeaderHighlight.test.tsx create mode 100644 packages/plugin-detail/src/__tests__/SectionGroup.test.tsx create mode 100644 packages/plugin-detail/src/__tests__/roadmap-features.test.tsx diff --git a/ROADMAP.md b/ROADMAP.md index 1a72d9ef1..9edac7958 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1461,13 +1461,13 @@ All 313 `@object-ui/fields` tests pass. - [x] 2 new DetailView i18n fallback tests (Record not found text, Related heading) - [x] Updated DetailSection tests for new empty value styling -**Remaining (future PRs):** -- [ ] Auto-discover related lists from objectSchema reference fields -- [ ] Tab layout (Details/Related/Activity) for detail page -- [ ] Related list row-level Edit/Delete quick actions -- [ ] Related list pagination, sorting, filtering -- [ ] Collapsible section groups -- [ ] Header highlight area with key fields +**Completed:** +- [x] Auto-discover related lists from objectSchema reference fields +- [x] Tab layout (Details/Related/Activity) for detail page +- [x] Related list row-level Edit/Delete quick actions +- [x] Related list pagination, sorting, filtering +- [x] Collapsible section groups +- [x] Header highlight area with key fields --- diff --git a/packages/plugin-detail/src/DetailView.tsx b/packages/plugin-detail/src/DetailView.tsx index e14f2137a..990834e95 100644 --- a/packages/plugin-detail/src/DetailView.tsx +++ b/packages/plugin-detail/src/DetailView.tsx @@ -21,6 +21,10 @@ import { TooltipContent, TooltipProvider, TooltipTrigger, + Tabs, + TabsList, + TabsTrigger, + TabsContent, } from '@object-ui/components'; import { ArrowLeft, @@ -40,6 +44,8 @@ import { import { DetailSection } from './DetailSection'; import { DetailTabs } from './DetailTabs'; import { RelatedList } from './RelatedList'; +import { SectionGroup } from './SectionGroup'; +import { HeaderHighlight } from './HeaderHighlight'; import { RecordComments } from './RecordComments'; import { ActivityTimeline } from './ActivityTimeline'; import { SchemaRenderer } from '@object-ui/react'; @@ -288,6 +294,40 @@ export const DetailView: React.FC = ({ return () => document.removeEventListener('keydown', handler); }, [schema.recordNavigation]); + // Auto-discover related lists from objectSchema reference fields + const discoveredRelated = React.useMemo(() => { + if (!schema.autoDiscoverRelated || !objectSchema?.fields) return []; + // Only auto-discover when no explicit related config is provided + if (schema.related && schema.related.length > 0) return []; + const refs: Array<{ title: string; type: 'list' | 'grid' | 'table'; objectName: string; referenceField: string }> = []; + const fields = objectSchema.fields; + for (const [fieldName, fieldDef] of Object.entries(fields)) { + if ( + fieldDef && + (fieldDef.type === 'lookup' || fieldDef.type === 'master_detail') && + fieldDef.reference_to + ) { + refs.push({ + title: fieldDef.label || fieldName.charAt(0).toUpperCase() + fieldName.slice(1), + type: 'table', + objectName: fieldDef.reference_to, + referenceField: fieldName, + }); + } + } + return refs; + }, [schema.autoDiscoverRelated, schema.related, objectSchema]); + + // Merge explicit and auto-discovered related lists + const effectiveRelated = React.useMemo(() => { + if (schema.related && schema.related.length > 0) return schema.related; + return discoveredRelated.map((r) => ({ + title: r.title, + type: r.type, + data: [] as any[], + })); + }, [schema.related, discoveredRelated]); + if (loading || schema.loading) { return (
@@ -528,70 +568,205 @@ export const DetailView: React.FC = ({
)} - {/* Sections */} - {schema.sections && schema.sections.length > 0 && ( -
- {schema.sections.map((section, index) => ( + {/* Header Highlight Area */} + {schema.highlightFields && schema.highlightFields.length > 0 && ( + + )} + + {/* Auto Tabs mode: wrap sections, related, activity into tabs */} + {schema.autoTabs && !schema.tabs?.length ? ( + + + + {t('detail.details')} + + {effectiveRelated.length > 0 && ( + + + {t('detail.related')} + {effectiveRelated.length} + + + )} + {schema.activities && schema.activities.length > 0 && ( + + + {t('detail.activity')} + {schema.activities.length} + + + )} + + + {/* Details Tab Content */} + +
+ {/* Section Groups */} + {schema.sectionGroups && schema.sectionGroups.length > 0 && ( + schema.sectionGroups.map((group, index) => ( + + )) + )} + {schema.sections && schema.sections.length > 0 && ( + schema.sections.map((section, index) => ( + + )) + )} + {schema.fields && schema.fields.length > 0 && !schema.sections?.length && ( + + )} + {/* Comments in details tab */} + {schema.comments && ( + + )} +
+
+ + {/* Related Tab Content */} + {effectiveRelated.length > 0 && ( + +
+ {effectiveRelated.map((related, index) => ( + + ))} +
+
+ )} + + {/* Activity Tab Content */} + {schema.activities && schema.activities.length > 0 && ( + + + + )} +
+ ) : ( + <> + {/* Section Groups */} + {schema.sectionGroups && schema.sectionGroups.length > 0 && ( +
+ {schema.sectionGroups.map((group, index) => ( + + ))} +
+ )} + + {/* Sections */} + {schema.sections && schema.sections.length > 0 && ( +
+ {schema.sections.map((section, index) => ( + + ))} +
+ )} + + {/* Direct Fields (if no sections) */} + {schema.fields && schema.fields.length > 0 && !schema.sections?.length && ( - ))} -
- )} - - {/* Direct Fields (if no sections) */} - {schema.fields && schema.fields.length > 0 && !schema.sections?.length && ( - - )} - - {/* Tabs */} - {schema.tabs && schema.tabs.length > 0 && ( - - )} + )} + + {/* Tabs */} + {schema.tabs && schema.tabs.length > 0 && ( + + )} + + {/* Related Lists */} + {effectiveRelated.length > 0 && ( +
+

{t('detail.related')}

+ {effectiveRelated.map((related, index) => ( + + ))} +
+ )} - {/* Related Lists */} - {schema.related && schema.related.length > 0 && ( -
-

{t('detail.related')}

- {schema.related.map((related, index) => ( - - ))} -
- )} - - {/* Comments */} - {schema.comments && ( - - )} + )} - {/* Activity Timeline */} - {schema.activities && schema.activities.length > 0 && ( - + {/* Activity Timeline */} + {schema.activities && schema.activities.length > 0 && ( + + )} + )} {/* Custom Footer */} diff --git a/packages/plugin-detail/src/HeaderHighlight.tsx b/packages/plugin-detail/src/HeaderHighlight.tsx new file mode 100644 index 000000000..65c754d68 --- /dev/null +++ b/packages/plugin-detail/src/HeaderHighlight.tsx @@ -0,0 +1,62 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import * as React from 'react'; +import { cn, Card, CardContent } from '@object-ui/components'; +import type { HighlightField } from '@object-ui/types'; + +export interface HeaderHighlightProps { + fields: HighlightField[]; + data?: any; + className?: string; +} + +export const HeaderHighlight: React.FC = ({ + fields, + data, + className, +}) => { + if (!fields.length || !data) return null; + + // Filter to only fields with values + const visibleFields = fields.filter((f) => { + const val = data?.[f.name]; + return val !== null && val !== undefined && val !== ''; + }); + + if (visibleFields.length === 0) return null; + + return ( + + +
+ {visibleFields.map((field) => { + const value = data[field.name]; + return ( +
+ + {field.icon && {field.icon}} + {field.label} + + + {String(value)} + +
+ ); + })} +
+
+
+ ); +}; diff --git a/packages/plugin-detail/src/RelatedList.tsx b/packages/plugin-detail/src/RelatedList.tsx index e12f16956..4996e7e36 100644 --- a/packages/plugin-detail/src/RelatedList.tsx +++ b/packages/plugin-detail/src/RelatedList.tsx @@ -7,9 +7,24 @@ */ import * as React from 'react'; -import { Card, CardHeader, CardTitle, CardContent, Button } from '@object-ui/components'; +import { + Card, + CardHeader, + CardTitle, + CardContent, + Button, + Input, +} from '@object-ui/components'; import { SchemaRenderer } from '@object-ui/react'; -import { Plus, ExternalLink } from 'lucide-react'; +import { + Plus, + ExternalLink, + Edit, + Trash2, + ChevronLeft, + ChevronRight, + ArrowUpDown, +} from 'lucide-react'; import type { DataSource } from '@object-ui/types'; import { useDetailTranslation } from './useDetailTranslation'; @@ -26,6 +41,16 @@ export interface RelatedListProps { onNew?: () => void; /** Callback when "View All" button is clicked */ onViewAll?: () => void; + /** Callback when a row Edit action is clicked */ + onRowEdit?: (row: any) => void; + /** Callback when a row Delete action is clicked */ + onRowDelete?: (row: any) => void; + /** Page size for pagination (enables pagination when set) */ + pageSize?: number; + /** Enable column sorting */ + sortable?: boolean; + /** Enable text filtering */ + filterable?: boolean; } export const RelatedList: React.FC = ({ @@ -39,9 +64,18 @@ export const RelatedList: React.FC = ({ dataSource, onNew, onViewAll, + onRowEdit, + onRowDelete, + pageSize, + sortable = false, + filterable = false, }) => { const [relatedData, setRelatedData] = React.useState(data); const [loading, setLoading] = React.useState(false); + const [currentPage, setCurrentPage] = React.useState(0); + const [sortField, setSortField] = React.useState(null); + const [sortDirection, setSortDirection] = React.useState<'asc' | 'desc'>('asc'); + const [filterText, setFilterText] = React.useState(''); const { t } = useDetailTranslation(); React.useEffect(() => { @@ -75,6 +109,58 @@ export const RelatedList: React.FC = ({ } }, [api, data, dataSource]); + // Filter data + const filteredData = React.useMemo(() => { + if (!filterText) return relatedData; + const lower = filterText.toLowerCase(); + return relatedData.filter((row) => + Object.values(row).some((val) => + val !== null && val !== undefined && String(val).toLowerCase().includes(lower) + ) + ); + }, [relatedData, filterText]); + + // Sort data + const sortedData = React.useMemo(() => { + if (!sortField) return filteredData; + return [...filteredData].sort((a, b) => { + const aVal = a[sortField]; + const bVal = b[sortField]; + if (aVal == null && bVal == null) return 0; + if (aVal == null) return 1; + if (bVal == null) return -1; + const cmp = String(aVal).localeCompare(String(bVal), undefined, { numeric: true }); + return sortDirection === 'asc' ? cmp : -cmp; + }); + }, [filteredData, sortField, sortDirection]); + + // Paginate data + const effectivePageSize = pageSize && pageSize > 0 ? pageSize : 0; + const totalPages = effectivePageSize ? Math.max(1, Math.ceil(sortedData.length / effectivePageSize)) : 1; + const paginatedData = effectivePageSize + ? sortedData.slice(currentPage * effectivePageSize, (currentPage + 1) * effectivePageSize) + : sortedData; + + // Reset to first page when filter/sort changes + React.useEffect(() => { + setCurrentPage(0); + }, [filterText, sortField, sortDirection]); + + const handleSort = React.useCallback((field: string) => { + if (sortField === field) { + setSortDirection((d) => (d === 'asc' ? 'desc' : 'asc')); + } else { + setSortField(field); + setSortDirection('asc'); + } + }, [sortField]); + + const handleDeleteRow = React.useCallback((row: any) => { + if (window.confirm(t('detail.deleteRowConfirmation'))) { + onRowDelete?.(row); + } + }, [onRowDelete, t]); + const viewSchema = React.useMemo(() => { if (schema) return schema; @@ -84,25 +170,27 @@ export const RelatedList: React.FC = ({ case 'table': return { type: 'data-table', - data: relatedData, + data: paginatedData, columns: columns || [], - pagination: relatedData.length > 10, - pageSize: 10, + pagination: false, // We handle pagination ourselves + pageSize: effectivePageSize || 10, }; case 'list': return { type: 'data-list', - data: relatedData, + data: paginatedData, }; default: return { type: 'div', children: 'No view configured' }; } - }, [type, relatedData, columns, schema]); + }, [type, paginatedData, columns, schema, effectivePageSize]); const recordCountText = relatedData.length === 1 ? t('detail.relatedRecordOne', { count: relatedData.length }) : t('detail.relatedRecords', { count: relatedData.length }); + const hasRowActions = !!onRowEdit || !!onRowDelete; + return ( @@ -130,6 +218,43 @@ export const RelatedList: React.FC = ({ + {/* Filter bar */} + {filterable && relatedData.length > 0 && ( +
+ setFilterText(e.target.value)} + className="h-8 text-sm" + /> +
+ )} + + {/* Sortable column headers */} + {sortable && columns && columns.length > 0 && relatedData.length > 0 && ( +
+ {columns.map((col: any) => { + const field = col.accessorKey || col.field || col.name; + if (!field) return null; + const label = col.header || col.label || field; + const isActive = sortField === field; + return ( + + ); + })} +
+ )} + {loading ? (
{t('detail.loading')} @@ -139,7 +264,75 @@ export const RelatedList: React.FC = ({ {t('detail.noRelatedRecords')}
) : ( - + <> + + + {/* Row-level actions (rendered as a simple action list below data) */} + {hasRowActions && paginatedData.length > 0 && ( +
+ {paginatedData.map((row, idx) => ( +
+ + {row.name || row.title || row.id || `Row ${idx + 1}`} + +
+ {onRowEdit && ( + + )} + {onRowDelete && ( + + )} +
+
+ ))} +
+ )} + + )} + + {/* Pagination controls */} + {effectivePageSize > 0 && sortedData.length > effectivePageSize && ( +
+ + + {t('detail.pageOf', { current: currentPage + 1, total: totalPages })} + + +
)}
diff --git a/packages/plugin-detail/src/SectionGroup.tsx b/packages/plugin-detail/src/SectionGroup.tsx new file mode 100644 index 000000000..87e4fd72e --- /dev/null +++ b/packages/plugin-detail/src/SectionGroup.tsx @@ -0,0 +1,97 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import * as React from 'react'; +import { + cn, + Collapsible, + CollapsibleTrigger, + CollapsibleContent, +} from '@object-ui/components'; +import { ChevronDown, ChevronRight } from 'lucide-react'; +import { DetailSection } from './DetailSection'; +import type { SectionGroup as SectionGroupType } from '@object-ui/types'; + +export interface SectionGroupProps { + group: SectionGroupType; + data?: any; + className?: string; + objectSchema?: any; + isEditing?: boolean; + onFieldChange?: (field: string, value: any) => void; +} + +export const SectionGroup: React.FC = ({ + group, + data, + className, + objectSchema, + isEditing = false, + onFieldChange, +}) => { + const collapsible = group.collapsible ?? true; + const [isCollapsed, setIsCollapsed] = React.useState(group.defaultCollapsed ?? false); + + const sectionsContent = ( +
+ {group.sections.map((section, index) => ( + + ))} +
+ ); + + if (!collapsible) { + return ( +
+
+ {group.icon && {group.icon}} +

{group.title}

+
+ {group.description && ( +

{group.description}

+ )} + {sectionsContent} +
+ ); + } + + return ( + setIsCollapsed(!open)} + className={className} + > + +
+ {isCollapsed ? ( + + ) : ( + + )} + {group.icon && {group.icon}} +

{group.title}

+
+
+ {group.description && !isCollapsed && ( +

{group.description}

+ )} + +
+ {sectionsContent} +
+
+
+ ); +}; diff --git a/packages/plugin-detail/src/__tests__/HeaderHighlight.test.tsx b/packages/plugin-detail/src/__tests__/HeaderHighlight.test.tsx new file mode 100644 index 000000000..8ff2718b3 --- /dev/null +++ b/packages/plugin-detail/src/__tests__/HeaderHighlight.test.tsx @@ -0,0 +1,68 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { HeaderHighlight } from '../HeaderHighlight'; +import type { HighlightField } from '@object-ui/types'; + +describe('HeaderHighlight', () => { + const fields: HighlightField[] = [ + { name: 'revenue', label: 'Annual Revenue' }, + { name: 'employees', label: 'Employees' }, + { name: 'industry', label: 'Industry' }, + ]; + + const data = { + revenue: '$5M', + employees: 150, + industry: 'Technology', + }; + + it('should render highlight fields with labels and values', () => { + render(); + expect(screen.getByText('Annual Revenue')).toBeInTheDocument(); + expect(screen.getByText('$5M')).toBeInTheDocument(); + expect(screen.getByText('Employees')).toBeInTheDocument(); + expect(screen.getByText('150')).toBeInTheDocument(); + expect(screen.getByText('Industry')).toBeInTheDocument(); + expect(screen.getByText('Technology')).toBeInTheDocument(); + }); + + it('should not render when no data is provided', () => { + const { container } = render(); + expect(container.innerHTML).toBe(''); + }); + + it('should not render when fields array is empty', () => { + const { container } = render(); + expect(container.innerHTML).toBe(''); + }); + + it('should hide fields with null or empty values', () => { + const sparseData = { revenue: '$5M', employees: null, industry: '' }; + render(); + expect(screen.getByText('$5M')).toBeInTheDocument(); + expect(screen.queryByText('Employees')).not.toBeInTheDocument(); + expect(screen.queryByText('Industry')).not.toBeInTheDocument(); + }); + + it('should not render when all field values are empty', () => { + const emptyData = { revenue: null, employees: undefined, industry: '' }; + const { container } = render(); + expect(container.innerHTML).toBe(''); + }); + + it('should render icon when provided', () => { + const fieldsWithIcon: HighlightField[] = [ + { name: 'revenue', label: 'Revenue', icon: '💰' }, + ]; + render(); + expect(screen.getByText('💰')).toBeInTheDocument(); + }); +}); diff --git a/packages/plugin-detail/src/__tests__/SectionGroup.test.tsx b/packages/plugin-detail/src/__tests__/SectionGroup.test.tsx new file mode 100644 index 000000000..54b1781a9 --- /dev/null +++ b/packages/plugin-detail/src/__tests__/SectionGroup.test.tsx @@ -0,0 +1,101 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { SectionGroup } from '../SectionGroup'; +import type { SectionGroup as SectionGroupType } from '@object-ui/types'; + +describe('SectionGroup', () => { + const baseGroup: SectionGroupType = { + title: 'Address Information', + sections: [ + { + title: 'Billing', + fields: [ + { name: 'billingStreet', label: 'Street' }, + { name: 'billingCity', label: 'City' }, + ], + }, + { + title: 'Shipping', + fields: [ + { name: 'shippingStreet', label: 'Street' }, + { name: 'shippingCity', label: 'City' }, + ], + }, + ], + }; + + const data = { + billingStreet: '123 Main St', + billingCity: 'Springfield', + shippingStreet: '456 Oak Ave', + shippingCity: 'Shelbyville', + }; + + it('should render group title', () => { + render(); + expect(screen.getByText('Address Information')).toBeInTheDocument(); + }); + + it('should render child section titles', () => { + render(); + expect(screen.getByText('Billing')).toBeInTheDocument(); + expect(screen.getByText('Shipping')).toBeInTheDocument(); + }); + + it('should render field values in child sections', () => { + render(); + expect(screen.getByText('123 Main St')).toBeInTheDocument(); + expect(screen.getByText('Springfield')).toBeInTheDocument(); + }); + + it('should be collapsible by default', () => { + render(); + // The group should render a collapsible trigger + const trigger = screen.getByText('Address Information'); + expect(trigger.closest('[data-state]') || trigger.closest('div')).toBeTruthy(); + }); + + it('should start collapsed when defaultCollapsed is true', () => { + const collapsedGroup = { ...baseGroup, defaultCollapsed: true }; + render(); + // Title should still be visible + expect(screen.getByText('Address Information')).toBeInTheDocument(); + // Child section content should be hidden (in collapsed state) + expect(screen.queryByText('123 Main St')).not.toBeInTheDocument(); + }); + + it('should expand when clicked while collapsed', () => { + const collapsedGroup = { ...baseGroup, defaultCollapsed: true }; + render(); + + // Click the trigger to expand + fireEvent.click(screen.getByText('Address Information')); + + // Content should now be visible + expect(screen.getByText('123 Main St')).toBeInTheDocument(); + }); + + it('should render description when provided', () => { + const groupWithDesc = { ...baseGroup, description: 'Billing and shipping addresses', collapsible: false }; + render(); + expect(screen.getByText('Billing and shipping addresses')).toBeInTheDocument(); + }); + + it('should not be collapsible when collapsible is false', () => { + const nonCollapsible = { ...baseGroup, collapsible: false }; + const { container } = render(); + // The top-level group heading should not have a cursor-pointer collapsible trigger + const heading = screen.getByText('Address Information'); + const parentDiv = heading.closest('div'); + // Non-collapsible group renders a static border-b div, not a CollapsibleTrigger + expect(parentDiv?.className).not.toContain('cursor-pointer'); + }); +}); diff --git a/packages/plugin-detail/src/__tests__/roadmap-features.test.tsx b/packages/plugin-detail/src/__tests__/roadmap-features.test.tsx new file mode 100644 index 000000000..6c419ee5c --- /dev/null +++ b/packages/plugin-detail/src/__tests__/roadmap-features.test.tsx @@ -0,0 +1,478 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { DetailView } from '../DetailView'; +import { RelatedList } from '../RelatedList'; +import type { DetailViewSchema } from '@object-ui/types'; + +describe('Roadmap Features', () => { + // ── Feature 1: Auto-discover related lists ── + describe('Auto-discover related lists', () => { + it('should auto-discover related lists from objectSchema reference fields', async () => { + const mockDataSource = { + getObjectSchema: vi.fn().mockResolvedValue({ + fields: { + name: { type: 'text' }, + account: { type: 'lookup', reference_to: 'account', label: 'Account' }, + contact: { type: 'master_detail', reference_to: 'contact', label: 'Primary Contact' }, + }, + }), + findOne: vi.fn().mockResolvedValue({ name: 'Order 1' }), + } as any; + + const schema: DetailViewSchema = { + type: 'detail-view', + title: 'Order Details', + objectName: 'order', + resourceId: 'order-1', + fields: [{ name: 'name', label: 'Name' }], + autoDiscoverRelated: true, + }; + + render(); + + // Wait for data to load + await waitFor(() => { + expect(screen.getByText('Order 1')).toBeInTheDocument(); + }); + + // Should show auto-discovered related lists + expect(screen.getByText('Account')).toBeInTheDocument(); + expect(screen.getByText('Primary Contact')).toBeInTheDocument(); + }); + + it('should not auto-discover when autoDiscoverRelated is false', () => { + const schema: DetailViewSchema = { + type: 'detail-view', + title: 'Order Details', + data: { name: 'Order 1' }, + fields: [{ name: 'name', label: 'Name' }], + autoDiscoverRelated: false, + }; + + render(); + // Should not show "Related" heading + expect(screen.queryByText('Related')).not.toBeInTheDocument(); + }); + + it('should not auto-discover when explicit related lists are provided', async () => { + const mockDataSource = { + getObjectSchema: vi.fn().mockResolvedValue({ + fields: { + name: { type: 'text' }, + account: { type: 'lookup', reference_to: 'account', label: 'Account' }, + }, + }), + findOne: vi.fn().mockResolvedValue({ name: 'Order 1' }), + } as any; + + const schema: DetailViewSchema = { + type: 'detail-view', + title: 'Order Details', + objectName: 'order', + resourceId: 'order-1', + fields: [{ name: 'name', label: 'Name' }], + autoDiscoverRelated: true, + related: [ + { title: 'Custom Related', type: 'table', data: [] }, + ], + }; + + render(); + + await waitFor(() => { + expect(screen.getByText('Order 1')).toBeInTheDocument(); + }); + + // Should show explicit related, not auto-discovered + expect(screen.getByText('Custom Related')).toBeInTheDocument(); + }); + }); + + // ── Feature 2: Auto Tabs layout ── + describe('Auto Tabs layout', () => { + it('should render Details/Related/Activity tabs when autoTabs is true', () => { + const schema: DetailViewSchema = { + type: 'detail-view', + title: 'Account Details', + data: { name: 'Acme Corp' }, + fields: [{ name: 'name', label: 'Name' }], + autoTabs: true, + related: [ + { title: 'Contacts', type: 'table', data: [] }, + ], + activities: [ + { id: '1', type: 'create', user: 'Bob', timestamp: '2026-02-15T10:00:00Z' }, + ], + }; + + render(); + + // All three tabs should be present + expect(screen.getByText('Details')).toBeInTheDocument(); + expect(screen.getByText('Related')).toBeInTheDocument(); + }); + + it('should show sections inside Details tab when autoTabs is true', () => { + const schema: DetailViewSchema = { + type: 'detail-view', + title: 'Account Details', + data: { name: 'Acme Corp', email: 'acme@example.com' }, + sections: [ + { + title: 'Basic Info', + fields: [ + { name: 'name', label: 'Name' }, + { name: 'email', label: 'Email' }, + ], + }, + ], + autoTabs: true, + }; + + render(); + + // Details tab should be active by default + expect(screen.getByText('Basic Info')).toBeInTheDocument(); + expect(screen.getByText('Acme Corp')).toBeInTheDocument(); + }); + + it('should not render autoTabs when explicit tabs are provided', () => { + const schema: DetailViewSchema = { + type: 'detail-view', + title: 'Account', + data: { name: 'Acme' }, + fields: [{ name: 'name', label: 'Name' }], + autoTabs: true, + tabs: [ + { key: 'custom', label: 'Custom Tab', content: { type: 'text', text: 'Custom' } }, + ], + }; + + render(); + // Should not render auto-tabs Details/Related/Activity + // Instead renders explicit tabs + expect(screen.queryByRole('tab', { name: 'Details' })).not.toBeInTheDocument(); + }); + }); + + // ── Feature 3: Related list row-level Edit/Delete ── + describe('Related list row-level actions', () => { + it('should render Edit button for each row when onRowEdit is provided', () => { + const onRowEdit = vi.fn(); + const data = [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + ]; + + render( + + ); + + const editButtons = screen.getAllByText('Edit'); + expect(editButtons.length).toBe(2); + }); + + it('should call onRowEdit with the correct row when clicked', () => { + const onRowEdit = vi.fn(); + const data = [{ id: 1, name: 'Alice' }]; + + render( + + ); + + fireEvent.click(screen.getByText('Edit')); + expect(onRowEdit).toHaveBeenCalledWith({ id: 1, name: 'Alice' }); + }); + + it('should render Delete button for each row when onRowDelete is provided', () => { + const onRowDelete = vi.fn(); + const data = [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + ]; + + render( + + ); + + const deleteButtons = screen.getAllByText('Delete'); + expect(deleteButtons.length).toBe(2); + }); + + it('should call onRowDelete with the correct row after confirmation', () => { + const onRowDelete = vi.fn(); + const data = [{ id: 1, name: 'Alice' }]; + const confirmSpy = vi.fn().mockReturnValue(true); + window.confirm = confirmSpy; + + render( + + ); + + fireEvent.click(screen.getByText('Delete')); + expect(confirmSpy).toHaveBeenCalled(); + expect(onRowDelete).toHaveBeenCalledWith({ id: 1, name: 'Alice' }); + }); + + it('should not call onRowDelete when confirmation is cancelled', () => { + const onRowDelete = vi.fn(); + const data = [{ id: 1, name: 'Alice' }]; + const confirmSpy = vi.fn().mockReturnValue(false); + window.confirm = confirmSpy; + + render( + + ); + + fireEvent.click(screen.getByText('Delete')); + expect(onRowDelete).not.toHaveBeenCalled(); + }); + }); + + // ── Feature 4: Related list pagination, sorting, filtering ── + describe('Related list pagination', () => { + const manyItems = Array.from({ length: 15 }, (_, i) => ({ + id: i + 1, + name: `Item ${i + 1}`, + })); + + it('should show pagination controls when pageSize is set', () => { + render( + + ); + + expect(screen.getByText('Page 1 of 3')).toBeInTheDocument(); + expect(screen.getByText('Next')).toBeInTheDocument(); + expect(screen.getByText('Previous')).toBeInTheDocument(); + }); + + it('should navigate to next page', () => { + render( + + ); + + fireEvent.click(screen.getByText('Next')); + expect(screen.getByText('Page 2 of 3')).toBeInTheDocument(); + }); + + it('should disable Previous on first page', () => { + render( + + ); + + const prevButton = screen.getByText('Previous').closest('button'); + expect(prevButton).toBeDisabled(); + }); + + it('should not show pagination when all items fit on one page', () => { + const fewItems = [{ id: 1, name: 'Item 1' }]; + render( + + ); + + expect(screen.queryByText(/Page \d+ of \d+/)).not.toBeInTheDocument(); + }); + }); + + describe('Related list filtering', () => { + const data = [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + { id: 3, name: 'Charlie' }, + ]; + + it('should render filter input when filterable is true', () => { + render( + + ); + + expect(screen.getByPlaceholderText('Filter...')).toBeInTheDocument(); + }); + + it('should not render filter input when filterable is false', () => { + render( + + ); + + expect(screen.queryByPlaceholderText('Filter...')).not.toBeInTheDocument(); + }); + }); + + describe('Related list sorting', () => { + const data = [ + { id: 1, name: 'Charlie' }, + { id: 2, name: 'Alice' }, + { id: 3, name: 'Bob' }, + ]; + + const columns = [ + { accessorKey: 'name', header: 'Name' }, + ]; + + it('should render sort buttons when sortable is true', () => { + render( + + ); + + // Sort buttons include an ArrowUpDown icon + const sortBtns = screen.getAllByRole('button').filter(btn => + btn.querySelector('.lucide-arrow-up-down') + ); + expect(sortBtns.length).toBe(1); + }); + + it('should not render sort buttons when sortable is false', () => { + render( + + ); + + // Name appears as a sort button only when sortable; verify no ArrowUpDown icon + const sortBtns = screen.queryAllByRole('button').filter(btn => + btn.querySelector('.lucide-arrow-up-down') + ); + expect(sortBtns.length).toBe(0); + }); + }); + + // ── Feature 5: Collapsible section groups ── + describe('Collapsible section groups in DetailView', () => { + it('should render section groups', () => { + const schema: DetailViewSchema = { + type: 'detail-view', + title: 'Account Details', + data: { billingStreet: '123 Main St', shippingStreet: '456 Oak Ave' }, + fields: [], + sectionGroups: [ + { + title: 'Address Information', + sections: [ + { + title: 'Billing', + fields: [{ name: 'billingStreet', label: 'Street' }], + }, + { + title: 'Shipping', + fields: [{ name: 'shippingStreet', label: 'Street' }], + }, + ], + }, + ], + }; + + render(); + expect(screen.getByText('Address Information')).toBeInTheDocument(); + expect(screen.getByText('Billing')).toBeInTheDocument(); + expect(screen.getByText('Shipping')).toBeInTheDocument(); + }); + }); + + // ── Feature 6: Header highlight area ── + describe('Header highlight area', () => { + it('should render highlight fields below the header', () => { + const schema: DetailViewSchema = { + type: 'detail-view', + title: 'Account Details', + data: { name: 'Acme Corp', revenue: '$5M', employees: 150 }, + fields: [{ name: 'name', label: 'Name' }], + highlightFields: [ + { name: 'revenue', label: 'Annual Revenue' }, + { name: 'employees', label: 'Employees' }, + ], + }; + + render(); + expect(screen.getByText('Annual Revenue')).toBeInTheDocument(); + expect(screen.getByText('$5M')).toBeInTheDocument(); + expect(screen.getByText('Employees')).toBeInTheDocument(); + expect(screen.getByText('150')).toBeInTheDocument(); + }); + + it('should not render highlight area when no highlightFields are provided', () => { + const schema: DetailViewSchema = { + type: 'detail-view', + title: 'Account Details', + data: { name: 'Acme Corp' }, + fields: [{ name: 'name', label: 'Name' }], + }; + + const { container } = render(); + // No highlight card should be present + expect(container.querySelector('.border-dashed')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/packages/plugin-detail/src/index.tsx b/packages/plugin-detail/src/index.tsx index 85e4bffd7..9b710cfa9 100644 --- a/packages/plugin-detail/src/index.tsx +++ b/packages/plugin-detail/src/index.tsx @@ -14,6 +14,8 @@ import { RelatedList } from './RelatedList'; import type { DetailViewSchema } from '@object-ui/types'; export { DetailView, DetailSection, DetailTabs, RelatedList }; +export { SectionGroup } from './SectionGroup'; +export { HeaderHighlight } from './HeaderHighlight'; export { inferDetailColumns, isWideFieldType, applyAutoSpan, applyDetailAutoLayout } from './autoLayout'; export { useDetailTranslation, DETAIL_DEFAULT_TRANSLATIONS, createSafeTranslationHook } from './useDetailTranslation'; export { RecordComments } from './RecordComments'; @@ -37,6 +39,8 @@ export type { DetailViewProps } from './DetailView'; export type { DetailSectionProps } from './DetailSection'; export type { DetailTabsProps } from './DetailTabs'; export type { RelatedListProps } from './RelatedList'; +export type { SectionGroupProps } from './SectionGroup'; +export type { HeaderHighlightProps } from './HeaderHighlight'; export type { RecordCommentsProps } from './RecordComments'; export type { ActivityTimelineProps, ActivityFilterType } from './ActivityTimeline'; export type { InlineCreateRelatedProps, RelatedFieldDefinition, RelatedRecordOption } from './InlineCreateRelated'; diff --git a/packages/plugin-detail/src/useDetailTranslation.ts b/packages/plugin-detail/src/useDetailTranslation.ts index ddaf79643..62ff947ba 100644 --- a/packages/plugin-detail/src/useDetailTranslation.ts +++ b/packages/plugin-detail/src/useDetailTranslation.ts @@ -91,6 +91,17 @@ export const DETAIL_DEFAULT_TRANSLATIONS: Record = { 'detail.viewAll': 'View All', 'detail.new': 'New', 'detail.emptyValue': '—', + 'detail.activity': 'Activity', + 'detail.editRow': 'Edit', + 'detail.deleteRow': 'Delete', + 'detail.deleteRowConfirmation': 'Are you sure you want to delete this record?', + 'detail.actions': 'Actions', + 'detail.previousPage': 'Previous', + 'detail.nextPage': 'Next', + 'detail.pageOf': 'Page {{current}} of {{total}}', + 'detail.sortBy': 'Sort by', + 'detail.filterPlaceholder': 'Filter...', + 'detail.highlightFields': 'Key Fields', }; /** diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index b07e12a03..eda13e1bb 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -711,6 +711,8 @@ export type { DetailViewField, DetailViewSection, DetailViewTab, + SectionGroup, + HighlightField, ViewSwitcherSchema, FilterUISchema, SortUISchema, diff --git a/packages/types/src/views.ts b/packages/types/src/views.ts index 9ca4f7f09..84599729c 100644 --- a/packages/types/src/views.ts +++ b/packages/types/src/views.ts @@ -90,6 +90,52 @@ export interface DetailViewField { currency?: string; } +/** + * Collapsible Section Group — groups multiple DetailViewSections under + * a single collapsible header. + */ +export interface SectionGroup { + /** + * Group title + */ + title: string; + /** + * Group description + */ + description?: string; + /** + * Group icon + */ + icon?: string; + /** + * Whether the group is collapsible + * @default true + */ + collapsible?: boolean; + /** + * Default collapsed state + */ + defaultCollapsed?: boolean; + /** + * Sections in this group + */ + sections: DetailViewSection[]; +} + +/** + * Header Highlight Field — a key field to display prominently in the header area. + */ +export interface HighlightField { + /** Field name from the record data */ + name: string; + /** Display label */ + label: string; + /** Optional field type for formatting */ + type?: DetailViewField['type']; + /** Optional icon */ + icon?: string; +} + /** * Detail View Section/Group */ @@ -511,6 +557,28 @@ export interface DetailViewSchema extends BaseSchema { */ fields?: string[]; }>; + /** + * When true, auto-discover related lists from objectSchema reference fields + * (lookup, master_detail) when no explicit `related` is provided. + * Requires a DataSource with getObjectSchema. + * @default false + */ + autoDiscoverRelated?: boolean; + /** + * When true, automatically generate Details/Related/Activity tabs + * when no explicit `tabs` are configured. Sections go into the Details tab, + * related lists go into the Related tab, and activities go into the Activity tab. + * @default false + */ + autoTabs?: boolean; + /** + * Section groups — groups of sections rendered under a collapsible header. + */ + sectionGroups?: SectionGroup[]; + /** + * Key fields to display prominently in a highlight banner below the header. + */ + highlightFields?: HighlightField[]; /** * Record navigation configuration for prev/next navigation. * Allows navigating through a result set from within the detail view. From adb68393a0b88d7050823c8a41fcc0ae0515d183 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Mar 2026 10:17:34 +0000 Subject: [PATCH 3/4] fix(vercel): increase memory limit and reduce turbo concurrency for Vercel build Add NODE_OPTIONS=--max-old-space-size=8192 and --concurrency=2 to the Vercel build command to prevent OOM during monorepo compilation. Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- apps/console/vercel.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/console/vercel.json b/apps/console/vercel.json index 0cc3ddb22..52416bc91 100644 --- a/apps/console/vercel.json +++ b/apps/console/vercel.json @@ -1,7 +1,7 @@ { "$schema": "https://openapi.vercel.sh/vercel.json", "installCommand": "cd ../.. && pnpm install --frozen-lockfile", - "buildCommand": "cd ../.. && pnpm turbo run build --filter=@object-ui/console^... && cd apps/console && VITE_BASE_PATH=/ pnpm build:vercel", + "buildCommand": "cd ../.. && NODE_OPTIONS=--max-old-space-size=8192 pnpm turbo run build --filter=@object-ui/console^... --concurrency=2 && cd apps/console && NODE_OPTIONS=--max-old-space-size=8192 VITE_BASE_PATH=/ pnpm build:vercel", "outputDirectory": "dist", "framework": "vite", "rewrites": [ From 6eb3fbd3a8609810c8669b6909b2e526c08bf370 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Mar 2026 10:27:17 +0000 Subject: [PATCH 4/4] fix(vercel): revert vercel.json to original build command The NODE_OPTIONS and --concurrency=2 changes caused the build to exceed the 45-minute timeout. Reverting to the original build command that was working before this PR. Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- apps/console/vercel.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/console/vercel.json b/apps/console/vercel.json index 52416bc91..0cc3ddb22 100644 --- a/apps/console/vercel.json +++ b/apps/console/vercel.json @@ -1,7 +1,7 @@ { "$schema": "https://openapi.vercel.sh/vercel.json", "installCommand": "cd ../.. && pnpm install --frozen-lockfile", - "buildCommand": "cd ../.. && NODE_OPTIONS=--max-old-space-size=8192 pnpm turbo run build --filter=@object-ui/console^... --concurrency=2 && cd apps/console && NODE_OPTIONS=--max-old-space-size=8192 VITE_BASE_PATH=/ pnpm build:vercel", + "buildCommand": "cd ../.. && pnpm turbo run build --filter=@object-ui/console^... && cd apps/console && VITE_BASE_PATH=/ pnpm build:vercel", "outputDirectory": "dist", "framework": "vite", "rewrites": [