diff --git a/client/src/includes/panels.ts b/client/src/includes/panels.ts
index b75286bb55cb..ea49b30137df 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 2631c75c9dfe..a47bfe37364b 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 1644af84a146..454d6c7d8dea 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 000000000000..3894f362e740
--- /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 */ `
+
+ `;
+ });
+
+ 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 000000000000..b32c0cbae6c9
--- /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 ...
+ *
+ * @example getElementByContentPath()
+ * // with an URL e.g. https://example.com/#:w:contentpath=abc1.d2e.3f
+ * // returns ...
+ */
+export function getElementByContentPath(contentPath?: string) {
+ const path =
+ contentPath ||
+ new URLSearchParams(getWagtailDirectives()).get('contentpath');
+
+ return path
+ ? document.querySelector(getContentPathSelector(path))
+ : null;
+}
diff --git a/wagtail/admin/templates/wagtailadmin/tables/references_cell.html b/wagtail/admin/templates/wagtailadmin/tables/references_cell.html
index 45d4fd7e7f37..a53427ba07ba 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 %}
{% if edit_url %}
-
+
{% endif %}
{{ reference.describe_source_field }}{% if edit_url %}{% endif %}{% if describe_on_delete %}: {{ reference.describe_on_delete }}{% endif %}
diff --git a/wagtail/admin/tests/pages/test_page_usage.py b/wagtail/admin/tests/pages/test_page_usage.py
index ae7ffcaf8423..a0241417a6c4 100644
--- a/wagtail/admin/tests/pages/test_page_usage.py
+++ b/wagtail/admin/tests/pages/test_page_usage.py
@@ -103,7 +103,9 @@ def test_has_editable_usage(self):
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, "Form page with redirect | ", html=True)
diff --git a/wagtail/admin/tests/viewsets/test_model_viewset.py b/wagtail/admin/tests/viewsets/test_model_viewset.py
index e7e7651a5fd1..529dfc3e8857 100644
--- a/wagtail/admin/tests/viewsets/test_model_viewset.py
+++ b/wagtail/admin/tests/viewsets/test_model_viewset.py
@@ -1241,6 +1241,11 @@ def test_simple(self):
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 4b390115f79e..cc344d0b94cf 100644
--- a/wagtail/snippets/tests/test_usage.py
+++ b/wagtail/snippets/tests/test_usage.py
@@ -97,6 +97,11 @@ def test_usage(self):
self.assertContains(response, "Field | ", html=True)
self.assertNotContains(response, "If you confirm deletion | ", 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