diff --git a/packages/universal/api-schemas/package.json b/packages/universal/api-schemas/package.json index c5addaa4..9978398a 100644 --- a/packages/universal/api-schemas/package.json +++ b/packages/universal/api-schemas/package.json @@ -24,6 +24,7 @@ }, "./package.json": "./package.json" }, + "sideEffects": false, "files": [ "dist/**/*", "THIRD_PARTY_NOTICES.txt" diff --git a/packages/universal/core-sdk/src/preview-support/PreviewOverrideManager.changes.test.ts b/packages/universal/core-sdk/src/preview-support/PreviewOverrideManager.changes.test.ts new file mode 100644 index 00000000..c1966103 --- /dev/null +++ b/packages/universal/core-sdk/src/preview-support/PreviewOverrideManager.changes.test.ts @@ -0,0 +1,98 @@ +import type { + ChangeArray, + OptimizationData, + OptimizationEntry, + SelectedOptimizationArray, +} from '@contentful/optimization-api-client/api-schemas' +import { signal } from '@preact/signals-core' +import { InterceptorManager } from '../lib/interceptor' +import { PreviewOverrideManager } from './PreviewOverrideManager' +import { + BASELINE, + makeOptimizationData, + type InterceptorFn, +} from './PreviewOverrideManager.test-utils' + +const INITIAL_CHANGES: ChangeArray = [ + { key: 'a', type: 'Variable', value: 1, meta: { experienceId: 'exp-1', variantIndex: 0 } }, + { key: 'b', type: 'Variable', value: 2, meta: { experienceId: 'exp-2', variantIndex: 0 } }, +] + +let selectedOptimizations: ReturnType> +let changes: ReturnType> +let stateInterceptors: InterceptorManager +let capturedInterceptor: InterceptorFn | undefined +let manager: PreviewOverrideManager | undefined + +function createManager( + opts: { withChanges?: boolean; entries?: readonly OptimizationEntry[] } = {}, +): PreviewOverrideManager { + const { withChanges = true, entries = [] } = opts + selectedOptimizations = signal(BASELINE) + changes = signal(INITIAL_CHANGES) + stateInterceptors = new InterceptorManager() + capturedInterceptor = undefined + rs.spyOn(stateInterceptors, 'add').mockImplementation((fn: InterceptorFn) => { + capturedInterceptor = fn + return 1 + }) + rs.spyOn(stateInterceptors, 'remove').mockImplementation(() => true) + manager = new PreviewOverrideManager({ + selectedOptimizations, + changes: withChanges ? changes : undefined, + optimizationEntries: () => entries, + stateInterceptors, + onOverridesChanged: rs.fn(), + }) + return manager +} + +describe('PreviewOverrideManager — changes coordination', () => { + afterEach(() => { + manager?.destroy() + manager = undefined + }) + + it('captures initial changes signal as baseline', () => { + const mgr = createManager() + expect(mgr.getBaselineChanges()).toEqual(INITIAL_CHANGES) + }) + + it('restores changes signal to baseline on resetAll', () => { + const mgr = createManager() + mgr.setVariantOverride('exp-1', 1) + // Manually mutate so we can prove resetAll rewrites the signal value. + changes.value = [] + mgr.resetAll() + expect(changes.value).toEqual(INITIAL_CHANGES) + }) + + it('updates changes baseline on API-refresh interceptor', async () => { + const mgr = createManager() + mgr.setVariantOverride('exp-1', 1) + if (!capturedInterceptor) throw new Error('Interceptor not captured') + + const refreshedChanges: ChangeArray = [ + { key: 'a', type: 'Variable', value: 99, meta: { experienceId: 'exp-1', variantIndex: 0 } }, + ] + await capturedInterceptor({ + ...makeOptimizationData(BASELINE), + changes: refreshedChanges, + }) + + // After the API refresh, the manager treats the new payload as the baseline. + expect(mgr.getBaselineChanges()).toEqual(refreshedChanges) + + // resetAll restores to the *new* baseline, not the original. + mgr.resetAll() + expect(changes.value).toEqual(refreshedChanges) + }) + + it('falls back to single-signal sync when no changes signal is configured (backward-compat)', () => { + const mgr = createManager({ withChanges: false }) + mgr.setVariantOverride('exp-1', 1) + expect(selectedOptimizations.value?.find((s) => s.experienceId === 'exp-1')?.variantIndex).toBe( + 1, + ) + }) +}) diff --git a/packages/universal/core-sdk/src/preview-support/PreviewOverrideManager.ts b/packages/universal/core-sdk/src/preview-support/PreviewOverrideManager.ts index 06a4adcb..fc421071 100644 --- a/packages/universal/core-sdk/src/preview-support/PreviewOverrideManager.ts +++ b/packages/universal/core-sdk/src/preview-support/PreviewOverrideManager.ts @@ -1,11 +1,14 @@ import type { + ChangeArray, OptimizationData, + OptimizationEntry, Profile, SelectedOptimizationArray, } from '@contentful/optimization-api-client/api-schemas' import { createScopedLogger } from '@contentful/optimization-api-client/logger' -import type { Signal } from '@preact/signals-core' +import { batch, type Signal } from '@preact/signals-core' import type { InterceptorManager } from '../lib/interceptor' +import { applyChangeOverrides } from './applyChangeOverrides' import { applyOptimizationOverrides } from './applyOptimizationOverrides' import type { OptimizationOverride, OverrideState } from './types' @@ -45,6 +48,23 @@ export interface PreviewOverrideManagerConfig { */ profile?: Signal + /** + * Optional `changes` signal. When provided alongside {@link optimizationEntries}, + * the manager keeps inline-variable Variable changes in sync with overrides on + * every mutation and on every API refresh — so `getFlag()` consumers see the + * preview-selected variant value without a round-trip. + */ + changes?: Signal + + /** + * Optional provider for the optimization entries the panel knows about. + * Required to coordinate {@link changes}; the manager uses each entry's + * `nt_config.components` to translate variant overrides into Variable changes. + * Implemented as a function so callers can return an up-to-date list as the + * panel fetches additional entries. + */ + optimizationEntries?: () => readonly OptimizationEntry[] + /** The state interceptor registry to register with. */ stateInterceptors: StateInterceptorAccess @@ -73,18 +93,30 @@ export interface PreviewOverrideManagerConfig { */ export class PreviewOverrideManager { private baselineSelectedOptimizations: SelectedOptimizationArray | null = null + private baselineChanges: ChangeArray | null = null private baselineAudienceQualifications: Record = {} private overrides: OverrideState = { ...INITIAL_STATE, audiences: {}, selectedOptimizations: {} } private interceptorId: number | null = null private readonly selectedOptimizations: Signal + private readonly changes: Signal | undefined + private readonly optimizationEntries: (() => readonly OptimizationEntry[]) | undefined private readonly profile: Signal | undefined private readonly stateInterceptors: StateInterceptorAccess private readonly onOverridesChanged: ((state: Readonly) => void) | undefined constructor(config: PreviewOverrideManagerConfig) { - const { selectedOptimizations, profile, stateInterceptors, onOverridesChanged } = config + const { + selectedOptimizations, + changes, + optimizationEntries, + profile, + stateInterceptors, + onOverridesChanged, + } = config this.selectedOptimizations = selectedOptimizations + this.changes = changes + this.optimizationEntries = optimizationEntries this.profile = profile this.stateInterceptors = stateInterceptors this.onOverridesChanged = onOverridesChanged @@ -97,21 +129,28 @@ export class PreviewOverrideManager { logger.debug('Captured initial signal state as baseline') } + if (changes) { + const { value: initialChanges } = changes + if (initialChanges) this.baselineChanges = initialChanges + } + // Register state interceptor to preserve overrides when API responses arrive this.interceptorId = config.stateInterceptors.add( (data: Readonly): OptimizationData => { - // Cache the un-overridden selectedOptimizations as the new baseline - const { selectedOptimizations: incoming } = data - this.baselineSelectedOptimizations = incoming + // Cache the un-overridden state as the new baseline + const { selectedOptimizations: incomingSelected, changes: incomingChanges } = data + this.baselineSelectedOptimizations = incomingSelected + this.baselineChanges = incomingChanges const hasOverrides = Object.keys(this.overrides.selectedOptimizations).length > 0 - const next = hasOverrides + const next: OptimizationData = hasOverrides ? { ...data, selectedOptimizations: applyOptimizationOverrides( data.selectedOptimizations, this.overrides.selectedOptimizations, ), + changes: this.deriveChanges(data.changes), } : { ...data } @@ -233,8 +272,8 @@ export class PreviewOverrideManager { // --------------------------------------------------------------------------- /** - * Clear all overrides and restore the `selectedOptimizations` signal to the - * clean API baseline. + * Clear all overrides and restore the tracked signals to their clean + * API baselines. */ resetAll(): void { logger.info('Resetting all overrides to baseline') @@ -242,11 +281,17 @@ export class PreviewOverrideManager { this.overrides = { audiences: {}, selectedOptimizations: {} } this.baselineAudienceQualifications = {} - // Restore signal to baseline - const { baselineSelectedOptimizations } = this + const { baselineSelectedOptimizations, baselineChanges, changes } = this if (baselineSelectedOptimizations) { - this.selectedOptimizations.value = baselineSelectedOptimizations - logger.debug('Restored signal to baseline') + if (changes && baselineChanges) { + batch(() => { + this.selectedOptimizations.value = baselineSelectedOptimizations + changes.value = baselineChanges + }) + } else { + this.selectedOptimizations.value = baselineSelectedOptimizations + } + logger.debug('Restored signals to baseline') } this.notifyChanged() @@ -266,6 +311,11 @@ export class PreviewOverrideManager { return this.baselineSelectedOptimizations } + /** Returns the cached baseline changes array, or null if no baseline yet. */ + getBaselineChanges(): Readonly | null { + return this.baselineChanges + } + /** * Returns the pre-override audience qualification snapshot — a map from * `audienceId` to whether the user was naturally in that audience (`profile.audiences` @@ -294,6 +344,7 @@ export class PreviewOverrideManager { this.overrides = { audiences: {}, selectedOptimizations: {} } this.baselineSelectedOptimizations = null + this.baselineChanges = null this.baselineAudienceQualifications = {} } @@ -319,15 +370,42 @@ export class PreviewOverrideManager { } /** - * Recompute the signal from the clean API baseline + current overrides. - * Never reads from the current signal — always derives from baseline. + * Recompute the tracked signals from their clean API baselines + current + * overrides. Both writes happen inside one signals batch so subscribers + * never observe a half-updated state. Never reads from the current + * signal values — always derives from the cached baselines. */ private syncOverridesToSignal(): void { - this.selectedOptimizations.value = applyOptimizationOverrides( + const nextSelected = applyOptimizationOverrides( this.baselineSelectedOptimizations ?? [], this.overrides.selectedOptimizations, ) - logger.debug('Synced overrides to signal') + + if (this.changes) { + const nextChanges = this.deriveChanges(this.baselineChanges ?? []) + batch(() => { + this.selectedOptimizations.value = nextSelected + if (this.changes) this.changes.value = nextChanges + }) + } else { + this.selectedOptimizations.value = nextSelected + } + + logger.debug('Synced overrides to signals') + } + + /** + * Translate the current variant overrides into Variable changes and merge + * them into the supplied baseline `changes` array. Returns the input + * unchanged when the manager wasn't given an `optimizationEntries` provider. + */ + private deriveChanges(baseline: ChangeArray): ChangeArray { + if (!this.optimizationEntries) return baseline + return applyChangeOverrides( + baseline, + this.optimizationEntries(), + this.overrides.selectedOptimizations, + ) } private setAudienceOverride( diff --git a/packages/universal/core-sdk/src/preview-support/applyChangeOverrides.test.ts b/packages/universal/core-sdk/src/preview-support/applyChangeOverrides.test.ts new file mode 100644 index 00000000..f3ef8fdd --- /dev/null +++ b/packages/universal/core-sdk/src/preview-support/applyChangeOverrides.test.ts @@ -0,0 +1,116 @@ +import { + type ChangeArray, + OptimizationEntry, +} from '@contentful/optimization-api-client/api-schemas' +import { applyChangeOverrides } from './applyChangeOverrides' +import type { OptimizationOverride } from './types' + +// Build OptimizationEntry fixtures via the public Zod schema so we exercise +// applyChangeOverrides against real entry shapes without unsafe type casts. +function buildEntry( + experienceId: string, + key: string, + baseline: unknown, + variant: unknown, +): OptimizationEntry { + return OptimizationEntry.parse({ + metadata: { tags: [], concepts: [] }, + sys: { + space: { sys: { type: 'Link', linkType: 'Space', id: 's' } }, + id: `entry-${experienceId}`, + type: 'Entry', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + environment: { sys: { id: 'master', type: 'Link', linkType: 'Environment' } }, + publishedVersion: 1, + revision: 1, + contentType: { sys: { type: 'Link', linkType: 'ContentType', id: 'nt_experience' } }, + locale: 'en-US', + }, + fields: { + nt_name: `Flag ${key}`, + nt_type: 'nt_personalization', + nt_experience_id: experienceId, + nt_config: { + components: [ + { + type: 'InlineVariable', + key, + valueType: typeof baseline === 'boolean' ? 'Boolean' : 'Number', + baseline: { value: baseline }, + variants: [{ value: variant }], + }, + ], + distribution: [0, 1], + }, + }, + }) +} + +const ENTRIES: readonly OptimizationEntry[] = [ + buildEntry('exp-1', 'flag-a', false, true), + buildEntry('exp-2', 'flag-b', 0, 42), +] + +const BASELINE_CHANGES: ChangeArray = [ + { + key: 'flag-a', + type: 'Variable', + value: false, + meta: { experienceId: 'exp-1', variantIndex: 0 }, + }, + { key: 'flag-b', type: 'Variable', value: 0, meta: { experienceId: 'exp-2', variantIndex: 0 } }, +] + +const overrides = (record: Record): Record => + Object.fromEntries( + Object.entries(record).map(([experienceId, variantIndex]) => [ + experienceId, + { experienceId, variantIndex }, + ]), + ) + +describe('applyChangeOverrides', () => { + it('returns the input array unchanged when no overrides are provided', () => { + const result = applyChangeOverrides(BASELINE_CHANGES, ENTRIES, {}) + expect(result).toBe(BASELINE_CHANGES) + }) + + it('translates variant overrides into Variable changes for inline-variable components', () => { + const result = applyChangeOverrides(BASELINE_CHANGES, ENTRIES, overrides({ 'exp-1': 1 })) + expect(result.find((c) => c.key === 'flag-a')?.value).toBe(true) + expect(result.find((c) => c.key === 'flag-a')?.meta.variantIndex).toBe(1) + // Untouched override leaves flag-b alone. + expect(result.find((c) => c.key === 'flag-b')?.value).toBe(0) + }) + + it('falls back to baseline value when the override variantIndex points past the variants array', () => { + const result = applyChangeOverrides(BASELINE_CHANGES, ENTRIES, overrides({ 'exp-1': 99 })) + // No variant at index 98 → baseline value re-emitted. + expect(result.find((c) => c.key === 'flag-a')?.value).toBe(false) + }) + + it('emits the baseline value for variantIndex 0', () => { + // Pretend the baseline was already overridden to variant 1 in the baseline-changes + // payload, then drop back to 0 via override. + const baselineWithVariantApplied: ChangeArray = [ + { + key: 'flag-a', + type: 'Variable', + value: true, + meta: { experienceId: 'exp-1', variantIndex: 1 }, + }, + ] + const result = applyChangeOverrides( + baselineWithVariantApplied, + ENTRIES, + overrides({ 'exp-1': 0 }), + ) + expect(result.find((c) => c.key === 'flag-a')?.value).toBe(false) + }) + + it('returns the input unchanged when overrides exist but no entries have inline-variable components', () => { + const result = applyChangeOverrides(BASELINE_CHANGES, [], overrides({ 'exp-1': 1 })) + expect(result).toBe(BASELINE_CHANGES) + }) +}) diff --git a/packages/universal/core-sdk/src/preview-support/applyChangeOverrides.ts b/packages/universal/core-sdk/src/preview-support/applyChangeOverrides.ts new file mode 100644 index 00000000..637a1405 --- /dev/null +++ b/packages/universal/core-sdk/src/preview-support/applyChangeOverrides.ts @@ -0,0 +1,72 @@ +import type { + ChangeArray, + InlineVariableComponent, + OptimizationEntry, +} from '@contentful/optimization-api-client/api-schemas' +import { isInlineVariableComponent } from '@contentful/optimization-api-client/api-schemas' +import type { OptimizationOverride } from './types' + +function getInlineVariableComponents(optimization: OptimizationEntry): InlineVariableComponent[] { + const { components } = optimization.fields.nt_config ?? {} + return Array.isArray(components) ? components.filter(isInlineVariableComponent) : [] +} + +/** + * Merges user-selected variant overrides into the given custom-flag changes. + * + * For every overridden experience, any `InlineVariable` components are converted + * into `Variable` changes so the runtime flag APIs resolve the selected preview + * value from `changes`. This keeps `getFlag()` consumers in sync with manual + * variant picks made in a preview panel. + * + * @param changes - Current array of custom-flag changes (the un-overridden API baseline). + * @param optimizationEntries - Available optimization entries indexed for preview. + * @param overrides - Map of experience ID to the desired override. + * @returns A new array with inline-variable change overrides applied, or the original array when no overrides exist. + * + * @public + */ +export function applyChangeOverrides( + changes: ChangeArray, + optimizationEntries: readonly OptimizationEntry[], + overrides: Record, +): ChangeArray { + const overrideValues = Object.values(overrides) + if (overrideValues.length === 0) return changes + + const overrideChanges = optimizationEntries.flatMap((optimization): ChangeArray => { + const { + fields: { nt_experience_id: experienceId }, + } = optimization + const { [experienceId]: override } = overrides + if (override === undefined) return [] + + const { variantIndex } = override + + return getInlineVariableComponents(optimization).map((component): ChangeArray[number] => ({ + key: component.key, + type: 'Variable', + value: + variantIndex === 0 + ? component.baseline.value + : (component.variants[variantIndex - 1]?.value ?? component.baseline.value), + meta: { + experienceId, + variantIndex, + }, + })) + }) + + if (overrideChanges.length === 0) return changes + + return [ + ...changes.filter( + (change) => + !overrideChanges.some( + ({ key, meta: { experienceId } }) => + change.key === key && change.meta.experienceId === experienceId, + ), + ), + ...overrideChanges, + ] +} diff --git a/packages/universal/core-sdk/src/preview-support/index.ts b/packages/universal/core-sdk/src/preview-support/index.ts index 770e8df4..b4255885 100644 --- a/packages/universal/core-sdk/src/preview-support/index.ts +++ b/packages/universal/core-sdk/src/preview-support/index.ts @@ -8,6 +8,7 @@ * @packageDocumentation */ +export { applyChangeOverrides } from './applyChangeOverrides' export { applyOptimizationOverrides } from './applyOptimizationOverrides' export { buildPreviewModel, diff --git a/packages/web/preview-panel/package.json b/packages/web/preview-panel/package.json index 356775ad..fc7318a0 100644 --- a/packages/web/preview-panel/package.json +++ b/packages/web/preview-panel/package.json @@ -70,6 +70,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@contentful/optimization-core": "workspace:*", "@contentful/optimization-web": "workspace:*", "@leeoniya/ufuzzy": "^1.0.19", "@lit/context": "^1.1.6", diff --git a/packages/web/preview-panel/src/attachOptimizationPreviewPanel.ts b/packages/web/preview-panel/src/attachOptimizationPreviewPanel.ts index 30da0652..ca407a1c 100644 --- a/packages/web/preview-panel/src/attachOptimizationPreviewPanel.ts +++ b/packages/web/preview-panel/src/attachOptimizationPreviewPanel.ts @@ -1,21 +1,13 @@ +import { PreviewOverrideManager } from '@contentful/optimization-core/preview-support' import type ContentfulOptimization from '@contentful/optimization-web' import type { AudienceEntrySkeleton, - ChangeArray, - OptimizationData, OptimizationEntry, OptimizationEntrySkeleton, - SelectedOptimizationArray, + Profile, } from '@contentful/optimization-web/api-schemas' -import type { - PreviewPanelSignalObject, - SignalFns, - Signals, -} from '@contentful/optimization-web/core-sdk' -import { - PREVIEW_PANEL_SIGNALS_SYMBOL, - PREVIEW_PANEL_SIGNAL_FNS_SYMBOL, -} from '@contentful/optimization-web/symbols' +import type { PreviewPanelSignalObject } from '@contentful/optimization-web/core-sdk' +import { PREVIEW_PANEL_SIGNALS_SYMBOL } from '@contentful/optimization-web/symbols' import type { ChainModifiers, ContentfulClientApi, Entry } from 'contentful' import { AUDIENCE_SWITCH_CHANGE, @@ -39,8 +31,6 @@ import { } from './components/panel' import { defineSearch } from './components/search' import { getAllEntries, isAudienceEntry, isOptimizationEntry } from './lib/entries' -import { applyChangeOverrides, applyOptimizationOverrides } from './lib/overrides' -import { isChange, isSelectedOptimization } from './lib/schemaGuards' import { createScopedLogger } from './logger' declare global { @@ -54,19 +44,10 @@ declare global { } } -/** @internal */ -let defaults: { - selectedOptimizations?: SelectedOptimizationArray - changes?: ChangeArray -} = {} - /** @internal */ let previewPanelAttachment: Promise | undefined = undefined -/** @internal */ -const overrides = new Map() const OVERRIDES_STORAGE_KEY = '__ctfl_opt_preview_overrides__' -const DEFAULTS_STORAGE_KEY = '__ctfl_opt_preview_defaults__' const storageLogger = createScopedLogger('PreviewPanelStorage') /** @internal */ @@ -75,15 +56,15 @@ function isRecord(value: unknown): value is Record { } /** @internal */ -function loadOverrides(): void { - overrides.clear() +function loadOverridesFromStorage(): Map { + const overrides = new Map() try { const stored = localStorage.getItem(OVERRIDES_STORAGE_KEY) - if (!stored) return + if (!stored) return overrides const parsed = JSON.parse(stored) as unknown - if (!isRecord(parsed)) return + if (!isRecord(parsed)) return overrides for (const [experienceId, variantIndex] of Object.entries(parsed)) { if (typeof variantIndex === 'number') overrides.set(experienceId, variantIndex) @@ -91,83 +72,23 @@ function loadOverrides(): void { } catch (error) { storageLogger.warn(`Failed to read localStorage key "${OVERRIDES_STORAGE_KEY}"`, error) } -} -/** @internal */ -function loadDefaults(): void { - defaults = {} - - try { - const stored = localStorage.getItem(DEFAULTS_STORAGE_KEY) - if (!stored) return - - const parsed = JSON.parse(stored) as unknown - if (!isRecord(parsed)) return - const { selectedOptimizations: storedSelectedOptimizations, changes: storedChanges } = parsed - - defaults.selectedOptimizations = - Array.isArray(storedSelectedOptimizations) && - storedSelectedOptimizations.every(isSelectedOptimization) - ? storedSelectedOptimizations - : undefined - defaults.changes = - Array.isArray(storedChanges) && storedChanges.every(isChange) ? storedChanges : undefined - } catch (error) { - storageLogger.warn(`Failed to read localStorage key "${DEFAULTS_STORAGE_KEY}"`, error) - } + return overrides } -function persistOverrideState(): void { +function persistOverrideMap(overrides: Map): void { try { if (overrides.size === 0) { localStorage.removeItem(OVERRIDES_STORAGE_KEY) - localStorage.removeItem(DEFAULTS_STORAGE_KEY) return } localStorage.setItem(OVERRIDES_STORAGE_KEY, JSON.stringify(Object.fromEntries(overrides))) - if ((defaults.selectedOptimizations ?? defaults.changes) !== undefined) { - localStorage.setItem(DEFAULTS_STORAGE_KEY, JSON.stringify(defaults)) - } } catch (error) { storageLogger.warn('Failed to persist preview panel override state', error) } } -/** @internal */ -function syncOverrides( - panel: { - optimizationEntries: OptimizationEntry[] - defaultSelectedOptimizations: SelectedOptimizationArray - overrides: Map | undefined - }, - signals: Pick, - signalFns: Pick, -): void { - if (defaults.selectedOptimizations === undefined && signals.selectedOptimizations.value) { - defaults.selectedOptimizations = [...signals.selectedOptimizations.value] - panel.defaultSelectedOptimizations = [...defaults.selectedOptimizations] - } - - if (defaults.changes === undefined && signals.changes.value) { - defaults.changes = [...signals.changes.value] - } - - panel.overrides = new Map(overrides) - signalFns.batch(() => { - signals.selectedOptimizations.value = applyOptimizationOverrides( - defaults.selectedOptimizations ?? [], - overrides, - ) - signals.changes.value = applyChangeOverrides( - defaults.changes ?? [], - panel.optimizationEntries, - overrides, - ) - }) - persistOverrideState() -} - /** * Throws if a preview panel element already exists in the DOM. * @@ -197,6 +118,23 @@ function resolveOptimization( return window.contentfulOptimization } +/** + * Convert the manager's selectedOptimizations override record into the + * `Map` shape consumed by the panel UI and the localStorage + * persistence helpers. + * + * @internal + */ +function overridesToMap( + selectedOptimizations: Record, +): Map { + const result = new Map() + for (const { experienceId, variantIndex } of Object.values(selectedOptimizations)) { + result.set(experienceId, variantIndex) + } + return result +} + /** * Configuration for {@link attachOptimizationPreviewPanelToSdk}. * @@ -234,8 +172,8 @@ export interface AttachOptimizationPreviewPanelArgs isOptimizationEntry(optimization), ) - - contentfulOptimization.interceptors.state.add((states): OptimizationData => { - const { changes, selectedOptimizations, ...otherStates } = states - - defaults = { - selectedOptimizations: [...selectedOptimizations], - changes: [...changes], - } - panel.defaultSelectedOptimizations = [...selectedOptimizations] - if (overrides.size > 0) persistOverrideState() - - return { - ...otherStates, - changes: applyChangeOverrides(changes, panel.optimizationEntries, overrides), - selectedOptimizations: applyOptimizationOverrides(selectedOptimizations, overrides), - } + panel.overrides = new Map(initialOverrides) + panel.defaultSelectedOptimizations = [] + + const manager = new PreviewOverrideManager({ + selectedOptimizations: signals.selectedOptimizations, + changes: signals.changes, + optimizationEntries: () => panel.optimizationEntries, + profile: signals.profile, + stateInterceptors: contentfulOptimization.interceptors.state, + onOverridesChanged: (state) => { + const overridesMap = overridesToMap(state.selectedOptimizations) + panel.overrides = new Map(overridesMap) + persistOverrideMap(overridesMap) + + // Keep the panel UI's "default" badges in sync with whatever the manager + // currently considers the un-overridden API baseline. + const baseline = manager.getBaselineSelectedOptimizations() + if (baseline) panel.defaultSelectedOptimizations = [...baseline] + }, }) panel.addEventListener(PANEL_DRAWER_TOGGLE, (event: Event) => { @@ -328,43 +265,67 @@ async function attachOptimizationPreviewPanelToSdk { + if (audienceId) { + manager.resetAudienceOverride(audienceId) + return + } + for (const experienceId of experienceIds) { + manager.resetOptimizationOverride(experienceId) + } + } + + const onAudienceSwitchActivate = ( + audienceId: string, + experienceIds: string[], + variantIndex: number, + ): void => { + if (variantIndex === 1) { + manager.activateAudience(audienceId, experienceIds) + } else { + manager.deactivateAudience(audienceId, experienceIds) + } + } + panel.addEventListener(AUDIENCE_SWITCH_CHANGE, (event: Event) => { if (!isAudienceSwitchChangeEvent(event)) return const [target] = event.composedPath() if (!(target instanceof Element) || !isAudience(target)) return - for (const { - fields: { nt_experience_id: experienceId }, - } of target.optimizations) { - overrides.delete(experienceId) + const audienceId = target.audience?.sys.id + const experienceIds = target.optimizations.map(({ fields: { nt_experience_id: id } }) => id) + + if (event.detail.length === 0) { + onAudienceSwitchReset(audienceId, experienceIds) + return } - for (const { key: experienceId, value: variantIndex } of event.detail) { - overrides.set(experienceId, variantIndex) + if (!audienceId) { + for (const { key, value } of event.detail) { + manager.setVariantOverride(key, value) + } + return } - syncOverrides(panel, signals, signalFns) + const variantIndex = event.detail[0]?.value ?? 0 + onAudienceSwitchActivate(audienceId, experienceIds, variantIndex) }) panel.addEventListener(PANEL_RESET, () => { - overrides.clear() - syncOverrides(panel, signals, signalFns) - panel.defaultSelectedOptimizations = [...(defaults.selectedOptimizations ?? [])] + manager.resetAll() }) - signalFns.effect(() => { - const { - profile: { value: profile }, - } = signals + signals.profile.subscribe((profile: Profile | undefined) => { if (profile) panel.profile = profile }) - if (overrides.size > 0) { - syncOverrides(panel, signals, signalFns) + // Hydrate overrides loaded from storage into the manager so its state + // interceptor and downstream signals reflect them on first render. + for (const [experienceId, variantIndex] of initialOverrides) { + manager.setVariantOverride(experienceId, variantIndex) } document.body.appendChild(panel) @@ -376,7 +337,7 @@ async function attachOptimizationPreviewPanelToSdk, -): SelectedOptimizationArray { - // Clone only if overrides exist - if (overrides.size === 0) return selectedOptimizations - - const overridden = selectedOptimizations.map((selected) => { - const overrideIndex = overrides.get(selected.experienceId) - return overrideIndex !== undefined ? { ...selected, variantIndex: overrideIndex } : selected - }) - - // Add new overrides not present in selectedOptimizations - for (const [experienceId, variantIndex] of overrides) { - if (!overridden.some((selected) => selected.experienceId === experienceId)) { - overridden.push({ experienceId, variantIndex, variants: {} }) - } - } - - return overridden -} - -/** - * Merges user-selected variant overrides into the given custom-flag changes. - * - * For every overridden experience, any `InlineVariable` components are converted - * into `Variable` changes so the runtime flag APIs resolve the selected preview - * value from `changes`. - * - * @param changes - Current array of custom-flag changes. - * @param optimizationEntries - Available optimization entries indexed for preview. - * @param overrides - Map of experience ID to the desired variant index. - * @returns A new array with inline-variable change overrides applied, or the original array when no overrides exist. - * - * @public - */ -export function applyChangeOverrides( - changes: ChangeArray, - optimizationEntries: OptimizationEntry[], - overrides: Map, -): ChangeArray { - if (overrides.size === 0) return changes - - const overrideChanges = optimizationEntries.flatMap((optimization): ChangeArray => { - const { - fields: { nt_experience_id: experienceId }, - } = optimization - const variantIndex = overrides.get(experienceId) - - if (variantIndex === undefined) return [] - - return getInlineVariableComponents(optimization).map((component): ChangeArray[number] => ({ - key: component.key, - type: 'Variable', - value: - variantIndex === 0 - ? component.baseline.value - : (component.variants[variantIndex - 1]?.value ?? component.baseline.value), - meta: { - experienceId, - variantIndex, - }, - })) - }) - - if (overrideChanges.length === 0) return changes - - return [ - ...changes.filter( - (change) => - !overrideChanges.some( - ({ key, meta: { experienceId } }) => - change.key === key && change.meta.experienceId === experienceId, - ), - ), - ...overrideChanges, - ] -} diff --git a/packages/web/preview-panel/src/lib/schemaGuards.ts b/packages/web/preview-panel/src/lib/schemaGuards.ts deleted file mode 100644 index ef98b02b..00000000 --- a/packages/web/preview-panel/src/lib/schemaGuards.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { - ChangeArray, - InlineVariableComponent, - SelectedOptimizationArray, -} from '@contentful/optimization-web/api-schemas' - -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === 'object' && !Array.isArray(value) -} - -export function isSelectedOptimization(value: unknown): value is SelectedOptimizationArray[number] { - return ( - isRecord(value) && - typeof value.experienceId === 'string' && - typeof value.variantIndex === 'number' && - isRecord(value.variants) - ) -} - -export function isChange(value: unknown): value is ChangeArray[number] { - return ( - isRecord(value) && - typeof value.key === 'string' && - typeof value.type === 'string' && - 'value' in value && - isRecord(value.meta) && - typeof value.meta.experienceId === 'string' && - typeof value.meta.variantIndex === 'number' - ) -} - -export function isInlineVariableComponent(value: unknown): value is InlineVariableComponent { - return ( - isRecord(value) && - value.type === 'InlineVariable' && - typeof value.key === 'string' && - isRecord(value.baseline) && - 'value' in value.baseline && - Array.isArray(value.variants) - ) -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e7b2eb8b..ea50e160 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -209,7 +209,7 @@ importers: version: 24.10.13 contentful: specifier: 'catalog:' - version: 11.10.5 + version: 11.10.5(debug@4.3.7) contentful-cli: specifier: ^3.10.4 version: 3.10.4(@types/node@24.10.13)(enquirer@2.3.6)(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.0(eslint@10.0.0(jiti@2.6.1))(typescript@5.9.3))(eslint@8.57.1))(eslint-plugin-n@17.24.0(eslint@8.57.1)(typescript@5.9.3))(eslint-plugin-promise@7.2.1(eslint@8.57.1)) @@ -276,7 +276,7 @@ importers: version: link:../../../lib/build-tools contentful: specifier: 'catalog:' - version: 11.10.5 + version: 11.10.5(debug@4.3.7) ejs: specifier: 'catalog:' version: 3.1.10 @@ -415,7 +415,7 @@ importers: version: link:../../lib/build-tools contentful: specifier: 'catalog:' - version: 11.10.5 + version: 11.10.5(debug@4.3.7) core-js: specifier: ^3.49.0 version: 3.49.0 @@ -503,7 +503,7 @@ importers: dependencies: contentful: specifier: 'catalog:' - version: 11.10.5 + version: 11.10.5(debug@4.3.7) zod: specifier: 'catalog:' version: 4.3.6 @@ -546,7 +546,7 @@ importers: version: 1.13.0 contentful: specifier: 'catalog:' - version: 11.10.5 + version: 11.10.5(debug@4.3.7) es-toolkit: specifier: 'catalog:' version: 1.46.0 @@ -657,7 +657,7 @@ importers: version: link:../../../../lib/build-tools contentful: specifier: 'catalog:' - version: 11.10.5 + version: 11.10.5(debug@4.3.7) happy-dom: specifier: 'catalog:' version: 20.8.9 @@ -679,6 +679,9 @@ importers: packages/web/preview-panel: dependencies: + '@contentful/optimization-core': + specifier: workspace:* + version: link:../../universal/core-sdk '@contentful/optimization-web': specifier: workspace:* version: link:../web-sdk @@ -690,7 +693,7 @@ importers: version: 1.1.6 contentful: specifier: 'catalog:' - version: 11.10.5 + version: 11.10.5(debug@4.3.7) es-toolkit: specifier: 'catalog:' version: 1.46.0 @@ -10408,7 +10411,7 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 - axios@1.16.1: + axios@1.16.1(debug@4.3.7): dependencies: follow-redirects: 1.16.0(debug@4.3.7) form-data: 4.0.5 @@ -11048,11 +11051,11 @@ snapshots: contentful-export@7.22.5: dependencies: - axios: 1.16.1 + axios: 1.16.1(debug@4.3.7) bfj: 9.1.3 bluebird: 3.7.2 cli-table3: 0.6.5 - contentful: 11.10.5 + contentful: 11.10.5(debug@4.3.7) contentful-batch-libs: 10.1.6 contentful-management: 11.75.0 date-fns: 4.1.0 @@ -11099,7 +11102,7 @@ snapshots: contentful-management@11.75.0: dependencies: '@contentful/rich-text-types': 16.8.5 - axios: 1.16.1 + axios: 1.16.1(debug@4.3.7) contentful-sdk-core: 9.4.5 fast-copy: 3.0.2 globals: 15.15.0 @@ -11110,7 +11113,7 @@ snapshots: contentful-migration@4.32.0(@types/node@24.10.13)(enquirer@2.3.6): dependencies: '@hapi/hoek': 11.0.7 - axios: 1.16.1 + axios: 1.16.1(debug@4.3.7) bluebird: 3.7.2 callsites: 3.1.0 cardinal: 2.1.1 @@ -11154,11 +11157,11 @@ snapshots: optionalDependencies: '@rollup/rollup-linux-x64-gnu': 4.60.1 - contentful@11.10.5: + contentful@11.10.5(debug@4.3.7): dependencies: '@contentful/content-source-maps': 0.11.44 '@contentful/rich-text-types': 16.8.5 - axios: 1.16.1 + axios: 1.16.1(debug@4.3.7) contentful-resolve-response: 1.9.6 contentful-sdk-core: 9.4.4 json-stringify-safe: 5.0.1