Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
02bd1f0
FOUR-29250 End Event – External URL with Mustache/FEEL Support
gproly Feb 10, 2026
a900ff2
FOUR-29250 Add InspectorConfigViewer: collapsible card in modeler ins…
gproly Feb 11, 2026
1b5ac66
FOUR-29250 Remove InspectorConfigViewer and update ElementDestination.
gproly Feb 11, 2026
87098c8
FOUR-29250 Require URL when External URL is selected in ElementDestin…
gproly Feb 12, 2026
c02f982
FOUR-29250 ElementDestination: improve URL validation
gproly Feb 12, 2026
fc7402d
FOUR-29250 Fix ElementDestination URL validation message: keep Mustac…
gproly Feb 12, 2026
80848dd
FOUR-29250 Refactor URL validation in ElementDestination and TaskDest…
gproly Feb 22, 2026
c3d28c9
FOUR-29250 Add isValidElementDestinationURL utility for validating El…
gproly Feb 22, 2026
5072325
FOUR-29250 Update Mustache placeholder regex in elementDestinationUrl…
gproly Feb 23, 2026
8161714
FOUR-29250 Add unit tests for elementDestinationUrl validation
gproly Feb 23, 2026
5cced67
FOUR-29250 Export hasValidMustacheOnly and add tests for full coverage.
gproly Feb 23, 2026
cbdf52b
FOUR-29250 Reject empty mustache {{}} in URL validation and add tests
gproly Feb 24, 2026
3de0274
FOUR-29250 Add tests for empty mustache validation and fix indent in …
gproly Feb 24, 2026
cb9d8d7
FOUR-29250 Improve elementDestinationUrl unit tests: add file comment…
gproly Feb 24, 2026
acc032f
FOUR-29250 Strict mustache URL validation and tests
gproly Feb 25, 2026
c1374e6
FOUR-29250 Add tests for mustache URL validation (stray braces, inval…
gproly Feb 25, 2026
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
31 changes: 19 additions & 12 deletions src/components/inspectors/ConditionalRedirect/TaskDestination.vue
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,9 @@
v-if="taskDestination?.value === 'externalURL'"
:label="$t('URL')"
v-model="externalURL"
:error="getValidationErrorForCustomURL(externalURL)"
:error="getValidationErrorForURL(externalURL)"
:placeholder="urlPlaceholder"
:helper="$t('Determine the URL where the request will end')"
:helper="externalUrlHelperText"
data-test="conditional-task-external-url"
/>

Expand All @@ -79,6 +79,7 @@
</template>

<script>
import { isValidElementDestinationURL } from '@/utils/elementDestinationUrl';
import debounce from 'lodash/debounce';
import isEqual from 'lodash/isEqual';
import cloneDeep from 'lodash/cloneDeep';
Expand Down Expand Up @@ -119,6 +120,9 @@ export default {
node() {
return this.$root.$children[0].$refs.modeler.highlightedNode.definition;
},
externalUrlHelperText() {
return this.$t('URL where the request will redirect. Supports Mustache:') + ' {{APP_URL}}, {{_request.id}}, {{_user.id}}, ' + this.$t('process variables.');
},
},
watch: {
condition: {
Expand Down Expand Up @@ -193,18 +197,21 @@ export default {
onRemoveCondition() {
this.$emit('remove', this.conditionId);
},
getValidationErrorForCustomURL(url) {
if (!url) return this.$t('URL is required');
if (!this.isValidCustomURL(url)) return this.$t('Must be a valid URL');
getValidationErrorForURL(url) {
const isEmpty = typeof url !== 'string' || !url || !url.trim();
if (isEmpty) {
if (this.taskDestination?.value === 'externalURL') {
return this.$t('URL is required when External URL is selected.');
}
return '';
}
if (!this.isValidURL(url)) {
return this.$t('Must be a valid URL or Mustache expressions') + ' ({{APP_URL}}, {{_request.id}}, {{_user.id}}, ' + this.$t('process variables') + ').';
}
return '';
},
isValidCustomURL(url) {
try {
const parsed = new URL(url);
return ['http:', 'https:'].includes(parsed.protocol);
} catch {
return false;
}
isValidURL(string) {
return isValidElementDestinationURL(string);
Comment thread
gproly marked this conversation as resolved.
},
getCustomDashboards(filter) {
this.loading = true;
Expand Down
23 changes: 15 additions & 8 deletions src/components/inspectors/ElementDestination.vue
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
:error="getValidationErrorForURL(externalURL)"
data-cy="events-add-id"
:placeholder="urlPlaceholder"
:helper="$t('Determine the URL where the request will end')"
:helper="externalUrlHelperText"
data-test="external-url"
/>
<process-form-select
Expand All @@ -63,8 +63,10 @@

<script>
import ProcessFormSelect from '@/components/inspectors/ProcessFormSelect';
import { isValidElementDestinationURL } from '@/utils/elementDestinationUrl';
import debounce from 'lodash/debounce';
import isEqual from 'lodash/isEqual';

export default {
components: { ProcessFormSelect },
props: {
Expand Down Expand Up @@ -159,6 +161,9 @@ export default {

return this.$t('Select where to send users after this task. Any Non-default destination will disable the "Display Next Assigned Task" function.');
},
externalUrlHelperText() {
return this.$t('URL where the request will redirect. Supports Mustache:') + ' {{APP_URL}}, {{_request.id}}, {{_user.id}}, ' + this.$t('process variables.');
},
},
created() {
this.loadDashboardsDebounced = debounce((filter) => {
Expand All @@ -174,18 +179,20 @@ export default {
},
methods: {
getValidationErrorForURL(url) {
const isEmpty = typeof url !== 'string' || !url || !url.trim();
if (isEmpty) {
if (this.destinationType === 'externalURL') {
return this.$t('URL is required when External URL is selected.');
}
return '';
}
if (!this.isValidURL(url)) {
return this.$t('Must be a valid URL');
return this.$t('Must be a valid URL or Mustache expressions') + ' ({{APP_URL}}, {{_request.id}}, {{_user.id}}, ' + this.$t('process variables') + ').';
}
return '';
},
isValidURL(string) {
try {
new URL(string);
return true;
} catch (_) {
return false;
}
return isValidElementDestinationURL(string);
},
loadData() {
this.optionsCopy = this.options.map(option => ({
Expand Down
55 changes: 55 additions & 0 deletions src/utils/elementDestinationUrl.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* Valid Mustache placeholder: {{ variable }}.
* Variable: one or more non-whitespace characters (no spaces inside the name). Rejects {{}}, {{ }}, {{var2 var2}}.
*/
const MUSTACHE_PLACEHOLDER = /{{\s*\S+\s*}}/;
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated

/**
* Returns true if the string has only valid Mustache definitions (one or more) and no stray {{ or }}.
* Valid: {{var}}, {{ APP_URL }}, https://host/{{path}}, {{a}}{{b}}. Invalid: {{}}, {{ }}, {{unclosed, }}solo.
* @param {string} str - Non-empty trimmed string.
* @returns {boolean}
*/
function hasValidMustacheOnly(str) {
if (!str.includes('{{')) {
return false;
}
const globalRegex = new RegExp(MUSTACHE_PLACEHOLDER.source, 'g');
const withoutPlaceholders = str.replace(globalRegex, '');
const hasStrayBraces = withoutPlaceholders.includes('{{') || withoutPlaceholders.includes('}}');
if (hasStrayBraces) {
return false;
}
// No stray braces and string contained {{ → at least one valid placeholder was matched.
return true;
}

/**
* Validates Element Destination / Conditional Redirect URL field.
* 1. Must be a non-empty string.
* 2. If it contains {{: must be valid Mustache (one or more placeholders like {{var}}, no stray braces). Invalid Mustache → false (same as invalid URL).
* 3. If it does not contain {{: must be a valid URL.
*
* @param {string} value - Value to validate (URL or Mustache template).
* @returns {boolean}
*/
export function isValidElementDestinationURL(value) {
if (typeof value !== 'string') {
return false;
}
const trimmed = value.trim();
if (trimmed.length === 0) {
return false;
}

if (trimmed.includes('{{')) {
return hasValidMustacheOnly(trimmed);
}

Comment thread
cursor[bot] marked this conversation as resolved.
try {
new URL(trimmed);
return true;
} catch {
return false;
}
Comment thread
gproly marked this conversation as resolved.
}
Loading