Initialize EditingSessionsModule component and SessionController

Use a separate component instead of including the markup directly in
slim_header.html, so that we can pass any necessary variables via Python
when instantiating the component, instead of polluting the
slim_header.html with a bunch of variables.
pull/12185/head
Sage Abdullah 2024-07-01 14:27:40 +01:00 zatwierdzone przez Thibaud Colas
rodzic 80480d8499
commit 886af6de98
11 zmienionych plików z 754 dodań i 17 usunięć

Wyświetl plik

@ -0,0 +1,327 @@
import { Application } from '@hotwired/stimulus';
import { SessionController } from './SessionController';
import { DialogController } from './DialogController';
jest.useFakeTimers();
describe('SessionController', () => {
let application;
beforeAll(() => {
application = Application.start();
application.register('w-session', SessionController);
});
afterEach(() => {
jest.clearAllTimers();
});
describe('dispatching ping event at the set interval', () => {
let handlePing;
beforeAll(() => {
handlePing = jest.fn();
document.addEventListener('w-session:ping', handlePing);
});
afterAll(() => {
document.removeEventListener('w-session:ping', handlePing);
});
afterEach(() => {
handlePing.mockClear();
});
it('should dispatch a ping event every 10s by default and can be changed afterwards', async () => {
document.body.innerHTML = /* html */ `
<div data-controller="w-session">
Default
</div>
`;
await Promise.resolve();
expect(handlePing).not.toHaveBeenCalled();
jest.advanceTimersByTime(10000);
expect(handlePing).toHaveBeenCalledTimes(1);
jest.advanceTimersByTime(10000);
expect(handlePing).toHaveBeenCalledTimes(2);
handlePing.mockClear();
jest.advanceTimersByTime(123456);
expect(handlePing).toHaveBeenCalledTimes(12);
handlePing.mockClear();
const element = document.querySelector('[data-controller="w-session"]');
element.setAttribute('data-w-session-interval-value', '20000');
await Promise.resolve();
jest.advanceTimersByTime(20000);
expect(handlePing).toHaveBeenCalledTimes(1);
jest.advanceTimersByTime(20000);
expect(handlePing).toHaveBeenCalledTimes(2);
handlePing.mockClear();
jest.advanceTimersByTime(123456);
expect(handlePing).toHaveBeenCalledTimes(6);
});
it('should allow setting a custom interval value on init and changing it afterwards', async () => {
document.body.innerHTML = /* html */ `
<div data-controller="w-session" data-w-session-interval-value="5000">
Custom interval
</div>
`;
await Promise.resolve();
expect(handlePing).not.toHaveBeenCalled();
jest.advanceTimersByTime(5000);
expect(handlePing).toHaveBeenCalledTimes(1);
jest.advanceTimersByTime(5000);
expect(handlePing).toHaveBeenCalledTimes(2);
handlePing.mockClear();
jest.advanceTimersByTime(123456);
expect(handlePing).toHaveBeenCalledTimes(24);
handlePing.mockClear();
const element = document.querySelector('[data-controller="w-session"]');
element.setAttribute('data-w-session-interval-value', '15000');
await Promise.resolve();
jest.advanceTimersByTime(15000);
expect(handlePing).toHaveBeenCalledTimes(1);
jest.advanceTimersByTime(15000);
expect(handlePing).toHaveBeenCalledTimes(2);
handlePing.mockClear();
jest.advanceTimersByTime(123456);
expect(handlePing).toHaveBeenCalledTimes(8);
});
});
describe('dispatching the visibility state of the document', () => {
let handleHidden;
let handleVisible;
let visibility = document.visibilityState;
beforeAll(() => {
handleHidden = jest.fn();
handleVisible = jest.fn();
document.addEventListener('w-session:hidden', handleHidden);
document.addEventListener('w-session:visible', handleVisible);
Object.defineProperty(document, 'visibilityState', {
configurable: true,
get: () => visibility,
set: (value) => {
visibility = value;
document.dispatchEvent(new Event('visibilitychange'));
},
});
});
afterAll(() => {
document.removeEventListener('w-session:hidden', handleHidden);
document.removeEventListener('w-session:visible', handleVisible);
});
afterEach(() => {
handleHidden.mockClear();
handleVisible.mockClear();
});
it('should dispatch the event', async () => {
document.body.innerHTML = /* html */ `
<div
data-controller="w-session"
data-action="visibilitychange@document->w-session#dispatchVisibilityState"
>
Visibility
</div>
`;
await Promise.resolve();
expect(handleVisible).not.toHaveBeenCalled();
expect(handleHidden).not.toHaveBeenCalled();
document.visibilityState = 'hidden';
expect(handleHidden).toHaveBeenCalledTimes(1);
expect(handleVisible).not.toHaveBeenCalled();
handleHidden.mockClear();
document.visibilityState = 'visible';
expect(handleVisible).toHaveBeenCalledTimes(1);
expect(handleHidden).not.toHaveBeenCalled();
});
});
describe('preventing events triggered by submit buttons in the edit form', () => {
let handleSubmit;
let handleWorkflowAction;
let handleDialogShow;
let handleDialogHidden;
let handleDialogConfirmed;
let form;
let workflowActionButton;
beforeAll(() => {
application.register('w-dialog', DialogController);
handleSubmit = jest.fn().mockImplementation((e) => e.preventDefault());
handleWorkflowAction = jest.fn();
handleDialogShow = jest.fn();
handleDialogHidden = jest.fn();
handleDialogConfirmed = jest.fn();
});
beforeEach(async () => {
document.body.innerHTML = /* html */ `
<form data-edit-form>
<input type="text" name="title" value="Title" />
<button type="submit">Submit</button>
<button type="button" data-workflow-action-name="approve">Approve</button>
<div
id="w-overwrite-changes-dialog"
aria-hidden="true"
data-controller="w-dialog"
data-action="w-dialog:hide->w-dialog#hide w-dialog:show->w-dialog#show"
>
<div role="document">
<div id="dialog-body" data-w-dialog-target="body">
This will overwrite the changes made by Leon S. Kennedy.
<button id="confirm" type="button" data-action="click->w-dialog#confirm">Continue</button>
<button id="cancel" type="button" data-action="click->w-dialog#hide">Cancel</button>
</div>
</div>
</div>
</form>
<div
data-controller="w-session"
data-w-session-w-dialog-outlet="[data-edit-form] [data-controller='w-dialog']#w-overwrite-changes-dialog"
>
</div>
`;
await Promise.resolve();
form = document.querySelector('[data-edit-form]');
workflowActionButton = form.querySelector('[data-workflow-action-name]');
form.addEventListener('submit', handleSubmit);
workflowActionButton.addEventListener('click', handleWorkflowAction, {
capture: true,
});
document.addEventListener('w-dialog:shown', handleDialogShow);
document.addEventListener('w-dialog:hidden', handleDialogHidden);
document.addEventListener('w-dialog:confirmed', handleDialogConfirmed);
});
afterEach(() => {
jest.clearAllMocks();
form.removeEventListener('submit', handleSubmit);
workflowActionButton.removeEventListener('click', handleWorkflowAction, {
capture: true,
});
document.removeEventListener('w-dialog:shown', handleDialogShow);
document.removeEventListener('w-dialog:hidden', handleDialogHidden);
document.removeEventListener('w-dialog:confirmed', handleDialogConfirmed);
});
it('should show the dialog and prevent the submit event', async () => {
const dialog = document.querySelector('#w-overwrite-changes-dialog');
const submitButton = form.querySelector('button[type="submit"]');
expect(dialog.getAttribute('aria-hidden')).toEqual('true');
expect(handleDialogShow).not.toHaveBeenCalled();
submitButton.click();
expect(handleSubmit).not.toHaveBeenCalled();
expect(handleWorkflowAction).not.toHaveBeenCalled();
expect(dialog.getAttribute('aria-hidden')).toBeNull();
expect(handleDialogShow).toHaveBeenCalled();
});
it('should continue the action after confirming the dialog', async () => {
const dialog = document.querySelector('#w-overwrite-changes-dialog');
const confirmButton = document.getElementById('confirm');
expect(handleDialogShow).not.toHaveBeenCalled();
workflowActionButton.click();
expect(handleSubmit).not.toHaveBeenCalled();
expect(handleWorkflowAction).not.toHaveBeenCalled();
expect(handleDialogShow).toHaveBeenCalled();
expect(dialog.getAttribute('aria-hidden')).toBeNull();
confirmButton.click();
expect(handleDialogHidden).toHaveBeenCalled();
expect(handleDialogConfirmed).toHaveBeenCalled();
expect(handleWorkflowAction).toHaveBeenCalled();
expect(handleSubmit).not.toHaveBeenCalled();
expect(dialog.getAttribute('aria-hidden')).toEqual('true');
});
it('should allow the action to be cancelled', async () => {
const dialog = document.querySelector('#w-overwrite-changes-dialog');
const cancelButton = document.getElementById('cancel');
expect(handleDialogShow).not.toHaveBeenCalled();
workflowActionButton.click();
expect(handleSubmit).not.toHaveBeenCalled();
expect(handleWorkflowAction).not.toHaveBeenCalled();
expect(handleDialogShow).toHaveBeenCalled();
expect(dialog.getAttribute('aria-hidden')).toBeNull();
cancelButton.click();
expect(handleDialogHidden).toHaveBeenCalled();
expect(handleDialogConfirmed).not.toHaveBeenCalled();
expect(handleWorkflowAction).not.toHaveBeenCalled();
expect(handleSubmit).not.toHaveBeenCalled();
expect(dialog.getAttribute('aria-hidden')).toEqual('true');
});
it('should show the dialog again if clicking the action again after the dialog is hidden', async () => {
const dialog = document.querySelector('#w-overwrite-changes-dialog');
const confirmButton = document.getElementById('confirm');
const cancelButton = document.getElementById('cancel');
expect(handleDialogShow).not.toHaveBeenCalled();
// Clicking a workflow action should show the dialog for the first time
workflowActionButton.click();
expect(handleSubmit).not.toHaveBeenCalled();
expect(handleWorkflowAction).not.toHaveBeenCalled();
expect(handleDialogShow).toHaveBeenCalledTimes(1);
expect(dialog.getAttribute('aria-hidden')).toBeNull();
confirmButton.click();
// Confirming the dialog would hide it and continue the action
expect(handleDialogHidden).toHaveBeenCalledTimes(1);
expect(handleDialogConfirmed).toHaveBeenCalledTimes(1);
expect(handleWorkflowAction).toHaveBeenCalledTimes(1);
expect(handleSubmit).not.toHaveBeenCalled();
expect(dialog.getAttribute('aria-hidden')).toEqual('true');
// If the action is clicked again, the dialog should show again
// (this may happen if the action wasn't completed, so the button is clickable again)
workflowActionButton.click();
expect(handleSubmit).not.toHaveBeenCalled();
expect(handleWorkflowAction).toHaveBeenCalledTimes(1);
expect(handleDialogShow).toHaveBeenCalledTimes(2);
expect(dialog.getAttribute('aria-hidden')).toBeNull();
cancelButton.click();
// Cancelling the dialog would hide it and not continue the action
expect(handleDialogHidden).toHaveBeenCalledTimes(2);
expect(handleDialogConfirmed).toHaveBeenCalledTimes(1);
expect(handleWorkflowAction).toHaveBeenCalledTimes(1);
expect(handleSubmit).not.toHaveBeenCalled();
expect(dialog.getAttribute('aria-hidden')).toEqual('true');
// If the action is clicked again, the dialog should show again
workflowActionButton.click();
expect(handleSubmit).not.toHaveBeenCalled();
expect(handleWorkflowAction).toHaveBeenCalledTimes(1);
expect(handleDialogShow).toHaveBeenCalledTimes(3);
expect(dialog.getAttribute('aria-hidden')).toBeNull();
});
});
});

