Skip to content

Commit 1ea1b20

Browse files
authored
Merge pull request #1003 from objectstack-ai/copilot/fix-duplicate-action-buttons
2 parents a06a1c7 + 97d6678 commit 1ea1b20

File tree

3 files changed

+59
-9
lines changed

3 files changed

+59
-9
lines changed

apps/console/src/components/RecordDetailView.tsx

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

389-
// Filter actions for record_header location
390-
const recordHeaderActions = (objectDef.actions || []).filter(
391-
(a: any) => a.locations?.includes('record_header'),
392-
);
389+
// Filter actions for record_header location and deduplicate by name
390+
const recordHeaderActions = (() => {
391+
const seen = new Set<string>();
392+
return (objectDef.actions || []).filter((a: any) => {
393+
if (!a.locations?.includes('record_header')) return false;
394+
if (!a.name) return true;
395+
if (seen.has(a.name)) return false;
396+
seen.add(a.name);
397+
return true;
398+
});
399+
})();
393400

394401
// Build highlightFields: prefer explicit config, fallback to auto-detect key fields
395402
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)