-
Notifications
You must be signed in to change notification settings - Fork 675
Feat/wb ab testing #5349
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Feat/wb ab testing #5349
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 6495966
feat(website-builder): admin UI for A/B testing
SvenAlHamad 691a292
fix(website-builder): correct experiment/variant id handling in A/B t…
SvenAlHamad e810fec
feat(website-builder): variant content editing in the A/B testing UI
SvenAlHamad 77a6964
fix(website-builder): make the variant editor canvas render live in t…
SvenAlHamad 0898f39
feat(website-builder): gate A/B variants behind page publish + approval
SvenAlHamad 8cb8ab9
feat(website-builder): Experiments button + empty-state drawer (UI st…
SvenAlHamad bdbb92b
fix(website-builder): place the Experiments button before Publish
SvenAlHamad 91f69fc
feat(website-builder): New experiment form (UI step 2)
SvenAlHamad 298c263
feat(website-builder): editable variant names + optional descriptions
SvenAlHamad 5ceac20
feat(website-builder): per-variant key + labelled variant fields
SvenAlHamad a255ba0
feat(website-builder): Experiments switcher in the top bar (UI step)
SvenAlHamad 39cebb8
feat(website-builder): persist experiments + manage/edit drawer
SvenAlHamad 1771d5f
feat(website-builder): in-preview experiment toolbar
SvenAlHamad 5f7d425
feat(website-builder): experiment list with activate/deactivate/delete
SvenAlHamad 6ef1a82
fix(website-builder): match experiment card button icon colours to la…
SvenAlHamad a3bd580
feat(website-builder): edit variants in the page editor canvas
SvenAlHamad 344c41b
fix(website-builder): resolve variant preview base by the page id
SvenAlHamad 13b4a03
feat(website-builder): preview a variant's draft in a new tab
SvenAlHamad 15bb352
refactor(website-builder): govern experiments & variants by the page …
SvenAlHamad fba1786
feat(website-builder-sdk): first-class experiment serving + Next midd…
SvenAlHamad 46951fe
fix(website-builder): experiment form alignment + consistent variant …
SvenAlHamad 783cfad
fix(website-builder): drop chevron from the empty-state Experiments b…
SvenAlHamad 29d3861
feat(website-builder): published-page experiment indicator + kill-switch
SvenAlHamad 8d3f9a8
refactor(api-website-builder): address PR review — no null from use c…
SvenAlHamad 783ace2
refactor(website-builder): address Bruno review — model ids + slug lib
SvenAlHamad d547cbc
refactor(api-website-builder): one abstraction per file (PR review)
SvenAlHamad ff6d7ed
refactor(app-website-builder): experiments admin as presenters (PR re…
SvenAlHamad cf58418
refactor(app-website-builder): tighten experiments views to webhooks …
SvenAlHamad affabb1
merge: release/6.5.0 into feat/wb-ab-testing
SvenAlHamad File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
34 changes: 34 additions & 0 deletions
34
packages/api-website-builder/src/domain/experiment/EntryToExperimentMapper.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
84
packages/api-website-builder/src/domain/experiment/abstractions.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
79
packages/api-website-builder/src/domain/experiment/errors.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 } | ||
| }); | ||
| } | ||
| } |
41 changes: 41 additions & 0 deletions
41
packages/api-website-builder/src/domain/experiment/experiment.model.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 => ({ | ||
| // 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: [] | ||
| }); | ||
31 changes: 31 additions & 0 deletions
31
packages/api-website-builder/src/domain/variant/EntryToVariantMapper.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
45
packages/api-website-builder/src/domain/variant/abstractions.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
33
packages/api-website-builder/src/domain/variant/variant.model.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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: [] | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.