Skip to content

Instantly share code, notes, and snippets.

@cwest-consultch
Last active January 4, 2023 04:17
  • Star 1 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
Star You must be signed in to star a gist
Embed
What would you like to do?
Salesforce Bookmarklet - Export Object Descriptions
body {
background: rgba(255,255,255,0.8);
overflow: hidden;
}
.btn {
background-image: linear-gradient(to bottom, rgba(255,255,255,0), rgba(255,255,255,0.1) 50%, rgba(0,0,0,0.2) 50%, rgba(0,0,0,0));
box-shadow: 0 0 0.2em #000;
}
.tab-pane {
padding: 1em;
}
pre {
overflow: inherit;
}
<html>
<head>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-Zenh87qX5JnK2Jl0vWa8Ck2rdkQ2Bzep5IDxbcnCeuOxjzrPF/et3URy9Bv1WTRi" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.2/font/bootstrap-icons.css">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-OERcA2EqjJCMA+/3y+gxIOqMEjwtxJY7qPCqsdltbNJuaOe923+mo//f6V8Qbsw3" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/exceljs/4.3.0/exceljs.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/ag-grid/25.1.0/ag-grid-community.min.js"></script>
</head>
<body>
<div id="myVue" style="position: absolute; inset: 0;">
<table style="width: 100%; height: 100%;">
<tr>
<td style="height: 1px; padding: 8px;">
<label class="input-group mb-3">
<span class="input-group-text">SObject</span>
<input
type="text"
class="form-control"
placeholder="eg. Account"
list="sobjectNames"
v-model="selectedSObjectName"
@change="validateSObjectSelection"
/>
</label>
<datalist id="sobjectNames">
<option v-for="sobject in sobjects" v-bind:value="sobject.name">{{sobject.label}}</option>
</datalist>
<div v-show="hasSelectedSObject">
<nav>
<div class="nav nav-tabs" id="nav-tab" role="tablist">
<button class="nav-link active" id="nav-overview-json-tab" data-bs-toggle="tab" data-bs-target="#nav-overview-json" type="button">Overview (JSON)</button>
<button v-show="hasRawDescription" class="nav-link" id="nav-describe-json-tab" data-bs-toggle="tab" data-bs-target="#nav-describe-json" type="button">Describe (JSON)</button>
<button v-show="hasRawDescription" class="nav-link" id="nav-describe-table-tab" data-bs-toggle="tab" data-bs-target="#nav-describe-table" type="button">Describe (Table)</button>
<button v-show="canDownloadCombinedXLSX" class="nav-link" id="nav-combined-xlsx-tab" data-bs-toggle="tab" data-bs-target="#nav-combined-xlsx" type="button">Combined XLSX</button>
</div>
</nav>
</div>
</td>
</tr>
<tr>
<td style="height: 99%;">
<div style="height: 100%; overflow: auto; max-width: 100vw;">
<div v-show="hasSelectedSObject">
<div class="tab-content" id="nav-tabContent" style="height: 100%;">
<div class="tab-pane fade show active" id="nav-overview-json" tabindex="0">
<div class="btn-group">
<button class="btn btn-primary" @click="downloadSelectedSObjectJSON">
<i class="bi bi-cloud-arrow-down"></i>
Download JSON
</button>
<button class="btn btn-secondary" @click="copySelectedSObjectJSON">
<i class="bi bi-clipboard-plus"></i>
Copy JSON
</button>
</div>
<hr>
<pre ref="selectedSObjectJSON">{{ selectedSObjectJSON }}</pre>
</div>
<div v-show="hasRawDescription" class="tab-pane fade" id="nav-describe-json" tabindex="0">
<div class="btn-group">
<button class="btn btn-primary" @click="downloadRawDescriptionJSON">
<i class="bi bi-cloud-arrow-down"></i>
Download JSON
</button>
<button class="btn btn-secondary" @click="copyRawDescriptionJSON">
<i class="bi bi-clipboard-plus"></i>
Copy JSON
</button>
</div>
<hr>
<pre ref="rawDescriptionJSON">{{ rawDescriptionJSON }}</pre>
</div>
<div v-show="hasRawDescription" class="tab-pane fade" id="nav-describe-table" tabindex="0" style="height: 100%;">
<table style="height: 100%; width: 100%;">
<tr>
<td style="height: 1px;">
<button class="btn btn-primary" @click="downloadRawDescriptionXLSX">
<i class="bi bi-cloud-arrow-down"></i>
Download XLSX
</button>
<div class="form-check form-switch">
<input
class="form-check-input"
type="checkbox"
id="flexSwitchCheckChecked"
:checked="isIncludedInXLSX"
@input="toggleIncludeInXLSX"
/>
<label class="form-check-label" for="flexSwitchCheckChecked">Include in Master XLSX</label>
</div>
<hr>
</td>
</tr>
<tr>
<td style="height: 99%;">
<ag-grid theme="balham" :column-defs="sobjectColumnDefs" :rows="sobjectRows" style="height: 100%;"></ag-grid>
</td>
</tr>
</table>
</div>
<div v-show="canDownloadCombinedXLSX" class="tab-pane fade" id="nav-combined-xlsx" tabindex="0">
<label class="input-group mb-3">
<span class="input-group-text">File Name</span>
<input
type="text"
class="form-control"
placeholder="eg. Account"
list="sobjectLabels"
v-model="combinedXLSXName"
/>
<button class="btn btn-primary" @click="downloadCombinedXLSX">
<i class="bi bi-cloud-arrow-down"></i>
Download XLSX
</button>
</label>
<hr>
<table class="table table-striped table-hover">
<thead>
<tr>
<th>Label</th>
<th>API Name</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="rawDescription in sortedCombinedRawDescriptions">
<td class="align-middle">{{ rawDescription.label }}</td>
<td class="align-middle">{{ rawDescription.name }}</td>
<td class="align-middle" style="width: 1px; white-space: nowrap;">
<button class="btn btn-danger" @click="removeIncludedInXLSX(rawDescription.name)">
<i class="bi bi-trash"></i>
Remove
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</td>
</tr>
</table>
</div>
</body>
</html>
function copyToClipboard(value, elToSelect) {
navigator.clipboard.writeText(value);
elToSelect && selectText(elToSelect);
}
function selectText(container) {
if (document.selection) { // IE
var range = document.body.createTextRange();
range.moveToElementText(container);
range.select();
} else if (window.getSelection) {
var range = document.createRange();
range.selectNode(container);
window.getSelection().removeAllRanges();
window.getSelection().addRange(range);
}
}
function handleMainMessage(message) {
console.log('Got main message:', message);
if (message.success) {
if (message.action === 'list-sobject-types') {
myVue.sobjects = message.data.sobjects.filter(x => x.queryable && x.createable && !x.associateEntityType).sort((a, b) => a.label < b.label ? -1 : 1);
}
else if (message.action === 'describe') {
myVue.rawDescription = message.data;
}
}
else {
alert('An error occurred when trying to make a request to Salesforce. Please check the JavaScript Console for more information.');
console.error(message);
}
}
/**
* Uses ExcelJS to create and download an XLSX file.
* @requires {@link https://cdnjs.cloudflare.com/ajax/libs/exceljs/4.3.0/exceljs.min.js}
* @param {string} name
* @param {downloadXLSX__Worksheet[]} worksheets
*/
function downloadXLSX(name, worksheets) {
const wb = new ExcelJS.Workbook();
worksheets.forEach((worksheet, worksheetIndex) => {
const ws = wb.addWorksheet(worksheet.name ?? `Sheet ${worksheetIndex + 1}`);
if (worksheet.frozenRows || worksheet.frozenColumns) {
const view = {state: 'frozen'};
if (worksheet.frozenRows) view.ySplit = worksheet.frozenRows;
if (worksheet.frozenColumns) view.xSplit = worksheet.frozenColumns;
ws.views = [view];
}
ws.columns = worksheet.rows[0].map((header, headerIndex) => {
const isObject = header && 'object' === typeof header && !(header instanceof Date);
const defaultWidth = 'number' === typeof worksheet.columnWidths
? worksheet.columnWidths
: worksheet.columnWidths?.[headerIndex] ?? 10;
return {
header: isObject ? header.header ?? `Column ${headerIndex + 1}` : header,
key: isObject ? header.key ?? `col${headerIndex}` : header,
width: isObject ? header.width ?? defaultWidth : defaultWidth,
};
});
worksheet.rows.slice(1).forEach(row => {
ws.addRow(row);
});
if (worksheet.argbHeaderColor || worksheet.argbRowColors?.length) {
ws.eachRow((row, rowNumber) => {
row.eachCell({includeEmpty: true}, (cell, cellNumber) => {
if (rowNumber === 1) {
if (worksheet.argbHeaderColor) {
if (worksheet.argbHeaderColor.fg) {
cell.font = { color: {argb: worksheet.argbHeaderColor.fg} };
}
if (worksheet.argbHeaderColor.bg) {
cell.fill = {
type: 'pattern',
pattern: 'solid',
fgColor: {argb: worksheet.argbHeaderColor.bg}
};
}
}
}
else if (worksheet.argbRowColors?.length) {
const argbRowColor = worksheet.argbRowColors[(rowNumber - 2) % worksheet.argbRowColors.length];
if (argbRowColor.fg) {
cell.font = { color: {argb: argbRowColor.fg} };
}
if (argbRowColor.bg) {
cell.fill = {
type: 'pattern',
pattern: 'solid',
fgColor: {argb: argbRowColor.bg}
};
}
}
});
});
}
if (worksheet.autoFilter) {
ws.autoFilter = {
from: { column: 1, row: 1 },
to: { column: worksheet.rows?.[0]?.length ?? 0, row: worksheet.rows.length },
};
}
worksheet.callback && worksheet.callback(ws);
});
wb.xlsx.writeBuffer().then((data) => {
const blob = new Blob([data], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;charset=utf-8' });
const url = window.URL.createObjectURL(blob);
Object.assign(document.createElement('a'), {
href: url,
download: name,
}).click();
window.URL.revokeObjectURL(url);
});
}
/**
* @typedef {Object} downloadXLSX__Worksheet
* @property {string} name
* @property {any[][]} rows
* @property {number} [frozenRows=0]
* @property {number} [frozenColumns=0]
* @property {{bg?: string, fg?: string}} [argbHeaderColor]
* @property {{bg?: string, fg?: string}[]} [argbRowColors]
* @property {number[]|number} [columnWidths]
* @property {boolean} [autoFilter=false]
* @property {(excelWorksheet: any) => void} callback
* If specified this is called after creating the ExcelJS worksheet and it is
* passed the ExcelJS worksheet object that was created so that you can make
* other custom changes.
*/
/**
* @see https://gist.github.com/westc/ea154cab93336999968ece2fe6f629e1
* Takes an integer and returns the corresponding column name (eg. 5 becomes E).
* @param {number} number
* The integer to convert to a column name. If `opt_isZeroBased` is true
* then 0 will be converted to "A", otherwise 1 will be converted to "A".
* @param {?boolean=} opt_isZeroBased
* Indicates if `number` is interpreted as a 0-based index.
* @return {string}
* The column name.
*/
function toColumnName(number, opt_isZeroBased) {
for (
var index, num = number + (opt_isZeroBased ? 1 : 0), ret = '';
index = num - 1, num;
num = Math.floor(index / 26)
) {
ret = String.fromCharCode(65 + index % 26) + ret;
}
return ret;
}
const myVue = window.myVue = new Vue({
el: '#myVue',
data: {
sobjects: [],
selectedSObjectName: null,
rawDescription: null,
includedXLSXDescriptions: {},
combinedXLSXName: 'Combined',
},
computed: {
selectedSObject() {
return this.sobjects.find(x => x.name === this.selectedSObjectName);
},
selectedSObjectJSON() {
return JSON.stringify(this.selectedSObject, null, 2);
},
rawDescriptionJSON() {
return JSON.stringify(this.rawDescription, null, 2);
},
hasRawDescription() {
return this.rawDescription != null;
},
hasSelectedSObject() {
return this.selectedSObjectJSON != null;
},
rawDescriptionRows() {
return this.getRawDescriptionRows(this.rawDescription);
},
isIncludedInXLSX() {
return !!this.selectedSObject
&& Object.keys(this.includedXLSXDescriptions).includes(this.selectedSObject.name);
},
canDownloadCombinedXLSX() {
return Object.keys(this.includedXLSXDescriptions).length > 0;
},
sortedCombinedRawDescriptions() {
return Object.values(this.includedXLSXDescriptions).sort((a, b) => a.label < b.label ? -1 : 1);
},
sobjectColumnDefs() {
return this.rawDescriptionRows[0].map((c, i) => ({
field: `field${i}`,
headerName: c,
sortable: true,
filter: true,
suppressMovable: true
}));
},
sobjectRows() {
return this.rawDescriptionRows.slice(1).map(
row => row.reduce((row, c, i) => {
row[`field${i}`] = c;
return row;
}, {})
);
}
},
methods: {
toggleIncludeInXLSX() {
if (this.isIncludedInXLSX) {
this.removeIncludedInXLSX(this.selectedSObject.name);
}
else {
const newObj = JSON.parse(JSON.stringify(this.includedXLSXDescriptions));
newObj[this.selectedSObject.name] = this.rawDescription;
this.includedXLSXDescriptions = newObj;
}
},
removeIncludedInXLSX(name) {
const newObj = JSON.parse(JSON.stringify(this.includedXLSXDescriptions));
delete newObj[name];
this.includedXLSXDescriptions = newObj;
},
validateSObjectSelection(e) {
if (this.selectedSObject) {
this.rawDescription = null;
parent.postMessage({
action: 'describe',
path: this.selectedSObject.urls.describe,
objectName: this.selectedSObject.name,
});
}
else {
this.selectedSObjectName = null;
}
},
copySelectedSObjectJSON() {
copyToClipboard(this.selectedSObjectJSON, this.$refs.selectedSObjectJSON);
},
downloadSelectedSObjectJSON() {
const blob = new Blob([this.selectedSObjectJSON], { type: 'application/json;charset=utf-8' });
const url = window.URL.createObjectURL(blob);
Object.assign(document.createElement('a'), {
href: url,
download: `${this.selectedSObject.label}.sobject-summary.json`,
}).click();
window.URL.revokeObjectURL(url);
},
copyRawDescriptionJSON() {
copyToClipboard(this.rawDescriptionJSON, this.$refs.rawDescriptionJSON);
},
downloadRawDescriptionJSON() {
const blob = new Blob([this.rawDescriptionJSON], { type: 'application/json;charset=utf-8' });
const url = window.URL.createObjectURL(blob);
Object.assign(document.createElement('a'), {
href: url,
download: `${this.selectedSObject.label}.sobject-description.json`,
}).click();
window.URL.revokeObjectURL(url);
},
getRawDescriptionRows(rawDescription) {
const rows = [[
"Object",
"Label",
"Name",
"Description",
"Type",
"Required",
"Picklist Values",
"Sync",
]];
if (rawDescription != null) {
const objectName = rawDescription.name;
rawDescription.fields.forEach(field => {
let typeValue = field.type;
if (typeValue == "reference") {
typeValue += `(${field.referenceTo.join(',')})`;
} else if (typeValue.endsWith("picklist")) {
typeValue += `(${field.restrictedPicklist ? '' : 'un'}restricted)`;
} else if (["string", "textarea", "url", "phone"].includes(typeValue)) {
typeValue += `(${field.length})`;
} else if (["double", "currency"].includes(typeValue)) {
typeValue += `(${field.precision},${field.scale})`;
} else if (typeValue == "int") {
typeValue += `(${field.digits})`;
}
rows.push([
objectName,
field.label,
field.name,
field.description,
typeValue,
field.nillable ? "No" : "Yes",
field.picklistValues.map(v => JSON.stringify(v)).join('\n'),
null,
]);
});
}
return rows;
},
downloadRawDescriptionXLSX() {
this.downloadDescriptionsAsXLSX(
this.selectedSObject.label,
[this.rawDescription]
);
},
downloadCombinedXLSX() {
this.downloadDescriptionsAsXLSX(
this.combinedXLSXName,
this.sortedCombinedRawDescriptions
);
},
downloadDescriptionsAsXLSX(name, descriptions) {
const worksheets = descriptions.map(d => (
{
name: d.label,
rows: this.getRawDescriptionRows(d),
argbHeaderColor: {bg: 'FF666666', fg: 'FFFFFFFF'},
argbRowColors: [
{bg: 'FFBBBBBB', fg: 'FF000000'},
{bg: 'FFDDDDDD', fg: 'FF000000'},
],
columnWidths: [30, 30, 30, 40, 30, 10, 50, 10],
autoFilter: true,
frozenColumns: 2,
frozenRows: 1,
callback(sheet) {
const LAST_COL_ID = toColumnName(sheet.columnCount);
const addr = `${LAST_COL_ID}2:${LAST_COL_ID}${sheet.rowCount}`;
sheet.dataValidations.add(addr, {
type: 'list',
allowBlank: true,
formulae: ['"Yes,No"'],
showErrorMessage: true,
});
for (let colNumber = 1; colNumber <= sheet.columnCount; colNumber++) {
sheet.getColumn(colNumber).alignment = {
vertical: 'top',
horizontal: 'left',
wrapText: true
};
}
}
}
));
downloadXLSX(name, worksheets);
}
},
components: {
'ag-grid': {
props: ['columnDefs', 'defaultColDef', 'rows', 'theme'],
data() {
return {
getGridOptions: null
};
},
watch: {
columnDefs() {
this.redraw();
},
defaultColDef() {
this.redraw();
},
rows() {
this.redraw();
},
theme() {
this.redraw();
}
},
methods: {
redraw() {
const {wrapper} = this.$refs;
let gridOptions = this.getGridOptions && this.getGridOptions();
gridOptions?.api?.destroy();
wrapper.innerHTML = `<div style="height: 100%;" class="ag-theme-${this.theme ?? 'alpine'}"></div>`;
gridOptions = {
defaultColDef: this.defaultColDef,
columnDefs: this.columnDefs,
rowData: this.rows
};
new agGrid.Grid(wrapper.childNodes[0], gridOptions);
this.getGridOptions = () => gridOptions;
}
},
mounted() {
this.redraw();
},
template: `
<div>
<div ref="wrapper" :class="'ag-theme-' + theme" style="height: 100%;"></div>
</div>
`
}
}
});
const SESSION_ID = document.cookie.match(/(?:^|;\s*)sid=(.+?)(?:;|$)/)?.[1];
let frame, restVersionPath;
function handleInit() {
if (!/\.salesforce\.com$/.test(location.origin)) {
return alert('Sorry, this will only work on a page under the related "\u2026.salesforce.com" domain. Feel free to try again while on the Salesforce Developer Console.');
}
if (!SESSION_ID) {
return alert('Sorry, this will not work because the session ID (sid) could not be retrieved from the cookie.');
}
frame = createFrame({
title: 'Salesforce Object Describer',
position: 'fullscreen',
onReady() {
doRelativeGetFromSF('/services/data', function(data, success, xhr) {
if (success) {
restVersionPath = data.slice(-1)[0].url;
doRelativeGetFromSF(`${restVersionPath}/sobjects`, function(data, success, xhr) {
frame.postMessage({action: 'list-sobject-types', data, success});
if (!success) {
console.error({data, xhr});
}
});
}
else {
frame.postMessage({action: 'list-sobject-types', data, success});
console.error({data, xhr});
}
});
}
});
}
function handleFrameMessage(message) {
const {action} = message;
if (action === 'describe') {
doRelativeGetFromSF(message.path, (data, success, xhr) => {
if (success) {
doRelativeGetFromSF(
`${restVersionPath}/tooling/query/?q=SELECT+QualifiedApiName,Description,LastModifiedDate,LastModifiedBy.Name,LastModifiedById+FROM+FieldDefinition+WHERE+EntityDefinition.QualifiedApiName=\'${message.objectName}\'`,
(toolingData, success, xhr) => {
if (success) {
const extraDataByName = toolingData.records.reduce((byName, record) => {
byName[record.QualifiedApiName] = record;
return byName;
}, {});
data.fields.forEach(field => {
const extraData = extraDataByName?.[field.name];
field.description = extraData?.Description;
field.lastModifiedByName = extraData?.LastModifiedBy?.Name;
field.lastModifiedById = extraData?.LastModifiedById;
field.lastModifiedDate = extraData?.LastModifiedDate;
});
}
else {
console.error(`Could not get the field description texts for ${message.objectName} but was able to get the general field definitions.`, data);
}
frame.postMessage({action, data, success});
}
);
}
else {
frame.postMessage({action, data, success});
}
});
}
else {
console.error(`Unknown action: ${JSON.serialize(action)}`);
}
}
function doRelativeGetFromSF(path, callback, options) {
options = Object(options);
var xhr = new XMLHttpRequest();
xhr.withCredentials = true;
xhr.addEventListener("readystatechange", function() {
if(this.readyState === 4) {
let response = this.responseText;
try {response = JSON.parse(response);}catch(e){}
callback(response, ~~(xhr.status / 200) === 1, xhr);
}
});
var url = (
location.origin
+ path
+ (options.bustCache ? '&cache-buster=' + Math.random() : '')
).replace(/^([^?&]*)&/, '$1?');
xhr.open("GET", url);
xhr.setRequestHeader("Authorization", `Bearer ${SESSION_ID}`);
xhr.send();
}
console.log({doRelativeGetFromSF, SESSION_ID, restVersionPath});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment