From e7a8b41ba93455156857a6365c1e251d7e8ab8c4 Mon Sep 17 00:00:00 2001 From: alphaeusmote <41258468-alphaeusmote@users.noreply.replit.com> Date: Thu, 10 Apr 2025 03:52:39 +0000 Subject: [PATCH] Refactor dynamic group rule builder UI for improved usability. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 705f2157-ef97-4fbd-89e4-8c7f2ecaea90 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/7ed01c5f-a82d-405a-b728-b2e3d127c60c/a287b6d4-b0e2-4481-9e90-30d919a70c4f.jpg --- .../dynamic-groups/condition-builder.tsx | 1085 +++++++++-------- .../components/dynamic-groups/rule-editor.tsx | 864 +++++++------ 2 files changed, 1066 insertions(+), 883 deletions(-) diff --git a/client/src/components/dynamic-groups/condition-builder.tsx b/client/src/components/dynamic-groups/condition-builder.tsx index b98b11c..2ef1d18 100644 --- a/client/src/components/dynamic-groups/condition-builder.tsx +++ b/client/src/components/dynamic-groups/condition-builder.tsx @@ -1,102 +1,186 @@ -import React, { useState, useEffect } from "react"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent } 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 { useQuery } from "@tanstack/react-query"; -import { Loader2, Plus, Trash, ArrowRightLeft, MoveVertical, Group, Layers } from "lucide-react"; -import { Badge } from "@/components/ui/badge"; +import React, { useState, useEffect } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Card, CardContent } from '@/components/ui/card'; +import { useToast } from '@/hooks/use-toast'; +import { useQuery } from '@tanstack/react-query'; import { DndContext, - DragOverlay, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, - DragStartEvent, - DragEndEvent, -} from "@dnd-kit/core"; + DragEndEvent +} from '@dnd-kit/core'; import { + arrayMove, SortableContext, sortableKeyboardCoordinates, - verticalListSortingStrategy, - useSortable, -} from "@dnd-kit/sortable"; -import { CSS } from "@dnd-kit/utilities"; - -interface Condition { - id: number; - type: "condition" | "group"; - parentId: number | null; - operator: string; - attribute?: string; - value?: string; - position: number; - children?: Condition[]; -} - -interface Connection { - id: number; - name: string; - server: string; -} - -interface LdapAttribute { - id: number; - name: string; - displayName: string | null; - description: string | null; - type: string | null; - multiValued: boolean; - objectClass: string; -} + useSortable +} from '@dnd-kit/sortable'; +import { Trash, Plus, Move, PlusCircle, FolderPlus, ChevronDown, ChevronRight } from 'lucide-react'; +import { Condition } from './rule-editor'; +import { LdapAttribute } from '@shared/schema'; interface ConditionBuilderProps { conditions: Condition[]; onChange: (conditions: Condition[]) => void; - connections: Connection[]; + connections: number[]; } -const conditionOperators = [ - { value: "equals", label: "Equals" }, - { value: "contains", label: "Contains" }, - { value: "startsWith", label: "Starts With" }, - { value: "endsWith", label: "Ends With" }, - { value: "present", label: "Is Present" }, - { value: "notPresent", label: "Is Not Present" }, - { value: "greaterThan", label: "Greater Than" }, - { value: "lessThan", label: "Less Than" }, +const operators = [ + { value: '=', label: 'Equals' }, + { value: '!=', label: 'Not Equals' }, + { value: 'contains', label: 'Contains' }, + { value: 'startsWith', label: 'Starts With' }, + { value: 'endsWith', label: 'Ends With' }, + { value: 'present', label: 'Is Present' }, + { value: 'notPresent', label: 'Is Not Present' } ]; -const groupOperators = [ - { value: "and", label: "AND" }, - { value: "or", label: "OR" }, - { value: "not", label: "NOT" }, +const logicalOperators = [ + { value: 'AND', label: 'AND' }, + { value: 'OR', label: 'OR' } ]; -export default function ConditionBuilder({ conditions, onChange, connections }: ConditionBuilderProps) { - const [selectedConnectionId, setSelectedConnectionId] = useState(null); - const [activeId, setActiveId] = useState(null); - const [nextId, setNextId] = useState(1); +const defaultCondition: Condition = { + attribute: '', + operator: '=', + value: '', + logicalOperator: 'AND' +}; - // Ensure we have unique IDs for new conditions - useEffect(() => { - if (conditions.length > 0) { - const maxId = Math.max(...conditions.map(c => c.id)) + 1; - setNextId(maxId); - } - }, [conditions]); +const defaultGroup: Condition = { + attribute: '', + operator: '', + value: '', + logicalOperator: 'AND', + isGroup: true +}; - // Fetch LDAP attributes for the selected connection - const { data: attributes, isLoading: isLoadingAttributes } = useQuery({ - queryKey: ["/api/ldap-attributes", selectedConnectionId], - enabled: !!selectedConnectionId, - retry: false, +const ConditionBuilder: React.FC = ({ + conditions, + onChange, + connections +}) => { + const { toast } = useToast(); + const [expandedGroups, setExpandedGroups] = useState>(new Set()); + + // Load LDAP attributes from connections + const { data: attributesData = [] } = useQuery({ + queryKey: ['/api/ldap-attributes', { connectionIds: connections }], + enabled: connections.length > 0, + staleTime: 60000, }); - // DnD setup + const [availableAttributes, setAvailableAttributes] = useState([ + 'cn', 'sAMAccountName', 'givenName', 'sn', 'mail', 'displayName', + 'memberOf', 'distinguishedName', 'objectClass', 'objectCategory', + 'userAccountControl', 'department', 'company', 'title', 'manager' + ]); + + // Update available attributes when data is loaded + useEffect(() => { + if (attributesData.length > 0) { + setAvailableAttributes([ + ...new Set(attributesData.map(attr => attr.name)) + ]); + } + }, [attributesData]); + + // Handle adding a new condition + const addCondition = (parentId?: number | null) => { + const newCondition: Condition = { + ...defaultCondition, + parentId + }; + + // Find the appropriate insertion position + if (parentId === undefined) { + // Add to root level + onChange([...conditions, newCondition]); + } else { + // Find the last condition with the same parent to add it after + const sameParentConditions = conditions.filter(c => c.parentId === parentId); + const insertIndex = sameParentConditions.length > 0 + ? conditions.indexOf(sameParentConditions[sameParentConditions.length - 1]) + 1 + : conditions.findIndex(c => c.id === parentId) + 1; + + const newConditions = [...conditions]; + newConditions.splice(insertIndex, 0, newCondition); + onChange(newConditions); + } + }; + + // Handle adding a new condition group + const addConditionGroup = (parentId?: number | null) => { + const newGroup: Condition = { + ...defaultGroup, + parentId + }; + + // Similar insertion logic as addCondition + if (parentId === undefined) { + onChange([...conditions, newGroup]); + } else { + const sameParentConditions = conditions.filter(c => c.parentId === parentId); + const insertIndex = sameParentConditions.length > 0 + ? conditions.indexOf(sameParentConditions[sameParentConditions.length - 1]) + 1 + : conditions.findIndex(c => c.id === parentId) + 1; + + const newConditions = [...conditions]; + newConditions.splice(insertIndex, 0, newGroup); + onChange(newConditions); + + // Automatically expand the new group + if (newGroup.id) { + setExpandedGroups(prev => new Set([...prev, newGroup.id!])); + } + } + }; + + // Handle updating a condition + const updateCondition = (index: number, field: keyof Condition, value: any) => { + const newConditions = [...conditions]; + newConditions[index] = { ...newConditions[index], [field]: value }; + onChange(newConditions); + }; + + // Handle removing a condition + const removeCondition = (index: number) => { + const conditionToRemove = conditions[index]; + + // If it's a group, also remove all child conditions + if (conditionToRemove.isGroup) { + const childConditions = conditions.filter(c => c.parentId === conditionToRemove.id); + + if (childConditions.length > 0) { + const confirmDelete = window.confirm( + `This will also delete ${childConditions.length} child condition(s). Continue?` + ); + + if (!confirmDelete) { + return; + } + } + + // Remove the group and all its children + const newConditions = conditions.filter( + c => c.id !== conditionToRemove.id && c.parentId !== conditionToRemove.id + ); + onChange(newConditions); + } else { + // Just remove the single condition + const newConditions = [...conditions]; + newConditions.splice(index, 1); + onChange(newConditions); + } + }; + + // Setup drag sensors const sensors = useSensors( useSensor(PointerSensor), useSensor(KeyboardSensor, { @@ -104,461 +188,225 @@ export default function ConditionBuilder({ conditions, onChange, connections }: }) ); - const handleDragStart = (event: DragStartEvent) => { - setActiveId(event.active.id as number); - }; - + // Handle drag and drop const handleDragEnd = (event: DragEndEvent) => { - setActiveId(null); - const { active, over } = event; - if (over && active.id !== over.id) { - // Handle reordering logic here - const updatedConditions = [...conditions]; - // Implement sorting logic - onChange(updatedConditions); - } - }; - - // Add a new condition to the root level - const addCondition = () => { - const newCondition: Condition = { - id: nextId, - type: "condition", - parentId: null, - operator: "equals", - attribute: "", - value: "", - position: conditions.length, - }; + if (!over) return; - onChange([...conditions, newCondition]); - setNextId(nextId + 1); - }; - - // Add a new condition group to the root level - const addGroup = () => { - const newGroup: Condition = { - id: nextId, - type: "group", - parentId: null, - operator: "and", - position: conditions.length, - children: [], - }; - - onChange([...conditions, newGroup]); - setNextId(nextId + 1); - }; - - // Update a condition - const updateCondition = (id: number, updates: Partial) => { - const updatedConditions = conditions.map(condition => { - if (condition.id === id) { - return { ...condition, ...updates }; + if (active.id !== over.id) { + const activeIndex = conditions.findIndex(c => c.id === active.id); + const overIndex = conditions.findIndex(c => c.id === over.id); + + // Don't allow dropping a condition outside its parent group + const sourceCondition = conditions[activeIndex]; + const overCondition = conditions[overIndex]; + + if (sourceCondition.parentId !== overCondition.parentId) { + toast({ + title: "Invalid Move", + description: "Conditions can only be reordered within the same group", + variant: "destructive" + }); + return; } - return condition; - }); - - onChange(updatedConditions); - }; - - // Delete a condition - const deleteCondition = (id: number) => { - const filteredConditions = conditions.filter(condition => condition.id !== id); - onChange(filteredConditions); - }; - - // Add a nested condition inside a group - const addNestedCondition = (parentId: number) => { - const updatedConditions = [...conditions]; - const parentIndex = updatedConditions.findIndex(c => c.id === parentId); - - if (parentIndex !== -1 && updatedConditions[parentIndex].type === "group") { - const parent = updatedConditions[parentIndex]; - const children = parent.children || []; - const newCondition: Condition = { - id: nextId, - type: "condition", - parentId: parentId, - operator: "equals", - attribute: "", - value: "", - position: children.length, - }; - - updatedConditions[parentIndex] = { - ...parent, - children: [...children, newCondition], - }; - - onChange(updatedConditions); - setNextId(nextId + 1); + const newConditions = arrayMove(conditions, activeIndex, overIndex); + onChange(newConditions); } }; - return ( -
-
- - -
- -
- - -
- - - c.id)} - strategy={verticalListSortingStrategy} - > -
- {conditions.length === 0 ? ( -
-

No conditions defined. Add a condition or group to get started.

-
- ) : ( - conditions.map(condition => ( - updateCondition(condition.id, updates)} - onDelete={() => deleteCondition(condition.id)} - onAddNested={ - condition.type === "group" - ? () => addNestedCondition(condition.id) - : undefined - } - selectedConnectionId={selectedConnectionId} - /> - )) - )} -
-
- - - {activeId ? ( -
- Dragging Item {activeId} -
- ) : null} -
-
-
- ); -} - -interface ConditionItemProps { - condition: Condition; - attributes: LdapAttribute[]; - isLoadingAttributes: boolean; - onUpdate: (updates: Partial) => void; - onDelete: () => void; - onAddNested?: () => void; - selectedConnectionId: number | null; -} - -function ConditionItem({ - condition, - attributes, - isLoadingAttributes, - onUpdate, - onDelete, - onAddNested, - selectedConnectionId -}: ConditionItemProps) { - const { - attributes: sortableAttributes, - listeners, - setNodeRef, - transform, - transition, - } = useSortable({ id: condition.id }); - - const style = { - transform: CSS.Transform.toString(transform), - transition, + // Toggle group expansion + const toggleGroupExpand = (groupId: number) => { + setExpandedGroups(prev => { + const newExpanded = new Set(prev); + if (newExpanded.has(groupId)) { + newExpanded.delete(groupId); + } else { + newExpanded.add(groupId); + } + return newExpanded; + }); }; - if (condition.type === "group") { - return ( -
- - -
-
- - - Drag - - - - -
- -
- - -
-
- -
- {condition.children && condition.children.length > 0 ? ( - condition.children.map((child) => ( - { - const updatedChildren = condition.children?.map((c) => { - if (c.id === child.id) { - return { ...c, ...updates }; - } - return c; - }); - onUpdate({ children: updatedChildren }); - }} - onDelete={() => { - const updatedChildren = condition.children?.filter((c) => c.id !== child.id); - onUpdate({ children: updatedChildren }); - }} - selectedConnectionId={selectedConnectionId} - /> - )) - ) : ( -
-

No conditions in this group.

- -
- )} -
-
-
-
+ // Get the conditions that belong to a specific parent (or root level) + const getConditionsForParent = (parentId?: number | null) => { + return conditions.filter(c => + parentId === undefined + ? c.parentId === null || c.parentId === undefined + : c.parentId === parentId ); - } - - return ( -
- - -
- - - Drag - - -
- - {isLoadingAttributes ? ( -
- - Loading attributes... -
- ) : !selectedConnectionId ? ( - onUpdate({ attribute: e.target.value })} - placeholder="Select a connection first" - className="w-full" - /> + }; + + // Render a condition group + const renderConditionGroup = (condition: Condition, index: number, level = 0) => { + const isExpanded = condition.id ? expandedGroups.has(condition.id) : false; + const childConditions = condition.id ? getConditionsForParent(condition.id) : []; + + return ( +
0 ? `${level * 20}px` : '0' }} + > +
+
+
+ -
- - -
- -
- - onUpdate({ value: e.target.value })} - placeholder="Value" - className="w-full" - disabled={["present", "notPresent"].includes(condition.operator)} - /> -
- - -
- - -
- ); -} - -interface NestedConditionItemProps { - condition: Condition; - attributes: LdapAttribute[]; - isLoadingAttributes: boolean; - onUpdate: (updates: Partial) => void; - onDelete: () => void; - selectedConnectionId: number | null; -} - -function NestedConditionItem({ - condition, - attributes, - isLoadingAttributes, - onUpdate, - onDelete, - selectedConnectionId -}: NestedConditionItemProps) { - return ( - - -
-
- - {isLoadingAttributes ? ( -
- - Loading... -
- ) : !selectedConnectionId ? ( - onUpdate({ attribute: e.target.value })} - placeholder="Select a connection first" - className="w-full" - /> - ) : ( - - )} + + Condition Group ({condition.logicalOperator}) +
-
- +
+ + + + + + +
+
+ + {isExpanded && ( +
+ {childConditions.length === 0 ? ( +
+ No conditions in this group. Add one using the buttons above. +
+ ) : ( + childConditions.map((child, childIndex) => { + const originalIndex = conditions.findIndex(c => c === child); + return child.isGroup + ? renderConditionGroup(child, originalIndex, level + 1) + : renderCondition(child, originalIndex, level + 1); + }) + )} +
+ )} +
+ ); + }; + + // Sortable Item wrapper component + const SortableItem = ({ + condition, + index, + level = 0 + }: { + condition: Condition; + index: number; + level?: number; + }) => { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging + } = useSortable({ + id: condition.id || index, + data: { + condition, + index, + level, + type: condition.isGroup ? 'group' : 'condition' + } + }); + + const style = { + transform: transform ? `translate3d(${transform.x}px, ${transform.y}px, 0)` : undefined, + transition, + marginLeft: level > 0 ? `${level * 20}px` : '0', + zIndex: isDragging ? 100 : 1, + position: 'relative' as 'relative', + opacity: isDragging ? 0.5 : 1, + }; + + return ( +
+ {condition.isGroup ? ( + renderConditionGroup(condition, index, level) + ) : ( + renderConditionContent(condition, index, level, listeners) + )} +
+ ); + }; + + // Render condition content (without the wrapper) + const renderConditionContent = ( + condition: Condition, + index: number, + level = 0, + listeners?: ReturnType['listeners'] + ) => { + return ( +
+ {index > 0 && !condition.isGroup && condition.parentId === conditions[index-1].parentId && ( +
+
+ )} + + {(index === 0 || condition.parentId !== conditions[index-1].parentId) && ( +
+ )} + +
+ -
- + {condition.attribute === 'custom' && ( onUpdate({ value: e.target.value })} - placeholder="Value" - className="w-full" - disabled={["present", "notPresent"].includes(condition.operator)} + className="mt-1" + placeholder="Enter custom attribute" + value={condition.attribute === 'custom' ? '' : condition.attribute} + onChange={(e) => updateCondition(index, 'attribute', e.target.value)} /> -
- -
+ +
+ +
+ +
+ {condition.operator !== 'present' && condition.operator !== 'notPresent' && ( + updateCondition(index, 'value', e.target.value)} + /> + )} +
+ +
+
- - +
+ ); + }; + + return ( +
+ {conditions.length === 0 ? ( + + +

No conditions defined yet

+
+ + +
+
+
+ ) : ( + <> + + c.id || 0)}> +
+ {conditions.map((condition, index) => ( + condition.parentId === null || condition.parentId === undefined ? ( + condition.isGroup ? + renderConditionGroup(condition, index) : + renderCondition(condition, index) + ) : null + ))} +
+
+
+ +
+ + +
+ +
+ +
+ {generateLdapFilter(conditions)} +
+
+ + )} +
); -} \ No newline at end of file +}; + +// Function to generate LDAP filter syntax from conditions +const generateLdapFilter = (conditions: Condition[]): string => { + if (conditions.length === 0) return '(objectClass=*)'; + + // Helper function to generate filter for a single condition + const generateSingleFilter = (condition: Condition): string => { + if (condition.isGroup) { + // Get all child conditions for this group + const childConditions = conditions.filter(c => c.parentId === condition.id); + if (childConditions.length === 0) return '(objectClass=*)'; + + const operator = condition.logicalOperator === 'AND' ? '&' : '|'; + const childFilters = childConditions.map(generateSingleFilter).join(''); + + return `(${operator}${childFilters})`; + } else { + // Regular condition + switch (condition.operator) { + case '=': + return `(${condition.attribute}=${condition.value})`; + case '!=': + return `(!(${condition.attribute}=${condition.value}))`; + case 'contains': + return `(${condition.attribute}=*${condition.value}*)`; + case 'startsWith': + return `(${condition.attribute}=${condition.value}*)`; + case 'endsWith': + return `(${condition.attribute}=*${condition.value})`; + case 'present': + return `(${condition.attribute}=*)`; + case 'notPresent': + return `(!(${condition.attribute}=*))`; + default: + return `(${condition.attribute}=${condition.value})`; + } + } + }; + + // Handle root-level conditions + const rootConditions = conditions.filter(c => + c.parentId === null || c.parentId === undefined + ); + + if (rootConditions.length === 1) { + return generateSingleFilter(rootConditions[0]); + } else { + // When multiple root conditions, wrap in an AND by default + const rootFilters = rootConditions.map(generateSingleFilter).join(''); + return `(&${rootFilters})`; + } +}; + +export default ConditionBuilder; \ No newline at end of file diff --git a/client/src/components/dynamic-groups/rule-editor.tsx b/client/src/components/dynamic-groups/rule-editor.tsx index 448c821..dc2f5aa 100644 --- a/client/src/components/dynamic-groups/rule-editor.tsx +++ b/client/src/components/dynamic-groups/rule-editor.tsx @@ -1,397 +1,547 @@ -import React, { useState, useEffect } from "react"; -import { useQuery, useMutation } from "@tanstack/react-query"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Separator } from "@/components/ui/separator"; -import { Switch } from "@/components/ui/switch"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Textarea } from "@/components/ui/textarea"; -import { useToast } from "@/hooks/use-toast"; -import { queryClient, apiRequest } from "@/lib/queryClient"; -import { Loader2, Plus, Save, Trash, Code, Filter } from "lucide-react"; -import { Badge } from "@/components/ui/badge"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { Checkbox } from "@/components/ui/checkbox"; -import { useForm, Controller } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { z } from "zod"; -import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; -import ConditionBuilder from "./condition-builder"; +import React, { useState, useEffect } from 'react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Textarea } from '@/components/ui/textarea'; +import { Switch } from '@/components/ui/switch'; +import { Badge } from '@/components/ui/badge'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { Separator } from '@/components/ui/separator'; +import { + Info, + Clock, + Settings, + ListFilter, + EyeIcon, + CalendarDays, + RefreshCw, + AlertTriangle +} from 'lucide-react'; -// Define the schema for rule form -const ruleFormSchema = z.object({ - name: z.string().min(1, "Name is required"), - description: z.string().optional(), - targetGroup: z.string().min(1, "Target group is required"), - enabled: z.boolean().default(true), - schedule: z.string().min(1, "Schedule is required"), - variablePattern: z.string().optional(), - connectionIds: z.array(z.number()).min(1, "At least one connection is required"), -}); +import { DynamicGroupRule, LdapConnection } from '@shared/schema'; +import CronJobBuilder, { ScheduleItem } from './cron-job-builder'; +import ConditionBuilder from './condition-builder'; +import { useToast } from '@/hooks/use-toast'; +import { queryClient, apiRequest } from '@/lib/queryClient'; +import { useMutation, useQuery } from '@tanstack/react-query'; -type RuleFormValues = z.infer; - -interface Condition { - id: number; - type: "condition" | "group"; - parentId: number | null; - operator: string; - attribute?: string; - value?: string; - position: number; - children?: Condition[]; -} - -interface Connection { - id: number; - name: string; - server: string; -} - -interface DynamicGroupRule { +export interface Condition { id?: number; - name: string; - description: string | null; - targetGroup: string; - enabled: boolean; - schedule: string; - variablePattern: string | null; - connectionIds: number[]; - conditions: Condition[]; + ruleId?: number; + parentId?: number | null; + attribute: string; + operator: string; + value: string; + logicalOperator?: string | null; + isGroup?: boolean; } interface RuleEditorProps { - ruleId?: number; - onSuccess?: () => void; - onCancel?: () => void; + rule?: DynamicGroupRule; + onSave: (rule: DynamicGroupRule, schedules: ScheduleItem[]) => void; + onCancel: () => void; } -export default function RuleEditor({ ruleId, onSuccess, onCancel }: RuleEditorProps) { +export const RuleEditor: React.FC = ({ rule, onSave, onCancel }) => { const { toast } = useToast(); + const [activeTab, setActiveTab] = useState('general'); const [conditions, setConditions] = useState([]); - const [activeTab, setActiveTab] = useState("general"); - const isEditMode = !!ruleId; - - // Fetch connections for dropdown - const { data: connections, isLoading: isLoadingConnections } = useQuery({ - queryKey: ["/api/ldap-connections"], - retry: false, + const [schedules, setSchedules] = useState([]); + const [formData, setFormData] = useState({ + name: rule?.name || '', + description: rule?.description || '', + targetGroup: rule?.targetGroup || '', + enabled: rule?.enabled ?? true, + variablePattern: rule?.variablePattern || '', + connections: rule?.connectionIds || [] + }); + + // Load LDAP connections + const { data: ldapConnections = [] } = useQuery({ + queryKey: ['/api/ldap-connections'], + staleTime: 60000, }); - // Fetch rule data if editing - const { data: ruleData, isLoading: isLoadingRule } = useQuery({ - queryKey: ["/api/dynamic-group-rules", ruleId], - enabled: !!ruleId, - retry: false, + // Load conditions for the rule if editing + const { data: conditionsData } = useQuery({ + queryKey: ['/api/dynamic-group-rules', rule?.id, 'conditions'], + enabled: !!rule?.id, + staleTime: 60000, }); - // Form setup - const form = useForm({ - resolver: zodResolver(ruleFormSchema), - defaultValues: { - name: "", - description: "", - targetGroup: "", - enabled: true, - schedule: "0 0 * * *", // Daily at midnight - variablePattern: "", - connectionIds: [], - }, + // Load schedules for the rule if editing + const { data: schedulesData } = useQuery({ + queryKey: ['/api/dynamic-group-rules', rule?.id, 'schedules'], + enabled: !!rule?.id, + staleTime: 60000, }); - // Update form when rule data is loaded + // Initialize conditions and schedules when data loads useEffect(() => { - if (ruleData) { - form.reset({ - name: ruleData.name, - description: ruleData.description || "", - targetGroup: ruleData.targetGroup, - enabled: ruleData.enabled, - schedule: ruleData.schedule, - variablePattern: ruleData.variablePattern || "", - connectionIds: ruleData.connections.map((c: any) => c.id), - }); - setConditions(ruleData.conditions || []); + if (conditionsData) { + setConditions(conditionsData); } - }, [ruleData, form]); + }, [conditionsData]); - // Create/Update rule mutation - const saveMutation = useMutation({ - mutationFn: async (data: DynamicGroupRule) => { - const url = isEditMode - ? `/api/dynamic-group-rules/${ruleId}` - : `/api/dynamic-group-rules`; - const method = isEditMode ? "PUT" : "POST"; - const response = await apiRequest(method, url, data); - return response.json(); - }, - onSuccess: () => { + useEffect(() => { + if (schedulesData) { + setSchedules(schedulesData); + } + }, [schedulesData]); + + // Handle form input changes + const handleInputChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setFormData(prev => ({ ...prev, [name]: value })); + }; + + // Handle toggle changes + const handleToggleChange = (field: string, value: boolean) => { + setFormData(prev => ({ ...prev, [field]: value })); + }; + + // Handle connection selection + const handleConnectionChange = (connectionIds: number[]) => { + setFormData(prev => ({ ...prev, connections: connectionIds })); + }; + + // Handle form submission + const handleSubmit = () => { + if (!formData.name) { toast({ - title: "Success", - description: `Rule ${isEditMode ? "updated" : "created"} successfully`, - variant: "default", + title: "Missing Information", + description: "Please provide a name for the rule", + variant: "destructive" + }); + setActiveTab('general'); + return; + } + + if (!formData.targetGroup) { + toast({ + title: "Missing Information", + description: "Please specify a target AD group", + variant: "destructive" + }); + setActiveTab('general'); + return; + } + + if (formData.connections.length === 0) { + toast({ + title: "Missing Information", + description: "Please select at least one LDAP connection", + variant: "destructive" + }); + setActiveTab('general'); + return; + } + + if (conditions.length === 0) { + toast({ + title: "Missing Information", + description: "Please define at least one condition", + variant: "destructive" + }); + setActiveTab('conditions'); + return; + } + + if (schedules.length === 0) { + toast({ + title: "Missing Information", + description: "Please define at least one schedule", + variant: "destructive" + }); + setActiveTab('schedules'); + return; + } + + const ruleData: DynamicGroupRule = { + id: rule?.id, + name: formData.name, + description: formData.description || null, + targetGroup: formData.targetGroup, + enabled: formData.enabled, + createdAt: rule?.createdAt || new Date(), + updatedAt: new Date(), + lastRun: rule?.lastRun || null, + lastRunStatus: rule?.lastRunStatus || null, + variablePattern: formData.variablePattern || null, + connectionIds: formData.connections + }; + + onSave(ruleData, schedules); + }; + + // Test the rule + const testRuleMutation = useMutation({ + mutationFn: async () => { + const res = await apiRequest('POST', '/api/dynamic-group-rules/test', { + rule: { + name: formData.name, + targetGroup: formData.targetGroup, + connectionIds: formData.connections, + variablePattern: formData.variablePattern || null + }, + conditions + }); + return await res.json(); + }, + onSuccess: (data) => { + toast({ + title: "Test Results", + description: `The rule would match ${data.matchCount} objects`, }); - queryClient.invalidateQueries({ queryKey: ["/api/dynamic-group-rules"] }); - if (onSuccess) onSuccess(); }, onError: (error: Error) => { toast({ - title: "Error", - description: `Failed to ${isEditMode ? "update" : "create"} rule: ${error.message}`, - variant: "destructive", + title: "Test Failed", + description: error.message, + variant: "destructive" }); - }, + } }); - // Handle form submission - const onSubmit = (values: RuleFormValues) => { - const ruleData: DynamicGroupRule = { - ...values, - conditions, - }; - saveMutation.mutate(ruleData); - }; + const handleTest = () => { + if (formData.connections.length === 0) { + toast({ + title: "Missing Information", + description: "Please select at least one LDAP connection to test", + variant: "destructive" + }); + return; + } - // Update conditions handler - const handleConditionsUpdate = (updatedConditions: Condition[]) => { - setConditions(updatedConditions); - }; + if (conditions.length === 0) { + toast({ + title: "Missing Information", + description: "Please define at least one condition to test", + variant: "destructive" + }); + return; + } - // Loading state - const isLoading = isLoadingConnections || (isEditMode && isLoadingRule); + testRuleMutation.mutate(); + }; return (
- - - General - Conditions - Preview + + + + + General + + + + Conditions + + + + Schedules + + + + Preview + - -
- - - {isLoading ? ( -
- -
- ) : ( - - - Rule Configuration - - - {/* Basic rule information */} - ( - - Rule Name - - - - - - )} - /> - - ( - - Description - -