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 => (