Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Import support for custom metadata #238

Merged
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
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
2 changes: 2 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## Version 1.21

- Add support for upserting and deleting Custom Metadata (contribution by [Joshua Yarmak](https://github.com/toly11))

tprouvot marked this conversation as resolved.
Show resolved Hide resolved
- 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))
Expand Down
6 changes: 3 additions & 3 deletions addon/data-import-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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);
Expand Down
164 changes: 132 additions & 32 deletions addon/data-import.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -51,13 +52,30 @@ 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);
}
}

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"] },
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can also add the new option "undelete" I've added yesterday

#237

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now I think about this again, Isn't undelete supported in Enterprise API only?
If so, I will remove Tooling from the supportedApis attribute

{ 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.
* Should only be called once at the end of an event or asynchronous operation, since each call can take some time.
Expand Down Expand Up @@ -121,8 +139,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";
Expand Down Expand Up @@ -186,7 +206,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);
Expand Down Expand Up @@ -220,18 +240,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 [];
Expand All @@ -246,17 +273,18 @@ class Model {

if (importAction == "delete") {
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) {
Expand All @@ -267,6 +295,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;
}
}
}
}
Expand Down Expand Up @@ -306,7 +338,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() {
Expand Down Expand Up @@ -373,7 +413,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])],
Expand Down Expand Up @@ -433,7 +473,6 @@ class Model {
actionColumnIndex,
errorColumnIndex,
importAction: this.importAction,
useToolingApi: this.useToolingApi,
sobjectType: this.importType,
idFieldName: this.idFieldName(),
inputIdColumnIndex: this.inputIdColumnIndex()
Expand All @@ -452,7 +491,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;
Expand Down Expand Up @@ -594,7 +633,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 = [];
Expand All @@ -604,6 +643,11 @@ 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 {
importArgs.sObjects = [];
}
Expand All @@ -619,6 +663,53 @@ 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 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;
Expand Down Expand Up @@ -672,7 +763,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];
Expand All @@ -682,8 +775,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"
: "Unknown";
} else {
row[statusColumnIndex] = "Failed";
Expand Down Expand Up @@ -739,7 +832,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);
Expand All @@ -759,9 +852,12 @@ 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.importActionName = model.allActions.find(action => action.value == model.importAction).label;
model.updateImportTableResult();
model.didUpdate();
}
Expand Down Expand Up @@ -928,10 +1024,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))
)
)
)
),
Expand All @@ -940,10 +1038,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")
...model.availableActions.map((action, index) => h("option", { key: index, value: action.value }, action.label))
)
)
)
Expand Down Expand Up @@ -1157,3 +1252,8 @@ class StatusBox extends React.Component {
});

}


function stringIsEmpty(str) {
return str == null || str == undefined || str.trim() == "";
}
Loading