@ninetailed/experience.js-plugin-preview
Version:
Ninetailed SDK plugin for preview
667 lines (638 loc) • 23.9 kB
JavaScript
import { logger, ComponentTypeEnum, ChangeTypes, unionBy } from '@ninetailed/experience.js-shared';
import { OnChangeEmitter, PROFILE_CHANGE, isExperienceMatch, selectDistribution } from '@ninetailed/experience.js';
import { NinetailedPlugin } from '@ninetailed/experience.js-plugin-analytics';
function _objectWithoutPropertiesLoose(source, excluded) {
if (source == null) return {};
var target = {};
var sourceKeys = Object.keys(source);
var key, i;
for (i = 0; i < sourceKeys.length; i++) {
key = sourceKeys[i];
if (excluded.indexOf(key) >= 0) continue;
target[key] = source[key];
}
return target;
}
function _toPrimitive(input, hint) {
if (typeof input !== "object" || input === null) return input;
var prim = input[Symbol.toPrimitive];
if (prim !== undefined) {
var res = prim.call(input, hint || "default");
if (typeof res !== "object") return res;
throw new TypeError("@@toPrimitive must return a primitive value.");
}
return (hint === "string" ? String : Number)(input);
}
function _toPropertyKey(arg) {
var key = _toPrimitive(arg, "string");
return typeof key === "symbol" ? key : String(key);
}
const CONTAINER_WIDTH = 432;
const BUTTON_WIDTH = 48;
const BUTTON_HEIGHT = 192;
const BUTTON_BOTTOM_POSITION = 128;
const TRANSFORM_CLOSED = `translate(${CONTAINER_WIDTH - BUTTON_WIDTH}px, 0px)`;
const TRANSFORM_CLOSED_HIDE = `translate(${CONTAINER_WIDTH}px, 0px)`;
const TRANSFORM_OPEN = `translate(0px, 0px)`;
class WidgetContainer {
constructor(options) {
var _options$ui;
this.container = void 0;
this.options = options;
this.container = document.createElement('div');
this.container.classList.add(WidgetContainer.CONTAINER_CLASS);
this.container.style.position = 'fixed';
this.container.style.zIndex = '999999';
this.container.style.right = '0px';
this.container.style.bottom = `${BUTTON_BOTTOM_POSITION}px`;
this.container.style.width = `${CONTAINER_WIDTH}px`;
this.container.style.height = `${BUTTON_HEIGHT}px`;
this.container.style.overflow = 'hidden';
if ((_options$ui = options.ui) != null && (_options$ui = _options$ui.opener) != null && _options$ui.hide) {
this.container.style.transform = TRANSFORM_CLOSED_HIDE;
} else {
this.container.style.transform = TRANSFORM_CLOSED;
}
this.container.style.transitionTimingFunction = 'cubic-bezier(0.4, 0, 0.2, 1)';
this.container.style.transitionDuration = '700ms';
this.container.style.transitionProperty = 'transform';
document.body.appendChild(this.container);
}
open() {
this.container.style.transform = TRANSFORM_OPEN;
this.container.style.height = '100vh';
this.container.style.bottom = `0px`;
}
close() {
var _this$options$ui;
if ((_this$options$ui = this.options.ui) != null && (_this$options$ui = _this$options$ui.opener) != null && _this$options$ui.hide) {
this.container.style.transform = TRANSFORM_CLOSED_HIDE;
} else {
this.container.style.transform = TRANSFORM_CLOSED;
}
setTimeout(() => {
this.container.style.height = `${BUTTON_HEIGHT}px`;
this.container.style.bottom = `${BUTTON_BOTTOM_POSITION}px`;
}, 700);
}
get element() {
return this.container;
}
static isContainerAttached() {
return document.querySelector(`.${WidgetContainer.CONTAINER_CLASS}`) !== null;
}
}
WidgetContainer.CONTAINER_CLASS = 'nt-preview-widget-container';
class NinetailedPreviewPlugin extends NinetailedPlugin {
constructor(options) {
var _this;
super();
_this = this;
this.name = 'ninetailed:preview' + Math.random();
this.isOpen = false;
this.experiences = [];
this.audienceDefinitions = [];
this.audienceOverwrites = {};
this.experienceVariantIndexOverwrites = {};
this.variableOverwrites = {};
this.profile = null;
this.changes = [];
this.container = null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.bridge = null;
/**
* Since several instances of the plugin can be created, we need to make sure only one is marked as active.
*/
this.isActiveInstance = false;
this.onChangeEmitter = new OnChangeEmitter();
this.onOpenExperienceEditor = void 0;
this.onOpenAudienceEditor = void 0;
this.clientId = null;
this.environment = null;
this.initialize = async function () {
if (typeof window !== 'undefined') {
var _window$ninetailed, _this$changes;
if (WidgetContainer.isContainerAttached()) {
logger.warn('Preview plugin is already attached.');
_this.isActiveInstance = false;
return;
}
const {
PreviewBridge
} = await import('@ninetailed/experience.js-preview-bridge');
_this.isActiveInstance = true;
_this.container = new WidgetContainer({
ui: _this.options.ui
});
_this.bridge = PreviewBridge({
url: _this.options.url,
nonce: _this.options.nonce
});
_this.bridge.render(_this.container.element);
window.ninetailed = Object.assign({}, window.ninetailed, {
plugins: Object.assign({}, (_window$ninetailed = window.ninetailed) == null ? void 0 : _window$ninetailed.plugins, {
preview: _this.windowApi
})
});
_this.bridge.updateProps({
props: _this.pluginApi
});
if (!((_this$changes = _this.changes) != null && _this$changes.length)) {
_this.onChange();
}
}
};
this.loaded = () => true;
this[PROFILE_CHANGE] = ({
payload
}) => {
if (!this.isActiveInstance) {
return;
}
if (payload != null && payload.profile) {
this.onProfileChange(payload.profile, payload.changes || []);
}
};
/**
* Implements the HasChangesModificationMiddleware interface
* Returns a middleware function that applies variable overwrites to changes
*/
this.getChangesModificationMiddleware = () => {
if (!this.isActiveInstance || Object.keys(this.variableOverwrites).length === 0) {
return undefined;
}
return ({
changes: inputChanges
}) => {
if (!inputChanges || inputChanges.length === 0) {
return {
changes: inputChanges
};
}
// Calculate and return overridden changes on demand instead of storing them
return {
changes: this.getEffectiveChanges(inputChanges)
};
};
};
this.getExperienceSelectionMiddleware = ({
baseline,
experiences
}) => {
if (!this.isActiveInstance) {
return;
}
return () => {
const experienceIds = Object.keys(this.pluginApi.experienceVariantIndexes);
const experience = experiences.find(experience => {
return experienceIds.includes(experience.id);
});
if (!experience) {
return {
experience: null,
variant: baseline,
variantIndex: 0
};
}
// Handle entry replacements as before
const entryReplacementComponents = experience.components.filter(component => component.type === ComponentTypeEnum.EntryReplacement && 'id' in component.baseline);
const baselineComponent = entryReplacementComponents.find(component => component.baseline.id === baseline.id);
// Get the selected variant index
const variantIndex = this.pluginApi.experienceVariantIndexes[experience.id];
// Handle variable components for this experience (NEW CODE)
if (variantIndex !== undefined) {
// Process all variable components for this experience
const variableComponents = experience.components.filter(component => component.type === ComponentTypeEnum.InlineVariable);
// Set variable values based on the selected variant index
variableComponents.forEach(component => {
const key = component.key;
let value;
if (variantIndex === 0) {
value = component.baseline;
} else {
const variant = component.variants[variantIndex - 1];
value = variant && 'value' in variant ? variant.value : component.baseline;
}
// Set the variable in our changes system
this.setVariableValue({
experienceId: experience.id,
key,
value,
variantIndex
});
});
}
// Continue with entry replacement handling
if (!baselineComponent) {
return {
experience,
variant: baseline,
variantIndex: 0
};
}
const allVariants = [baseline, ...baselineComponent.variants];
if (allVariants.length <= variantIndex) {
return {
experience,
variant: baseline,
variantIndex: 0
};
}
const variant = allVariants[variantIndex];
if (!variant) {
return {
experience,
variant: baseline,
variantIndex: 0
};
}
return {
experience,
variant,
variantIndex
};
};
};
this.onChange = () => {
logger.debug('Ninetailed Preview Plugin onChange pluginApi:', this.pluginApi);
if (typeof window !== 'undefined') {
var _window$ninetailed2;
window.ninetailed = Object.assign({}, window.ninetailed, {
plugins: Object.assign({}, (_window$ninetailed2 = window.ninetailed) == null ? void 0 : _window$ninetailed2.plugins, {
preview: this.windowApi
})
});
}
this.bridge.updateProps({
props: this.pluginApi
});
this.onChangeEmitter.invokeListeners();
};
this.onProfileChange = (profile, changes) => {
this.profile = profile;
logger.debug('Profile changed:', {
profile,
changes
});
// If changes are provided, update them
if (changes) {
this.onChangesChange(changes);
}
this.onChange();
};
/**
* Handles changes from the SDK and applies any variable overrides.
* This should be called whenever the original changes are updated.
*/
this.onChangesChange = incomingChanges => {
if (!this.isActiveInstance) {
return;
}
logger.debug('Received changes:', incomingChanges);
// Store the original changes
this.changes = incomingChanges;
// Notify listeners and update UI
this.onChange();
};
this.setCredentials = ({
clientId,
environment
}) => {
this.clientId = clientId;
this.environment = environment;
};
this.options = options;
this.experiences = options.experiences || [];
this.audienceDefinitions = options.audiences || [];
this.onOpenExperienceEditor = options.onOpenExperienceEditor;
this.onOpenAudienceEditor = options.onOpenAudienceEditor;
}
open() {
var _this$container;
if (!this.isActiveInstance) {
return;
}
(_this$container = this.container) == null || _this$container.open();
this.isOpen = true;
this.onChange();
}
close() {
var _this$container2;
if (!this.isActiveInstance) {
return;
}
(_this$container2 = this.container) == null || _this$container2.close();
setTimeout(() => {
this.isOpen = false;
this.onChange();
}, 700);
}
toggle() {
if (!this.isActiveInstance) {
return;
}
if (this.isOpen) {
this.close();
} else {
this.open();
}
}
activateAudience(id) {
if (!this.isActiveInstance) {
return;
}
if (!this.isKnownAudience(id)) {
logger.warn(`You cannot activate an unknown audience (id: ${id}).`);
return;
}
this.audienceOverwrites = Object.assign({}, this.audienceOverwrites, {
[id]: true
});
this.experienceVariantIndexOverwrites = Object.assign({}, this.experienceVariantIndexOverwrites, this.experiences.filter(experience => {
var _experience$audience;
return ((_experience$audience = experience.audience) == null ? void 0 : _experience$audience.id) === id;
}).map(experience => experience.id).reduce((acc, curr) => {
return Object.assign({}, acc, {
[curr]: this.experienceVariantIndexes[curr] || 0
});
}, {}));
this.onChange();
}
deactivateAudience(id) {
if (!this.isActiveInstance) {
return;
}
if (!this.isKnownAudience(id)) {
logger.warn(`You cannot deactivate an unknown audience (id: ${id}). How did you get it in the first place?`);
return;
}
this.experienceVariantIndexOverwrites = Object.entries(this.experienceVariantIndexOverwrites).filter(([key, _]) => {
return !this.experiences.filter(experience => {
var _experience$audience2;
return ((_experience$audience2 = experience.audience) == null ? void 0 : _experience$audience2.id) === id;
}).map(experience => experience.id).includes(key);
}).reduce((acc, [key, value]) => {
return Object.assign({}, acc, {
[key]: value
});
}, {});
this.audienceOverwrites = Object.assign({}, this.audienceOverwrites, {
[id]: false
});
this.onChange();
this.experiences.filter(experience => {
var _experience$audience3;
return ((_experience$audience3 = experience.audience) == null ? void 0 : _experience$audience3.id) === id;
}).forEach(experience => {
this.setExperienceVariant({
experienceId: experience.id,
variantIndex: 0
});
});
this.audienceOverwrites = Object.assign({}, this.audienceOverwrites, {
[id]: false
});
this.onChange();
}
resetAudience(id) {
if (!this.isActiveInstance) {
return;
}
if (!this.isKnownAudience(id)) {
logger.warn(`You cannot reset an unknown audience (id: ${id}). How did you get it in the first place?`);
return;
}
const _this$audienceOverwri = this.audienceOverwrites,
audienceOverwrites = _objectWithoutPropertiesLoose(_this$audienceOverwri, [id].map(_toPropertyKey));
this.audienceOverwrites = audienceOverwrites;
this.onChange();
}
setExperienceVariant({
experienceId,
variantIndex
}) {
if (!this.isActiveInstance) {
return;
}
const experience = this.experiences.find(experience => experience.id === experienceId);
if (!experience) {
logger.warn(`Cannot activate a variant for an unknown experience (id: ${experienceId})`);
return;
}
if (experience.audience && !this.activeAudiences.some(id => {
var _experience$audience4;
return id === ((_experience$audience4 = experience.audience) == null ? void 0 : _experience$audience4.id);
})) {
logger.warn(`Cannot activate a variant for an experience (id: ${experienceId}) which is not in the active audiences.`);
return;
}
const isValidIndex = experience.components.map(component => component.variants.length + 1).every(length => length > variantIndex);
if (!isValidIndex) {
logger.warn(`You activated a variant at index ${variantIndex} for the experience (id: ${experienceId}). Not all components have that many variants, you may see the baseline for some.`);
}
// Update the experience variant index
this.experienceVariantIndexOverwrites = Object.assign({}, this.experienceVariantIndexOverwrites, {
[experienceId]: variantIndex
});
// Process all components to extract variable values
experience.components.forEach(component => {
if (component.type === ComponentTypeEnum.InlineVariable) {
var _component$variants$v, _component$variants;
const key = component.key;
const value = variantIndex === 0 ? component.baseline.value : (_component$variants$v = (_component$variants = component.variants[variantIndex - 1]) == null ? void 0 : _component$variants.value) != null ? _component$variants$v : component.baseline.value;
// Set the variable value
this.setVariableValue({
experienceId,
key,
value,
variantIndex
});
}
});
// Trigger change notification - this updates the middleware
this.onChange();
}
resetExperience(experienceId) {
if (!this.isActiveInstance) {
return;
}
const _this$experienceVaria = this.experienceVariantIndexOverwrites,
experienceVariantIndexOverwrites = _objectWithoutPropertiesLoose(_this$experienceVaria, [experienceId].map(_toPropertyKey));
this.experienceVariantIndexOverwrites = experienceVariantIndexOverwrites;
this.onChange();
}
reset() {
if (!this.isActiveInstance) {
return;
}
if (typeof window !== 'undefined' && window.ninetailed && typeof window.ninetailed.reset === 'function') {
window.ninetailed.reset();
}
}
/**
* Sets a variable value override for preview
*/
setVariableValue({
experienceId,
key,
value,
variantIndex
}) {
var _this$variableOverwri;
if (!this.isActiveInstance) {
return;
}
const overrideKey = `${experienceId}:${key}`;
// Only create new object if actually changing
if (((_this$variableOverwri = this.variableOverwrites[overrideKey]) == null ? void 0 : _this$variableOverwri.value) === value) {
return; // No change needed
}
const change = {
type: ChangeTypes.Variable,
key,
value,
meta: {
experienceId,
variantIndex
}
};
// Update variable overwrites
this.variableOverwrites = Object.assign({}, this.variableOverwrites, {
[overrideKey]: change
});
// Notify listeners
this.onChangeEmitter.invokeListeners();
this.onChange();
}
openExperienceEditor(experience) {
if (this.onOpenExperienceEditor && typeof this.onOpenExperienceEditor === 'function') return this.onOpenExperienceEditor(experience);
}
/**
* @deprecated This method will be removed in a future release. Use `openExperienceEditor` instead to see the experience insights.
*/
openExperienceAnalytics(experience) {
logger.warn('The `openExperienceAnalytics` method is deprecated and will be removed in a future release. Use `openExperienceEditor` instead to see the experience insights.');
window.open(`https://app.ninetailed.io/${this.clientId}/${this.environment}/experiences/${experience.id}`, '_blank');
}
openAudienceEditor(audience) {
if (this.onOpenAudienceEditor && typeof this.onOpenAudienceEditor === 'function') return this.onOpenAudienceEditor(audience);
}
get pluginApi() {
var _this$profile;
return {
version: "7.17.0" ,
open: this.open.bind(this),
close: this.close.bind(this),
toggle: this.toggle.bind(this),
isOpen: this.isOpen,
activateAudience: this.activateAudience.bind(this),
deactivateAudience: this.deactivateAudience.bind(this),
resetAudience: this.resetAudience.bind(this),
apiAudiences: ((_this$profile = this.profile) == null ? void 0 : _this$profile.audiences) || [],
audienceOverwrites: this.audienceOverwrites,
activeAudiences: this.activeAudiences,
audienceDefinitions: this.audienceDefinitions,
setExperienceVariant: this.setExperienceVariant.bind(this),
resetExperience: this.resetExperience.bind(this),
apiExperienceVariantIndexes: this.apiExperienceVariantIndexes,
experienceVariantIndexes: Object.assign({}, this.experienceVariantIndexes, this.experienceVariantIndexOverwrites),
experienceVariantIndexOverwrites: this.experienceVariantIndexOverwrites,
reset: this.reset.bind(this),
experiences: this.experiences,
openExperienceEditor: this.onOpenExperienceEditor ? this.openExperienceEditor.bind(this) : undefined,
openExperienceAnalytics: this.openExperienceAnalytics.bind(this),
openAudienceEditor: this.onOpenAudienceEditor ? this.openAudienceEditor.bind(this) : undefined
};
}
get windowApi() {
return {
version: '2.0.0',
open: this.open.bind(this),
close: this.close.bind(this),
toggle: this.toggle.bind(this),
activateAudience: this.activateAudience.bind(this),
deactivateAudience: this.deactivateAudience.bind(this),
resetAudience: this.resetAudience.bind(this),
activeAudiences: this.activeAudiences,
setExperienceVariant: this.setExperienceVariant.bind(this),
resetExperience: this.resetExperience.bind(this),
experienceVariantIndexes: Object.assign({}, this.experienceVariantIndexes, this.experienceVariantIndexOverwrites),
setVariableValue: this.setVariableValue.bind(this),
variableOverwrites: this.variableOverwrites
};
}
isKnownAudience(id) {
return this.potentialAudiences.some(audience => audience.id === id);
}
get potentialAudiences() {
const audiencesFromExperiences = this.experiences.map(experience => experience.audience).filter(audience => !!audience);
return unionBy(this.audienceDefinitions, audiencesFromExperiences, 'id');
}
get activeAudiences() {
var _this$profile2;
const deactivatedAudiences = Object.entries(this.audienceOverwrites).filter(([id, active]) => !active).map(([id]) => id);
const activatedAudiences = Object.entries(this.audienceOverwrites).filter(([id, active]) => active).map(([id]) => id);
return [...(((_this$profile2 = this.profile) == null ? void 0 : _this$profile2.audiences) || []), ...activatedAudiences].filter(id => !deactivatedAudiences.includes(id));
}
calculateExperienceVariantIndexes(profile) {
const matchedExperiences = this.experiences.filter(experience => isExperienceMatch({
experience,
profile
}));
return matchedExperiences.reduce((acc, experience) => {
const distribution = selectDistribution({
experience,
profile
});
return Object.assign({}, acc, {
[experience.id]: distribution.index
});
}, {});
}
get apiExperienceVariantIndexes() {
const profile = this.profile;
if (!profile) {
return {};
}
return this.calculateExperienceVariantIndexes(profile);
}
get experienceVariantIndexes() {
const profile = this.profile;
if (!profile) {
return {};
}
return this.calculateExperienceVariantIndexes(Object.assign({}, profile, {
audiences: this.activeAudiences
}));
}
/**
* Get the override key for a variable
*/
getOverrideKey(experienceId, key) {
return `${experienceId}:${key}`;
}
/**
* Get effective changes by applying overrides - compute on demand
*/
getEffectiveChanges(inputChanges = this.changes) {
if (!inputChanges || Object.keys(this.variableOverwrites).length === 0) {
return inputChanges || [];
}
// Filter out changes that we're overriding
const filteredChanges = inputChanges.filter(change => {
var _change$meta;
if (change.type !== ChangeTypes.Variable) return true;
const changeKey = (_change$meta = change.meta) != null && _change$meta.experienceId ? this.getOverrideKey(change.meta.experienceId, change.key) : change.key;
return !this.variableOverwrites[changeKey];
});
const effectiveChanges = [...filteredChanges, ...Object.values(this.variableOverwrites)];
logger.debug('Overridden changes after applying override:', effectiveChanges);
// Add our overrides to create the final result
return effectiveChanges;
}
}
if (typeof window === 'object' && !('process' in window)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
window.process = {};
}
export { NinetailedPreviewPlugin, NinetailedPreviewPlugin as default };