Skip to content

Commit 28e66e2

Browse files
feat: support slot ID aliases (#178)
Add idAliases prop to Slot, allowing operations configured under deprecated slot IDs to continue applying after a slot is renamed. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent fead54a commit 28e66e2

6 files changed

Lines changed: 67 additions & 14 deletions

File tree

runtime/slots/Slot.test.tsx

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { useContext } from 'react';
44
import { useLayoutForSlotId } from './layout/hooks';
55
import Slot from './Slot';
66
import SlotContext from './SlotContext';
7+
import { getSiteConfig, setSiteConfig } from '../config';
8+
import { WidgetOperationTypes } from '.';
79

810
jest.mock('./layout/hooks');
911

@@ -38,3 +40,33 @@ describe('Slot component', () => {
3840
expect(getByText('Custom Layout Element')).toBeInTheDocument();
3941
});
4042
});
43+
44+
const TestSlot = () => <Slot id="new-slot.ui" idAliases={['old-slot.ui']} />;
45+
46+
describe('Slot component with site config operations', () => {
47+
beforeEach(() => {
48+
(useLayoutForSlotId as jest.Mock).mockReturnValue(null);
49+
});
50+
51+
afterEach(() => {
52+
setSiteConfig({ ...getSiteConfig(), apps: [] });
53+
});
54+
55+
it('renders widgets configured under the primary slot id', async () => {
56+
setSiteConfig({
57+
...getSiteConfig(),
58+
apps: [{ appId: 'test-app', slots: [{ slotId: 'new-slot.ui', op: WidgetOperationTypes.APPEND, id: 'widget1', element: <div>Primary Widget</div> }] }],
59+
});
60+
const { findByText } = render(<MemoryRouter><TestSlot /></MemoryRouter>);
61+
await findByText('Primary Widget');
62+
});
63+
64+
it('renders widgets configured under an alias slot id', async () => {
65+
setSiteConfig({
66+
...getSiteConfig(),
67+
apps: [{ appId: 'test-app', slots: [{ slotId: 'old-slot.ui', op: WidgetOperationTypes.APPEND, id: 'widget1', element: <div>Alias Widget</div> }] }],
68+
});
69+
const { findByText } = render(<MemoryRouter><TestSlot /></MemoryRouter>);
70+
await findByText('Alias Widget');
71+
});
72+
});

runtime/slots/Slot.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,18 @@ import { ComponentType, createElement, isValidElement, ReactNode } from 'react';
22
import DefaultSlotLayout from './layout/DefaultSlotLayout';
33
import { useLayoutForSlotId } from './layout/hooks';
44
import SlotContext from './SlotContext';
5+
import { useSlotContext } from './hooks';
56

67
interface SlotProps {
78
id: string,
9+
idAliases?: string[],
810
children?: ReactNode,
911
layout?: ComponentType | ReactNode,
1012
[key: string]: unknown,
1113
}
1214

