Merge pull request #1778 from umbraco/feature/document-preview

Feature: Document Preview
This commit is contained in:
Lee Kelleher
2024-05-16 14:06:09 +01:00
committed by GitHub
17 changed files with 901 additions and 1 deletions

View File

@@ -99,6 +99,11 @@ export class UmbAppElement extends UmbLitElement {
component: () => import('../upgrader/upgrader.element.js'),
guards: [this.#isAuthorizedGuard()],
},
{
path: 'preview',
component: () => import('../preview/preview.element.js'),
guards: [this.#isAuthorizedGuard()],
},
{
path: 'logout',
resolve: () => {

View File

@@ -0,0 +1,32 @@
import type { ManifestPreviewAppProvider } from '@umbraco-cms/backoffice/extension-registry';
export const manifests: Array<ManifestPreviewAppProvider> = [
{
type: 'previewApp',
alias: 'Umb.PreviewApps.Device',
name: 'Preview: Device Switcher',
element: () => import('./preview-device.element.js'),
weight: 400,
},
{
type: 'previewApp',
alias: 'Umb.PreviewApps.Culture',
name: 'Preview: Culture Switcher',
element: () => import('./preview-culture.element.js'),
weight: 300,
},
{
type: 'previewApp',
alias: 'Umb.PreviewApps.OpenWebsite',
name: 'Preview: Open Website Button',
element: () => import('./preview-open-website.element.js'),
weight: 200,
},
{
type: 'previewApp',
alias: 'Umb.PreviewApps.Exit',
name: 'Preview: Exit Button',
element: () => import('./preview-exit.element.js'),
weight: 100,
},
];

View File

@@ -0,0 +1,103 @@
import { UMB_PREVIEW_CONTEXT } from '../preview.context.js';
import { css, customElement, html, nothing, repeat, state } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { UmbLanguageCollectionRepository } from '@umbraco-cms/backoffice/language';
import type { UmbLanguageDetailModel } from '@umbraco-cms/backoffice/language';
const elementName = 'umb-preview-culture';
@customElement(elementName)
export class UmbPreviewCultureElement extends UmbLitElement {
#languageRepository = new UmbLanguageCollectionRepository(this);
@state()
private _culture?: UmbLanguageDetailModel;
@state()
private _cultures: Array<UmbLanguageDetailModel> = [];
connectedCallback() {
super.connectedCallback();
this.#getCultures();
}
async #getCultures() {
const { data: langauges } = await this.#languageRepository.requestCollection({ skip: 0, take: 100 });
this._cultures = langauges?.items ?? [];
const searchParams = new URLSearchParams(window.location.search);
const culture = searchParams.get('culture');
if (culture && culture !== this._culture?.unique) {
this._culture = this._cultures.find((c) => c.unique === culture);
}
}
async #onClick(culture: UmbLanguageDetailModel) {
if (this._culture === culture) return;
this._culture = culture;
const previewContext = await this.getContext(UMB_PREVIEW_CONTEXT);
previewContext.updateIFrame({ culture: culture.unique });
}
render() {
if (this._cultures.length <= 1) return nothing;
return html`
<uui-button look="primary" popovertarget="cultures-popover">
<div>
<uui-icon name="icon-globe"></uui-icon>
<span>${this._culture?.name ?? this.localize.term('treeHeaders_languages')}</span>
</div>
</uui-button>
<uui-popover-container id="cultures-popover" placement="top-end">
<umb-popover-layout>
${repeat(
this._cultures,
(item) => item.unique,
(item) => html`
<uui-menu-item
label=${item.name}
?active=${item.unique === this._culture?.unique}
@click=${() => this.#onClick(item)}>
<uui-icon slot="icon" name="icon-globe"></uui-icon>
</uui-menu-item>
`,
)}
</umb-popover-layout>
</uui-popover-container>
`;
}
static styles = [
css`
:host {
display: flex;
border-left: 1px solid var(--uui-color-header-contrast);
--uui-button-font-weight: 400;
--uui-button-padding-left-factor: 3;
--uui-button-padding-right-factor: 3;
}
uui-button > div {
display: flex;
align-items: center;
gap: 5px;
}
umb-popover-layout {
--uui-color-surface: var(--uui-color-header-surface);
--uui-color-border: var(--uui-color-header-surface);
color: var(--uui-color-header-contrast);
}
`,
];
}
export { UmbPreviewCultureElement as element };
declare global {
interface HTMLElementTagNameMap {
[elementName]: UmbPreviewCultureElement;
}
}

View File

@@ -0,0 +1,149 @@
import { UMB_PREVIEW_CONTEXT } from '../preview.context.js';
import { css, customElement, html, property, repeat } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
export interface UmbPreviewDevice {
alias: string;
label: string;
css: string;
icon: string;
dimensions: { height: string; width: string };
}
const elementName = 'umb-preview-device';
@customElement(elementName)
export class UmbPreviewDeviceElement extends UmbLitElement {
#devices: Array<UmbPreviewDevice> = [
{
alias: 'fullsize',
label: 'Fit browser',
css: 'fullsize',
icon: 'icon-application-window-alt',
dimensions: { height: '100%', width: '100%' },
},
{
alias: 'desktop',
label: 'Desktop',
css: 'desktop shadow',
icon: 'icon-display',
dimensions: { height: '1080px', width: '1920px' },
},
{
alias: 'laptop',
label: 'Laptop',
css: 'laptop shadow',
icon: 'icon-laptop',
dimensions: { height: '768px', width: '1366px' },
},
{
alias: 'ipad-portrait',
label: 'Tablet portrait',
css: 'ipad-portrait shadow',
icon: 'icon-ipad',
dimensions: { height: '929px', width: '769px' },
},
{
alias: 'ipad-landscape',
label: 'Tablet landscape',
css: 'ipad-landscape shadow flip',
icon: 'icon-ipad',
dimensions: { height: '675px', width: '1024px' },
},
{
alias: 'smartphone-portrait',
label: 'Smartphone portrait',
css: 'smartphone-portrait shadow',
icon: 'icon-iphone',
dimensions: { height: '640px', width: '360px' },
},
{
alias: 'smartphone-landscape',
label: 'Smartphone landscape',
css: 'smartphone-landscape shadow flip',
icon: 'icon-iphone',
dimensions: { height: '360px', width: '640px' },
},
];
@property({ attribute: false, type: Object })
device = this.#devices[0];
connectedCallback() {
super.connectedCallback();
this.#changeDevice(this.device);
}
async #changeDevice(device: UmbPreviewDevice) {
if (device === this.device) return;
this.device = device;
const previewContext = await this.getContext(UMB_PREVIEW_CONTEXT);
previewContext?.updateIFrame({
className: device.css,
height: device.dimensions.height,
width: device.dimensions.width,
});
}
render() {
return html`
<uui-button look="primary" popovertarget="devices-popover">
<div>
<uui-icon name=${this.device.icon} class=${this.device.css.includes('flip') ? 'flip' : ''}></uui-icon>
<span>${this.device.label}</span>
</div>
</uui-button>
<uui-popover-container id="devices-popover" placement="top-end">
<umb-popover-layout>
${repeat(
this.#devices,
(item) => item.alias,
(item) => html`
<uui-menu-item
label=${item.label}
?active=${item === this.device}
@click=${() => this.#changeDevice(item)}>
<uui-icon slot="icon" name=${item.icon} class=${item.css.includes('flip') ? 'flip' : ''}></uui-icon>
</uui-menu-item>
`,
)}
</umb-popover-layout>
</uui-popover-container>
`;
}
static styles = [
css`
:host {
display: flex;
border-left: 1px solid var(--uui-color-header-contrast);
--uui-button-font-weight: 400;
--uui-button-padding-left-factor: 3;
--uui-button-padding-right-factor: 3;
}
uui-button > div {
display: flex;
align-items: center;
gap: 5px;
}
umb-popover-layout {
--uui-color-surface: var(--uui-color-header-surface);
--uui-color-border: var(--uui-color-header-surface);
color: var(--uui-color-header-contrast);
}
`,
];
}
export { UmbPreviewDeviceElement as element };
declare global {
interface HTMLElementTagNameMap {
[elementName]: UmbPreviewDeviceElement;
}
}

View File

@@ -0,0 +1,49 @@
import { UMB_PREVIEW_CONTEXT } from '../preview.context.js';
import { css, customElement, html } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
const elementName = 'umb-preview-exit';
@customElement(elementName)
export class UmbPreviewExitElement extends UmbLitElement {
async #onClick() {
const previewContext = await this.getContext(UMB_PREVIEW_CONTEXT);
previewContext.exitPreview(0);
}
render() {
return html`
<uui-button look="primary" @click=${this.#onClick}>
<div>
<uui-icon name="icon-power"></uui-icon>
<span>${this.localize.term('preview_endLabel')}</span>
</div>
</uui-button>
`;
}
static styles = [
css`
:host {
display: flex;
border-left: 1px solid var(--uui-color-header-contrast);
--uui-button-font-weight: 400;
--uui-button-padding-left-factor: 3;
--uui-button-padding-right-factor: 3;
}
uui-button > div {
display: flex;
align-items: center;
gap: 5px;
}
`,
];
}
export { UmbPreviewExitElement as element };
declare global {
interface HTMLElementTagNameMap {
[elementName]: UmbPreviewExitElement;
}
}

View File

@@ -0,0 +1,49 @@
import { UMB_PREVIEW_CONTEXT } from '../preview.context.js';
import { css, customElement, html } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
const elementName = 'umb-preview-open-website';
@customElement(elementName)
export class UmbPreviewOpenWebsiteElement extends UmbLitElement {
async #onClick() {
const previewContext = await this.getContext(UMB_PREVIEW_CONTEXT);
previewContext.openWebsite();
}
render() {
return html`
<uui-button look="primary" @click=${this.#onClick}>
<div>
<uui-icon name="icon-out"></uui-icon>
<span>${this.localize.term('preview_openWebsiteLabel')}</span>
</div>
</uui-button>
`;
}
static styles = [
css`
:host {
display: flex;
border-left: 1px solid var(--uui-color-header-contrast);
--uui-button-font-weight: 400;
--uui-button-padding-left-factor: 3;
--uui-button-padding-right-factor: 3;
}
uui-button > div {
display: flex;
align-items: center;
gap: 5px;
}
`,
];
}
export { UmbPreviewOpenWebsiteElement as element };
declare global {
interface HTMLElementTagNameMap {
[elementName]: UmbPreviewOpenWebsiteElement;
}
}

