Skip to content

Commit 9fd3b5a

Browse files
Copilothotlong
andcommitted
fix: deduplicate action buttons in action-bar and RecordDetailView
Actions with duplicate `name` entries are now filtered (keep-first) in the action:bar renderer (platform-level fix) and in RecordDetailView (consumer-level defense). This prevents duplicate buttons appearing inline and in overflow menus. Fixes #duplicate-action-buttons Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent ed70ab3 commit 9fd3b5a

3 files changed

Lines changed: 50 additions & 7 deletions

File tree

apps/console/src/components/RecordDetailView.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -386,10 +386,10 @@ export function RecordDetailView({ dataSource, objects, onEdit }: RecordDetailVi
386386
},
387387
];
388388

389-
// Filter actions for record_header location
389+
// Filter actions for record_header location and deduplicate by name
390390
const recordHeaderActions = (objectDef.actions || []).filter(
391391
(a: any) => a.locations?.includes('record_header'),
392-
);
392+
).filter((a: any, i: number, arr: any[]) => arr.findIndex((b: any) => b.name === a.name) === i);
393393

394394
// Build highlightFields: prefer explicit config, fallback to auto-detect key fields
395395
const explicitHighlight: HighlightField[] | undefined = objectDef.views?.detail?.highlightFields;

packages/components/src/__tests__/action-bar.test.tsx

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,40 @@ describe('ActionBar (action:bar)', () => {
9494
expect(container.textContent).toContain('Action 1');
9595
expect(container.textContent).toContain('Action 2');
9696
});
97+
98+
it('deduplicates actions by name', () => {
99+
const { container } = renderComponent({
100+
type: 'action:bar',
101+
actions: [
102+
{ name: 'change_status', label: 'Change Status', type: 'script', component: 'action:button' },
103+
{ name: 'assign_user', label: 'Assign User', type: 'script', component: 'action:button' },
104+
{ name: 'change_status', label: 'Change Status', type: 'script', component: 'action:button' },
105+
],
106+
});
107+
const toolbar = container.querySelector('[role="toolbar"]');
108+
expect(toolbar).toBeTruthy();
109+
// Should only render 2 actions (duplicates removed)
110+
expect(toolbar!.children.length).toBe(2);
111+
expect(container.textContent).toContain('Change Status');
112+
expect(container.textContent).toContain('Assign User');
113+
});
114+
115+
it('deduplicates actions after location filtering', () => {
116+
const { container } = renderComponent({
117+
type: 'action:bar',
118+
location: 'record_header',
119+
actions: [
120+
{ name: 'change_status', label: 'Change Status', type: 'script', locations: ['record_header'] },
121+
{ name: 'assign_user', label: 'Assign User', type: 'script', locations: ['record_header'] },
122+
{ name: 'change_status', label: 'Change Status', type: 'script', locations: ['record_header', 'record_more'] },
123+
{ name: 'assign_user', label: 'Assign User', type: 'script', locations: ['record_header'] },
124+
],
125+
});
126+
const toolbar = container.querySelector('[role="toolbar"]');
127+
expect(toolbar).toBeTruthy();
128+
// Should only render 2 unique actions
129+
expect(toolbar!.children.length).toBe(2);
130+
});
97131
});
98132

99133
describe('overflow', () => {

packages/components/src/renderers/action/action-bar.tsx

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -76,13 +76,22 @@ const ActionBarRenderer = forwardRef<HTMLDivElement, { schema: ActionBarSchema;
7676
const isVisible = useCondition(schema.visible ? `\${${schema.visible}}` : undefined);
7777
const isMobile = useIsMobile();
7878

79-
// Filter actions by location
79+
// Filter actions by location and deduplicate by name
8080
const filteredActions = useMemo(() => {
8181
const actions = schema.actions || [];
82-
if (!schema.location) return actions;
83-
return actions.filter(
84-
a => !a.locations || a.locations.length === 0 || a.locations.includes(schema.location!),
85-
);
82+
const located = !schema.location
83+
? actions
84+
: actions.filter(
85+
a => !a.locations || a.locations.length === 0 || a.locations.includes(schema.location!),
86+
);
87+
// Deduplicate by action name — keep first occurrence
88+
const seen = new Set<string>();
89+
return located.filter(a => {
90+
if (!a.name) return true;
91+
if (seen.has(a.name)) return false;
92+
seen.add(a.name);
93+
return true;
94+
});
8695
}, [schema.actions, schema.location]);
8796

8897
// Split into visible inline actions and overflow

0 commit comments

Comments
 (0)