From b0fbbc6958aab0941ec0a2adccf5bb9b0e04ea79 Mon Sep 17 00:00:00 2001 From: user Date: Sat, 25 Nov 2023 15:27:19 +0200 Subject: [PATCH 01/14] Add upsertMetadata to UI --- addon/data-import.js | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/addon/data-import.js b/addon/data-import.js index 92b28c08..14960c97 100644 --- a/addon/data-import.js +++ b/addon/data-import.js @@ -224,9 +224,16 @@ class Model { if (!globalDescribe) { return []; } - return globalDescribe.sobjects - .filter(sobjectDescribe => sobjectDescribe.createable || sobjectDescribe.deletable || sobjectDescribe.updateable) - .map(sobjectDescribe => sobjectDescribe.name); + + if (this.importAction == "upsertMetadata") { + return globalDescribe.sobjects + .filter(sobjectDescribe => sobjectDescribe.name.endsWith("__mdt")) + .map(sobjectDescribe => sobjectDescribe.name); + } else { + return globalDescribe.sobjects + .filter(sobjectDescribe => sobjectDescribe.createable || sobjectDescribe.deletable || sobjectDescribe.updateable) + .map(sobjectDescribe => sobjectDescribe.name); + } } idLookupList() { @@ -267,6 +274,10 @@ class Model { } } else if (field.idLookup && field.name.toLowerCase() == idFieldName.toLowerCase()) { yield field.name; + } else if (importAction == "upsertMetadata") { + if (["DeveloperName", "MasterLabel"].includes(field.name) || field.custom) { + yield field.name; + } } } } @@ -306,7 +317,15 @@ class Model { } idFieldName() { - return this.importAction == "create" ? "" : this.importAction == "upsert" ? this.externalId : "Id"; + if (this.importAction == "create") { + return ""; + } else if (this.importAction == "upsert") { + return this.externalId; + } else if (this.importAction == "upsertMetadata") { + return "DeveloperName"; + } else { + return "Id"; + } } inputIdColumnIndex() { @@ -943,7 +962,8 @@ class App extends React.Component { h("option", { value: "create" }, "Insert"), h("option", { value: "update" }, "Update"), h("option", { value: "upsert" }, "Upsert"), - h("option", { value: "delete" }, "Delete") + h("option", { value: "delete" }, "Delete"), + h("option", { value: "upsertMetadata" }, "Upsert Metadata") ) ) ) From 561bdbe10e6a9ad520528a26565cccbdc0566b5f Mon Sep 17 00:00:00 2001 From: user Date: Sat, 25 Nov 2023 16:09:59 +0200 Subject: [PATCH 02/14] Add upsertMetadata request and response support --- addon/data-import.js | 43 +++++++++++++++++++++++++++++++++++++++++-- addon/inspector.js | 36 ++++++++++++++++++++++++++---------- 2 files changed, 67 insertions(+), 12 deletions(-) diff --git a/addon/data-import.js b/addon/data-import.js index 14960c97..d8dd0d60 100644 --- a/addon/data-import.js +++ b/addon/data-import.js @@ -623,6 +623,8 @@ class Model { } if (importAction == "delete") { importArgs.ID = []; + } else if (importAction == "upsertMetadata") { + importArgs["met:metadata"] = []; } else { importArgs.sObjects = []; } @@ -638,6 +640,35 @@ class Model { row[statusColumnIndex] = "Processing"; if (importAction == "delete") { importArgs.ID.push(row[inputIdColumnIndex]); + } else if (importAction == "upsertMetadata") { + let sobject = {}; + sobject["$xsi:type"] = "met:CustomMetadata"; + + for (let c = 0; c < row.length; c++) { + let fieldName = header[c]; + let fieldValue = row[c]; + + if (fieldName.startsWith("_")) { + continue; + } + + if (fieldName == "DeveloperName") { + sobject["met:fullName"] = `${sobjectType}.${fieldValue}` + } else if(fieldName == "MasterLabel") { + sobject["met:label"] = fieldValue; + } else { + if (stringIsEmpty(fieldValue)) { + fieldValue = null + } + + sobject["met:values"] = { + "met:field": fieldName, + "met:value": fieldValue + } + } + } + + importArgs["met:metadata"].push(sobject); } else { let sobject = {}; sobject["$xsi:type"] = sobjectType; @@ -691,7 +722,10 @@ class Model { // unless batches are slower than timeoutDelay. setTimeout(this.executeBatch.bind(this), 2500); - this.spinFor(sfConn.soap(sfConn.wsdl(apiVersion, useToolingApi ? "Tooling" : "Enterprise"), importAction, importArgs).then(res => { + let wsdlApiName = useToolingApi ? "Tooling" : this.importAction == "upsertMetadata" ? "Metadata" : "Enterprise"; + let wsdl = sfConn.wsdl(apiVersion, wsdlApiName); + this.spinFor(sfConn.soap(wsdl, importAction, importArgs).then(res => { + let results = sfConn.asArray(res); for (let i = 0; i < results.length; i++) { let result = results[i]; @@ -701,7 +735,7 @@ class Model { row[actionColumnIndex] = importAction == "create" ? "Inserted" : importAction == "update" ? "Updated" - : importAction == "upsert" ? (result.created == "true" ? "Inserted" : "Updated") + : importAction == "upsert" || importAction == "upsertMetadata" ? (result.created == "true" ? "Inserted" : "Updated") : importAction == "delete" ? "Deleted" : "Unknown"; } else { @@ -1177,3 +1211,8 @@ class StatusBox extends React.Component { }); } + + +function stringIsEmpty(str) { + return str == null || str == undefined || str.trim() == ""; +} \ No newline at end of file diff --git a/addon/inspector.js b/addon/inspector.js index 38081525..7b66c80e 100644 --- a/addon/inspector.js +++ b/addon/inspector.js @@ -112,23 +112,28 @@ export let sfConn = { let wsdl = { Enterprise: { servicePortAddress: "/services/Soap/c/" + apiVersion, - targetNamespaces: ' xmlns="urn:enterprise.soap.sforce.com" xmlns:sf="urn:sobject.enterprise.soap.sforce.com"' + targetNamespaces: ' xmlns="urn:enterprise.soap.sforce.com" xmlns:sf="urn:sobject.enterprise.soap.sforce.com"', + apiName: "Enterprise" }, Partner: { servicePortAddress: "/services/Soap/u/" + apiVersion, - targetNamespaces: ' xmlns="urn:partner.soap.sforce.com" xmlns:sf="urn:sobject.partner.soap.sforce.com"' + targetNamespaces: ' xmlns="urn:partner.soap.sforce.com" xmlns:sf="urn:sobject.partner.soap.sforce.com"', + apiName: "Partner" }, Apex: { servicePortAddress: "/services/Soap/s/" + apiVersion, - targetNamespaces: ' xmlns="http://soap.sforce.com/2006/08/apex"' + targetNamespaces: ' xmlns="http://soap.sforce.com/2006/08/apex"', + apiName: "Apex" }, Metadata: { servicePortAddress: "/services/Soap/m/" + apiVersion, - targetNamespaces: ' xmlns="http://soap.sforce.com/2006/04/metadata"' + targetNamespaces: ' xmlns="http://soap.sforce.com/2006/04/metadata"', + apiName: "Metadata" }, Tooling: { servicePortAddress: "/services/Soap/T/" + apiVersion, - targetNamespaces: ' xmlns="urn:tooling.soap.sforce.com" xmlns:sf="urn:sobject.tooling.soap.sforce.com" xmlns:mns="urn:metadata.tooling.soap.sforce.com"' + targetNamespaces: ' xmlns="urn:tooling.soap.sforce.com" xmlns:sf="urn:sobject.tooling.soap.sforce.com" xmlns:mns="urn:metadata.tooling.soap.sforce.com"', + apiName: "Tooling" } }; if (apiName) { @@ -147,16 +152,27 @@ export let sfConn = { xhr.setRequestHeader("Content-Type", "text/xml"); xhr.setRequestHeader("SOAPAction", '""'); - let sessionHeader = {SessionHeader: {sessionId: this.sessionId}}; + let sessionHeaderKey = wsdl.apiName == "Metadata" ? "met:SessionHeader" : "SessionHeader"; + let sessionIdKey = wsdl.apiName == "Metadata" ? "met:sessionId" : "sessionId"; + let requestMethod = wsdl.apiName == "Metadata" ? `met:${method}` : method; + let requestAttributes = [ + 'xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/"', + 'xmlns:xsd="http://www.w3.org/2001/XMLSchema"', + 'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"', + ]; + if (wsdl.apiName == "Metadata") { + requestAttributes.push('xmlns:met="http://soap.sforce.com/2006/04/metadata"'); + } + let requestBody = XML.stringify({ name: "soapenv:Envelope", - attributes: ` xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"${wsdl.targetNamespaces}`, + attributes: ` ${requestAttributes.join(" ")}${wsdl.targetNamespaces}`, value: { - "soapenv:Header": Object.assign({}, sessionHeader, headers), - "soapenv:Body": {[method]: args} + "soapenv:Header": Object.assign({}, {[sessionHeaderKey]: {[sessionIdKey]: this.sessionId}}, headers), + "soapenv:Body": {[requestMethod]: args} } }); - + xhr.responseType = "document"; await new Promise(resolve => { xhr.onreadystatechange = () => { From 1874855359b7f3f2fe18de436a7426a347356435 Mon Sep 17 00:00:00 2001 From: user Date: Sat, 25 Nov 2023 17:50:25 +0200 Subject: [PATCH 03/14] Add deleteMetadata support --- addon/data-import.js | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/addon/data-import.js b/addon/data-import.js index d8dd0d60..f7b39fe2 100644 --- a/addon/data-import.js +++ b/addon/data-import.js @@ -58,6 +58,10 @@ class Model { } } + get isMetadataAction() { + return this.importAction == "upsertMetadata" || this.importAction == "deleteMetadata"; + } + /** * Notify React that we changed something, so it will rerender the view. * Should only be called once at the end of an event or asynchronous operation, since each call can take some time. @@ -225,7 +229,7 @@ class Model { return []; } - if (this.importAction == "upsertMetadata") { + if (this.isMetadataAction) { return globalDescribe.sobjects .filter(sobjectDescribe => sobjectDescribe.name.endsWith("__mdt")) .map(sobjectDescribe => sobjectDescribe.name); @@ -253,6 +257,8 @@ class Model { if (importAction == "delete") { yield "Id"; + } else if (importAction == "deleteMetadata") { + yield "DeveloperName"; } else { let sobjectName = self.importType; let useToolingApi = self.useToolingApi; @@ -321,7 +327,7 @@ class Model { return ""; } else if (this.importAction == "upsert") { return this.externalId; - } else if (this.importAction == "upsertMetadata") { + } else if (this.isMetadataAction) { return "DeveloperName"; } else { return "Id"; @@ -623,6 +629,9 @@ class Model { } if (importAction == "delete") { importArgs.ID = []; + } else if (importAction == "deleteMetadata") { + importArgs["met:type"] = "CustomMetadata"; + importArgs["met:fullNames"] = []; } else if (importAction == "upsertMetadata") { importArgs["met:metadata"] = []; } else { @@ -640,6 +649,8 @@ class Model { row[statusColumnIndex] = "Processing"; if (importAction == "delete") { importArgs.ID.push(row[inputIdColumnIndex]); + } else if (importAction == "deleteMetadata") { + importArgs["met:fullNames"].push(`${sobjectType}.${row[inputIdColumnIndex]}`); } else if (importAction == "upsertMetadata") { let sobject = {}; sobject["$xsi:type"] = "met:CustomMetadata"; @@ -722,7 +733,7 @@ class Model { // unless batches are slower than timeoutDelay. setTimeout(this.executeBatch.bind(this), 2500); - let wsdlApiName = useToolingApi ? "Tooling" : this.importAction == "upsertMetadata" ? "Metadata" : "Enterprise"; + let wsdlApiName = useToolingApi ? "Tooling" : this.isMetadataAction ? "Metadata" : "Enterprise"; let wsdl = sfConn.wsdl(apiVersion, wsdlApiName); this.spinFor(sfConn.soap(wsdl, importAction, importArgs).then(res => { @@ -736,7 +747,7 @@ class Model { = importAction == "create" ? "Inserted" : importAction == "update" ? "Updated" : importAction == "upsert" || importAction == "upsertMetadata" ? (result.created == "true" ? "Inserted" : "Updated") - : importAction == "delete" ? "Deleted" + : importAction == "delete" || importAction == "deleteMetadata" ? "Deleted" : "Unknown"; } else { row[statusColumnIndex] = "Failed"; @@ -997,7 +1008,8 @@ class App extends React.Component { h("option", { value: "update" }, "Update"), h("option", { value: "upsert" }, "Upsert"), h("option", { value: "delete" }, "Delete"), - h("option", { value: "upsertMetadata" }, "Upsert Metadata") + h("option", { value: "upsertMetadata" }, "Upsert Metadata"), + h("option", { value: "deleteMetadata" }, "Delete Metadata") ) ) ) From fbeaa27642fae8765101b9535b8d6a85b8c0b34a Mon Sep 17 00:00:00 2001 From: user Date: Sat, 25 Nov 2023 19:07:06 +0200 Subject: [PATCH 04/14] Update CHANGES.md --- CHANGES.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index 86eef67f..bc85bc50 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,8 @@ ## Version 1.21 +- Add support for upserting and deleting Custom Metadata (contribution by [Joshua Yarmak](https://github.com/toly11)) + - Org instance in not correct with after Hyperforce migration: store org instance in sessionStorage to retrieve it once per session [issue 167](https://github.com/tprouvot/Salesforce-Inspector-reloaded/issues/167) - Add Salesforce SObject documentation links [feature 219](https://github.com/tprouvot/Salesforce-Inspector-reloaded/issues/219) (idea by [Antoine Audollent]) - Add centering buttons section in footer after edit field (contribution by [Kamil Gadawski](https://github.com/KamilGadawski)) From b20911ae9e4fec1509a7c3ef3e821b6a21d19449 Mon Sep 17 00:00:00 2001 From: user Date: Sun, 26 Nov 2023 02:03:13 +0200 Subject: [PATCH 05/14] Use picklist to select API type --- addon/data-import.js | 88 ++++++++++++++++++++++++-------------------- 1 file changed, 49 insertions(+), 39 deletions(-) diff --git a/addon/data-import.js b/addon/data-import.js index f7b39fe2..93ad6c53 100644 --- a/addon/data-import.js +++ b/addon/data-import.js @@ -16,12 +16,13 @@ class Model { this.showHelp = false; this.userInfo = "..."; this.dataError = ""; - this.useToolingApi = false; + this.apiType = "Enterprise"; this.dataFormat = "excel"; this.importActionSelected = false; this.importAction = "create"; this.importActionName = "Insert"; this.importType = "Account"; + this.availableActions = this.allActions.filter(action => action.supportedApis.includes(this.apiType)); this.externalId = "Id"; this.batchSize = "200"; this.batchConcurrency = "6"; @@ -58,9 +59,20 @@ class Model { } } - get isMetadataAction() { - return this.importAction == "upsertMetadata" || this.importAction == "deleteMetadata"; - } + allApis = [ + { value: "Enterprise", label: "Enterprise (default)" }, + { value: "Tooling", label: "Tooling" }, + { value: "Metadata", label: "Metadata" } + ]; + + allActions = [ + { value: "create", label: "Insert", supportedApis: ["Enterprise", "Tooling"] }, + { value: "update", label: "Update", supportedApis: ["Enterprise", "Tooling"] }, + { value: "upsert", label: "Upsert", supportedApis: ["Enterprise", "Tooling"] }, + { value: "delete", label: "Delete", supportedApis: ["Enterprise", "Tooling"] }, + { value: "upsertMetadata", label: "Upsert Metadata", supportedApis: ["Metadata"] }, + { value: "deleteMetadata", label: "Delete Metadata", supportedApis: ["Metadata"] } + ]; /** * Notify React that we changed something, so it will rerender the view. @@ -125,8 +137,10 @@ class Model { if (data[0] && data[0][0] && data[0][0].trimStart().startsWith("salesforce-inspector-import-options")) { let importOptions = new URLSearchParams(data.shift()[0].trim()); - if (importOptions.get("useToolingApi") == "1") this.useToolingApi = true; - if (importOptions.get("useToolingApi") == "0") this.useToolingApi = false; + if (importOptions.get("useToolingApi") == "1") this.apiType = "Tooling"; + if (importOptions.get("useToolingApi") == "0") this.apiType = "Enterprise"; + // Keep the above two checks, in order to support old import options + if (this.allApis.some(api => api.value == importOptions.get("apiType"))) this.apiType = importOptions.get("apiType"); if (importOptions.get("action") == "create") this.importAction = "create"; if (importOptions.get("action") == "update") this.importAction = "update"; if (importOptions.get("action") == "upsert") this.importAction = "upsert"; @@ -190,7 +204,7 @@ class Model { copyOptions() { let importOptions = new URLSearchParams(); importOptions.set("salesforce-inspector-import-options", ""); - importOptions.set("useToolingApi", this.useToolingApi ? "1" : "0"); + importOptions.set("apiType", this.apiType); importOptions.set("action", this.importAction); importOptions.set("object", this.importType); if (this.importAction == "upsert") importOptions.set("externalId", this.externalId); @@ -224,12 +238,12 @@ class Model { } sobjectList() { - let { globalDescribe } = this.describeInfo.describeGlobal(this.useToolingApi); + let { globalDescribe } = this.describeInfo.describeGlobal(this.apiType == "Tooling"); if (!globalDescribe) { return []; } - - if (this.isMetadataAction) { + + if (this.apiType == "Metadata") { return globalDescribe.sobjects .filter(sobjectDescribe => sobjectDescribe.name.endsWith("__mdt")) .map(sobjectDescribe => sobjectDescribe.name); @@ -242,7 +256,7 @@ class Model { idLookupList() { let sobjectName = this.importType; - let sobjectDescribe = this.describeInfo.describeSobject(this.useToolingApi, sobjectName).sobjectDescribe; + let sobjectDescribe = this.describeInfo.describeSobject(this.apiType == "Tooling", sobjectName).sobjectDescribe; if (!sobjectDescribe) { return []; @@ -261,15 +275,14 @@ class Model { yield "DeveloperName"; } else { let sobjectName = self.importType; - let useToolingApi = self.useToolingApi; - let sobjectDescribe = self.describeInfo.describeSobject(useToolingApi, sobjectName).sobjectDescribe; + let sobjectDescribe = self.describeInfo.describeSobject(self.apiType == "Tooling", sobjectName).sobjectDescribe; if (sobjectDescribe) { let idFieldName = self.idFieldName(); for (let field of sobjectDescribe.fields) { if (field.createable || field.updateable) { yield field.name; for (let referenceSobjectName of field.referenceTo) { - let referenceSobjectDescribe = self.describeInfo.describeSobject(useToolingApi, referenceSobjectName).sobjectDescribe; + let referenceSobjectDescribe = self.describeInfo.describeSobject(self.apiType == "Tooling", referenceSobjectName).sobjectDescribe; if (referenceSobjectDescribe) { for (let referenceField of referenceSobjectDescribe.fields) { if (referenceField.idLookup) { @@ -327,7 +340,7 @@ class Model { return ""; } else if (this.importAction == "upsert") { return this.externalId; - } else if (this.isMetadataAction) { + } else if (this.apiType == "Metadata") { return "DeveloperName"; } else { return "Id"; @@ -398,7 +411,7 @@ class Model { let data = this.importData.taggedRows.map(row => row.cells); this.importTableResult = { table: [header, ...data], - isTooling: this.useToolingApi, + isTooling: this.apiType == "Tooling", describeInfo: this.describeInfo, sfHost: this.sfHost, rowVisibilities: [true, ...this.importData.taggedRows.map(row => this.showStatus[row.status])], @@ -458,7 +471,6 @@ class Model { actionColumnIndex, errorColumnIndex, importAction: this.importAction, - useToolingApi: this.useToolingApi, sobjectType: this.importType, idFieldName: this.idFieldName(), inputIdColumnIndex: this.inputIdColumnIndex() @@ -477,7 +489,7 @@ class Model { let args = new URLSearchParams(); args.set("host", this.sfHost); args.set("objectType", this.importType); - if (this.useToolingApi) { + if (this.apiType == "Tooling") { args.set("useToolingApi", "1"); } return "inspect.html?" + args; @@ -619,7 +631,7 @@ class Model { return; } - let { statusColumnIndex, resultIdColumnIndex, actionColumnIndex, errorColumnIndex, importAction, useToolingApi, sobjectType, idFieldName, inputIdColumnIndex } = this.importState; + let { statusColumnIndex, resultIdColumnIndex, actionColumnIndex, errorColumnIndex, importAction, sobjectType, idFieldName, inputIdColumnIndex } = this.importState; let data = this.importData.importTable.data; let header = this.importData.importTable.header.map(c => c.columnValue); let batchRows = []; @@ -664,21 +676,21 @@ class Model { } if (fieldName == "DeveloperName") { - sobject["met:fullName"] = `${sobjectType}.${fieldValue}` - } else if(fieldName == "MasterLabel") { + sobject["met:fullName"] = `${sobjectType}.${fieldValue}`; + } else if (fieldName == "MasterLabel") { sobject["met:label"] = fieldValue; } else { if (stringIsEmpty(fieldValue)) { - fieldValue = null + fieldValue = null; } - + sobject["met:values"] = { "met:field": fieldName, "met:value": fieldValue - } + }; } } - + importArgs["met:metadata"].push(sobject); } else { let sobject = {}; @@ -733,8 +745,7 @@ class Model { // unless batches are slower than timeoutDelay. setTimeout(this.executeBatch.bind(this), 2500); - let wsdlApiName = useToolingApi ? "Tooling" : this.isMetadataAction ? "Metadata" : "Enterprise"; - let wsdl = sfConn.wsdl(apiVersion, wsdlApiName); + let wsdl = sfConn.wsdl(apiVersion, this.apiType); this.spinFor(sfConn.soap(wsdl, importAction, importArgs).then(res => { let results = sfConn.asArray(res); @@ -803,7 +814,7 @@ let h = React.createElement; class App extends React.Component { constructor(props) { super(props); - this.onUseToolingApiChange = this.onUseToolingApiChange.bind(this); + this.onApiTypeChange = this.onApiTypeChange.bind(this); this.onImportActionChange = this.onImportActionChange.bind(this); this.onImportTypeChange = this.onImportTypeChange.bind(this); this.onDataFormatChange = this.onDataFormatChange.bind(this); @@ -823,9 +834,11 @@ class App extends React.Component { this.onConfirmPopupNoClick = this.onConfirmPopupNoClick.bind(this); this.unloadListener = null; } - onUseToolingApiChange(e) { + onApiTypeChange(e) { let { model } = this.props; - model.useToolingApi = e.target.checked; + model.apiType = e.target.value; + model.availableActions = model.allActions.filter(action => action.supportedApis.includes(model.apiType)); + model.importAction = model.availableActions[0].value; model.updateImportTableResult(); model.didUpdate(); } @@ -992,10 +1005,12 @@ class App extends React.Component { h("h1", {}, "Configure Import") ), h("div", { className: "conf-line" }, - h("label", { className: "conf-input", title: "With the tooling API you can query more metadata, but you cannot query regular data" }, - h("span", { className: "conf-label" }, "Use Tooling API?"), + h("label", { className: "conf-input", title: "With the tooling API you can import more metadata, but you cannot import regular data. With the metadata API you can import custom metadata types." }, + h("span", { className: "conf-label" }, "API Type"), h("span", { className: "conf-value" }, - h("input", { type: "checkbox", checked: model.useToolingApi, onChange: this.onUseToolingApiChange, disabled: model.isWorking() }), + h("select", { value: model.apiType, onChange: this.onApiTypeChange, disabled: model.isWorking() }, + ...model.allApis.map((api, index) => h("option", { key: index, value: api.value }, api.label)) + ) ) ) ), @@ -1004,12 +1019,7 @@ class App extends React.Component { h("span", { className: "conf-label" }, "Action"), h("span", { className: "conf-value" }, h("select", { value: model.importAction, onChange: this.onImportActionChange, disabled: model.isWorking() }, - h("option", { value: "create" }, "Insert"), - h("option", { value: "update" }, "Update"), - h("option", { value: "upsert" }, "Upsert"), - h("option", { value: "delete" }, "Delete"), - h("option", { value: "upsertMetadata" }, "Upsert Metadata"), - h("option", { value: "deleteMetadata" }, "Delete Metadata") + ...model.availableActions.map((action, index) => h("option", { key: index, value: action.value }, action.label)) ) ) ) From a0bc5fa1ef0ac9fcdea9791ca80e7dbf6d7ef5db Mon Sep 17 00:00:00 2001 From: user Date: Sun, 26 Nov 2023 03:25:09 +0200 Subject: [PATCH 06/14] Update test script --- addon/data-import-test.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/addon/data-import-test.js b/addon/data-import-test.js index 2ee27bed..451d249d 100644 --- a/addon/data-import-test.js +++ b/addon/data-import-test.js @@ -222,7 +222,7 @@ export async function dataImportTest(test) { vm.importAction = "update"; vm.didUpdate(); vm.copyOptions(); - assertEquals("salesforce-inspector-import-options=&useToolingApi=0&action=update&object=Inspector_Test__c&batchSize=200&threads=6", window.testClipboardValue); + assertEquals("salesforce-inspector-import-options=&apiType=Enterprise&action=update&object=Inspector_Test__c&batchSize=200&threads=6", window.testClipboardValue); // Restore import options vm.importAction = "create"; @@ -231,8 +231,8 @@ export async function dataImportTest(test) { vm.didUpdate(); vm.dataFormat = "excel"; vm.didUpdate(); - vm.setData('"salesforce-inspector-import-options=&useToolingApi=0&action=update&object=Inspector_Test__c&batchSize=200&threads=6"\t""\r\n"Name"\t"Number__c"\r\n"test"\t"100"\r\n"test"\t"200"\r\n'); - assertEquals(false, vm.useToolingApi); + vm.setData('"salesforce-inspector-import-options=&apiType=Enterprise&action=update&object=Inspector_Test__c&batchSize=200&threads=6"\t""\r\n"Name"\t"Number__c"\r\n"test"\t"100"\r\n"test"\t"200"\r\n'); + assertEquals("Enterprise", vm.apiType); assertEquals("update", vm.importAction); assertEquals("Inspector_Test__c", vm.importType); assertEquals("200", vm.batchSize); From 6b3c19e28d6d06549e759b3ebd783e4223f31b03 Mon Sep 17 00:00:00 2001 From: user Date: Sun, 26 Nov 2023 18:58:09 +0200 Subject: [PATCH 07/14] Fix import button text not updating after changing to metadata API type --- addon/data-import.js | 1 + 1 file changed, 1 insertion(+) diff --git a/addon/data-import.js b/addon/data-import.js index 93ad6c53..e784d096 100644 --- a/addon/data-import.js +++ b/addon/data-import.js @@ -839,6 +839,7 @@ class App extends React.Component { model.apiType = e.target.value; model.availableActions = model.allActions.filter(action => action.supportedApis.includes(model.apiType)); model.importAction = model.availableActions[0].value; + model.importActionName = model.allActions.find(action => action.value == model.importAction).label; model.updateImportTableResult(); model.didUpdate(); } From 575049604d96599ca46a42e639affd633f589a5a Mon Sep 17 00:00:00 2001 From: user Date: Mon, 27 Nov 2023 02:30:12 +0200 Subject: [PATCH 08/14] Fix issue where only one custom field is mapped when upserting metadata --- addon/data-import.js | 20 ++++++++++++++++++-- addon/inspector.js | 4 +++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/addon/data-import.js b/addon/data-import.js index e784d096..a0625b13 100644 --- a/addon/data-import.js +++ b/addon/data-import.js @@ -664,8 +664,16 @@ class Model { } else if (importAction == "deleteMetadata") { importArgs["met:fullNames"].push(`${sobjectType}.${row[inputIdColumnIndex]}`); } else if (importAction == "upsertMetadata") { + + let fieldTypes = {}; + let selectedObjectFields = this.describeInfo.describeSobject(false, sobjectType).sobjectDescribe?.fields || []; + selectedObjectFields.forEach(field => { + fieldTypes[field.name] = field.soapType; + }); + let sobject = {}; sobject["$xsi:type"] = "met:CustomMetadata"; + sobject["met:values"] = []; for (let c = 0; c < row.length; c++) { let fieldName = header[c]; @@ -684,10 +692,18 @@ class Model { fieldValue = null; } - sobject["met:values"] = { + let field = { "met:field": fieldName, - "met:value": fieldValue + "met:value": { + "_": fieldValue + } }; + + if (fieldTypes[fieldName]) { + field["met:value"]["$xsi:type"] = fieldTypes[fieldName]; + } + + sobject["met:values"].push(field); } } diff --git a/addon/inspector.js b/addon/inspector.js index 7b66c80e..06901875 100644 --- a/addon/inspector.js +++ b/addon/inspector.js @@ -215,7 +215,9 @@ class XML { el.setAttribute("xsi:nil", "true"); } else if (typeof params == "object") { for (let [key, value] of Object.entries(params)) { - if (key == "$xsi:type") { + if (key == "_") { + el.textContent = value; + } else if (key == "$xsi:type") { el.setAttribute("xsi:type", value); } else if (value === undefined) { // ignore From ee2bbbc71a57ebc06e216ded4df83c4408be1a71 Mon Sep 17 00:00:00 2001 From: user Date: Mon, 27 Nov 2023 19:12:49 +0200 Subject: [PATCH 09/14] Automatically select metadata API when receiving url arguments with custom metadata from the data export Delete Records button --- addon/data-import.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/addon/data-import.js b/addon/data-import.js index a0625b13..8d16e106 100644 --- a/addon/data-import.js +++ b/addon/data-import.js @@ -52,8 +52,10 @@ class Model { let data = atob(args.get("data")); this.dataFormat = "csv"; this.setData(data); - this.importAction = "delete"; - this.importActionName = "Delete"; + this.apiType = this.importType.endsWith("__mdt") ? "Metadata" : "Enterprise"; + this.availableActions = this.allActions.filter(action => action.supportedApis.includes(this.apiType)); + this.importAction = this.importType.endsWith("__mdt") ? "deleteMetadata" : "delete"; + this.importActionName = this.importType.endsWith("__mdt") ? "Delete Metadata" : "Delete"; this.skipAllUnknownFields(); console.log(this.importData); } From 58410d2edbef2dede958dfeeaecc665cffbc3b5d Mon Sep 17 00:00:00 2001 From: user Date: Fri, 1 Dec 2023 15:17:25 +0200 Subject: [PATCH 10/14] Automatically select Metadata API Type when pasting custom metadata --- addon/data-import.js | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/addon/data-import.js b/addon/data-import.js index 8d16e106..3955edfa 100644 --- a/addon/data-import.js +++ b/addon/data-import.js @@ -19,10 +19,8 @@ class Model { this.apiType = "Enterprise"; this.dataFormat = "excel"; this.importActionSelected = false; - this.importAction = "create"; - this.importActionName = "Insert"; + this.updateAvailableActions(); this.importType = "Account"; - this.availableActions = this.allActions.filter(action => action.supportedApis.includes(this.apiType)); this.externalId = "Id"; this.batchSize = "200"; this.batchConcurrency = "6"; @@ -53,7 +51,7 @@ class Model { this.dataFormat = "csv"; this.setData(data); this.apiType = this.importType.endsWith("__mdt") ? "Metadata" : "Enterprise"; - this.availableActions = this.allActions.filter(action => action.supportedApis.includes(this.apiType)); + this.updateAvailableActions(); this.importAction = this.importType.endsWith("__mdt") ? "deleteMetadata" : "delete"; this.importActionName = this.importType.endsWith("__mdt") ? "Delete Metadata" : "Delete"; this.skipAllUnknownFields(); @@ -76,6 +74,13 @@ class Model { { value: "deleteMetadata", label: "Delete Metadata", supportedApis: ["Metadata"] } ]; + // set available actions based on api type, and set the first one as the default + updateAvailableActions() { + this.availableActions = this.allActions.filter(action => action.supportedApis.includes(this.apiType)); + this.importAction = this.availableActions[0].value; + this.importActionName = this.availableActions[0].label; + } + /** * Notify React that we changed something, so it will rerender the view. * Should only be called once at the end of an event or asynchronous operation, since each call can take some time. @@ -166,10 +171,12 @@ class Model { //automatically select the SObject if possible let sobj = this.getSObject(data); if (sobj) { + this.apiType = sobj.endsWith("__mdt") ? "Metadata" : "Enterprise"; + this.updateAvailableActions(); this.importType = sobj; } //automatically select update if header contains id - if (this.hasIdColumn(header) && !this.importActionSelected) { + if (this.hasIdColumn(header) && !this.importActionSelected && this.apiType != "Metadata") { this.importAction = "update"; this.importActionName = "Update"; } @@ -855,7 +862,7 @@ class App extends React.Component { onApiTypeChange(e) { let { model } = this.props; model.apiType = e.target.value; - model.availableActions = model.allActions.filter(action => action.supportedApis.includes(model.apiType)); + model.updateAvailableActions(); model.importAction = model.availableActions[0].value; model.importActionName = model.allActions.find(action => action.value == model.importAction).label; model.updateImportTableResult(); From 6f4069884a849ee947fa4a371f5376520344905a Mon Sep 17 00:00:00 2001 From: user Date: Fri, 1 Dec 2023 15:25:39 +0200 Subject: [PATCH 11/14] Add Undelete action support --- addon/data-import.js | 1 + 1 file changed, 1 insertion(+) diff --git a/addon/data-import.js b/addon/data-import.js index 3955edfa..494fb9ec 100644 --- a/addon/data-import.js +++ b/addon/data-import.js @@ -70,6 +70,7 @@ class Model { { value: "update", label: "Update", supportedApis: ["Enterprise", "Tooling"] }, { value: "upsert", label: "Upsert", supportedApis: ["Enterprise", "Tooling"] }, { value: "delete", label: "Delete", supportedApis: ["Enterprise", "Tooling"] }, + { value: "undelete", label: "Undelete", supportedApis: ["Enterprise", "Tooling"] }, { value: "upsertMetadata", label: "Upsert Metadata", supportedApis: ["Metadata"] }, { value: "deleteMetadata", label: "Delete Metadata", supportedApis: ["Metadata"] } ]; From d01c6daafc472ebb4f70dd8cacc6258d521b0a94 Mon Sep 17 00:00:00 2001 From: user Date: Fri, 1 Dec 2023 16:37:09 +0200 Subject: [PATCH 12/14] Fix issue when setting number field to null --- addon/inspector.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/addon/inspector.js b/addon/inspector.js index 06901875..8b93ee03 100644 --- a/addon/inspector.js +++ b/addon/inspector.js @@ -216,7 +216,11 @@ class XML { } else if (typeof params == "object") { for (let [key, value] of Object.entries(params)) { if (key == "_") { - el.textContent = value; + if (value == null) { + el.setAttribute("xsi:nil", "true"); + } else { + el.textContent = value; + } } else if (key == "$xsi:type") { el.setAttribute("xsi:type", value); } else if (value === undefined) { From b71de3cf145e45b9677fa88ac242338816a212e2 Mon Sep 17 00:00:00 2001 From: user Date: Sat, 2 Dec 2023 21:29:29 +0200 Subject: [PATCH 13/14] Show error when missing MasterLabel field in upsertMetadata operations --- addon/data-import.css | 3 +++ addon/data-import.js | 25 ++++++++++++++++--------- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/addon/data-import.css b/addon/data-import.css index af20b1d5..9c1e1d72 100644 --- a/addon/data-import.css +++ b/addon/data-import.css @@ -234,6 +234,9 @@ h1 { .confError { box-shadow: 0 0 3px 1px #a00; } +.confError + .confError { + margin-top: 5px; +} .columns-mapping .conf-line { padding: 5px 0; margin: 0; diff --git a/addon/data-import.js b/addon/data-import.js index 494fb9ec..e8dcbd18 100644 --- a/addon/data-import.js +++ b/addon/data-import.js @@ -232,11 +232,25 @@ class Model { this.didUpdate(); } + // Used only for requried fields that will prevent us from building a valid API request or definitely cause an error if missing. + getRequiredMissingFields() { + let missingFields = []; + + if (!this.importIdColumnValid()) { + missingFields.push(this.idFieldName()); + } + + if (this.apiType == "Metadata" && this.importAction == "upsertMetadata" && !this.columns().some(c => c.columnValue == "MasterLabel")) { + missingFields.push("MasterLabel"); + } + return missingFields; + } + invalidInput() { // We should try to allow imports to succeed even if our validation logic does not exactly match the one in Salesforce. // We only hard-fail on errors that prevent us from building the API request. // When possible, we submit the request with errors and let Salesforce give a descriptive message in the response. - return !this.importIdColumnValid() || !this.importData.importTable || !this.importData.importTable.header.every(col => col.columnIgnore() || col.columnValid()); + return !this.importData.importTable || !this.importData.importTable.header.every(col => col.columnIgnore() || col.columnValid()) || this.getRequiredMissingFields().length > 0; } isWorking() { @@ -322,13 +336,6 @@ class Model { return this.importAction == "create" || this.inputIdColumnIndex() > -1; } - importIdColumnError() { - if (!this.importIdColumnValid()) { - return "Error: The field mapping has no '" + this.idFieldName() + "' column"; - } - return ""; - } - importTypeError() { let importType = this.importType; if (!this.sobjectList().some(s => s.toLowerCase() == importType.toLowerCase())) { @@ -1119,7 +1126,7 @@ class App extends React.Component { h("h1", {}, "Field Mapping") ), /* h("div", {className: "columns-label"}, "Field mapping"), */ - h("div", { className: "conf-error confError", hidden: !model.importIdColumnError() }, model.importIdColumnError()), + model.getRequiredMissingFields().map((field, index) => h("div", { key: index, className: "conf-error confError" }, `Error: The field mapping has no '${field}' column`)), h("div", { className: "conf-value" }, model.columns().map((column, index) => h(ColumnMapper, { key: index, model, column }))) ) ) From a928bf9e19c442440602489dec0d0ee68146521a Mon Sep 17 00:00:00 2001 From: Thomas Prouvot <35368290+tprouvot@users.noreply.github.com> Date: Mon, 4 Dec 2023 10:06:21 +0100 Subject: [PATCH 14/14] Update CHANGES.md --- CHANGES.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index bc85bc50..45af6b8f 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -3,7 +3,6 @@ ## Version 1.21 - Add support for upserting and deleting Custom Metadata (contribution by [Joshua Yarmak](https://github.com/toly11)) - - Org instance in not correct with after Hyperforce migration: store org instance in sessionStorage to retrieve it once per session [issue 167](https://github.com/tprouvot/Salesforce-Inspector-reloaded/issues/167) - Add Salesforce SObject documentation links [feature 219](https://github.com/tprouvot/Salesforce-Inspector-reloaded/issues/219) (idea by [Antoine Audollent]) - Add centering buttons section in footer after edit field (contribution by [Kamil Gadawski](https://github.com/KamilGadawski))