View File

@@ -0,0 +1,208 @@
import { UMB_APP_CONTEXT } from '../app/app.context.js';
import { UmbBooleanState, UmbStringState } from '@umbraco-cms/backoffice/observable-api';
import { umbConfirmModal } from '@umbraco-cms/backoffice/modal';
import { UmbContextBase } from '@umbraco-cms/backoffice/class-api';
import { UmbContextToken } from '@umbraco-cms/backoffice/context-api';
import { UmbDocumentPreviewRepository } from '@umbraco-cms/backoffice/document';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
const UMB_LOCALSTORAGE_SESSION_KEY = 'umb:previewSessions';
export class UmbPreviewContext extends UmbContextBase<UmbPreviewContext> {
#culture?: string | null;
#serverUrl: string = '';
#webSocket?: WebSocket;
#unique?: string | null;
#iframeReady = new UmbBooleanState(false);
public readonly iframeReady = this.#iframeReady.asObservable();
#previewUrl = new UmbStringState(undefined);
public readonly previewUrl = this.#previewUrl.asObservable();
#documentPreviewRepository = new UmbDocumentPreviewRepository(this);
constructor(host: UmbControllerHost) {
super(host, UMB_PREVIEW_CONTEXT);
this.#init();
}
async #init() {
const appContext = await this.getContext(UMB_APP_CONTEXT);
this.#serverUrl = appContext.getServerUrl();
const params = new URLSearchParams(window.location.search);
this.#culture = params.get('culture');
this.#unique = params.get('id');
if (!this.#unique) {
console.error('No unique ID found in query string.');
return;
}
this.#setPreviewUrl();
}
#configureWebSocket() {
if (this.#webSocket && this.#webSocket.readyState < 2) return;
const url = `${this.#serverUrl.replace('https://', 'wss://')}/umbraco/PreviewHub`;
this.#webSocket = new WebSocket(url);
this.#webSocket.addEventListener('open', () => {
// NOTE: SignalR protocol handshake; it requires a terminating control character.
const endChar = String.fromCharCode(30);
this.#webSocket?.send(`{"protocol":"json","version":1}${endChar}`);
});
this.#webSocket.addEventListener('message', (event: MessageEvent<string>) => {
if (!event?.data) return;
// NOTE: Strip the terminating control character, (from SignalR).
const data = event.data.substring(0, event.data.length - 1);
const json = JSON.parse(data) as { type: number; target: string; arguments: Array<string> };
if (json.type === 1 && json.target === 'refreshed') {
const pageId = json.arguments?.[0];
if (pageId === this.#unique) {
this.#setPreviewUrl({ rnd: Math.random() });
}
}
});
}
#getSessionCount(): number {
return Math.max(Number(localStorage.getItem(UMB_LOCALSTORAGE_SESSION_KEY)), 0) || 0;
}
#setPreviewUrl(args?: { serverUrl?: string; unique?: string | null; culture?: string | null; rnd?: number }) {
const host = args?.serverUrl || this.#serverUrl;
const path = args?.unique || this.#unique;
const params = new URLSearchParams();
const culture = args?.culture || this.#culture;
if (culture) params.set('culture', culture);
if (args?.rnd) params.set('rnd', args.rnd.toString());
this.#previewUrl.setValue(`${host}/${path}?${params}`);
}
#setSessionCount(sessions: number) {
localStorage.setItem(UMB_LOCALSTORAGE_SESSION_KEY, sessions.toString());
}
checkSession() {
const sessions = this.#getSessionCount();
if (sessions > 0) return;
umbConfirmModal(this._host, {
headline: `Preview website?`,
content: `You have ended preview mode, do you want to enable it again to view the latest saved version of your website?`,
cancelLabel: 'View published version',
confirmLabel: 'Preview latest version',
})
.then(() => {
this.restartSession();
})
.catch(() => {
this.exitSession();
});
}
async exitPreview(sessions: number = 0) {
this.#setSessionCount(sessions);
// We are good to end preview mode.
if (sessions <= 0) {
await this.#documentPreviewRepository.exit();
}
if (this.#webSocket) {
this.#webSocket.close();
this.#webSocket = undefined;
}
const url = this.#previewUrl.getValue() as string;
window.location.replace(url);
}
async exitSession() {
let sessions = this.#getSessionCount();
sessions--;
this.exitPreview(sessions);
}
iframeLoaded(iframe: HTMLIFrameElement) {
if (!iframe) return;
this.#configureWebSocket();
this.#iframeReady.setValue(true);
}
getIFrameWrapper(): HTMLElement | undefined {
return this.getHostElement().shadowRoot?.querySelector('#wrapper') as HTMLElement;
}
openWebsite() {
const url = this.#previewUrl.getValue() as string;
window.open(url, '_blank');
}
// TODO: [LK] Figure out how to make `iframe.contentDocument` works, as it's not from SameOrigin.
reloadIFrame(iframe: HTMLIFrameElement) {
const document = iframe.contentDocument;
if (!document) return;
document.location.reload();
}
async restartSession() {
await this.#documentPreviewRepository.enter();
this.startSession();
}
startSession() {
let sessions = this.#getSessionCount();
sessions++;
this.#setSessionCount(sessions);
}
async updateIFrame(args?: { culture?: string; className?: string; height?: string; width?: string }) {
if (!args) return;
const wrapper = this.getIFrameWrapper();
if (!wrapper) return;
const scaleIFrame = () => {
if (wrapper.className === 'fullsize') {
wrapper.style.transform = '';
} else {
const wScale = document.body.offsetWidth / (wrapper.offsetWidth + 30);
const hScale = document.body.offsetHeight / (wrapper.offsetHeight + 30);
const scale = Math.min(wScale, hScale, 1); // get the lowest ratio, but not higher than 1
wrapper.style.transform = `scale(${scale})`;
}
};
window.addEventListener('resize', scaleIFrame);
wrapper.addEventListener('transitionend', scaleIFrame);
if (args.culture) {
this.#iframeReady.setValue(false);
const params = new URLSearchParams(window.location.search);
params.set('culture', args.culture);
const newRelativePathQuery = window.location.pathname + '?' + params.toString();
history.pushState(null, '', newRelativePathQuery);
this.#setPreviewUrl({ culture: args.culture });
}
if (args.className) wrapper.className = args.className;
if (args.height) wrapper.style.height = args.height;
if (args.width) wrapper.style.width = args.width;
}
}
export const UMB_PREVIEW_CONTEXT = new UmbContextToken<UmbPreviewContext>('UmbPreviewContext');

