Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save westc/23b949925b0116f30ca95ab204aed5ae to your computer and use it in GitHub Desktop.
Save westc/23b949925b0116f30ca95ab204aed5ae to your computer and use it in GitHub Desktop.
Bookmarklet - Salesforce Object Describer

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;
white-space: pre-wrap;
}
#nav-tabContent, #nav-tabContent > .tab-pane {
height: 100%;
}
<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://cdnjs.cloudflare.com/ajax/libs/ag-grid/25.1.0/ag-grid-community.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.13.1/ace.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.min.js"></script>
</head>
<body>
<div id="myVue" style="position: absolute; inset: 8px;">
<max-body>
<template v-slot:header>
<nav>
<div class="nav nav-tabs" role="tablist">
<button class="nav-link active" id="navMain-describe-tab" data-bs-toggle="tab" data-bs-target="#navMain-describe" type="button">Describe</button>
<button class="nav-link" id="navMain-query-tab" data-bs-toggle="tab" data-bs-target="#navMain-query" type="button">Query</button>
<button class="nav-link" id="navMain-run-tab" data-bs-toggle="tab" data-bs-target="#navMain-run" type="button">Run Apex</button>
<button class="nav-link" id="navMain-view-logs-tab" data-bs-toggle="tab" data-bs-target="#navMain-view-logs" type="button" ref="navMain-view-logs-tab">See Logs</button>
<button class="nav-link" id="navMain-view-record-tab" data-bs-toggle="tab" data-bs-target="#navMain-view-record" type="button" ref="navMain-view-record-tab">Record View</button>
<button class="nav-link" id="navMain-utils-tab" data-bs-toggle="tab" data-bs-target="#navMain-utils" type="button" ref="navMain-utils-tab">Utils</button>
</div>
</nav>
</template>
<div class="tab-content" id="nav-tabContent">
<div class="p-0 tab-pane fade show active" id="navMain-describe" tabindex="0">
<max-body>
<template v-slot:header>
<div style="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"
:disabled="isShowingOverlay"
/>
</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" 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>
</div>
</template>
<div v-show="hasSelectedSObject">
<div class="tab-content" id="nav-tabContent">
<div class="tab-pane fade show active" id="nav-overview-json" tabindex="0">
<max-body>
<template v-slot:header>
<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>
</template>
<pre ref="selectedSObjectJSON">{{ selectedSObjectJSON }}</pre>
</max-body>
</div>
<div v-show="hasRawDescription" class="tab-pane fade" id="nav-describe-json" tabindex="0">
<max-body>
<template v-slot:header>
<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>
</template>
<pre ref="rawDescriptionJSON">{{ rawDescriptionJSON }}</pre>
</max-body>
</div>
<div v-show="hasRawDescription" class="tab-pane fade" id="nav-describe-table" tabindex="0">
<max-body>
<template v-slot:header>
<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>
</template>
<ag-grid theme="balham" :column-defs="sobjectColumnDefs" :rows="sobjectRows" style="height: 100%;"></ag-grid>
</max-body>
</div>
<div v-show="canDownloadCombinedXLSX" class="tab-pane fade" id="nav-combined-xlsx" tabindex="0">
<max-body>
<template v-slot:header>
<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>
</template>
<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>
</max-body>
</div>
</div>
</div>
</max-body>
</div>
<div class="p-0 tab-pane fade" id="navMain-query" tabindex="0">
<max-body>
<template v-slot:header>
<div class="d-flex pb-1" style="align-items: center; justify-content: center;">
<label for="txtSearch" style="flex: 1 auto; align-items: center;">SOQL or SOSL Search</label>
<div style="flex: 0 auto; align-items: center;">
<div class="form-check form-switch py-0" style="padding-right: 1rem; min-height: 1rem;">
<input
class="form-check-input"
type="checkbox"
id="chkAllRecords"
:checked="isToGetAllRecords"
@input="toggleIsToGetAllRecords"
/>
<label class="form-check-label" for="chkAllRecords">All</label>
</div>
</div>
<div style="flex: 0 auto; align-items: center;">
<div class="form-check form-switch py-0" style="min-height: 1rem;">
<input
class="form-check-input"
type="checkbox"
id="chkToolingAPI"
:checked="useToolingAPI"
@input="toggleUseToolingAPI"
/>
<label class="form-check-label" for="chkToolingAPI">Tooling API</label>
</div>
</div>
<div style="flex: 0 auto; margin-left: 1rem;">
<button class="btn btn-primary btn-sm" @click="executeSearch" :disabled="!canSearch">
<i class="bi bi-search"></i>
Search {{willSearchSelection ? 'Selection' : 'All'}}
</button>
</div>
</div>
<div style="height: 250px; box-shadow: 0 0 1px #000; border-radius: 5px; overflow: hidden;">
<ace-editor
ref="searchEditor"
v-model="searchValue"
@mac-combo-command-enter="executeSearch"
@win-combo-ctrl-enter="executeSearch"
@change-selection="updateSearchOptions"
height="100%"
mode="sql"
theme="sqlserver">
</ace-editor>
</div>
</template>
<max-body v-if="canSelectSearchResult">
<template v-slot:header>
<div class="input-group">
<label class="input-group-text" for="selSearchResults">SObject</label>
<select class="form-select" id="selSearchResults" v-model="selectedSearchResultSObject">
<option v-for="searchResult in searchResults" :value="searchResult.sobject">{{searchResult.sobject}} ({{searchResult.records.length}})</option>
</select>
<template v-if="selectedSearchResult != null">
<button class="btn btn-primary" @click="downloadSelectedSearchResultAsJSON">
<i class="bi bi-cloud-arrow-down"></i>
Download JSON
</button>
<button class="btn btn-primary" @click="downloadSelectedSearchResultAsXLSX">
<i class="bi bi-cloud-arrow-down"></i>
Download XLSX
</button>
</template>
</div>
</template>
<ag-grid
v-if="selectedSearchResult != null"
:column-defs="selectedSearchResultColumnDefs"
:rows="selectedSearchResultRows">
</ag-grid>
</max-body>
<div v-if="!canSelectSearchResult" style="border-radius: 0.5em; padding: 0.5em; text-align: center; background-color: rgba(0,0,0,0.2); box-shadow: 0 0 1px #000; margin: 0.25em 0;">
No results were found.
</div>
</max-body>
</div>
<div class="p-0 tab-pane fade" id="navMain-run" tabindex="0">
<max-body>
<template v-slot:header>
<div class="d-flex pb-1" style="align-items: center; justify-content: center;">
<label style="flex: 1 auto; align-items: center;">Apex Code</label>
<div style="flex: 0 auto; margin-left: 1rem;">
<button class="btn btn-primary btn-sm" @click="runApex">
<i class="bi bi-play-fill"></i>
Run
</button>
</div>
</div>
</template>
<div style="position: absolute; inset: 1px; box-shadow: 0 0 1px #000; border-radius: 5px; overflow: hidden;">
<ace-editor
v-model="apexCode"
@mac-combo-command-enter="runApex"
@win-combo-ctrl-enter="runApex"
height="100%"
mode="apex"
theme="sqlserver">
</ace-editor>
</div>
</max-body>
</div>
<div class="p-0 tab-pane fade" id="navMain-view-logs" tabindex="0">
<max-body>
<template v-slot:header>
<div class="d-flex pb-1" style="align-items: center; justify-content: center;">
<label style="flex: 1 auto; align-items: center;">Apex Logs</label>
<div style="flex: 0 auto; margin-left: 1rem;">
<button class="btn btn-primary btn-sm" @click="refreshApexLogs">
<i class="bi bi-arrow-clockwise"></i>
Refresh
</button>
</div>
</div>
<ag-grid
v-if="apexLogRows != null"
style="height: 250px;"
:column-defs="apexLogColDefs"
:rows="apexLogRows">
</ag-grid>
<div v-if="apexLogBody">
<div class="input-group">
<label class="input-group-text" for="selApexLogFilter">Show</label>
<select class="form-select" id="selApexLogFilter" v-model="apexLogFilter">
<option value="all">All</option>
<option value="metadata">All (Include Metadata)</option>
<option value="debug-only">Debug Only</option>
</select>
<div class="btn-group">
<button class="btn btn-primary btn-sm" @click="downloadApexLog('json')">
<i class="bi bi-cloud-arrow-down-fill"></i>
JSON
</button>
<button class="btn btn-primary btn-sm" @click="downloadApexLog('txt')">
<i class="bi bi-cloud-arrow-down-fill"></i>
TXT
</button>
<button class="btn btn-primary btn-sm" @click="downloadApexLog('xlsx')">
<i class="bi bi-cloud-arrow-down-fill"></i>
XLSX
</button>
</div>
</div>
</div>
</template>
<table v-if="apexLogBody" class="table table-striped table-hover">
<thead>
<tr>
<th>Time</th>
<th>Event</th>
<th>Line</th>
<th>Extra</th>
</tr>
</thead>
<tbody>
<tr v-for="apexLogBodyRow in apexLogBodyRows">
<td style="width: 1px; user-select: none;">{{ apexLogBodyRow.time }}</td>
<td style="width: 1px; user-select: none;">{{ apexLogBodyRow.event }}</td>
<td style="width: 1px; user-select: none;">{{ apexLogBodyRow.line }}</td>
<td style="font-family: monospace; white-space: pre-wrap; word-break: break-all;" @dblclick="selectText">{{ apexLogBodyRow.extra }}</td>
</tr>
</tbody>
</table>
</max-body>
</div>
<div class="p-0 tab-pane fade" id="navMain-view-record" tabindex="0">
<max-body>
<template v-slot:header>
<div class="container-fluid">
<div class="row">
<label class="input-group col">
<span class="input-group-text">ID</span>
<input
type="text"
class="form-control"
placeholder="eg. 0LQ3I000000DYY3WAO"
v-model="viewingRecordId"
@keydown="callOnEnter($event, getRecordById)"
/>
</label>
<label v-if="historyRecords.length" class="input-group col">
<span class="input-group-text">History ({{ historyRecords.length }})</span>
<select class="form-select" v-model="historyRecordId" @change="viewRecord(historyRecordId)">
<option v-for="historyRecord in historyRecords" :value="historyRecord.id">{{ historyRecord.label }}</option>
</select>
</label>
</div>
</div>
<div v-if="viewingRecordMeta != null">
<button class="btn btn-primary float-end" @click="downloadViewingRecordMeta">
<i class="bi bi-cloud-arrow-down"></i>
Download JSON
</button>
<h2>
{{viewingRecordMeta.description.label}}
<template v-if="viewingRecordMeta.description.label !== viewingRecordMeta.description.name">
&#x20;({{viewingRecordMeta.description.name}})
</template>
</h2>
<table class="table table-striped table-hover">
<tr>
<th style="min-width: 200px;">Label</th>
<th style="min-width: 200px;">Name</th>
<th style="width: 99%;">Value</th>
</tr>
</table>
</div>
</template>
<template v-if="viewingRecordMeta != null">
<table class="table table-striped table-hover">
<tr v-for="field in viewingRecordFields" :title="field.type">
<td style="min-width: 200px; word-break: break-word;">{{ field.label }}</td>
<td style="min-width: 200px; font-family: monospace; white-space: pre-wrap; word-break: break-all;" @dblclick="selectText">{{ field.name }}</td>
<td style="width: 99%; font-family: monospace; white-space: pre-wrap; word-break: break-all;" @dblclick="selectText">{{ field.value }}<span style="user-select: none;">&nbsp;<a v-if="field.value &amp;&amp; field.type === 'reference'" href="javascript:;" @click="viewRecord(field.value)">(View)</a><a v-if="field.value &amp;&amp; field.type === 'url'" :href="field.value" target="_blank">(Open)</a></span></td>
</tr>
</table>
</template>
</max-body>
</div>
<div class="p-0 tab-pane fade" id="navMain-utils" tabindex="0">
<max-body>
<div class="container-fluid">
<div class="row">
<div class="col-6 my-1">
<label class="input-group">
<span class="input-group-text">Organization Id</span>
<input type="text" readonly class="form-control" :value="org.Id">
</label>
</div>
<div class="col-6 my-1">
<label class="input-group">
<span class="input-group-text">Organization Name</span>
<input type="text" readonly class="form-control" :value="org.Name">
</label>
</div>
<div class="col-3 my-1">
<label class="input-group">
<span class="input-group-text">Instance Name</span>
<input type="text" readonly class="form-control" :value="org.InstanceName">
</label>
</div>
<div class="col-3 my-1">
<label class="input-group">
<span class="input-group-text">Is Sandbox</span>
<input type="text" readonly class="form-control" :value="org.IsSandbox ? 'Yes' : 'No'">
</label>
</div>
<div class="col-6 my-1">
<label class="input-group">
<span class="input-group-text">Instance URL</span>
<input type="text" readonly class="form-control" :value="instanceURL">
</label>
</div>
<div class="col-12 my-1">
<label class="input-group">
<span class="input-group-text">Session ID</span>
<input type="text" readonly class="form-control" :value="sessionId">
</label>
</div>
<div class="col-12 my-1">
<div class="mb-3">
<label for="formFile" class="form-label">ZIP File To Deploy</label>
<input class="form-control" type="file" accept="application/zip" @change="deployZip">
</div>
</div>
</div>
</div>
</max-body>
</div>
</div>
</max-body>
<div v-if="isShowingOverlay" style="position: fixed; inset: 0; background-color: rgba(255,255,255,0.5); backdrop-filter: blur(5px); z-index: 99999;">
<max-align horizontal="center" vertical="middle">
<div v-if="isWaiting" style="font-size: xx-large;">Loading&hellip;</div>
<max-body v-if="errorMessage != null">
<template v-slot:header>
<button class="btn btn-danger float-end" @click="errorMessage = null">Close</button>
<h1>Error</h1>
</template>
<pre style="border-left: 10px solid #F00; padding-left: 5px;">{{ errorMessage }}</pre>
</max-body>
</max-align>
</div>
</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);
}
}
/**
* Creates an element.
* @template {keyof HTMLElementTagNameMap} K
* @param {K} tagName
* The tag name of the element to create.
* @param {HTMLElementTagNameMap[K] & {style: CSSStyleDeclaration|string}} opt_props
* Optional. An object representing the properties to be set. If `children`
* is set it should be an array of elements and/or strings that will be adde
* as children to the created element.
* @returns {HTMLElementTagNameMap[K]}
* A new element that corresponds to the specified `tagName` and the given
* properties.
*/
function el(tagName, opt_props) {
const elem = document.createElement(tagName);
for (const [propName, propValue] of Object.entries(Object(opt_props))) {
if (propName === 'style') {
if ('string' === typeof propValue) {
elem.style.cssText = propValue;
}
else {
for (const [styleName, styleValue] of Object.entries(propValue)) {
elem.style[styleName] = styleValue;
}
}
}
else if (propName === 'children') {
for (const child of propValue) {
elem.appendChild(
'string' === typeof child
? document.createTextNode(child)
: child
);
}
}
else if (propName.indexOf('-') >= 0) {
elem.setAttribute(propName, propValue);
}
else {
elem[propName] = propValue;
}
}
return elem;
}
/**
* A tag function which can be used to create verbose regular expressions.
* @license Copyright 2021 - Chris West - MIT Licensed
* @see https://gist.github.com/westc/dc1b74018d278147e05cac3018acd8e5
*/
function vRegExp(input, ...fillins) {
let {raw} = input;
let content = raw[0];
for (let i = 1, l = raw.length; i < l; i++) {
content += fillins[i - 1] + raw[i];
}
content = content
// Escapes whitespaces within character classes.
.replace(
/(^|[^\\])\[([^\\]+|\\.)\]/g,
(_, m1, mX) => (m1 + '[' + mX.replace(/\s/g, '\\$&') + ']')
)
// Removes whitespaces and JS-style comments.
.replace(/(\\[^])|\s+|\/\/.*|\/\*[^]*?\*\//g, '$1');
return new RegExp(
// Removes the leading flags (eg. "(?i)").
content.replace(/^(?:\s*\(\?\w+\))+/g, ''),
// Only keeps the leading flags (eg. "(?i)" becomes "i").
content.replace(/\(\?(\w+)\)|[^(]+|\(/g, '$1')
);
}
function handleMainMessage(message) {
console.log('Got main message:', message);
myVue.isWaiting = false;
if (message.success) {
if (message.action === 'init-explorer') {
const {descriptions, org, sessionId} = message.data;
myVue.sobjects = descriptions.filter(x => x.queryable && !x.associateEntityType).sort((a, b) => a.label < b.label ? -1 : 1);
myVue.org = org;
myVue.sessionId = sessionId;
}
else if (message.action === 'describe') {
myVue.rawDescription = message.data;
}
else if (message.action === 'search' || message.action === 'query') {
myVue.searchResults = parseSearchResults(message.data.records ?? message.data.searchRecords);
}
else if (message.action === 'run-apex') {
myVue.showApexLog(message.data);
}
else if (message.action === 'get-logs') {
myVue.apexLogs = message.data;
}
else if (message.action === 'get-log-body') {
myVue.showApexLog(message.data);
}
else if (message.action === 'view-record') {
const {data} = message;
const {description, record} = data;
myVue.viewingRecordMeta = data;
myVue.historyRecords = [
{
id: record.Id,
label: record[description.fields.find(f => f.nameField)?.name ?? 'Id']
}
].concat(myVue.historyRecords.filter(r => r.id !== record.Id));
}
else {
myVue.errorMessage = `Unhandled action: ${message.action}`;
}
}
else {
myVue.errorMessage = 'The following error occurred:\n\n'
+ JSON.stringify(message, null, 2);
}
}
function postToMain(message) {
myVue.isWaiting = true;
try {
parent.postMessage(message);
}
catch (e) {
myVue.isWaiting = false;
myVue.errorMessage = `${e.message}\n\n${e.stack}`;
}
}
function downloadText(name, contents) {
const contentType = /\.jsonc?$/.test(name)
? 'application/json'
: /\.xlsx?$/.test(name)
? 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
: 'text/plain';
const blob = new Blob([contents], { type: `${contentType};charset=utf-8` });
const url = window.URL.createObjectURL(blob);
el('a', {
href: url,
download: name,
}).click();
window.URL.revokeObjectURL(url);
}
/**
* 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);
el('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;
}
/**
* @param {({attributes: {type: string, url: string}} & {[k: string]: (boolean|number|string|null)})[]} records
*/
function parseSearchResults(records) {
/** @type {{[k: string]: {columns: string[], records: [], sobject: string}}} */
const byType = {};
for (let record of records) {
const typeName = record.attributes.type;
/** @type {{columns: string[], records: [], sobject: string}} */
let searchResult = byType.hasOwnProperty(typeName)
? byType[typeName]
: (byType[typeName] = { columns: [], records: [], sobject: typeName });
record = flatten(
record,
(value, path) => !Array.isArray(value) && !['attributes', 'done', 'totalSize'].includes(path.at(-1)),
(value, filter, fix) => {
return Array.isArray(value)
? value.map(v => flatten(v, filter, fix))
: undefined;
}
);
for (const key of Object.keys(record)) {
if (key !== 'attributes' && !searchResult.columns.includes(key)) {
searchResult.columns.push(key);
}
}
searchResult.records.push(record);
}
return Object.values(byType).sort((a, b) => a.type < b.type ? -1 : 1);
}
/**
* @template {(value, path: string[], base) => boolean} T_Filter
* @template {(value, filter: T_Filter, fix: T_Fix) => boolean} T_Fix
* @param {any} value
* @param {T_Filter} filter
* @param {T_Fix} fix
* @returns {any}
*/
function flatten(value, filter, fix) {
function recurse(value, path, base) {
if (value && 'object' === typeof value) {
if (!filter || filter(value, path, base)) {
if (!base) {
base = {};
}
for (const [subKey, subValue] of Object.entries(value)) {
recurse(subValue, path.concat([subKey]), base);
}
return base;
}
if (filter && fix) {
value = fix(value, filter, fix);
if (value === undefined) {
return base;
}
}
}
if (base) {
base[path.map(p => p.replace(/[\\\.]/g, '\\$&')).join('.')] = value;
return base;
}
return value;
}
return recurse(value, []);
}
function parseColDef(v) {
if (v === '@VIEW_LOG_BUTTON') {
return {
cellRenderer({data: {Id}}) {
return el('a', {
href: 'javascript:;',
textContent: 'View',
onclick() {
postToMain({action: 'get-log-body', id: Id});
},
});
}
}
}
const parts = v.split('|');
const colDef = {
field: parts[1] || parts[0],
headerName: parts[0] || parts[1],
sortable: true,
filter: true,
resizable: true,
editable: true,
};
if (parts[2]) colDef.type = parts[2];
return colDef;
}
(function() {
const ACE_VALIDATORS = Object.entries({
autoSurround: {
type: 'boolean',
handler(value) { this.editor.setWrapBehavioursEnabled(value ?? true); }
},
editorOptions(value) { this.editor.setOptions(Object(value)); },
firstLineNumber: {
type: 'number',
handler(value) {
this.editor.session.setOption('firstLineNumber', value ?? 1);
}
},
foldable: {
type: 'boolean',
handler(value) { this.editor.setShowFoldWidgets(value ?? true); }
},
fontSize: {
type: 'number',
handler(value) { this.editor.setOption('fontSize', value ?? 12); }
},
gutter: {
type: 'boolean',
handler(value) { this.editor.setOption('showGutter', value ?? true); }
},
highlightActiveLine: {
type: 'boolean',
handler(value) { this.editor.setHighlightActiveLine(value ?? true); }
},
invisibles: {
type: 'boolean',
handler(value) { this.editor.setShowInvisibles(value ?? false); }
},
keybinding(value) {
this.editor.setKeyboardHandler(value ? `ace/keyboard/${value}` : null);
},
lineNumbers: {
type: 'boolean',
handler(value) {
this.editor.setOption('showLineNumbers', value ?? true);
}
},
mode(value) {
this.editor.session.setMode(`ace/mode/${value ?? 'plain_text'}`);
},
placeholder(value) { this.editor.setOption('placeholder', value); },
readOnly: {
type: 'boolean',
handler(value) { this.editor.setReadOnly(value ?? false); }
},
ruler: {
type: 'boolean',
handler(value) { this.editor.setShowPrintMargin(value ?? true); }
},
rulerColumn: {
type: 'number',
handler(value) { this.editor.setPrintMarginColumn(value ?? 80); }
},
sessionOptions(value) { this.editor.session.setOptions(Object(value)); },
softTabs: {
type: 'boolean',
handler(value) { this.editor.session.setUseSoftTabs(value ?? false); }
},
tabSize: {
type: 'number',
handler(value) { this.editor.session.setTabSize(value ?? 4); }
},
theme(value) { this.editor.setTheme(`ace/theme/${value ?? 'monokai'}`); },
wordWrap: {
type: 'boolean',
handler(value) { this.editor.session.setUseWrapMode(value ?? true); }
},
wrapLimit: {
type: 'number',
handler(value) { value && this.editor.session.setWrapLimit(value); }
},
}).reduce(
(ACE_VALIDATORS, [name, validator]) => {
let transform, handler;
if ('function' === typeof validator) {
handler = validator;
}
else {
transform = validator.type;
if (transform === 'boolean') {
transform = value => {
value += "";
return /^(?:yes|on|true)$/i.test(value)
|| (/^(?:no|off|false)$/i.test(value) ? false : undefined);
};
}
else if (transform === 'number') {
transform = value => {
value = +value;
return isNaN(value) ? undefined : value;
}
}
else {
transform = null;
}
handler = validator.handler;
}
ACE_VALIDATORS[name] = {transform, handler};
return ACE_VALIDATORS;
},
{}
);
const EDITOR_EVENTS = ['blur', 'changeSelection', 'changeSession', 'cut', 'copy', 'focus', 'paste'];
const INPUT_EVENTS = ['keydown', 'keypress', 'keyup'];
function emit(component, name, event) {
component.$emit(name.toLowerCase(), event);
if (name !== name.toLowerCase()) {
component.$emit(
name.replace(/[A-Z]+/g, function(m) { return ('-' + m).toLowerCase(); }),
event
);
}
}
Vue.component('ace-editor', {
props: [
'value',
'height',
'width',
].concat(Object.keys(ACE_VALIDATORS)),
data: function () {
return {
editor: null,
isShowingError: false,
isShowingWarning: false,
skipNextChange: false,
};
},
computed: {
style() {
return {
height: this.height != null
? 'number' === typeof this.height
? this.height + 'px'
: this.height
: '150px',
width: this.width != null
? 'number' === typeof this.width
? this.width + 'px'
: this.width
: '100%',
};
}
},
mounted() {
const editor = ace.edit(this.$refs.editor);
// Capture the key combo commands that are to be listened to.
for (const listenerKey of Object.keys(this.$listeners)) {
listenerKey.replace(
/^(?:(linux|mac|win)-)?combo-(.+)$/,
(_, env, keyCombo) => {
for (const envCandidate of ['mac', 'win']) {
if (!env || env === envCandidate) {
editor.commands.addCommand({
name: envCandidate + ':' + _,
bindKey: {[envCandidate]: keyCombo},
exec: editor => emit(this, _, this),
});
}
}
}
);
}
const {session} = editor;
INPUT_EVENTS.forEach(
eName => editor.textInput.getElement().addEventListener(
eName, e => emit(this, eName, e)
)
);
EDITOR_EVENTS.forEach(eName => {
editor.addEventListener(eName, e => emit(this, eName, e));
});
editor.on('change', () => {
if (!this.skipNextChange) {
this.$emit('input', session.getValue());
}
});
session.on('changeAnnotation', () => {
let annotations = session.getAnnotations();
let errors = annotations.filter(a => a.type === 'error');
let warnings = annotations.filter(a => a.type === 'warning');
emit(this, 'changeAnnotation', {
type: 'changeAnnotation',
annotations: annotations,
errors: errors,
warnings: warnings
});
if (errors.length) {
emit(this, 'error', { type: 'error', annotations: errors });
}
else if (this.isShowingError) {
emit(this, 'errorsRemoved', { type: 'errorsRemoved' });
}
this.isShowingError = !!errors.length;
if (warnings.length) {
emit(this, 'warning', { type: 'warning', annotations: warnings });
}
else if (this.isShowingWarning) {
emit(this, 'warningsRemoved', { type: 'warningsRemoved' });
}
this.isShowingWarning = !!warnings.length;
});
session.setValue(this.value);
this.editor = editor;
this.refreshProps();
},
methods: {
refreshProps(prop) {
for (const [propName, {transform, handler}] of Object.entries(ACE_VALIDATORS)) {
let propToSet = prop ?? propName;
let value = this[propName];
if (transform) {
value = transform.call(this, value);
}
if (propToSet === propName) {
handler.call(this, value);
}
}
},
},
watch: Object.keys(ACE_VALIDATORS).reduce(
(watch, propName) => {
watch[propName] = function() {
this.refreshProps(propName);
};
return watch;
},
{
value(newValue) {
if (this.editor.session.getValue() !== newValue) {
this.skipNextChange = true;
this.editor.session.setValue(newValue);
this.skipNextChange = false;
}
}
}
),
template: '<div ref="editor" :style="style"></div>',
});
Vue.component('max-body', {
template: `
<table style="height: 100%; width: 100%;">
<tr>
<td style="height: 1px;">
<slot name="header"></slot>
</td>
</tr>
<tr>
<td style="height: 99%;">
<div style="height: 100%; overflow: auto; position: relative;">
<slot></slot>
</div>
</td>
</tr>
</table>
`
});
Vue.component('max-align', {
props: ['horizontal', 'vertical'],
computed: {
cellStyle() {
return {
display: 'table-cell',
textAlign: this.horizontal || 'left',
verticalAlign: this.vertical || 'top',
overflow: 'auto',
};
}
},
template: `
<div style="display: table; height: 100%; width: 100%;">
<div :style="cellStyle">
<slot></slot>
</div>
</div>
`
});
Vue.component('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 ?? 'balham'}"></div>`;
gridOptions = {
columnTypes: {
date: {filter: 'agDateColumnFilter'},
number: {filter: 'agNumberColumnFilter'},
},
defaultColDef: this.defaultColDef,
columnDefs: this.columnDefs,
rowData: this.rows,
suppressFieldDotNotation: true,
};
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>
`
});
})();
// Used to parse an Apex log row.
const RGX_LOG_ROW = vRegExp`
^
(?<time>\d\d?:\d\d:\d\d\.\d+) // the time of the day
\ // followed by a space
\( // followed by an opening paren
(?<nano>\d+) // the number of nanoseconds since start
\) // followed by a closing paren
\| // followed by a pipe character
(?<event>[^|]+) // followed by the event name
(?: // followed by extra optional details
\| // followed by a pipe character
(?<line>[^|]+) // followed by line indicator
(?: // followed by extra optional details
\| // followed by a pipe character
(?<extra>[^]+) // followed by extra details
)?
)?
`;
const myVue = window.myVue = new Vue({
el: '#myVue',
data: {
sobjects: [],
selectedSObjectName: null,
rawDescription: null,
includedXLSXDescriptions: {},
combinedXLSXName: 'Combined',
searchValue: '',
selectedSearchValue: '',
isToGetAllRecords: false,
useToolingAPI: false,
selectedSearchResultSObject: null,
searchResults: [],
errorMessage: null,
isWaiting: false,
apexCode: '',
apexLogs: null,
apexLogColDefs: [
'@VIEW_LOG_BUTTON',
'User|LogUser.Username',
'Application',
'Operation',
'Time|StartTime|date',
'Duration|DurationMilliseconds|number',
'Status',
'Size|LogLength|number',
].map(parseColDef),
apexLog: null,
apexLogFilter: 'all',
apexLogBodyColDefs: [
'Time|time',
'Event|event',
'Line|line',
'Details|extra',
].map(parseColDef),
viewingRecordId: null,
viewingRecordMeta: null,
historyRecords: [],
historyRecordId: null,
org: {},
sessionId: null,
},
watch: {
searchResults(newValue) {
if (newValue.length) {
this.selectedSearchResultSObject = this.searchResults[0].sobject;
}
},
},
computed: {
instanceURL() {
return `https://${this.org.InstanceName}.salesforce.com`;
},
isViewingRecordInHistory() {
return !!this.historyRecords.find(r => r.id === this.viewingRecordId);
},
viewingRecordFields() {
const {record, description} = this.viewingRecordMeta;
return description.fields.map(field => ({
name: field.name,
label: field.label,
type: field.type,
value: record[field.name],
}));
},
apexLogBody() {
return this.apexLog?.Body;
},
apexLogBodyRows() {
return this.apexLogBody?.split(/(?:^|\r\n?|\n)(?=\d\d?:\d\d:\d\d\.\d{1,3} \(\d+\)\|)/g)
.reduce(
(rows, content) => {
let row;
let {time, nano, event, line, extra} = RGX_LOG_ROW.exec(content)?.groups ?? {};
if (time) {
nano = +nano;
if (/\[\d+\]/.test(line)) line = line.slice(1, -1);
if (event === 'USER_DEBUG') extra = extra.replace(/^DEBUG\|/, '');
row = {time, nano, event, line, extra};
}
else {
row = {extra: content};
}
const addToRows = this.apexLogFilter === 'metadata'
|| (this.apexLogFilter === 'all' && time)
|| (this.apexLogFilter === 'debug-only' && /^USER_DEBUG$|(^|_)ERROR(_|$)/.test(event));
if (addToRows) {
rows.push(row);
}
return rows;
},
[]
);
},
apexLogRows() {
return this.apexLogs?.records?.map(r => flatten(r));
},
isShowingOverlay() {
return this.isWaiting || this.errorMessage != null;
},
selectedSearchResult() {
return this.searchResults.find(r => r.sobject === this.selectedSearchResultSObject);
},
selectedSearchResultColumnDefs() {
return this.selectedSearchResult.columns.map((c, i) => ({
field: c,
headerName: c,
sortable: true,
filter: true,
resizable: true,
editable: true,
}));
},
selectedSearchResultRows() {
const {columns} = this.selectedSearchResult;
return this.selectedSearchResult.records.map(record => columns.reduce(
(row, c) => {
row[c] = record[c];
return row;
},
{}
));
},
searchValueType() {
if (this.canSearch) {
if (/^\s*(SELECT)\s/i.test(this.commentlessSearchValue)) {
return 'query';
}
if (/^\s*(FIND)\s/i.test(this.commentlessSearchValue)) {
return 'search';
}
}
},
canSelectSearchResult() {
return this.searchResults.length > 0;
},
canSearch() {
return /^\s*(SELECT|FIND)\s+\S/i.test(this.commentlessSearchValue);
},
willSearchSelection() {
return this.rawSearchValue !== this.searchValue;
},
rawSearchValue() {
return this.selectedSearchValue || this.searchValue;
},
commentlessSearchValue() {
return this.rawSearchValue.replace(
/('(?:[^'\\]|\\.)*')|--[^\r\n]*|\/\*[^]*?\*\//g,
'$1'
);
},
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,
editable: true,
}));
},
sobjectRows() {
return this.rawDescriptionRows.slice(1).map(
row => row.reduce((row, c, i) => {
row[`field${i}`] = c;
return row;
}, {})
);
}
},
methods: {
async deployZip(evt) {
const file = evt.target.files[0];
const {sessionId} = this;
const deployOptions = {
deployOptions: {
allowMissingFiles: false,
autoUpdatePackage: false,
checkOnly: false,
ignoreWarnings: false,
performRetrieve: false,
purgeOnDelete: false,
rollbackOnError: true,
runTests: null,
singlePackage: true,
testLevel: this.org.IsSandbox ? 'NoTestRun' : 'RunLocalTests'
}
};
const headers = new Headers();
headers.set("Authorization", `Bearer ${sessionId}`);
const formData = new FormData();
formData.set("json", new Blob([JSON.stringify(deployOptions)], {type: 'application/json'}), '');
formData.set("file", file);
const strResult = await (await fetch(`${location.origin}/services/data/v58.0/metadata/deployRequest`, {
method: 'POST',
headers: headers,
body: formData,
redirect: 'follow'
})).text();
const result = JSON.parse(strResult);
alert(JSON.stringify(result, null, 2));
console.log({strResult, result});
},
downloadViewingRecordMeta() {
const meta = this.viewingRecordMeta;
downloadText(
`${meta.description.name}-${meta.record.Id}.json`,
JSON.stringify(meta, null, 2),
);
},
/**
* @param {KeyboardEvent} event
* @param {Function} callback
*/
callOnEnter(event, callback) {
if (event.key === 'Enter') {
callback.apply(this, arguments);
}
},
viewRecord(id) {
this.viewingRecordId = id;
this.getRecordById();
},
getRecordById() {
postToMain({action: 'view-record', id: this.viewingRecordId});
},
selectText({target}) {
selectText(target);
},
showApexLog(apexLog) {
this.apexLog = apexLog;
(new bootstrap.Tab(this.$refs['navMain-view-logs-tab'])).show();
},
downloadApexLog(format) {
const rows = this.apexLogBodyRows;
if (format === 'json') {
downloadText(`apex-log-${this.apexLog.Id}.json`, JSON.stringify(rows));
}
else if (format === 'txt') {
downloadText(`full-apex-log-${this.apexLog.Id}.txt`, this.apexLogBody);
}
else if (format === 'xlsx') {
downloadXLSX(`apex-log-${this.apexLog.Id}.xlsx`, [
this.getWorksheetFromGridOptions(
this.apexLogBodyColDefs,
rows,
)
]);
}
},
getWorksheetFromGridOptions(columnDefs, rows) {
return {
rows: [columnDefs.map(cd => cd.headerName ?? cd.field)].concat(
rows.map(row => columnDefs.map(cd => row[cd.field]))
),
argbHeaderColor: {bg: 'FF666666', fg: 'FFFFFFFF'},
argbRowColors: [
{bg: 'FFDDDDDD', fg: 'FF000000'},
{bg: 'FFEEEEEE', fg: 'FF000000'},
],
columnWidths: columnDefs.map(x => 30),
autoFilter: true,
frozenRows: 1,
callback(sheet) {
for (let colNumber = 1; colNumber <= sheet.columnCount; colNumber++) {
sheet.getColumn(colNumber).alignment = {
vertical: 'top',
horizontal: 'left',
wrapText: true
};
}
}
};
},
downloadSelectedSearchResultAsJSON() {
const blob = new Blob(
[JSON.stringify(this.selectedSearchResult)],
{ type: 'application/json;charset=utf-8' }
);
const url = window.URL.createObjectURL(blob);
el('a', {
href: url,
download: `${this.selectedSearchResult.sobject}.results.json`,
}).click();
window.URL.revokeObjectURL(url);
},
downloadSelectedSearchResultAsXLSX() {
downloadXLSX(this.selectedSearchResult.sobject, [
this.getWorksheetFromGridOptions(
this.selectedSearchResultColumnDefs,
this.selectedSearchResultRows,
)
]);
},
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;
}
},
toggleIsToGetAllRecords() {
this.isToGetAllRecords = !this.isToGetAllRecords;
},
toggleUseToolingAPI() {
this.useToolingAPI = !this.useToolingAPI;
},
removeIncludedInXLSX(name) {
const newObj = JSON.parse(JSON.stringify(this.includedXLSXDescriptions));
delete newObj[name];
this.includedXLSXDescriptions = newObj;
},
validateSObjectSelection(e) {
if (this.selectedSObject) {
this.rawDescription = null;
postToMain({
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);
el('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);
a('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);
},
updateSearchOptions() {
this.selectedSearchValue = this.$refs.searchEditor.editor.getSelectedText() || this.searchValue;
},
executeSearch() {
if (this.canSearch) {
postToMain({
action: this.searchValueType,
value: this.commentlessSearchValue,
getAllRecords: this.isToGetAllRecords,
useToolingAPI: this.useToolingAPI,
});
}
},
runApex() {
postToMain({action: 'run-apex', value: this.apexCode});
},
refreshApexLogs() {
postToMain({action: 'get-logs'});
}
}
});
var {handleFrameMessage, handleInit} = (await (async function () {
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 Explorer",
position: "fullscreen",
onReady() {
frame.postMessage({
action: 'init-explorer',
data: {
descriptions: GLOBAL_DESCRIPTIONS,
org: ORGANIZATION,
sessionId: SESSION_ID,
},
success: true,
});
},
});
}
async function handleFrameMessage(message) {
const { action } = message;
if (action === "describe") {
sendRequestToSF(message.path, (data, success, xhr) => {
if (success) {
sendRequestToSF(
`${LATEST_VERSION_PATH}/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 if (action === "query") {
let data, success = true;
try {
data = await querySalesforce(
message.value,
message.useToolingAPI,
message.getAllRecords
);
}
catch (e) {
success = false;
data = e;
}
frame.postMessage({action, data, success});
}
else if (action === "search") {
let data, success = true;
try {
const url = `${LATEST_VERSION_PATH}/${action}/?q=`
+ encodeURIComponent(message.value).replace(/%20/g, "+");
data = await callSalesforce(url, {getAllRecords: message.getAllRecords});
}
catch (e) {
success = false;
data = e;
}
frame.postMessage({action, data, success});
}
else if (action === 'run-apex') {
let data, success = true;
try {
data = await executeApex(message.value);
}
catch (e) {
success = false;
data = e;
}
frame.postMessage({action, data, success});
}
else if (action === 'get-logs') {
let data, success = true;
try {
data = await querySalesforce(
`
SELECT LogUser.Username, ApexLog.*
FROM ApexLog
ORDER BY StartTime DESC, DurationMilliseconds ASC
`,
false,
true,
);
}
catch (e) {
success = false;
data = e;
}
frame.postMessage({action, data, success});
}
else if (action === 'get-log-body') {
let data, success = true;
try {
data = await callSalesforce(
`${LATEST_VERSION_PATH}/tooling/sobjects/ApexLog/${message.id}`
);
data.Body = await callSalesforce(
`${LATEST_VERSION_PATH}/tooling/sobjects/ApexLog/${message.id}/Body`
);
}
catch (e) {
success = false;
data = e;
}
frame.postMessage({action, data, success});
}
else if (action === 'view-record') {
let data, success = true;
try {
const record = await getRecordById(message.id);
data = {
record,
description: await describe(record.attributes.type, record.attributes.url.includes('/tooling/')),
};
}
catch (e) {
success = false;
data = e;
}
frame.postMessage({action, data, success});
}
else {
console.error(`Unknown action: ${JSON.serialize(action)}`);
}
}
/**
* @param {string} url
* Either the URL or the path (everything after the URL origin) that should be
* requested.
* @param {(response, success: boolean, xhr: XMLHttpRequest) => void} callback
* @param {SalesforceAPIOptions=} options
*/
function sendRequestToSF(url, callback, options) {
options = Object(options);
let xhr = new XMLHttpRequest();
xhr.withCredentials = true;
xhr.addEventListener("readystatechange", function () {
if (this.readyState === 4) {
const success = ~~(xhr.status / 200) === 1;
let response = this.responseText;
try {
response = JSON.parse(response);
} catch (e) {}
let skipCallback;
if (options.getAllRecords && success) {
if (options.lastResponse) {
response.records = options.lastResponse.records.concat(
response.records
);
}
if (
response.nextRecordsUrl &&
response.records?.length &&
response.done === false
) {
skipCallback = true;
sendRequestToSF(
response.nextRecordsUrl,
callback,
Object.assign({}, options, { lastResponse: response })
);
}
}
if (!skipCallback) {
callback(response, success, xhr);
}
}
});
url = (
(/^https?:/i.test(url) ? "" : location.origin) +
url +
(options.bustCache ? "&_=" + Math.random() : "")
).replace(/^([^?&]*)&/, "$1?");
xhr.open(options.method ?? "GET", url);
xhr.setRequestHeader("Authorization", `Bearer ${SESSION_ID}`);
for (const [key, value] of Object.entries(options.headers ?? {})) {
xhr.setRequestHeader(key, value);
}
options.body ? xhr.send(options.body) : xhr.send();
}
/**
* @param {string} url
* Either the URL or the path (everything after the URL origin) that should be
* requested.
* @param {SalesforceAPIOptions=} options
*/
function callSalesforce(url, options) {
return new Promise((resolve, reject) => {
sendRequestToSF(
url,
(response, success, xhr) => {
(success ? resolve : reject)(response);
},
options
);
});
}
/**
* @typedef {Object} SalesforceAPIOptions
* @property {boolean=} getAllRecords
* @property {boolean=} bustCache
* @property {"GET"|"POST"|"PATCH"|"DELETE"|"Query"=} method
* @property {{[k: string]: *}=} headers
* @property {*=} body
*/
/**
* @param {string} query
* @param {boolean} [useTooling=false]
* @param {boolean} [getAllRecords=false]
*/
async function querySalesforce(query, useTooling, getAllRecords) {
const urlPart = useTooling ? "/tooling" : "";
let realQuery = "", lastIndex = 0;
for (const match of query.matchAll(/\b((?:\w+\.)*)(\w+)\.\*/g)) {
realQuery +=
query.slice(lastIndex, match.index) +
(
await callSalesforce(
`${LATEST_VERSION_PATH + urlPart}/sobjects/${match[2]}/describe`
)
).fields
.map((f) => (match[1] ?? "") + f.name)
.join(",");
lastIndex = match.index + match[0].length;
}
realQuery += query.slice(lastIndex);
realQuery = realQuery
.replace(
/('(?:[^'\\]|\\.)*')|--[^\r\n]*|\/\*[^]*?\*\/|(\s\s+)|@(USER_ID)\b/g,
(_, str, ws, special) => {
if (special) {
return `'${USER_ID}'`;
}
return str ?? (ws && " ") ?? "";
}
)
.trim();
console.debug(realQuery);
return callSalesforce(
`${LATEST_VERSION_PATH + urlPart}/query?q=${encodeURIComponent(realQuery)}`,
{getAllRecords},
);
}
async function wait(ms) {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(new Date()), ms);
});
}
async function ensureDevConsoleTraceFlag() {
// Get the SF_Bookmarklet_Console debug level's ID.
let debugLevelId = (await querySalesforce(
`
SELECT Id, DeveloperName
FROM DebugLevel
WHERE DeveloperName = 'SF_Bookmarklet_Console'
`,
true
))?.records?.[0]?.Id;
if (!debugLevelId) {
debugLevelId = (await callSalesforce(`${LATEST_VERSION_PATH}/tooling/sobjects/DebugLevel`, {
headers: { "Content-Type": "application/json" },
method: 'POST',
body: JSON.stringify({
ApexCode: 'FINEST',
ApexProfiling: 'FINEST',
Callout: 'FINEST',
Database: 'FINEST',
DeveloperName: 'SF_Bookmarklet_Console',
Language: 'en_US',
MasterLabel: 'SF_Bookmarklet_Console',
Nba: 'FINEST',
System: 'FINEST',
Validation: 'FINEST',
Visualforce: 'FINEST',
Wave: 'FINEST',
Workflow: 'FINEST',
})
})).id;
}
// Setup or update a TraceFlag that expires in 23 hours.
const devConsoleTraceFlag = (
await querySalesforce(
`SELECT Id FROM TraceFlag WHERE TracedEntityId = '${USER_ID}'`,
true
)
).records?.[0];
const expirationDate = new Date(Date.now() + 24 * 60 * 60 * 1000 - 1);
if (devConsoleTraceFlag) {
await callSalesforce(
`${LATEST_VERSION_PATH}/tooling/sobjects/TraceFlag/${devConsoleTraceFlag.Id}`,
{
headers: { "Content-Type": "application/json" },
method: "PATCH",
body: JSON.stringify({
StartDate: (new Date).toJSON(),
ExpirationDate: expirationDate.toJSON(),
}),
}
);
} else {
await callSalesforce(`${LATEST_VERSION_PATH}/tooling/sobjects/TraceFlag`, {
headers: { "Content-Type": "application/json" },
method: "POST",
body: JSON.stringify({
ExpirationDate: expirationDate.toJSON(),
TracedEntityId: USER_ID,
LogType: "DEVELOPER_LOG",
DebugLevelId: debugLevelId,
}),
});
}
}
async function executeApex(code) {
code = code.trim();
await ensureDevConsoleTraceFlag();
const minStartTime =
(
await querySalesforce(
"SELECT StartTime FROM ApexLog ORDER BY StartTime DESC LIMIT 1",
true
)
)?.records?.[0]?.StartTime ?? "1970-01-01T00:00:00.000+0000";
const result = await callSalesforce(
`${LATEST_VERSION_PATH}/tooling/executeAnonymous/?anonymousBody=${encodeURIComponent(
code
)}`
);
if (!result.compiled) {
throw result;
}
while (true) {
await wait(500);
const apexLogs = (
await querySalesforce(
`
SELECT ApexLog.*
FROM ApexLog
WHERE StartTime > ${minStartTime}
AND LogUserId = '${USER_ID}'
ORDER BY StartTime DESC, DurationMilliseconds ASC
`,
true
)
).records;
for (const apexLog of apexLogs) {
const apexLogId = apexLog.Id;
let output = await callSalesforce(
`${LATEST_VERSION_PATH}/tooling/sobjects/ApexLog/${apexLogId}/Body`
);
const codeRan = output
.replace(/[^]*?\n(?=Execute Anonymous:)/, "")
.replace(/\n(?!Execute Anonymous:)([^]+)/, (m, o) => {
output = o;
return "";
})
.replace(/^Execute Anonymous: /gm, "");
if (codeRan === code) {
apexLog.Body = output;
return apexLog;
}
}
}
}
/**
* @param {string} id
* @returns {Promise<any>}
*/
async function getRecordById(id) {
for (const objectMeta of getObjecMetasByPrefix(id)) {
try {
return await callSalesforce(`${objectMeta.urls.sobject}/${id}`);
} catch (e) {}
}
throw new Error(`Could not retrieve record "${id}".`);
}
/**
* @param {string} idOrPrefix
* @returns {string[]}
*/
function getObjectNamesByPrefix(idOrPrefix) {
return getObjecMetasByPrefix(idOrPrefix).map(({name}) => name);
}
function getObjecMetasByPrefix(idOrPrefix) {
return GLOBAL_DESCRIPTIONS.filter(
({keyPrefix}) => keyPrefix && idOrPrefix.startsWith(keyPrefix)
);
}
async function describe(objectName, useToolingAPI) {
try {
return await callSalesforce(`${LATEST_VERSION_PATH}${useToolingAPI ? '/tooling' : ''}/sobjects/${objectName}/describe`);
}
catch (e) {
if ('boolean' !== typeof useToolingAPI) {
return await describe(objectName, true);
}
throw e;
}
}
async function genericSearch(objectName, query) {
const des = await describe(objectName);
const {searchRecords} = await temp1.callSalesforce(`${LATEST_VERSION_PATH}/parameterizedSearch`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
q: query,
sobjects: [
{
name: objectName,
fields: des.fields.map(({name}) => name)
}
]
})
});
return {
records: searchRecords,
genericFieldNames: des.fields
.filter(({idLookup, name}) => idLookup || name === 'Name')
.map(({name}) => name),
description: des,
};
}
let frame;
const SESSION_ID = document.cookie.match(/(?:^|;\s*)sid=(.+?)(?:;|$)/)?.[1]
?? prompt('The session ID could not be retrieved from your cookies. You can enter a session ID here:');
const LATEST_VERSION_PATH = (await callSalesforce("/services/data")).at(-1).url;
const USER_ID = ((await callSalesforce(LATEST_VERSION_PATH)).identity.match(
/(005\w{15})/
) ?? [])[0];
const GLOBAL_DESCRIPTIONS = (await callSalesforce(`${LATEST_VERSION_PATH}/sobjects`)).sobjects.concat(
(await callSalesforce(`${LATEST_VERSION_PATH}/tooling/sobjects`)).sobjects
);
const ORGANIZATION = (await querySalesforce('SELECT Id, Name, IsSandbox, InstanceName FROM Organization', false, true)).records[0];
console.log({
callSalesforce,
describe,
executeApex,
genericSearch,
getObjecMetasByPrefix,
getObjectNamesByPrefix,
getRecordById,
querySalesforce,
sendRequestToSF,
LATEST_VERSION_PATH,
GLOBAL_DESCRIPTIONS,
SESSION_ID,
USER_ID,
});
return {handleInit, handleFrameMessage};
})());
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment