feat(automations): multi-option form-field conditions (all / any / none / exact)
This commit is contained in:
@@ -22,6 +22,7 @@ import {
|
||||
automationStepToConfig,
|
||||
automationUsesAdvanced,
|
||||
firstAutomationAction,
|
||||
formConditionValues,
|
||||
matchFormFieldCondition,
|
||||
normalizeAutomationSteps,
|
||||
packAutomationConfig,
|
||||
@@ -668,7 +669,7 @@ function matchCondition(step: AutomationStep, facts: AutomationFacts): boolean {
|
||||
if (field === 'form_field') {
|
||||
const key = step.conditionKey?.trim();
|
||||
if (!key) return false;
|
||||
return matchFormFieldCondition(op, facts.formValues[key], step.conditionValue);
|
||||
return matchFormFieldCondition(op, facts.formValues[key], formConditionValues(step));
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
automationUsesAdvanced,
|
||||
formConditionValues,
|
||||
matchFormFieldCondition,
|
||||
splitAutomationBranches,
|
||||
planAutomationActions,
|
||||
@@ -66,6 +67,21 @@ describe('اتوماسیون شاخهای فرم', () => {
|
||||
assert.equal(matchFormFieldCondition('is_empty', '', ''), true);
|
||||
});
|
||||
|
||||
it('شرط با چند گزینهٔ انتخابی', () => {
|
||||
const many = ['آزاد', 'کارگر'];
|
||||
assert.equal(matchFormFieldCondition('contains', 'آزاد, کارگر, کارمند دولتی', many), true);
|
||||
assert.equal(matchFormFieldCondition('contains', 'آزاد', many), false);
|
||||
assert.equal(matchFormFieldCondition('contains_any', 'کارگر', many), true);
|
||||
assert.equal(matchFormFieldCondition('contains_any', 'کارمند دولتی', many), false);
|
||||
assert.equal(matchFormFieldCondition('not_contains', 'کارمند دولتی', many), true);
|
||||
assert.equal(matchFormFieldCondition('not_contains', 'کارگر', many), false);
|
||||
assert.equal(matchFormFieldCondition('eq', 'کارگر, آزاد', many), true);
|
||||
assert.equal(matchFormFieldCondition('eq', 'آزاد, کارگر, کارمند دولتی', many), false);
|
||||
assert.equal(matchFormFieldCondition('neq', 'آزاد', many), true);
|
||||
assert.deepEqual(formConditionValues({ conditionValue: 'آزاد', conditionValues: [] }), ['آزاد']);
|
||||
assert.deepEqual(formConditionValues({ conditionValue: 'x', conditionValues: many }), many);
|
||||
});
|
||||
|
||||
it('هر گزینه فقط شاخهٔ خودش را اجرا میکند', () => {
|
||||
assert.deepEqual(run(BSJ, 'بنکداری'), ['a1']);
|
||||
assert.deepEqual(run(BSJ, 'آنلاین شاپ'), ['a2']);
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
AUTOMATION_NOTIFY_CHANNEL_LABELS,
|
||||
AUTOMATION_TRIGGER_LABELS,
|
||||
MAX_AUTOMATION_STEPS,
|
||||
formConditionValues,
|
||||
isAutomationCondition,
|
||||
splitAutomationBranches,
|
||||
type AutomationAction,
|
||||
@@ -174,7 +175,8 @@ function stepHint(step: AutomationStep) {
|
||||
const opLabel = AUTOMATION_CONDITION_OP_LABELS[op];
|
||||
if (step.conditionField === 'form_field') {
|
||||
if (op === 'is_set' || op === 'is_empty') return opLabel;
|
||||
return `${opLabel} «${step.conditionValue ?? ''}»`;
|
||||
const joiner = op === 'contains_any' || op === 'not_contains' ? ' یا ' : ' و ';
|
||||
return `${opLabel} ${formConditionValues(step).map((v) => `«${v}»`).join(joiner) || '«»'}`;
|
||||
}
|
||||
if (step.conditionField === 'deal_amount' && step.conditionValue != null) {
|
||||
return `${opLabel} ${toFaDigits(Number(step.conditionValue))}`;
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
AUTOMATION_TRIGGER_LABELS,
|
||||
conditionFieldsForTrigger,
|
||||
conditionOpsForField,
|
||||
formConditionValues,
|
||||
isAutomationCondition,
|
||||
} from '@mento/shared';
|
||||
|
||||
@@ -127,6 +128,24 @@ function setConditionKey(step: AutomationStep, key: string) {
|
||||
step.conditionKey = key || null;
|
||||
const field = formFields.value.find((f) => f.key === key);
|
||||
step.conditionValue = field?.options?.[0] ?? '';
|
||||
step.conditionValues = step.conditionValue ? [step.conditionValue] : [];
|
||||
}
|
||||
|
||||
function toggleConditionValue(step: AutomationStep, option: string, on: boolean) {
|
||||
const cur = formConditionValues(step).filter((v) => v !== option);
|
||||
if (on) cur.push(option);
|
||||
const order = conditionFormField.value?.options ?? [];
|
||||
cur.sort((a, b) => order.indexOf(a) - order.indexOf(b));
|
||||
step.conditionValues = cur;
|
||||
step.conditionValue = cur[0] ?? '';
|
||||
}
|
||||
|
||||
function multiValueHint(op: AutomationConditionOp | undefined) {
|
||||
if (op === 'contains_any') return 'کافی است حداقل یکی از این گزینهها انتخاب شده باشد.';
|
||||
if (op === 'not_contains') return 'هیچکدام از این گزینهها نباید انتخاب شده باشد.';
|
||||
if (op === 'eq') return 'دقیقاً همین گزینهها (نه بیشتر و نه کمتر) باید انتخاب شده باشند.';
|
||||
if (op === 'neq') return 'برقرار است مگر اینکه دقیقاً همین گزینهها انتخاب شده باشند.';
|
||||
return 'همهٔ این گزینهها باید با هم انتخاب شده باشند.';
|
||||
}
|
||||
|
||||
const activityTypes: Array<{ value: ActivityType; label: string }> = [
|
||||
@@ -155,6 +174,7 @@ function setAction(step: AutomationStep, action: AutomationAction) {
|
||||
function setConditionField(step: AutomationStep, field: AutomationConditionField) {
|
||||
step.conditionField = field;
|
||||
step.conditionKey = null;
|
||||
step.conditionValues = [];
|
||||
if (field === 'deal_amount') {
|
||||
step.conditionOp = 'gte';
|
||||
step.conditionValue = typeof step.conditionValue === 'number' ? step.conditionValue : 0;
|
||||
@@ -289,12 +309,20 @@ watch(
|
||||
</select>
|
||||
</label>
|
||||
<template v-if="selectedStep.conditionField === 'form_field' && needsConditionValue">
|
||||
<label v-if="conditionFormField?.options?.length">
|
||||
گزینه
|
||||
<select v-model="selectedStep.conditionValue">
|
||||
<option v-for="o in conditionFormField.options" :key="o" :value="o">{{ o }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<div v-if="conditionFormField?.options?.length" class="user-picks">
|
||||
<p class="field-label">گزینهها (یک یا چند مورد)</p>
|
||||
<label v-for="o in conditionFormField.options" :key="o" class="ch">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="formConditionValues(selectedStep).includes(o)"
|
||||
@change="toggleConditionValue(selectedStep, o, ($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
{{ o }}
|
||||
</label>
|
||||
<p v-if="formConditionValues(selectedStep).length > 1" class="hint">
|
||||
{{ multiValueHint(selectedStep.conditionOp) }}
|
||||
</p>
|
||||
</div>
|
||||
<label v-else>
|
||||
مقدار
|
||||
<input v-model.trim="selectedStep.conditionValue" type="text" maxlength="200" />
|
||||
|
||||
@@ -112,7 +112,7 @@ function conditionDefaults(field: AutomationConditionField): Partial<AutomationS
|
||||
return { conditionOp: 'eq', conditionValue: 'normal', conditionKey: null };
|
||||
}
|
||||
if (field === 'form_field') {
|
||||
return { conditionOp: 'contains', conditionValue: '', conditionKey: null };
|
||||
return { conditionOp: 'contains', conditionValue: '', conditionValues: [], conditionKey: null };
|
||||
}
|
||||
return { conditionOp: 'is_set', conditionValue: undefined, conditionKey: null };
|
||||
}
|
||||
@@ -197,6 +197,7 @@ function hydrate(row?: AutomationRuleDto | null) {
|
||||
dueOffsetMinutes: s.dueOffsetMinutes ?? null,
|
||||
activityTypeDefId: s.activityTypeDefId ?? null,
|
||||
conditionKey: s.conditionKey ?? null,
|
||||
conditionValues: s.conditionValues ?? [],
|
||||
}));
|
||||
if (!steps.value.length) steps.value = [makeStep('create_activity')];
|
||||
selected.value = 'trigger';
|
||||
|
||||
@@ -3166,6 +3166,7 @@ export type AutomationConditionOp =
|
||||
| "is_set"
|
||||
| "is_empty"
|
||||
| "contains"
|
||||
| "contains_any"
|
||||
| "not_contains";
|
||||
|
||||
/** stop: شرط ناموفق کل زنجیره را متوقف میکند — branches: هر شرط فقط عملهای بعدی خودش را تا شرط بعدی کنترل میکند */
|
||||
@@ -3214,6 +3215,7 @@ export const AUTOMATION_CONDITION_OP_LABELS: Record<
|
||||
is_set: "دارد",
|
||||
is_empty: "ندارد",
|
||||
contains: "شامل",
|
||||
contains_any: "شامل یکی از",
|
||||
not_contains: "شامل نیست",
|
||||
};
|
||||
|
||||
@@ -3269,7 +3271,7 @@ export function conditionOpsForField(
|
||||
if (field === "deal_amount") return ["gte", "lte", "eq", "neq"];
|
||||
if (field === "ticket_priority") return ["eq", "neq"];
|
||||
if (field === "form_field") {
|
||||
return ["contains", "not_contains", "eq", "neq", "is_set", "is_empty"];
|
||||
return ["contains", "contains_any", "not_contains", "eq", "neq", "is_set", "is_empty"];
|
||||
}
|
||||
return ["is_set", "is_empty"];
|
||||
}
|
||||
@@ -3289,6 +3291,8 @@ export interface AutomationStep {
|
||||
conditionField?: AutomationConditionField;
|
||||
conditionOp?: AutomationConditionOp;
|
||||
conditionValue?: string | number;
|
||||
/** چند گزینهٔ فیلد فرم (چندانتخابی)؛ اگر پر باشد بهجای conditionValue استفاده میشود */
|
||||
conditionValues?: string[];
|
||||
/** کلید فیلد فرم برای شرط form_field */
|
||||
conditionKey?: string | null;
|
||||
assignMode?: AutomationAssignMode;
|
||||
@@ -3365,6 +3369,9 @@ export function normalizeAutomationSteps(
|
||||
conditionField: s.conditionField,
|
||||
conditionOp: s.conditionOp,
|
||||
conditionValue: s.conditionValue,
|
||||
conditionValues: Array.isArray(s.conditionValues)
|
||||
? s.conditionValues.map((v) => String(v).trim()).filter(Boolean)
|
||||
: undefined,
|
||||
conditionKey: s.conditionKey,
|
||||
assignMode: s.assignMode,
|
||||
assigneeTeamId: s.assigneeTeamId,
|
||||
@@ -3518,27 +3525,49 @@ export function splitFormAnswer(raw: unknown): string[] {
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/** گزینههای مورد انتظار شرط فرم: چندتایی اگر پر باشد، وگرنه مقدار تکی */
|
||||
export function formConditionValues(
|
||||
step: Pick<AutomationStep, "conditionValue" | "conditionValues">,
|
||||
): string[] {
|
||||
const many = (step.conditionValues ?? []).map((v) => String(v).trim()).filter(Boolean);
|
||||
if (many.length) return [...new Set(many)];
|
||||
const one = String(step.conditionValue ?? "").trim();
|
||||
return one ? [one] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* contains: همهٔ گزینهها انتخاب شده باشند — contains_any: حداقل یکی —
|
||||
* not_contains: هیچکدام — eq: دقیقاً همین گزینهها (نه بیشتر، نه کمتر)
|
||||
*/
|
||||
export function matchFormFieldCondition(
|
||||
op: AutomationConditionOp | undefined,
|
||||
raw: unknown,
|
||||
expected: unknown,
|
||||
expected: string | number | string[] | null | undefined,
|
||||
): boolean {
|
||||
const parts = splitFormAnswer(raw);
|
||||
const value = String(expected ?? "").trim();
|
||||
const whole = raw == null ? "" : String(raw).trim();
|
||||
const values = (Array.isArray(expected) ? expected : [expected])
|
||||
.map((v) => String(v ?? "").trim())
|
||||
.filter(Boolean);
|
||||
const exact = () =>
|
||||
values.length === 1
|
||||
? whole === values[0] || (parts.length === 1 && parts[0] === values[0])
|
||||
: new Set(parts).size === values.length && values.every((v) => parts.includes(v));
|
||||
switch (op ?? "contains") {
|
||||
case "is_set":
|
||||
return parts.length > 0;
|
||||
case "is_empty":
|
||||
return parts.length === 0;
|
||||
case "contains":
|
||||
return Boolean(value) && parts.includes(value);
|
||||
return values.length > 0 && values.every((v) => parts.includes(v));
|
||||
case "contains_any":
|
||||
return values.some((v) => parts.includes(v));
|
||||
case "not_contains":
|
||||
return !value || !parts.includes(value);
|
||||
return !values.some((v) => parts.includes(v));
|
||||
case "eq":
|
||||
return whole === value || (parts.length === 1 && parts[0] === value);
|
||||
return values.length > 0 && exact();
|
||||
case "neq":
|
||||
return !(whole === value || (parts.length === 1 && parts[0] === value));
|
||||
return !(values.length > 0 && exact());
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user