init
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
# List files or directories below to ignore them when running force:source:push, force:source:pull, and force:source:status
|
||||
# More information: https://developer.salesforce.com/docs/atlas.en-us.sfdx_dev.meta/sfdx_dev/sfdx_dev_exclude_source.htm
|
||||
#
|
||||
|
||||
package.xml
|
||||
|
||||
# LWC configuration files
|
||||
**/jsconfig.json
|
||||
**/.eslintrc.json
|
||||
|
||||
# LWC Jest
|
||||
**/__tests__/**
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
# This file is used for Git repositories to specify intentionally untracked files that Git should ignore.
|
||||
# If you are not using git, you can delete this file. For more information see: https://git-scm.com/docs/gitignore
|
||||
# For useful gitignore templates see: https://github.com/github/gitignore
|
||||
|
||||
# Salesforce cache
|
||||
.sf/
|
||||
.sfdx/
|
||||
.localdevserver/
|
||||
deploy-options.json
|
||||
|
||||
# LWC VSCode autocomplete
|
||||
**/lwc/jsconfig.json
|
||||
|
||||
# LWC Jest coverage reports
|
||||
coverage/
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
|
||||
# ApexPMD cache
|
||||
.pmdCache
|
||||
|
||||
# Eslint cache
|
||||
.eslintcache
|
||||
|
||||
# MacOS system files
|
||||
.DS_Store
|
||||
|
||||
# Windows system files
|
||||
Thumbs.db
|
||||
ehthumbs.db
|
||||
[Dd]esktop.ini
|
||||
$RECYCLE.BIN/
|
||||
|
||||
# Local environment variables
|
||||
.env
|
||||
|
||||
# Python Salesforce Functions
|
||||
**/__pycache__/
|
||||
**/.venv/
|
||||
**/venv/
|
||||
@@ -0,0 +1 @@
|
||||
npm run precommit
|
||||
@@ -0,0 +1,11 @@
|
||||
# List files or directories below to ignore them when running prettier
|
||||
# More information: https://prettier.io/docs/en/ignore.html
|
||||
#
|
||||
|
||||
**/staticresources/**
|
||||
.localdevserver
|
||||
.sfdx
|
||||
.sf
|
||||
.vscode
|
||||
|
||||
coverage/
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"trailingComma": "none",
|
||||
"plugins": [
|
||||
"prettier-plugin-apex",
|
||||
"@prettier/plugin-xml"
|
||||
],
|
||||
"overrides": [
|
||||
{
|
||||
"files": "**/lwc/**/*.html",
|
||||
"options": { "parser": "lwc" }
|
||||
},
|
||||
{
|
||||
"files": "*.{cmp,page,component}",
|
||||
"options": { "parser": "html" }
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"salesforce.salesforcedx-vscode",
|
||||
"redhat.vscode-xml",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"esbenp.prettier-vscode",
|
||||
"financialforce.lana"
|
||||
]
|
||||
}
|
||||
Vendored
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Launch Apex Replay Debugger",
|
||||
"type": "apex-replay",
|
||||
"request": "launch",
|
||||
"logFile": "${command:AskForLogFileName}",
|
||||
"stopOnEntry": true,
|
||||
"trace": true
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"search.exclude": {
|
||||
"**/node_modules": true,
|
||||
"**/bower_components": true,
|
||||
"**/.sf": true,
|
||||
"**/.sfdx": true
|
||||
},
|
||||
"xml.preferences.showSchemaDocumentationType": "none"
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
# Queue Metadata Flow Actions
|
||||
|
||||
This project contains only the four Apex actions needed by the existing queue-creation Flow:
|
||||
|
||||
- `CreateOriginPicklistItem`
|
||||
- `CreateEmailToCase`
|
||||
- `CreateCaseAutoResponseRule`
|
||||
- `CreateListView`
|
||||
|
||||
`MetadataApiClient` and `MetadataXml` are shared implementation helpers, not additional Flow actions.
|
||||
|
||||
## Required Named Credential
|
||||
|
||||
Create a Named Credential named `Salesforce_Metadata` with:
|
||||
|
||||
- URL: your org's My Domain URL
|
||||
- Authentication: OAuth 2.0 to the same Salesforce org
|
||||
- Generate Authorization Header: enabled
|
||||
- Principal permissions: API Enabled and Customize Application
|
||||
|
||||
The actions call Metadata API v65.0 through this credential.
|
||||
|
||||
## Flow action contracts
|
||||
|
||||
### Create Origin Picklist Item
|
||||
|
||||
Input: `picklistItemName`
|
||||
|
||||
Outputs: `caseOrigin`, `metadataFullName`, `created`
|
||||
|
||||
The action reads `StandardValueSet.CaseOrigin`, returns an existing case-insensitive match, or appends a value. The API name is generated from the label.
|
||||
|
||||
### Create Email-To-Case
|
||||
|
||||
Inputs: `routingName`, `emailAddress`, `caseOwnerId`, `caseOrigin`
|
||||
|
||||
Outputs: `metadataFullName`, `routingName`, `emailAddress`, `emailToCaseAddress`, `emailRoutingAddressId`, `created`
|
||||
|
||||
The owner ID must be a queue `Group`. Priority is always `Medium`. Email-to-Case must already be enabled in Setup. Salesforce sends a verification message for a new routing address; the generated service address can therefore be blank immediately after creation.
|
||||
|
||||
### Create Case Auto-Response Rule Entry
|
||||
|
||||
Inputs: `autoResponseRuleId` or `autoResponseRuleName`, optional `sortOrder`, `caseOrigin`, `emailTemplateId`
|
||||
|
||||
Outputs: `asyncJobId`, `status`, `metadataFullName`
|
||||
|
||||
This action is asynchronous because Salesforce requires file-based retrieve/deploy for auto-response rules. Salesforce stores the sort order as the rule-entry sequence in the metadata file; the supplied position inserts there, and a blank position appends the entry. It adds these criteria:
|
||||
|
||||
- `Case.Origin` equals the supplied origin
|
||||
- `Case.ContactEmail` is not blank
|
||||
- `Case.Status` equals `Active`
|
||||
- `Case.RecordTypeId` equals `Customer Service`
|
||||
|
||||
The sender is `Columbus McKinnon Support <cmcoautoreply@columbusmckinnon.com>`. The email template must be a Classic Email Template in a shared folder. The returned ID identifies the first coordinator job; the retrieve/deploy continuation jobs are visible in Setup's Apex Jobs page.
|
||||
|
||||
### Create List View
|
||||
|
||||
Inputs: `listViewLabel`, `objectApiName`, `groupId`, `displayColumns`
|
||||
|
||||
Outputs: `listViewApiName`, `metadataFullName`, `created`
|
||||
|
||||
`displayColumns` is comma-separated and must use Metadata API list-view column names (for example, `CASE.CASE_NUMBER,CASE.SUBJECT,CASE.STATUS`). The action creates or replaces a queue-scoped view and excludes `Resolved`, `Cancelled`, and `Closed` statuses. The queue Group ID is resolved to its developer name for Metadata API use.
|
||||
|
||||
## Transaction note
|
||||
|
||||
Place each callout action after a transaction boundary if earlier Flow elements create records. In particular, commit the queue and email template before invoking the Email-to-Case or auto-response actions.
|
||||
|
||||
## Deploy
|
||||
|
||||
```bash
|
||||
sf project deploy start --manifest manifest/package.xml --target-org YOUR_ORG
|
||||
sf apex run test --class-names QueueMetadataActionsTest --result-format human --target-org YOUR_ORG
|
||||
```
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"orgName": "zachsaunders company",
|
||||
"edition": "Developer",
|
||||
"features": ["EnableSetPasswordInApi"],
|
||||
"settings": {
|
||||
"lightningExperienceSettings": {
|
||||
"enableS1DesktopEnabled": true
|
||||
},
|
||||
"mobileSettings": {
|
||||
"enableS1EncryptedStoragePref2": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
const { defineConfig } = require('eslint/config');
|
||||
const eslintJs = require('@eslint/js');
|
||||
const jestPlugin = require('eslint-plugin-jest');
|
||||
const auraConfig = require('@salesforce/eslint-plugin-aura');
|
||||
const lwcConfig = require('@salesforce/eslint-config-lwc/recommended');
|
||||
const globals = require('globals');
|
||||
|
||||
module.exports = defineConfig([
|
||||
// Aura configuration
|
||||
{
|
||||
files: ['**/aura/**/*.js'],
|
||||
extends: [
|
||||
...auraConfig.configs.recommended,
|
||||
...auraConfig.configs.locker
|
||||
]
|
||||
},
|
||||
|
||||
// LWC configuration
|
||||
{
|
||||
files: ['**/lwc/**/*.js'],
|
||||
extends: [lwcConfig]
|
||||
},
|
||||
|
||||
// LWC configuration with override for LWC test files
|
||||
{
|
||||
files: ['**/lwc/**/*.test.js'],
|
||||
extends: [lwcConfig],
|
||||
rules: {
|
||||
'@lwc/lwc/no-unexpected-wire-adapter-usages': 'off'
|
||||
},
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Jest mocks configuration
|
||||
{
|
||||
files: ['**/jest-mocks/**/*.js'],
|
||||
languageOptions: {
|
||||
sourceType: 'module',
|
||||
ecmaVersion: 'latest',
|
||||
globals: {
|
||||
...globals.node,
|
||||
...globals.es2021,
|
||||
...jestPlugin.environments.globals.globals
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
eslintJs
|
||||
},
|
||||
extends: ['eslintJs/recommended']
|
||||
}
|
||||
]);
|
||||
@@ -0,0 +1,374 @@
|
||||
public inherited sharing class CreateCaseAutoResponseRule {
|
||||
private static final String SENDER_NAME = 'Columbus McKinnon Support';
|
||||
private static final String SENDER_EMAIL = 'cmcoautoreply@columbusmckinnon.com';
|
||||
private static final Integer MAX_POLLS = 20;
|
||||
|
||||
public class Request {
|
||||
@InvocableVariable(label='Auto Response Rule Id')
|
||||
public Id autoResponseRuleId;
|
||||
|
||||
@InvocableVariable(label='Auto Response Rule Name')
|
||||
public String autoResponseRuleName;
|
||||
|
||||
@InvocableVariable(label='Sort Order')
|
||||
public Integer sortOrder;
|
||||
|
||||
@InvocableVariable(required=true label='Case Origin')
|
||||
public String caseOrigin;
|
||||
|
||||
@InvocableVariable(required=true label='Email Template Id')
|
||||
public Id emailTemplateId;
|
||||
}
|
||||
|
||||
public class Result {
|
||||
@InvocableVariable(label='Async Apex Job Id')
|
||||
public Id asyncJobId;
|
||||
|
||||
@InvocableVariable(label='Status')
|
||||
public String status;
|
||||
|
||||
@InvocableVariable(label='Metadata Full Name')
|
||||
public String metadataFullName;
|
||||
}
|
||||
|
||||
public class Configuration {
|
||||
public Id autoResponseRuleId;
|
||||
public String autoResponseRuleName;
|
||||
public Integer sortOrder;
|
||||
public String caseOrigin;
|
||||
public String templateFullName;
|
||||
}
|
||||
|
||||
@InvocableMethod(
|
||||
label='Create Case Auto-Response Rule Entry'
|
||||
description='Queues a file-based Metadata API deployment that appends an entry to a Case auto-response rule.'
|
||||
category='Queue Configuration'
|
||||
callout=true
|
||||
)
|
||||
public static List<Result> createRuleEntry(List<Request> requests) {
|
||||
MetadataXml.require(
|
||||
requests != null && requests.size() == 1,
|
||||
'Create Case Auto-Response Rule Entry accepts exactly one request per invocation.'
|
||||
);
|
||||
Request input = requests[0];
|
||||
input.autoResponseRuleName = input.autoResponseRuleName == null
|
||||
? null
|
||||
: input.autoResponseRuleName.trim();
|
||||
input.caseOrigin = input.caseOrigin == null
|
||||
? null
|
||||
: input.caseOrigin.trim();
|
||||
MetadataXml.require(
|
||||
input.autoResponseRuleId != null ||
|
||||
!String.isBlank(input.autoResponseRuleName),
|
||||
'Auto Response Rule Id or Auto Response Rule Name is required.'
|
||||
);
|
||||
MetadataXml.require(
|
||||
!String.isBlank(input.caseOrigin),
|
||||
'Case Origin is required.'
|
||||
);
|
||||
MetadataXml.require(
|
||||
input.emailTemplateId != null,
|
||||
'Email Template Id is required.'
|
||||
);
|
||||
MetadataXml.require(
|
||||
input.sortOrder == null || input.sortOrder > 0,
|
||||
'Sort Order must be greater than zero.'
|
||||
);
|
||||
|
||||
List<EmailTemplate> templates = [
|
||||
SELECT Id, DeveloperName, FolderId
|
||||
FROM EmailTemplate
|
||||
WHERE Id = :input.emailTemplateId
|
||||
WITH USER_MODE
|
||||
LIMIT 1
|
||||
];
|
||||
MetadataXml.require(
|
||||
!templates.isEmpty(),
|
||||
'Email Template Id was not found.'
|
||||
);
|
||||
List<Folder> folders = [
|
||||
SELECT Id, DeveloperName
|
||||
FROM Folder
|
||||
WHERE Id = :templates[0].FolderId AND Type = 'Email'
|
||||
WITH USER_MODE
|
||||
LIMIT 1
|
||||
];
|
||||
MetadataXml.require(
|
||||
!folders.isEmpty(),
|
||||
'The email template must be stored in a shared Classic Email Template folder.'
|
||||
);
|
||||
|
||||
Configuration configuration = new Configuration();
|
||||
configuration.autoResponseRuleId = input.autoResponseRuleId;
|
||||
configuration.autoResponseRuleName = String.isBlank(
|
||||
input.autoResponseRuleName
|
||||
)
|
||||
? MetadataApiClient.toolingAutoResponseRuleName(input.autoResponseRuleId)
|
||||
: input.autoResponseRuleName;
|
||||
configuration.sortOrder = input.sortOrder;
|
||||
configuration.caseOrigin = input.caseOrigin;
|
||||
configuration.templateFullName =
|
||||
folders[0].DeveloperName +
|
||||
'/' +
|
||||
templates[0].DeveloperName;
|
||||
|
||||
Id jobId = System.enqueueJob(new StartRetrieveJob(configuration));
|
||||
Result result = new Result();
|
||||
result.asyncJobId = jobId;
|
||||
result.status = 'Queued';
|
||||
result.metadataFullName = 'Case.' + configuration.autoResponseRuleName;
|
||||
return new List<Result>{ result };
|
||||
}
|
||||
|
||||
private class StartRetrieveJob implements Queueable, Database.AllowsCallouts {
|
||||
private Configuration configuration;
|
||||
|
||||
private StartRetrieveJob(Configuration configuration) {
|
||||
this.configuration = configuration;
|
||||
}
|
||||
|
||||
public void execute(QueueableContext context) {
|
||||
String retrieveId = MetadataApiClient.startRetrieve(
|
||||
'Case.' + configuration.autoResponseRuleName,
|
||||
'AutoResponseRule'
|
||||
);
|
||||
System.enqueueJob(new RetrievePollJob(configuration, retrieveId, 0), 1);
|
||||
}
|
||||
}
|
||||
|
||||
private class RetrievePollJob implements Queueable, Database.AllowsCallouts {
|
||||
private Configuration configuration;
|
||||
private String retrieveId;
|
||||
private Integer pollCount;
|
||||
|
||||
private RetrievePollJob(
|
||||
Configuration configuration,
|
||||
String retrieveId,
|
||||
Integer pollCount
|
||||
) {
|
||||
this.configuration = configuration;
|
||||
this.retrieveId = retrieveId;
|
||||
this.pollCount = pollCount;
|
||||
}
|
||||
|
||||
public void execute(QueueableContext context) {
|
||||
MetadataApiClient.RetrieveResult retrieveResult = MetadataApiClient.checkRetrieve(
|
||||
retrieveId
|
||||
);
|
||||
if (!retrieveResult.done) {
|
||||
if (pollCount >= MAX_POLLS) {
|
||||
throw new MetadataApiClient.MetadataApiException(
|
||||
'Timed out waiting for the auto-response rule metadata retrieve.'
|
||||
);
|
||||
}
|
||||
System.enqueueJob(
|
||||
new RetrievePollJob(configuration, retrieveId, pollCount + 1),
|
||||
1
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
retrieveResult.status != 'Succeeded' ||
|
||||
retrieveResult.zipFile == null
|
||||
) {
|
||||
throw new MetadataApiClient.MetadataApiException(
|
||||
'Auto-response rule retrieve failed: ' +
|
||||
(String.isBlank(retrieveResult.errorMessage)
|
||||
? retrieveResult.status
|
||||
: retrieveResult.errorMessage)
|
||||
);
|
||||
}
|
||||
|
||||
Blob deploymentZip = buildDeploymentZip(
|
||||
retrieveResult.zipFile,
|
||||
configuration
|
||||
);
|
||||
String deployId = MetadataApiClient.startDeploy(deploymentZip);
|
||||
System.enqueueJob(new DeployPollJob(deployId, 0), 1);
|
||||
}
|
||||
}
|
||||
|
||||
private class DeployPollJob implements Queueable, Database.AllowsCallouts {
|
||||
private String deployId;
|
||||
private Integer pollCount;
|
||||
|
||||
private DeployPollJob(String deployId, Integer pollCount) {
|
||||
this.deployId = deployId;
|
||||
this.pollCount = pollCount;
|
||||
}
|
||||
|
||||
public void execute(QueueableContext context) {
|
||||
MetadataApiClient.DeployResult deployResult = MetadataApiClient.checkDeploy(
|
||||
deployId
|
||||
);
|
||||
if (!deployResult.done) {
|
||||
if (pollCount >= MAX_POLLS) {
|
||||
throw new MetadataApiClient.MetadataApiException(
|
||||
'Timed out waiting for the auto-response rule metadata deployment.'
|
||||
);
|
||||
}
|
||||
System.enqueueJob(new DeployPollJob(deployId, pollCount + 1), 1);
|
||||
return;
|
||||
}
|
||||
if (!deployResult.success) {
|
||||
throw new MetadataApiClient.MetadataApiException(
|
||||
'Auto-response rule deployment failed: ' +
|
||||
(String.isBlank(deployResult.errorMessage)
|
||||
? deployResult.status
|
||||
: deployResult.errorMessage)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@TestVisible
|
||||
private static Blob buildDeploymentZip(
|
||||
Blob retrievedZip,
|
||||
Configuration configuration
|
||||
) {
|
||||
Compression.ZipReader reader = new Compression.ZipReader(retrievedZip);
|
||||
Compression.ZipEntry ruleEntry;
|
||||
for (String entryName : reader.getEntriesMap().keySet()) {
|
||||
if (entryName.endsWith('Case.autoResponseRules')) {
|
||||
ruleEntry = reader.getEntriesMap().get(entryName);
|
||||
break;
|
||||
}
|
||||
}
|
||||
MetadataXml.require(
|
||||
ruleEntry != null,
|
||||
'The retrieved Case auto-response rule file is missing.'
|
||||
);
|
||||
String sourceXml = reader.extractEntry(ruleEntry).toString();
|
||||
String updatedXml = appendRuleEntry(sourceXml, configuration);
|
||||
|
||||
String packageXml =
|
||||
'<?xml version="1.0" encoding="UTF-8"?>' +
|
||||
'<Package xmlns="http://soap.sforce.com/2006/04/metadata">' +
|
||||
'<types><members>Case.' +
|
||||
MetadataXml.escape(configuration.autoResponseRuleName) +
|
||||
'</members><name>AutoResponseRule</name></types>' +
|
||||
'<version>' +
|
||||
MetadataApiClient.API_VERSION +
|
||||
'</version></Package>';
|
||||
Compression.ZipWriter writer = new Compression.ZipWriter();
|
||||
writer.addEntry('package.xml', Blob.valueOf(packageXml));
|
||||
writer.addEntry(
|
||||
'autoResponseRules/Case.autoResponseRules',
|
||||
Blob.valueOf(updatedXml)
|
||||
);
|
||||
return writer.getArchive();
|
||||
}
|
||||
|
||||
@TestVisible
|
||||
private static String appendRuleEntry(
|
||||
String sourceXml,
|
||||
Configuration configuration
|
||||
) {
|
||||
Dom.Document document = new Dom.Document();
|
||||
document.load(sourceXml);
|
||||
Dom.XmlNode root = document.getRootElement();
|
||||
Dom.XmlNode targetRule;
|
||||
for (Dom.XmlNode rule : MetadataXml.children(root, 'autoResponseRule')) {
|
||||
if (
|
||||
configuration.autoResponseRuleName == MetadataXml.text(rule, 'fullName')
|
||||
) {
|
||||
targetRule = rule;
|
||||
break;
|
||||
}
|
||||
}
|
||||
MetadataXml.require(
|
||||
targetRule != null,
|
||||
'Auto-response rule "' +
|
||||
configuration.autoResponseRuleName +
|
||||
'" was not found.'
|
||||
);
|
||||
|
||||
List<Dom.XmlNode> existingEntries = MetadataXml.children(
|
||||
targetRule,
|
||||
'ruleEntry'
|
||||
);
|
||||
for (Dom.XmlNode existingEntry : existingEntries) {
|
||||
if (
|
||||
configuration.caseOrigin ==
|
||||
criterionValue(existingEntry, 'Case.Origin') &&
|
||||
configuration.templateFullName ==
|
||||
MetadataXml.text(existingEntry, 'template')
|
||||
) {
|
||||
return sourceXml;
|
||||
}
|
||||
}
|
||||
Integer insertionPosition = configuration.sortOrder == null
|
||||
? existingEntries.size() + 1
|
||||
: Math.min(configuration.sortOrder, existingEntries.size() + 1);
|
||||
|
||||
String newEntry =
|
||||
'<ruleEntry><booleanFilter>1 AND 2 AND 3 AND 4</booleanFilter>' +
|
||||
criterion('Case.Origin', 'equals', configuration.caseOrigin) +
|
||||
criterion('Case.ContactEmail', 'notEqual', '') +
|
||||
criterion('Case.Status', 'equals', 'Active') +
|
||||
criterion('Case.RecordTypeId', 'equals', 'Customer Service') +
|
||||
'<senderEmail>' +
|
||||
MetadataXml.escape(SENDER_EMAIL) +
|
||||
'</senderEmail><senderName>' +
|
||||
MetadataXml.escape(SENDER_NAME) +
|
||||
'</senderName><template>' +
|
||||
MetadataXml.escape(configuration.templateFullName) +
|
||||
'</template></ruleEntry>';
|
||||
|
||||
String targetRuleXml = '<autoResponseRule>';
|
||||
Integer currentEntryPosition = 0;
|
||||
Boolean inserted = false;
|
||||
for (Dom.XmlNode child : targetRule.getChildElements()) {
|
||||
if (child.getName() == 'ruleEntry') {
|
||||
currentEntryPosition++;
|
||||
if (!inserted && currentEntryPosition == insertionPosition) {
|
||||
targetRuleXml += newEntry;
|
||||
inserted = true;
|
||||
}
|
||||
}
|
||||
targetRuleXml += MetadataXml.serialize(child);
|
||||
}
|
||||
if (!inserted) {
|
||||
targetRuleXml += newEntry;
|
||||
}
|
||||
targetRuleXml += '</autoResponseRule>';
|
||||
|
||||
String output =
|
||||
'<?xml version="1.0" encoding="UTF-8"?>' +
|
||||
'<AutoResponseRules xmlns="http://soap.sforce.com/2006/04/metadata">';
|
||||
for (Dom.XmlNode rule : root.getChildElements()) {
|
||||
output += rule == targetRule
|
||||
? targetRuleXml
|
||||
: MetadataXml.serialize(rule);
|
||||
}
|
||||
return output + '</AutoResponseRules>';
|
||||
}
|
||||
|
||||
private static String criterion(
|
||||
String field,
|
||||
String operation,
|
||||
String value
|
||||
) {
|
||||
return '<criteriaItems><field>' +
|
||||
MetadataXml.escape(field) +
|
||||
'</field><operation>' +
|
||||
MetadataXml.escape(operation) +
|
||||
'</operation><value>' +
|
||||
MetadataXml.escape(value) +
|
||||
'</value></criteriaItems>';
|
||||
}
|
||||
|
||||
private static String criterionValue(Dom.XmlNode ruleEntry, String field) {
|
||||
for (
|
||||
Dom.XmlNode criterionNode : MetadataXml.children(
|
||||
ruleEntry,
|
||||
'criteriaItems'
|
||||
)
|
||||
) {
|
||||
if (field == MetadataXml.text(criterionNode, 'field')) {
|
||||
return MetadataXml.text(criterionNode, 'value');
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
|
||||
<apiVersion>65.0</apiVersion>
|
||||
<status>Active</status>
|
||||
</ApexClass>
|
||||
@@ -0,0 +1,243 @@
|
||||
public inherited sharing class CreateEmailToCase {
|
||||
public class Request {
|
||||
@InvocableVariable(required=true label='Routing Name')
|
||||
public String routingName;
|
||||
|
||||
@InvocableVariable(required=true label='Email Address')
|
||||
public String emailAddress;
|
||||
|
||||
@InvocableVariable(required=true label='Case Owner Queue Id')
|
||||
public Id caseOwnerId;
|
||||
|
||||
@InvocableVariable(required=true label='Case Origin')
|
||||
public String caseOrigin;
|
||||
}
|
||||
|
||||
public class Result {
|
||||
@InvocableVariable(label='Metadata Full Name')
|
||||
public String metadataFullName;
|
||||
|
||||
@InvocableVariable(label='Routing Name')
|
||||
public String routingName;
|
||||
|
||||
@InvocableVariable(label='Email Address')
|
||||
public String emailAddress;
|
||||
|
||||
@InvocableVariable(label='Email-to-Case Address')
|
||||
public String emailToCaseAddress;
|
||||
|
||||
@InvocableVariable(label='Email Routing Address Id')
|
||||
public Id emailRoutingAddressId;
|
||||
|
||||
@InvocableVariable(label='Created')
|
||||
public Boolean created;
|
||||
}
|
||||
|
||||
@InvocableMethod(
|
||||
label='Create Email-To-Case'
|
||||
description='Adds an on-demand Email-to-Case routing address to Case Settings.'
|
||||
category='Queue Configuration'
|
||||
callout=true
|
||||
)
|
||||
public static List<Result> createRoutingAddress(List<Request> requests) {
|
||||
MetadataXml.require(
|
||||
requests != null && requests.size() == 1,
|
||||
'Create Email-To-Case accepts exactly one request per invocation.'
|
||||
);
|
||||
Request input = requests[0];
|
||||
input.routingName = input.routingName == null
|
||||
? null
|
||||
: input.routingName.trim();
|
||||
input.emailAddress = input.emailAddress == null
|
||||
? null
|
||||
: input.emailAddress.trim();
|
||||
input.caseOrigin = input.caseOrigin == null
|
||||
? null
|
||||
: input.caseOrigin.trim();
|
||||
MetadataXml.require(
|
||||
!String.isBlank(input.routingName),
|
||||
'Routing Name is required.'
|
||||
);
|
||||
MetadataXml.require(
|
||||
!String.isBlank(input.emailAddress),
|
||||
'Email Address is required.'
|
||||
);
|
||||
MetadataXml.require(
|
||||
input.caseOwnerId != null,
|
||||
'Case Owner Queue Id is required.'
|
||||
);
|
||||
MetadataXml.require(
|
||||
!String.isBlank(input.caseOrigin),
|
||||
'Case Origin is required.'
|
||||
);
|
||||
|
||||
List<Group> queues = [
|
||||
SELECT Id, DeveloperName
|
||||
FROM Group
|
||||
WHERE Id = :input.caseOwnerId AND Type = 'Queue'
|
||||
WITH USER_MODE
|
||||
LIMIT 1
|
||||
];
|
||||
MetadataXml.require(
|
||||
!queues.isEmpty(),
|
||||
'Case Owner Id must identify a queue.'
|
||||
);
|
||||
|
||||
Dom.XmlNode caseSettings = MetadataApiClient.readMetadata(
|
||||
'CaseSettings',
|
||||
'Case'
|
||||
);
|
||||
MetadataXml.require(
|
||||
caseSettings != null,
|
||||
'Case Settings metadata was not found.'
|
||||
);
|
||||
Dom.XmlNode emailToCase = MetadataXml.child(caseSettings, 'emailToCase');
|
||||
MetadataXml.require(
|
||||
emailToCase != null,
|
||||
'Email-to-Case is not configured. Enable Email-to-Case in Setup before creating a routing address.'
|
||||
);
|
||||
|
||||
for (
|
||||
Dom.XmlNode route : MetadataXml.children(emailToCase, 'routingAddresses')
|
||||
) {
|
||||
String existingName = MetadataXml.text(route, 'routingName');
|
||||
String existingAddress = MetadataXml.text(route, 'emailAddress');
|
||||
Boolean sameName = input.routingName.equalsIgnoreCase(existingName);
|
||||
Boolean sameAddress = input.emailAddress.equalsIgnoreCase(
|
||||
existingAddress
|
||||
);
|
||||
if (sameName || sameAddress) {
|
||||
MetadataXml.require(
|
||||
sameName && sameAddress,
|
||||
'An Email-to-Case routing address already uses this routing name or email address with different values.'
|
||||
);
|
||||
return new List<Result>{ buildResult(input, false) };
|
||||
}
|
||||
}
|
||||
|
||||
String routeXml =
|
||||
'<routingAddresses>' +
|
||||
'<addressType>EmailToCase</addressType>' +
|
||||
'<caseOrigin>' +
|
||||
MetadataXml.escape(input.caseOrigin) +
|
||||
'</caseOrigin>' +
|
||||
'<caseOwner>' +
|
||||
MetadataXml.escape(queues[0].DeveloperName) +
|
||||
'</caseOwner>' +
|
||||
'<caseOwnerType>Queue</caseOwnerType>' +
|
||||
'<casePriority>Medium</casePriority>' +
|
||||
'<emailAddress>' +
|
||||
MetadataXml.escape(input.emailAddress) +
|
||||
'</emailAddress>' +
|
||||
'<routingName>' +
|
||||
MetadataXml.escape(input.routingName) +
|
||||
'</routingName>' +
|
||||
'<saveEmailHeaders>false</saveEmailHeaders>' +
|
||||
'</routingAddresses>';
|
||||
String emailToCaseXml = '<emailToCase>';
|
||||
Boolean inserted = false;
|
||||
for (Dom.XmlNode child : emailToCase.getChildElements()) {
|
||||
if (!inserted && child.getName().compareTo('routingAddresses') > 0) {
|
||||
emailToCaseXml += routeXml;
|
||||
inserted = true;
|
||||
}
|
||||
emailToCaseXml += MetadataXml.serialize(child);
|
||||
}
|
||||
if (!inserted) {
|
||||
emailToCaseXml += routeXml;
|
||||
}
|
||||
emailToCaseXml += '</emailToCase>';
|
||||
|
||||
MetadataApiClient.updateMetadata(
|
||||
'CaseSettings',
|
||||
MetadataXml.replaceOrInsertChild(
|
||||
caseSettings,
|
||||
'emailToCase',
|
||||
emailToCaseXml
|
||||
)
|
||||
);
|
||||
return new List<Result>{ buildResult(input, true) };
|
||||
}
|
||||
|
||||
private static Result buildResult(Request input, Boolean created) {
|
||||
Result result = new Result();
|
||||
result.metadataFullName = 'Case';
|
||||
result.routingName = input.routingName;
|
||||
result.emailAddress = input.emailAddress;
|
||||
result.created = created;
|
||||
|
||||
Schema.SObjectType routingType = Schema.getGlobalDescribe()
|
||||
.get('EmailRoutingAddress');
|
||||
if (routingType == null) {
|
||||
return result;
|
||||
}
|
||||
Map<String, Schema.SObjectField> fields = routingType.getDescribe()
|
||||
.fields.getMap();
|
||||
String addressField = fields.containsKey('EmailAddress')
|
||||
? 'EmailAddress'
|
||||
: (fields.containsKey('Address') ? 'Address' : null);
|
||||
if (addressField == null) {
|
||||
return result;
|
||||
}
|
||||
|
||||
List<String> selectFields = new List<String>{ 'Id', addressField };
|
||||
if (fields.containsKey('RoutingAddress')) {
|
||||
selectFields.add('RoutingAddress');
|
||||
}
|
||||
if (fields.containsKey('EmailServicesAddressId')) {
|
||||
selectFields.add('EmailServicesAddressId');
|
||||
}
|
||||
String query =
|
||||
'SELECT ' +
|
||||
String.join(selectFields, ',') +
|
||||
' FROM EmailRoutingAddress WHERE ' +
|
||||
addressField +
|
||||
' = :emailAddress LIMIT 1';
|
||||
List<SObject> matches = Database.queryWithBinds(
|
||||
query,
|
||||
new Map<String, Object>{ 'emailAddress' => input.emailAddress },
|
||||
AccessLevel.USER_MODE
|
||||
);
|
||||
if (matches.isEmpty()) {
|
||||
return result;
|
||||
}
|
||||
|
||||
result.emailRoutingAddressId = (Id) matches[0].get('Id');
|
||||
if (fields.containsKey('RoutingAddress')) {
|
||||
result.emailToCaseAddress = (String) matches[0].get('RoutingAddress');
|
||||
} else if (fields.containsKey('EmailServicesAddressId')) {
|
||||
result.emailToCaseAddress = emailServicesAddress(
|
||||
(Id) matches[0].get('EmailServicesAddressId')
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static String emailServicesAddress(Id serviceAddressId) {
|
||||
if (serviceAddressId == null) {
|
||||
return null;
|
||||
}
|
||||
Schema.SObjectType serviceType = Schema.getGlobalDescribe()
|
||||
.get('EmailServicesAddress');
|
||||
if (serviceType == null) {
|
||||
return null;
|
||||
}
|
||||
Map<String, Schema.SObjectField> fields = serviceType.getDescribe()
|
||||
.fields.getMap();
|
||||
if (
|
||||
!fields.containsKey('LocalPart') || !fields.containsKey('EmailDomainName')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
List<SObject> rows = Database.queryWithBinds(
|
||||
'SELECT LocalPart, EmailDomainName FROM EmailServicesAddress WHERE Id = :serviceAddressId LIMIT 1',
|
||||
new Map<String, Object>{ 'serviceAddressId' => serviceAddressId },
|
||||
AccessLevel.USER_MODE
|
||||
);
|
||||
return rows.isEmpty()
|
||||
? null
|
||||
: String.valueOf(rows[0].get('LocalPart')) +
|
||||
'@' +
|
||||
String.valueOf(rows[0].get('EmailDomainName'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
|
||||
<apiVersion>65.0</apiVersion>
|
||||
<status>Active</status>
|
||||
</ApexClass>
|
||||
@@ -0,0 +1,120 @@
|
||||
public inherited sharing class CreateListView {
|
||||
public class Request {
|
||||
@InvocableVariable(required=true label='List View Label')
|
||||
public String listViewLabel;
|
||||
|
||||
@InvocableVariable(required=true label='Object API Name')
|
||||
public String objectApiName;
|
||||
|
||||
@InvocableVariable(required=true label='Queue Group Id')
|
||||
public Id groupId;
|
||||
|
||||
@InvocableVariable(required=true label='Display Columns')
|
||||
public String displayColumns;
|
||||
}
|
||||
|
||||
public class Result {
|
||||
@InvocableVariable(label='List View API Name')
|
||||
public String listViewApiName;
|
||||
|
||||
@InvocableVariable(label='Metadata Full Name')
|
||||
public String metadataFullName;
|
||||
|
||||
@InvocableVariable(label='Created')
|
||||
public Boolean created;
|
||||
}
|
||||
|
||||
@InvocableMethod(
|
||||
label='Create List View'
|
||||
description='Creates or updates a queue-scoped list view that excludes resolved, cancelled, and closed records.'
|
||||
category='Queue Configuration'
|
||||
callout=true
|
||||
)
|
||||
public static List<Result> createView(List<Request> requests) {
|
||||
MetadataXml.require(
|
||||
requests != null && requests.size() == 1,
|
||||
'Create List View accepts exactly one request per invocation.'
|
||||
);
|
||||
Request input = requests[0];
|
||||
input.listViewLabel = input.listViewLabel == null
|
||||
? null
|
||||
: input.listViewLabel.trim();
|
||||
input.objectApiName = input.objectApiName == null
|
||||
? null
|
||||
: input.objectApiName.trim();
|
||||
MetadataXml.require(
|
||||
!String.isBlank(input.listViewLabel),
|
||||
'List View Label is required.'
|
||||
);
|
||||
MetadataXml.require(
|
||||
!String.isBlank(input.objectApiName),
|
||||
'Object API Name is required.'
|
||||
);
|
||||
MetadataXml.require(input.groupId != null, 'Queue Group Id is required.');
|
||||
MetadataXml.require(
|
||||
!String.isBlank(input.displayColumns),
|
||||
'Display Columns is required.'
|
||||
);
|
||||
MetadataXml.require(
|
||||
Schema.getGlobalDescribe().containsKey(input.objectApiName),
|
||||
'Object API Name does not identify an object in this org.'
|
||||
);
|
||||
|
||||
List<Group> queues = [
|
||||
SELECT Id, DeveloperName
|
||||
FROM Group
|
||||
WHERE Id = :input.groupId AND Type = 'Queue'
|
||||
WITH USER_MODE
|
||||
LIMIT 1
|
||||
];
|
||||
MetadataXml.require(
|
||||
!queues.isEmpty(),
|
||||
'Queue Group Id must identify a queue.'
|
||||
);
|
||||
|
||||
List<String> columns = new List<String>();
|
||||
for (String column : input.displayColumns.split(',')) {
|
||||
if (!String.isBlank(column)) {
|
||||
columns.add(column.trim());
|
||||
}
|
||||
}
|
||||
MetadataXml.require(
|
||||
!columns.isEmpty(),
|
||||
'Display Columns must contain at least one field.'
|
||||
);
|
||||
|
||||
String apiName = MetadataXml.developerName(input.listViewLabel);
|
||||
String fullName = input.objectApiName + '.' + apiName;
|
||||
Boolean existed =
|
||||
MetadataApiClient.readMetadata('ListView', fullName) != null;
|
||||
String metadataXml =
|
||||
'<fullName>' +
|
||||
MetadataXml.escape(fullName) +
|
||||
'</fullName>';
|
||||
for (String column : columns) {
|
||||
metadataXml += '<columns>' + MetadataXml.escape(column) + '</columns>';
|
||||
}
|
||||
metadataXml +=
|
||||
'<filterScope>Queue</filterScope>' +
|
||||
'<filters><field>' +
|
||||
MetadataXml.escape(statusField(input.objectApiName)) +
|
||||
'</field><operation>notEqual</operation>' +
|
||||
'<value>Resolved,Cancelled,Closed</value></filters>' +
|
||||
'<label>' +
|
||||
MetadataXml.escape(input.listViewLabel) +
|
||||
'</label><queue>' +
|
||||
MetadataXml.escape(queues[0].DeveloperName) +
|
||||
'</queue>';
|
||||
|
||||
MetadataApiClient.upsertMetadata('ListView', metadataXml);
|
||||
Result result = new Result();
|
||||
result.listViewApiName = apiName;
|
||||
result.metadataFullName = fullName;
|
||||
result.created = !existed;
|
||||
return new List<Result>{ result };
|
||||
}
|
||||
|
||||
private static String statusField(String objectApiName) {
|
||||
return objectApiName.equalsIgnoreCase('Case') ? 'CASE.STATUS' : 'Status';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
|
||||
<apiVersion>65.0</apiVersion>
|
||||
<status>Active</status>
|
||||
</ApexClass>
|
||||
@@ -0,0 +1,92 @@
|
||||
public inherited sharing class CreateOriginPicklistItem {
|
||||
public class Request {
|
||||
@InvocableVariable(required=true label='Picklist Item Name')
|
||||
public String picklistItemName;
|
||||
}
|
||||
|
||||
public class Result {
|
||||
@InvocableVariable(label='Case Origin')
|
||||
public String caseOrigin;
|
||||
|
||||
@InvocableVariable(label='Created')
|
||||
public Boolean created;
|
||||
|
||||
@InvocableVariable(label='Metadata Full Name')
|
||||
public String metadataFullName;
|
||||
}
|
||||
|
||||
@InvocableMethod(
|
||||
label='Create Origin Picklist Item'
|
||||
description='Adds a value to the Case Origin standard value set when it does not already exist.'
|
||||
category='Queue Configuration'
|
||||
callout=true
|
||||
)
|
||||
public static List<Result> createOrigin(List<Request> requests) {
|
||||
MetadataXml.require(
|
||||
requests != null && requests.size() == 1,
|
||||
'Create Origin Picklist Item accepts exactly one request per invocation.'
|
||||
);
|
||||
String label = requests[0].picklistItemName == null
|
||||
? null
|
||||
: requests[0].picklistItemName.trim();
|
||||
MetadataXml.require(
|
||||
!String.isBlank(label),
|
||||
'Picklist Item Name is required.'
|
||||
);
|
||||
|
||||
String developerName = MetadataXml.developerName(label);
|
||||
Dom.XmlNode valueSet = MetadataApiClient.readMetadata(
|
||||
'StandardValueSet',
|
||||
'CaseOrigin'
|
||||
);
|
||||
MetadataXml.require(
|
||||
valueSet != null,
|
||||
'The CaseOrigin standard value set was not found.'
|
||||
);
|
||||
|
||||
for (
|
||||
Dom.XmlNode valueNode : MetadataXml.children(valueSet, 'standardValue')
|
||||
) {
|
||||
String existingName = MetadataXml.text(valueNode, 'fullName');
|
||||
String existingLabel = MetadataXml.text(valueNode, 'label');
|
||||
if (
|
||||
label.equalsIgnoreCase(existingLabel) ||
|
||||
developerName.equalsIgnoreCase(existingName)
|
||||
) {
|
||||
if (
|
||||
developerName.equalsIgnoreCase(existingName) &&
|
||||
!label.equalsIgnoreCase(existingLabel)
|
||||
) {
|
||||
throw new IllegalArgumentException(
|
||||
'Case Origin developer name ' +
|
||||
developerName +
|
||||
' is already used by the label "' +
|
||||
existingLabel +
|
||||
'".'
|
||||
);
|
||||
}
|
||||
return new List<Result>{ result(existingName, false) };
|
||||
}
|
||||
}
|
||||
|
||||
String newValueXml =
|
||||
'<standardValue><fullName>' +
|
||||
MetadataXml.escape(developerName) +
|
||||
'</fullName><default>false</default><label>' +
|
||||
MetadataXml.escape(label) +
|
||||
'</label></standardValue>';
|
||||
MetadataApiClient.updateMetadata(
|
||||
'StandardValueSet',
|
||||
MetadataXml.serializeChildren(valueSet) + newValueXml
|
||||
);
|
||||
return new List<Result>{ result(developerName, true) };
|
||||
}
|
||||
|
||||
private static Result result(String caseOrigin, Boolean created) {
|
||||
Result value = new Result();
|
||||
value.caseOrigin = caseOrigin;
|
||||
value.created = created;
|
||||
value.metadataFullName = 'CaseOrigin';
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
|
||||
<apiVersion>65.0</apiVersion>
|
||||
<status>Active</status>
|
||||
</ApexClass>
|
||||
@@ -0,0 +1,305 @@
|
||||
public inherited sharing class MetadataApiClient {
|
||||
public static final String NAMED_CREDENTIAL = 'Salesforce_Metadata';
|
||||
public static final String API_VERSION = '65.0';
|
||||
|
||||
private static final String METADATA_NS = 'http://soap.sforce.com/2006/04/metadata';
|
||||
private static final String SOAP_NS = 'http://schemas.xmlsoap.org/soap/envelope/';
|
||||
private static final String XSI_NS = 'http://www.w3.org/2001/XMLSchema-instance';
|
||||
|
||||
public class MetadataApiException extends Exception {
|
||||
}
|
||||
|
||||
public class SaveResult {
|
||||
public Boolean created;
|
||||
public String fullName;
|
||||
}
|
||||
|
||||
public class RetrieveResult {
|
||||
public Boolean done;
|
||||
public String id;
|
||||
public String status;
|
||||
public String errorMessage;
|
||||
public Blob zipFile;
|
||||
}
|
||||
|
||||
public class DeployResult {
|
||||
public Boolean done;
|
||||
public Boolean success;
|
||||
public String id;
|
||||
public String status;
|
||||
public String errorMessage;
|
||||
}
|
||||
|
||||
public static Dom.XmlNode readMetadata(String metadataType, String fullName) {
|
||||
String operation =
|
||||
'<met:readMetadata>' +
|
||||
'<met:type>' +
|
||||
MetadataXml.escape(metadataType) +
|
||||
'</met:type>' +
|
||||
'<met:fullNames>' +
|
||||
MetadataXml.escape(fullName) +
|
||||
'</met:fullNames>' +
|
||||
'</met:readMetadata>';
|
||||
|
||||
Dom.XmlNode body = send(operation);
|
||||
Dom.XmlNode records = MetadataXml.findDescendant(body, 'records');
|
||||
if (
|
||||
records == null ||
|
||||
'true'.equalsIgnoreCase(records.getAttributeValue('nil', XSI_NS))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
public static SaveResult updateMetadata(
|
||||
String metadataType,
|
||||
String metadataChildrenXml
|
||||
) {
|
||||
return save('updateMetadata', metadataType, metadataChildrenXml);
|
||||
}
|
||||
|
||||
public static SaveResult upsertMetadata(
|
||||
String metadataType,
|
||||
String metadataChildrenXml
|
||||
) {
|
||||
return save('upsertMetadata', metadataType, metadataChildrenXml);
|
||||
}
|
||||
|
||||
public static String startRetrieve(String member, String metadataType) {
|
||||
Dom.XmlNode body = send(
|
||||
'<met:retrieve><met:retrieveRequest>' +
|
||||
'<met:apiVersion>' +
|
||||
API_VERSION +
|
||||
'</met:apiVersion><met:singlePackage>true</met:singlePackage>' +
|
||||
'<met:unpackaged><met:members>' +
|
||||
MetadataXml.escape(member) +
|
||||
'</met:members><met:name>' +
|
||||
MetadataXml.escape(metadataType) +
|
||||
'</met:name></met:unpackaged>' +
|
||||
'</met:retrieveRequest></met:retrieve>'
|
||||
);
|
||||
String id = MetadataXml.text(
|
||||
MetadataXml.findDescendant(body, 'result'),
|
||||
'id'
|
||||
);
|
||||
if (String.isBlank(id)) {
|
||||
throw new MetadataApiException(
|
||||
'The Metadata API did not return a retrieve job ID.'
|
||||
);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
public static RetrieveResult checkRetrieve(String retrieveId) {
|
||||
Dom.XmlNode body = send(
|
||||
'<met:checkRetrieveStatus><met:asyncProcessId>' +
|
||||
MetadataXml.escape(retrieveId) +
|
||||
'</met:asyncProcessId><met:includeZip>true</met:includeZip>' +
|
||||
'</met:checkRetrieveStatus>'
|
||||
);
|
||||
Dom.XmlNode resultNode = MetadataXml.findDescendant(body, 'result');
|
||||
RetrieveResult result = new RetrieveResult();
|
||||
result.done = MetadataXml.booleanValue(resultNode, 'done');
|
||||
result.id = MetadataXml.text(resultNode, 'id');
|
||||
result.status = MetadataXml.text(resultNode, 'status');
|
||||
result.errorMessage = MetadataXml.text(resultNode, 'errorMessage');
|
||||
String zipFile = MetadataXml.text(resultNode, 'zipFile');
|
||||
result.zipFile = String.isBlank(zipFile)
|
||||
? null
|
||||
: EncodingUtil.base64Decode(zipFile);
|
||||
return result;
|
||||
}
|
||||
|
||||
public static String startDeploy(Blob zipFile) {
|
||||
Dom.XmlNode body = send(
|
||||
'<met:deploy><met:ZipFile>' +
|
||||
EncodingUtil.base64Encode(zipFile) +
|
||||
'</met:ZipFile><met:DeployOptions>' +
|
||||
'<met:allowMissingFiles>false</met:allowMissingFiles>' +
|
||||
'<met:autoUpdatePackage>false</met:autoUpdatePackage>' +
|
||||
'<met:checkOnly>false</met:checkOnly>' +
|
||||
'<met:ignoreWarnings>false</met:ignoreWarnings>' +
|
||||
'<met:performRetrieve>false</met:performRetrieve>' +
|
||||
'<met:purgeOnDelete>false</met:purgeOnDelete>' +
|
||||
'<met:rollbackOnError>true</met:rollbackOnError>' +
|
||||
'<met:singlePackage>true</met:singlePackage>' +
|
||||
'<met:testLevel>NoTestRun</met:testLevel>' +
|
||||
'</met:DeployOptions></met:deploy>'
|
||||
);
|
||||
String id = MetadataXml.text(
|
||||
MetadataXml.findDescendant(body, 'result'),
|
||||
'id'
|
||||
);
|
||||
if (String.isBlank(id)) {
|
||||
throw new MetadataApiException(
|
||||
'The Metadata API did not return a deployment job ID.'
|
||||
);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
public static DeployResult checkDeploy(String deployId) {
|
||||
Dom.XmlNode body = send(
|
||||
'<met:checkDeployStatus><met:asyncProcessId>' +
|
||||
MetadataXml.escape(deployId) +
|
||||
'</met:asyncProcessId><met:includeDetails>true</met:includeDetails>' +
|
||||
'</met:checkDeployStatus>'
|
||||
);
|
||||
Dom.XmlNode resultNode = MetadataXml.findDescendant(body, 'result');
|
||||
DeployResult result = new DeployResult();
|
||||
result.done = MetadataXml.booleanValue(resultNode, 'done');
|
||||
result.success = MetadataXml.booleanValue(resultNode, 'success');
|
||||
result.id = MetadataXml.text(resultNode, 'id');
|
||||
result.status = MetadataXml.text(resultNode, 'status');
|
||||
result.errorMessage = MetadataXml.text(resultNode, 'errorMessage');
|
||||
if (String.isBlank(result.errorMessage)) {
|
||||
Dom.XmlNode problem = MetadataXml.findDescendant(resultNode, 'problem');
|
||||
result.errorMessage = problem == null ? null : problem.getText();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public static String toolingAutoResponseRuleName(Id ruleId) {
|
||||
HttpRequest request = new HttpRequest();
|
||||
request.setEndpoint(
|
||||
'callout:' +
|
||||
NAMED_CREDENTIAL +
|
||||
'/services/data/v' +
|
||||
API_VERSION +
|
||||
'/tooling/query/?q=' +
|
||||
EncodingUtil.urlEncode(
|
||||
'SELECT Id, Name, EntityDefinitionId FROM AutoResponseRule WHERE Id = \'' +
|
||||
String.escapeSingleQuotes(String.valueOf(ruleId)) +
|
||||
'\' LIMIT 1',
|
||||
'UTF-8'
|
||||
)
|
||||
);
|
||||
request.setMethod('GET');
|
||||
request.setTimeout(120000);
|
||||
HttpResponse response = new Http().send(request);
|
||||
if (response.getStatusCode() < 200 || response.getStatusCode() >= 300) {
|
||||
throw new MetadataApiException(
|
||||
'Tooling API returned HTTP ' +
|
||||
response.getStatusCode() +
|
||||
': ' +
|
||||
response.getBody()
|
||||
);
|
||||
}
|
||||
Map<String, Object> payload = (Map<String, Object>) JSON.deserializeUntyped(
|
||||
response.getBody()
|
||||
);
|
||||
List<Object> records = (List<Object>) payload.get('records');
|
||||
if (records == null || records.isEmpty()) {
|
||||
throw new MetadataApiException('Auto Response Rule Id was not found.');
|
||||
}
|
||||
return (String) ((Map<String, Object>) records[0]).get('Name');
|
||||
}
|
||||
|
||||
private static SaveResult save(
|
||||
String operationName,
|
||||
String metadataType,
|
||||
String metadataChildrenXml
|
||||
) {
|
||||
String operation =
|
||||
'<met:' +
|
||||
operationName +
|
||||
'><met:metadata xmlns="' +
|
||||
METADATA_NS +
|
||||
'" xsi:type="met:' +
|
||||
MetadataXml.escape(metadataType) +
|
||||
'">' +
|
||||
metadataChildrenXml +
|
||||
'</met:metadata></met:' +
|
||||
operationName +
|
||||
'>';
|
||||
|
||||
Dom.XmlNode body = send(operation);
|
||||
Dom.XmlNode resultNode = MetadataXml.findDescendant(body, 'result');
|
||||
if (resultNode == null) {
|
||||
throw new MetadataApiException(
|
||||
'The Metadata API returned no save result.'
|
||||
);
|
||||
}
|
||||
|
||||
Boolean success = MetadataXml.booleanValue(resultNode, 'success');
|
||||
if (!success) {
|
||||
Dom.XmlNode errors = MetadataXml.child(resultNode, 'errors');
|
||||
String statusCode = errors == null
|
||||
? null
|
||||
: MetadataXml.text(errors, 'statusCode');
|
||||
String message = errors == null
|
||||
? null
|
||||
: MetadataXml.text(errors, 'message');
|
||||
throw new MetadataApiException(
|
||||
'Metadata API save failed' +
|
||||
(String.isBlank(statusCode) ? '' : ' [' + statusCode + ']') +
|
||||
(String.isBlank(message) ? '.' : ': ' + message)
|
||||
);
|
||||
}
|
||||
|
||||
SaveResult result = new SaveResult();
|
||||
result.created = MetadataXml.booleanValue(resultNode, 'created');
|
||||
result.fullName = MetadataXml.text(resultNode, 'fullName');
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Dom.XmlNode send(String operationXml) {
|
||||
HttpRequest request = new HttpRequest();
|
||||
request.setEndpoint(
|
||||
'callout:' + NAMED_CREDENTIAL + '/services/Soap/m/' + API_VERSION
|
||||
);
|
||||
request.setMethod('POST');
|
||||
request.setHeader('Content-Type', 'text/xml; charset=UTF-8');
|
||||
request.setHeader('SOAPAction', '""');
|
||||
request.setTimeout(120000);
|
||||
request.setBody(
|
||||
'<?xml version="1.0" encoding="UTF-8"?>' +
|
||||
'<soapenv:Envelope xmlns:soapenv="' +
|
||||
SOAP_NS +
|
||||
'" xmlns:met="' +
|
||||
METADATA_NS +
|
||||
'" xmlns:xsi="' +
|
||||
XSI_NS +
|
||||
'"><soapenv:Header/><soapenv:Body>' +
|
||||
operationXml +
|
||||
'</soapenv:Body></soapenv:Envelope>'
|
||||
);
|
||||
|
||||
HttpResponse response = new Http().send(request);
|
||||
Dom.Document document = new Dom.Document();
|
||||
try {
|
||||
document.load(response.getBody());
|
||||
} catch (Exception exceptionValue) {
|
||||
throw new MetadataApiException(
|
||||
'Metadata API returned HTTP ' +
|
||||
response.getStatusCode() +
|
||||
' with an unreadable response: ' +
|
||||
response.getBody()
|
||||
);
|
||||
}
|
||||
|
||||
Dom.XmlNode body = MetadataXml.findDescendant(
|
||||
document.getRootElement(),
|
||||
'Body'
|
||||
);
|
||||
Dom.XmlNode fault = MetadataXml.findDescendant(body, 'Fault');
|
||||
if (fault != null) {
|
||||
String faultMessage = MetadataXml.text(fault, 'faultstring');
|
||||
throw new MetadataApiException(
|
||||
String.isBlank(faultMessage)
|
||||
? 'Unknown Metadata API SOAP fault.'
|
||||
: faultMessage
|
||||
);
|
||||
}
|
||||
if (response.getStatusCode() < 200 || response.getStatusCode() >= 300) {
|
||||
throw new MetadataApiException(
|
||||
'Metadata API returned HTTP ' +
|
||||
response.getStatusCode() +
|
||||
': ' +
|
||||
response.getBody()
|
||||
);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
|
||||
<apiVersion>65.0</apiVersion>
|
||||
<status>Active</status>
|
||||
</ApexClass>
|
||||
@@ -0,0 +1,140 @@
|
||||
public inherited sharing class MetadataXml {
|
||||
public static String escape(String value) {
|
||||
if (value == null) {
|
||||
return '';
|
||||
}
|
||||
return value
|
||||
.replace('&', '&')
|
||||
.replace('<', '<')
|
||||
.replace('>', '>')
|
||||
.replace('"', '"')
|
||||
.replace('\'', ''');
|
||||
}
|
||||
|
||||
public static Dom.XmlNode child(Dom.XmlNode parent, String localName) {
|
||||
if (parent == null) {
|
||||
return null;
|
||||
}
|
||||
for (Dom.XmlNode candidate : parent.getChildElements()) {
|
||||
if (candidate.getName() == localName) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static List<Dom.XmlNode> children(
|
||||
Dom.XmlNode parent,
|
||||
String localName
|
||||
) {
|
||||
List<Dom.XmlNode> matches = new List<Dom.XmlNode>();
|
||||
if (parent == null) {
|
||||
return matches;
|
||||
}
|
||||
for (Dom.XmlNode candidate : parent.getChildElements()) {
|
||||
if (candidate.getName() == localName) {
|
||||
matches.add(candidate);
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
public static Dom.XmlNode findDescendant(
|
||||
Dom.XmlNode parent,
|
||||
String localName
|
||||
) {
|
||||
if (parent == null) {
|
||||
return null;
|
||||
}
|
||||
if (parent.getName() == localName) {
|
||||
return parent;
|
||||
}
|
||||
for (Dom.XmlNode candidate : parent.getChildElements()) {
|
||||
Dom.XmlNode match = findDescendant(candidate, localName);
|
||||
if (match != null) {
|
||||
return match;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static String text(Dom.XmlNode parent, String localName) {
|
||||
Dom.XmlNode valueNode = child(parent, localName);
|
||||
return valueNode == null ? null : valueNode.getText();
|
||||
}
|
||||
|
||||
public static Boolean booleanValue(Dom.XmlNode parent, String localName) {
|
||||
return 'true'.equalsIgnoreCase(text(parent, localName));
|
||||
}
|
||||
|
||||
public static String serialize(Dom.XmlNode node) {
|
||||
String name = node.getName();
|
||||
List<Dom.XmlNode> childNodes = node.getChildElements();
|
||||
if (childNodes.isEmpty()) {
|
||||
return '<' + name + '>' + escape(node.getText()) + '</' + name + '>';
|
||||
}
|
||||
|
||||
String result = '<' + name + '>';
|
||||
for (Dom.XmlNode childNode : childNodes) {
|
||||
result += serialize(childNode);
|
||||
}
|
||||
return result + '</' + name + '>';
|
||||
}
|
||||
|
||||
public static String serializeChildren(Dom.XmlNode parent) {
|
||||
String result = '';
|
||||
for (Dom.XmlNode childNode : parent.getChildElements()) {
|
||||
result += serialize(childNode);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public static String replaceOrInsertChild(
|
||||
Dom.XmlNode parent,
|
||||
String childName,
|
||||
String replacementXml
|
||||
) {
|
||||
String result = '';
|
||||
Boolean replaced = false;
|
||||
for (Dom.XmlNode childNode : parent.getChildElements()) {
|
||||
if (childNode.getName() == childName) {
|
||||
if (!replaced) {
|
||||
result += replacementXml;
|
||||
replaced = true;
|
||||
}
|
||||
} else if (!replaced && childNode.getName().compareTo(childName) > 0) {
|
||||
result += replacementXml;
|
||||
result += serialize(childNode);
|
||||
replaced = true;
|
||||
} else {
|
||||
result += serialize(childNode);
|
||||
}
|
||||
}
|
||||
if (!replaced) {
|
||||
result += replacementXml;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public static String developerName(String label) {
|
||||
String value = String.isBlank(label)
|
||||
? ''
|
||||
: label.trim().replaceAll('[^A-Za-z0-9_]+', '_');
|
||||
value = value.replaceAll('_+', '_').replaceAll('^_+|_+$', '');
|
||||
if (String.isBlank(value)) {
|
||||
throw new IllegalArgumentException(
|
||||
'A developer name could not be generated from the label.'
|
||||
);
|
||||
}
|
||||
if (Pattern.matches('^[0-9].*', value)) {
|
||||
value = 'X_' + value;
|
||||
}
|
||||
return value.length() > 80 ? value.substring(0, 80) : value;
|
||||
}
|
||||
|
||||
public static void require(Boolean condition, String message) {
|
||||
if (!condition) {
|
||||
throw new IllegalArgumentException(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
|
||||
<apiVersion>65.0</apiVersion>
|
||||
<status>Active</status>
|
||||
</ApexClass>
|
||||
@@ -0,0 +1,252 @@
|
||||
@IsTest
|
||||
private class QueueMetadataActionsTest {
|
||||
@TestSetup
|
||||
static void setupQueue() {
|
||||
insert new Group(
|
||||
Name = 'Support Queue',
|
||||
DeveloperName = 'Support_Queue',
|
||||
Type = 'Queue',
|
||||
Email = 'support@example.com'
|
||||
);
|
||||
}
|
||||
|
||||
@IsTest
|
||||
static void createsOriginWhenMissing() {
|
||||
MetadataMock mock = new MetadataMock('origin');
|
||||
Test.setMock(HttpCalloutMock.class, mock);
|
||||
CreateOriginPicklistItem.Request request = new CreateOriginPicklistItem.Request();
|
||||
request.picklistItemName = 'Phone Support';
|
||||
|
||||
Test.startTest();
|
||||
CreateOriginPicklistItem.Result result = CreateOriginPicklistItem.createOrigin(
|
||||
new List<CreateOriginPicklistItem.Request>{ request }
|
||||
)[0];
|
||||
Test.stopTest();
|
||||
|
||||
Assert.areEqual('Phone_Support', result.caseOrigin);
|
||||
Assert.isTrue(result.created);
|
||||
Assert.areEqual(2, mock.requests.size());
|
||||
Assert.isTrue(
|
||||
mock.requests[1].contains('<fullName>Phone_Support</fullName>')
|
||||
);
|
||||
Assert.isTrue(mock.requests[1].contains('<label>Phone Support</label>'));
|
||||
}
|
||||
|
||||
@IsTest
|
||||
static void createsEmailToCaseRoute() {
|
||||
Group queue = [
|
||||
SELECT Id
|
||||
FROM Group
|
||||
WHERE DeveloperName = 'Support_Queue'
|
||||
LIMIT 1
|
||||
];
|
||||
MetadataMock mock = new MetadataMock('emailToCase');
|
||||
Test.setMock(HttpCalloutMock.class, mock);
|
||||
CreateEmailToCase.Request request = new CreateEmailToCase.Request();
|
||||
request.routingName = 'Support Route';
|
||||
request.emailAddress = 'support@example.com';
|
||||
request.caseOwnerId = queue.Id;
|
||||
request.caseOrigin = 'Phone_Support';
|
||||
|
||||
Test.startTest();
|
||||
CreateEmailToCase.Result result = CreateEmailToCase.createRoutingAddress(
|
||||
new List<CreateEmailToCase.Request>{ request }
|
||||
)[0];
|
||||
Test.stopTest();
|
||||
|
||||
Assert.isTrue(result.created);
|
||||
Assert.areEqual('Case', result.metadataFullName);
|
||||
Assert.areEqual(2, mock.requests.size());
|
||||
Assert.isTrue(
|
||||
mock.requests[1].contains('<caseOwner>Support_Queue</caseOwner>')
|
||||
);
|
||||
Assert.isTrue(
|
||||
mock.requests[1].contains('<casePriority>Medium</casePriority>')
|
||||
);
|
||||
Assert.isTrue(
|
||||
mock.requests[1].contains('<addressType>EmailToCase</addressType>')
|
||||
);
|
||||
Assert.isTrue(
|
||||
mock.requests[1]
|
||||
.contains('<emailAddress>support@example.com</emailAddress>')
|
||||
);
|
||||
Assert.isTrue(
|
||||
mock.requests[1].contains('<caseOrigin>Phone_Support</caseOrigin>')
|
||||
);
|
||||
}
|
||||
|
||||
@IsTest
|
||||
static void createsQueueListView() {
|
||||
Group queue = [
|
||||
SELECT Id
|
||||
FROM Group
|
||||
WHERE DeveloperName = 'Support_Queue'
|
||||
LIMIT 1
|
||||
];
|
||||
MetadataMock mock = new MetadataMock('listView');
|
||||
Test.setMock(HttpCalloutMock.class, mock);
|
||||
CreateListView.Request request = new CreateListView.Request();
|
||||
request.listViewLabel = 'Support Open Cases';
|
||||
request.objectApiName = 'Case';
|
||||
request.groupId = queue.Id;
|
||||
request.displayColumns = 'CASE.CASE_NUMBER, CASE.SUBJECT, CASE.STATUS';
|
||||
|
||||
Test.startTest();
|
||||
CreateListView.Result result = CreateListView.createView(
|
||||
new List<CreateListView.Request>{ request }
|
||||
)[0];
|
||||
Test.stopTest();
|
||||
|
||||
Assert.areEqual('Support_Open_Cases', result.listViewApiName);
|
||||
Assert.areEqual('Case.Support_Open_Cases', result.metadataFullName);
|
||||
Assert.isTrue(result.created);
|
||||
Assert.isTrue(
|
||||
mock.requests[1].contains('<filterScope>Queue</filterScope>')
|
||||
);
|
||||
Assert.isTrue(mock.requests[1].contains('<queue>Support_Queue</queue>'));
|
||||
Assert.isTrue(
|
||||
mock.requests[1].contains('<value>Resolved,Cancelled,Closed</value>')
|
||||
);
|
||||
}
|
||||
|
||||
@IsTest
|
||||
static void appendsAutoResponseEntryAtEnd() {
|
||||
String sourceXml =
|
||||
'<?xml version="1.0" encoding="UTF-8"?>' +
|
||||
'<AutoResponseRules xmlns="http://soap.sforce.com/2006/04/metadata">' +
|
||||
'<autoResponseRule><fullName>Case Auto-Response</fullName><active>true</active>' +
|
||||
'<ruleEntry><senderEmail>old@example.com</senderEmail>' +
|
||||
'<template>unfiled$public/Old</template></ruleEntry>' +
|
||||
'</autoResponseRule></AutoResponseRules>';
|
||||
CreateCaseAutoResponseRule.Configuration configuration = new CreateCaseAutoResponseRule.Configuration();
|
||||
configuration.autoResponseRuleName = 'Case Auto-Response';
|
||||
configuration.caseOrigin = 'Phone_Support';
|
||||
configuration.templateFullName = 'unfiled$public/Support_Template';
|
||||
|
||||
String result = CreateCaseAutoResponseRule.appendRuleEntry(
|
||||
sourceXml,
|
||||
configuration
|
||||
);
|
||||
|
||||
Assert.areNotEqual(
|
||||
result.indexOf('<ruleEntry>'),
|
||||
result.lastIndexOf('<ruleEntry>')
|
||||
);
|
||||
Assert.isFalse(result.contains('<sortOrder>'));
|
||||
Assert.isTrue(result.contains('<field>Case.Origin</field>'));
|
||||
Assert.isTrue(result.contains('<value>Phone_Support</value>'));
|
||||
Assert.isTrue(result.contains('<field>Case.ContactEmail</field>'));
|
||||
Assert.isTrue(result.contains('<field>Case.Status</field>'));
|
||||
Assert.isTrue(result.contains('<value>Active</value>'));
|
||||
Assert.isTrue(result.contains('<field>Case.RecordTypeId</field>'));
|
||||
Assert.isTrue(result.contains('<value>Customer Service</value>'));
|
||||
Assert.isTrue(
|
||||
result.contains('<template>unfiled$public/Support_Template</template>')
|
||||
);
|
||||
}
|
||||
|
||||
@IsTest
|
||||
static void rejectsMultipleInvocableRequests() {
|
||||
CreateOriginPicklistItem.Request first = new CreateOriginPicklistItem.Request();
|
||||
first.picklistItemName = 'One';
|
||||
CreateOriginPicklistItem.Request second = new CreateOriginPicklistItem.Request();
|
||||
second.picklistItemName = 'Two';
|
||||
|
||||
try {
|
||||
CreateOriginPicklistItem.createOrigin(
|
||||
new List<CreateOriginPicklistItem.Request>{ first, second }
|
||||
);
|
||||
Assert.fail('Expected a validation exception.');
|
||||
} catch (IllegalArgumentException expected) {
|
||||
Assert.isTrue(expected.getMessage().contains('exactly one request'));
|
||||
}
|
||||
}
|
||||
|
||||
@IsTest
|
||||
static void insertsAutoResponseEntryAtRequestedPosition() {
|
||||
String sourceXml =
|
||||
'<AutoResponseRules xmlns="http://soap.sforce.com/2006/04/metadata">' +
|
||||
'<autoResponseRule><fullName>Case Auto-Response</fullName><active>true</active>' +
|
||||
'<ruleEntry><template>unfiled$public/First</template></ruleEntry>' +
|
||||
'</autoResponseRule></AutoResponseRules>';
|
||||
CreateCaseAutoResponseRule.Configuration configuration = new CreateCaseAutoResponseRule.Configuration();
|
||||
configuration.autoResponseRuleName = 'Case Auto-Response';
|
||||
configuration.caseOrigin = 'Phone_Support';
|
||||
configuration.templateFullName = 'unfiled$public/Inserted';
|
||||
configuration.sortOrder = 1;
|
||||
|
||||
String result = CreateCaseAutoResponseRule.appendRuleEntry(
|
||||
sourceXml,
|
||||
configuration
|
||||
);
|
||||
|
||||
Assert.isTrue(
|
||||
result.indexOf('unfiled$public/Inserted') <
|
||||
result.indexOf('unfiled$public/First')
|
||||
);
|
||||
}
|
||||
|
||||
private class MetadataMock implements HttpCalloutMock {
|
||||
private String scenario;
|
||||
public List<String> requests = new List<String>();
|
||||
|
||||
private MetadataMock(String scenario) {
|
||||
this.scenario = scenario;
|
||||
}
|
||||
|
||||
public HttpResponse respond(HttpRequest request) {
|
||||
requests.add(request.getBody());
|
||||
HttpResponse response = new HttpResponse();
|
||||
response.setStatusCode(200);
|
||||
response.setHeader('Content-Type', 'text/xml');
|
||||
if (request.getBody().contains('<met:readMetadata>')) {
|
||||
response.setBody(readResponse());
|
||||
} else {
|
||||
response.setBody(saveResponse());
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
private String readResponse() {
|
||||
String recordXml;
|
||||
if (scenario == 'origin') {
|
||||
recordXml =
|
||||
'<records xsi:type="StandardValueSet"><fullName>CaseOrigin</fullName>' +
|
||||
'<standardValue><fullName>Email</fullName><default>false</default>' +
|
||||
'<label>Email</label></standardValue></records>';
|
||||
} else if (scenario == 'emailToCase') {
|
||||
recordXml =
|
||||
'<records xsi:type="CaseSettings"><fullName>Case</fullName>' +
|
||||
'<emailToCase><enableEmailToCase>true</enableEmailToCase>' +
|
||||
'<unauthorizedSenderAction>Discard</unauthorizedSenderAction>' +
|
||||
'</emailToCase></records>';
|
||||
} else {
|
||||
recordXml = '<records xsi:nil="true"/>';
|
||||
}
|
||||
return envelope(
|
||||
'<readMetadataResponse><result>' +
|
||||
recordXml +
|
||||
'</result></readMetadataResponse>'
|
||||
);
|
||||
}
|
||||
|
||||
private String saveResponse() {
|
||||
Boolean created = scenario == 'listView';
|
||||
return envelope(
|
||||
'<saveResponse><result><created>' +
|
||||
String.valueOf(created).toLowerCase() +
|
||||
'</created><fullName>Test</fullName><success>true</success>' +
|
||||
'</result></saveResponse>'
|
||||
);
|
||||
}
|
||||
|
||||
private String envelope(String body) {
|
||||
return '<?xml version="1.0" encoding="UTF-8"?>' +
|
||||
'<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" ' +
|
||||
'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">' +
|
||||
'<soapenv:Body>' +
|
||||
body +
|
||||
'</soapenv:Body></soapenv:Envelope>';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
|
||||
<apiVersion>65.0</apiVersion>
|
||||
<status>Active</status>
|
||||
</ApexClass>
|
||||
@@ -0,0 +1,6 @@
|
||||
const { jestConfig } = require('@salesforce/sfdx-lwc-jest/config');
|
||||
|
||||
module.exports = {
|
||||
...jestConfig,
|
||||
modulePathIgnorePatterns: ['<rootDir>/.localdevserver']
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<Package xmlns="http://soap.sforce.com/2006/04/metadata">
|
||||
<types>
|
||||
<members>*</members>
|
||||
<name>ApexClass</name>
|
||||
</types>
|
||||
<version>65.0</version>
|
||||
</Package>
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "salesforce-app",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"description": "Salesforce App",
|
||||
"scripts": {
|
||||
"lint": "eslint **/{aura,lwc}/**/*.js",
|
||||
"test": "npm run test:unit",
|
||||
"test:unit": "sfdx-lwc-jest",
|
||||
"test:unit:watch": "sfdx-lwc-jest --watch",
|
||||
"test:unit:debug": "sfdx-lwc-jest --debug",
|
||||
"test:unit:coverage": "sfdx-lwc-jest --coverage",
|
||||
"prettier": "prettier --write \"**/*.{cls,cmp,component,css,html,js,json,md,page,trigger,xml,yaml,yml}\"",
|
||||
"prettier:verify": "prettier --check \"**/*.{cls,cmp,component,css,html,js,json,md,page,trigger,xml,yaml,yml}\"",
|
||||
"prepare": "husky || true",
|
||||
"precommit": "lint-staged"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lwc/eslint-plugin-lwc": "^3.1.0",
|
||||
"@prettier/plugin-xml": "^3.4.1",
|
||||
"@salesforce/eslint-config-lwc": "^4.0.0",
|
||||
"@salesforce/eslint-plugin-aura": "^3.0.0",
|
||||
"@salesforce/eslint-plugin-lightning": "^2.0.0",
|
||||
"@salesforce/sfdx-lwc-jest": "^7.0.2",
|
||||
"eslint": "^9.29.0",
|
||||
"eslint-plugin-import": "^2.31.0",
|
||||
"eslint-plugin-jest": "^28.14.0",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.1.2",
|
||||
"prettier": "^3.5.3",
|
||||
"prettier-plugin-apex": "^2.2.6"
|
||||
},
|
||||
"lint-staged": {
|
||||
"**/*.{cls,cmp,component,css,html,js,json,md,page,trigger,xml,yaml,yml}": [
|
||||
"prettier --write"
|
||||
],
|
||||
"**/{aura,lwc}/**/*.js": [
|
||||
"eslint"
|
||||
],
|
||||
"**/lwc/**": [
|
||||
"sfdx-lwc-jest -- --bail --findRelatedTests --passWithNoTests"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// Use .apex files to store anonymous Apex.
|
||||
// You can execute anonymous Apex in VS Code by selecting the
|
||||
// apex text and running the command:
|
||||
// SFDX: Execute Anonymous Apex with Currently Selected Text
|
||||
// You can also execute the entire file by running the command:
|
||||
// SFDX: Execute Anonymous Apex with Editor Contents
|
||||
|
||||
string tempvar = 'Enter_your_name_here';
|
||||
System.debug('Hello World!');
|
||||
System.debug('My name is ' + tempvar);
|
||||
@@ -0,0 +1,6 @@
|
||||
// Use .soql files to store SOQL queries.
|
||||
// You can execute queries in VS Code by selecting the
|
||||
// query text and running the command:
|
||||
// SFDX: Execute SOQL Query with Currently Selected Text
|
||||
|
||||
SELECT Id, Name FROM Account
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"packageDirectories": [
|
||||
{
|
||||
"path": "force-app",
|
||||
"default": true
|
||||
}
|
||||
],
|
||||
"name": "queue-creation",
|
||||
"namespace": "",
|
||||
"sfdcLoginUrl": "https://login.salesforce.com",
|
||||
"sourceApiVersion": "65.0"
|
||||
}
|
||||
Reference in New Issue
Block a user