From 69d68e8a4150c186badf10cb6ea5ab69df834f29 Mon Sep 17 00:00:00 2001 From: Coskun Aydinoglu Date: Tue, 14 Jul 2026 07:56:02 +0000 Subject: [PATCH 01/10] SER-2915: add dynamic parameters input to content builder core UI Adds a new 'dynamic-params' sidebar input type with the cly-content-dynamic-params-input component: a repeater of parameter name / user property / fallback rows used by the content editor to attach dynamic query string parameters to URL/deep-link button actions. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AmXBxfgpFtGBFhDJrR7A8r --- .../countly/vue/components/content.js | 81 +++++++++++++++++++ .../UI/content-dynamic-params-input.html | 71 ++++++++++++++++ .../dashboard/dashboard.properties | 7 ++ 3 files changed, 159 insertions(+) create mode 100644 frontend/express/public/javascripts/countly/vue/templates/content/UI/content-dynamic-params-input.html diff --git a/frontend/express/public/javascripts/countly/vue/components/content.js b/frontend/express/public/javascripts/countly/vue/components/content.js index e4e84018130..c892a4992d5 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 Array.isArray(this.value) ? this.value : []; + } + if (this.isListBlockInput) { return null; } @@ -826,6 +832,81 @@ template: CV.T('/javascripts/countly/vue/templates/content/UI/content-block-list-input.html'), })); + // SER-2915: repeater used by the content builder to attach dynamic query + // string parameters to URL/deep-link button actions. Each row holds the + // parameter name, the user property that provides its value at send time + // and a fallback used when the property is missing on the target user. + 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: Array + } + }, + + emits: [ + 'input' + ], + + mixins: [countlyVue.mixins.i18n], + + computed: { + groupedOptions() { + return (this.options || []).filter(group => Array.isArray(group.options) && group.options.length); + }, + + rows() { + return Array.isArray(this.value) ? this.value : []; + } + }, + + methods: { + emitRows(rows) { + this.$emit('input', rows); + }, + + onAddRow() { + this.emitRows(this.rows.concat([{ fallback: '', key: '', property: null }])); + }, + + onRemoveRow(index) { + const rows = this.rows.slice(); + + rows.splice(index, 1); + this.emitRows(rows); + }, + + onRowFieldChange(index, field, fieldValue) { + const rows = this.rows.map((row, rowIndex) => { + if (rowIndex !== index) { + return row; + } + + return { ...row, [field]: fieldValue }; + }); + + this.emitRows(rows); + } + }, + + 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..937ce3a4d93 --- /dev/null +++ b/frontend/express/public/javascripts/countly/vue/templates/content/UI/content-dynamic-params-input.html @@ -0,0 +1,71 @@ +
+
+
+ {{ i18n('content.dynamic-params.parameter', index + 1) }} + +
+ + + + + + + +
+ + {{ i18n('content.dynamic-params.add') }} + +
diff --git a/frontend/express/public/localization/dashboard/dashboard.properties b/frontend/express/public/localization/dashboard/dashboard.properties index 741803e6ec4..6ba3790c726 100644 --- a/frontend/express/public/localization/dashboard/dashboard.properties +++ b/frontend/express/public/localization/dashboard/dashboard.properties @@ -1268,3 +1268,10 @@ initial-setup.newsletter-question = Would you be interested in subscribing to ou initial-setup.newsletter-no = No, thank you. initial-setup.newsletter-yes = Yes, subscribe me to the newsletter initial-setup.quickstart-title = Quick Start Guide + +#content builder dynamic parameters input +content.dynamic-params.parameter = Parameter {0} +content.dynamic-params.parameter-name = Parameter name +content.dynamic-params.user-property = Select user property +content.dynamic-params.fallback = Fallback value +content.dynamic-params.add = Add parameter From 4414008c4da8cc06f127c8d6255b45d64de07b3c Mon Sep 17 00:00:00 2001 From: Coskun Aydinoglu Date: Tue, 14 Jul 2026 09:43:37 +0000 Subject: [PATCH 02/10] SER-2915: rework dynamic deeplink params UI to Add User Property popover Replaces the parameter repeater with a popover matching the push notifications Add User Property popover: internal/external property tabs, the same cly-select-x property selector, "start with capital letter" switch and fallback value. Confirming appends a visible {property|fallback|c} placeholder to the URL/deeplink input. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AmXBxfgpFtGBFhDJrR7A8r --- .../countly/vue/components/content.js | 95 ++++++++---- .../UI/content-dynamic-params-input.html | 146 +++++++++++------- .../dashboard/dashboard.properties | 19 ++- 3 files changed, 168 insertions(+), 92 deletions(-) diff --git a/frontend/express/public/javascripts/countly/vue/components/content.js b/frontend/express/public/javascripts/countly/vue/components/content.js index c892a4992d5..93bb6385df1 100644 --- a/frontend/express/public/javascripts/countly/vue/components/content.js +++ b/frontend/express/public/javascripts/countly/vue/components/content.js @@ -669,7 +669,7 @@ } if (this.type === COUNTLY_CONTENT_SIDEBAR_INPUT_COMPONENT_BY_TYPE_DYNAMIC_PARAMS) { - return Array.isArray(this.value) ? this.value : []; + return countlyCommon.unescapeHtml(this.value) || ''; } if (this.isListBlockInput) { @@ -832,10 +832,12 @@ template: CV.T('/javascripts/countly/vue/templates/content/UI/content-block-list-input.html'), })); - // SER-2915: repeater used by the content builder to attach dynamic query - // string parameters to URL/deep-link button actions. Each row holds the - // parameter name, the user property that provides its value at send time - // and a fallback used when the property is missing on the target user. + // SER-2915: "Add User Property" popover for URL/deep-link button actions in + // the content builder — mirrors the push notifications Add User Property + // popover (internal/external property tabs, property selector, capitalize + // switch and fallback value). Confirming appends a visible + // `{property|fallback|c}` placeholder to the action URL, which is resolved + // per targeted user when the content is served. Vue.component('cly-content-dynamic-params-input', countlyVue.components.create({ props: { disabled: { @@ -854,8 +856,8 @@ }, value: { - default: () => [], - type: Array + default: '', + type: String } }, @@ -865,42 +867,79 @@ mixins: [countlyVue.mixins.i18n], + data() { + return { + isPanelOpen: false, + property: { + fallback: '', + isUppercase: false, + value: '' + }, + selectedPropertyCategory: 'internal' + }; + }, + computed: { - groupedOptions() { - return (this.options || []).filter(group => Array.isArray(group.options) && group.options.length); + isConfirmDisabled() { + return !this.property.value; }, - rows() { - return Array.isArray(this.value) ? this.value : []; + propertyCategoryOptions() { + return [ + { label: this.i18n('content.dynamic-params.internal-properties'), value: 'internal' }, + { label: this.i18n('content.dynamic-params.external-properties'), value: 'external' } + ]; + } + }, + + watch: { + selectedPropertyCategory() { + this.property.value = ''; } }, methods: { - emitRows(rows) { - this.$emit('input', rows); + onAddButtonClick() { + this.isPanelOpen = !this.isPanelOpen; }, - onAddRow() { - this.emitRows(this.rows.concat([{ fallback: '', key: '', property: null }])); + onCancel() { + this.resetPanel(); }, - onRemoveRow(index) { - const rows = this.rows.slice(); + onConfirm() { + if (this.isConfirmDisabled) { + return; + } - rows.splice(index, 1); - this.emitRows(rows); - }, + let placeholder = '{' + this.property.value; - onRowFieldChange(index, field, fieldValue) { - const rows = this.rows.map((row, rowIndex) => { - if (rowIndex !== index) { - return row; - } + if (this.property.fallback || this.property.isUppercase) { + placeholder += '|' + this.property.fallback; + } - return { ...row, [field]: fieldValue }; - }); + if (this.property.isUppercase) { + placeholder += '|c'; + } - this.emitRows(rows); + placeholder += '}'; + + this.$emit('input', (this.value || '') + placeholder); + this.resetPanel(); + }, + + onPropertySelect(value) { + this.property.value = value; + }, + + resetPanel() { + this.isPanelOpen = false; + this.selectedPropertyCategory = 'internal'; + this.property = { + fallback: '', + isUppercase: false, + value: '' + }; } }, 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 index 937ce3a4d93..c92c63c5c16 100644 --- 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 @@ -2,70 +2,100 @@ class="cly-vue-content-dynamic-params-input" :data-test-id="testId" > -
-
- {{ i18n('content.dynamic-params.parameter', index + 1) }} - -
- - - - - - - -
- {{ i18n('content.dynamic-params.add') }} + {{ i18n('content.dynamic-params.add-user-property') }} +
+
+ {{ i18n('content.dynamic-params.add-user-property') }} +
+
+ + + {{ item.label }} + + +
+ + + + {{ i18n('content.dynamic-params.start-with-capital-letter') }} + +
{{ i18n('content.dynamic-params.fallback-value') }}
+ +
+ + {{ i18n('common.cancel') }} + + + {{ i18n('content.dynamic-params.confirm') }} + +
+
diff --git a/frontend/express/public/localization/dashboard/dashboard.properties b/frontend/express/public/localization/dashboard/dashboard.properties index 6ba3790c726..2d88122454d 100644 --- a/frontend/express/public/localization/dashboard/dashboard.properties +++ b/frontend/express/public/localization/dashboard/dashboard.properties @@ -1269,9 +1269,16 @@ initial-setup.newsletter-no = No, thank you. initial-setup.newsletter-yes = Yes, subscribe me to the newsletter initial-setup.quickstart-title = Quick Start Guide -#content builder dynamic parameters input -content.dynamic-params.parameter = Parameter {0} -content.dynamic-params.parameter-name = Parameter name -content.dynamic-params.user-property = Select user property -content.dynamic-params.fallback = Fallback value -content.dynamic-params.add = Add parameter +#content builder dynamic deeplink parameters (add user property popover) +content.dynamic-params.add-user-property = Add User Property +content.dynamic-params.internal-properties = Internal Properties +content.dynamic-params.external-properties = External Properties +content.dynamic-params.property = Property +content.dynamic-params.api-property = API Property +content.dynamic-params.enter-value = Enter value +content.dynamic-params.select-property = Select Property +content.dynamic-params.search-in-properties = Search in Properties +content.dynamic-params.start-with-capital-letter = Make user property start with capital letter +content.dynamic-params.fallback-value = Fallback value +content.dynamic-params.enter-fallback-value = Enter fallback value +content.dynamic-params.confirm = Confirm From 8b7a7c11c7605c47cad24aac24d104d723c4c3ee Mon Sep 17 00:00:00 2001 From: Coskun Aydinoglu Date: Tue, 14 Jul 2026 12:01:47 +0000 Subject: [PATCH 03/10] SER-2915: add parameter name input to Add User Property popover Unlike push notifications, deep-link placeholders are query string parameters, so the popover now also asks for the parameter name and appends `name={property|fallback|c}` to the URL with the proper ?/& separator (keeping any hash fragment at the end). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AmXBxfgpFtGBFhDJrR7A8r --- .../countly/vue/components/content.js | 30 +++++++++++++++---- .../UI/content-dynamic-params-input.html | 8 +++++ .../dashboard/dashboard.properties | 2 ++ 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/frontend/express/public/javascripts/countly/vue/components/content.js b/frontend/express/public/javascripts/countly/vue/components/content.js index 93bb6385df1..1abf927d464 100644 --- a/frontend/express/public/javascripts/countly/vue/components/content.js +++ b/frontend/express/public/javascripts/countly/vue/components/content.js @@ -835,9 +835,10 @@ // SER-2915: "Add User Property" popover for URL/deep-link button actions in // the content builder — mirrors the push notifications Add User Property // popover (internal/external property tabs, property selector, capitalize - // switch and fallback value). Confirming appends a visible - // `{property|fallback|c}` placeholder to the action URL, which is resolved - // per targeted user when the content is served. + // switch and fallback value) plus a parameter name input. Confirming + // appends a visible `name={property|fallback|c}` query parameter to the + // action URL, which is resolved per targeted user when the content is + // served. Vue.component('cly-content-dynamic-params-input', countlyVue.components.create({ props: { disabled: { @@ -873,6 +874,7 @@ property: { fallback: '', isUppercase: false, + key: '', value: '' }, selectedPropertyCategory: 'internal' @@ -881,7 +883,7 @@ computed: { isConfirmDisabled() { - return !this.property.value; + return !this.property.value || !this.property.key.trim(); }, propertyCategoryOptions() { @@ -899,6 +901,21 @@ }, methods: { + // appends `name={property}` as a query parameter, keeping any hash + // fragment at the end and picking the right ?/& separator + appendQueryParam(url, pair) { + const hashIndex = url.indexOf('#'); + const hash = hashIndex === -1 ? '' : url.slice(hashIndex); + const base = hashIndex === -1 ? url : url.slice(0, hashIndex); + let separator = base.indexOf('?') === -1 ? '?' : '&'; + + if (base.endsWith('?') || base.endsWith('&')) { + separator = ''; + } + + return base + separator + pair + hash; + }, + onAddButtonClick() { this.isPanelOpen = !this.isPanelOpen; }, @@ -924,7 +941,9 @@ placeholder += '}'; - this.$emit('input', (this.value || '') + placeholder); + const pair = encodeURIComponent(this.property.key.trim()) + '=' + placeholder; + + this.$emit('input', this.appendQueryParam(this.value || '', pair)); this.resetPanel(); }, @@ -938,6 +957,7 @@ this.property = { fallback: '', isUppercase: false, + key: '', value: '' }; } 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 index c92c63c5c16..5c4ac660ff8 100644 --- 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 @@ -22,6 +22,14 @@
{{ i18n('content.dynamic-params.add-user-property') }}
+
{{ i18n('content.dynamic-params.parameter-name') }}
+
Date: Tue, 14 Jul 2026 12:29:13 +0000 Subject: [PATCH 04/10] SER-2915: static value params and editable parameter chips Two additions to the deep-link parameter editor popover: - a "Static Value" mode next to internal/external properties, for hardcoded parameters like param5=param5value (value URL-encoded, served as-is) - every query parameter in the URL is now shown as a highlighted chip under the URL input; clicking a chip reopens the popover pre-filled (parameter name, property/fallback/capitalize or static value) and Confirm updates that parameter in place, while the chip's close icon removes it from the URL Dynamic placeholder chips are highlighted differently from static ones, and reserved characters ({}|&=?#) are stripped from fallbacks so they cannot break URL parsing. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AmXBxfgpFtGBFhDJrR7A8r --- .../countly/vue/components/content.js | 206 +++++++++++++++--- .../UI/content-dynamic-params-input.html | 66 ++++-- .../dashboard/dashboard.properties | 4 + 3 files changed, 225 insertions(+), 51 deletions(-) diff --git a/frontend/express/public/javascripts/countly/vue/components/content.js b/frontend/express/public/javascripts/countly/vue/components/content.js index 1abf927d464..b2be2afaa93 100644 --- a/frontend/express/public/javascripts/countly/vue/components/content.js +++ b/frontend/express/public/javascripts/countly/vue/components/content.js @@ -832,13 +832,19 @@ template: CV.T('/javascripts/countly/vue/templates/content/UI/content-block-list-input.html'), })); - // SER-2915: "Add User Property" popover for URL/deep-link button actions in - // the content builder — mirrors the push notifications Add User Property - // popover (internal/external property tabs, property selector, capitalize - // switch and fallback value) plus a parameter name input. Confirming - // appends a visible `name={property|fallback|c}` query parameter to the - // action URL, which is resolved per targeted user when the content is - // served. + // 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))?\}$/; + + // SER-2915: parameter editor popover for URL/deep-link button actions in the + // content builder — 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. + // Confirming appends a `name={property|fallback|c}` (or `name=value`) query + // parameter to the action URL; placeholders are resolved per targeted user + // when the content is served. Every query parameter already present in the + // URL is rendered as a highlighted chip that reopens the popover pre-filled + // for editing, or removes the parameter via its close icon. Vue.component('cly-content-dynamic-params-input', countlyVue.components.create({ props: { disabled: { @@ -870,11 +876,13 @@ data() { return { + editingIndex: null, isPanelOpen: false, property: { fallback: '', isUppercase: false, key: '', + staticValue: '', value: '' }, selectedPropertyCategory: 'internal' @@ -883,83 +891,213 @@ computed: { isConfirmDisabled() { - return !this.property.value || !this.property.key.trim(); + 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.external-properties'), value: 'external' }, + { label: this.i18n('content.dynamic-params.static-value'), value: 'static' } ]; - } - }, + }, - watch: { - selectedPropertyCategory() { - this.property.value = ''; + // query parameters currently present in the URL, each rendered as a + // clickable chip; isDynamic marks `{property|fallback|c}` placeholders + urlQueryParams() { + return this.parseUrl(this.value || '').params.map((pair) => { + const eqIndex = pair.indexOf('='); + const rawKey = eqIndex === -1 ? pair : pair.slice(0, eqIndex); + const rawValue = eqIndex === -1 ? '' : pair.slice(eqIndex + 1); + + return { + isDynamic: CONTENT_DYNAMIC_PARAM_PLACEHOLDER_RE.test(rawValue), + key: this.safeDecode(rawKey), + rawValue + }; + }); } }, methods: { - // appends `name={property}` as a query parameter, keeping any hash - // fragment at the end and picking the right ?/& separator - appendQueryParam(url, pair) { - const hashIndex = url.indexOf('#'); - const hash = hashIndex === -1 ? '' : url.slice(hashIndex); - const base = hashIndex === -1 ? url : url.slice(0, hashIndex); - let separator = base.indexOf('?') === -1 ? '?' : '&'; + 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 (base.endsWith('?') || base.endsWith('&')) { - separator = ''; + if (fallback || this.property.isUppercase) { + placeholder += '|' + fallback; } - return base + separator + pair + hash; + if (this.property.isUppercase) { + placeholder += '|c'; + } + + return placeholder + '}'; + }, + + buildUrl(parsed) { + return parsed.path + (parsed.params.length ? '?' + parsed.params.join('&') : '') + parsed.hash; + }, + + 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() { - this.isPanelOpen = !this.isPanelOpen; + 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; } - let placeholder = '{' + this.property.value; + const pair = encodeURIComponent(this.property.key.trim()) + '=' + this.buildParamValue(); + const parsed = this.parseUrl(this.value || ''); + + 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(); + }, - if (this.property.fallback || this.property.isUppercase) { - placeholder += '|' + this.property.fallback; + onParamChipClick(index) { + if (this.disabled) { + return; } - if (this.property.isUppercase) { - placeholder += '|c'; + const param = this.urlQueryParams[index]; + + if (!param) { + return; } - placeholder += '}'; + this.resetPanelState(); + this.editingIndex = index; + this.property.key = param.key; - const pair = encodeURIComponent(this.property.key.trim()) + '=' + placeholder; + const match = param.rawValue.match(CONTENT_DYNAMIC_PARAM_PLACEHOLDER_RE); - this.$emit('input', this.appendQueryParam(this.value || '', pair)); - this.resetPanel(); + 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(param.rawValue); + this.selectedPropertyCategory = 'static'; + } + + this.isPanelOpen = true; + }, + + onParamChipRemove(index) { + if (this.disabled) { + return; + } + + const parsed = this.parseUrl(this.value || ''); + + parsed.params.splice(index, 1); + this.$emit('input', this.buildUrl(parsed)); + + if (this.editingIndex === index) { + this.resetPanel(); + } + else if (this.editingIndex !== null && this.editingIndex > index) { + this.editingIndex -= 1; + } }, onPropertySelect(value) { this.property.value = value; }, + 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) + }; + }, + 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; + } } }, 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 index 5c4ac660ff8..a3cfe6e1205 100644 --- 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 @@ -2,6 +2,25 @@ class="cly-vue-content-dynamic-params-input" :data-test-id="testId" > +
+ + {{ param.key }}={{ param.rawValue }} + +
- {{ i18n('content.dynamic-params.add-user-property') }} + {{ panelTitle }}
{{ i18n('content.dynamic-params.parameter-name') }}
- -
- - {{ i18n('common.cancel') }} - +
- {{ i18n('content.dynamic-params.confirm') }} + {{ i18n('content.dynamic-params.remove') }} +
+
+ + {{ i18n('common.cancel') }} + + + {{ i18n('content.dynamic-params.confirm') }} + +
diff --git a/frontend/express/public/localization/dashboard/dashboard.properties b/frontend/express/public/localization/dashboard/dashboard.properties index 50890fc6168..0cccfa23517 100644 --- a/frontend/express/public/localization/dashboard/dashboard.properties +++ b/frontend/express/public/localization/dashboard/dashboard.properties @@ -1274,6 +1274,7 @@ content.dynamic-params.add-user-property = Add User Property content.dynamic-params.parameter-name = Parameter name content.dynamic-params.enter-parameter-name = Enter parameter name content.dynamic-params.edit-parameter = Edit Parameter +content.dynamic-params.remove = Remove content.dynamic-params.internal-properties = Internal Properties content.dynamic-params.external-properties = External Properties content.dynamic-params.static-value = Static Value From ce6d44c7a7b6dd274c1a964a70a054fa30800944 Mon Sep 17 00:00:00 2001 From: Coskun Aydinoglu Date: Tue, 14 Jul 2026 13:03:17 +0000 Subject: [PATCH 06/10] SER-2915: floating parameter popover and wrapping URL editor - the Add User Property panel now floats over the canvas as a fixed-position popover (440px wide, right-aligned to the sidebar, scrollable within the viewport), so all three category tabs and the Cancel/Confirm buttons are always visible regardless of the narrow sidebar width - the URL editor keeps a static width and wraps long URLs onto multiple lines (scrolling vertically past ~5 lines) instead of scrolling horizontally Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AmXBxfgpFtGBFhDJrR7A8r --- .../countly/vue/components/content.js | 48 ++++++++++++++++++- .../UI/content-dynamic-params-input.html | 9 ++-- 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/frontend/express/public/javascripts/countly/vue/components/content.js b/frontend/express/public/javascripts/countly/vue/components/content.js index 32c64ecdd58..ae219f8ceac 100644 --- a/frontend/express/public/javascripts/countly/vue/components/content.js +++ b/frontend/express/public/javascripts/countly/vue/components/content.js @@ -837,6 +837,7 @@ const CONTENT_DYNAMIC_PARAM_PLACEHOLDER_RE = /^\{([^{}|]+)(?:\|([^{}|]*))?(?:\|(c))?\}$/; const CONTENT_DYNAMIC_PARAM_NODE_CLASS = 'cly-vue-content-dynamic-params-input__param'; + const CONTENT_DYNAMIC_PARAMS_PANEL_WIDTH = 440; 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;'; @@ -884,6 +885,11 @@ editingIndex: null, isPanelOpen: false, lastEmittedValue: '', + panelPosition: { + left: 8, + maxHeight: 400, + top: 8 + }, property: { fallback: '', isUppercase: false, @@ -908,6 +914,24 @@ return !this.property.value; }, + // the panel floats over the canvas as a fixed-position popover so it + // is not constrained by the narrow sidebar width + panelStyle() { + return { + background: '#FFFFFF', + border: '1px solid #E2E4E8', + borderRadius: '4px', + boxShadow: '0 4px 12px rgba(0, 0, 0, 0.12)', + left: this.panelPosition.left + 'px', + maxHeight: this.panelPosition.maxHeight + 'px', + overflowY: 'auto', + position: 'fixed', + top: this.panelPosition.top + 'px', + width: CONTENT_DYNAMIC_PARAMS_PANEL_WIDTH + 'px', + zIndex: 1000 + }; + }, + panelTitle() { return this.editingIndex === null ? this.i18n('content.dynamic-params.add-user-property') : @@ -997,7 +1021,7 @@ } this.resetPanelState(); - this.isPanelOpen = true; + this.openPanel(); }, onCancel() { @@ -1081,6 +1105,11 @@ this.property.value = value; }, + openPanel() { + this.positionPanel(); + this.isPanelOpen = true; + }, + openPanelForParam(index, pair) { this.resetPanelState(); this.editingIndex = index; @@ -1104,7 +1133,7 @@ this.selectedPropertyCategory = 'static'; } - this.isPanelOpen = true; + this.openPanel(); }, parseUrl(url) { @@ -1120,6 +1149,21 @@ }; }, + positionPanel() { + const anchor = this.$refs.addButton && this.$refs.addButton.$el ? this.$refs.addButton.$el : this.$el; + const rect = anchor.getBoundingClientRect(); + const viewportWidth = window.innerWidth || rect.right; + const viewportHeight = window.innerHeight || 800; + const left = Math.max(8, Math.min(rect.right - CONTENT_DYNAMIC_PARAMS_PANEL_WIDTH, viewportWidth - CONTENT_DYNAMIC_PARAMS_PANEL_WIDTH - 8)); + const top = rect.bottom + 4; + + this.panelPosition = { + left, + maxHeight: Math.max(240, viewportHeight - top - 16), + top + }; + }, + rebuildEditor() { const editor = this.$refs.editor; 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 index 8accaa76a8b..49f7846d435 100644 --- 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 @@ -6,7 +6,7 @@ ref="editor" class="cly-vue-content-dynamic-params-input__editor" spellcheck="false" - style="background: #FFFFFF; border: 1px solid #DBDDE1; border-radius: 4px; padding: 5px 12px; min-height: 30px; font-size: 14px; line-height: 20px; color: #383A3F; white-space: nowrap; overflow-x: auto; outline: none;" + style="background: #FFFFFF; border: 1px solid #DBDDE1; border-radius: 4px; padding: 5px 12px; min-height: 30px; max-height: 110px; overflow-y: auto; font-size: 14px; line-height: 20px; color: #383A3F; white-space: pre-wrap; word-break: break-all; outline: none;" :contenteditable="!disabled" :data-test-id="testId + '-url-editor'" @click="onEditorClick" @@ -15,6 +15,7 @@ @paste="onEditorPaste" >
@@ -89,7 +90,7 @@ :placeholder="i18n('content.dynamic-params.select-property')" :search-placeholder="i18n('content.dynamic-params.search-in-properties')" :value="property.value" - :width="264" + :width="380" :data-test-id="testId + '-property-select'" @change="onPropertySelect" /> From 3318cf3e8b25eb0cc75c2835708366cc29b155ba Mon Sep 17 00:00:00 2001 From: Coskun Aydinoglu Date: Tue, 14 Jul 2026 13:18:22 +0000 Subject: [PATCH 07/10] SER-2915: open the parameter panel inside the sidebar pane again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts the fixed-position floating popover — the Add User Property/Edit Parameter panel opens in-flow under the button inside the sidebar, as before. To keep everything visible in the narrow pane, the category tabs wrap onto a second row when needed and the wrapping URL editor (static width, overflowing downwards) keeps the panel from being pushed off-screen. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AmXBxfgpFtGBFhDJrR7A8r --- .../countly/vue/components/content.js | 48 +------------------ .../UI/content-dynamic-params-input.html | 9 ++-- 2 files changed, 7 insertions(+), 50 deletions(-) diff --git a/frontend/express/public/javascripts/countly/vue/components/content.js b/frontend/express/public/javascripts/countly/vue/components/content.js index ae219f8ceac..32c64ecdd58 100644 --- a/frontend/express/public/javascripts/countly/vue/components/content.js +++ b/frontend/express/public/javascripts/countly/vue/components/content.js @@ -837,7 +837,6 @@ const CONTENT_DYNAMIC_PARAM_PLACEHOLDER_RE = /^\{([^{}|]+)(?:\|([^{}|]*))?(?:\|(c))?\}$/; const CONTENT_DYNAMIC_PARAM_NODE_CLASS = 'cly-vue-content-dynamic-params-input__param'; - const CONTENT_DYNAMIC_PARAMS_PANEL_WIDTH = 440; 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;'; @@ -885,11 +884,6 @@ editingIndex: null, isPanelOpen: false, lastEmittedValue: '', - panelPosition: { - left: 8, - maxHeight: 400, - top: 8 - }, property: { fallback: '', isUppercase: false, @@ -914,24 +908,6 @@ return !this.property.value; }, - // the panel floats over the canvas as a fixed-position popover so it - // is not constrained by the narrow sidebar width - panelStyle() { - return { - background: '#FFFFFF', - border: '1px solid #E2E4E8', - borderRadius: '4px', - boxShadow: '0 4px 12px rgba(0, 0, 0, 0.12)', - left: this.panelPosition.left + 'px', - maxHeight: this.panelPosition.maxHeight + 'px', - overflowY: 'auto', - position: 'fixed', - top: this.panelPosition.top + 'px', - width: CONTENT_DYNAMIC_PARAMS_PANEL_WIDTH + 'px', - zIndex: 1000 - }; - }, - panelTitle() { return this.editingIndex === null ? this.i18n('content.dynamic-params.add-user-property') : @@ -1021,7 +997,7 @@ } this.resetPanelState(); - this.openPanel(); + this.isPanelOpen = true; }, onCancel() { @@ -1105,11 +1081,6 @@ this.property.value = value; }, - openPanel() { - this.positionPanel(); - this.isPanelOpen = true; - }, - openPanelForParam(index, pair) { this.resetPanelState(); this.editingIndex = index; @@ -1133,7 +1104,7 @@ this.selectedPropertyCategory = 'static'; } - this.openPanel(); + this.isPanelOpen = true; }, parseUrl(url) { @@ -1149,21 +1120,6 @@ }; }, - positionPanel() { - const anchor = this.$refs.addButton && this.$refs.addButton.$el ? this.$refs.addButton.$el : this.$el; - const rect = anchor.getBoundingClientRect(); - const viewportWidth = window.innerWidth || rect.right; - const viewportHeight = window.innerHeight || 800; - const left = Math.max(8, Math.min(rect.right - CONTENT_DYNAMIC_PARAMS_PANEL_WIDTH, viewportWidth - CONTENT_DYNAMIC_PARAMS_PANEL_WIDTH - 8)); - const top = rect.bottom + 4; - - this.panelPosition = { - left, - maxHeight: Math.max(240, viewportHeight - top - 16), - top - }; - }, - rebuildEditor() { const editor = this.$refs.editor; 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 index 49f7846d435..ed2afc80860 100644 --- 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 @@ -28,8 +28,8 @@
@@ -43,10 +43,11 @@ :placeholder="i18n('content.dynamic-params.enter-parameter-name')" :data-test-id="testId + '-parameter-name-input'" /> -
+
@@ -90,7 +91,7 @@ :placeholder="i18n('content.dynamic-params.select-property')" :search-placeholder="i18n('content.dynamic-params.search-in-properties')" :value="property.value" - :width="380" + :width="264" :data-test-id="testId + '-property-select'" @change="onPropertySelect" /> From 74d7fc3858875e942c5b3f1cd88db590cdf7e7f8 Mon Sep 17 00:00:00 2001 From: Coskun Aydinoglu Date: Tue, 14 Jul 2026 13:36:41 +0000 Subject: [PATCH 08/10] SER-2915: value type dropdown and wrapping checkbox label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Internal/External/Static category tabs consumed too much of the narrow sidebar pane — replaced with a full-width "Value type" dropdown. The "start with capital letter" checkbox label now wraps onto multiple lines so it fits the pane. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AmXBxfgpFtGBFhDJrR7A8r --- .../UI/content-dynamic-params-input.html | 37 ++++++++++--------- .../dashboard/dashboard.properties | 1 + 2 files changed, 20 insertions(+), 18 deletions(-) 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 index ed2afc80860..5e28f2b165c 100644 --- 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 @@ -43,23 +43,21 @@ :placeholder="i18n('content.dynamic-params.enter-parameter-name')" :data-test-id="testId + '-parameter-name-input'" /> -
- - - {{ item.label }} - - -
+
{{ i18n('content.dynamic-params.value-type') }}
+ + +