From 3d9b80d06bf8e897a31f962fad491bf925b70b4d Mon Sep 17 00:00:00 2001 From: John Hauck Date: Thu, 11 Jun 2026 16:26:17 -0600 Subject: [PATCH 1/4] add buildSolution --- demos/compareJSON/package-lock.json | 2 +- demos/compareSolutions/package-lock.json | 2 +- demos/copyItemInfo/package-lock.json | 2 +- demos/copySolutions/package-lock.json | 2 +- demos/createSolution/package-lock.json | 2 +- demos/deleteSolution/package-lock.json | 2 +- demos/deploySolution/index.html | 9 +++++ demos/deploySolution/package-lock.json | 2 +- demos/deploySolution/src/index.ts | 40 ++++++++++++++++++++-- demos/getItemInfo/package-lock.json | 2 +- demos/implementedTypes/package-lock.json | 2 +- demos/recreateSolution/package-lock.json | 2 +- demos/reuseDeployedItems/package-lock.json | 2 +- demos/verifySolution/package-lock.json | 2 +- 14 files changed, 59 insertions(+), 14 deletions(-) diff --git a/demos/compareJSON/package-lock.json b/demos/compareJSON/package-lock.json index 57405da8a..03f909374 100644 --- a/demos/compareJSON/package-lock.json +++ b/demos/compareJSON/package-lock.json @@ -2344,4 +2344,4 @@ "license": "MIT" } } -} \ No newline at end of file +} diff --git a/demos/compareSolutions/package-lock.json b/demos/compareSolutions/package-lock.json index 69cd11ab2..152478d48 100644 --- a/demos/compareSolutions/package-lock.json +++ b/demos/compareSolutions/package-lock.json @@ -2328,4 +2328,4 @@ "license": "MIT" } } -} \ No newline at end of file +} diff --git a/demos/copyItemInfo/package-lock.json b/demos/copyItemInfo/package-lock.json index 5adf36605..e9dfabeb1 100644 --- a/demos/copyItemInfo/package-lock.json +++ b/demos/copyItemInfo/package-lock.json @@ -2344,4 +2344,4 @@ "license": "MIT" } } -} \ No newline at end of file +} diff --git a/demos/copySolutions/package-lock.json b/demos/copySolutions/package-lock.json index aa341f8d6..2cd5cbbc5 100644 --- a/demos/copySolutions/package-lock.json +++ b/demos/copySolutions/package-lock.json @@ -2719,4 +2719,4 @@ "license": "MIT" } } -} \ No newline at end of file +} diff --git a/demos/createSolution/package-lock.json b/demos/createSolution/package-lock.json index 2f6df7c3e..d0bcfa1d3 100644 --- a/demos/createSolution/package-lock.json +++ b/demos/createSolution/package-lock.json @@ -2350,4 +2350,4 @@ "license": "MIT" } } -} \ No newline at end of file +} diff --git a/demos/deleteSolution/package-lock.json b/demos/deleteSolution/package-lock.json index 56de541e8..d45061029 100644 --- a/demos/deleteSolution/package-lock.json +++ b/demos/deleteSolution/package-lock.json @@ -2351,4 +2351,4 @@ "license": "MIT" } } -} \ No newline at end of file +} diff --git a/demos/deploySolution/index.html b/demos/deploySolution/index.html index 30087eed1..6c0f8ac25 100644 --- a/demos/deploySolution/index.html +++ b/demos/deploySolution/index.html @@ -105,6 +105,15 @@

Deploy one or more Solutions



+
buildSolution
+
+ +
+ +
+ +

