UI for Automated rules

This commit is contained in:
Víctor Falcón 2025-11-11 16:41:33 +00:00
parent b2e041c420
commit b866e6152e
6 changed files with 1005 additions and 225 deletions

View File

@ -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>

View File

@ -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>

View File

@ -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>
);
}

View File

@ -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() !== ''),
),
);
}

View File

@ -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>

View File

@ -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',
]);
});