diff --git a/CHANGES.md b/CHANGES.md index f87795bd..b0fbda56 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,7 @@ ## Version 1.21 +- Add support for upserting and deleting Custom Metadata (contribution by [Joshua Yarmak](https://github.com/toly11)) - Add "Org" tab to display org and instance information (contribution by [Victor Garcia Zarco](https://github.com/victorgz)) - Undelete records from data import page [feature 193](https://github.com/tprouvot/Salesforce-Inspector-reloaded/issues/193) - Create new records from SObject tab ("New" button) [feature 226](https://github.com/tprouvot/Salesforce-Inspector-reloaded/issues/226) diff --git a/addon/data-import-test.js b/addon/data-import-test.js index 64f78d0e..3a203e70 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); 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 05e018d3..85d7a7b3 100644 --- a/addon/data-import.js +++ b/addon/data-import.js @@ -16,11 +16,10 @@ 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.updateAvailableActions(); this.importType = "Account"; this.externalId = "Id"; this.batchSize = "200"; @@ -51,13 +50,38 @@ 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.updateAvailableActions(); + this.importAction = this.importType.endsWith("__mdt") ? "deleteMetadata" : "delete"; + this.importActionName = this.importType.endsWith("__mdt") ? "Delete Metadata" : "Delete"; this.skipAllUnknownFields(); console.log(this.importData); } } + 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: "undelete", label: "Undelete", supportedApis: ["Enterprise", "Tooling"] }, + { value: "upsertMetadata", label: "Upsert Metadata", supportedApis: ["Metadata"] }, + { 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. @@ -121,8 +145,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"; @@ -146,10 +172,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"; } @@ -184,7 +212,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); @@ -202,11 +230,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() { @@ -218,18 +260,25 @@ class Model { } sobjectList() { - let {globalDescribe} = this.describeInfo.describeGlobal(this.useToolingApi); + let { globalDescribe } = this.describeInfo.describeGlobal(this.apiType == "Tooling"); if (!globalDescribe) { return []; } - return globalDescribe.sobjects - .filter(sobjectDescribe => sobjectDescribe.createable || sobjectDescribe.deletable || sobjectDescribe.updateable) - .map(sobjectDescribe => sobjectDescribe.name); + + if (this.apiType == "Metadata") { + 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() { 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 []; @@ -244,17 +293,18 @@ class Model { if (importAction == "delete" || importAction == "undelete") { yield "Id"; + } else if (importAction == "deleteMetadata") { + 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) { @@ -265,6 +315,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; + } } } } @@ -280,13 +334,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())) { @@ -304,7 +351,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.apiType == "Metadata") { + return "DeveloperName"; + } else { + return "Id"; + } } inputIdColumnIndex() { @@ -371,7 +426,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])], @@ -431,7 +486,6 @@ class Model { actionColumnIndex, errorColumnIndex, importAction: this.importAction, - useToolingApi: this.useToolingApi, sobjectType: this.importType, idFieldName: this.idFieldName(), inputIdColumnIndex: this.inputIdColumnIndex() @@ -450,7 +504,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; @@ -610,7 +664,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 = []; @@ -620,6 +674,11 @@ class Model { } if (importAction == "delete" || importAction == "undelete") { importArgs.ID = []; + } else if (importAction == "deleteMetadata") { + importArgs["met:type"] = "CustomMetadata"; + importArgs["met:fullNames"] = []; + } else if (importAction == "upsertMetadata") { + importArgs["met:metadata"] = []; } else { importArgs.sObjects = []; } @@ -635,6 +694,53 @@ class Model { row[statusColumnIndex] = "Processing"; if (importAction == "delete" || importAction == "undelete") { importArgs.ID.push(row[inputIdColumnIndex]); + } 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]; + 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; + } + + let field = { + "met:field": fieldName, + "met:value": { + "_": fieldValue + } + }; + + if (fieldTypes[fieldName]) { + field["met:value"]["$xsi:type"] = fieldTypes[fieldName]; + } + + sobject["met:values"].push(field); + } + } + + importArgs["met:metadata"].push(sobject); } else { let sobject = {}; sobject["$xsi:type"] = sobjectType; @@ -688,7 +794,9 @@ 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 wsdl = sfConn.wsdl(apiVersion, this.apiType); + 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]; @@ -698,8 +806,8 @@ class Model { row[actionColumnIndex] = importAction == "create" ? "Inserted" : importAction == "update" ? "Updated" - : importAction == "upsert" ? (result.created == "true" ? "Inserted" : "Updated") - : importAction == "delete" ? "Deleted" + : importAction == "upsert" || importAction == "upsertMetadata" ? (result.created == "true" ? "Inserted" : "Updated") + : importAction == "delete" || importAction == "deleteMetadata" ? "Deleted" : importAction == "undelete" ? "Undeleted" : "Unknown"; } else { @@ -756,7 +864,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); @@ -776,9 +884,12 @@ class App extends React.Component { this.onConfirmPopupNoClick = this.onConfirmPopupNoClick.bind(this); this.unloadListener = null; } - onUseToolingApiChange(e) { - let {model} = this.props; - model.useToolingApi = e.target.checked; + onApiTypeChange(e) { + let { model } = this.props; + model.apiType = e.target.value; + model.updateAvailableActions(); + model.importAction = model.availableActions[0].value; + model.importActionName = model.allActions.find(action => action.value == model.importAction).label; model.updateImportTableResult(); model.didUpdate(); } @@ -960,24 +1071,22 @@ class App extends React.Component { h("div", {className: "area-header"}, 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("span", {className: "conf-value"}, - h("input", {type: "checkbox", checked: model.useToolingApi, onChange: this.onUseToolingApiChange, disabled: model.isWorking()}), + h("div", { className: "conf-line" }, + 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("select", { value: model.apiType, onChange: this.onApiTypeChange, disabled: model.isWorking() }, + ...model.allApis.map((api, index) => h("option", { key: index, value: api.value }, api.label)) + ) ) ) ), - h("div", {className: "conf-line"}, - h("label", {className: "conf-input"}, - 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: "undelete"}, "Undelete") + h("div", { className: "conf-line" }, + h("label", { className: "conf-input" }, + h("span", { className: "conf-label" }, "Action"), + h("span", { className: "conf-value" }, + h("select", { value: model.importAction, onChange: this.onImportActionChange, disabled: model.isWorking() }, + ...model.availableActions.map((action, index) => h("option", { key: index, value: action.value }, action.label)) ) ) ) @@ -1050,8 +1159,8 @@ 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()), - h("div", {className: "conf-value"}, model.columns().map((column, index) => h(ColumnMapper, {key: index, model, column}))) + 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 }))) ) ) ), @@ -1191,3 +1300,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 1e1df2e7..47f00dff 100644 --- a/addon/inspector.js +++ b/addon/inspector.js @@ -114,23 +114,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) { @@ -149,16 +154,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 = () => { @@ -201,7 +217,13 @@ 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 == "_") { + 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) { // ignore