From f33a1e05ad98c46eb55aa1e9a92be5e733e10101 Mon Sep 17 00:00:00 2001 From: SonPhung Date: Wed, 15 Jul 2026 01:15:14 +0700 Subject: [PATCH] feat: implement ThresholdsBuilder and InstructionsBuilder components for dynamic form logic --- src/components/InstructionsBuilder.tsx | 166 ++++++++++++++------- src/components/ThresholdsBuilder.tsx | 197 +++++++++++++------------ 2 files changed, 213 insertions(+), 150 deletions(-) diff --git a/src/components/InstructionsBuilder.tsx b/src/components/InstructionsBuilder.tsx index ada2fd2..e6e1105 100644 --- a/src/components/InstructionsBuilder.tsx +++ b/src/components/InstructionsBuilder.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'; import { useFieldArray, useWatch, Controller, type Control, type UseFormRegister } from 'react-hook-form'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; -import { Plus, Trash2 } from 'lucide-react'; +import { Plus, Trash2, Target, Settings2, Zap, ArrowRight } from 'lucide-react'; import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/components/ui/accordion'; import { Select, @@ -12,6 +12,7 @@ import { SelectValue, } from '@/components/ui/select'; import { ThresholdsBuilder } from './ThresholdsBuilder'; +import { CreatableCombobox } from '@/components/ui/creatable-combobox'; const getAvailableOperations = (type?: string) => { switch (type) { @@ -36,36 +37,49 @@ interface InstructionsBuilderProps { } export function InstructionsBuilder({ control, register }: InstructionsBuilderProps) { + const currentEventName = useWatch({ control, name: 'name' }); const { fields, append, remove } = useFieldArray({ control, name: 'instructions', }); const [schema, setSchema] = useState>({}); + const [events, setEvents] = useState([]); useEffect(() => { fetch('/api/v1/attributes/schema') .then(res => res.json()) .then(data => setSchema(data)) .catch(console.error); + + fetch('/api/v1/events') + .then(res => res.json()) + .then(data => { + if (Array.isArray(data)) { + setEvents(Array.from(new Set(data.map((e: any) => e.name)))); + } + }) + .catch(console.error); }, []); + const eventOptions = events.filter(e => e !== currentEventName); + return (
-

Counter Instructions

+

Counter Actions

{fields.length === 0 && ( -

No instructions defined.

+

No actions defined.

)} @@ -78,6 +92,7 @@ export function InstructionsBuilder({ control, register }: InstructionsBuilderPr register={register} remove={remove} schema={schema} + eventOptions={eventOptions} /> ))} @@ -85,7 +100,7 @@ export function InstructionsBuilder({ control, register }: InstructionsBuilderPr ); } -function InstructionRow({ fieldId, index, control, register, remove, schema }: any) { +function InstructionRow({ fieldId, index, control, register, remove, schema, eventOptions }: any) { const instructionName = useWatch({ control, name: `instructions.${index}.name` }); const currentEntity = useWatch({ control, name: `instructions.${index}.entityType` }); const currentAttributeCode = useWatch({ control, name: `instructions.${index}.attributeCode` }); @@ -106,16 +121,28 @@ function InstructionRow({ fieldId, index, control, register, remove, schema }: a const availableOperations = getAvailableOperations(attributeType); return ( - +
- - {`▼ Instruction ${index + 1}: ${instructionName || 'New Instruction'}`} + +
+ {`Action ${index + 1}: ${instructionName || 'New Action'}`} + {currentEntity && currentAttributeCode && ( + + {currentEntity} {currentAttributeCode} + + )} + {currentOperation && ( + + {availableOperations.find(op => op.value === currentOperation)?.label || currentOperation} + + )} +
- -
- Instruction Name: + +
+ Action Name:
-
-
+
+
+ +

1. Target Definition

+
+
- + ({ ...acc, [e]: e.charAt(0).toUpperCase() + e.slice(1).toLowerCase() }), {})} > - + @@ -165,7 +196,7 @@ function InstructionRow({ fieldId, index, control, register, remove, schema }: a />
- + ({ ...acc, [attr.code]: attr.code }), {})} > - + @@ -192,7 +223,7 @@ function InstructionRow({ fieldId, index, control, register, remove, schema }: a />
- + ({ ...acc, [op.value]: op.label }), {})} > - + @@ -217,28 +248,48 @@ function InstructionRow({ fieldId, index, control, register, remove, schema }: a
- + + {currentOperation !== 'SET_TRUE_ONCE' && ( - +
+
+ +

3. Thresholds Config

+
+
+ +
+
)} ); } -function DynamicFields({ control, register, index }: { control: Control, register: UseFormRegister, index: number }) { +function DynamicFields({ control, register, index, eventOptions }: { control: Control, register: UseFormRegister, index: number, eventOptions: string[] }) { const operation = useWatch({ control, name: `instructions.${index}.operation` }); - if (operation === 'INCREMENT' || operation === 'DECREMENT') { + const Wrapper = ({ title, children }: { title: string, children: React.ReactNode }) => ( +
+
+ +

2. {title}

+
+
+ {children} +
+
+ ); + + if (operation === 'INCREMENT' || operation === 'DECREMENT' || operation === 'STREAK_INCREMENT') { return ( -
-

Counter Configuration

-
+ +
- + , re YEARLY: 'Yearly' }} > - + @@ -268,67 +319,72 @@ function DynamicFields({ control, register, index }: { control: Control, re )} />
-
- +
+
-
+
); } if (operation === 'APPEND_UNIQUE') { return ( -
-

Value Mapping

-
+ +
- +
-
- +
+
-
+
); } if (operation === 'SET_TRUE_ONCE') { return ( -
-

One-Time Transition Trigger

+
- - Trigger Event (on False → True) + ( + + )} />
-
+ ); } if (operation === 'COMPUTE_GAP' || operation === 'CHECK_AND_UPDATE_TIMER') { return ( -
- + + , re MINUTES: 'Minutes' }} > - + @@ -353,7 +409,7 @@ function DynamicFields({ control, register, index }: { control: Control, re )} /> -
+ ); } diff --git a/src/components/ThresholdsBuilder.tsx b/src/components/ThresholdsBuilder.tsx index ce4a2aa..a979fba 100644 --- a/src/components/ThresholdsBuilder.tsx +++ b/src/components/ThresholdsBuilder.tsx @@ -14,6 +14,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; +import { Settings2 } from 'lucide-react'; const getAvailableConditions = (type?: string) => { switch (type) { @@ -65,138 +66,144 @@ export function ThresholdsBuilder({ control, register, instructionIndex, attribu }, []); return ( -
-
-

Thresholds Config

+
+
-
+
{fields.map((field, index) => ( -
-
-
- Condition: +
+ {/* Condition Group */} +
+ IF Value + { + const currentValue = availableConditions.find(c => c.value === field.value) ? field.value : defaultOperator; + return ( + + )}} + /> + {attributeType === 'BOOLEAN' ? ( { - const currentValue = availableConditions.find(c => c.value === field.value) ? field.value : defaultOperator; - return ( + name={`instructions.${instructionIndex}.thresholds.${index}.value` as const} + rules={{ required: true }} + render={({ field }) => ( - )}} + )} /> - {attributeType === 'BOOLEAN' ? ( - ( - - )} - /> - ) : ( - - )} -
- -
- Trigger: Fire Event -
- ( + ) : ( + + )} +
+ + {/* Trigger Group */} +
+ THEN Trigger +
+ ( +
- )} - /> -
+
+ )} + />
+
+ {/* Reset Policy Group */} + {showResetPolicy && ( +
+ WITH Reset + ( + + )} + /> +
+ )} + + {/* Actions */} +
- - {showResetPolicy && ( -
- Reset Policy: - ( - - -
- - -
-
- - -
-
- )} - /> -
- )}
))}