Skip to content

Commit fae47dd

Browse files
authored
Merge pull request #1963 from ProcessMaker/feature/FOUR-29250
FOUR-29250 End Event – External URL with Mustache Support
2 parents 069a475 + c1374e6 commit fae47dd

4 files changed

Lines changed: 243 additions & 20 deletions

File tree

src/components/inspectors/ConditionalRedirect/TaskDestination.vue

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,9 @@
5050
v-if="taskDestination?.value === 'externalURL'"
5151
:label="$t('URL')"
5252
v-model="externalURL"
53-
:error="getValidationErrorForCustomURL(externalURL)"
53+
:error="getValidationErrorForURL(externalURL)"
5454
:placeholder="urlPlaceholder"
55-
:helper="$t('Determine the URL where the request will end')"
55+
:helper="externalUrlHelperText"
5656
data-test="conditional-task-external-url"
5757
/>
5858

@@ -79,6 +79,7 @@
7979
</template>
8080

8181
<script>
82+
import { isValidElementDestinationURL } from '@/utils/elementDestinationUrl';
8283
import debounce from 'lodash/debounce';
8384
import isEqual from 'lodash/isEqual';
8485
import cloneDeep from 'lodash/cloneDeep';
@@ -119,6 +120,9 @@ export default {
119120
node() {
120121
return this.$root.$children[0].$refs.modeler.highlightedNode.definition;
121122
},
123+
externalUrlHelperText() {
124+
return this.$t('URL where the request will redirect. Supports Mustache:') + ' {{APP_URL}}, {{_request.id}}, {{_user.id}}, ' + this.$t('process variables.');
125+
},
122126
},
123127
watch: {
124128
condition: {
@@ -193,18 +197,21 @@ export default {
193197
onRemoveCondition() {
194198
this.$emit('remove', this.conditionId);
195199
},
196-
getValidationErrorForCustomURL(url) {
197-
if (!url) return this.$t('URL is required');
198-
if (!this.isValidCustomURL(url)) return this.$t('Must be a valid URL');
200+
getValidationErrorForURL(url) {
201+
const isEmpty = typeof url !== 'string' || !url || !url.trim();
202+
if (isEmpty) {
203+
if (this.taskDestination?.value === 'externalURL') {
204+
return this.$t('URL is required when External URL is selected.');
205+
}
206+
return '';
207+
}
208+
if (!this.isValidURL(url)) {
209+
return this.$t('Must be a valid URL or Mustache expressions') + ' ({{APP_URL}}, {{_request.id}}, {{_user.id}}, ' + this.$t('process variables') + ').';
210+
}
199211
return '';
200212
},
201-
isValidCustomURL(url) {
202-
try {
203-
const parsed = new URL(url);
204-
return ['http:', 'https:'].includes(parsed.protocol);
205-
} catch {
206-
return false;
207-
}
213+
isValidURL(string) {
214+
return isValidElementDestinationURL(string);
208215
},
209216
getCustomDashboards(filter) {
210217
this.loading = true;

src/components/inspectors/ElementDestination.vue

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@
5050
:error="getValidationErrorForURL(externalURL)"
5151
data-cy="events-add-id"
5252
:placeholder="urlPlaceholder"
53-
:helper="$t('Determine the URL where the request will end')"
53+
:helper="externalUrlHelperText"
5454
data-test="external-url"
5555
/>
5656
<process-form-select
@@ -63,8 +63,10 @@
6363

6464
<script>
6565
import ProcessFormSelect from '@/components/inspectors/ProcessFormSelect';
66+
import { isValidElementDestinationURL } from '@/utils/elementDestinationUrl';
6667
import debounce from 'lodash/debounce';
6768
import isEqual from 'lodash/isEqual';
69+
6870
export default {
6971
components: { ProcessFormSelect },
7072
props: {
@@ -159,6 +161,9 @@ export default {
159161
160162
return this.$t('Select where to send users after this task. Any Non-default destination will disable the "Display Next Assigned Task" function.');
161163
},
164+
externalUrlHelperText() {
165+
return this.$t('URL where the request will redirect. Supports Mustache:') + ' {{APP_URL}}, {{_request.id}}, {{_user.id}}, ' + this.$t('process variables.');
166+
},
162167
},
163168
created() {
164169
this.loadDashboardsDebounced = debounce((filter) => {
@@ -174,18 +179,20 @@ export default {
174179
},
175180
methods: {
176181
getValidationErrorForURL(url) {
182+
const isEmpty = typeof url !== 'string' || !url || !url.trim();
183+
if (isEmpty) {
184+
if (this.destinationType === 'externalURL') {
185+
return this.$t('URL is required when External URL is selected.');
186+
}
187+
return '';
188+
}
177189
if (!this.isValidURL(url)) {
178-
return this.$t('Must be a valid URL');
190+
return this.$t('Must be a valid URL or Mustache expressions') + ' ({{APP_URL}}, {{_request.id}}, {{_user.id}}, ' + this.$t('process variables') + ').';
179191
}
180192
return '';
181193
},
182194
isValidURL(string) {
183-
try {
184-
new URL(string);
185-
return true;
186-
} catch (_) {
187-
return false;
188-
}
195+
return isValidElementDestinationURL(string);
189196
},
190197
loadData() {
191198
this.optionsCopy = this.options.map(option => ({

src/utils/elementDestinationUrl.js

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/** Matches one Mustache placeholder {{ variable }}. Variable: [^\s}]+ (no spaces, no '}'). Rejects {{}}, {{ }}, {{a b}}. */
2+
const MUSTACHE_PLACEHOLDER = /\{\{\s*[^\s}]+\s*\}\}/;
3+
4+
/** Matches URL scheme at start (e.g. http:, https:). */
5+
const HAS_SCHEME = /^[a-zA-Z][a-zA-Z0-9+.-]*:/;
6+
7+
/**
8+
* True when the string has only valid Mustache placeholders and the literal parts form a valid URL.
9+
* Rejects empty mustache ({{}}, {{ }}), stray { or }, and invalid URL characters.
10+
*
11+
* @param {string} str - Non-empty trimmed string.
12+
* @returns {boolean}
13+
*/
14+
export function hasValidMustacheOnly(str) {
15+
if (!str.includes('{{')) return false;
16+
17+
const g = new RegExp(MUSTACHE_PLACEHOLDER.source, 'g');
18+
const urlSkeleton = str.replace(g, 'a');
19+
if (urlSkeleton.includes('{') || urlSkeleton.includes('}')) return false;
20+
21+
const urlToTest = HAS_SCHEME.test(urlSkeleton) ? urlSkeleton : `http://${urlSkeleton}`;
22+
try {
23+
new URL(urlToTest);
24+
return true;
25+
} catch {
26+
return false;
27+
}
28+
}
29+
30+
/**
31+
* Validates the Element Destination / Conditional Redirect URL field.
32+
* (1) Non-empty string. (2) If it contains {{: only valid Mustache placeholders and URL-valid literals. (3) Else: valid URL.
33+
*
34+
* @param {string} value - URL or Mustache template to validate.
35+
* @returns {boolean}
36+
*/
37+
export function isValidElementDestinationURL(value) {
38+
if (typeof value !== 'string') {
39+
return false;
40+
}
41+
const trimmed = value.trim();
42+
if (trimmed.length === 0) {
43+
return false;
44+
}
45+
46+
if (trimmed.includes('{{')) {
47+
return hasValidMustacheOnly(trimmed);
48+
}
49+
50+
try {
51+
new URL(trimmed);
52+
return true;
53+
} catch {
54+
return false;
55+
}
56+
}
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
/**
2+
* Tests for elementDestinationUrl utils (hasValidMustacheOnly, isValidElementDestinationURL).
3+
* Written for 100% line and branch coverage of src/utils/elementDestinationUrl.js.
4+
*/
5+
import {
6+
isValidElementDestinationURL,
7+
hasValidMustacheOnly,
8+
} from '@/utils/elementDestinationUrl';
9+
10+
describe('elementDestinationUrl', () => {
11+
describe('hasValidMustacheOnly', () => {
12+
it('returns false when string does not contain {{', () => {
13+
expect(hasValidMustacheOnly('no mustache here')).toBe(false);
14+
expect(hasValidMustacheOnly('https://example.com')).toBe(false);
15+
});
16+
17+
it('returns true when string has only valid placeholders', () => {
18+
expect(hasValidMustacheOnly('{{var}}')).toBe(true);
19+
expect(hasValidMustacheOnly('{{a}}{{b}}')).toBe(true);
20+
});
21+
22+
it('returns true for placeholder with spaces around variable name', () => {
23+
expect(hasValidMustacheOnly('{{ x }}')).toBe(true);
24+
});
25+
26+
it('returns false when string contains empty mustache {{}} or {{ }}', () => {
27+
expect(hasValidMustacheOnly('{{}}')).toBe(false);
28+
expect(hasValidMustacheOnly('{{ }}')).toBe(false);
29+
});
30+
31+
it('returns false when string has stray {{ (unclosed)', () => {
32+
expect(hasValidMustacheOnly('{{unclosed')).toBe(false);
33+
});
34+
35+
it('returns false when string has stray }} after valid placeholder', () => {
36+
expect(hasValidMustacheOnly('{{a}} }}')).toBe(false);
37+
});
38+
39+
it('returns false when valid placeholders are followed by empty {{}}', () => {
40+
expect(hasValidMustacheOnly('{{server}}/{{_request.id}}{{}}')).toBe(false);
41+
});
42+
43+
it('returns false when skeleton has stray single { or }', () => {
44+
expect(hasValidMustacheOnly('{{v}} {')).toBe(false);
45+
expect(hasValidMustacheOnly('https://127.0.0.5:8092/admin/users/12/edit{{v}} {{v}} {')).toBe(false);
46+
});
47+
48+
it('returns false when literal parts form invalid URL', () => {
49+
expect(hasValidMustacheOnly('{{variable}} / `[[[][∫ad')).toBe(false);
50+
});
51+
52+
it('returns true when URL has scheme (HAS_SCHEME branch)', () => {
53+
expect(hasValidMustacheOnly('https://host/{{path}}')).toBe(true);
54+
expect(hasValidMustacheOnly('http://example.com/{{id}}/view')).toBe(true);
55+
});
56+
57+
it('returns true when no scheme so http:// is prepended', () => {
58+
expect(hasValidMustacheOnly('{{host}}/path')).toBe(true);
59+
});
60+
});
61+
62+
describe('isValidElementDestinationURL', () => {
63+
describe('non-string or empty', () => {
64+
it('returns false for non-string values', () => {
65+
expect(isValidElementDestinationURL(null)).toBe(false);
66+
expect(isValidElementDestinationURL(undefined)).toBe(false);
67+
expect(isValidElementDestinationURL(123)).toBe(false);
68+
expect(isValidElementDestinationURL(false)).toBe(false);
69+
expect(isValidElementDestinationURL({})).toBe(false);
70+
expect(isValidElementDestinationURL([])).toBe(false);
71+
});
72+
73+
it('returns false for empty string', () => {
74+
expect(isValidElementDestinationURL('')).toBe(false);
75+
});
76+
77+
it('returns false for whitespace-only string', () => {
78+
expect(isValidElementDestinationURL(' ')).toBe(false);
79+
expect(isValidElementDestinationURL('\t\n')).toBe(false);
80+
});
81+
});
82+
83+
describe('Mustache template (contains {{)', () => {
84+
it('returns true for valid single placeholder', () => {
85+
expect(isValidElementDestinationURL('{{var}}')).toBe(true);
86+
expect(isValidElementDestinationURL('{{ APP_URL }}')).toBe(true);
87+
expect(isValidElementDestinationURL(' {{path}} ')).toBe(true);
88+
});
89+
90+
it('returns true for valid multiple placeholders', () => {
91+
expect(isValidElementDestinationURL('{{a}}{{b}}')).toBe(true);
92+
expect(isValidElementDestinationURL('{{x}}/{{y}}')).toBe(true);
93+
});
94+
95+
it('returns true for URL with valid Mustache placeholders', () => {
96+
expect(isValidElementDestinationURL('https://host/{{path}}')).toBe(true);
97+
expect(isValidElementDestinationURL('http://example.com/{{id}}/view')).toBe(true);
98+
});
99+
100+
it('returns false for empty placeholder {{}}', () => {
101+
expect(isValidElementDestinationURL('{{}}')).toBe(false);
102+
});
103+
104+
it('returns false for placeholder with only spaces {{ }}', () => {
105+
expect(isValidElementDestinationURL('{{ }}')).toBe(false);
106+
});
107+
108+
it('returns false for unclosed Mustache (stray {{)', () => {
109+
expect(isValidElementDestinationURL('{{unclosed')).toBe(false);
110+
expect(isValidElementDestinationURL('{{a}} {{')).toBe(false);
111+
});
112+
113+
it('returns false for stray closing braces }}', () => {
114+
expect(isValidElementDestinationURL('{{a}} }}')).toBe(false);
115+
expect(isValidElementDestinationURL('}}solo')).toBe(false);
116+
});
117+
118+
it('returns false for placeholder with space inside variable name', () => {
119+
expect(isValidElementDestinationURL('{{var2 var2}}')).toBe(false);
120+
});
121+
122+
it('returns false for URL with valid placeholders plus empty {{}}', () => {
123+
expect(isValidElementDestinationURL('{{server}}/{{_request.id}}{{}}')).toBe(false);
124+
});
125+
126+
it('returns false when literal parts form invalid URL', () => {
127+
expect(isValidElementDestinationURL('https://127.0.0.5:8092/admin/users/12/edit{{v}} {{v}} {')).toBe(false);
128+
expect(isValidElementDestinationURL('{{variable}} / `[[[][∫ad')).toBe(false);
129+
});
130+
});
131+
132+
describe('plain URL (no Mustache)', () => {
133+
it('returns true for valid HTTP URL', () => {
134+
expect(isValidElementDestinationURL('http://example.com')).toBe(true);
135+
expect(isValidElementDestinationURL('http://a.b')).toBe(true);
136+
});
137+
138+
it('returns true for valid HTTPS URL', () => {
139+
expect(isValidElementDestinationURL('https://example.com')).toBe(true);
140+
expect(isValidElementDestinationURL('https://example.com/path?q=1')).toBe(true);
141+
});
142+
143+
it('returns true for URL with trimmed whitespace', () => {
144+
expect(isValidElementDestinationURL(' https://example.com ')).toBe(true);
145+
});
146+
147+
it('returns false for invalid URL (exercises catch branch)', () => {
148+
expect(isValidElementDestinationURL('not a url')).toBe(false);
149+
expect(isValidElementDestinationURL('://bad')).toBe(false);
150+
});
151+
});
152+
});
153+
});

0 commit comments

Comments
 (0)