Wyświetl plik

@ -0,0 +1,179 @@
import { Controller } from '@hotwired/stimulus';
import { DialogController } from './DialogController';
/**
* Manage an editing session by indicating the presence of the user and handling
* cases when there are multiple users editing the same content.
*
* This controller defines the following behaviors:
* - Dispatching a ping event periodically, which can be utilized by other
* controllers to keep the session alive or indicate presence.
* - Dispatching an event indicating the visibility state of the document.
* - Preventing events triggered by submit buttons and workflow action buttons
* in the edit form, and showing a confirmation dialog instead, before
* proceeding with the original action after the user confirms the dialog.
*
* Ideally this controller should be used in conjunction with `ActionController`
* and `DialogController` to compose the user experience.
*
* @example
* ```html
* <form
* id="w-editing-sessions"
* method="post"
* data-controller="w-action w-session"
* data-w-action-continue-value="true"
* data-w-action-url-value="/path/to/release/session/"
* data-w-session-w-dialog-outlet="[data-edit-form] [data-controller='w-dialog']#w-overwrite-changes-dialog"
* data-action="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"
* >
* </form>
* ```
*/
export class SessionController extends Controller<HTMLElement> {
static values = {
interval: { type: Number, default: 1000 * 10 }, // 10 seconds
intercept: { type: Boolean, default: true },
};
static outlets = ['w-dialog'];
declare readonly hasWDialogOutlet: boolean;
/** The confirmation dialog for overwriting changes made by another user */
declare readonly wDialogOutlet: DialogController;
/** The interval duration for the ping event */
declare intervalValue: number;
/** Whether to intercept the original event and show a confirmation dialog */
declare interceptValue: boolean;
/** The interval ID for the periodic pinging */
declare interval: number;
/** The last action button that triggered the event */
lastActionButton?: HTMLButtonElement;
initialize(): void {
// Bind these methods in initialize() instead of connect, as they will be
// used as event listeners for other controllers e.g. DialogController, and
// they may be connected before this controller is connected.
this.showConfirmationDialog = this.showConfirmationDialog.bind(this);
this.confirmAction = this.confirmAction.bind(this);
this.ping = this.ping.bind(this);
}
connect(): void {
this.interceptTargets.forEach((button) => {
// Match the event listener configuration of workflow-action that uses
// capture so we can intercept the workflow-action's listener.
button.addEventListener('click', this.showConfirmationDialog, {
capture: true,
});
});
}
/**
* Buttons that will be intercepted to show the confirmation dialog.
*
* Defaults to submit buttons and workflow action buttons in the edit form.
*/
get interceptTargets(): NodeListOf<HTMLButtonElement> {
return document?.querySelectorAll<HTMLButtonElement>(
'[data-edit-form] button:is([type="submit"], [data-workflow-action-name])',
);
}
/**
* Dispatch a ping event, to be used by other controllers to keep the session
* alive or indicate presence.
*/
ping(): void {
this.dispatch('ping');
}
intervalValueChanged(): void {
this.addInterval();
}
/**
* Set the interval for the periodic pinging, clearing the previous interval
* if it exists.
*/
addInterval(): void {
this.clearInterval();
this.interval = window.setInterval(this.ping, this.intervalValue);
}
/**
* Clear the interval for the periodic pinging if one is set.
*/
clearInterval(): void {
if (this.interval) {
window.clearInterval(this.interval);
}
}
/**
* Dispatch the visibility state of the document. When used as an event
* listener for the `visibilitychange` event, it will dispatch two separate
* events: `identifier:visible` and `identifier:hidden`, which makes it easier
* to attach event listeners to specific visibility states.
*/
dispatchVisibilityState(): void {
this.dispatch(document.visibilityState);
}
/**
* Intercept the original event and show a confirmation dialog instead.
*
* The interception can be controlled dynamically via the `interceptValue`
* so that we can temporarily disable it when the user confirms the overwrite
* and immediately reenable it without having to remove and re-add the event
* listener. This is useful for events that are triggered by a multi-step
* process, such as a workflow action, which may have its own dialogs and may
* be cancelled in the middle of the process.
*
* @param event The original event that triggered the action.
*/
showConfirmationDialog(event: Event): void {
// Store the last action button that triggered the event, so we can
// trigger it again after the user confirms the dialog.
this.lastActionButton = event.target as HTMLButtonElement;
// Allow us to proceed with the original behaviour (i.e. form submission or
// workflow action modal) after the user confirms the dialog.
if (!this.interceptValue || !this.hasWDialogOutlet) return;
// Prevent form submission
event.preventDefault();
// Prevent triggering other event listeners e.g. workflow actions modal
event.stopImmediatePropagation();
this.wDialogOutlet.show();
}
/**
* Proceed with the original action after the user confirms the dialog.
*/
confirmAction(): void {
this.interceptValue = false;
this.lastActionButton?.click();
this.interceptValue = true;
}
wDialogOutletConnected(): void {
this.wDialogOutlet.element.addEventListener(
'w-dialog:confirmed',
this.confirmAction,
);
}
disconnect(): void {
if (this.interval) {
window.clearInterval(this.interval);
}
this.interceptTargets?.forEach((button) => {
button.removeEventListener('click', this.showConfirmationDialog, {
capture: true,
});
});
}
}

Wyświetl plik

@ -18,6 +18,7 @@ import { LinkController } from './LinkController';
import { OrderableController } from './OrderableController';
import { ProgressController } from './ProgressController';
import { RevealController } from './RevealController';
import { SessionController } from './SessionController';
import { SkipLinkController } from './SkipLinkController';
import { SlugController } from './SlugController';
import { SubmitController } from './SubmitController';
@ -53,6 +54,7 @@ export const coreControllerDefinitions: Definition[] = [
{ controllerConstructor: ProgressController, identifier: 'w-progress' },
{ controllerConstructor: RevealController, identifier: 'w-breadcrumbs' },
{ controllerConstructor: RevealController, identifier: 'w-reveal' },
{ controllerConstructor: SessionController, identifier: 'w-session' },
{ controllerConstructor: SkipLinkController, identifier: 'w-skip-link' },
{ controllerConstructor: SlugController, identifier: 'w-slug' },
{ controllerConstructor: SubmitController, identifier: 'w-submit' },

Wyświetl plik

@ -0,0 +1,96 @@
{% load wagtailadmin_tags i18n %}
<div class="w-flex w-pr-8 w-mr-5 w-border-r w-border-border-furniture">
{% for session in sessions|slice:":4" %}
{% if session.revision_id %}
{% fragment as avatar %}
<div class="w-relative">
{% avatar user=session.user classname="w-mx-[1px] w-w-7 w-h-7 w-border-2 w-border-critical-200 w-ping w-ping--critical" %}
{% icon name="warning" classname="w-text-critical-200 w-w-4 w-h-4 w-absolute -w-bottom-2 w-left-2 w-z-10 w-stroke-surface-header w-shadow-white group-hover:w-hidden" %}
</div>
{% endfragment %}
{% blocktranslate trimmed with user_name=session.user|user_display_name|default:_("system") asvar saved_new_version_message %}
{{ user_name }} saved a new version
{% endblocktranslate %}
{% dropdown theme="popup" classname="w-group" toggle_label=avatar toggle_classname="w-p-0" %}
<div class="w-flex w-flex-col w-items-center w-justify-center w-gap-1 w-p-1">
<div class="w-label-2 w-flex w-items-center w-gap-1.5">
{% icon name="warning" classname="w-text-critical-200 w-w-5 w-h-5" %}
{{ saved_new_version_message|capfirst }}
</div>
{% fragment as refresh_button_content %}
{% icon name="rotate" %}
{% trans "Refresh" %}
{% endfragment %}
{% dialog_toggle classname="button button-small button-secondary chooser__choose-button" text=refresh_button_content dialog_id="w-unsaved-changes-dialog" %}
</div>
{% enddropdown %}
{% elif session.is_editing %}
{% fragment as avatar %}
{% avatar user=session.user classname="w-mx-[1px] w-w-7 w-h-7 w-border-2 w-border-warning-100" %}
{% endfragment %}
{% blocktranslate trimmed with user_name=session.user|user_display_name|default:_("system") asvar has_unsaved_changes_message %}
{{ user_name }} has unsaved changes
{% endblocktranslate %}
{% dropdown theme="popup" toggle_label=avatar toggle_classname="w-p-0" %}
<div class="w-label-2 w-flex w-items-center w-gap-1.5 w-p-1">
{% icon name="warning" classname="w-text-warning-100 w-w-5 w-h-5" %}
{{ has_unsaved_changes_message|capfirst }}
</div>
{% enddropdown %}
{% else %}
{% fragment as avatar %}
{% avatar user=session.user classname="w-mx-[1px] w-w-7 w-h-7 w-border-2 w-border-surface-field hover:w-border-positive-100" %}
{% endfragment %}
<div>
<button class="w-p-0 w-bg-transparent" type="button" data-controller="w-tooltip">
{{ avatar }}
<div class="w-flex w-flex-col w-items-center w-justify-center" data-w-tooltip-target="content" hidden>
<span class="w-font-semibold">{{ session.user|user_display_name|default:_("system")|capfirst }}</span>
<span>{% trans "Currently viewing" %}</span>
</div>
</button>
</div>
{% endif %}
{% endfor %}
{% if sessions|length > 4 %}
{% fragment as more_sessions_toggle %}
<div class="w-flex w-items-center w-justify-center w-bg-surface-page w-rounded-full w-w-7 w-h-7 w-border-2 w-border-border-furniture hover:w-border-positive-100 w-font-bold w-text-11">
+{{ sessions|length|add:"-4" }}
</div>
{% endfragment %}
{% dropdown theme="drilldown" toggle_label=more_sessions_toggle toggle_classname="w-p-0 w-mx-[1px]" %}
<div class="w-flex w-flex-col w-gap-4 w-p-2">
{% for session in sessions|slice:"4:" %}
<div class="w-flex w-items-center">
{% avatar user=session.user size="small" %}
<span class="w-text-14">{{ session.user|user_display_name|default:_("system")|capfirst }}</span>
</div>
{% endfor %}
</div>
{% enddropdown %}
{% endif %}
</div>
{% if sessions.0.revision_id %}
<template data-controller="w-teleport" data-w-teleport-target-value="#title-text-w-overwrite-changes-dialog" data-w-teleport-reset-value="true">
{% blocktranslate trimmed with user_name=sessions.0.user|user_display_name|default:_("system") model_name=_("page") asvar someone_has_saved_message %}
{{ user_name }} has saved a newer version of this {{ model_name }}
{% endblocktranslate %}
{{ someone_has_saved_message|capfirst }}
</template>
<template data-controller="w-teleport" data-w-teleport-target-value="#subtitle-w-overwrite-changes-dialog" data-w-teleport-reset-value="true">
{% blocktranslate trimmed with user_name=sessions.0.user|user_display_name|default:_("system") asvar overwrite_message %}
Proceeding will overwrite the changes made by {{ user_name }}. Refreshing the page will lose any of your unsaved changes.
{% endblocktranslate %}
{{ overwrite_message|capfirst }}
</template>
{% endif %}

Wyświetl plik

@ -0,0 +1,35 @@
{% load i18n wagtailadmin_tags %}
{% dialog id="w-unsaved-changes-dialog" icon_name="warning" icon_classname="w-text-critical-200" title=_("Refreshing the page means you will lose any unsaved changes") %}
<div class="w-mt-8">
<button class="button" type="button" data-controller="w-action" data-action="w-action#reload">{% trans "Refresh page" %}</button>
<button class="button button-secondary" type="button" data-action="w-dialog#hide">{% trans "Cancel" %}</button>
</div>
{% enddialog %}
{% blocktranslate trimmed with user_name=_("system") model_name=_("page") asvar someone_has_saved_message %}
{{ user_name }} has saved a newer version of this {{ model_name }}
{% endblocktranslate %}
{% blocktranslate trimmed with user_name=_("system") asvar overwrite_message %}
Proceeding will overwrite the changes made by {{ user_name }}. Refreshing the page will lose any of your unsaved changes.
{% endblocktranslate %}
{% dialog id="w-overwrite-changes-dialog" dialog_root_selector='[data-edit-form]' icon_name="warning" icon_classname="w-text-critical-200" title=someone_has_saved_message|capfirst subtitle=overwrite_message|capfirst %}
<div class="w-mt-8">
<button class="button" type="button" data-action="w-dialog#confirm">{% trans "Continue" %}</button>
<button class="button button-secondary" type="button" data-controller="w-action" data-action="w-action#reload">{% trans "Refresh the page" %}</button>
</div>
{% enddialog %}
<form
id="w-editing-sessions"
method="post"
data-controller="w-action w-session"
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="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"
>
{% include "wagtailadmin/shared/editing_sessions/list.html" with sessions=sessions only %}
</form>

Wyświetl plik

@ -114,6 +114,12 @@
{% endif %}
{% endblock %}
{% block editing_sessions %}
{% if editing_sessions %}
{% component editing_sessions %}
{% endif %}
{% endblock %}
<div class="w-w-full sm:w-w-min w-flex sm:w-flex-nowrap sm:w-flex-row w-items-center w-p-0 sm:w-py-0 sm:w-pr-4 sm:w-justify-end">
{% block toggles %}
{% if side_panels %}

Wyświetl plik

@ -127,12 +127,14 @@ class TestPageEdit(WagtailTestUtils, TestCase):
allow_extra_attrs=True,
)
# Should show the dialog template pointing to the [data-edit-form] selector as the root
self.assertTagInHTML(
'<template data-controller="w-teleport" data-w-teleport-target-value="[data-edit-form]">',
html,
count=1,
allow_extra_attrs=True,
soup = self.get_soup(html)
dialog = soup.select_one(
"""
template[data-controller="w-teleport"][data-w-teleport-target-value="[data-edit-form]"]
#schedule-publishing-dialog
"""
)
self.assertIsNotNone(dialog)
# Should render the main form with data-edit-form attribute
self.assertTagInHTML(
f'<form action="{edit_url}" method="POST" data-edit-form>',
@ -2067,7 +2069,7 @@ class TestPageEdit(WagtailTestUtils, TestCase):
# as when running it within the full test suite
self.client.get(reverse("wagtailadmin_pages:edit", args=(self.event_page.id,)))
with self.assertNumQueries(33):
with self.assertNumQueries(36):
self.client.get(
reverse("wagtailadmin_pages:edit", args=(self.event_page.id,))
)
@ -2080,7 +2082,7 @@ class TestPageEdit(WagtailTestUtils, TestCase):
# Warm up the cache as above.
self.client.get(reverse("wagtailadmin_pages:edit", args=(self.event_page.id,)))
with self.assertNumQueries(37):
with self.assertNumQueries(40):
self.client.get(
reverse("wagtailadmin_pages:edit", args=(self.event_page.id,))
)

Wyświetl plik

@ -0,0 +1,17 @@
from wagtail.admin.ui.components import Component
class EditingSessionsModule(Component):
template_name = "wagtailadmin/shared/editing_sessions/module.html"
def __init__(self, ping_url, release_url, sessions):
self.ping_url = ping_url
self.release_url = release_url
self.sessions = sessions
def get_context_data(self, parent_context):
return {
"ping_url": self.ping_url,
"release_url": self.release_url,
"sessions": self.sessions,
}

Wyświetl plik

@ -2,6 +2,7 @@ import json
from django.conf import settings
from django.contrib.admin.utils import quote
from django.contrib.contenttypes.models import ContentType
from django.db import models, transaction
from django.forms import Media
from django.shortcuts import get_object_or_404
@ -16,7 +17,9 @@ from django.utils.translation import gettext as _
from wagtail import hooks
from wagtail.admin import messages
from wagtail.admin.models import EditingSession
from wagtail.admin.templatetags.wagtailadmin_tags import user_display_name
from wagtail.admin.ui.editing_sessions import EditingSessionsModule
from wagtail.admin.ui.tables import TitleColumn
from wagtail.admin.utils import get_latest_str, set_query_params
from wagtail.locks import BasicLock, ScheduledForPublishLock, WorkflowLock
@ -682,6 +685,40 @@ class CreateEditViewOptionalFeaturesMixin:
return context
def get_editing_sessions(self):
if self.view_name == "create":
return None
EditingSession.cleanup()
content_type = ContentType.objects.get_for_model(self.model)
session = EditingSession.objects.create(
user=self.request.user,
content_type=content_type,
object_id=self.object.pk,
last_seen_at=timezone.now(),
)
return EditingSessionsModule(
reverse(
"wagtailadmin_editing_sessions:ping",
args=(
self.model._meta.app_label,
self.model._meta.model_name,
quote(self.object.pk),
session.id,
),
),
reverse(
"wagtailadmin_editing_sessions:release",
args=(session.id,),
),
list(
EditingSession.objects.filter(
content_type=content_type, object_id=self.object.pk
)
.exclude(id=session.id)
.select_related("user", "user__wagtail_userprofile")
),
)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context.update(self.get_lock_context())
@ -696,6 +733,7 @@ class CreateEditViewOptionalFeaturesMixin:
settings, "WAGTAIL_WORKFLOW_CANCEL_ON_PUBLISH", True
) and bool(self.workflow_tasks)
context["revisions_compare_url_name"] = self.revisions_compare_url_name
context["editing_sessions"] = self.get_editing_sessions()
return context
def post(self, request, *args, **kwargs):

Wyświetl plik

@ -17,7 +17,9 @@ from wagtail.actions.publish_page_revision import PublishPageRevisionAction
from wagtail.admin import messages
from wagtail.admin.action_menu import PageActionMenu
from wagtail.admin.mail import send_notification
from wagtail.admin.models import EditingSession
from wagtail.admin.ui.components import MediaContainer
from wagtail.admin.ui.editing_sessions import EditingSessionsModule
from wagtail.admin.ui.side_panels import (
ChecksSidePanel,
CommentsSidePanel,
@ -36,6 +38,7 @@ from wagtail.models import (
Page,
PageSubscription,
WorkflowState,
get_default_page_content_type,
)
from wagtail.utils.timestamps import render_timestamp
@ -878,6 +881,33 @@ class EditView(WagtailAdminTemplateMixin, HookResponseMixin, View):
side_panels.append(CommentsSidePanel(self.page, self.request))
return MediaContainer(side_panels)
def get_editing_sessions(self):
EditingSession.cleanup()
content_type = get_default_page_content_type()
session = EditingSession.objects.create(
user=self.request.user,
content_type=content_type,
object_id=self.page.pk,
last_seen_at=timezone.now(),
)
return EditingSessionsModule(
reverse(
"wagtailadmin_editing_sessions:ping",
args=("wagtailcore", "page", self.page.pk, session.id),
),
reverse(
"wagtailadmin_editing_sessions:release",
args=(session.id,),
),
list(
EditingSession.objects.filter(
content_type=content_type, object_id=self.page.pk
)
.exclude(id=session.id)
.select_related("user", "user__wagtail_userprofile")
),
)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
user_perms = self.page.permissions_for_user(self.request.user)
@ -925,6 +955,7 @@ class EditView(WagtailAdminTemplateMixin, HookResponseMixin, View):
and user_perms.can_unlock(),
"locale": self.locale,
"media": media,
"editing_sessions": self.get_editing_sessions(),
}
)

