Skip to content

Commit f91511b

Browse files
joeauyeungdevin-ai-integration[bot]claude
authored
feat(salesforce): add field rules for round robin routing (calcom#27402)
* feat(salesforce): add field rules for round robin skip - Add rrSkipFieldRules schema to appDataSchema in zod.ts - Implement applyFieldRules method in CrmService.ts for post-query filtering - Integrate field rules into getContacts method when forRoundRobinSkip=true - Add UI component for configuring field rules in EventTypeAppCardInterface - Add translations for new UI strings - Add comprehensive tests for field rules functionality Field rules allow users to specify conditions based on Salesforce record fields with 'ignore' or 'must_include' actions. Rules are evaluated with AND logic and gracefully handle missing fields by skipping those rules. Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: add children prop to Section.SubSectionHeader for field rules Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * feat(salesforce): add edit button for field rules Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * feat(salesforce): apply field rules to GraphQL results Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * feat(salesforce): add Redis caching for field validation Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix(salesforce): remove Redis caching to fix ERR_INVALID_THIS error - Removed Redis caching for field validation that was causing ERR_INVALID_THIS errors - Simplified field rules filtering to try query directly and skip filtering if it fails - Restored ensureFieldsExistOnObject method for other uses (write to record) - Field rules now gracefully handle invalid fields by skipping filtering Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * chore: reformat getAttributes.ts Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(salesforce): dynamically build GraphQL query for field rules with multi-edge iteration - Add buildDynamicAccountQuery to inject field rule fields into GraphQL query - Add passesFieldRules to evaluate ignore/must_include rules against UIAPI nodes - Validate field rules via getObjectFieldNames (in-memory + Redis cache) before branching - Remove applyFieldRulesToGraphQLRecords (used jsforce conn in GraphQL path) - Iterate all edges in Tiers 1 and 2 instead of only the first result - Rank Tier 3 accounts by contact count and fallback to next on field rule failure Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(salesforce): add field rule routing trace steps Add trace calls alongside field rule info logs for routing visibility: - fieldRulesValidated: records validation result in CrmService - fieldRuleFilteredRecord: records when account is filtered at each tier - fieldRuleEvaluated: records individual rule evaluation details - allRecordsFilteredByFieldRules: records when SOQL records are all filtered Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(salesforce): remove duplicate field validation and fix disabled Select options applyFieldRules now receives pre-validated rules from the early validation block instead of re-validating internally. Also adds the missing options prop to the disabled Select in the field rules UI. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(salesforce): include failed rule details in field rule trace steps Rename passesFieldRules to getFailingFieldRule so the caller receives the specific rule that caused filtering. The fieldRuleFilteredRecord trace step now includes failedRule with field, value, and action. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * test(salesforce): add GraphQL path tests for field rules Cover field rule filtering across all three GraphQL resolution tiers (contact, account, related contacts) with ignore and must_include actions, multi-edge fallback, case-insensitivity, and dynamic query selection. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Abstract new field rules settings * Reduce extra SOQL call * fix(salesforce): fix type errors in CrmService and GraphQL client - Add missing import for getRedisService from @calcom/features/di/containers/Redis - Fix ensureFieldsExistOnObject to properly return Field[] instead of incomplete function - Use Array.from() instead of spread operator for Set and Map iterators to fix downlevelIteration errors Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 3f053b4 commit f91511b

11 files changed

Lines changed: 1680 additions & 151 deletions

File tree

apps/web/public/static/locales/en/common.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3734,6 +3734,11 @@
37343734
"completed_onboarding": "Completed onboarding",
37353735
"salesforce_on_cancel_write_to_event": "On cancelled booking, write to event record instead of deleting event",
37363736
"salesforce_on_every_cancellation": "On every cancellation",
3737+
"salesforce_rr_skip_field_rules": "Field rules for round robin skip",
3738+
"salesforce_rr_skip_field_rule_ignore": "Ignore",
3739+
"salesforce_rr_skip_field_rule_must_include": "Must include",
3740+
"salesforce_field_name_placeholder": "Field name (e.g., Industry)",
3741+
"add_new_rule": "Add new rule",
37373742
"report_issue": "Report issue",
37383743
"report_issue_description": "You can submit a ticket on GitHub or upgrade your plan to receive real-time support with developer conferences",
37393744
"open_issue": "Open Issue",

packages/app-store/salesforce/components/EventTypeAppCardInterface.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ import { Section } from "@calcom/ui/components/section";
1717
import { showToast } from "@calcom/ui/components/toast";
1818

1919
import { SalesforceRecordEnum } from "../lib/enums";
20-
import type { appDataSchema } from "../zod";
20+
import type { appDataSchema, RRSkipFieldRule } from "../zod";
21+
import FieldRulesSettings from "./components/FieldRulesSettings";
2122
import WriteToObjectSettings, { BookingActionEnum } from "./components/WriteToObjectSettings";
2223

2324
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType, onAppInstallSuccess }) {
@@ -46,6 +47,7 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
4647
const roundRobinSkipFallbackToLeadOwner = getAppData("roundRobinSkipFallbackToLeadOwner") ?? false;
4748
const onCancelWriteToEventRecord = getAppData("onCancelWriteToEventRecord") ?? false;
4849
const onCancelWriteToEventRecordFields = getAppData("onCancelWriteToEventRecordFields") ?? {};
50+
const rrSkipFieldRules = (getAppData("rrSkipFieldRules") ?? []) as RRSkipFieldRule[];
4951

5052
const { t } = useLocale();
5153

@@ -435,6 +437,10 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
435437
</Section.SubSectionHeader>
436438
<Alert severity="info" title={t("skip_rr_description")} />
437439
</Section.SubSection>
440+
<FieldRulesSettings
441+
fieldRules={rrSkipFieldRules}
442+
updateFieldRules={(rules) => setAppData("rrSkipFieldRules", rules)}
443+
/>
438444
</>
439445
) : null}
440446
</>
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
import { useState } from "react";
2+
3+
import { useLocale } from "@calcom/lib/hooks/useLocale";
4+
import { Button } from "@calcom/ui/components/button";
5+
import { InputField } from "@calcom/ui/components/form";
6+
import { Select } from "@calcom/ui/components/form";
7+
import { Section } from "@calcom/ui/components/section";
8+
9+
import type { RRSkipFieldRule } from "../../zod";
10+
import { RRSkipFieldRuleActionEnum } from "../../zod";
11+
12+
type FieldRuleAction = (typeof RRSkipFieldRuleActionEnum)[keyof typeof RRSkipFieldRuleActionEnum];
13+
14+
const FieldRulesSettings = ({
15+
fieldRules,
16+
updateFieldRules,
17+
}: {
18+
fieldRules: RRSkipFieldRule[];
19+
updateFieldRules: (rules: RRSkipFieldRule[]) => void;
20+
}) => {
21+
const { t } = useLocale();
22+
23+
const actionOptions = [
24+
{ label: t("salesforce_rr_skip_field_rule_ignore"), value: RRSkipFieldRuleActionEnum.IGNORE },
25+
{ label: t("salesforce_rr_skip_field_rule_must_include"), value: RRSkipFieldRuleActionEnum.MUST_INCLUDE },
26+
];
27+
28+
const [newRule, setNewRule] = useState<{ field: string; value: string; action: FieldRuleAction }>({
29+
field: "",
30+
value: "",
31+
action: RRSkipFieldRuleActionEnum.IGNORE,
32+
});
33+
34+
const [editingIndex, setEditingIndex] = useState<number | null>(null);
35+
const [editingRule, setEditingRule] = useState<{
36+
field: string;
37+
value: string;
38+
action: FieldRuleAction;
39+
} | null>(null);
40+
41+
const startEditing = (index: number) => {
42+
const rule = fieldRules[index];
43+
setEditingIndex(index);
44+
setEditingRule({ field: rule.field, value: rule.value, action: rule.action });
45+
};
46+
47+
const cancelEditing = () => {
48+
setEditingIndex(null);
49+
setEditingRule(null);
50+
};
51+
52+
const saveEditing = () => {
53+
if (editingIndex === null || !editingRule) return;
54+
if (!editingRule.field.trim() || !editingRule.value.trim()) return;
55+
56+
const newRules = [...fieldRules];
57+
newRules[editingIndex] = {
58+
field: editingRule.field.trim(),
59+
value: editingRule.value.trim(),
60+
action: editingRule.action,
61+
};
62+
updateFieldRules(newRules);
63+
cancelEditing();
64+
};
65+
66+
return (
67+
<Section.SubSection>
68+
<Section.SubSectionHeader
69+
icon="filter"
70+
title={t("salesforce_rr_skip_field_rules")}
71+
labelFor="rr-skip-field-rules">
72+
<></>
73+
</Section.SubSectionHeader>
74+
<Section.SubSectionContent>
75+
<div className="text-subtle flex gap-3 px-3 py-[6px] text-sm font-medium">
76+
<div className="flex-1">{t("field_name")}</div>
77+
<div className="flex-1">{t("value")}</div>
78+
<div className="w-32">{t("action")}</div>
79+
<div className="w-20" />
80+
</div>
81+
<Section.SubSectionNested>
82+
{fieldRules.map((rule, index) => {
83+
const isEditing = editingIndex === index;
84+
return (
85+
<div className="flex items-center gap-2" key={`${rule.field}-${index}`}>
86+
<div className="flex-1">
87+
{isEditing ? (
88+
<InputField
89+
value={editingRule?.field || ""}
90+
onChange={(e) =>
91+
setEditingRule((prev) => (prev ? { ...prev, field: e.target.value } : null))
92+
}
93+
size="sm"
94+
className="w-full"
95+
/>
96+
) : (
97+
<InputField value={rule.field} readOnly size="sm" className="w-full" />
98+
)}
99+
</div>
100+
<div className="flex-1">
101+
{isEditing ? (
102+
<InputField
103+
value={editingRule?.value || ""}
104+
onChange={(e) =>
105+
setEditingRule((prev) => (prev ? { ...prev, value: e.target.value } : null))
106+
}
107+
size="sm"
108+
className="w-full"
109+
/>
110+
) : (
111+
<InputField value={rule.value} readOnly size="sm" className="w-full" />
112+
)}
113+
</div>
114+
<div className="w-32">
115+
{isEditing ? (
116+
<Select
117+
size="sm"
118+
className="w-full"
119+
options={actionOptions}
120+
value={actionOptions.find((opt) => opt.value === editingRule?.action)}
121+
onChange={(e) => {
122+
if (e) {
123+
setEditingRule((prev) => (prev ? { ...prev, action: e.value } : null));
124+
}
125+
}}
126+
/>
127+
) : (
128+
<Select
129+
size="sm"
130+
className="w-full"
131+
options={actionOptions}
132+
value={actionOptions.find((opt) => opt.value === rule.action)}
133+
isDisabled={true}
134+
/>
135+
)}
136+
</div>
137+
<div className="flex w-20 justify-center gap-1">
138+
{isEditing ? (
139+
<>
140+
<Button
141+
size="sm"
142+
StartIcon="check"
143+
variant="icon"
144+
color="primary"
145+
onClick={() => saveEditing()}
146+
/>
147+
<Button
148+
size="sm"
149+
StartIcon="x"
150+
variant="icon"
151+
color="secondary"
152+
onClick={() => cancelEditing()}
153+
/>
154+
</>
155+
) : (
156+
<>
157+
<Button
158+
size="sm"
159+
StartIcon="pencil"
160+
variant="icon"
161+
color="minimal"
162+
onClick={() => startEditing(index)}
163+
/>
164+
<Button
165+
StartIcon="x"
166+
variant="icon"
167+
size="sm"
168+
color="minimal"
169+
onClick={() => {
170+
updateFieldRules(fieldRules.filter((_, i) => i !== index));
171+
}}
172+
/>
173+
</>
174+
)}
175+
</div>
176+
</div>
177+
);
178+
})}
179+
<div className="mt-2 flex gap-2">
180+
<div className="flex-1">
181+
<InputField
182+
size="sm"
183+
className="w-full"
184+
placeholder={t("salesforce_field_name_placeholder")}
185+
value={newRule.field}
186+
onChange={(e) => setNewRule({ ...newRule, field: e.target.value })}
187+
/>
188+
</div>
189+
<div className="flex-1">
190+
<InputField
191+
size="sm"
192+
className="w-full"
193+
placeholder={t("value")}
194+
value={newRule.value}
195+
onChange={(e) => setNewRule({ ...newRule, value: e.target.value })}
196+
/>
197+
</div>
198+
<div className="w-32">
199+
<Select
200+
size="sm"
201+
className="w-full"
202+
options={actionOptions}
203+
value={actionOptions.find((opt) => opt.value === newRule.action)}
204+
onChange={(e) => {
205+
if (e) {
206+
setNewRule({ ...newRule, action: e.value });
207+
}
208+
}}
209+
/>
210+
</div>
211+
<div className="w-20" />
212+
</div>
213+
</Section.SubSectionNested>
214+
<Button
215+
className="text-subtle mt-2 w-fit"
216+
StartIcon="plus"
217+
color="minimal"
218+
size="sm"
219+
disabled={!(newRule.field && newRule.value)}
220+
onClick={() => {
221+
updateFieldRules([
222+
...fieldRules,
223+
{
224+
field: newRule.field.trim(),
225+
value: newRule.value.trim(),
226+
action: newRule.action,
227+
},
228+
]);
229+
setNewRule({ field: "", value: "", action: RRSkipFieldRuleActionEnum.IGNORE });
230+
}}>
231+
{t("add_new_rule")}
232+
</Button>
233+
</Section.SubSectionContent>
234+
</Section.SubSection>
235+
);
236+
};
237+
238+
export default FieldRulesSettings;

0 commit comments

Comments
 (0)