Skip to content

Instantly share code, notes, and snippets.

@cwest-consultch
Last active January 4, 2023 04:17
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save cwest-consultch/720db6b0b18b75310cda896206a98d33 to your computer and use it in GitHub Desktop.
Save cwest-consultch/720db6b0b18b75310cda896206a98d33 to your computer and use it in GitHub Desktop.
Salesforce Bookmarklet - Export Object Descriptions

Salesforce Object Describer

This GitHub Gist contains the code for the YourJS Bookmarklet that can be used from within the Salesforce Developer Console to easily describe any Salesforce object.

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