kopia lustrzana https://github.com/wagtail/wagtail
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
rodzic
80480d8499
commit
886af6de98
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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' },
|
||||
|
|
|
@ -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 %}
|
|
@ -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>
|
|
@ -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 %}
|
||||
|
|
|
@ -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,))
|
||||
)
|
||||
|
|
|
@ -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,
|
||||
}
|
|
@ -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):
|
||||
|
|
|
@ -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(),
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
@ -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>',
|
||||
|
|
Ładowanie…
Reference in New Issue