13-
export default function Slot({ id, children, layout = DefaultSlotLayout, ...props }: SlotProps) {
15+
function SlotRenderer({ layout }: { layout: ComponentType | ReactNode }) {
16+
const { id } = useSlotContext();
1417
let layoutElement: ComponentType | ReactNode = layout;
1518

1619
const overrideLayout = useLayoutForSlotId(id);
@@ -24,9 +27,13 @@ export default function Slot({ id, children, layout = DefaultSlotLayout, ...prop
2427
layoutElement = createElement(layoutElement as ComponentType);
2528
}
2629

30+
return <>{layoutElement}</>;
31+
}
32+
33+
export default function Slot({ id, idAliases, children, layout = DefaultSlotLayout, ...props }: SlotProps) {
2734
return (
28-
<SlotContext.Provider value={{ id, children, ...props }}>
29-
{layoutElement}
35+
<SlotContext.Provider value={{ id, idAliases, children, ...props }}>
36+
<SlotRenderer layout={layout} />
3037
</SlotContext.Provider>
3138
);
3239
}

runtime/slots/SlotContext.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { createContext, ReactNode } from 'react';
22

3-
const SlotContext = createContext<{ id: string, children?: ReactNode, [key: string]: unknown }>({
3+
const SlotContext = createContext<{ id: string, idAliases?: string[], children?: ReactNode, [key: string]: unknown }>({
44
id: '',
55
children: null,
66
});

runtime/slots/hooks.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { createWidgetAppendOperation } from './widget';
1212
* config as it changes.
1313
*/
1414
export function useSlotOperations(id: string) {
15-
const { children } = useSlotContext();
15+
const { children, idAliases } = useSlotContext();
1616
const location = useLocation();
1717
const [operations, setOperations] = useState<SlotOperation[]>([]);
1818

@@ -21,11 +21,11 @@ export function useSlotOperations(id: string) {
2121
// when [children] props change. This avoids an endless render loop. After all, the whole
2222
// point of a slot is to modify its children via slot operations.
2323
const defaultOperation = createWidgetAppendOperation('defaultContent', id, children);
24-
setOperations(getSlotOperations(id, defaultOperation));
24+
setOperations(getSlotOperations([id, ...(idAliases ?? [])], defaultOperation));
2525

2626
// We depend on [location] to force re-renders on navigation. This guarantees changes in active
2727
// roles (and thus, changes in what conditional widgets are shown) properly.
28-
}, [id, children, location]);
28+
}, [id, children, idAliases, location]);
2929

3030
return operations;
3131
}

runtime/slots/utils.test.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,20 +8,20 @@ jest.mock('../config');
88
describe('getSlotOperations', () => {
99
it('should return an empty array if no apps are configured', () => {
1010
(getSiteConfig as jest.Mock).mockReturnValue({ apps: [] });
11-
const result = getSlotOperations('test-slot.ui');
11+
const result = getSlotOperations(['test-slot.ui']);
1212
expect(result).toEqual([]);
1313
});
1414

1515
it('should return an empty array if no slots are present in apps', () => {
1616
(getSiteConfig as jest.Mock).mockReturnValue({ apps: [{ slots: [] }] });
17-
const result = getSlotOperations('test-slot.ui');
17+
const result = getSlotOperations(['test-slot.ui']);
1818
expect(result).toEqual([]);
1919
});
2020

2121
it('should return an empty array if no matching slotId is found', () => {
2222
const mockSlots: SlotOperation[] = [{ slotId: 'other-slot.ui', op: WidgetOperationTypes.APPEND, id: 'widget1', element: '' }];
2323
(getSiteConfig as jest.Mock).mockReturnValue({ apps: [{ slots: mockSlots }] });
24-
const result = getSlotOperations('test-slot.ui');
24+
const result = getSlotOperations(['test-slot.ui']);
2525
expect(result).toEqual([]);
2626
});
2727

@@ -32,7 +32,7 @@ describe('getSlotOperations', () => {
3232
{ slotId: 'other-slot.ui', op: WidgetOperationTypes.APPEND, id: 'widget3', element: '' },
3333
];
3434
(getSiteConfig as jest.Mock).mockReturnValue({ apps: [{ slots: mockSlots }] });
35-
const result = getSlotOperations('test-slot.ui');
35+
const result = getSlotOperations(['test-slot.ui']);
3636
expect(result).toEqual([
3737
{ slotId: 'test-slot.ui', op: WidgetOperationTypes.APPEND, id: 'widget1', element: '' },
3838
{ slotId: 'test-slot.ui', op: WidgetOperationTypes.APPEND, id: 'widget2', element: '' },
@@ -55,10 +55,24 @@ describe('getSlotOperations', () => {
5555
}
5656
]
5757
});
58-
const result = getSlotOperations('test-slot.ui');
58+
const result = getSlotOperations(['test-slot.ui']);
5959
expect(result).toEqual([
6060
{ slotId: 'test-slot.ui', op: WidgetOperationTypes.APPEND, id: 'widget1', element: '' },
6161
{ slotId: 'test-slot.ui', op: WidgetOperationTypes.APPEND, id: 'widget2', element: '' },
6262
]);
6363
});
64+
65+
it('should return operations matching both the primary id and an alias id', () => {
66+
const mockSlots: SlotOperation[] = [
67+
{ slotId: 'test-slot.ui', op: WidgetOperationTypes.APPEND, id: 'widget1', element: '' },
68+
{ slotId: 'test-slot-alias.ui', op: WidgetOperationTypes.APPEND, id: 'widget2', element: '' },
69+
{ slotId: 'other-slot.ui', op: WidgetOperationTypes.APPEND, id: 'widget3', element: '' },
70+
];
71+
(getSiteConfig as jest.Mock).mockReturnValue({ apps: [{ slots: mockSlots }] });
72+
const result = getSlotOperations(['test-slot.ui', 'test-slot-alias.ui']);
73+
expect(result).toEqual([
74+
{ slotId: 'test-slot.ui', op: WidgetOperationTypes.APPEND, id: 'widget1', element: '' },
75+
{ slotId: 'test-slot-alias.ui', op: WidgetOperationTypes.APPEND, id: 'widget2', element: '' },
76+
]);
77+
});
6478
});

runtime/slots/utils.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { getAuthenticatedUser } from '../auth';
22
import { getActiveRoles, getSiteConfig } from '../config';
33
import { SlotOperation } from './types';
44

5-
export function getSlotOperations(id: string, defaultOperation?: SlotOperation) {
5+
export function getSlotOperations(ids: string[], defaultOperation?: SlotOperation) {
66
const { apps } = getSiteConfig();
77
const ops: SlotOperation[] = [];
88

@@ -14,7 +14,7 @@ export function getSlotOperations(id: string, defaultOperation?: SlotOperation)
1414
apps.forEach((app) => {
1515
if (Array.isArray(app.slots)) {
1616
app.slots.forEach((operation) => {
17-
if (operation.slotId === id) {
17+
if (ids.includes(operation.slotId)) {
1818
ops.push(operation);
1919
}
2020
});

0 commit comments

Comments
 (0)