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:
@@ -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 => (
|
||||
|
||||
Reference in New Issue
Block a user