feat: implement ThresholdsBuilder and InstructionsBuilder components for dynamic form logic

This commit is contained in:
SonPhung
2026-07-15 01:15:14 +07:00
parent 19087ec1af
commit f33a1e05ad
2 changed files with 213 additions and 150 deletions

View File

@@ -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<Record<string, { code: string; type: string }[]>>({});
const [events, setEvents] = useState<string[]>([]);
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 (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium">Counter Instructions</h3>
<h3 className="text-lg font-medium">Counter Actions</h3>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => append({ name: '', entityType: '', attributeCode: '', operation: 'INCREMENT', thresholds: [] })}
>
<Plus className="w-4 h-4 mr-2" /> Add Instruction
<Plus className="w-4 h-4 mr-2" /> Add Action
</Button>
</div>
{fields.length === 0 && (
<p className="text-sm text-gray-500">No instructions defined.</p>
<p className="text-sm text-gray-500">No actions defined.</p>
)}
<Accordion type="multiple" className="w-full space-y-4">
@@ -78,6 +92,7 @@ export function InstructionsBuilder({ control, register }: InstructionsBuilderPr
register={register}
remove={remove}
schema={schema}
eventOptions={eventOptions}
/>
))}
</Accordion>
@@ -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 (
<AccordionItem value={fieldId} className="bg-gray-50 border rounded-md px-4">
<AccordionItem value={fieldId} className="bg-white border rounded-xl shadow-sm px-4 overflow-hidden">
<div className="flex items-center justify-between w-full">
<AccordionTrigger className="hover:no-underline flex-1 py-4 font-semibold text-gray-700">
{`▼ Instruction ${index + 1}: ${instructionName || 'New Instruction'}`}
<AccordionTrigger className="hover:no-underline flex-1 py-4 font-semibold text-gray-800">
<div className="flex flex-wrap items-center gap-3 text-left">
<span>{`Action ${index + 1}: ${instructionName || 'New Action'}`}</span>
{currentEntity && currentAttributeCode && (
<span className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded-md text-[11px] font-medium border border-blue-100 flex items-center gap-1 font-normal">
{currentEntity} <ArrowRight className="w-3 h-3" /> {currentAttributeCode}
</span>
)}
{currentOperation && (
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 rounded-md text-[11px] font-medium border border-slate-200 font-normal">
{availableOperations.find(op => op.value === currentOperation)?.label || currentOperation}
</span>
)}
</div>
</AccordionTrigger>
<Button
type="button"
variant="ghost"
size="sm"
className="text-gray-500 hover:text-red-600 hover:bg-red-50 ml-4"
className="text-gray-500 hover:text-red-600 hover:bg-red-50 ml-4 shrink-0"
onClick={(e) => {
e.preventDefault();
remove(index);
@@ -124,20 +151,24 @@ function InstructionRow({ fieldId, index, control, register, remove, schema }: a
<Trash2 className="w-4 h-4 mr-1" /> Delete
</Button>
</div>
<AccordionContent className="pb-4 pt-2">
<div className="flex items-center gap-3 mb-4">
<span className="text-sm font-medium text-gray-700 min-w-[120px]">Instruction Name:</span>
<AccordionContent className="pb-5 pt-1">
<div className="flex items-center gap-3 mb-5 bg-slate-50/50 p-3 rounded-lg border border-slate-100">
<span className="text-sm font-medium text-slate-700 min-w-[100px]">Action Name:</span>
<Input
placeholder="e.g. Counter Lượt Xem Tuần"
className="bg-white h-9 flex-1"
className="bg-white h-9 flex-1 shadow-sm"
{...register(`instructions.${index}.name` as const, { required: true })}
/>
</div>
<div className="flex items-start justify-between gap-4 mb-4">
<div className="grid grid-cols-3 gap-3 flex-1">
<div className="mb-6 rounded-xl border border-slate-200 bg-slate-50/30 overflow-hidden">
<div className="bg-slate-100/50 px-4 py-2.5 border-b border-slate-200 flex items-center gap-2">
<Target className="w-4 h-4 text-blue-600" />
<h4 className="text-sm font-semibold text-slate-700">1. Target Definition</h4>
</div>
<div className="p-4 grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Target Entity</label>
<label className="block text-xs font-medium text-slate-600 mb-1.5">Target Entity</label>
<Controller
control={control}
name={`instructions.${index}.entityType` as const}
@@ -150,7 +181,7 @@ function InstructionRow({ fieldId, index, control, register, remove, schema }: a
value={normalizedValue || ""}
items={entities.reduce((acc, e) => ({ ...acc, [e]: e.charAt(0).toUpperCase() + e.slice(1).toLowerCase() }), {})}
>
<SelectTrigger className="bg-white h-9">
<SelectTrigger className="bg-white h-9 shadow-sm">
<SelectValue placeholder="Select Entity" />
</SelectTrigger>
<SelectContent>
@@ -165,7 +196,7 @@ function InstructionRow({ fieldId, index, control, register, remove, schema }: a
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Target Attribute</label>
<label className="block text-xs font-medium text-slate-600 mb-1.5">Target Attribute</label>
<Controller
control={control}
name={`instructions.${index}.attributeCode` as const}
@@ -177,7 +208,7 @@ function InstructionRow({ fieldId, index, control, register, remove, schema }: a
disabled={!currentEntity}
items={availableAttributes.reduce((acc: any, attr: any) => ({ ...acc, [attr.code]: attr.code }), {})}
>
<SelectTrigger className="bg-white h-9">
<SelectTrigger className="bg-white h-9 shadow-sm">
<SelectValue placeholder="Select Attribute" />
</SelectTrigger>
<SelectContent>
@@ -192,7 +223,7 @@ function InstructionRow({ fieldId, index, control, register, remove, schema }: a
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Operation</label>
<label className="block text-xs font-medium text-slate-600 mb-1.5">Operation</label>
<Controller
control={control}
name={`instructions.${index}.operation` as const}
@@ -202,7 +233,7 @@ function InstructionRow({ fieldId, index, control, register, remove, schema }: a
value={field.value || ""}
items={availableOperations.reduce((acc, op) => ({ ...acc, [op.value]: op.label }), {})}
>
<SelectTrigger className="bg-white h-9">
<SelectTrigger className="bg-white h-9 shadow-sm">
<SelectValue placeholder="Select Operation" />
</SelectTrigger>
<SelectContent>
@@ -217,28 +248,48 @@ function InstructionRow({ fieldId, index, control, register, remove, schema }: a
</div>
</div>
<DynamicFields control={control} register={register} index={index} />
<DynamicFields control={control} register={register} index={index} eventOptions={eventOptions} />
{currentOperation !== 'SET_TRUE_ONCE' && (
<div className="mt-6 rounded-xl border border-dashed border-slate-300 bg-white overflow-hidden shadow-sm">
<div className="bg-slate-50/80 px-4 py-2.5 border-b border-dashed border-slate-300 flex items-center gap-2">
<Zap className="w-4 h-4 text-amber-500" />
<h4 className="text-sm font-semibold text-slate-700">3. Thresholds Config</h4>
</div>
<div className="p-4">
<ThresholdsBuilder control={control} register={register} instructionIndex={index} attributeType={attributeType} operation={currentOperation} />
</div>
</div>
)}
</AccordionContent>
</AccordionItem>
);
}
function DynamicFields({ control, register, index }: { control: Control<any>, register: UseFormRegister<any>, index: number }) {
function DynamicFields({ control, register, index, eventOptions }: { control: Control<any>, register: UseFormRegister<any>, 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 }) => (
<div className="mb-6 rounded-xl border border-slate-200 bg-white overflow-hidden shadow-sm">
<div className="bg-slate-50/80 px-4 py-2.5 border-b border-slate-200 flex items-center gap-2">
<Settings2 className="w-4 h-4 text-slate-500" />
<h4 className="text-sm font-semibold text-slate-700">2. {title}</h4>
</div>
<div className="p-4">
{children}
</div>
</div>
);
if (operation === 'INCREMENT' || operation === 'DECREMENT' || operation === 'STREAK_INCREMENT') {
return (
<div className="mb-4 bg-white p-3 rounded border">
<h4 className="text-sm font-medium text-gray-700 mb-3">Counter Configuration</h4>
<div className="flex gap-4">
<Wrapper title="Counter Configuration">
<div className="flex flex-col md:flex-row gap-4">
<div className="flex-1">
<label className="block text-xs text-gray-500 mb-1">Period Type (Lazy Reset)</label>
<label className="block text-xs font-medium text-slate-600 mb-1.5">Period Type (Lazy Reset)</label>
<Controller
control={control}
name={`instructions.${index}.periodType` as const}
@@ -254,7 +305,7 @@ function DynamicFields({ control, register, index }: { control: Control<any>, re
YEARLY: 'Yearly'
}}
>
<SelectTrigger className="bg-white h-9">
<SelectTrigger className="bg-white h-9 shadow-sm">
<SelectValue placeholder="Select Period" />
</SelectTrigger>
<SelectContent>
@@ -268,67 +319,72 @@ function DynamicFields({ control, register, index }: { control: Control<any>, re
)}
/>
</div>
<div className="w-48">
<label className="block text-xs text-gray-500 mb-1">Step</label>
<div className="w-full md:w-48">
<label className="block text-xs font-medium text-slate-600 mb-1.5">Step</label>
<Input
type="number"
placeholder="e.g. 1"
className="bg-white h-9"
className="bg-white h-9 shadow-sm"
{...register(`instructions.${index}.step` as const, { valueAsNumber: true })}
/>
</div>
</div>
</div>
</Wrapper>
);
}
if (operation === 'APPEND_UNIQUE') {
return (
<div className="mb-4 bg-white p-3 rounded border">
<h4 className="text-sm font-medium text-gray-700 mb-3">Value Mapping</h4>
<div className="flex gap-4">
<Wrapper title="Value Mapping">
<div className="flex flex-col md:flex-row gap-4">
<div className="flex-1">
<label className="block text-xs text-gray-500 mb-1">Source Field</label>
<label className="block text-xs font-medium text-slate-600 mb-1.5">Source Field</label>
<Input
placeholder="e.g. event.itemId"
className="bg-white h-9"
className="bg-white h-9 shadow-sm"
{...register(`instructions.${index}.sourceField` as const)}
/>
</div>
<div className="w-48">
<label className="block text-xs text-gray-500 mb-1">Max Length</label>
<div className="w-full md:w-48">
<label className="block text-xs font-medium text-slate-600 mb-1.5">Max Length</label>
<Input
type="number"
placeholder="e.g. 100"
className="bg-white h-9"
className="bg-white h-9 shadow-sm"
{...register(`instructions.${index}.maxLength` as const, { valueAsNumber: true })}
/>
</div>
</div>
</div>
</Wrapper>
);
}
if (operation === 'SET_TRUE_ONCE') {
return (
<div className="mb-4 bg-white p-3 rounded border">
<h4 className="text-sm font-medium text-gray-700 mb-3">One-Time Transition Trigger</h4>
<Wrapper title="One-Time Transition Trigger">
<div className="flex-1">
<label className="block text-xs text-gray-500 mb-1">Trigger Event (on False True)</label>
<Input
<label className="block text-xs font-medium text-slate-600 mb-1.5">Trigger Event (on False True)</label>
<Controller
control={control}
name={`instructions.${index}.triggerEventOnSuccess`}
render={({ field }) => (
<CreatableCombobox
options={eventOptions}
value={field.value}
onChange={field.onChange}
placeholder="e.g. First_Login_Reward"
className="bg-white h-9"
{...register(`instructions.${index}.triggerEventOnSuccess` as const)}
/>
)}
/>
</div>
</div>
</Wrapper>
);
}
if (operation === 'COMPUTE_GAP' || operation === 'CHECK_AND_UPDATE_TIMER') {
return (
<div className="mb-4 bg-white p-3 rounded border">
<label className="block text-xs text-gray-500 mb-1">Unit</label>
<Wrapper title="Unit Configuration">
<label className="block text-xs font-medium text-slate-600 mb-1.5">Unit</label>
<Controller
control={control}
name={`instructions.${index}.unit` as const}
@@ -342,7 +398,7 @@ function DynamicFields({ control, register, index }: { control: Control<any>, re
MINUTES: 'Minutes'
}}
>
<SelectTrigger className="bg-white h-9 w-48">
<SelectTrigger className="bg-white h-9 w-full md:w-48 shadow-sm">
<SelectValue placeholder="Select Unit" />
</SelectTrigger>
<SelectContent>
@@ -353,7 +409,7 @@ function DynamicFields({ control, register, index }: { control: Control<any>, re
</Select>
)}
/>
</div>
</Wrapper>
);
}

View File

@@ -14,6 +14,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Settings2 } from 'lucide-react';
const getAvailableConditions = (type?: string) => {
switch (type) {
@@ -65,26 +66,25 @@ export function ThresholdsBuilder({ control, register, instructionIndex, attribu
}, []);
return (
<div className="space-y-3 mt-4 border-l-2 border-blue-200 pl-4">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium text-gray-700">Thresholds Config</h4>
<div className="space-y-3">
<div className="flex items-center justify-end">
<Button
type="button"
variant="outline"
size="sm"
className="h-8 text-xs"
className="h-8 text-xs bg-white"
onClick={() => append({ operator: defaultOperator, value: defaultValue, triggerEvent: '', resetPolicy: 'NONE' })}
>
<Plus className="w-3 h-3 mr-1" /> Add Threshold
</Button>
</div>
<div className="space-y-4">
<div className="space-y-2">
{fields.map((field, index) => (
<div key={field.id} className="flex flex-col gap-4 bg-white p-4 rounded border border-gray-200">
<div className="flex items-center gap-6">
<div className="flex items-center gap-3">
<span className="text-sm font-medium text-gray-700 w-24">Condition:</span>
<div key={field.id} className="flex flex-col xl:flex-row items-start xl:items-center gap-3 bg-white p-3 rounded-lg border border-slate-200 shadow-sm transition-all hover:border-blue-200">
{/* Condition Group */}
<div className="flex items-center gap-2 shrink-0">
<span className="text-xs font-medium text-slate-500 w-16">IF Value</span>
<Controller
control={control}
name={`instructions.${instructionIndex}.thresholds.${index}.operator` as const}
@@ -97,7 +97,7 @@ export function ThresholdsBuilder({ control, register, instructionIndex, attribu
value={currentValue}
items={availableConditions.reduce((acc, c) => ({ ...acc, [c.value]: c.label }), {})}
>
<SelectTrigger className="bg-white h-9 w-[70px] px-2 text-sm">
<SelectTrigger className="bg-slate-50 h-8 w-[70px] px-2 text-xs border-slate-200 shadow-none">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -122,7 +122,7 @@ export function ThresholdsBuilder({ control, register, instructionIndex, attribu
'false': 'False'
}}
>
<SelectTrigger className="bg-white h-9 w-24">
<SelectTrigger className="bg-slate-50 h-8 w-[80px] text-xs border-slate-200 shadow-none">
<SelectValue placeholder="Value" />
</SelectTrigger>
<SelectContent>
@@ -136,67 +136,74 @@ export function ThresholdsBuilder({ control, register, instructionIndex, attribu
<Input
type={attributeType === 'STRING' ? 'text' : 'number'}
placeholder="Value"
className="h-9 w-24"
className="h-8 w-[80px] text-xs bg-slate-50 border-slate-200 shadow-none"
{...register(`instructions.${instructionIndex}.thresholds.${index}.value` as const, { required: true, valueAsNumber: attributeType === 'NUMBER' })}
/>
)}
</div>
<div className="flex items-center gap-3 flex-1 min-w-[250px] ml-4">
<span className="text-sm font-medium text-gray-700 whitespace-nowrap">Trigger: Fire Event</span>
{/* Trigger Group */}
<div className="flex items-center gap-2 flex-1 w-full xl:w-auto xl:min-w-[200px]">
<span className="text-xs font-medium text-slate-500 xl:ml-2">THEN Trigger</span>
<div className="flex-1">
<Controller
control={control}
name={`instructions.${instructionIndex}.thresholds.${index}.triggerEvent`}
render={({ field }) => (
<div className="[&_[role=combobox]]:h-8 [&_[role=combobox]]:text-xs [&_[role=combobox]]:bg-slate-50 [&_[role=combobox]]:border-slate-200 [&_[role=combobox]]:shadow-none">
<CreatableCombobox
options={eventOptions}
value={field.value}
onChange={field.onChange}
placeholder="e.g. milestone_reached"
/>
</div>
)}
/>
</div>
</div>
{/* Reset Policy Group */}
{showResetPolicy && (
<div className="flex items-center gap-2 shrink-0 w-full xl:w-auto">
<span className="text-xs font-medium text-slate-500 xl:ml-2">WITH Reset</span>
<Controller
control={control}
name={`instructions.${instructionIndex}.thresholds.${index}.resetPolicy`}
render={({ field }) => (
<Select
onValueChange={field.onChange}
value={field.value || "NONE"}
items={{
'NONE': 'None',
'RESET_ON_TRIGGER': 'Reset on Trigger'
}}
>
<SelectTrigger className="bg-slate-50 h-8 w-[140px] text-xs border-slate-200 shadow-none">
<SelectValue placeholder="Policy" />
</SelectTrigger>
<SelectContent>
<SelectItem value="NONE" label="None">None</SelectItem>
<SelectItem value="RESET_ON_TRIGGER" label="Reset on Trigger">Reset on Trigger</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
)}
{/* Actions */}
<div className="flex items-center justify-end w-full xl:w-auto mt-2 xl:mt-0">
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 text-red-500 hover:text-red-700 hover:bg-red-50 ml-auto"
className="h-8 w-8 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-md"
onClick={() => remove(index)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
{showResetPolicy && (
<div className="flex items-center gap-6 pt-3 mt-2 border-t border-gray-100">
<span className="text-sm font-medium text-gray-700 w-24">Reset Policy:</span>
<Controller
control={control}
name={`instructions.${instructionIndex}.thresholds.${index}.resetPolicy`}
render={({ field }) => (
<RadioGroup
onValueChange={field.onChange}
value={field.value || ""}
className="flex flex-wrap gap-6"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="NONE" id={`r-none-${index}`} />
<Label htmlFor={`r-none-${index}`} className="cursor-pointer text-sm font-normal text-gray-600">NONE</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="RESET_ON_TRIGGER" id={`r-reset-${index}`} />
<Label htmlFor={`r-reset-${index}`} className="cursor-pointer text-sm font-normal text-gray-600">RESET_ON_TRIGGER</Label>
</div>
</RadioGroup>
)}
/>
</div>
)}
</div>
))}
</div>