diff --git a/client/src/controllers/SessionController.test.js b/client/src/controllers/SessionController.test.js
index 3df13a064b..a891cfb7a1 100644
--- a/client/src/controllers/SessionController.test.js
+++ b/client/src/controllers/SessionController.test.js
@@ -1,6 +1,8 @@
import { Application } from '@hotwired/stimulus';
import { SessionController } from './SessionController';
import { DialogController } from './DialogController';
+import { SwapController } from './SwapController';
+import { ActionController } from './ActionController';
jest.useFakeTimers();
@@ -10,6 +12,8 @@ describe('SessionController', () => {
beforeAll(() => {
application = Application.start();
application.register('w-session', SessionController);
+ application.register('w-swap', SwapController);
+ application.register('w-action', ActionController);
});
afterEach(() => {
@@ -389,4 +393,189 @@ describe('SessionController', () => {
expect(new FormData(form).get('is_editing')).toBeNull();
});
});
+
+ describe('updating the session state based on JSON data from an event', () => {
+ describe('with complete controller configuration', () => {
+ afterEach(() => {
+ fetch.mockRestore();
+ });
+
+ let element;
+
+ const setup = async () => {
+ document.body.innerHTML = /* html */ `
+
+ `;
+ element = document.querySelector('form');
+ await Promise.resolve();
+ };
+
+ it('should update the SwapController and ActionController URL values', async () => {
+ fetch.mockResponseSuccessJSON(
+ JSON.stringify({
+ html: '',
+ ping_url: 'http://localhost/sessions/2/',
+ release_url: 'http://localhost/sessions/2/release/',
+ }),
+ );
+ await setup();
+
+ expect(global.fetch).toHaveBeenCalledWith(
+ 'http://localhost/sessions/1/',
+ expect.any(Object),
+ );
+
+ // Simulate request finishing
+ await Promise.resolve();
+
+ // Simulate JSON parsing
+ await Promise.resolve();
+
+ expect(element.dataset.wSwapSrcValue).toEqual(
+ 'http://localhost/sessions/2/',
+ );
+ expect(element.dataset.wActionUrlValue).toEqual(
+ 'http://localhost/sessions/2/release/',
+ );
+
+ await Promise.resolve();
+ expect(document.getElementById('w-editing-sessions').innerHTML).toEqual(
+ '',
+ );
+
+ // Simulate ping after 10s
+ fetch.mockResponseSuccessJSON(
+ JSON.stringify({
+ html: '',
+ ping_url: 'http://localhost/sessions/999/',
+ release_url: 'http://localhost/sessions/release/999/',
+ }),
+ );
+ jest.advanceTimersByTime(10000);
+
+ expect(global.fetch).toHaveBeenCalledWith(
+ 'http://localhost/sessions/2/',
+ expect.any(Object),
+ );
+
+ // Simulate request finishing
+ await Promise.resolve();
+
+ // Simulate JSON parsing
+ await Promise.resolve();
+
+ expect(element.dataset.wSwapSrcValue).toEqual(
+ 'http://localhost/sessions/999/',
+ );
+ expect(element.dataset.wActionUrlValue).toEqual(
+ 'http://localhost/sessions/release/999/',
+ );
+
+ await Promise.resolve();
+ await Promise.resolve();
+ expect(document.getElementById('w-editing-sessions').innerHTML).toEqual(
+ '',
+ );
+ });
+
+ it('should handle unexpected data gracefully', async () => {
+ fetch.mockResponseSuccessJSON(
+ JSON.stringify({
+ html: '',
+ }),
+ );
+ await setup();
+
+ expect(global.fetch).toHaveBeenCalledWith(
+ 'http://localhost/sessions/1/',
+ expect.any(Object),
+ );
+
+ // Simulate request finishing
+ await Promise.resolve();
+
+ // Simulate JSON parsing
+ await Promise.resolve();
+
+ // Should not update the URL values
+ expect(element.dataset.wSwapSrcValue).toEqual(
+ 'http://localhost/sessions/1/',
+ );
+ expect(element.dataset.wActionUrlValue).toEqual(
+ 'http://localhost/sessions/1/release/',
+ );
+
+ // Should still update the HTML
+ await Promise.resolve();
+ expect(document.getElementById('w-editing-sessions').innerHTML).toEqual(
+ '',
+ );
+ });
+ });
+
+ describe('with improper configuration', () => {
+ let element;
+ beforeEach(async () => {
+ document.body.innerHTML = /* html */ `
+
+
+ `;
+ element = document.querySelector('[data-controller="w-session"]');
+ await Promise.resolve();
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should handle the data gracefully even if SwapController and ActionController are not present', async () => {
+ const mock = jest.spyOn(
+ SessionController.prototype,
+ 'updateSessionData',
+ );
+ element.dispatchEvent(
+ new CustomEvent('w-swap:json', {
+ detail: {
+ data: {
+ html: '',
+ ping_url: 'http://localhost/sessions/999/',
+ release_url: 'http://localhost/sessions/release/999/',
+ },
+ },
+ }),
+ );
+
+ expect(mock).toHaveBeenCalledTimes(1);
+ expect(element.dataset.wSwapSrcValue).toBeUndefined();
+ expect(element.dataset.wActionUrlValue).toBeUndefined();
+ });
+
+ it('should handle the event gracefully even if it does not contain data', async () => {
+ const mock = jest.spyOn(
+ SessionController.prototype,
+ 'updateSessionData',
+ );
+ element.dispatchEvent(
+ new CustomEvent('w-swap:json', { detail: { foo: 'bar' } }),
+ );
+
+ expect(mock).toHaveBeenCalledTimes(1);
+ expect(element.dataset.wSwapSrcValue).toBeUndefined();
+ expect(element.dataset.wActionUrlValue).toBeUndefined();
+ });
+ });
+ });
});
diff --git a/client/src/controllers/SessionController.ts b/client/src/controllers/SessionController.ts
index 89109861ae..4a07cad7fb 100644
--- a/client/src/controllers/SessionController.ts
+++ b/client/src/controllers/SessionController.ts
@@ -1,5 +1,21 @@
import { Controller } from '@hotwired/stimulus';
import { DialogController } from './DialogController';
+import { SwapController } from './SwapController';
+import { ActionController } from './ActionController';
+
+interface PingResponse {
+ session_id: string;
+ ping_url: string;
+ release_url: string;
+ other_sessions: {
+ session_id: string | null;
+ user: string;
+ last_seen_at: string;
+ is_editing: boolean;
+ revision_id: number | null;
+ }[];
+ html: string;
+}
/**
* Manage an editing session by indicating the presence of the user and handling
@@ -203,6 +219,43 @@ export class SessionController extends Controller {
this.unsavedChangesTarget.checked = type !== 'clear';
}
+ get swapController() {
+ return this.application.getControllerForElementAndIdentifier(
+ this.element,
+ 'w-swap',
+ ) as SwapController | null;
+ }
+
+ get actionController() {
+ return this.application.getControllerForElementAndIdentifier(
+ this.element,
+ 'w-action',
+ ) as ActionController | null;
+ }
+
+ /**
+ * Update the session state with the latest data from the server.
+ * @param event an event that contains JSON data in the `detail` property. Normally a `w-swap:json` event.
+ */
+ updateSessionData(event: CustomEvent) {
+ const { detail } = event;
+ if (!detail || !detail.data) return;
+ const data: PingResponse = detail.data;
+
+ // Update the ping and release URLs in case the session ID has changed
+ // e.g. when the user has been inactive for too long and resumed their session.
+ // Modify the values via the controllers directly instead of setting the data
+ // attributes so we get type checking.
+ const swapController = this.swapController;
+ if (swapController && data.ping_url) {
+ swapController.srcValue = data.ping_url;
+ }
+ const actionController = this.actionController;
+ if (actionController && data.release_url) {
+ actionController.urlValue = data.release_url;
+ }
+ }
+
disconnect(): void {
if (this.interval) {
window.clearInterval(this.interval);
diff --git a/wagtail/admin/templates/wagtailadmin/shared/editing_sessions/module.html b/wagtail/admin/templates/wagtailadmin/shared/editing_sessions/module.html
index c3caef7f9b..806fe90a8c 100644
--- a/wagtail/admin/templates/wagtailadmin/shared/editing_sessions/module.html
+++ b/wagtail/admin/templates/wagtailadmin/shared/editing_sessions/module.html
@@ -31,7 +31,7 @@
data-w-action-continue-value="true"
data-w-action-url-value="{{ release_url }}"
data-w-session-w-dialog-outlet="[data-edit-form] [data-controller='w-dialog']#w-overwrite-changes-dialog"
- data-action="w-session:ping->w-swap#submit visibilitychange@document->w-session#dispatchVisibilityState w-session:visible->w-session#ping w-session:visible->w-session#addInterval w-session:hidden->w-session#clearInterval w-session:hidden->w-action#sendBeacon w-unsaved:add@document->w-session#setUnsavedChanges w-unsaved:clear@document->w-session#setUnsavedChanges"
+ data-action="w-session:ping->w-swap#submit visibilitychange@document->w-session#dispatchVisibilityState w-session:visible->w-session#ping w-session:visible->w-session#addInterval w-session:hidden->w-session#clearInterval w-session:hidden->w-action#sendBeacon w-unsaved:add@document->w-session#setUnsavedChanges w-unsaved:clear@document->w-session#setUnsavedChanges w-swap:json->w-session#updateSessionData"
>
{% if revision_id %}
diff --git a/wagtail/admin/views/editing_sessions.py b/wagtail/admin/views/editing_sessions.py
index 93c04802f3..9b13194964 100644
--- a/wagtail/admin/views/editing_sessions.py
+++ b/wagtail/admin/views/editing_sessions.py
@@ -4,6 +4,7 @@ from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.http import Http404, JsonResponse
from django.shortcuts import get_object_or_404
+from django.urls import reverse
from django.utils import timezone
from django.views.decorators.http import require_POST
@@ -162,6 +163,13 @@ def ping(request, app_label, model_name, object_id, session_id):
return JsonResponse(
{
"session_id": session.id,
+ "ping_url": reverse(
+ "wagtailadmin_editing_sessions:ping",
+ args=(app_label, model_name, object_id, session.id),
+ ),
+ "release_url": reverse(
+ "wagtailadmin_editing_sessions:release", args=(session.id,)
+ ),
"other_sessions": [
{
"session_id": other_session["session_id"],