UI for Automated rules
This commit is contained in:
parent
b2e041c420
commit
b866e6152e
|
|
@ -1,10 +1,6 @@
|
|||
import { store } from '@/actions/App/Http/Controllers/Settings/AutomationRuleController';
|
||||
import { RuleBuilder } from '@/components/automation-rules/rule-builder';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -25,10 +21,15 @@ import {
|
|||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { encrypt, importKey } from '@/lib/crypto';
|
||||
import { getStoredKey } from '@/lib/key-storage';
|
||||
import {
|
||||
buildJsonLogic,
|
||||
createEmptyGroup,
|
||||
isValidRuleStructure,
|
||||
type RuleStructure,
|
||||
} from '@/lib/rule-builder-utils';
|
||||
import { categorySyncService } from '@/services/category-sync';
|
||||
import type { Category } from '@/types/category';
|
||||
import { router } from '@inertiajs/react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface CreateAutomationRuleDialogProps {
|
||||
|
|
@ -44,12 +45,14 @@ export function CreateAutomationRuleDialog({
|
|||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [title, setTitle] = useState('');
|
||||
const [priority, setPriority] = useState('0');
|
||||
const [rulesJson, setRulesJson] = useState('');
|
||||
const [ruleStructure, setRuleStructure] = useState<RuleStructure>({
|
||||
groups: [createEmptyGroup()],
|
||||
groupOperator: 'and',
|
||||
});
|
||||
const [categoryId, setCategoryId] = useState<string>('');
|
||||
const [note, setNote] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const loadCategories = async () => {
|
||||
|
|
@ -59,22 +62,6 @@ export function CreateAutomationRuleDialog({
|
|||
loadCategories();
|
||||
}, []);
|
||||
|
||||
const validateJsonLogic = (json: string): boolean => {
|
||||
try {
|
||||
const parsed = JSON.parse(json);
|
||||
if (
|
||||
typeof parsed !== 'object' ||
|
||||
parsed === null ||
|
||||
Array.isArray(parsed)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return Object.keys(parsed).length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setErrors({});
|
||||
|
|
@ -84,18 +71,10 @@ export function CreateAutomationRuleDialog({
|
|||
return;
|
||||
}
|
||||
|
||||
if (!rulesJson.trim()) {
|
||||
if (!isValidRuleStructure(ruleStructure)) {
|
||||
setErrors((prev) => ({
|
||||
...prev,
|
||||
rules_json: 'Rules JSON is required',
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validateJsonLogic(rulesJson)) {
|
||||
setErrors((prev) => ({
|
||||
...prev,
|
||||
rules_json: 'Invalid JsonLogic format',
|
||||
rules_json: 'At least one valid condition is required',
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
|
@ -125,12 +104,14 @@ export function CreateAutomationRuleDialog({
|
|||
noteIv = encrypted.iv;
|
||||
}
|
||||
|
||||
const jsonLogic = buildJsonLogic(ruleStructure);
|
||||
|
||||
router.post(
|
||||
store().url,
|
||||
{
|
||||
title: title.trim(),
|
||||
priority: parseInt(priority, 10),
|
||||
rules_json: rulesJson.trim(),
|
||||
rules_json: JSON.stringify(jsonLogic),
|
||||
action_category_id: categoryId
|
||||
? parseInt(categoryId, 10)
|
||||
: null,
|
||||
|
|
@ -142,7 +123,10 @@ export function CreateAutomationRuleDialog({
|
|||
setOpen(false);
|
||||
setTitle('');
|
||||
setPriority('0');
|
||||
setRulesJson('');
|
||||
setRuleStructure({
|
||||
groups: [createEmptyGroup()],
|
||||
groupOperator: 'and',
|
||||
});
|
||||
setCategoryId('');
|
||||
setNote('');
|
||||
setErrors({});
|
||||
|
|
@ -213,74 +197,11 @@ export function CreateAutomationRuleDialog({
|
|||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rules_json">Rules (JsonLogic)</Label>
|
||||
<Textarea
|
||||
id="rules_json"
|
||||
value={rulesJson}
|
||||
onChange={(e) => setRulesJson(e.target.value)}
|
||||
placeholder='{"in": ["grocery", {"var": "description"}]}'
|
||||
rows={4}
|
||||
className="font-mono text-sm"
|
||||
required
|
||||
/>
|
||||
{errors.rules_json && (
|
||||
<p className="text-sm text-red-500">
|
||||
{errors.rules_json}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Collapsible open={helpOpen} onOpenChange={setHelpOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex w-full items-center justify-between"
|
||||
>
|
||||
<span>Available Fields & Examples</span>
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 transition-transform ${helpOpen ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-2 rounded-md border p-3 text-sm">
|
||||
<div>
|
||||
<strong>Available fields:</strong>
|
||||
<ul className="mt-1 ml-4 list-disc">
|
||||
<li>
|
||||
description (string, case-insensitive)
|
||||
</li>
|
||||
<li>
|
||||
notes (string or null, case-insensitive)
|
||||
</li>
|
||||
<li>amount (number)</li>
|
||||
<li>transaction_date (string)</li>
|
||||
<li>bank_name (string)</li>
|
||||
<li>account_name (string)</li>
|
||||
<li>category (string or null)</li>
|
||||
</ul>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Note: Use any case for description and notes
|
||||
- matching is automatic!
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Example rules:</strong>
|
||||
<pre className="mt-1 overflow-x-auto rounded bg-muted p-2 text-xs">
|
||||
{`{"in": ["grocery", {"var": "description"}]}
|
||||
{"in": ["M3 SPORT", {"var": "description"}]}
|
||||
{"in": ["important", {"var": "notes"}]}
|
||||
{"and": [
|
||||
{">": [{"var": "amount"}, 100]},
|
||||
{"==": [{"var": "bank_name"}, "Chase"]}
|
||||
]}
|
||||
{"==": [{"var": "category"}, null]}`}
|
||||
</pre>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
<RuleBuilder
|
||||
value={ruleStructure}
|
||||
onChange={setRuleStructure}
|
||||
error={errors.rules_json}
|
||||
/>
|
||||
|
||||
<div className="space-y-4 rounded-md border p-4">
|
||||
<h4 className="font-medium">Actions</h4>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,6 @@
|
|||
import { update } from '@/actions/App/Http/Controllers/Settings/AutomationRuleController';
|
||||
import { RuleBuilder } from '@/components/automation-rules/rule-builder';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -24,11 +20,17 @@ import {
|
|||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { decrypt, encrypt, importKey } from '@/lib/crypto';
|
||||
import { getStoredKey } from '@/lib/key-storage';
|
||||
import {
|
||||
buildJsonLogic,
|
||||
createEmptyGroup,
|
||||
isValidRuleStructure,
|
||||
parseJsonLogic,
|
||||
type RuleStructure,
|
||||
} from '@/lib/rule-builder-utils';
|
||||
import { categorySyncService } from '@/services/category-sync';
|
||||
import type { AutomationRule } from '@/types/automation-rule';
|
||||
import type { Category } from '@/types/category';
|
||||
import { router } from '@inertiajs/react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface EditAutomationRuleDialogProps {
|
||||
|
|
@ -47,12 +49,14 @@ export function EditAutomationRuleDialog({
|
|||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [title, setTitle] = useState('');
|
||||
const [priority, setPriority] = useState('0');
|
||||
const [rulesJson, setRulesJson] = useState('');
|
||||
const [ruleStructure, setRuleStructure] = useState<RuleStructure>({
|
||||
groups: [createEmptyGroup()],
|
||||
groupOperator: 'and',
|
||||
});
|
||||
const [categoryId, setCategoryId] = useState<string>('');
|
||||
const [note, setNote] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const loadCategories = async () => {
|
||||
|
|
@ -66,7 +70,7 @@ export function EditAutomationRuleDialog({
|
|||
if (rule && open) {
|
||||
setTitle(rule.title);
|
||||
setPriority(String(rule.priority));
|
||||
setRulesJson(JSON.stringify(rule.rules_json, null, 2));
|
||||
setRuleStructure(parseJsonLogic(rule.rules_json));
|
||||
setCategoryId(
|
||||
rule.action_category_id ? String(rule.action_category_id) : '',
|
||||
);
|
||||
|
|
@ -96,22 +100,6 @@ export function EditAutomationRuleDialog({
|
|||
}
|
||||
}, [rule, open]);
|
||||
|
||||
const validateJsonLogic = (json: string): boolean => {
|
||||
try {
|
||||
const parsed = JSON.parse(json);
|
||||
if (
|
||||
typeof parsed !== 'object' ||
|
||||
parsed === null ||
|
||||
Array.isArray(parsed)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return Object.keys(parsed).length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setErrors({});
|
||||
|
|
@ -121,18 +109,10 @@ export function EditAutomationRuleDialog({
|
|||
return;
|
||||
}
|
||||
|
||||
if (!rulesJson.trim()) {
|
||||
if (!isValidRuleStructure(ruleStructure)) {
|
||||
setErrors((prev) => ({
|
||||
...prev,
|
||||
rules_json: 'Rules JSON is required',
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validateJsonLogic(rulesJson)) {
|
||||
setErrors((prev) => ({
|
||||
...prev,
|
||||
rules_json: 'Invalid JsonLogic format',
|
||||
rules_json: 'At least one valid condition is required',
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
|
@ -162,12 +142,14 @@ export function EditAutomationRuleDialog({
|
|||
noteIv = encrypted.iv;
|
||||
}
|
||||
|
||||
const jsonLogic = buildJsonLogic(ruleStructure);
|
||||
|
||||
router.patch(
|
||||
update(rule.id).url,
|
||||
{
|
||||
title: title.trim(),
|
||||
priority: parseInt(priority, 10),
|
||||
rules_json: rulesJson.trim(),
|
||||
rules_json: JSON.stringify(jsonLogic),
|
||||
action_category_id: categoryId
|
||||
? parseInt(categoryId, 10)
|
||||
: null,
|
||||
|
|
@ -242,74 +224,11 @@ export function EditAutomationRuleDialog({
|
|||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rules_json">Rules (JsonLogic)</Label>
|
||||
<Textarea
|
||||
id="rules_json"
|
||||
value={rulesJson}
|
||||
onChange={(e) => setRulesJson(e.target.value)}
|
||||
placeholder='{"in": ["grocery", {"var": "description"}]}'
|
||||
rows={4}
|
||||
className="font-mono text-sm"
|
||||
required
|
||||
/>
|
||||
{errors.rules_json && (
|
||||
<p className="text-sm text-red-500">
|
||||
{errors.rules_json}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Collapsible open={helpOpen} onOpenChange={setHelpOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex w-full items-center justify-between"
|
||||
>
|
||||
<span>Available Fields & Examples</span>
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 transition-transform ${helpOpen ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-2 rounded-md border p-3 text-sm">
|
||||
<div>
|
||||
<strong>Available fields:</strong>
|
||||
<ul className="mt-1 ml-4 list-disc">
|
||||
<li>
|
||||
description (string, case-insensitive)
|
||||
</li>
|
||||
<li>
|
||||
notes (string or null, case-insensitive)
|
||||
</li>
|
||||
<li>amount (number)</li>
|
||||
<li>transaction_date (string)</li>
|
||||
<li>bank_name (string)</li>
|
||||
<li>account_name (string)</li>
|
||||
<li>category (string or null)</li>
|
||||
</ul>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Note: Use any case for description and notes
|
||||
- matching is automatic!
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Example rules:</strong>
|
||||
<pre className="mt-1 overflow-x-auto rounded bg-muted p-2 text-xs">
|
||||
{`{"in": ["grocery", {"var": "description"}]}
|
||||
{"in": ["M3 SPORT", {"var": "description"}]}
|
||||
{"in": ["important", {"var": "notes"}]}
|
||||
{"and": [
|
||||
{">": [{"var": "amount"}, 100]},
|
||||
{"==": [{"var": "bank_name"}, "Chase"]}
|
||||
]}
|
||||
{"==": [{"var": "category"}, null]}`}
|
||||
</pre>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
<RuleBuilder
|
||||
value={ruleStructure}
|
||||
onChange={setRuleStructure}
|
||||
error={errors.rules_json}
|
||||
/>
|
||||
|
||||
<div className="space-y-4 rounded-md border p-4">
|
||||
<h4 className="font-medium">Actions</h4>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,331 @@
|
|||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
type Condition,
|
||||
type ConditionGroup,
|
||||
createEmptyCondition,
|
||||
createEmptyGroup,
|
||||
FIELD_CONFIG,
|
||||
OPERATOR_LABELS,
|
||||
type RuleStructure,
|
||||
} from '@/lib/rule-builder-utils';
|
||||
import { ChevronDown, Plus, Trash2, X } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface RuleBuilderProps {
|
||||
value: RuleStructure;
|
||||
onChange: (value: RuleStructure) => void;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function RuleBuilder({ value, onChange, error }: RuleBuilderProps) {
|
||||
const [structure, setStructure] = useState<RuleStructure>(value);
|
||||
|
||||
useEffect(() => {
|
||||
setStructure(value);
|
||||
}, [value]);
|
||||
|
||||
const handleStructureChange = (newStructure: RuleStructure) => {
|
||||
setStructure(newStructure);
|
||||
onChange(newStructure);
|
||||
};
|
||||
|
||||
const addGroup = () => {
|
||||
handleStructureChange({
|
||||
...structure,
|
||||
groups: [...structure.groups, createEmptyGroup()],
|
||||
});
|
||||
};
|
||||
|
||||
const removeGroup = (groupId: string) => {
|
||||
if (structure.groups.length === 1) {
|
||||
return;
|
||||
}
|
||||
handleStructureChange({
|
||||
...structure,
|
||||
groups: structure.groups.filter((g) => g.id !== groupId),
|
||||
});
|
||||
};
|
||||
|
||||
const updateGroup = (groupId: string, updatedGroup: ConditionGroup) => {
|
||||
handleStructureChange({
|
||||
...structure,
|
||||
groups: structure.groups.map((g) =>
|
||||
g.id === groupId ? updatedGroup : g,
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const toggleGroupOperator = () => {
|
||||
handleStructureChange({
|
||||
...structure,
|
||||
groupOperator: structure.groupOperator === 'and' ? 'or' : 'and',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Conditions</Label>
|
||||
{structure.groups.length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={toggleGroupOperator}
|
||||
className='py-4 px-1.5'
|
||||
>
|
||||
Groups joined by:{' '}
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
{structure.groupOperator.toUpperCase()}
|
||||
<ChevronDown className="inline-block h-3 w-3 -mr-1" />
|
||||
</Badge>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{structure.groups.map((group, groupIndex) => (
|
||||
<div key={group.id}>
|
||||
<Card className="p-4 gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{group.conditions.length > 1 && (<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className='py-4 px-1.5'
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
updateGroup(group.id, {
|
||||
...group,
|
||||
operator:
|
||||
group.operator === 'and'
|
||||
? 'or'
|
||||
: 'and',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span className='text-sm'>Conditions joined by:{' '}</span>
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
{group.operator.toUpperCase()}
|
||||
<ChevronDown className="inline-block h-3 w-3 -mr-1" />
|
||||
</Badge>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{structure.groups.length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeGroup(group.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{group.conditions.map(
|
||||
(condition, conditionIndex) => (
|
||||
<div key={condition.id}>
|
||||
<ConditionRow
|
||||
condition={condition}
|
||||
onChange={(updatedCondition) => {
|
||||
updateGroup(group.id, {
|
||||
...group,
|
||||
conditions:
|
||||
group.conditions.map(
|
||||
(c) =>
|
||||
c.id ===
|
||||
condition.id
|
||||
? updatedCondition
|
||||
: c,
|
||||
),
|
||||
});
|
||||
}}
|
||||
onRemove={() => {
|
||||
if (
|
||||
group.conditions
|
||||
.length === 1
|
||||
) {
|
||||
return;
|
||||
}
|
||||
updateGroup(group.id, {
|
||||
...group,
|
||||
conditions:
|
||||
group.conditions.filter(
|
||||
(c) =>
|
||||
c.id !==
|
||||
condition.id,
|
||||
),
|
||||
});
|
||||
}}
|
||||
canRemove={
|
||||
group.conditions.length > 1
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full mt-0"
|
||||
onClick={() => {
|
||||
updateGroup(group.id, {
|
||||
...group,
|
||||
conditions: [
|
||||
...group.conditions,
|
||||
createEmptyCondition(),
|
||||
],
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Condition
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={addGroup}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Group
|
||||
</Button>
|
||||
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ConditionRowProps {
|
||||
condition: Condition;
|
||||
onChange: (condition: Condition) => void;
|
||||
onRemove: () => void;
|
||||
canRemove: boolean;
|
||||
}
|
||||
|
||||
function ConditionRow({
|
||||
condition,
|
||||
onChange,
|
||||
onRemove,
|
||||
canRemove,
|
||||
}: ConditionRowProps) {
|
||||
const fieldConfig = FIELD_CONFIG[condition.field];
|
||||
const availableOperators = fieldConfig?.operators || [];
|
||||
|
||||
const handleFieldChange = (field: string) => {
|
||||
const newFieldConfig = FIELD_CONFIG[field];
|
||||
const newOperator = newFieldConfig.operators[0];
|
||||
onChange({
|
||||
...condition,
|
||||
field,
|
||||
operator: newOperator,
|
||||
value: '',
|
||||
});
|
||||
};
|
||||
|
||||
const handleOperatorChange = (operator: string) => {
|
||||
onChange({
|
||||
...condition,
|
||||
operator: operator as Condition['operator'],
|
||||
value:
|
||||
operator === 'is_empty' || operator === 'is_not_empty'
|
||||
? ''
|
||||
: condition.value,
|
||||
});
|
||||
};
|
||||
|
||||
const showValueInput =
|
||||
condition.operator !== 'is_empty' &&
|
||||
condition.operator !== 'is_not_empty';
|
||||
|
||||
const inputType =
|
||||
fieldConfig?.type === 'number'
|
||||
? 'number'
|
||||
: fieldConfig?.type === 'date'
|
||||
? 'date'
|
||||
: 'text';
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={condition.field} onValueChange={handleFieldChange}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(FIELD_CONFIG).map(([key, config]) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
{config.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={condition.operator}
|
||||
onValueChange={handleOperatorChange}
|
||||
>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableOperators.map((op) => (
|
||||
<SelectItem key={op} value={op}>
|
||||
{OPERATOR_LABELS[op]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{showValueInput && (
|
||||
<Input
|
||||
type={inputType}
|
||||
value={condition.value}
|
||||
onChange={(e) =>
|
||||
onChange({ ...condition, value: e.target.value })
|
||||
}
|
||||
placeholder="Value"
|
||||
className="flex-1"
|
||||
step={inputType === 'number' ? 'any' : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!showValueInput && <div className="flex-1" />}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onRemove}
|
||||
disabled={!canRemove}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,364 @@
|
|||
export type FieldType = 'string' | 'number' | 'date' | 'nullable';
|
||||
|
||||
export type Operator =
|
||||
| 'contains'
|
||||
| 'equals'
|
||||
| 'greater_than'
|
||||
| 'less_than'
|
||||
| 'is_empty'
|
||||
| 'is_not_empty';
|
||||
|
||||
export interface Condition {
|
||||
id: string;
|
||||
field: string;
|
||||
operator: Operator;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface ConditionGroup {
|
||||
id: string;
|
||||
operator: 'and' | 'or';
|
||||
conditions: Condition[];
|
||||
}
|
||||
|
||||
export interface RuleStructure {
|
||||
groups: ConditionGroup[];
|
||||
groupOperator: 'and' | 'or';
|
||||
}
|
||||
|
||||
export const FIELD_CONFIG: Record<
|
||||
string,
|
||||
{ label: string; type: FieldType; operators: Operator[] }
|
||||
> = {
|
||||
description: {
|
||||
label: 'Description',
|
||||
type: 'string',
|
||||
operators: ['contains', 'equals'],
|
||||
},
|
||||
notes: {
|
||||
label: 'Notes',
|
||||
type: 'nullable',
|
||||
operators: ['contains', 'equals', 'is_empty', 'is_not_empty'],
|
||||
},
|
||||
amount: {
|
||||
label: 'Amount',
|
||||
type: 'number',
|
||||
operators: ['equals', 'greater_than', 'less_than'],
|
||||
},
|
||||
transaction_date: {
|
||||
label: 'Transaction Date',
|
||||
type: 'date',
|
||||
operators: ['equals'],
|
||||
},
|
||||
bank_name: {
|
||||
label: 'Bank Name',
|
||||
type: 'string',
|
||||
operators: ['contains', 'equals'],
|
||||
},
|
||||
account_name: {
|
||||
label: 'Account Name',
|
||||
type: 'string',
|
||||
operators: ['contains', 'equals'],
|
||||
},
|
||||
category: {
|
||||
label: 'Category',
|
||||
type: 'nullable',
|
||||
operators: ['equals', 'is_empty', 'is_not_empty'],
|
||||
},
|
||||
};
|
||||
|
||||
export const OPERATOR_LABELS: Record<Operator, string> = {
|
||||
contains: 'contains',
|
||||
equals: 'equals',
|
||||
greater_than: 'greater than',
|
||||
less_than: 'less than',
|
||||
is_empty: 'is empty',
|
||||
is_not_empty: 'is not empty',
|
||||
};
|
||||
|
||||
function buildConditionJsonLogic(condition: Condition): Record<string, any> {
|
||||
const { field, operator, value } = condition;
|
||||
|
||||
switch (operator) {
|
||||
case 'contains':
|
||||
return { in: [value, { var: field }] };
|
||||
case 'equals':
|
||||
if (FIELD_CONFIG[field].type === 'number') {
|
||||
return { '==': [{ var: field }, parseFloat(value)] };
|
||||
}
|
||||
return { '==': [{ var: field }, value] };
|
||||
case 'greater_than':
|
||||
return { '>': [{ var: field }, parseFloat(value)] };
|
||||
case 'less_than':
|
||||
return { '<': [{ var: field }, parseFloat(value)] };
|
||||
case 'is_empty':
|
||||
return { '==': [{ var: field }, null] };
|
||||
case 'is_not_empty':
|
||||
return { '!=': [{ var: field }, null] };
|
||||
default:
|
||||
throw new Error(`Unknown operator: ${operator}`);
|
||||
}
|
||||
}
|
||||
|
||||
function buildGroupJsonLogic(group: ConditionGroup): Record<string, any> {
|
||||
if (group.conditions.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (group.conditions.length === 1) {
|
||||
return buildConditionJsonLogic(group.conditions[0]);
|
||||
}
|
||||
|
||||
const conditions = group.conditions.map(buildConditionJsonLogic);
|
||||
return { [group.operator]: conditions };
|
||||
}
|
||||
|
||||
export function buildJsonLogic(structure: RuleStructure): Record<string, any> {
|
||||
const validGroups = structure.groups.filter(
|
||||
(group) => group.conditions.length > 0,
|
||||
);
|
||||
|
||||
if (validGroups.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (validGroups.length === 1) {
|
||||
return buildGroupJsonLogic(validGroups[0]);
|
||||
}
|
||||
|
||||
const groupLogics = validGroups.map(buildGroupJsonLogic);
|
||||
return { [structure.groupOperator]: groupLogics };
|
||||
}
|
||||
|
||||
function parseConditionFromJsonLogic(
|
||||
jsonLogic: Record<string, any>,
|
||||
): Condition | null {
|
||||
const id = crypto.randomUUID();
|
||||
|
||||
if ('in' in jsonLogic) {
|
||||
const [value, varObj] = jsonLogic.in;
|
||||
if (varObj && typeof varObj === 'object' && 'var' in varObj) {
|
||||
return {
|
||||
id,
|
||||
field: varObj.var,
|
||||
operator: 'contains',
|
||||
value: String(value),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if ('==' in jsonLogic) {
|
||||
const [varObj, value] = jsonLogic['=='];
|
||||
if (varObj && typeof varObj === 'object' && 'var' in varObj) {
|
||||
if (value === null) {
|
||||
return {
|
||||
id,
|
||||
field: varObj.var,
|
||||
operator: 'is_empty',
|
||||
value: '',
|
||||
};
|
||||
}
|
||||
return {
|
||||
id,
|
||||
field: varObj.var,
|
||||
operator: 'equals',
|
||||
value: String(value),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if ('!=' in jsonLogic) {
|
||||
const [varObj, value] = jsonLogic['!='];
|
||||
if (
|
||||
varObj &&
|
||||
typeof varObj === 'object' &&
|
||||
'var' in varObj &&
|
||||
value === null
|
||||
) {
|
||||
return {
|
||||
id,
|
||||
field: varObj.var,
|
||||
operator: 'is_not_empty',
|
||||
value: '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if ('>' in jsonLogic) {
|
||||
const [varObj, value] = jsonLogic['>'];
|
||||
if (varObj && typeof varObj === 'object' && 'var' in varObj) {
|
||||
return {
|
||||
id,
|
||||
field: varObj.var,
|
||||
operator: 'greater_than',
|
||||
value: String(value),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if ('<' in jsonLogic) {
|
||||
const [varObj, value] = jsonLogic['<'];
|
||||
if (varObj && typeof varObj === 'object' && 'var' in varObj) {
|
||||
return {
|
||||
id,
|
||||
field: varObj.var,
|
||||
operator: 'less_than',
|
||||
value: String(value),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseJsonLogic(
|
||||
jsonLogic: Record<string, any>,
|
||||
): RuleStructure {
|
||||
const defaultStructure: RuleStructure = {
|
||||
groups: [
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
operator: 'and',
|
||||
conditions: [],
|
||||
},
|
||||
],
|
||||
groupOperator: 'and',
|
||||
};
|
||||
|
||||
if (!jsonLogic || Object.keys(jsonLogic).length === 0) {
|
||||
return defaultStructure;
|
||||
}
|
||||
|
||||
if ('and' in jsonLogic || 'or' in jsonLogic) {
|
||||
const operator = 'and' in jsonLogic ? 'and' : 'or';
|
||||
const items = jsonLogic[operator];
|
||||
|
||||
if (!Array.isArray(items)) {
|
||||
return defaultStructure;
|
||||
}
|
||||
|
||||
const hasNestedGroups = items.some(
|
||||
(item) =>
|
||||
typeof item === 'object' &&
|
||||
item !== null &&
|
||||
('and' in item || 'or' in item),
|
||||
);
|
||||
|
||||
if (hasNestedGroups) {
|
||||
const groups: ConditionGroup[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
if (
|
||||
typeof item === 'object' &&
|
||||
item !== null &&
|
||||
('and' in item || 'or' in item)
|
||||
) {
|
||||
const groupOp = 'and' in item ? 'and' : 'or';
|
||||
const groupItems = item[groupOp];
|
||||
|
||||
if (Array.isArray(groupItems)) {
|
||||
const conditions: Condition[] = [];
|
||||
for (const condItem of groupItems) {
|
||||
const parsed =
|
||||
parseConditionFromJsonLogic(condItem);
|
||||
if (parsed) {
|
||||
conditions.push(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
if (conditions.length > 0) {
|
||||
groups.push({
|
||||
id: crypto.randomUUID(),
|
||||
operator: groupOp,
|
||||
conditions,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const parsed = parseConditionFromJsonLogic(item);
|
||||
if (parsed) {
|
||||
groups.push({
|
||||
id: crypto.randomUUID(),
|
||||
operator: 'and',
|
||||
conditions: [parsed],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
groups: groups.length > 0 ? groups : defaultStructure.groups,
|
||||
groupOperator: operator,
|
||||
};
|
||||
} else {
|
||||
const conditions: Condition[] = [];
|
||||
for (const item of items) {
|
||||
const parsed = parseConditionFromJsonLogic(item);
|
||||
if (parsed) {
|
||||
conditions.push(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
groups: [
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
operator,
|
||||
conditions:
|
||||
conditions.length > 0
|
||||
? conditions
|
||||
: defaultStructure.groups[0].conditions,
|
||||
},
|
||||
],
|
||||
groupOperator: 'and',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const parsed = parseConditionFromJsonLogic(jsonLogic);
|
||||
if (parsed) {
|
||||
return {
|
||||
groups: [
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
operator: 'and',
|
||||
conditions: [parsed],
|
||||
},
|
||||
],
|
||||
groupOperator: 'and',
|
||||
};
|
||||
}
|
||||
|
||||
return defaultStructure;
|
||||
}
|
||||
|
||||
export function createEmptyCondition(): Condition {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
field: 'description',
|
||||
operator: 'contains',
|
||||
value: '',
|
||||
};
|
||||
}
|
||||
|
||||
export function createEmptyGroup(): ConditionGroup {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
operator: 'and',
|
||||
conditions: [createEmptyCondition()],
|
||||
};
|
||||
}
|
||||
|
||||
export function isValidRuleStructure(structure: RuleStructure): boolean {
|
||||
return structure.groups.some((group) =>
|
||||
group.conditions.some(
|
||||
(condition) =>
|
||||
condition.field &&
|
||||
condition.operator &&
|
||||
(condition.operator === 'is_empty' ||
|
||||
condition.operator === 'is_not_empty' ||
|
||||
condition.value.trim() !== ''),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -13,7 +13,7 @@ import {
|
|||
} from '@tanstack/react-table';
|
||||
import { useLiveQuery } from 'dexie-react-hooks';
|
||||
import { ArrowUpDown, MoreHorizontal } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { index as automationRulesIndex } from '@/actions/App/Http/Controllers/Settings/AutomationRuleController';
|
||||
import { CreateAutomationRuleDialog } from '@/components/automation-rules/create-automation-rule-dialog';
|
||||
|
|
@ -84,13 +84,11 @@ function AutomationRuleActions({ rule }: { rule: AutomationRule }) {
|
|||
rule={rule}
|
||||
open={editOpen}
|
||||
onOpenChange={setEditOpen}
|
||||
onSuccess={() => {}}
|
||||
/>
|
||||
<DeleteAutomationRuleDialog
|
||||
rule={rule}
|
||||
open={deleteOpen}
|
||||
onOpenChange={setDeleteOpen}
|
||||
onSuccess={() => {}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
@ -100,13 +98,17 @@ export default function AutomationRules() {
|
|||
const { isKeySet } = useEncryptionKey();
|
||||
const rawRules =
|
||||
useLiveQuery(() => db.automation_rules.toArray(), []) || [];
|
||||
const rules = rawRules.map((rule) => ({
|
||||
...rule,
|
||||
rules_json:
|
||||
typeof rule.rules_json === 'string'
|
||||
? JSON.parse(rule.rules_json)
|
||||
: rule.rules_json,
|
||||
}));
|
||||
const rules = useMemo(
|
||||
() =>
|
||||
rawRules.map((rule) => ({
|
||||
...rule,
|
||||
rules_json:
|
||||
typeof rule.rules_json === 'string'
|
||||
? JSON.parse(rule.rules_json)
|
||||
: rule.rules_json,
|
||||
})),
|
||||
[rawRules],
|
||||
);
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
|
||||
|
|
@ -187,12 +189,7 @@ export default function AutomationRules() {
|
|||
{
|
||||
id: 'actions',
|
||||
enableHiding: false,
|
||||
cell: ({ row }) => (
|
||||
<AutomationRuleActions
|
||||
rule={row.original}
|
||||
onSuccess={loadRules}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => <AutomationRuleActions rule={row.original} />,
|
||||
},
|
||||
];
|
||||
|
||||
|
|
@ -242,7 +239,6 @@ export default function AutomationRules() {
|
|||
/>
|
||||
<CreateAutomationRuleDialog
|
||||
disabled={!isKeySet}
|
||||
onSuccess={loadRules}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,249 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
|
||||
use function Pest\Laravel\actingAs;
|
||||
|
||||
beforeEach(function () {
|
||||
Notification::fake();
|
||||
});
|
||||
|
||||
it('can create an automation rule with visual builder', function () {
|
||||
$user = User::factory()->create();
|
||||
$category = Category::factory()->create(['user_id' => $user->id]);
|
||||
|
||||
actingAs($user);
|
||||
|
||||
$page = visit('/settings/automation-rules');
|
||||
|
||||
$page->assertSee('Automation Rules')
|
||||
->click('Create Rule')
|
||||
->waitFor('dialog')
|
||||
->fill('title', 'Test Rule')
|
||||
->fill('priority', '10')
|
||||
->assertSee('Conditions')
|
||||
->assertSee('Description')
|
||||
->fill('input[placeholder="Value"]', 'grocery')
|
||||
->click('Set Category')
|
||||
->click($category->name)
|
||||
->click('Create')
|
||||
->waitFor('Test Rule')
|
||||
->assertSee('Test Rule')
|
||||
->assertNoJavascriptErrors();
|
||||
|
||||
$this->assertDatabaseHas('automation_rules', [
|
||||
'user_id' => $user->id,
|
||||
'title' => 'Test Rule',
|
||||
'priority' => 10,
|
||||
]);
|
||||
});
|
||||
|
||||
it('can add multiple conditions to a group', function () {
|
||||
$user = User::factory()->create();
|
||||
$category = Category::factory()->create(['user_id' => $user->id]);
|
||||
|
||||
actingAs($user);
|
||||
|
||||
$page = visit('/settings/automation-rules');
|
||||
|
||||
$page->assertSee('Automation Rules')
|
||||
->click('Create Rule')
|
||||
->waitFor('dialog')
|
||||
->fill('title', 'Multi-Condition Rule')
|
||||
->fill('priority', '5')
|
||||
->click('Add Condition')
|
||||
->assertSee('AND')
|
||||
->click('Set Category')
|
||||
->click($category->name)
|
||||
->click('Create')
|
||||
->waitFor('Multi-Condition Rule')
|
||||
->assertSee('Multi-Condition Rule')
|
||||
->assertNoJavascriptErrors();
|
||||
|
||||
$this->assertDatabaseHas('automation_rules', [
|
||||
'user_id' => $user->id,
|
||||
'title' => 'Multi-Condition Rule',
|
||||
]);
|
||||
});
|
||||
|
||||
it('can add multiple groups', function () {
|
||||
$user = User::factory()->create();
|
||||
$category = Category::factory()->create(['user_id' => $user->id]);
|
||||
|
||||
actingAs($user);
|
||||
|
||||
$page = visit('/settings/automation-rules');
|
||||
|
||||
$page->assertSee('Automation Rules')
|
||||
->click('Create Rule')
|
||||
->waitFor('dialog')
|
||||
->fill('title', 'Multi-Group Rule')
|
||||
->fill('priority', '3')
|
||||
->click('Add Group')
|
||||
->assertSee('Group 1')
|
||||
->assertSee('Group 2')
|
||||
->click('Set Category')
|
||||
->click($category->name)
|
||||
->click('Create')
|
||||
->waitFor('Multi-Group Rule')
|
||||
->assertSee('Multi-Group Rule')
|
||||
->assertNoJavascriptErrors();
|
||||
|
||||
$this->assertDatabaseHas('automation_rules', [
|
||||
'user_id' => $user->id,
|
||||
'title' => 'Multi-Group Rule',
|
||||
]);
|
||||
});
|
||||
|
||||
it('can select different field types and operators', function () {
|
||||
$user = User::factory()->create();
|
||||
$category = Category::factory()->create(['user_id' => $user->id]);
|
||||
|
||||
actingAs($user);
|
||||
|
||||
$page = visit('/settings/automation-rules');
|
||||
|
||||
$page->assertSee('Automation Rules')
|
||||
->click('Create Rule')
|
||||
->waitFor('dialog')
|
||||
->fill('title', 'Amount Rule')
|
||||
->fill('priority', '1')
|
||||
->click('Description')
|
||||
->click('Amount')
|
||||
->assertSee('greater than')
|
||||
->click('contains')
|
||||
->click('greater than')
|
||||
->fill('input[type="number"]', '100')
|
||||
->click('Set Category')
|
||||
->click($category->name)
|
||||
->click('Create')
|
||||
->waitFor('Amount Rule')
|
||||
->assertSee('Amount Rule')
|
||||
->assertNoJavascriptErrors();
|
||||
|
||||
$this->assertDatabaseHas('automation_rules', [
|
||||
'user_id' => $user->id,
|
||||
'title' => 'Amount Rule',
|
||||
]);
|
||||
});
|
||||
|
||||
it('can edit an existing rule with visual builder', function () {
|
||||
$user = User::factory()->create();
|
||||
$category = Category::factory()->create(['user_id' => $user->id]);
|
||||
|
||||
$rule = $user->automationRules()->create([
|
||||
'title' => 'Original Rule',
|
||||
'priority' => 5,
|
||||
'rules_json' => ['in' => ['grocery', ['var' => 'description']]],
|
||||
'action_category_id' => $category->id,
|
||||
]);
|
||||
|
||||
actingAs($user);
|
||||
|
||||
$page = visit('/settings/automation-rules');
|
||||
|
||||
$page->assertSee('Original Rule')
|
||||
->click('button[aria-label="Actions"]')
|
||||
->click('Edit')
|
||||
->waitFor('dialog')
|
||||
->assertSee('Edit Automation Rule')
|
||||
->assertSee('grocery')
|
||||
->fill('title', 'Updated Rule')
|
||||
->click('Save Changes')
|
||||
->waitFor('Updated Rule')
|
||||
->assertSee('Updated Rule')
|
||||
->assertNoJavascriptErrors();
|
||||
|
||||
$this->assertDatabaseHas('automation_rules', [
|
||||
'id' => $rule->id,
|
||||
'title' => 'Updated Rule',
|
||||
]);
|
||||
});
|
||||
|
||||
it('validates that at least one condition is required', function () {
|
||||
$user = User::factory()->create();
|
||||
$category = Category::factory()->create(['user_id' => $user->id]);
|
||||
|
||||
actingAs($user);
|
||||
|
||||
$page = visit('/settings/automation-rules');
|
||||
|
||||
$page->assertSee('Automation Rules')
|
||||
->click('Create Rule')
|
||||
->waitFor('dialog')
|
||||
->fill('title', 'Invalid Rule')
|
||||
->fill('priority', '1')
|
||||
->click('Set Category')
|
||||
->click($category->name)
|
||||
->click('Create')
|
||||
->assertSee('At least one valid condition is required')
|
||||
->assertNoJavascriptErrors();
|
||||
|
||||
$this->assertDatabaseMissing('automation_rules', [
|
||||
'user_id' => $user->id,
|
||||
'title' => 'Invalid Rule',
|
||||
]);
|
||||
});
|
||||
|
||||
it('can toggle group operators between AND and OR', function () {
|
||||
$user = User::factory()->create();
|
||||
$category = Category::factory()->create(['user_id' => $user->id]);
|
||||
|
||||
actingAs($user);
|
||||
|
||||
$page = visit('/settings/automation-rules');
|
||||
|
||||
$page->assertSee('Automation Rules')
|
||||
->click('Create Rule')
|
||||
->waitFor('dialog')
|
||||
->fill('title', 'OR Rule')
|
||||
->fill('priority', '1')
|
||||
->click('Add Condition')
|
||||
->assertSee('AND')
|
||||
->click('Conditions joined by:')
|
||||
->assertSee('OR')
|
||||
->fill('input[placeholder="Value"]', 'test')
|
||||
->click('Set Category')
|
||||
->click($category->name)
|
||||
->click('Create')
|
||||
->waitFor('OR Rule')
|
||||
->assertSee('OR Rule')
|
||||
->assertNoJavascriptErrors();
|
||||
|
||||
$this->assertDatabaseHas('automation_rules', [
|
||||
'user_id' => $user->id,
|
||||
'title' => 'OR Rule',
|
||||
]);
|
||||
});
|
||||
|
||||
it('can use is empty operator for nullable fields', function () {
|
||||
$user = User::factory()->create();
|
||||
$category = Category::factory()->create(['user_id' => $user->id]);
|
||||
|
||||
actingAs($user);
|
||||
|
||||
$page = visit('/settings/automation-rules');
|
||||
|
||||
$page->assertSee('Automation Rules')
|
||||
->click('Create Rule')
|
||||
->waitFor('dialog')
|
||||
->fill('title', 'Empty Category Rule')
|
||||
->fill('priority', '1')
|
||||
->click('Description')
|
||||
->click('Category')
|
||||
->click('contains')
|
||||
->click('is empty')
|
||||
->click('Set Category')
|
||||
->click($category->name)
|
||||
->click('Create')
|
||||
->waitFor('Empty Category Rule')
|
||||
->assertSee('Empty Category Rule')
|
||||
->assertNoJavascriptErrors();
|
||||
|
||||
$this->assertDatabaseHas('automation_rules', [
|
||||
'user_id' => $user->id,
|
||||
'title' => 'Empty Category Rule',
|
||||
]);
|
||||
});
|
||||
Loading…
Reference in New Issue