From 5ee5acb7ce576f53b881a1fc092c5cfe8d5f7fc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?sag=E1=A0=8Ee?= Date: Thu, 14 Jul 2022 19:59:05 +0700 Subject: [PATCH] Introduce a new auto-updating preview panel inside the page editor (#8671) Co-authored-by: Thibaud Colas --- CHANGELOG.txt | 1 + client/scss/components/_preview-error.scss | 22 ++ client/scss/components/_preview-panel.scss | 187 ++++++++++++ client/scss/core.scss | 2 + client/scss/layouts/_page-editor.scss | 13 +- client/src/entrypoints/admin/page-editor.js | 80 +----- client/src/entrypoints/admin/preview-panel.js | 269 ++++++++++++++++++ client/webpack.config.js | 1 + docs/reference/settings.md | 16 +- docs/releases/4.0.md | 8 + .../templates/wagtailadmin/icons/desktop.svg | 3 + .../templates/wagtailadmin/icons/rotate.svg | 3 + .../wagtailadmin/icons/tablet-alt.svg | 3 + .../wagtailadmin/pages/_editor_js.html | 1 + .../pages/_preview_button_on_create.html | 6 - .../pages/_preview_button_on_edit.html | 6 - .../templates/wagtailadmin/pages/create.html | 22 +- .../templates/wagtailadmin/pages/edit.html | 23 +- .../wagtailadmin/pages/preview_error.html | 29 +- .../shared/side_panels/preview.html | 67 ++++- .../shared/side_panels/status.html | 2 +- .../admin/templatetags/wagtailadmin_tags.py | 18 +- wagtail/admin/templatetags/wagtailuserbar.py | 4 + wagtail/admin/tests/pages/test_create_page.py | 5 +- wagtail/admin/tests/pages/test_edit_page.py | 16 +- wagtail/admin/tests/pages/test_preview.py | 127 ++++++++- wagtail/admin/ui/side_panels.py | 32 ++- wagtail/admin/views/pages/create.py | 6 +- wagtail/admin/views/pages/edit.py | 2 +- wagtail/admin/views/pages/preview.py | 43 ++- wagtail/admin/views/pages/revisions.py | 2 +- wagtail/admin/wagtail_hooks.py | 3 + wagtail/utils/decorators.py | 23 ++ 33 files changed, 850 insertions(+), 195 deletions(-) create mode 100644 client/scss/components/_preview-error.scss create mode 100644 client/scss/components/_preview-panel.scss create mode 100644 client/src/entrypoints/admin/preview-panel.js create mode 100644 wagtail/admin/templates/wagtailadmin/icons/desktop.svg create mode 100644 wagtail/admin/templates/wagtailadmin/icons/rotate.svg create mode 100644 wagtail/admin/templates/wagtailadmin/icons/tablet-alt.svg delete mode 100644 wagtail/admin/templates/wagtailadmin/pages/_preview_button_on_create.html delete mode 100644 wagtail/admin/templates/wagtailadmin/pages/_preview_button_on_edit.html diff --git a/CHANGELOG.txt b/CHANGELOG.txt index dfc4278f4a18..2de45c69576f 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -68,6 +68,7 @@ Changelog * Add rich text editor empty block highlight by showing their block type (Thibaud Colas) * Make ModelAdmin InspectView footer actions consistent with other parts of the UI (Thibaud Colas) * Switch all report views to use Wagtail’s reusable header component (Paarth Agarwal) + * Introduce a new auto-updating preview panel inside the page editor (Sage Abdullah) * Fix: Typo in `ResumeWorkflowActionFormatter` message (Stefan Hammer) * Fix: Throw a meaningful error when saving an image to an unrecognised image format (Christian Franke) * Fix: Remove extra padding for headers with breadcrumbs on mobile viewport (Steven Steinwand) diff --git a/client/scss/components/_preview-error.scss b/client/scss/components/_preview-error.scss new file mode 100644 index 000000000000..75e9b4824e43 --- /dev/null +++ b/client/scss/components/_preview-error.scss @@ -0,0 +1,22 @@ +.preview-error { + @apply w-bg-white; + position: absolute; + inset-inline-start: 0; + width: 100%; + display: grid; + min-height: 100vh; + align-content: center; + text-align: center; + + &__header { + margin-bottom: 0.5rem; + } + + &__title { + @apply w-h4 w-text-grey-600; + } + + &__details { + @apply w-help-text; + } +} diff --git a/client/scss/components/_preview-panel.scss b/client/scss/components/_preview-panel.scss new file mode 100644 index 000000000000..d4c9d8d82053 --- /dev/null +++ b/client/scss/components/_preview-panel.scss @@ -0,0 +1,187 @@ +.preview-panel { + height: 100%; + display: flex; + flex-direction: column; + + --preview-width-ratio: min( + 1, + var(--preview-panel-width, 450) / var(--preview-device-width, 375) + ); + --preview-iframe-width: calc(1px * var(--preview-device-width, 375)); + + &__area { + height: 100%; + display: flex; + flex-direction: column-reverse; + justify-content: space-between; + // Needed for the warning message when the data is not valid. + overflow: hidden; + } + + &__wrapper { + width: calc(var(--preview-iframe-width) * var(--preview-width-ratio)); + height: 100%; + margin-inline-start: auto; + margin-inline-end: auto; + } + + &__iframe { + transform-origin: 0 0; + width: var(--preview-iframe-width); + height: calc(100% / var(--preview-width-ratio)); + transform: scale(var(--preview-width-ratio)); + display: block; + } + + &__sizes { + @apply w-border-grey-100 w-border-b; + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; + padding-bottom: 1rem; + margin-bottom: 1rem; + } + + &__size-button { + @apply w-text-grey-400 w-transition hover:w-transform hover:w-scale-110 hover:w-text-primary focus:w-text-primary; + + width: 2rem; + height: 2rem; + background: transparent; + box-sizing: border-box; + padding: 0; + border-radius: 5px; + display: grid; + place-items: center; + cursor: pointer; + + &:focus-within { + @include focus-outline; + } + + .icon { + @include svg-icon(1rem); + + &.icon-tablet-alt, + &.icon-desktop { + @include svg-icon(1.25rem); + } + + &.icon-link-external { + @include svg-icon(0.9rem); + } + } + + input[type='radio'] { + position: absolute; + width: 0; + height: 0; + opacity: 0; + } + } + + &__refresh-button.button--icon { + display: flex; + align-items: center; + gap: 0.5rem; + position: absolute; + top: 1.25rem; + inset-inline-end: 1.5rem; + + .icon { + @include svg-icon(0.9rem); + } + } + + &__spinner { + position: absolute; + top: 1.25rem; + inset-inline-end: 1.5rem; + } + + &--mobile &__size-button--mobile, + &--tablet &__size-button--tablet, + &--desktop &__size-button--desktop { + @apply w-bg-primary w-text-white w-transform-none w-border w-border-transparent; + } + + &__controls { + @apply w-border-t w-border-transparent w-duration-500 w-ease-in-out; + transition-property: border-color, margin-top, padding-top; + margin-top: 0; + padding-top: 0; + + // Show the border only if there's an error, + // but always show it if there are multiple preview modes + .preview-panel--has-errors &:not(&--multiple), + &--multiple { + @apply w-border-grey-100 w-border-t; + padding-top: 1rem; + margin-top: 1rem; + } + } + + &__error-banner { + @apply w-text-grey-600 w-duration-1000 w-ease-in-out w-translate-y-20; + transition-property: max-height, transform, visibility; + visibility: hidden; + max-height: 0; + display: flex; + align-items: center; + gap: 1rem; + position: relative; + z-index: -1; + + .icon { + @apply w-text-warning-100; + } + } + + &--has-errors &__error-banner { + @apply w-translate-y-0; + visibility: visible; + max-height: 6rem; + } + + &__error-title { + @apply w-label-2; + color: inherit; + margin-top: 0; + margin-bottom: 0.25rem; + } + + &__error-details { + color: inherit; + } + + &__modes { + @apply w-bg-white; + display: flex; + align-items: center; + justify-content: center; + gap: 1rem; + } + + &__mode-label { + padding: 0; + width: auto; + } + + &__mode-select { + @apply w-bg-white w-outline-offset-inside; + font-size: 1em; + padding: 0.6rem 0.75rem; + padding-inline-end: 2.5rem; + width: auto; + } + + &__modes.choice_field &__mode-select ~ span:after { + @apply w-text-grey-400; + border: 0; + font-size: 1.5em; + line-height: 1.65em; + top: auto; + width: 2.5rem; + } +} diff --git a/client/scss/core.scss b/client/scss/core.scss index 4590e422312e..5b6265e8e563 100644 --- a/client/scss/core.scss +++ b/client/scss/core.scss @@ -138,6 +138,8 @@ These are classes for components. @import 'components/workflow-timeline'; @import 'components/switch'; @import 'components/bulk_actions'; +@import 'components/preview-panel'; +@import 'components/preview-error'; @import '../src/components/Sidebar/Sidebar'; diff --git a/client/scss/layouts/_page-editor.scss b/client/scss/layouts/_page-editor.scss index 9322909b5141..bb7ae5753c75 100644 --- a/client/scss/layouts/_page-editor.scss +++ b/client/scss/layouts/_page-editor.scss @@ -64,13 +64,12 @@ w-right-0 w-top-full w-w-full - w-h-screen - sm:w-max-w-[420px] + w-h-[calc(100vh-100%)] + sm:w-max-w-[500px] w-transform w-translate-x-full - w-px-5 - w-py-10 - md:w-p-10 + w-px-6 + w-py-4 w-bg-white w-box-border w-transition @@ -85,7 +84,7 @@ } &__close-button { - @apply w-text-primary w-absolute w-left-2 w-top-2 hover:w-text-primary-200 w-bg-white w-p-2.5 w-hidden w-transition; + @apply w-text-primary w-absolute w-left-3 w-top-3 hover:w-text-primary-200 w-bg-white w-p-3 w-hidden w-transition; .icon { @apply w-w-4 w-h-4; @@ -98,7 +97,7 @@ } &__panel { - @apply w-hidden w-pl-2.5; + @apply w-hidden w-h-full; } } } diff --git a/client/src/entrypoints/admin/page-editor.js b/client/src/entrypoints/admin/page-editor.js index e3d7f3ce6d0c..aba57c52204f 100644 --- a/client/src/entrypoints/admin/page-editor.js +++ b/client/src/entrypoints/admin/page-editor.js @@ -267,7 +267,10 @@ window.initErrorDetection = initErrorDetection; function initKeyboardShortcuts() { // eslint-disable-next-line no-undef Mousetrap.bind(['mod+p'], () => { - $('.action-preview').trigger('click'); + const previewToggle = document.querySelector( + '[data-side-panel-toggle="preview"]', + ); + if (previewToggle) previewToggle.click(); return false; }); @@ -289,81 +292,6 @@ $(() => { initSlugCleaning(); initErrorDetection(); initKeyboardShortcuts(); - - // - // Preview - // - // In order to make the preview truly reliable, the preview page needs - // to be perfectly independent from the edit page, - // from the browser perspective. To pass data from the edit page - // to the preview page, we send the form after each change - // and save it inside the user session. - - const $previewButton = $('.action-preview'); - const $form = $('#page-edit-form'); - const previewUrl = $previewButton.data('action'); - let autoUpdatePreviewDataTimeout = -1; - - function setPreviewData() { - return $.ajax({ - url: previewUrl, - method: 'POST', - data: new FormData($form[0]), - processData: false, - contentType: false, - }); - } - - $previewButton.one('click', () => { - if ($previewButton.data('auto-update')) { - // Form data is changed when field values are changed - // (change event), when HTML elements are added, modified, moved, - // and deleted (DOMSubtreeModified event), and we need to delay - // setPreviewData when typing to avoid useless extra AJAX requests - // (so we postpone setPreviewData when keyup occurs). - - // TODO: Replace DOMSubtreeModified with a MutationObserver. - $form - .on('change keyup DOMSubtreeModified', () => { - clearTimeout(autoUpdatePreviewDataTimeout); - autoUpdatePreviewDataTimeout = setTimeout(setPreviewData, 1000); - }) - .trigger('change'); - } - }); - - // eslint-disable-next-line func-names - $previewButton.on('click', function (e) { - e.preventDefault(); - const $this = $(this); - const $icon = $this.filter('.icon'); - const thisPreviewUrl = $this.data('action'); - $icon.addClass('icon-spinner').removeClass('icon-view'); - const previewWindow = window.open('', thisPreviewUrl); - previewWindow.focus(); - - setPreviewData() - .done((data) => { - if (data.is_valid) { - previewWindow.document.location = thisPreviewUrl; - } else { - window.focus(); - previewWindow.close(); - - // TODO: Stop sending the form, as it removes file data. - $form.trigger('submit'); - } - }) - .fail(() => { - // eslint-disable-next-line no-alert - alert('Error while sending preview data.'); - window.focus(); - previewWindow.close(); - }) - .always(() => { - $icon.addClass('icon-view').removeClass('icon-spinner'); - }); - }); }); let updateFooterTextTimeout = -1; diff --git a/client/src/entrypoints/admin/preview-panel.js b/client/src/entrypoints/admin/preview-panel.js new file mode 100644 index 000000000000..5fec7c737c99 --- /dev/null +++ b/client/src/entrypoints/admin/preview-panel.js @@ -0,0 +1,269 @@ +import { gettext } from '../../utils/gettext'; + +function initPreview() { + const previewSidePanel = document.querySelector( + '[data-side-panel="preview"]', + ); + + // Preview side panel is not shown if the object does not have any preview modes + if (!previewSidePanel) return; + + // Get settings from the preview_settings template tag + const settings = JSON.parse( + document.getElementById('wagtail-preview-settings').textContent, + ); + + // The previewSidePanel is a generic container for side panels, + // the content of the preview panel itself is in a child element + const previewPanel = previewSidePanel.querySelector('[data-preview-panel]'); + + // + // Preview size handling + // + + const sizeInputs = previewPanel.querySelectorAll('[data-device-width]'); + const defaultSizeInput = previewPanel.querySelector('[data-default-size]'); + + const setPreviewWidth = (width) => { + const isUnavailable = previewPanel.classList.contains( + 'preview-panel--unavailable', + ); + + let deviceWidth = width; + // Reset to default size if width is falsy or preview is unavailable + if (!width || isUnavailable) { + deviceWidth = defaultSizeInput.dataset.deviceWidth; + } + + previewPanel.style.setProperty('--preview-device-width', deviceWidth); + }; + + const togglePreviewSize = (event) => { + const device = event.target.value; + const deviceWidth = event.target.dataset.deviceWidth; + + setPreviewWidth(deviceWidth); + + // Ensure only one device class is applied + sizeInputs.forEach((input) => { + previewPanel.classList.toggle( + `preview-panel--${input.value}`, + input.value === device, + ); + }); + }; + + sizeInputs.forEach((input) => + input.addEventListener('change', togglePreviewSize), + ); + + const resizeObserver = new ResizeObserver((entries) => + previewPanel.style.setProperty( + '--preview-panel-width', + entries[0].contentRect.width, + ), + ); + resizeObserver.observe(previewPanel); + + // + // Preview data handling + // + // In order to make the preview truly reliable, the preview page needs + // to be perfectly independent from the edit page, + // from the browser perspective. To pass data from the edit page + // to the preview page, we send the form after each change + // and save it inside the user session. + + const newTabButton = previewPanel.querySelector('[data-preview-new-tab]'); + const refreshButton = previewPanel.querySelector('[data-refresh-preview]'); + const loadingSpinner = previewPanel.querySelector('[data-preview-spinner]'); + const form = document.querySelector('[data-edit-form]'); + const previewUrl = previewPanel.dataset.action; + const previewModeSelect = document.querySelector( + '[data-preview-mode-select]', + ); + let iframe = previewPanel.querySelector('[data-preview-iframe]'); + let spinnerTimeout; + let hasPendingUpdate = false; + + const finishUpdate = () => { + clearTimeout(spinnerTimeout); + loadingSpinner.classList.add('w-hidden'); + hasPendingUpdate = false; + }; + + const reloadIframe = () => { + // Instead of reloading the iframe, we're replacing it with a new iframe to + // prevent flashing + + // Create a new invisible iframe element + const newIframe = document.createElement('iframe'); + newIframe.style.width = 0; + newIframe.style.height = 0; + newIframe.style.opacity = 0; + newIframe.style.position = 'absolute'; + newIframe.src = iframe.src; + + // Put it in the DOM so it loads the page + iframe.insertAdjacentElement('afterend', newIframe); + + const handleLoad = () => { + // Copy all attributes from the old iframe to the new one, + // except src as that will cause the iframe to be reloaded + Array.from(iframe.attributes).forEach((key) => { + if (key.nodeName === 'src') return; + newIframe.setAttribute(key.nodeName, key.nodeValue); + }); + + // Restore scroll position + newIframe.contentWindow.scroll( + iframe.contentWindow.scrollX, + iframe.contentWindow.scrollY, + ); + + // Remove the old iframe and swap it with the new one + iframe.remove(); + iframe = newIframe; + + // Make the new iframe visible + newIframe.style = null; + + // Ready for another update + finishUpdate(); + + // Remove the load event listener so it doesn't fire when switching modes + newIframe.removeEventListener('load', handleLoad); + }; + + newIframe.addEventListener('load', handleLoad); + }; + + const setPreviewData = () => { + hasPendingUpdate = true; + spinnerTimeout = setTimeout( + () => loadingSpinner.classList.remove('w-hidden'), + 2000, + ); + + return fetch(previewUrl, { + method: 'POST', + body: new FormData(form), + }) + .then((response) => response.json()) + .then((data) => { + previewPanel.classList.toggle( + 'preview-panel--has-errors', + !data.is_valid, + ); + previewPanel.classList.toggle( + 'preview-panel--unavailable', + !data.is_available, + ); + + if (data.is_valid) { + reloadIframe(); + } else { + finishUpdate(); + } + + return data.is_valid; + }) + .catch((error) => { + finishUpdate(); + // Re-throw error so it can be handled by handlePreview + throw error; + }); + }; + + const handlePreview = () => + setPreviewData().catch(() => { + // eslint-disable-next-line no-alert + window.alert(gettext('Error while sending preview data.')); + }); + + const handlePreviewInNewTab = (event) => { + event.preventDefault(); + const previewWindow = window.open('', previewUrl); + previewWindow.focus(); + + handlePreview().then((success) => { + if (success) { + const url = new URL(newTabButton.href); + previewWindow.document.location = url.toString(); + } else { + window.focus(); + previewWindow.close(); + } + }); + }; + + newTabButton.addEventListener('click', handlePreviewInNewTab); + + if (refreshButton) { + refreshButton.addEventListener('click', handlePreview); + } + + if (settings.WAGTAIL_AUTO_UPDATE_PREVIEW) { + let oldPayload = new URLSearchParams(new FormData(form)).toString(); + let updateInterval; + + const hasChanges = () => { + const newPayload = new URLSearchParams(new FormData(form)).toString(); + const changed = oldPayload !== newPayload; + + oldPayload = newPayload; + return changed; + }; + + const checkAndUpdatePreview = () => { + // Do not check for preview update if an update request is still pending + // and don't send a new request if the form hasn't changed + if (hasPendingUpdate || !hasChanges()) return; + setPreviewData(); + }; + + previewSidePanel.addEventListener('show', () => { + // Immediately update the preview when the panel is opened + checkAndUpdatePreview(); + + // Only set the interval while the panel is shown + updateInterval = setInterval( + checkAndUpdatePreview, + settings.WAGTAIL_AUTO_UPDATE_PREVIEW_INTERVAL, + ); + }); + + previewSidePanel.addEventListener('hide', () => { + clearInterval(updateInterval); + }); + } + + // + // Preview mode handling + // + + const handlePreviewModeChange = (event) => { + const mode = event.target.value; + const url = new URL(iframe.src); + url.searchParams.set('mode', mode); + + iframe.src = url.toString(); + url.searchParams.delete('in_preview_panel'); + newTabButton.href = url.toString(); + + // Make sure data is updated + handlePreview(); + }; + + if (previewModeSelect) { + previewModeSelect.addEventListener('change', handlePreviewModeChange); + } + + // Make sure current preview data in session exists and is up-to-date. + setPreviewData(); + setPreviewWidth(); +} + +document.addEventListener('DOMContentLoaded', () => { + initPreview(); +}); diff --git a/client/webpack.config.js b/client/webpack.config.js index 7ce23af44a0a..d95ac9f72356 100644 --- a/client/webpack.config.js +++ b/client/webpack.config.js @@ -45,6 +45,7 @@ module.exports = function exports(env, argv) { 'page-chooser-modal', 'page-chooser', 'page-editor', + 'preview-panel', 'privacy-switch', 'sidebar', 'task-chooser-modal', diff --git a/docs/reference/settings.md b/docs/reference/settings.md index 2ca5630e2de3..8638ab7f1f9a 100644 --- a/docs/reference/settings.md +++ b/docs/reference/settings.md @@ -527,18 +527,24 @@ WAGTAIL_ALLOW_UNICODE_SLUGS = True By default, page slugs can contain any alphanumeric characters, including non-Latin alphabets. Set this to False to limit slugs to ASCII characters. -(WAGTAIL_AUTO_UPDATE_PREVIEW)= - ## Auto update preview ### `WAGTAIL_AUTO_UPDATE_PREVIEW` ```python -WAGTAIL_AUTO_UPDATE_PREVIEW = False +WAGTAIL_AUTO_UPDATE_PREVIEW = True +``` + +When enabled, the preview panel in the page editor is automatically updated on each change. If set to `False`, a refresh button will be shown and the preview is only updated when the button is clicked. +This behaviour is enabled by default. + +### `WAGTAIL_AUTO_UPDATE_PREVIEW_INTERVAL` + +```python +WAGTAIL_AUTO_UPDATE_PREVIEW_INTERVAL = 500 ``` -When enabled, data from an edited page is automatically sent to the server on each change, even without saving. That way, users don’t have to click on “Preview” to update the content of the preview page. However, the preview page tab is not refreshed automatically, users have to do it manually. -This behaviour is disabled by default. +The interval (in milliseconds) to check for changes made in the page editor before updating the preview. The default value is `500`. ## Custom User Edit Forms diff --git a/docs/releases/4.0.md b/docs/releases/4.0.md index af3570ec472f..6a3195ab8a3d 100644 --- a/docs/releases/4.0.md +++ b/docs/releases/4.0.md @@ -26,6 +26,10 @@ As part of the page editor redesign project sponsored by Google, we have made a * Focus-aware placeholder: The editor’s placeholder text will now follow the user’s focus, to make it easier to understand where to type in long fields. * Empty heading highlight: The editor now highlights empty headings and list items by showing their type (“Heading 3”) as a placeholder, so content is less likely to be published with empty headings. +### Live preview panel + +Wagtail’s page preview is now available in a side panel within the page editor. This preview auto-updates as users type, and can display the page in three different viewports: mobile, tablet, desktop. The existing preview functionality is still present, moved inside the preview panel rather than at the bottom of the page editor. The auto-update delay can be configured with the `WAGTAIL_AUTO_UPDATE_PREVIEW_INTERVAL` setting. This feature was developed by Sage Abdullah. + ### Other features * Add clarity to confirmation when being asked to convert an external link to an internal one (Thijs Kramer) @@ -185,3 +189,7 @@ If your code contains references to URL route names within the `wagtaildocs` or * `wagtaildocs:chooser_upload` is now `wagtaildocs_chooser:create` * `wagtailsnippets:list`, `wagtailsnippets:list_results`, `wagtailsnippets:add`, `wagtailsnippets:edit`, `wagtailsnippets:delete-multiple`, `wagtailsnippets:delete`, `wagtailsnippets:usage`, `wagtailsnippets:history`: These now exist in a separate `wagtailsnippets_{app_label}_{model_name}` namespace for each snippet model, and no longer take `app_label` and `model_name` as arguments. * `wagtailsnippets:choose`, `wagtailsnippets:choose_results`, `wagtailsnippets:chosen`: These now exist in a separate `wagtailsnippetchoosers_{app_label}_{model_name}` namespace for each snippet model, and no longer take `app_label` and `model_name` as arguments. + +### Auto-updating preview + +As part of the introduction of the new live preview panel, we have changed the `WAGTAIL_AUTO_UPDATE_PREVIEW` setting to be on (`True`) by default. This can still be turned off by setting it to `False`. The `WAGTAIL_AUTO_UPDATE_PREVIEW_INTERVAL` setting has been introduced for sites willing to reduce the performance cost of the live preview without turning it off completely. diff --git a/wagtail/admin/templates/wagtailadmin/icons/desktop.svg b/wagtail/admin/templates/wagtailadmin/icons/desktop.svg new file mode 100644 index 000000000000..d103c0d4af91 --- /dev/null +++ b/wagtail/admin/templates/wagtailadmin/icons/desktop.svg @@ -0,0 +1,3 @@ + + + diff --git a/wagtail/admin/templates/wagtailadmin/icons/rotate.svg b/wagtail/admin/templates/wagtailadmin/icons/rotate.svg new file mode 100644 index 000000000000..9f0227cc1c9b --- /dev/null +++ b/wagtail/admin/templates/wagtailadmin/icons/rotate.svg @@ -0,0 +1,3 @@ + + + diff --git a/wagtail/admin/templates/wagtailadmin/icons/tablet-alt.svg b/wagtail/admin/templates/wagtailadmin/icons/tablet-alt.svg new file mode 100644 index 000000000000..886b18ede39d --- /dev/null +++ b/wagtail/admin/templates/wagtailadmin/icons/tablet-alt.svg @@ -0,0 +1,3 @@ + + + diff --git a/wagtail/admin/templates/wagtailadmin/pages/_editor_js.html b/wagtail/admin/templates/wagtailadmin/pages/_editor_js.html index 138b74ccde06..a2302aa3fe7a 100644 --- a/wagtail/admin/templates/wagtailadmin/pages/_editor_js.html +++ b/wagtail/admin/templates/wagtailadmin/pages/_editor_js.html @@ -23,6 +23,7 @@ + diff --git a/wagtail/admin/templates/wagtailadmin/pages/_preview_button_on_create.html b/wagtail/admin/templates/wagtailadmin/pages/_preview_button_on_create.html deleted file mode 100644 index 9bb7b1c7781b..000000000000 --- a/wagtail/admin/templates/wagtailadmin/pages/_preview_button_on_create.html +++ /dev/null @@ -1,6 +0,0 @@ -{% load wagtailadmin_tags %} - -{% auto_update_preview as auto_update %} - diff --git a/wagtail/admin/templates/wagtailadmin/pages/_preview_button_on_edit.html b/wagtail/admin/templates/wagtailadmin/pages/_preview_button_on_edit.html deleted file mode 100644 index c589e2c7ad1a..000000000000 --- a/wagtail/admin/templates/wagtailadmin/pages/_preview_button_on_edit.html +++ /dev/null @@ -1,6 +0,0 @@ -{% load wagtailadmin_tags %} - -{% auto_update_preview as auto_update %} - diff --git a/wagtail/admin/templates/wagtailadmin/pages/create.html b/wagtail/admin/templates/wagtailadmin/pages/create.html index b5c5805fa00d..d283e6e36d82 100644 --- a/wagtail/admin/templates/wagtailadmin/pages/create.html +++ b/wagtail/admin/templates/wagtailadmin/pages/create.html @@ -13,7 +13,7 @@ {% include "wagtailadmin/shared/side_panels.html" %} -
+ {% csrf_token %} @@ -36,26 +36,6 @@ - {% if preview_modes %} -
  • - {% trans 'Preview' as preview_label %} - {% if preview_modes|length > 1 %} - - {% else %} - {% include "wagtailadmin/pages/_preview_button_on_create.html" with label=preview_label icon=1 %} - {% endif %} -
  • - {% endif %} {% block extra_footer_actions %} {% endblock %} diff --git a/wagtail/admin/templates/wagtailadmin/pages/edit.html b/wagtail/admin/templates/wagtailadmin/pages/edit.html index 2993a64609ad..62a062e9f99c 100644 --- a/wagtail/admin/templates/wagtailadmin/pages/edit.html +++ b/wagtail/admin/templates/wagtailadmin/pages/edit.html @@ -15,7 +15,7 @@ {% block form %} - + {% csrf_token %} @@ -36,27 +36,6 @@ - {% if preview_modes %} -
  • - {% trans 'Preview' as preview_label %} - {% if preview_modes|length > 1 %} - - {% else %} - {% include "wagtailadmin/pages/_preview_button_on_edit.html" with label=preview_label icon=1 %} - {% endif %} -
  • - {% endif %} - {% block extra_footer_actions %} {% endblock %} diff --git a/wagtail/admin/templates/wagtailadmin/pages/preview_error.html b/wagtail/admin/templates/wagtailadmin/pages/preview_error.html index aedc7c442a66..d27cddd12243 100644 --- a/wagtail/admin/templates/wagtailadmin/pages/preview_error.html +++ b/wagtail/admin/templates/wagtailadmin/pages/preview_error.html @@ -1,22 +1,23 @@ -{% extends 'wagtailadmin/base.html' %} +{% extends 'wagtailadmin/admin_base.html' %} {% load i18n %} -{% block titletag %}{% trans 'Preview error' %}{% endblock %} +{% block titletag %}{% trans 'Preview not available' %}{% endblock %} -{% block content %} -
    -

    {% trans 'Preview error' %}

    -
    -
    -

    +{% block furniture %} +

    +
    +

    {% trans 'Preview not available' %}

    +
    +

    {% blocktrans trimmed %} - Impossible to preview this page, some errors are remaining. - Please close this tab, edit the page to fix these errors, - then use preview again. + Preview cannot display due to validation errors. +
    + Save a draft to highlight the relevant fields. {% endblocktrans %}

    - - {% trans 'Close' %} -
    {% endblock %} + +{# Avoid loading all of Wagtail’s JavaScript for a fully static page. #} +{% block js %} +{% endblock %} diff --git a/wagtail/admin/templates/wagtailadmin/shared/side_panels/preview.html b/wagtail/admin/templates/wagtailadmin/shared/side_panels/preview.html index 24b4512cf2c3..26c1cf926da6 100644 --- a/wagtail/admin/templates/wagtailadmin/shared/side_panels/preview.html +++ b/wagtail/admin/templates/wagtailadmin/shared/side_panels/preview.html @@ -1 +1,66 @@ -{# TODO: Page history panel #} +{% load i18n wagtailadmin_tags %} + +{% preview_settings as settings %} +{{ settings|json_script:"wagtail-preview-settings" }} + +
    +
    + + + + + {% icon name="link-external" %} +
    + +
    + {% icon name="spinner" class_name="default" %} + {% trans 'Loading'%} +
    + + {% if not settings.WAGTAIL_AUTO_UPDATE_PREVIEW %} + + {% endif %} + +
    +
    +
    + {% icon name='warning' class_name='default' %} +
    +

    + {% trans 'Preview is out of date' %} +

    +

    + {% trans 'Correct the validation errors to resume preview (saving will highlight errors)' %} +

    +
    +
    + {% if has_multiple_modes %} +
    + +
    + + + +
    +
    + {% endif %} +
    + +
    + +
    +
    +
    diff --git a/wagtail/admin/templates/wagtailadmin/shared/side_panels/status.html b/wagtail/admin/templates/wagtailadmin/shared/side_panels/status.html index 6e6ca940ab45..63428c264edb 100644 --- a/wagtail/admin/templates/wagtailadmin/shared/side_panels/status.html +++ b/wagtail/admin/templates/wagtailadmin/shared/side_panels/status.html @@ -1,6 +1,6 @@ {% load wagtailadmin_tags i18n %} -
    +
    {% for template in status_templates %} {% include template %} {% endfor %} diff --git a/wagtail/admin/templatetags/wagtailadmin_tags.py b/wagtail/admin/templatetags/wagtailadmin_tags.py index d693a243a052..2bf0f24a0ee9 100644 --- a/wagtail/admin/templatetags/wagtailadmin_tags.py +++ b/wagtail/admin/templatetags/wagtailadmin_tags.py @@ -237,11 +237,6 @@ def allow_unicode_slugs(): return getattr(settings, "WAGTAIL_ALLOW_UNICODE_SLUGS", True) -@register.simple_tag -def auto_update_preview(): - return getattr(settings, "WAGTAIL_AUTO_UPDATE_PREVIEW", False) - - class EscapeScriptNode(template.Node): TAG_NAME = "escapescript" @@ -838,6 +833,19 @@ def get_comments_enabled(): return getattr(settings, "WAGTAILADMIN_COMMENTS_ENABLED", True) +@register.simple_tag +def preview_settings(): + default_options = { + "WAGTAIL_AUTO_UPDATE_PREVIEW": True, + "WAGTAIL_AUTO_UPDATE_PREVIEW_INTERVAL": 500, + } + + return { + option: getattr(settings, option, default) + for option, default in default_options.items() + } + + @register.simple_tag def resolve_url(url): # Used by wagtailadmin/shared/pagination_nav.html - given an input that may be a URL route diff --git a/wagtail/admin/templatetags/wagtailuserbar.py b/wagtail/admin/templatetags/wagtailuserbar.py index 5922285567ea..6e50d51af899 100644 --- a/wagtail/admin/templatetags/wagtailuserbar.py +++ b/wagtail/admin/templatetags/wagtailuserbar.py @@ -48,6 +48,10 @@ def wagtailuserbar(context, position="bottom-right"): if not user.has_perm("wagtailadmin.access_admin"): return "" + # Don't render if page is loaded in page editor's preview panel iframe + if getattr(request, "in_preview_panel", False): + return "" + # Render the userbar using the user's preferred admin language userprofile = UserProfile.get_for_user(user) with translation.override(userprofile.get_preferred_language()): diff --git a/wagtail/admin/tests/pages/test_create_page.py b/wagtail/admin/tests/pages/test_create_page.py index 5c0ab1ccd0ac..46b098aab179 100644 --- a/wagtail/admin/tests/pages/test_create_page.py +++ b/wagtail/admin/tests/pages/test_create_page.py @@ -748,7 +748,10 @@ def test_preview_on_create(self): # Check the JSON response self.assertEqual(response.status_code, 200) - self.assertJSONEqual(response.content.decode(), {"is_valid": True}) + self.assertJSONEqual( + response.content.decode(), + {"is_valid": True, "is_available": True}, + ) response = self.client.get(preview_url) diff --git a/wagtail/admin/tests/pages/test_edit_page.py b/wagtail/admin/tests/pages/test_edit_page.py index b276c36214f5..385753fa0a54 100644 --- a/wagtail/admin/tests/pages/test_edit_page.py +++ b/wagtail/admin/tests/pages/test_edit_page.py @@ -833,7 +833,10 @@ def test_preview_on_edit(self): # Check the JSON response self.assertEqual(response.status_code, 200) - self.assertJSONEqual(response.content.decode(), {"is_valid": True}) + self.assertJSONEqual( + response.content.decode(), + {"is_valid": True, "is_available": True}, + ) response = self.client.get(preview_url) @@ -856,10 +859,12 @@ def test_preview_on_edit_no_session_key(self): # We should have an error page because we are unable to # preview; the page key was not in the session. self.assertContains( - response, "Wagtail - Preview error", html=True + response, "Wagtail - Preview not available", html=True ) self.assertContains( - response, '

    Preview error

    ', html=True + response, + '

    Preview not available

    ', + html=True, ) @override_settings( @@ -915,7 +920,10 @@ def test_preview_uses_correct_site(self): # Check the JSON response self.assertEqual(response.status_code, 200) - self.assertJSONEqual(response.content.decode(), {"is_valid": True}) + self.assertJSONEqual( + response.content.decode(), + {"is_valid": True, "is_available": True}, + ) response = self.client.get(preview_url) diff --git a/wagtail/admin/tests/pages/test_preview.py b/wagtail/admin/tests/pages/test_preview.py index f853d4ec2529..e49148dab1fe 100644 --- a/wagtail/admin/tests/pages/test_preview.py +++ b/wagtail/admin/tests/pages/test_preview.py @@ -45,7 +45,10 @@ def test_issue_2599(self): # Check the JSON response self.assertEqual(response.status_code, 200) - self.assertJSONEqual(response.content.decode(), {"is_valid": True}) + self.assertJSONEqual( + response.content.decode(), + {"is_valid": True, "is_available": True}, + ) response = self.client.get(preview_url) @@ -123,6 +126,72 @@ def setUp(self): "comments-MAX_NUM_FORMS": 1000, } + def test_preview_on_create_with_no_session_data(self): + preview_url = reverse( + "wagtailadmin_pages:preview_on_add", + args=("tests", "eventpage", self.home_page.id), + ) + + preview_session_key = "wagtail-preview-tests-eventpage-{}".format( + self.home_page.id + ) + self.assertNotIn(preview_session_key, self.client.session) + + response = self.client.get(preview_url) + + # The preview should be unavailable + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "wagtailadmin/pages/preview_error.html") + self.assertContains( + response, + "Wagtail - Preview not available", + html=True, + ) + self.assertContains( + response, + '

    Preview not available

    ', + html=True, + ) + + def test_preview_on_create_with_invalid_data(self): + preview_url = reverse( + "wagtailadmin_pages:preview_on_add", + args=("tests", "eventpage", self.home_page.id), + ) + + preview_session_key = "wagtail-preview-tests-eventpage-{}".format( + self.home_page.id + ) + self.assertNotIn(preview_session_key, self.client.session) + + response = self.client.post(preview_url, {**self.post_data, "title": ""}) + + # Check the JSON response + self.assertEqual(response.status_code, 200) + self.assertJSONEqual( + response.content.decode(), + {"is_valid": False, "is_available": False}, + ) + + # The invalid data should not be saved in the session + self.assertNotIn(preview_session_key, self.client.session) + + response = self.client.get(preview_url) + + # The preview should still be unavailable + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "wagtailadmin/pages/preview_error.html") + self.assertContains( + response, + "Wagtail - Preview not available", + html=True, + ) + self.assertContains( + response, + '

    Preview not available

    ', + html=True, + ) + def test_preview_on_create_with_m2m_field(self): preview_url = reverse( "wagtailadmin_pages:preview_on_add", @@ -132,7 +201,10 @@ def test_preview_on_create_with_m2m_field(self): # Check the JSON response self.assertEqual(response.status_code, 200) - self.assertJSONEqual(response.content.decode(), {"is_valid": True}) + self.assertJSONEqual( + response.content.decode(), + {"is_valid": True, "is_available": True}, + ) # Check the user can refresh the preview preview_session_key = "wagtail-preview-tests-eventpage-{}".format( @@ -157,7 +229,10 @@ def test_preview_on_edit_with_m2m_field(self): # Check the JSON response self.assertEqual(response.status_code, 200) - self.assertJSONEqual(response.content.decode(), {"is_valid": True}) + self.assertJSONEqual( + response.content.decode(), + {"is_valid": True, "is_available": True}, + ) # Check the user can refresh the preview preview_session_key = "wagtail-preview-{}".format(self.event_page.id) @@ -172,6 +247,40 @@ def test_preview_on_edit_with_m2m_field(self): self.assertContains(response, "
  • Parties
  • ") self.assertContains(response, "
  • Holidays
  • ") + def test_preview_on_edit_with_valid_then_invalid_data(self): + preview_url = reverse( + "wagtailadmin_pages:preview_on_edit", args=(self.event_page.id,) + ) + response = self.client.post(preview_url, self.post_data) + + # Check the JSON response + self.assertEqual(response.status_code, 200) + self.assertJSONEqual( + response.content.decode(), + {"is_valid": True, "is_available": True}, + ) + + # Send an invalid update request + response = self.client.post(preview_url, {**self.post_data, "title": ""}) + self.assertEqual(response.status_code, 200) + self.assertJSONEqual( + response.content.decode(), + {"is_valid": False, "is_available": True}, + ) + + # Check the user can still see the preview with the last valid data + preview_session_key = "wagtail-preview-{}".format(self.event_page.id) + self.assertIn(preview_session_key, self.client.session) + + response = self.client.get(preview_url) + + # Check the HTML response + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "tests/event_page.html") + self.assertContains(response, "Beach party") + self.assertContains(response, "
  • Parties
  • ") + self.assertContains(response, "
  • Holidays
  • ") + def test_preview_on_edit_expiry(self): initial_datetime = timezone.now() expiry_datetime = initial_datetime + datetime.timedelta( @@ -229,7 +338,8 @@ def test_disable_preview_on_create(self): "wagtailadmin_pages:preview_on_add", args=("tests", "simplepage", self.root_page.id), ) - self.assertContains(response, '
  • ') + self.assertContains(response, 'data-side-panel-toggle="preview"') + self.assertContains(response, 'data-side-panel="preview"') self.assertContains(response, 'data-action="%s"' % preview_url) # StreamPage has preview_modes = [] @@ -245,7 +355,8 @@ def test_disable_preview_on_create(self): "wagtailadmin_pages:preview_on_add", args=("tests", "streampage", self.root_page.id), ) - self.assertNotContains(response, '
  • ') + self.assertNotContains(response, 'data-side-panel-toggle="preview"') + self.assertNotContains(response, 'data-side-panel="preview"') self.assertNotContains(response, 'data-action="%s"' % preview_url) def test_disable_preview_on_edit(self): @@ -261,7 +372,8 @@ def test_disable_preview_on_edit(self): preview_url = reverse( "wagtailadmin_pages:preview_on_edit", args=(simple_page.id,) ) - self.assertContains(response, '
  • ') + self.assertContains(response, 'data-side-panel-toggle="preview"') + self.assertContains(response, 'data-side-panel="preview"') self.assertContains(response, 'data-action="%s"' % preview_url) stream_page = StreamPage(title="stream page", body=[("text", "hello")]) @@ -276,7 +388,8 @@ def test_disable_preview_on_edit(self): preview_url = reverse( "wagtailadmin_pages:preview_on_edit", args=(stream_page.id,) ) - self.assertNotContains(response, '
  • ') + self.assertNotContains(response, 'data-side-panel-toggle="preview"') + self.assertNotContains(response, 'data-side-panel="preview"') self.assertNotContains(response, 'data-action="%s"' % preview_url) def test_disable_preview_on_revisions_list(self): diff --git a/wagtail/admin/ui/side_panels.py b/wagtail/admin/ui/side_panels.py index f60a3f6a76a1..6ceffdf2c7e3 100644 --- a/wagtail/admin/ui/side_panels.py +++ b/wagtail/admin/ui/side_panels.py @@ -126,7 +126,7 @@ class CommentsSidePanel(BaseSidePanel): toggle_icon_name = "comment" -class PreviewSidePanel(BaseSidePanel): +class BasePreviewSidePanel(BaseSidePanel): name = "preview" title = gettext_lazy("Preview") template_name = "wagtailadmin/shared/side_panels/preview.html" @@ -134,6 +134,28 @@ class PreviewSidePanel(BaseSidePanel): toggle_aria_label = gettext_lazy("Toggle preview") toggle_icon_name = "mobile-alt" + def get_context_data(self, parent_context): + context = super().get_context_data(parent_context) + context["has_multiple_modes"] = len(self.object.preview_modes) > 1 + return context + + +class PagePreviewSidePanel(BasePreviewSidePanel): + def get_context_data(self, parent_context): + context = super().get_context_data(parent_context) + if self.object.id: + context["preview_url"] = reverse( + "wagtailadmin_pages:preview_on_edit", args=[self.object.id] + ) + else: + content_type = parent_context["content_type"] + parent_page = parent_context["parent_page"] + context["preview_url"] = reverse( + "wagtailadmin_pages:preview_on_add", + args=[content_type.app_label, content_type.model, parent_page.id], + ) + return context + class BaseSidePanels: def __init__(self, request, object): @@ -156,14 +178,18 @@ def media(self): class PageSidePanels(BaseSidePanels): - def __init__(self, request, page, *, comments_enabled): + def __init__(self, request, page, *, preview_enabled, comments_enabled): super().__init__(request, page) self.side_panels = [ PageStatusSidePanel(page, self.request), - # PreviewSidePanel(page), ] + if preview_enabled and page.preview_modes: + self.side_panels += [ + PagePreviewSidePanel(page, self.request), + ] + if comments_enabled: self.side_panels += [ CommentsSidePanel(page, self.request), diff --git a/wagtail/admin/views/pages/create.py b/wagtail/admin/views/pages/create.py index fed695655652..3338e603b1e8 100644 --- a/wagtail/admin/views/pages/create.py +++ b/wagtail/admin/views/pages/create.py @@ -331,7 +331,10 @@ def get_context_data(self, **kwargs): self.request, view="create", parent_page=self.parent_page ) side_panels = PageSidePanels( - self.request, self.page, comments_enabled=self.form.show_comments_toggle + self.request, + self.page, + preview_enabled=True, + comments_enabled=self.form.show_comments_toggle, ) context.update( @@ -342,7 +345,6 @@ def get_context_data(self, **kwargs): "edit_handler": bound_panel, "action_menu": action_menu, "side_panels": side_panels, - "preview_modes": self.page.preview_modes, "form": self.form, "next": self.next_url, "has_unsaved_changes": self.has_unsaved_changes, diff --git a/wagtail/admin/views/pages/edit.py b/wagtail/admin/views/pages/edit.py index 14183e599480..d07e5e40b492 100644 --- a/wagtail/admin/views/pages/edit.py +++ b/wagtail/admin/views/pages/edit.py @@ -873,6 +873,7 @@ def get_context_data(self, **kwargs): side_panels = PageSidePanels( self.request, self.page_for_status, + preview_enabled=True, comments_enabled=self.form.show_comments_toggle, ) @@ -885,7 +886,6 @@ def get_context_data(self, **kwargs): "errors_debug": self.errors_debug, "action_menu": action_menu, "side_panels": side_panels, - "preview_modes": self.page.preview_modes, "form": self.form, "next": self.next_url, "has_unsaved_changes": self.has_unsaved_changes, diff --git a/wagtail/admin/views/pages/preview.py b/wagtail/admin/views/pages/preview.py index 3ce6d3b8bb00..31705e3623af 100644 --- a/wagtail/admin/views/pages/preview.py +++ b/wagtail/admin/views/pages/preview.py @@ -6,9 +6,11 @@ from django.http.request import QueryDict from django.shortcuts import get_object_or_404 from django.template.response import TemplateResponse +from django.utils.decorators import method_decorator from django.views.generic import View from wagtail.models import Page +from wagtail.utils.decorators import xframe_options_sameorigin_override def view_draft(request, page_id): @@ -54,31 +56,44 @@ def get_form(self, page, query_dict): form_class = page.get_edit_handler().get_form_class() parent_page = page.get_parent().specific - if self.session_key not in self.request.session: - # Session key not in session, returning null form + if not query_dict: + # Query dict is empty, return null form return form_class(instance=page, parent_page=parent_page) return form_class(query_dict, instance=page, parent_page=parent_page) + def _get_data_from_session(self): + post_data, _ = self.request.session.get(self.session_key, (None, None)) + if not isinstance(post_data, str): + post_data = "" + return QueryDict(post_data) + def post(self, request, *args, **kwargs): - # TODO: Handle request.FILES. - request.session[self.session_key] = request.POST.urlencode(), time() self.remove_old_preview_data() - form = self.get_form(self.get_page(), request.POST) - return JsonResponse({"is_valid": form.is_valid()}) + page = self.get_page() + form = self.get_form(page, request.POST) + is_valid = form.is_valid() + + if is_valid: + # TODO: Handle request.FILES. + request.session[self.session_key] = request.POST.urlencode(), time() + is_available = True + else: + # Check previous data in session to determine preview availability + form = self.get_form(page, self._get_data_from_session()) + is_available = form.is_valid() + + return JsonResponse({"is_valid": is_valid, "is_available": is_available}) def error_response(self, page): return TemplateResponse( self.request, "wagtailadmin/pages/preview_error.html", {"page": page} ) + @method_decorator(xframe_options_sameorigin_override) def get(self, request, *args, **kwargs): page = self.get_page() - - post_data, timestamp = self.request.session.get(self.session_key, (None, None)) - if not isinstance(post_data, str): - post_data = "" - form = self.get_form(page, QueryDict(post_data)) + form = self.get_form(page, self._get_data_from_session()) if not form.is_valid(): return self.error_response(page) @@ -90,7 +105,11 @@ def get(self, request, *args, **kwargs): except IndexError: raise PermissionDenied - return page.make_preview_request(request, preview_mode) + extra_attrs = { + "in_preview_panel": request.GET.get("in_preview_panel") == "true" + } + + return page.make_preview_request(request, preview_mode, extra_attrs) class PreviewOnCreate(PreviewOnEdit): diff --git a/wagtail/admin/views/pages/revisions.py b/wagtail/admin/views/pages/revisions.py index 01d246872f44..e271e6a31ac1 100644 --- a/wagtail/admin/views/pages/revisions.py +++ b/wagtail/admin/views/pages/revisions.py @@ -46,6 +46,7 @@ def revisions_revert(request, page_id, revision_id): side_panels = PageSidePanels( request, page, + preview_enabled=True, comments_enabled=form.show_comments_toggle, ) @@ -78,7 +79,6 @@ def revisions_revert(request, page_id, revision_id): "errors_debug": None, "action_menu": action_menu, "side_panels": side_panels, - "preview_modes": page.preview_modes, "form": form, # Used in unit tests "media": edit_handler.media + form.media diff --git a/wagtail/admin/wagtail_hooks.py b/wagtail/admin/wagtail_hooks.py index 6b22363263eb..8ea26fe24c96 100644 --- a/wagtail/admin/wagtail_hooks.py +++ b/wagtail/admin/wagtail_hooks.py @@ -950,6 +950,7 @@ def register_icons(icons): "cross.svg", "cut.svg", "date.svg", + "desktop.svg", "doc-empty-inverse.svg", "doc-empty.svg", "doc-full-inverse.svg", @@ -1013,6 +1014,7 @@ def register_icons(icons): "repeat.svg", "reset.svg", "resubmit.svg", + "rotate.svg", "search.svg", "site.svg", "snippet.svg", @@ -1022,6 +1024,7 @@ def register_icons(icons): "subscript.svg", "superscript.svg", "table.svg", + "tablet-alt.svg", "tag.svg", "tasks.svg", "thumbtack.svg", diff --git a/wagtail/utils/decorators.py b/wagtail/utils/decorators.py index 7b74a0336b32..42cb092aa53c 100644 --- a/wagtail/utils/decorators.py +++ b/wagtail/utils/decorators.py @@ -57,3 +57,26 @@ def cache_clear(self): """Clear the cached value.""" # Named after lru_cache.cache_clear self.cache.pop(self.cls, None) + + +def xframe_options_sameorigin_override(view_func): + """ + Modify a view function so its response has the X-Frame-Options HTTP header + set to 'SAMEORIGIN'. + + Adapted from Django's xframe_options_sameorigin so that it's always applied + even if the response already has that header set: + https://github.com/django/django/blob/3.2/django/views/decorators/clickjacking.py#L22-L37 + + Usage: + @xframe_options_sameorigin_override + def some_view(request): + ... + """ + + def wrapped_view(*args, **kwargs): + resp = view_func(*args, **kwargs) + resp["X-Frame-Options"] = "SAMEORIGIN" + return resp + + return functools.wraps(view_func)(wrapped_view)