Update ping and release URLs on every editing session ping

In case the original session has been cleaned up
pull/12185/head
Sage Abdullah 2024-07-03 14:05:15 +01:00 zatwierdzone przez Thibaud Colas
rodzic 1139f2a36e
commit edb3a1ab80
4 zmienionych plików z 251 dodań i 1 usunięć

Wyświetl plik

@ -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 */ `
<form
method="post"
data-controller="w-swap w-action w-session"
data-w-swap-target-value="#w-editing-sessions"
data-w-swap-json-path-value="html"
data-w-swap-src-value="http://localhost/sessions/1/"
data-w-action-url-value="http://localhost/sessions/1/release/"
data-action="w-session:ping->w-swap#submit w-swap:json->w-session#updateSessionData"
>
<div id="w-editing-sessions"></div>
</form>
`;
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: '<ul><li>Session 7</li></ul>',
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(
'<ul><li>Session 7</li></ul>',
);
});
it('should handle unexpected data gracefully', async () => {
fetch.mockResponseSuccessJSON(
JSON.stringify({
html: '<ul><li>Session 1</li></ul>',
}),
);
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(
'<ul><li>Session 1</li></ul>',
);
});
});
describe('with improper configuration', () => {
let element;
beforeEach(async () => {
document.body.innerHTML = /* html */ `
<div
data-controller="w-session"
data-action="w-swap:json->w-session#updateSessionData"
>
</div>
`;
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: '<ul><li>Session 2</li></ul>',
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();
});
});
});
});

Wyświetl plik

@ -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<HTMLElement> {
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);

Wyświetl plik

@ -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"
>
<input type="checkbox" name="is_editing" value="1" data-w-session-target="unsavedChanges" hidden />
{% if revision_id %}

Wyświetl plik

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