diff --git a/packages/api-headless-cms/src/features/contentEntry/PublishEntry/PublishEntryUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/PublishEntry/PublishEntryUseCase.ts index e4d9343c5fa..83a050758b4 100644 --- a/packages/api-headless-cms/src/features/contentEntry/PublishEntry/PublishEntryUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntry/PublishEntry/PublishEntryUseCase.ts @@ -1,7 +1,10 @@ import { Result } from "@webiny/feature/api"; import { createImplementation } from "@webiny/feature/api"; import { EntryEventPublisher } from "~/features/contentEntry/EntryEventPublisher/index.js"; -import { PublishEntryUseCase as UseCaseAbstraction } from "./abstractions.js"; +import { + PublishEntryUseCase as UseCaseAbstraction, + type IPublishEntryUseCaseOptions +} from "./abstractions.js"; import { PublishEntryRepository } from "./abstractions.js"; import { AccessControl } from "~/features/shared/abstractions.js"; import { GetRevisionByIdUseCase } from "~/features/contentEntry/GetRevisionById/index.js"; @@ -25,7 +28,8 @@ class PublishEntryUseCaseImpl implements UseCaseAbstraction.Interface { async execute( model: CmsModel, - id: string + id: string, + options?: IPublishEntryUseCaseOptions ): Promise, UseCaseAbstraction.Error>> { const canAccess = await this.accessControl.canAccessEntry({ model, pw: "p" }); if (!canAccess) { @@ -50,7 +54,10 @@ class PublishEntryUseCaseImpl implements UseCaseAbstraction.Interface { return Result.fail(EntryNotAuthorizedError.fromModel(model)); } - const dataResult = await this.createPublishEntryDataFactory.create(model, originalEntry); + const source = options?.values + ? { ...originalEntry, values: options.values } + : originalEntry; + const dataResult = await this.createPublishEntryDataFactory.create(model, source); if (dataResult.isFail()) { return Result.fail(dataResult.error); } diff --git a/packages/api-headless-cms/src/features/contentEntry/PublishEntry/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/PublishEntry/abstractions.ts index c93f3c5e567..760a2408f14 100644 --- a/packages/api-headless-cms/src/features/contentEntry/PublishEntry/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/PublishEntry/abstractions.ts @@ -10,10 +10,19 @@ import type { /** * PublishEntry Use Case - Publishes an entry revision. */ +export interface IPublishEntryUseCaseOptions { + /** + * Values to publish instead of the revision's stored values. They go through the same + * validation as stored values do. + */ + values?: T; +} + export interface IPublishEntryUseCase { execute( model: CmsModel, - id: string + id: string, + options?: IPublishEntryUseCaseOptions ): Promise, UseCaseError>>; } diff --git a/packages/api-website-builder-workflows/src/features/PageWorkflows/handlers/ValidateWorkflowStateOnPageBeforePublish.ts b/packages/api-website-builder-workflows/src/features/PageWorkflows/handlers/ValidateWorkflowStateOnPageBeforePublish.ts index 46c1055c7fb..941e236486c 100644 --- a/packages/api-website-builder-workflows/src/features/PageWorkflows/handlers/ValidateWorkflowStateOnPageBeforePublish.ts +++ b/packages/api-website-builder-workflows/src/features/PageWorkflows/handlers/ValidateWorkflowStateOnPageBeforePublish.ts @@ -9,7 +9,7 @@ class ValidateWorkflowStateOnPageBeforePublishImpl constructor(private getTargetState: GetTargetWorkflowStateUseCase.Interface) {} async handle(event: PageBeforePublishEventHandler.Event): Promise { - const { page } = event.payload; + const { original: page } = event.payload; const stateResult = await this.getTargetState.execute({ app: WB_PAGE_APP, diff --git a/packages/api-website-builder/__tests__/pages.beforeEvents.test.ts b/packages/api-website-builder/__tests__/pages.beforeEvents.test.ts new file mode 100644 index 00000000000..283c5b74cd0 --- /dev/null +++ b/packages/api-website-builder/__tests__/pages.beforeEvents.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from "vitest"; +import { createContextPlugin } from "@webiny/api"; +import { useHandler } from "./utils/useHandler.js"; +import { pageMocks } from "./mocks/page.mock.js"; +import { + CreatePageUseCase, + PageBeforeCreateEventHandler +} from "~/features/pages/CreatePage/index.js"; +import { + PageBeforeUpdateEventHandler, + UpdatePageUseCase +} from "~/features/pages/UpdatePage/index.js"; +import { GetPageByIdUseCase } from "~/features/pages/GetPageById/index.js"; +import { + PageBeforePublishEventHandler, + PublishPageUseCase +} from "~/features/pages/PublishPage/index.js"; +import { + CreatePageRevisionFromUseCase, + PageBeforeCreateRevisionFromEventHandler +} from "~/features/pages/CreatePageRevisionFrom/index.js"; + +/* + * "Before" event handlers run after authorization and before the write. Each event carries + * `page`, which is what gets saved, next to `original` (the stored revision) and `input` (the + * request) where those exist. Handlers edit `page` in place, as with CMS and File Manager + * events. Throwing stops the write. + */ +const setup = async (register: Parameters[0]) => { + const handler = useHandler({ plugins: [createContextPlugin(register)] }); + const context = await handler.handler(); + + const createResult = await context.container + .resolve(CreatePageUseCase) + .execute(pageMocks.pageA); + if (createResult.isFail()) { + throw createResult.error; + } + + const getStored = async (id: string) => { + const result = await context.container.resolve(GetPageByIdUseCase).execute(id); + if (result.isFail()) { + throw result.error; + } + return result.value; + }; + + return { context, page: createResult.value, getStored }; +}; + +describe("Page before-event handlers", () => { + it("creates the page a page.beforeCreate handler edited", async () => { + const { page, getStored } = await setup(context => { + context.container.registerFactory(PageBeforeCreateEventHandler, () => ({ + async handle(event) { + const { page } = event.payload; + page.properties.title = "Changed before create"; + } + })); + }); + + expect(page.properties.title).toBe("Changed before create"); + expect((await getStored(page.id)).properties.title).toBe("Changed before create"); + }); + + it("saves the page a page.beforeUpdate handler edited", async () => { + const { context, page, getStored } = await setup(context => { + context.container.registerFactory(PageBeforeUpdateEventHandler, () => ({ + async handle(event) { + const { original, input, page } = event.payload; + expect(original.properties.title).toBe("Page A"); + expect(input.data.properties?.title).toBe("Typed by the user"); + // `page` is `original` with the input applied. + expect(page.properties.title).toBe("Typed by the user"); + page.properties.title = "Changed before update"; + // `page` is a copy, so neither `original` nor `input` changes with it. + expect(original.properties.title).toBe("Page A"); + expect(input.data.properties?.title).toBe("Typed by the user"); + } + })); + }); + + const result = await context.container.resolve(UpdatePageUseCase).execute(page.id, { + properties: { ...page.properties, title: "Typed by the user" } + }); + if (result.isFail()) { + throw result.error; + } + + expect(result.value.properties.title).toBe("Changed before update"); + expect((await getStored(page.id)).properties.title).toBe("Changed before update"); + }); + + it("publishes the page a page.beforePublish handler edited", async () => { + const { context, page, getStored } = await setup(context => { + context.container.registerFactory(PageBeforePublishEventHandler, () => ({ + async handle(event) { + const { original, page } = event.payload; + expect(page.id).toBe(original.id); + page.properties.title = "Changed before publish"; + } + })); + }); + + const result = await context.container.resolve(PublishPageUseCase).execute({ id: page.id }); + if (result.isFail()) { + throw result.error; + } + + expect(result.value.status).toBe("published"); + expect(result.value.properties.title).toBe("Changed before publish"); + + const stored = await getStored(page.id); + expect(stored.status).toBe("published"); + expect(stored.properties.title).toBe("Changed before publish"); + }); + + it("does not publish when a page.beforePublish handler throws", async () => { + const { context, page, getStored } = await setup(context => { + context.container.registerFactory(PageBeforePublishEventHandler, () => ({ + async handle() { + throw new Error("Page is not ready to be published."); + } + })); + }); + + await expect( + context.container.resolve(PublishPageUseCase).execute({ id: page.id }) + ).rejects.toThrow("Page is not ready to be published."); + + expect((await getStored(page.id)).status).toBe("draft"); + }); + + it("creates the revision from the page a page.beforeCreateRevisionFrom handler edited", async () => { + const { context, page, getStored } = await setup(context => { + context.container.registerFactory(PageBeforeCreateRevisionFromEventHandler, () => ({ + async handle(event) { + const { original, page, params } = event.payload; + expect(original.id).toBe(params.id); + page.properties.title = "Changed in new revision"; + } + })); + }); + + const result = await context.container + .resolve(CreatePageRevisionFromUseCase) + .execute({ id: page.id }); + if (result.isFail()) { + throw result.error; + } + + expect(result.value.version).toBe(2); + expect(result.value.properties.title).toBe("Changed in new revision"); + + // The source revision is left as it was. + expect((await getStored(page.id)).properties.title).toBe("Page A"); + }); + + it("does not create a revision when a page.beforeCreateRevisionFrom handler throws", async () => { + const { context, page } = await setup(context => { + context.container.registerFactory(PageBeforeCreateRevisionFromEventHandler, () => ({ + async handle() { + throw new Error("No new revisions for this page."); + } + })); + }); + + await expect( + context.container.resolve(CreatePageRevisionFromUseCase).execute({ id: page.id }) + ).rejects.toThrow("No new revisions for this page."); + }); +}); diff --git a/packages/api-website-builder/src/domain/page/PageToEntryValuesMapper.ts b/packages/api-website-builder/src/domain/page/PageToEntryValuesMapper.ts new file mode 100644 index 00000000000..900805d2557 --- /dev/null +++ b/packages/api-website-builder/src/domain/page/PageToEntryValuesMapper.ts @@ -0,0 +1,17 @@ +import type { CmsEntryWbPage, WbPage } from "~/domain/page/abstractions.js"; + +/** + * The page fields stored as CMS entry values. Used to save a page that a "before" event + * handler may have changed. + */ +export class PageToEntryValuesMapper { + static toValues(page: WbPage): CmsEntryWbPage { + return { + properties: page.properties, + metadata: page.metadata, + bindings: page.bindings, + elements: page.elements, + extensions: page.extensions + }; + } +} diff --git a/packages/api-website-builder/src/features/pages/CreatePage/CreatePageUseCase.ts b/packages/api-website-builder/src/features/pages/CreatePage/CreatePageUseCase.ts index b6a2d3ad28a..b13c5ebdb8d 100644 --- a/packages/api-website-builder/src/features/pages/CreatePage/CreatePageUseCase.ts +++ b/packages/api-website-builder/src/features/pages/CreatePage/CreatePageUseCase.ts @@ -21,13 +21,13 @@ class CreatePageUseCaseImpl implements UseCaseAbstraction.Interface { return Result.fail(new PageNotAuthorizedError()); } - // Publish before create event - const beforeCreateEvent = new PageBeforeCreateEvent({ input: data }); + // Publish before create event. Handlers can edit `page`, and that object is what we save. + const pageToCreate = structuredClone(data); + const beforeCreateEvent = new PageBeforeCreateEvent({ input: data, page: pageToCreate }); await this.eventPublisher.publish(beforeCreateEvent); - // Execute the create operation - const result = await this.repository.execute(data); + const result = await this.repository.execute(pageToCreate); if (result.isFail()) { return result; diff --git a/packages/api-website-builder/src/features/pages/CreatePage/abstractions.ts b/packages/api-website-builder/src/features/pages/CreatePage/abstractions.ts index 072b8925fc0..bde4496d52e 100644 --- a/packages/api-website-builder/src/features/pages/CreatePage/abstractions.ts +++ b/packages/api-website-builder/src/features/pages/CreatePage/abstractions.ts @@ -73,7 +73,10 @@ export namespace CreatePageUseCase { // ============================================================================ export interface PageBeforeCreatePayload { + /** The create request, as received. */ input: ICreateWbPageParams; + /** What gets created. Starts as a copy of `input`; handlers can edit it. */ + page: ICreateWbPageParams; } export interface PageAfterCreatePayload { diff --git a/packages/api-website-builder/src/features/pages/CreatePageRevisionFrom/CreatePageRevisionFromRepository.ts b/packages/api-website-builder/src/features/pages/CreatePageRevisionFrom/CreatePageRevisionFromRepository.ts index d1df2c77f7a..fa80e897c4b 100644 --- a/packages/api-website-builder/src/features/pages/CreatePageRevisionFrom/CreatePageRevisionFromRepository.ts +++ b/packages/api-website-builder/src/features/pages/CreatePageRevisionFrom/CreatePageRevisionFromRepository.ts @@ -31,7 +31,7 @@ class CreatePageRevisionFromRepositoryImpl implements RepositoryAbstraction.Inte // Create revision from the existing page const result = await this.createRevisionFrom.execute(this.pageModel, params.id, { location: getResult.value.location, - values: {} + values: params.values }); if (result.isFail()) { diff --git a/packages/api-website-builder/src/features/pages/CreatePageRevisionFrom/CreatePageRevisionFromUseCase.ts b/packages/api-website-builder/src/features/pages/CreatePageRevisionFrom/CreatePageRevisionFromUseCase.ts index b4955a4d806..d14839de68d 100644 --- a/packages/api-website-builder/src/features/pages/CreatePageRevisionFrom/CreatePageRevisionFromUseCase.ts +++ b/packages/api-website-builder/src/features/pages/CreatePageRevisionFrom/CreatePageRevisionFromUseCase.ts @@ -11,6 +11,7 @@ import { PageBeforeCreateRevisionFromEvent, PageAfterCreateRevisionFromEvent } f import { GetPageByIdUseCase } from "~/features/pages/GetPageById/index.js"; import { WbPermissions } from "~/features/permissions/abstractions.js"; import { PageNotAuthorizedError } from "~/domain/page/errors.js"; +import { PageToEntryValuesMapper } from "~/domain/page/PageToEntryValuesMapper.js"; class CreatePageRevisionFromUseCaseImpl implements UseCaseAbstraction.Interface { constructor( @@ -32,15 +33,21 @@ class CreatePageRevisionFromUseCaseImpl implements UseCaseAbstraction.Interface return getResult; } - // Publish before event + // Publish before event. Handlers can edit `page`, and that object is what the revision is created from. + const original = getResult.value; + const pageToCreate = structuredClone(original); const beforeEvent = new PageBeforeCreateRevisionFromEvent({ - params + params, + original, + page: pageToCreate }); await this.eventPublisher.publish(beforeEvent); - // Execute the create revision operation - const result = await this.repository.execute(params); + const result = await this.repository.execute({ + id: params.id, + values: PageToEntryValuesMapper.toValues(pageToCreate) + }); if (result.isFail()) { return result; diff --git a/packages/api-website-builder/src/features/pages/CreatePageRevisionFrom/abstractions.ts b/packages/api-website-builder/src/features/pages/CreatePageRevisionFrom/abstractions.ts index 33e99a317aa..1794890abf2 100644 --- a/packages/api-website-builder/src/features/pages/CreatePageRevisionFrom/abstractions.ts +++ b/packages/api-website-builder/src/features/pages/CreatePageRevisionFrom/abstractions.ts @@ -1,7 +1,7 @@ import { createAbstraction, type Result } from "@webiny/feature/api"; import type { IEventHandler } from "@webiny/api-core/features/eventPublisher/index.js"; import type { DomainEvent } from "@webiny/api-core/features/eventPublisher/index.js"; -import type { WbPage } from "~/domain/page/abstractions.js"; +import type { CmsEntryWbPage, WbPage } from "~/domain/page/abstractions.js"; import { PageNotFoundError, PagePersistenceError, @@ -21,8 +21,18 @@ export interface ICreateWbPageRevisionFromParams { // Repository Abstraction // ============================================================================ +export interface ICreatePageRevisionFromRepositoryParams extends ICreateWbPageRevisionFromParams { + /** + * The values to create the new revision with. They can differ from the source revision's + * after `page.beforeCreateRevisionFrom`. + */ + values: CmsEntryWbPage; +} + export interface ICreatePageRevisionFromRepository { - execute(params: ICreateWbPageRevisionFromParams): Promise>; + execute( + params: ICreatePageRevisionFromRepositoryParams + ): Promise>; } export interface ICreatePageRevisionFromRepositoryErrors { @@ -39,7 +49,7 @@ export const CreatePageRevisionFromRepository = export namespace CreatePageRevisionFromRepository { export type Interface = ICreatePageRevisionFromRepository; - export type Params = ICreateWbPageRevisionFromParams; + export type Params = ICreatePageRevisionFromRepositoryParams; export type Return = Promise>; export type Error = RepositoryError; } @@ -81,6 +91,12 @@ export namespace CreatePageRevisionFromUseCase { export interface PageBeforeCreateRevisionFromPayload { params: ICreateWbPageRevisionFromParams; + /** The revision the new one is created from. */ + original: WbPage; + /** + * What the new revision is created with. Starts as a copy of `original`; handlers can edit it. + */ + page: WbPage; } export interface PageAfterCreateRevisionFromPayload { diff --git a/packages/api-website-builder/src/features/pages/PublishPage/PublishPageRepository.ts b/packages/api-website-builder/src/features/pages/PublishPage/PublishPageRepository.ts index d72c30f5c45..c4a88902fd0 100644 --- a/packages/api-website-builder/src/features/pages/PublishPage/PublishPageRepository.ts +++ b/packages/api-website-builder/src/features/pages/PublishPage/PublishPageRepository.ts @@ -25,7 +25,9 @@ class PublishPageRepositoryImpl implements RepositoryAbstraction.Interface { } // Publish the entry - const result = await this.publishEntry.execute(this.pageModel, params.id); + const result = await this.publishEntry.execute(this.pageModel, params.id, { + values: params.values + }); if (result.isFail()) { if (result.error.code === "Cms/Entry/NotFound") { diff --git a/packages/api-website-builder/src/features/pages/PublishPage/PublishPageUseCase.ts b/packages/api-website-builder/src/features/pages/PublishPage/PublishPageUseCase.ts index e245663cfec..a7b2f7e5ad2 100644 --- a/packages/api-website-builder/src/features/pages/PublishPage/PublishPageUseCase.ts +++ b/packages/api-website-builder/src/features/pages/PublishPage/PublishPageUseCase.ts @@ -8,6 +8,7 @@ import { PageBeforePublishEvent, PageAfterPublishEvent } from "./events.js"; import { GetPageByIdUseCase } from "~/features/pages/GetPageById/index.js"; import { WbPermissions } from "~/features/permissions/abstractions.js"; import { PageNotAuthorizedError } from "~/domain/page/errors.js"; +import { PageToEntryValuesMapper } from "~/domain/page/PageToEntryValuesMapper.js"; class PublishPageUseCaseImpl implements UseCaseAbstraction.Interface { constructor( @@ -37,15 +38,19 @@ class PublishPageUseCaseImpl implements UseCaseAbstraction.Interface { return Result.fail(new PageNotAuthorizedError()); } - // Publish before publish event + // Publish before publish event. Handlers can edit `page`, and that object is what we publish. + const pageToPublish = structuredClone(page); const beforeEvent = new PageBeforePublishEvent({ - page + original: page, + page: pageToPublish }); await this.eventPublisher.publish(beforeEvent); - // Execute the publish operation - const result = await this.repository.execute(params); + const result = await this.repository.execute({ + id: params.id, + values: PageToEntryValuesMapper.toValues(pageToPublish) + }); if (result.isFail()) { return result; diff --git a/packages/api-website-builder/src/features/pages/PublishPage/abstractions.ts b/packages/api-website-builder/src/features/pages/PublishPage/abstractions.ts index 348aa3b28bc..7b52811bd72 100644 --- a/packages/api-website-builder/src/features/pages/PublishPage/abstractions.ts +++ b/packages/api-website-builder/src/features/pages/PublishPage/abstractions.ts @@ -1,7 +1,7 @@ import { createAbstraction, type Result } from "@webiny/feature/api"; import type { IEventHandler } from "@webiny/api-core/features/eventPublisher/index.js"; import type { DomainEvent } from "@webiny/api-core/features/eventPublisher/index.js"; -import type { WbPage } from "~/domain/page/abstractions.js"; +import type { CmsEntryWbPage, WbPage } from "~/domain/page/abstractions.js"; import { PageNotFoundError, PagePersistenceError, @@ -16,12 +16,17 @@ export interface IPublishWbPageParams { id: string; } +export interface IPublishPageRepositoryParams extends IPublishWbPageParams { + /** The page values to publish. They can differ from the stored ones after `page.beforePublish`. */ + values: CmsEntryWbPage; +} + // ============================================================================ // Repository Abstraction // ============================================================================ export interface IPublishPageRepository { - execute(params: IPublishWbPageParams): Promise>; + execute(params: IPublishPageRepositoryParams): Promise>; } export interface IPublishPageRepositoryErrors { @@ -37,7 +42,7 @@ export const PublishPageRepository = createAbstraction( export namespace PublishPageRepository { export type Interface = IPublishPageRepository; - export type Params = IPublishWbPageParams; + export type Params = IPublishPageRepositoryParams; export type Return = Promise>; export type Error = RepositoryError; } @@ -74,6 +79,9 @@ export namespace PublishPageUseCase { // ============================================================================ export interface PageBeforePublishPayload { + /** The stored revision being published. */ + original: WbPage; + /** What gets published. Starts as a copy of `original`; handlers can edit it. */ page: WbPage; } diff --git a/packages/api-website-builder/src/features/pages/UpdatePage/UpdatePageUseCase.ts b/packages/api-website-builder/src/features/pages/UpdatePage/UpdatePageUseCase.ts index edc3cc1a853..5516c04276b 100644 --- a/packages/api-website-builder/src/features/pages/UpdatePage/UpdatePageUseCase.ts +++ b/packages/api-website-builder/src/features/pages/UpdatePage/UpdatePageUseCase.ts @@ -8,6 +8,21 @@ import { PageBeforeUpdateEvent, PageAfterUpdateEvent } from "./events.js"; import { GetPageByIdUseCase } from "~/features/pages/GetPageById/index.js"; import { WbPermissions } from "~/features/permissions/abstractions.js"; import { PageNotAuthorizedError } from "~/domain/page/errors.js"; +import { PageToEntryValuesMapper } from "~/domain/page/PageToEntryValuesMapper.js"; +import type { WbPage } from "~/domain/page/abstractions.js"; + +/** The page as it will be after the update. Fields the update leaves out keep their stored value. */ +const applyUpdate = (original: WbPage, data: UseCaseAbstraction.UpdateData): WbPage => { + return { + ...original, + location: data.location ?? original.location, + properties: (data.properties as WbPage["properties"] | undefined) ?? original.properties, + metadata: data.metadata ?? original.metadata, + bindings: data.bindings ?? original.bindings, + elements: data.elements ?? original.elements, + extensions: data.extensions ?? original.extensions + }; +}; class UpdatePageUseCaseImpl implements UseCaseAbstraction.Interface { constructor( @@ -37,16 +52,21 @@ class UpdatePageUseCaseImpl implements UseCaseAbstraction.Interface { return Result.fail(new PageNotAuthorizedError()); } - // Publish before update event + // Publish before update event. Handlers can edit `page`, and that object is what we save. + const page = structuredClone(applyUpdate(original, data)); const beforeEvent = new PageBeforeUpdateEvent({ original, - input: { id, data } + input: { id, data }, + page }); await this.eventPublisher.publish(beforeEvent); - // Execute the update operation - const result = await this.repository.execute(id, data); + const result = await this.repository.execute(id, { + ...PageToEntryValuesMapper.toValues(page), + location: page.location, + system: data.system + }); if (result.isFail()) { return result; diff --git a/packages/api-website-builder/src/features/pages/UpdatePage/abstractions.ts b/packages/api-website-builder/src/features/pages/UpdatePage/abstractions.ts index 5ff0c0dcc28..2b591fbaa91 100644 --- a/packages/api-website-builder/src/features/pages/UpdatePage/abstractions.ts +++ b/packages/api-website-builder/src/features/pages/UpdatePage/abstractions.ts @@ -20,6 +20,7 @@ export interface IUpdateWbPageData { metadata?: Record; bindings?: Record; elements?: Record; + extensions?: Record; system?: ICmsEntrySystem; } @@ -82,11 +83,17 @@ export namespace UpdatePageUseCase { // ============================================================================ export interface PageBeforeUpdatePayload { + /** The stored revision being updated. */ original: WbPage; + /** The update request, as received. */ input: { id: string; data: IUpdateWbPageData; }; + /** + * What gets saved: `original` with `input.data` applied. Handlers can edit it. + */ + page: WbPage; } export interface PageAfterUpdatePayload {