Wyświetl plik

@ -1186,12 +1186,14 @@ class TestCreateDraftStateSnippet(WagtailTestUtils, TestCase):
allow_extra_attrs=True,
)
# Should show the dialog template pointing to the [data-edit-form] selector as the root
self.assertTagInHTML(
'<template data-controller="w-teleport" data-w-teleport-target-value="[data-edit-form]">',
html,
count=1,
allow_extra_attrs=True,
soup = self.get_soup(html)
dialog = soup.select_one(
"""
template[data-controller="w-teleport"][data-w-teleport-target-value="[data-edit-form]"]
#schedule-publishing-dialog
"""
)
self.assertIsNotNone(dialog)
# Should render the main form with data-edit-form attribute
self.assertTagInHTML(
f'<form action="{add_url}" method="POST" data-edit-form>',
@ -1586,12 +1588,14 @@ class BaseTestSnippetEditView(WagtailTestUtils, TestCase):
allow_extra_attrs=True,
)
# Should show the dialog template pointing to the [data-edit-form] selector as the root
self.assertTagInHTML(
'<template data-controller="w-teleport" data-w-teleport-target-value="[data-edit-form]">',
html,
count=1,
allow_extra_attrs=True,
soup = self.get_soup(html)
dialog = soup.select_one(
"""
template[data-controller="w-teleport"][data-w-teleport-target-value="[data-edit-form]"]
#schedule-publishing-dialog
"""
)
self.assertIsNotNone(dialog)
# Should render the main form with data-edit-form attribute
self.assertTagInHTML(
f'<form action="{self.get_edit_url()}" method="POST" data-edit-form>',