View File

@@ -0,0 +1,204 @@
import { manifests as previewApps } from './apps/manifests.js';
import { UmbPreviewContext } from './preview.context.js';
import { css, customElement, html, nothing, state, when } from '@umbraco-cms/backoffice/external/lit';
import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
const elementName = 'umb-preview';
/**
* @element umb-preview
*/
@customElement(elementName)
export class UmbPreviewElement extends UmbLitElement {
#context = new UmbPreviewContext(this);
constructor() {
super();
if (previewApps?.length) {
umbExtensionsRegistry.registerMany(previewApps);
}
this.observe(this.#context.iframeReady, (iframeReady) => (this._iframeReady = iframeReady));
this.observe(this.#context.previewUrl, (previewUrl) => (this._previewUrl = previewUrl));
}
connectedCallback() {
super.connectedCallback();
this.addEventListener('visibilitychange', this.#onVisibilityChange);
window.addEventListener('beforeunload', () => this.#context.exitSession());
this.#context.startSession();
}
disconnectedCallback() {
super.disconnectedCallback();
this.removeEventListener('visibilitychange', this.#onVisibilityChange);
// NOTE: Unsure how we remove an anonymous function from 'beforeunload' event listener.
// The reason for the anonymous function is that if we used a named function,
// `this` would be the `window` and would not have context to the class instance. [LK]
//window.removeEventListener('beforeunload', () => this.#context.exitSession());
this.#context.exitSession();
}
@state()
private _iframeReady?: boolean;
@state()
private _previewUrl?: string;
#onIFrameLoad(event: Event & { target: HTMLIFrameElement }) {
this.#context.iframeLoaded(event.target);
}
#onVisibilityChange() {
this.#context.checkSession();
}
render() {
if (!this._previewUrl) return nothing;
return html`
${when(!this._iframeReady, () => html`<div id="loading"><uui-loader-circle></uui-loader-circle></div>`)}
<div id="wrapper">
<div id="container">
<iframe
src=${this._previewUrl}
title="Page preview"
@load=${this.#onIFrameLoad}
sandbox="allow-scripts"></iframe>
</div>
</div>
<div id="menu">
<h4>Preview Mode</h4>
<uui-button-group>
<umb-extension-slot id="apps" type="previewApp"></umb-extension-slot>
</uui-button-group>
</div>
`;
}
static styles = [
css`
:host {
display: flex;
justify-content: center;
align-items: center;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
padding-bottom: 40px;
}
#loading {
display: flex;
align-items: center;
justify-content: center;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
font-size: 6rem;
backdrop-filter: blur(5px);
}
#wrapper {
transition: all 240ms cubic-bezier(0.165, 0.84, 0.44, 1);
flex-shrink: 0;
height: 100%;
width: 100%;
}
#wrapper.fullsize {
margin: 0 auto;
overflow: hidden;
}
#wrapper.shadow {
margin: 10px auto;
background-color: white;
border-radius: 3px;
overflow: hidden;
opacity: 1;
box-shadow: 0 5px 20px 0 rgba(0, 0, 0, 0.26);
}
#container {
width: 100%;
height: 100%;
margin: 0 auto;
overflow: hidden;
}
#menu {
display: flex;
justify-content: space-between;
align-items: center;
position: absolute;
bottom: 0;
left: 0;
right: 0;
background-color: var(--uui-color-header-surface);
height: 40px;
animation: menu-bar-animation 1.2s;
animation-timing-function: cubic-bezier(0.23, 1, 0.32, 1);
}
#menu > h4 {
color: var(--uui-color-header-contrast-emphasis);
margin: 0;
padding: 0 15px;
}
#menu > uui-button-group {
height: 100%;
}
uui-icon.flip {
rotate: 90deg;
}
iframe {
border: 0;
top: 0;
right: 0;
bottom: 0;
left: 0;
width: 100%;
height: 100%;
overflow: hidden;
overflow-x: hidden;
overflow-y: hidden;
}
@keyframes menu-bar-animation {
0% {
bottom: -50px;
}
40% {
bottom: -50px;
}
80% {
bottom: 0px;
}
}
`,
];
}
export default UmbPreviewElement;
declare global {
interface HTMLElementTagNameMap {
[elementName]: UmbPreviewElement;
}
}

View File

@@ -0,0 +1,10 @@
import type { Meta } from '@storybook/web-components';
import { html } from '@umbraco-cms/backoffice/external/lit';
export default {
title: 'Apps/Preview',
component: 'umb-preview',
id: 'umb-preview',
} satisfies Meta;
export const Preview = () => html`<umb-preview></umb-preview>`;

View File

@@ -0,0 +1,14 @@
import { expect, fixture, html } from '@open-wc/testing';
import { UmbPreviewElement } from './preview.element.js';
describe('UmbPreview', () => {
let element: UmbPreviewElement;
beforeEach(async () => {
element = await fixture(html`<umb-preview></umb-preview>`);
});
it('is defined with its own instance', () => {
expect(element).to.be.instanceOf(UmbPreviewElement);
});
});

View File

@@ -33,6 +33,7 @@ import type { ManifestMenu } from './menu.model.js';
import type { ManifestMenuItem, ManifestMenuItemTreeKind } from './menu-item.model.js';
import type { ManifestModal } from './modal.model.js';
import type { ManifestPackageView } from './package-view.model.js';
import type { ManifestPreviewAppProvider } from './preview-app.model.js';
import type { ManifestPropertyAction, ManifestPropertyActionDefaultKind } from './property-action.model.js';
import type { ManifestPropertyEditorUi, ManifestPropertyEditorSchema } from './property-editor.model.js';
import type { ManifestRepository } from './repository.model.js';
@@ -97,6 +98,7 @@ export type * from './mfa-login-provider.model.js';
export type * from './modal.model.js';
export type * from './monaco-markdown-editor-action.model.js';
export type * from './package-view.model.js';
export type * from './preview-app.model.js';
export type * from './property-action.model.js';
export type * from './property-editor.model.js';
export type * from './repository.model.js';
@@ -183,6 +185,7 @@ export type ManifestTypes =
| ManifestModal
| ManifestMonacoMarkdownEditorAction
| ManifestPackageView
| ManifestPreviewAppProvider
| ManifestPropertyActions
| ManifestPropertyEditorSchema
| ManifestPropertyEditorUi

View File

@@ -0,0 +1,8 @@
import type { ManifestElement } from '@umbraco-cms/backoffice/extension-api';
/**
* Preview apps are displayed in the menu of the preview window.
*/
export interface ManifestPreviewAppProvider extends ManifestElement {
type: 'previewApp';
}

View File

@@ -1,5 +1,6 @@
export { UmbDocumentDetailRepository, UMB_DOCUMENT_DETAIL_REPOSITORY_ALIAS } from './detail/index.js';
export { UmbDocumentItemRepository, UMB_DOCUMENT_ITEM_REPOSITORY_ALIAS } from './item/index.js';
export { UmbDocumentPublishingRepository, UMB_DOCUMENT_PUBLISHING_REPOSITORY_ALIAS } from './publishing/index.js';
export { UmbDocumentPreviewRepository } from './preview/index.js';
export type { UmbDocumentItemModel } from './item/types.js';

View File

@@ -0,0 +1,30 @@
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
import { UmbRepositoryBase } from '@umbraco-cms/backoffice/repository';
import { PreviewService } from '@umbraco-cms/backoffice/external/backend-api';
import { tryExecute } from '@umbraco-cms/backoffice/resources';
export class UmbDocumentPreviewRepository extends UmbRepositoryBase {
constructor(host: UmbControllerHost) {
super(host);
}
/**
* Enters preview mode.
* @return {Promise<void>}
* @memberof UmbDocumentPreviewRepository
*/
async enter(): Promise<void> {
await tryExecute(PreviewService.postPreview());
return;
}
/**
* Exits preview mode.
* @return {Promise<void>}
* @memberof UmbDocumentPreviewRepository
*/
async exit(): Promise<void> {
await tryExecute(PreviewService.deletePreview());
return;
}
}

View File

@@ -0,0 +1 @@
export { UmbDocumentPreviewRepository } from './document-preview.repository.js';

View File

@@ -1,8 +1,13 @@
import { UmbDocumentUserPermissionCondition } from '../../user-permissions/document-user-permission.condition.js';
import { UMB_DOCUMENT_WORKSPACE_CONTEXT } from '../document-workspace.context-token.js';
import { UMB_USER_PERMISSION_DOCUMENT_UPDATE } from '../../user-permissions/index.js';
import { UmbWorkspaceActionBase } from '@umbraco-cms/backoffice/workspace';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
// TODO: Investigate how additional preview environments can be supported. [LK:2024-05-16]
// https://docs.umbraco.com/umbraco-cms/reference/content-delivery-api/additional-preview-environments-support
// In v13, they are registered on the server using `SendingContentNotification`, which is no longer available in v14.
export class UmbDocumentSaveAndPreviewWorkspaceAction extends UmbWorkspaceActionBase {
constructor(host: UmbControllerHost, args: any) {
super(host, args);
@@ -24,7 +29,8 @@ export class UmbDocumentSaveAndPreviewWorkspaceAction extends UmbWorkspaceAction
}
async execute() {
alert('Save and preview');
const workspaceContext = await this.getContext(UMB_DOCUMENT_WORKSPACE_CONTEXT);
workspaceContext.saveAndPreview();
}
}

View File

@@ -24,6 +24,7 @@ import {
UMB_EDIT_DOCUMENT_WORKSPACE_PATH_PATTERN,
} from '../paths.js';
import { UMB_DOCUMENTS_SECTION_PATH } from '../../paths.js';
import { UmbDocumentPreviewRepository } from '../repository/preview/index.js';
import { UMB_DOCUMENT_WORKSPACE_ALIAS } from './manifests.js';
import { UmbEntityContext } from '@umbraco-cms/backoffice/entity';
import { UMB_INVARIANT_CULTURE, UmbVariantId } from '@umbraco-cms/backoffice/variant';
@@ -604,6 +605,28 @@ export class UmbDocumentWorkspaceContext
}
}
async #handleSaveAndPreview() {
const unique = this.getUnique();
if (!unique) throw new Error('Unique is missing');
let culture = UMB_INVARIANT_CULTURE;
// Save document (the active variant) before previewing.
const { selected } = await this.#determineVariantOptions();
if (selected.length > 0) {
culture = selected[0];
const variantId = UmbVariantId.FromString(culture);
const saveData = this.#buildSaveData([variantId]);
await this.#performSaveOrCreate(saveData);
}
// Tell the server that we're entering preview mode.
await new UmbDocumentPreviewRepository(this).enter();
const preview = window.open(`preview?id=${unique}&culture=${culture}`, 'umbpreview');
preview?.focus();
}
async #handleSaveAndPublish() {
const unique = this.getUnique();
if (!unique) throw new Error('Unique is missing');
@@ -672,6 +695,7 @@ export class UmbDocumentWorkspaceContext
},
);
}
async #performSaveAndPublish(variantIds: Array<UmbVariantId>, saveData: UmbDocumentDetailModel): Promise<void> {
const unique = this.getUnique();
if (!unique) throw new Error('Unique is missing');
@@ -732,6 +756,10 @@ export class UmbDocumentWorkspaceContext
throw new Error('Method not implemented.');
}
public async saveAndPreview(): Promise<void> {
return this.#handleSaveAndPreview();
}
public async saveAndPublish(): Promise<void> {
return this.#handleSaveAndPublish();
}