Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
b936ad6
feat(website-builder): A/B testing for the Website Builder
SvenAlHamad Jun 30, 2026
6495966
feat(website-builder): admin UI for A/B testing
SvenAlHamad Jun 30, 2026
691a292
fix(website-builder): correct experiment/variant id handling in A/B t…
SvenAlHamad Jun 30, 2026
e810fec
feat(website-builder): variant content editing in the A/B testing UI
SvenAlHamad Jun 30, 2026
77a6964
fix(website-builder): make the variant editor canvas render live in t…
SvenAlHamad Jun 30, 2026
0898f39
feat(website-builder): gate A/B variants behind page publish + approval
SvenAlHamad Jul 1, 2026
8cb8ab9
feat(website-builder): Experiments button + empty-state drawer (UI st…
SvenAlHamad Jul 1, 2026
bdbb92b
fix(website-builder): place the Experiments button before Publish
SvenAlHamad Jul 1, 2026
91f69fc
feat(website-builder): New experiment form (UI step 2)
SvenAlHamad Jul 1, 2026
298c263
feat(website-builder): editable variant names + optional descriptions
SvenAlHamad Jul 1, 2026
5ceac20
feat(website-builder): per-variant key + labelled variant fields
SvenAlHamad Jul 1, 2026
a255ba0
feat(website-builder): Experiments switcher in the top bar (UI step)
SvenAlHamad Jul 1, 2026
39cebb8
feat(website-builder): persist experiments + manage/edit drawer
SvenAlHamad Jul 1, 2026
1771d5f
feat(website-builder): in-preview experiment toolbar
SvenAlHamad Jul 1, 2026
5f7d425
feat(website-builder): experiment list with activate/deactivate/delete
SvenAlHamad Jul 1, 2026
6ef1a82
fix(website-builder): match experiment card button icon colours to la…
SvenAlHamad Jul 1, 2026
a3bd580
feat(website-builder): edit variants in the page editor canvas
SvenAlHamad Jul 1, 2026
344c41b
fix(website-builder): resolve variant preview base by the page id
SvenAlHamad Jul 1, 2026
13b4a03
feat(website-builder): preview a variant's draft in a new tab
SvenAlHamad Jul 1, 2026
15bb352
refactor(website-builder): govern experiments & variants by the page …
SvenAlHamad Jul 1, 2026
fba1786
feat(website-builder-sdk): first-class experiment serving + Next midd…
SvenAlHamad Jul 1, 2026
46951fe
fix(website-builder): experiment form alignment + consistent variant …
SvenAlHamad Jul 1, 2026
783cfad
fix(website-builder): drop chevron from the empty-state Experiments b…
SvenAlHamad Jul 1, 2026
29d3861
feat(website-builder): published-page experiment indicator + kill-switch
SvenAlHamad Jul 1, 2026
8d3f9a8
refactor(api-website-builder): address PR review — no null from use c…
SvenAlHamad Jul 3, 2026
783ace2
refactor(website-builder): address Bruno review — model ids + slug lib
SvenAlHamad Jul 6, 2026
d547cbc
refactor(api-website-builder): one abstraction per file (PR review)
SvenAlHamad Jul 6, 2026
ff6d7ed
refactor(app-website-builder): experiments admin as presenters (PR re…
SvenAlHamad Jul 6, 2026
cf58418
refactor(app-website-builder): tighten experiments views to webhooks …
SvenAlHamad Jul 6, 2026
affabb1
merge: release/6.5.0 into feat/wb-ab-testing
SvenAlHamad Jul 16, 2026
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
@@ -0,0 +1,34 @@
import type { CmsEntry } from "@webiny/api-headless-cms/types";
import type {
CmsEntryWbExperimentValues,
ExperimentStatus,
WbExperiment
} from "~/domain/experiment/abstractions.js";

export class EntryToExperimentMapper {
static toExperiment(entry: CmsEntry<CmsEntryWbExperimentValues>): WbExperiment {
const values = entry.values;
return {
id: entry.id,
entryId: entry.entryId,
version: entry.version,
locked: entry.locked,
createdOn: entry.createdOn,
createdBy: entry.createdBy,
savedOn: entry.savedOn,
savedBy: entry.savedBy,
tenant: entry.tenant,
pageEntryId: values.pageEntryId,
baselineRevisionId: values.baselineRevisionId,
status: (values.status as ExperimentStatus) || "draft",
name: values.name || "",
trafficSplit: values.trafficSplit ?? { control: 100, variants: {} },
targeting: values.targeting ?? { trafficPercentage: 100 },
goals: values.goals ?? {},
analytics: values.analytics ?? { provider: "posthog" },
startedOn: values.startedOn ?? null,
stoppedOn: values.stoppedOn ?? null,
winningVariantId: values.winningVariantId ?? null
};
}
}
84 changes: 84 additions & 0 deletions packages/api-website-builder/src/domain/experiment/abstractions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { createAbstraction } from "@webiny/feature/api";
import type { CmsModel } from "@webiny/api-headless-cms/types/index.js";
import type { WbIdentity } from "~/domain/shared/abstractions.js";

/**
* The reserved variant id used for the control bucket. The control bucket renders the
* baseline revision directly, so it has no Variant object of its own.
*/
export const CONTROL_VARIANT_ID = "control";

export type ExperimentStatus = "draft" | "running" | "stopped" | "graduated";

export type DeviceType = "desktop" | "mobile" | "tablet";

/**
* Traffic split between the control bucket and each variant, expressed in whole percentages
* that sum to 100. Variants are keyed by their Variant entryId.
*/
export interface ExperimentTrafficSplit {
control: number;
variants: Record<string, number>;
}

export interface ExperimentTargeting {
/** Percentage (0-100) of eligible visitors entered into the experiment. */
trafficPercentage: number;
/** Optional ISO country codes the experiment is limited to. */
geo?: string[];
/** Optional device types the experiment is limited to. */
device?: DeviceType[];
}

/**
* Conversion goals. Opaque to Webiny — forwarded to the analytics provider as-is.
*/
export interface ExperimentGoals {
primaryMetric?: string;
[key: string]: unknown;
}

/**
* Provider-agnostic analytics configuration. No provider-specific field name may leak into
* the render path or the assignment logic — adapters read this and map it to their own shape.
*/
export interface ExperimentAnalyticsConfig {
provider: string;
[key: string]: unknown;
}

export interface CmsEntryWbExperimentValues {
pageEntryId: string;
baselineRevisionId: string;
status: ExperimentStatus;
name: string;
trafficSplit: ExperimentTrafficSplit;
targeting: ExperimentTargeting;
goals: ExperimentGoals;
analytics: ExperimentAnalyticsConfig;
startedOn: string | null;
stoppedOn: string | null;
winningVariantId: string | null;
}

export interface WbExperiment extends CmsEntryWbExperimentValues {
id: string;
entryId: string;
version: number;
locked: boolean;
createdOn: string;
createdBy: WbIdentity;
savedOn: string;
savedBy: WbIdentity;
tenant: string;
}

/**
* ExperimentModel abstraction - represents the Website Builder experiment CMS model.
* Registered via container.registerInstance in the composite feature.
*/
export const ExperimentModel = createAbstraction<CmsModel>("Wb/ExperimentModel");

export namespace ExperimentModel {
export type Interface = CmsModel;
}
79 changes: 79 additions & 0 deletions packages/api-website-builder/src/domain/experiment/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { BaseError } from "@webiny/feature/api";

export class ExperimentNotFoundError extends BaseError<{ id: string }> {
override readonly code = "WebsiteBuilder/Experiment/NotFound" as const;

constructor(id: string) {
super({
message: "Experiment not found!",
data: {
id
}
});
}
}

export class ExperimentPersistenceError extends BaseError {
override readonly code = "WebsiteBuilder/Experiment/PersistenceError" as const;

constructor(error: Error) {
super({ message: error.message });
}
}

export class ExperimentValidationError extends BaseError {
override readonly code = "WebsiteBuilder/Experiment/ValidationError" as const;

constructor(message: string) {
super({ message });
}
}

/**
* Raised when starting an experiment while another experiment is already running on the
* same revision. v1 allows only one active experiment per revision.
*/
export class ExperimentAlreadyActiveError extends BaseError<{ revisionId: string }> {
override readonly code = "WebsiteBuilder/Experiment/AlreadyActive" as const;

constructor(revisionId: string) {
super({
message: "An experiment is already active on this revision.",
data: {
revisionId
}
});
}
}

export class ExperimentNotAuthorizedError extends BaseError {
override readonly code = "WebsiteBuilder/Experiment/NotAuthorized" as const;

constructor() {
super({ message: "Not authorized!" });
}
}

/** Raised when there is no active (published, running) experiment for a path or revision. */
export class NoActiveExperimentError extends BaseError<{ reference: string }> {
override readonly code = "WebsiteBuilder/Experiment/NoActiveExperiment" as const;

constructor(reference: string) {
super({
message: "No active experiment found.",
data: { reference }
});
}
}

/** Raised when the active experiment is paused (kill-switch); serving falls back to the control. */
export class ExperimentPausedError extends BaseError<{ id: string }> {
override readonly code = "WebsiteBuilder/Experiment/Paused" as const;

constructor(id: string) {
super({
message: "The experiment is paused.",
data: { id }
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { ModelFactory } from "@webiny/api-headless-cms/features/modelBuilder/index.js";

export const EXPERIMENT_MODEL_ID = "wbyWbExperiment";

class ExperimentModelFactory implements ModelFactory.Interface {
async execute(builder: ModelFactory.Builder) {
const model = builder.private({
modelId: EXPERIMENT_MODEL_ID,
name: "Website Builder - Experiment"
});

model.fields(fields => ({
Comment thread
SvenAlHamad marked this conversation as resolved.
// The page (CMS entryId) this experiment belongs to.
pageEntryId: fields.text().label("Page entry ID"),
// The immutable, explicit baseline revision (CMS revision id) this experiment is pinned to.
baselineRevisionId: fields.text().label("Baseline revision ID"),
// Experiment lifecycle status: draft | running | stopped | graduated.
status: fields.text().label("Status"),
name: fields.text().label("Name"),
// Traffic split between the control bucket and each variant.
trafficSplit: fields.json().label("Traffic split"),
// Targeting rules: traffic percentage and optional geo / device.
targeting: fields.json().label("Targeting"),
// Conversion goals, opaque to Webiny and forwarded to the analytics provider.
goals: fields.json().label("Goals"),
// Provider-agnostic analytics configuration (e.g. provider id + experiment key).
analytics: fields.json().label("Analytics"),
startedOn: fields.datetime().label("Started on"),
stoppedOn: fields.datetime().label("Stopped on"),
// The variant graduated into a new revision when the experiment concluded.
winningVariantId: fields.text().label("Winning variant ID")
}));

return [model];
}
}

export const ExperimentModelPlugin = ModelFactory.createImplementation({
implementation: ExperimentModelFactory,
dependencies: []
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { CmsEntry } from "@webiny/api-headless-cms/types";
import type {
CmsEntryWbVariantValues,
VariantStatus,
WbVariant
} from "~/domain/variant/abstractions.js";

export class EntryToVariantMapper {
static toVariant(entry: CmsEntry<CmsEntryWbVariantValues>): WbVariant {
const values = entry.values;
return {
id: entry.id,
entryId: entry.entryId,
version: entry.version,
locked: entry.locked,
createdOn: entry.createdOn,
createdBy: entry.createdBy,
savedOn: entry.savedOn,
savedBy: entry.savedBy,
tenant: entry.tenant,
experimentId: values.experimentId,
name: values.name || "",
status: (values.status as VariantStatus) || "draft",
properties: values.properties ?? {},
metadata: values.metadata ?? {},
bindings: values.bindings ?? {},
elements: values.elements ?? {},
extensions: values.extensions ?? {}
};
}
}
45 changes: 45 additions & 0 deletions packages/api-website-builder/src/domain/variant/abstractions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { createAbstraction } from "@webiny/feature/api";
import type { CmsModel } from "@webiny/api-headless-cms/types/index.js";
import type { WbIdentity } from "~/domain/shared/abstractions.js";

export type VariantStatus = "draft" | "ready";

/**
* The content snapshot of a variant. Mirrors the page content fields exactly so a variant
* can be created as a copy of the baseline revision's content and then modified.
*/
export interface WbVariantContent {
properties: Record<string, any>;
metadata: Record<string, any>;
bindings: Record<string, any>;
elements: Record<string, any>;
extensions?: Record<string, any>;
}

export interface CmsEntryWbVariantValues extends WbVariantContent {
experimentId: string;
name: string;
status: VariantStatus;
}

export interface WbVariant extends CmsEntryWbVariantValues {
id: string;
entryId: string;
version: number;
locked: boolean;
createdOn: string;
createdBy: WbIdentity;
savedOn: string;
savedBy: WbIdentity;
tenant: string;
}

/**
* VariantModel abstraction - represents the Website Builder variant CMS model.
* Registered via container.registerInstance in the composite feature.
*/
export const VariantModel = createAbstraction<CmsModel>("Wb/VariantModel");

export namespace VariantModel {
export type Interface = CmsModel;
}
38 changes: 38 additions & 0 deletions packages/api-website-builder/src/domain/variant/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { BaseError } from "@webiny/feature/api";

export class VariantNotFoundError extends BaseError<{ id: string }> {
override readonly code = "WebsiteBuilder/Variant/NotFound" as const;

constructor(id: string) {
super({
message: "Variant not found!",
data: {
id
}
});
}
}

export class VariantPersistenceError extends BaseError {
override readonly code = "WebsiteBuilder/Variant/PersistenceError" as const;

constructor(error: Error) {
super({ message: error.message });
}
}

export class VariantValidationError extends BaseError {
override readonly code = "WebsiteBuilder/Variant/ValidationError" as const;

constructor(message: string) {
super({ message });
}
}

export class VariantNotAuthorizedError extends BaseError {
override readonly code = "WebsiteBuilder/Variant/NotAuthorized" as const;

constructor() {
super({ message: "Not authorized!" });
}
}
33 changes: 33 additions & 0 deletions packages/api-website-builder/src/domain/variant/variant.model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { ModelFactory } from "@webiny/api-headless-cms/features/modelBuilder/index.js";

export const VARIANT_MODEL_ID = "wbyWbVariant";

class VariantModelFactory implements ModelFactory.Interface {
async execute(builder: ModelFactory.Builder) {
const model = builder.private({
modelId: VARIANT_MODEL_ID,
name: "Website Builder - Variant"
});

model.fields(fields => ({
// The experiment (CMS entryId) this variant belongs to.
experimentId: fields.text().label("Experiment ID"),
name: fields.text().label("Name"),
// Variant lifecycle status: draft | ready.
status: fields.text().label("Status"),
// Full content snapshot — mirrors the page content fields. A variant is never a revision.
properties: fields.searchableJson().label("Properties"),
metadata: fields.searchableJson().label("Metadata"),
bindings: fields.json().label("Bindings"),
elements: fields.json().label("Elements"),
extensions: fields.searchableJson().label("Extensions")
}));

return [model];
}
}

export const VariantModelPlugin = ModelFactory.createImplementation({
implementation: VariantModelFactory,
dependencies: []
});
Loading