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"],