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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## Version 25.03.xx
Enterprise Features:
- [journey_engine] dynamic parameter support in deeplinks

## Version 25.03.49
Fixes:
- [journey_engine] Added user merge handling: running journeys are remapped to the surviving user, keeping the furthest-progressed instance and stopping duplicates
Expand Down
361 changes: 361 additions & 0 deletions frontend/express/public/javascripts/countly/vue/components/content.js
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,7 @@

const COUNTLY_CONTENT_SIDEBAR_INPUT_COMPONENT_BY_TYPE_COLOR_PICKER = 'color-picker';
const COUNTLY_CONTENT_SIDEBAR_INPUT_COMPONENT_BY_TYPE_DROPDOWN = 'dropdown';
const COUNTLY_CONTENT_SIDEBAR_INPUT_COMPONENT_BY_TYPE_DYNAMIC_PARAMS = 'dynamic-params';
const COUNTLY_CONTENT_SIDEBAR_INPUT_COMPONENT_BY_TYPE_IMAGE_RADIO = 'image-radio';
const COUNTLY_CONTENT_SIDEBAR_INPUT_COMPONENT_BY_TYPE_INPUT = 'input';
const COUNTLY_CONTENT_SIDEBAR_INPUT_COMPONENT_BY_TYPE_LIST_BLOCK = 'list-block';
Expand Down Expand Up @@ -514,6 +515,7 @@
const COUNTLY_CONTENT_SIDEBAR_INPUT_COMPONENT_BY_TYPE = {
[COUNTLY_CONTENT_SIDEBAR_INPUT_COMPONENT_BY_TYPE_COLOR_PICKER]: 'cly-colorpicker',
[COUNTLY_CONTENT_SIDEBAR_INPUT_COMPONENT_BY_TYPE_DROPDOWN]: 'el-select',
[COUNTLY_CONTENT_SIDEBAR_INPUT_COMPONENT_BY_TYPE_DYNAMIC_PARAMS]: 'cly-content-dynamic-params-input',
[COUNTLY_CONTENT_SIDEBAR_INPUT_COMPONENT_BY_TYPE_IMAGE_RADIO]: 'div',
[COUNTLY_CONTENT_SIDEBAR_INPUT_COMPONENT_BY_TYPE_INPUT]: 'el-input',
[COUNTLY_CONTENT_SIDEBAR_INPUT_COMPONENT_BY_TYPE_LIST_BLOCK]: 'cly-content-block-list-input',
Expand Down Expand Up @@ -666,6 +668,10 @@
return +this.value || 0;
}

if (this.type === COUNTLY_CONTENT_SIDEBAR_INPUT_COMPONENT_BY_TYPE_DYNAMIC_PARAMS) {
return countlyCommon.unescapeHtml(this.value) || '';
}

if (this.isListBlockInput) {
return null;
}
Expand Down Expand Up @@ -826,6 +832,361 @@
template: CV.T('/javascripts/countly/vue/templates/content/UI/content-block-list-input.html'),
}));

// SER-2915: matches a full `{property|fallback|c}` placeholder value —
// fallback may be empty ({property||c}), so the flag segment is unambiguous.
const CONTENT_DYNAMIC_PARAM_PLACEHOLDER_RE = /^\{([^{}|]+)(?:\|([^{}|]*))?(?:\|(c))?\}$/;

const CONTENT_DYNAMIC_PARAM_NODE_CLASS = 'cly-vue-content-dynamic-params-input__param';
const CONTENT_DYNAMIC_PARAM_NODE_STYLE = 'background: #E1EFFF; color: #0166D6; border-radius: 3px; padding: 0 3px; cursor: pointer;';
const CONTENT_STATIC_PARAM_NODE_STYLE = 'background: #E2E4E8; color: #383A3F; border-radius: 3px; padding: 0 3px; cursor: pointer;';

// SER-2915: URL/deep-link editor for content builder button actions with
// inline dynamic parameter support — mirrors the push notifications Add
// User Property popover (internal/external property tabs, property
// selector, capitalize switch and fallback value) plus a parameter name
// input and a static value mode. The URL is edited in a contenteditable
// field where every `name=value` query parameter is rendered as a
// highlighted, non-editable token (blue for `{property|fallback|c}`
// placeholders, gray for static values); clicking a token reopens the
// popover pre-filled so the parameter can be changed or removed.
// Placeholders are resolved per targeted user when the content is served.
Vue.component('cly-content-dynamic-params-input', countlyVue.components.create({
props: {
disabled: {
default: false,
type: Boolean
},

options: {
default: () => [],
type: Array
},

testId: {
default: 'content-dynamic-params-input',
type: String
},

value: {
default: '',
type: String
}
},

emits: [
'input'
],

mixins: [countlyVue.mixins.i18n],

data() {
return {
editingIndex: null,
isPanelOpen: false,
lastEmittedValue: '',
property: {
fallback: '',
isUppercase: false,
key: '',
staticValue: '',
value: ''
},
selectedPropertyCategory: 'internal'
};
},

computed: {
isConfirmDisabled() {
if (!this.property.key.trim()) {
return true;
}

if (this.selectedPropertyCategory === 'static') {
return !this.property.staticValue.trim();
}

return !this.property.value;
},

panelTitle() {
return this.editingIndex === null ?
this.i18n('content.dynamic-params.add-user-property') :
this.i18n('content.dynamic-params.edit-parameter');
},

propertyCategoryOptions() {
return [
{ label: this.i18n('content.dynamic-params.internal-properties'), value: 'internal' },
{ label: this.i18n('content.dynamic-params.external-properties'), value: 'external' },
{ label: this.i18n('content.dynamic-params.static-value'), value: 'static' }
];
}
},

watch: {
value(newValue) {
// rebuild only on external changes — echoes of our own input
// events must not reset the editor (it would move the caret)
if ((newValue || '') !== this.lastEmittedValue) {
this.lastEmittedValue = newValue || '';
this.$nextTick(this.rebuildEditor);
}
}
},

mounted() {
this.lastEmittedValue = this.value || '';
this.rebuildEditor();
},

methods: {
buildParamValue() {
if (this.selectedPropertyCategory === 'static') {
return encodeURIComponent(this.property.staticValue.trim());
}

// reserved characters would break placeholder/query string parsing
const fallback = this.property.fallback.replace(/[{}|&=?#]/g, '');
let placeholder = '{' + this.property.value;

if (fallback || this.property.isUppercase) {
placeholder += '|' + fallback;
}

if (this.property.isUppercase) {
placeholder += '|c';
}

return placeholder + '}';
},

buildUrl(parsed) {
return parsed.path + (parsed.params.length ? '?' + parsed.params.join('&') : '') + parsed.hash;
},

createParamNode(pair) {
const eqIndex = pair.indexOf('=');
const rawValue = eqIndex === -1 ? '' : pair.slice(eqIndex + 1);
const isDynamic = CONTENT_DYNAMIC_PARAM_PLACEHOLDER_RE.test(rawValue);
const node = document.createElement('span');

node.textContent = pair;
node.setAttribute('contenteditable', 'false');
node.className = CONTENT_DYNAMIC_PARAM_NODE_CLASS;
node.style.cssText = isDynamic ? CONTENT_DYNAMIC_PARAM_NODE_STYLE : CONTENT_STATIC_PARAM_NODE_STYLE;

return node;
},

findPropertyOption(value) {
for (let i = 0; i < (this.options || []).length; i++) {
const found = (this.options[i].options || []).find(option => option.value === value);

if (found) {
return found;
}
}

return null;
},

onAddButtonClick() {
if (this.isPanelOpen) {
this.resetPanel();
return;
}

this.resetPanelState();
this.isPanelOpen = true;
},

onCancel() {
this.resetPanel();
},

onCategoryChange() {
this.property.staticValue = '';
this.property.value = '';
},

onConfirm() {
if (this.isConfirmDisabled) {
return;
}

const pair = encodeURIComponent(this.property.key.trim()) + '=' + this.buildParamValue();
const parsed = this.parseUrl(this.serializeEditor());

if (this.editingIndex !== null && this.editingIndex < parsed.params.length) {
parsed.params[this.editingIndex] = pair;
}
else {
parsed.params.push(pair);
}

this.$emit('input', this.buildUrl(parsed));
this.resetPanel();
},

onEditorClick(event) {
if (this.disabled || !event.target || !event.target.closest) {
return;
}

const paramNode = event.target.closest('.' + CONTENT_DYNAMIC_PARAM_NODE_CLASS);

if (!paramNode || !this.$refs.editor || !this.$refs.editor.contains(paramNode)) {
return;
}

const pair = paramNode.textContent || '';
const index = this.parseUrl(this.serializeEditor()).params.indexOf(pair);

if (index !== -1) {
this.openPanelForParam(index, pair);
}
},

onEditorInput() {
this.lastEmittedValue = this.serializeEditor();
this.$emit('input', this.lastEmittedValue);
},

onEditorPaste(event) {
// paste as plain text so foreign markup never enters the editor
event.preventDefault();

const clipboard = event.clipboardData || window.clipboardData;
const text = clipboard ? (clipboard.getData('text/plain') || '').replace(/[\r\n]+/g, '') : '';

if (text) {
document.execCommand('insertText', false, text);
}
},

onRemoveParam() {
if (this.editingIndex !== null) {
const parsed = this.parseUrl(this.serializeEditor());

if (this.editingIndex < parsed.params.length) {
parsed.params.splice(this.editingIndex, 1);
this.$emit('input', this.buildUrl(parsed));
}
}

this.resetPanel();
},

onPropertySelect(value) {
this.property.value = value;
},

openPanelForParam(index, pair) {
this.resetPanelState();
this.editingIndex = index;

const eqIndex = pair.indexOf('=');
const rawKey = eqIndex === -1 ? pair : pair.slice(0, eqIndex);
const rawValue = eqIndex === -1 ? '' : pair.slice(eqIndex + 1);

this.property.key = this.safeDecode(rawKey);

const match = rawValue.match(CONTENT_DYNAMIC_PARAM_PLACEHOLDER_RE);

if (match) {
this.property.value = match[1];
this.property.fallback = match[2] || '';
this.property.isUppercase = !!match[3];
this.selectedPropertyCategory = this.findPropertyOption(match[1]) ? 'internal' : 'external';
}
else {
this.property.staticValue = this.safeDecode(rawValue);
this.selectedPropertyCategory = 'static';
}

this.isPanelOpen = true;
},

parseUrl(url) {
const hashIndex = url.indexOf('#');
const hash = hashIndex === -1 ? '' : url.slice(hashIndex);
const base = hashIndex === -1 ? url : url.slice(0, hashIndex);
const queryIndex = base.indexOf('?');

return {
hash,
params: queryIndex === -1 ? [] : base.slice(queryIndex + 1).split('&').filter(param => param !== ''),
path: queryIndex === -1 ? base : base.slice(0, queryIndex)
};
},

rebuildEditor() {
const editor = this.$refs.editor;

if (!editor) {
return;
}

while (editor.firstChild) {
editor.removeChild(editor.firstChild);
}

const parsed = this.parseUrl(this.value || '');

if (parsed.path) {
editor.appendChild(document.createTextNode(parsed.path));
}

parsed.params.forEach((pair, index) => {
editor.appendChild(document.createTextNode(index === 0 ? '?' : '&'));
editor.appendChild(this.createParamNode(pair));
});

if (parsed.hash) {
editor.appendChild(document.createTextNode(parsed.hash));
}
},

resetPanel() {
this.resetPanelState();
this.isPanelOpen = false;
},

resetPanelState() {
this.editingIndex = null;
this.selectedPropertyCategory = 'internal';
this.property = {
fallback: '',
isUppercase: false,
key: '',
staticValue: '',
value: ''
};
},

safeDecode(value) {
try {
return decodeURIComponent(value);
}
catch (error) {
return value;
}
},

serializeEditor() {
const editor = this.$refs.editor;

if (!editor) {
return '';
}

return (editor.textContent || '').replace(/[\r\n]+/g, '').replace(/\u00a0/g, ' ');
}
},

template: CV.T('/javascripts/countly/vue/templates/content/UI/content-dynamic-params-input.html')
}));

Vue.component("cly-option-swapper", countlyVue.components.create({
template: CV.T('/javascripts/countly/vue/templates/UI/option-swapper.html'),

Expand Down
Loading
Loading