scaffolder: sync selected action with URL hash

Reflect the currently selected action in the URL hash so that users can
deep-link to a specific action on the actions page. On load the hash is
read to pre-select and scroll to the matching action.

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
Made-with: Cursor
This commit is contained in:
Patrik Oldsberg
2026-04-04 11:28:47 +02:00
parent 8f93ed9e12
commit 67fcc5a689
3 changed files with 106 additions and 6 deletions
+1 -1
View File
@@ -2,4 +2,4 @@
'@backstage/plugin-scaffolder': patch
---
Migrated the actions page to use `@backstage/ui` list and search components. Actions are now presented in a sidebar list with a separate detail panel for the selected action, along with built-in search filtering.
Migrated the actions page to use `@backstage/ui` list and search components. Actions are now presented in a sidebar list with a separate detail panel for the selected action, along with built-in search filtering. The selected action is also reflected in the URL hash, allowing deep-linking to a specific action.
@@ -54,7 +54,10 @@ async function selectAction(actionId: string) {
}
describe('ActionsPage', () => {
beforeEach(() => jest.resetAllMocks());
beforeEach(() => {
jest.resetAllMocks();
window.location.hash = '';
});
it('renders actions in a table and shows detail on row click', async () => {
scaffolderApiMock.listActions.mockResolvedValue([
@@ -678,6 +681,78 @@ describe('ActionsPage', () => {
).toBeInTheDocument();
});
it('should pre-select the action matching the URL hash on load', async () => {
scaffolderApiMock.listActions.mockResolvedValue([
{
id: 'publish:github',
description: 'Publish to GitHub',
schema: {
input: {
type: 'object',
properties: {
repo: { title: 'Repo name', type: 'string' },
},
},
},
},
{
id: 'fetch:plain',
description: 'Fetch plain content',
schema: {},
},
]);
window.location.hash = '#publish:github';
await renderInTestApp(
<ApiProvider apis={apis}>
<ActionsPage />
</ApiProvider>,
{
mountedRoutes: {
'/create/actions': rootRouteRef,
},
routeEntries: ['/create/actions#publish:github'],
},
);
expect(
await screen.findByRole('heading', { name: 'publish:github' }),
).toBeInTheDocument();
});
it('should update the URL hash when selecting and deselecting actions', async () => {
scaffolderApiMock.listActions.mockResolvedValue([
{
id: 'publish:github',
description: 'Publish to GitHub',
schema: {},
},
{
id: 'fetch:plain',
description: 'Fetch plain content',
schema: {},
},
]);
await renderInTestApp(
<ApiProvider apis={apis}>
<ActionsPage />
</ApiProvider>,
{
mountedRoutes: {
'/create/actions': rootRouteRef,
},
},
);
await selectAction('publish:github');
expect(window.location.hash).toBe('#publish:github');
await selectAction('publish:github');
expect(window.location.hash).toBe('');
});
it('should keep search field focused when filtering causes empty then non-empty results', async () => {
scaffolderApiMock.listActions.mockResolvedValue([
{
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import useAsync from 'react-use/esm/useAsync';
import { Action, scaffolderApiRef } from '@backstage/plugin-scaffolder-react';
@@ -116,6 +116,24 @@ export const ActionPageContent = () => {
string | undefined
>();
const [searchQuery, setSearchQuery] = useState('');
const initialHashHandled = useRef(false);
useEffect(() => {
if (initialHashHandled.current || !actions) {
return;
}
const hash = window.location.hash.slice(1);
if (hash && actions.some(a => a.id === hash)) {
initialHashHandled.current = true;
setSelectedActionId(hash);
requestAnimationFrame(() => {
const row = document.querySelector(`[data-key="${CSS.escape(hash)}"]`);
if (row && typeof row.scrollIntoView === 'function') {
row.scrollIntoView({ block: 'nearest' });
}
});
}
}, [actions]);
const filteredActions = useMemo(() => {
const nonLegacy =
@@ -182,9 +200,16 @@ export const ActionPageContent = () => {
return;
}
const selected = [...selection][0] as string | undefined;
setSelectedActionId(prev =>
prev === selected ? undefined : selected,
);
setSelectedActionId(prev => {
const next = prev === selected ? undefined : selected;
const hash = next ? `#${next}` : '';
window.history.replaceState(
null,
'',
`${window.location.pathname}${window.location.search}${hash}`,
);
return next;
});
}}
>
{filteredActions.map(action => (