diff --git a/client/src/includes/panels.ts b/client/src/includes/panels.ts index b75286bb55..ea49b30137 100644 --- a/client/src/includes/panels.ts +++ b/client/src/includes/panels.ts @@ -1,3 +1,5 @@ +import { getElementByContentPath } from '../utils/contentPath'; + /** * Switches a collapsible panel from expanded to collapsed, or vice versa. * Updates the DOM and fires custom events for other code to hook into. @@ -110,7 +112,10 @@ export function initCollapsiblePanels( export function initAnchoredPanels( anchorTarget = document.getElementById(window.location.hash.slice(1)), ) { - const target = anchorTarget?.matches('[data-panel]') ? anchorTarget : null; + const target = anchorTarget?.matches('[data-panel]') + ? anchorTarget + : getElementByContentPath(); + if (target) { setTimeout(() => { target.scrollIntoView({ behavior: 'smooth' }); diff --git a/client/src/includes/tabs.js b/client/src/includes/tabs.js index 2631c75c9d..a47bfe3736 100644 --- a/client/src/includes/tabs.js +++ b/client/src/includes/tabs.js @@ -1,3 +1,4 @@ +import { getElementByContentPath } from '../utils/contentPath'; /** * All tabs and tab content must be nested in an element with the data-tab attribute * All tab buttons need the role="tab" attr and an href with the tab content ID @@ -271,9 +272,10 @@ class Tabs { selectTabByURLHash() { if (window.location.hash) { const anchorId = window.location.hash.slice(1); + const anchoredElement = + document.getElementById(anchorId) || getElementByContentPath(); // Support linking straight to a tab, or to an element within a tab. - const tabID = document - .getElementById(anchorId) + const tabID = anchoredElement ?.closest('[role="tabpanel"]') ?.getAttribute('aria-labelledby'); const tab = document.getElementById(tabID); diff --git a/client/src/includes/tabs.test.js b/client/src/includes/tabs.test.js index 1644af84a1..454d6c7d8d 100644 --- a/client/src/includes/tabs.test.js +++ b/client/src/includes/tabs.test.js @@ -118,6 +118,23 @@ describe('tabs', () => { expect(promoteTabLabel.getAttribute('aria-selected')).toEqual('true'); }); + it('should select the correct tab where the element pointed by the contentpath directive lives', async () => { + window.location.hash = '#:w:contentpath=search_description'; + initTabs(); + await Promise.resolve(); + + const contentTab = document.getElementById('tab-content'); + const promoteTab = document.getElementById('tab-promote'); + const contentTabLabel = document.getElementById('tab-label-content'); + const promoteTabLabel = document.getElementById('tab-label-promote'); + + expect(contentTab.hasAttribute('hidden')).toBe(true); + expect(promoteTab.hasAttribute('hidden')).toBe(false); + + expect(contentTabLabel.getAttribute('aria-selected')).toEqual('false'); + expect(promoteTabLabel.getAttribute('aria-selected')).toEqual('true'); + }); + it('should not throw an error if the URL hash begins with a number', async () => { window.location.hash = '#123abcd'; initTabs(); diff --git a/client/src/utils/contentPath.test.js b/client/src/utils/contentPath.test.js new file mode 100644 index 0000000000..3894f362e7 --- /dev/null +++ b/client/src/utils/contentPath.test.js @@ -0,0 +1,91 @@ +import { + getWagtailDirectives, + getContentPathSelector, + getElementByContentPath, +} from './contentPath'; + +describe('getWagtailDirectives', () => { + afterEach(() => { + window.location.hash = ''; + }); + + it('should return the directive after the delimiter as-is', () => { + window.location.hash = '#:w:contentpath=abc1.d2e.3f'; + expect(getWagtailDirectives()).toEqual('contentpath=abc1.d2e.3f'); + }); + + it('should allow a normal anchor in front of the delimiter', () => { + window.location.hash = '#an-anchor:w:contentpath=abc1.d2e.3f'; + expect(getWagtailDirectives()).toEqual('contentpath=abc1.d2e.3f'); + }); + + it('should allow multiple values for the same directive', () => { + window.location.hash = + '#hello:w:contentpath=abc1.d2e.3f&unknown=123&unknown=456'; + expect(getWagtailDirectives()).toEqual( + 'contentpath=abc1.d2e.3f&unknown=123&unknown=456', + ); + }); +}); + +describe('getContentPathSelector', () => { + it('should return a selector string for a single content path', () => { + expect(getContentPathSelector('abc1')).toEqual('[data-contentpath="abc1"]'); + }); + it('should allow dotted content path', () => { + expect(getContentPathSelector('abc1.d2e.3f')).toEqual( + '[data-contentpath="abc1"] [data-contentpath="d2e"] [data-contentpath="3f"]', + ); + }); + + it('should ignore leading, trailing, and extra dots', () => { + expect(getContentPathSelector('.abc1...d2e..3f.')).toEqual( + '[data-contentpath="abc1"] [data-contentpath="d2e"] [data-contentpath="3f"]', + ); + }); + + it('should return an empty string if content path is an empty string', () => { + expect(getContentPathSelector('')).toEqual(''); + }); +}); + +describe('getElementByContentPath', () => { + beforeEach(() => { + document.body.innerHTML = /* html */ ` + <div id="one" data-contentpath="abc1"> + <div id="two" data-contentpath="d2e"> + <div id="three" data-contentpath="3f"></div> + </div> + <div id="four" data-contentpath="g4h"></div> + </div> + `; + }); + + afterEach(() => { + window.location.hash = ''; + }); + + it('should return the element for a single content path', () => { + const element = getElementByContentPath('abc1'); + expect(element).toBeTruthy(); + expect(element.id).toEqual('one'); + }); + + it('should return the element for a dotted content path', () => { + const element = getElementByContentPath('abc1.d2e.3f'); + expect(element).toBeTruthy(); + expect(element.id).toEqual('three'); + }); + + it('should read from the contentpath directive if there is one', () => { + window.location.hash = '#:w:contentpath=abc1.d2e.3f'; + const element = getElementByContentPath(); + expect(element).toBeTruthy(); + expect(element.id).toEqual('three'); + }); + + it('should return null if it cannot find the element', () => { + expect(getElementByContentPath('abc1.d2e.3f.g4h')).toBeNull(); + expect(getElementByContentPath()).toBeNull(); + }); +}); diff --git a/client/src/utils/contentPath.ts b/client/src/utils/contentPath.ts new file mode 100644 index 0000000000..b32c0cbae6 --- /dev/null +++ b/client/src/utils/contentPath.ts @@ -0,0 +1,78 @@ +const WAGTAIL_DIRECTIVE_DELIMITER = ':w:'; + +/** + * Extract the Wagtail directives from the URL fragment. + * + * This follows the algorithm described in + * https://wicg.github.io/scroll-to-text-fragment/#extracting-the-fragment-directive + * for extracting the fragment directive from the URL fragment, with a few + * differences: + * - We use a :w: delimiter instead of the proposed :~: delimiter. + * - We don't remove our directive from the URL fragment. + * + * @param rawFragment The raw fragment (hash) from the URL, + * @returns a string of Wagtail directives, if any, in the style of URL search parameters. + * + * @example window.location.hash = '#:w:contentpath=abc1.d2e.3f' + * // getWagtailDirectives() === 'contentpath=abc1.d2e.3f' + * + * @example window.location.hash = '#an-anchor:w:contentpath=abc1.d2e.3f' + * // getWagtailDirectives() === 'contentpath=abc1.d2e.3f' + * + * @example window.location.hash = '#hello:w:contentpath=abc1.d2e.3f&unknown=123&unknown=456' + * // getWagtailDirectives() === 'contentpath=abc1.d2e.3f&unknown=123&unknown=456' + */ +export function getWagtailDirectives() { + const rawFragment = window.location.hash; + const position = rawFragment.indexOf(WAGTAIL_DIRECTIVE_DELIMITER); + if (position === -1) return ''; + return rawFragment.slice(position + WAGTAIL_DIRECTIVE_DELIMITER.length); +} + +/** + * Compose a selector string to find the content element based on the dotted + * content path. + * + * @param contentPath dotted path to the content element. + * @returns a selector string to find the content element. + * + * @example getContentPathSelector('abc1.d2e.3f') + * // returns '[data-contentpath="abc1"] [data-contentpath="d2e"] [data-contentpath="3f"]' + */ +export function getContentPathSelector(contentPath: string) { + const pathSegments = contentPath.split('.'); + const selector = pathSegments.reduce((acc, segment) => { + // In some cases the segment can be empty, e.g. when the path ends with + // a trailing dot, which may be the case with inline panels. + if (!segment) return acc; + + const segmentSelector = `[data-contentpath="${segment}"]`; + return acc ? `${acc} ${segmentSelector}` : segmentSelector; + }, ''); + return selector; +} + +/** + * Get the content element based on a given content path (or one extracted from + * the URL hash fragment). + * + * @param contentPath (optional) content path to the content element. If not + * provided, it will be extracted from the URL fragment. + * @returns the content element, if found, otherwise `null`. + * + * @example getElementByContentPath('abc1.d2e.3f') + * // returns <div data-contentpath="3f">...</div> + * + * @example getElementByContentPath() + * // with an URL e.g. https://example.com/#:w:contentpath=abc1.d2e.3f + * // returns <div data-contentpath="3f">...</div> + */ +export function getElementByContentPath(contentPath?: string) { + const path = + contentPath || + new URLSearchParams(getWagtailDirectives()).get('contentpath'); + + return path + ? document.querySelector<HTMLElement>(getContentPathSelector(path)) + : null; +} diff --git a/wagtail/admin/templates/wagtailadmin/tables/references_cell.html b/wagtail/admin/templates/wagtailadmin/tables/references_cell.html index 45d4fd7e7f..a53427ba07 100644 --- a/wagtail/admin/templates/wagtailadmin/tables/references_cell.html +++ b/wagtail/admin/templates/wagtailadmin/tables/references_cell.html @@ -3,7 +3,7 @@ {% for reference in value %} <li> {% if edit_url %} - <a href="{{ edit_url }}#content-path-{{ reference.content_path }}"> + <a href="{{ edit_url }}#:w:contentpath={{ reference.content_path }}"> {% endif %} {{ reference.describe_source_field }}{% if edit_url %}</a>{% endif %}{% if describe_on_delete %}: {{ reference.describe_on_delete }}{% endif %} </li> diff --git a/wagtail/admin/tests/pages/test_page_usage.py b/wagtail/admin/tests/pages/test_page_usage.py index ae7ffcaf84..a0241417a6 100644 --- a/wagtail/admin/tests/pages/test_page_usage.py +++ b/wagtail/admin/tests/pages/test_page_usage.py @@ -103,7 +103,9 @@ class TestPageUsage(AdminTemplateTestUtils, WagtailTestUtils, TestCase): self.assertContains(response, "Contact us") self.assertContains( - response, reverse("wagtailadmin_pages:edit", args=(form_page.id,)) + response, + reverse("wagtailadmin_pages:edit", args=(form_page.id,)) + + "#:w:contentpath=thank_you_redirect_page", ) self.assertContains(response, "Thank you redirect page") self.assertContains(response, "<td>Form page with redirect</td>", html=True) diff --git a/wagtail/admin/tests/viewsets/test_model_viewset.py b/wagtail/admin/tests/viewsets/test_model_viewset.py index e7e7651a5f..529dfc3e88 100644 --- a/wagtail/admin/tests/viewsets/test_model_viewset.py +++ b/wagtail/admin/tests/viewsets/test_model_viewset.py @@ -1241,6 +1241,11 @@ class TestUsageView(WagtailTestUtils, TestCase): link = tds[0].select_one("a") self.assertIsNotNone(link) self.assertEqual(link.attrs.get("href"), tbx_edit_url) + content_path_link = tds[-1].select_one("a") + self.assertEqual( + content_path_link.attrs.get("href"), + tbx_edit_url + "#:w:contentpath=cascading_toy", + ) # Link to referrer's edit view with parameters for the specific field link = tds[2].select_one("a") diff --git a/wagtail/snippets/tests/test_usage.py b/wagtail/snippets/tests/test_usage.py index 4b390115f7..cc344d0b94 100644 --- a/wagtail/snippets/tests/test_usage.py +++ b/wagtail/snippets/tests/test_usage.py @@ -97,6 +97,11 @@ class TestSnippetUsageView(WagtailTestUtils, TestCase): self.assertContains(response, "<th>Field</th>", html=True) self.assertNotContains(response, "<th>If you confirm deletion</th>", html=True) self.assertContains(response, "Snippet content object") + self.assertContains( + response, + reverse("wagtailadmin_pages:edit", args=[gfk_page.id]) + + "#:w:contentpath=snippet_content_object", + ) def test_usage_without_edit_permission_on_snippet(self): # Create a user with basic admin backend access