This commit is contained in:
2026-08-21 16:38:06 +00:00
commit 43d764f9bc
31 changed files with 1911 additions and 0 deletions
+12
View File
@@ -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
View File
@@ -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/
+1
View File
@@ -0,0 +1 @@
npm run precommit
+11
View File
@@ -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
View File
@@ -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" }
}
]
}
+9
View File
@@ -0,0 +1,9 @@
{
"recommendations": [
"salesforce.salesforcedx-vscode",
"redhat.vscode-xml",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"financialforce.lana"
]
}
+16
View File
@@ -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
}
]
}
+9
View File
@@ -0,0 +1,9 @@
{
"search.exclude": {
"**/node_modules": true,
"**/bower_components": true,
"**/.sf": true,
"**/.sfdx": true
},
"xml.preferences.showSchemaDocumentationType": "none"
}
+73
View File
@@ -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
```
+13
View File
@@ -0,0 +1,13 @@
{
"orgName": "zachsaunders company",
"edition": "Developer",
"features": ["EnableSetPasswordInApi"],
"settings": {
"lightningExperienceSettings": {
"enableS1DesktopEnabled": true
},
"mobileSettings": {
"enableS1EncryptedStoragePref2": false
}
}
}
+55
View File
@@ -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('&', '&amp;')
.replace('<', '&lt;')
.replace('>', '&gt;')
.replace('"', '&quot;')
.replace('\'', '&apos;');
}
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>
+6
View File
@@ -0,0 +1,6 @@
const { jestConfig } = require('@salesforce/sfdx-lwc-jest/config');
module.exports = {
...jestConfig,
modulePathIgnorePatterns: ['<rootDir>/.localdevserver']
};
+8
View File
@@ -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>
+44
View File
@@ -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"
]
}
}
+10
View File
@@ -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);
+6
View File
@@ -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
+12
View File
@@ -0,0 +1,12 @@
{
"packageDirectories": [
{
"path": "force-app",
"default": true
}
],
"name": "queue-creation",
"namespace": "",
"sfdcLoginUrl": "https://login.salesforce.com",
"sourceApiVersion": "65.0"
}