diff --git a/CHANGELOG.md b/CHANGELOG.md index 85854d36fcc..d60fbe7171b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/frontend/express/public/javascripts/countly/vue/components/content.js b/frontend/express/public/javascripts/countly/vue/components/content.js index e4e84018130..32c64ecdd58 100644 --- a/frontend/express/public/javascripts/countly/vue/components/content.js +++ b/frontend/express/public/javascripts/countly/vue/components/content.js @@ -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'; @@ -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', @@ -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; } @@ -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'), diff --git a/frontend/express/public/javascripts/countly/vue/templates/content/UI/content-dynamic-params-input.html b/frontend/express/public/javascripts/countly/vue/templates/content/UI/content-dynamic-params-input.html new file mode 100644 index 00000000000..5e28f2b165c --- /dev/null +++ b/frontend/express/public/javascripts/countly/vue/templates/content/UI/content-dynamic-params-input.html @@ -0,0 +1,150 @@ +