Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -25,7 +28,8 @@ class PublishEntryUseCaseImpl implements UseCaseAbstraction.Interface {

async execute<T extends CmsEntryValues = CmsEntryValues>(
model: CmsModel,
id: string
id: string,
options?: IPublishEntryUseCaseOptions<T>
): Promise<Result<CmsEntry<T>, UseCaseAbstraction.Error>> {
const canAccess = await this.accessControl.canAccessEntry({ model, pw: "p" });
if (!canAccess) {
Expand All @@ -50,7 +54,10 @@ class PublishEntryUseCaseImpl implements UseCaseAbstraction.Interface {
return Result.fail(EntryNotAuthorizedError.fromModel(model));
}

const dataResult = await this.createPublishEntryDataFactory.create<T>(model, originalEntry);
const source = options?.values
? { ...originalEntry, values: options.values }
: originalEntry;
const dataResult = await this.createPublishEntryDataFactory.create<T>(model, source);
if (dataResult.isFail()) {
return Result.fail(dataResult.error);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,19 @@ import type {
/**
* PublishEntry Use Case - Publishes an entry revision.
*/
export interface IPublishEntryUseCaseOptions<T extends CmsEntryValues = CmsEntryValues> {
/**
* 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<T extends CmsEntryValues = CmsEntryValues>(
model: CmsModel,
id: string
id: string,
options?: IPublishEntryUseCaseOptions<T>
): Promise<Result<CmsEntry<T>, UseCaseError>>;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ class ValidateWorkflowStateOnPageBeforePublishImpl
constructor(private getTargetState: GetTargetWorkflowStateUseCase.Interface) {}

async handle(event: PageBeforePublishEventHandler.Event): Promise<void> {
const { page } = event.payload;
const { original: page } = event.payload;

const stateResult = await this.getTargetState.execute({
app: WB_PAGE_APP,
Expand Down
172 changes: 172 additions & 0 deletions packages/api-website-builder/__tests__/pages.beforeEvents.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createContextPlugin>[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.");
});
});
Original file line number Diff line number Diff line change
@@ -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
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<Result<WbPage, RepositoryError>>;
execute(
params: ICreatePageRevisionFromRepositoryParams
): Promise<Result<WbPage, RepositoryError>>;
}

export interface ICreatePageRevisionFromRepositoryErrors {
Expand All @@ -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<Result<WbPage, RepositoryError>>;
export type Error = RepositoryError;
}
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down
Loading
Loading