diff --git a/apps/console/src/components/RecordDetailView.tsx b/apps/console/src/components/RecordDetailView.tsx index b361b50d26..318b098dcf 100644 --- a/apps/console/src/components/RecordDetailView.tsx +++ b/apps/console/src/components/RecordDetailView.tsx @@ -386,10 +386,17 @@ export function RecordDetailView({ dataSource, objects, onEdit }: RecordDetailVi }, ]; - // Filter actions for record_header location - const recordHeaderActions = (objectDef.actions || []).filter( - (a: any) => a.locations?.includes('record_header'), - ); + // Filter actions for record_header location and deduplicate by name + const recordHeaderActions = (() => { + const seen = new Set(); + return (objectDef.actions || []).filter((a: any) => { + if (!a.locations?.includes('record_header')) return false; + if (!a.name) return true; + if (seen.has(a.name)) return false; + seen.add(a.name); + return true; + }); + })(); // Build highlightFields: prefer explicit config, fallback to auto-detect key fields const explicitHighlight: HighlightField[] | undefined = objectDef.views?.detail?.highlightFields; diff --git a/packages/components/src/__tests__/action-bar.test.tsx b/packages/components/src/__tests__/action-bar.test.tsx index a8e2b0d775..c432a9f13e 100644 --- a/packages/components/src/__tests__/action-bar.test.tsx +++ b/packages/components/src/__tests__/action-bar.test.tsx @@ -94,6 +94,40 @@ describe('ActionBar (action:bar)', () => { expect(container.textContent).toContain('Action 1'); expect(container.textContent).toContain('Action 2'); }); + + it('deduplicates actions by name', () => { + const { container } = renderComponent({ + type: 'action:bar', + actions: [ + { name: 'change_status', label: 'Change Status', type: 'script', component: 'action:button' }, + { name: 'assign_user', label: 'Assign User', type: 'script', component: 'action:button' }, + { name: 'change_status', label: 'Change Status', type: 'script', component: 'action:button' }, + ], + }); + const toolbar = container.querySelector('[role="toolbar"]'); + expect(toolbar).toBeTruthy(); + // Should only render 2 actions (duplicates removed) + expect(toolbar!.children.length).toBe(2); + expect(container.textContent).toContain('Change Status'); + expect(container.textContent).toContain('Assign User'); + }); + + it('deduplicates actions after location filtering', () => { + const { container } = renderComponent({ + type: 'action:bar', + location: 'record_header', + actions: [ + { name: 'change_status', label: 'Change Status', type: 'script', locations: ['record_header'] }, + { name: 'assign_user', label: 'Assign User', type: 'script', locations: ['record_header'] }, + { name: 'change_status', label: 'Change Status', type: 'script', locations: ['record_header', 'record_more'] }, + { name: 'assign_user', label: 'Assign User', type: 'script', locations: ['record_header'] }, + ], + }); + const toolbar = container.querySelector('[role="toolbar"]'); + expect(toolbar).toBeTruthy(); + // Should only render 2 unique actions + expect(toolbar!.children.length).toBe(2); + }); }); describe('overflow', () => { diff --git a/packages/components/src/renderers/action/action-bar.tsx b/packages/components/src/renderers/action/action-bar.tsx index 4d647d0e92..f11a618d2d 100644 --- a/packages/components/src/renderers/action/action-bar.tsx +++ b/packages/components/src/renderers/action/action-bar.tsx @@ -76,13 +76,22 @@ const ActionBarRenderer = forwardRef { const actions = schema.actions || []; - if (!schema.location) return actions; - return actions.filter( - a => !a.locations || a.locations.length === 0 || a.locations.includes(schema.location!), - ); + const located = !schema.location + ? actions + : actions.filter( + a => !a.locations || a.locations.length === 0 || a.locations.includes(schema.location!), + ); + // Deduplicate by action name — keep first occurrence + const seen = new Set(); + return located.filter(a => { + if (!a.name) return true; + if (seen.has(a.name)) return false; + seen.add(a.name); + return true; + }); }, [schema.actions, schema.location]); // Split into visible inline actions and overflow