+ diff --git a/demos/deploySolution/package-lock.json b/demos/deploySolution/package-lock.json index 7314d5db0..b6bfdbda9 100644 --- a/demos/deploySolution/package-lock.json +++ b/demos/deploySolution/package-lock.json @@ -2357,4 +2357,4 @@ "license": "MIT" } } -} \ No newline at end of file +} diff --git a/demos/deploySolution/src/index.ts b/demos/deploySolution/src/index.ts index 5111058f2..b5a0b12f5 100644 --- a/demos/deploySolution/src/index.ts +++ b/demos/deploySolution/src/index.ts @@ -156,7 +156,7 @@ function go( document.getElementById("output").style.display = "block"; // use the manually entered value, falling back to the select lists - const solutionId = + let solutionId = htmlUtil.getHTMLValue("solutionId") || htmlUtil.getHTMLValue("solutionPicklist"); const folderId = htmlUtil.getHTMLValue("foldersPicklist"); @@ -168,7 +168,43 @@ function go( const dontCreateSolutionItem = htmlUtil.getHTMLChecked("dontCreateSolutionItem"); // Custom Params - const customParams = htmlUtil.getHTMLValue("customParams"); + let customParams = htmlUtil.getHTMLValue("customParams"); + + // buildSolution: a separate JSON input that gets merged into customParams as `buildSolution` + // so we can easily test payloads coming from solutions-components + const buildSolutionRaw = (htmlUtil.getHTMLValue("buildSolution") || "").trim(); + if (buildSolutionRaw.length > 0) { + let buildSolutionObj: any; + try { + buildSolutionObj = JSON.parse(buildSolutionRaw); + } catch (e) { + document.getElementById("input").style.display = "block"; + document.getElementById("output").style.display = "none"; + alert("buildSolution input is not valid JSON: " + (e as Error).message); + return; + } + + let paramsObj: any = {}; + if (typeof customParams === "string" && customParams.trim().length > 0) { + try { + paramsObj = JSON.parse(customParams); + } catch (e) { + document.getElementById("input").style.display = "block"; + document.getElementById("output").style.display = "none"; + alert("Custom Params is not valid JSON: " + (e as Error).message); + return; + } + } + paramsObj.buildSolution = buildSolutionObj; + customParams = JSON.stringify(paramsObj); + + if (!solutionId) { + const embeddedId = buildSolutionObj?.solution?.item?.id; + if (typeof embeddedId === "string" && embeddedId.length > 0) { + solutionId = embeddedId; + } + } + } // Source credentials const srcHtmlValue = htmlUtil.getHTMLValue("srcPortal"); diff --git a/demos/getItemInfo/package-lock.json b/demos/getItemInfo/package-lock.json index efdf9244c..2354878a5 100644 --- a/demos/getItemInfo/package-lock.json +++ b/demos/getItemInfo/package-lock.json @@ -2344,4 +2344,4 @@ "license": "MIT" } } -} \ No newline at end of file +} diff --git a/demos/implementedTypes/package-lock.json b/demos/implementedTypes/package-lock.json index 59b7b28ab..48d042236 100644 --- a/demos/implementedTypes/package-lock.json +++ b/demos/implementedTypes/package-lock.json @@ -2336,4 +2336,4 @@ "license": "MIT" } } -} \ No newline at end of file +} diff --git a/demos/recreateSolution/package-lock.json b/demos/recreateSolution/package-lock.json index 06a06fe94..cae5f3344 100644 --- a/demos/recreateSolution/package-lock.json +++ b/demos/recreateSolution/package-lock.json @@ -2314,4 +2314,4 @@ "license": "MIT" } } -} \ No newline at end of file +} diff --git a/demos/reuseDeployedItems/package-lock.json b/demos/reuseDeployedItems/package-lock.json index 848f1f7ba..b2276c4d1 100644 --- a/demos/reuseDeployedItems/package-lock.json +++ b/demos/reuseDeployedItems/package-lock.json @@ -2379,4 +2379,4 @@ "license": "MIT" } } -} \ No newline at end of file +} diff --git a/demos/verifySolution/package-lock.json b/demos/verifySolution/package-lock.json index fcecaff79..33c0bfb9d 100644 --- a/demos/verifySolution/package-lock.json +++ b/demos/verifySolution/package-lock.json @@ -2350,4 +2350,4 @@ "license": "MIT" } } -} \ No newline at end of file +} From 5802e02137a10ad3148675f9ce8f73211cda0c2b Mon Sep 17 00:00:00 2001 From: John Hauck Date: Thu, 11 Jun 2026 17:02:37 -0600 Subject: [PATCH 2/4] just use existing custom params --- demos/deploySolution/index.html | 41 +++++++++++++++++----------- demos/deploySolution/src/index.ts | 45 +++++++++++-------------------- 2 files changed, 40 insertions(+), 46 deletions(-) diff --git a/demos/deploySolution/index.html b/demos/deploySolution/index.html index 6c0f8ac25..274ec0b4a 100644 --- a/demos/deploySolution/index.html +++ b/demos/deploySolution/index.html @@ -33,15 +33,15 @@

Deploy one or more Solutions

- +
- +
- +


@@ -66,15 +66,15 @@

Deploy one or more Solutions

- +
- +
- +
@@ -100,16 +100,25 @@

Deploy one or more Solutions

Custom Params
- -
- -

- -
buildSolution
-
- -
- +

+ Paste a JSON object whose properties will be added to the deploy's templateDictionary.params. + Templates can reference these via {{params.<key>...}}. +

+

+ To exercise a buildSolution payload (e.g. from solutions-components), wrap it under a + buildSolution key so templates can resolve it as {{params.buildSolution.items.<id>.title}}: +

+
{
+  "buildSolution": {
+    "solution": { "item": { "id": "<solutionItemId>", ... }, ... },
+    "items": { "<itemId>": { "title": "...", ... } }
+  }
+}
+

+ If the Solution Id field above is empty, it will be pulled from + buildSolution.solution.item.id. +

+


diff --git a/demos/deploySolution/src/index.ts b/demos/deploySolution/src/index.ts index b5a0b12f5..66db89ed1 100644 --- a/demos/deploySolution/src/index.ts +++ b/demos/deploySolution/src/index.ts @@ -168,42 +168,27 @@ function go( const dontCreateSolutionItem = htmlUtil.getHTMLChecked("dontCreateSolutionItem"); // Custom Params - let customParams = htmlUtil.getHTMLValue("customParams"); - - // buildSolution: a separate JSON input that gets merged into customParams as `buildSolution` - // so we can easily test payloads coming from solutions-components - const buildSolutionRaw = (htmlUtil.getHTMLValue("buildSolution") || "").trim(); - if (buildSolutionRaw.length > 0) { - let buildSolutionObj: any; + // The textarea contents are parsed and assigned to `templateDictionary.params` + // (see deploy-solution-main.ts). To exercise a buildSolution payload, wrap it + // as `{ "buildSolution": {...} }` so templates can reference + // `{{params.buildSolution.items..title}}` etc. + const customParams = htmlUtil.getHTMLValue("customParams"); + + // If no Solution Id was entered, fall back to buildSolution.solution.item.id + // when present in the custom params. + if (!solutionId && typeof customParams === "string" && customParams.trim().length > 0) { try { - buildSolutionObj = JSON.parse(buildSolutionRaw); + const paramsObj = JSON.parse(customParams); + const embeddedId = paramsObj?.buildSolution?.solution?.item?.id; + if (typeof embeddedId === "string" && embeddedId.length > 0) { + solutionId = embeddedId; + } } catch (e) { document.getElementById("input").style.display = "block"; document.getElementById("output").style.display = "none"; - alert("buildSolution input is not valid JSON: " + (e as Error).message); + alert("Custom Params is not valid JSON: " + (e as Error).message); return; } - - let paramsObj: any = {}; - if (typeof customParams === "string" && customParams.trim().length > 0) { - try { - paramsObj = JSON.parse(customParams); - } catch (e) { - document.getElementById("input").style.display = "block"; - document.getElementById("output").style.display = "none"; - alert("Custom Params is not valid JSON: " + (e as Error).message); - return; - } - } - paramsObj.buildSolution = buildSolutionObj; - customParams = JSON.stringify(paramsObj); - - if (!solutionId) { - const embeddedId = buildSolutionObj?.solution?.item?.id; - if (typeof embeddedId === "string" && embeddedId.length > 0) { - solutionId = embeddedId; - } - } } // Source credentials From ff86394343f16c5f8ab3db05b27eceebe6645555 Mon Sep 17 00:00:00 2001 From: John Hauck Date: Thu, 11 Jun 2026 17:53:23 -0600 Subject: [PATCH 3/4] handle name update --- demos/deploySolution/index.html | 12 ++--- packages/common/src/featureServiceHelpers.ts | 4 -- packages/common/src/restHelpers.ts | 12 +++++ .../common/test/featureServiceHelpers.test.ts | 21 -------- packages/common/test/restHelpers.test.ts | 52 +++++++++++++++++++ 5 files changed, 70 insertions(+), 31 deletions(-) diff --git a/demos/deploySolution/index.html b/demos/deploySolution/index.html index 274ec0b4a..2f8df4de3 100644 --- a/demos/deploySolution/index.html +++ b/demos/deploySolution/index.html @@ -33,15 +33,15 @@

Deploy one or more Solutions

- +
- +
- +


@@ -66,15 +66,15 @@

Deploy one or more Solutions

- +
- +
- +
diff --git a/packages/common/src/featureServiceHelpers.ts b/packages/common/src/featureServiceHelpers.ts index 88f89ba10..8eeffbffa 100644 --- a/packages/common/src/featureServiceHelpers.ts +++ b/packages/common/src/featureServiceHelpers.ts @@ -553,10 +553,6 @@ export function setNamesAndTitles(templates: IItemTemplate[]): IItemTemplate[] { // If the name already contains a GUID remove it baseName = baseName.replace(/_[0-9A-F]{32}/gi, ""); - // Replace characters that are not allowed in a feature service name with "_" - // Disallowed: '#', '%', '&', '"', '\', '/', '+', '?', ':', '*', '<', '>', ' ', '\t' - baseName = baseName.replace(/[#%&"\\/+?:*<> \t]/g, "_"); - // The name length limit is 98 // Limit the baseName to 50 characters before the _ // If the baseName includes '{{params' it is likely being used in a template replacement, so do not truncate. diff --git a/packages/common/src/restHelpers.ts b/packages/common/src/restHelpers.ts index 9a61c7c74..249026724 100644 --- a/packages/common/src/restHelpers.ts +++ b/packages/common/src/restHelpers.ts @@ -2105,6 +2105,17 @@ export function _countRelationships(layers: any[]): number { return layers.reduce(reducer, 0); } +/** + * Remove spaces and replace other characters that are not allowed in a feature service name with "_". + * Spaces are removed entirely. Disallowed (replaced with "_"): '#', '%', '&', '"', '\', '/', '+', '?', ':', '*', '<', '>', '\t' + * + * @param name The candidate service name + * @returns The sanitized name, or the input unchanged if it is not a string + */ +export function sanitizeFeatureServiceName(name: string): string { + return typeof name === "string" ? name.replace(/ /g, "").replace(/[#%&"\\/+?:*<>\t]/g, "_") : name; +} + /** * Gets the full definitions of the layers affiliated with a hosted service. * @@ -2171,6 +2182,7 @@ export function _getCreateServiceOptions( } createOptions.item = replaceInTemplate(createOptions.item, templateDictionary); createOptions.params = replaceInTemplate(createOptions.params, templateDictionary); + createOptions.item.name = sanitizeFeatureServiceName(createOptions.item.name); if (newItemTemplate.item.thumbnail) { // Pass thumbnail file in via params because item property is serialized, which discards a blob diff --git a/packages/common/test/featureServiceHelpers.test.ts b/packages/common/test/featureServiceHelpers.test.ts index e56a4b2c0..4067e57ef 100644 --- a/packages/common/test/featureServiceHelpers.test.ts +++ b/packages/common/test/featureServiceHelpers.test.ts @@ -1973,27 +1973,6 @@ describe("Module `featureServiceHelpers`: utility functions for feature-service expect(actual).toEqual(expected); }); - it("should replace disallowed characters in the name with '_'", () => { - const t: IItemTemplate = templates.getItemTemplateSkeleton(); - t.item.type = "Feature Service"; - // Includes each disallowed character: # % & " \ / + ? : * < > space tab - const disallowedName = 'a#b%c&d"e\\f/g+h?i:j*km n\to'; - t.item.name = disallowedName; - t.item.title = disallowedName; - const _templates: IItemTemplate[] = [t]; - - spyOn(generalHelpers, "generateGUID").and.returnValue("212dbc19b03943008fdfaf8d6adca00e"); - - const expectedTemplate: IItemTemplate = templates.getItemTemplateSkeleton(); - expectedTemplate.item.type = "Feature Service"; - expectedTemplate.item.name = `a_b_c_d_e_f_g_h_i_j_k_l_m_n_o_212dbc19b03943008fdfaf8d6adca00e`; - expectedTemplate.item.title = disallowedName; - const expected: IItemTemplate[] = [expectedTemplate]; - - const actual: IItemTemplate[] = setNamesAndTitles(_templates); - expect(actual).toEqual(expected); - }); - it("should not truncate if '{{param' is used", () => { const t: IItemTemplate = templates.getItemTemplateSkeleton(); t.item.type = "Feature Service"; diff --git a/packages/common/test/restHelpers.test.ts b/packages/common/test/restHelpers.test.ts index e79da3b84..9b9ed141d 100644 --- a/packages/common/test/restHelpers.test.ts +++ b/packages/common/test/restHelpers.test.ts @@ -4573,6 +4573,58 @@ describe("Module `restHelpers`: common REST utility functions shared across pack expect(templateDictionary[itemId].solutionExtent).toBeUndefined(); expect(templateDictionary[itemId].defaultSpatialReference).toEqual({ wkid: serviceWkid }); }); + + it("sanitizes disallowed characters in service name resolved from a {{params}} placeholder", async () => { + const userSession: UserSession = new UserSession({ + username: "jsmith", + password: "123456", + }); + + const templateDictionary: any = { + folderId: "aabb123456", + isPortal: false, + solutionItemId: "sol1234567890", + ab766cba0dd44ec080420acc10990282: {}, + organization: organization, + solutionItemExtent: solutionItemExtent, + params: { + buildSolution: { + items: { + ab766cba0dd44ec080420acc10990282: { + title: 'My & Service #1: a/b\\c "e" +f?g*h%i j\tk', + }, + }, + }, + }, + }; + + itemTemplate.item.name = + "{{params.buildSolution.items.ab766cba0dd44ec080420acc10990282.title}}_0a25612a2fc54f6e8828c679e2300a49"; + itemTemplate.item.title = "{{params.buildSolution.items.ab766cba0dd44ec080420acc10990282.title}}"; + itemTemplate.properties.service.spatialReference = { wkid: 102100 }; + itemTemplate.itemId = "ab766cba0dd44ec080420acc10990282"; + + const options = await restHelpers._getCreateServiceOptions(itemTemplate, userSession, templateDictionary); + expect(options.item.name).toEqual("My_Service_1_a_b_c_d__e__f_g_h_ij_k_0a25612a2fc54f6e8828c679e2300a49"); + // Title is *not* sanitized — only the service name has the character restriction. + expect(options.item.title).toEqual('My & Service #1: a/b\\c "e" +f?g*h%i j\tk'); + }); + }); + + describe("sanitizeFeatureServiceName", () => { + it("removes spaces and replaces every other disallowed character with '_'", () => { + const disallowedName = 'a#b%c&d"e\\f/g+h?i:j*km n\to'; + expect(restHelpers.sanitizeFeatureServiceName(disallowedName)).toEqual("a_b_c_d_e_f_g_h_i_j_k_l_mn_o"); + }); + + it("returns allowed names unchanged", () => { + expect(restHelpers.sanitizeFeatureServiceName("AcceptableName_1")).toEqual("AcceptableName_1"); + }); + + it("returns non-string inputs unchanged", () => { + expect(restHelpers.sanitizeFeatureServiceName(undefined as any)).toBeUndefined(); + expect(restHelpers.sanitizeFeatureServiceName(null as any)).toBeNull(); + }); }); describe("_extentIsValid", () => { From 48c692aa4181c9be7fe1d38f093f4f42e83973a9 Mon Sep 17 00:00:00 2001 From: John Hauck Date: Thu, 11 Jun 2026 17:55:12 -0600 Subject: [PATCH 4/4] remove comment --- demos/deploySolution/src/index.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/demos/deploySolution/src/index.ts b/demos/deploySolution/src/index.ts index 66db89ed1..1bb95a757 100644 --- a/demos/deploySolution/src/index.ts +++ b/demos/deploySolution/src/index.ts @@ -168,10 +168,6 @@ function go( const dontCreateSolutionItem = htmlUtil.getHTMLChecked("dontCreateSolutionItem"); // Custom Params - // The textarea contents are parsed and assigned to `templateDictionary.params` - // (see deploy-solution-main.ts). To exercise a buildSolution payload, wrap it - // as `{ "buildSolution": {...} }` so templates can reference - // `{{params.buildSolution.items..title}}` etc. const customParams = htmlUtil.getHTMLValue("customParams"); // If no Solution Id was entered, fall back to buildSolution.solution.item.id