diff --git a/.changeset/scaffolder-actions-page-table.md b/.changeset/scaffolder-actions-page-table.md
index ec6c7ad335..c0443a2ab3 100644
--- a/.changeset/scaffolder-actions-page-table.md
+++ b/.changeset/scaffolder-actions-page-table.md
@@ -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.
diff --git a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx
index b40e514f88..9c004ab8a6 100644
--- a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx
+++ b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx
@@ -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(
+
+
+ ,
+ {
+ 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(
+
+
+ ,
+ {
+ 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([
{
diff --git a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx
index 3cfd4a1873..227943e467 100644
--- a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx
+++ b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx
@@ -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 => (