diff --git a/client/src/components/dynamic-groups/condition-builder.tsx b/client/src/components/dynamic-groups/condition-builder.tsx index 1ad3f76..fd3df47 100644 --- a/client/src/components/dynamic-groups/condition-builder.tsx +++ b/client/src/components/dynamic-groups/condition-builder.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef, useMemo } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -394,24 +394,13 @@ const ConditionBuilder: React.FC = ({ {condition.attribute === 'custom' && ( - { - // Create direct reference to prevent re-rendering that causes focus loss - const newConditions = [...conditions]; - newConditions[index] = { - ...newConditions[index], - customAttribute: e.target.value - }; - // Use a stable reference - in React 18+ this helps maintain focus - requestAnimationFrame(() => { - onChange(newConditions); - }); + onChange={(value) => { + updateCondition(index, 'customAttribute', value); }} - // Add key to ensure input identity is stable - key={`custom-attr-input-${condition.id || index}`} /> )} @@ -436,23 +425,12 @@ const ConditionBuilder: React.FC = ({
{condition.operator !== 'present' && condition.operator !== 'notPresent' && ( - { - // Create direct reference to prevent re-rendering that causes focus loss - const newConditions = [...conditions]; - newConditions[index] = { - ...newConditions[index], - value: e.target.value - }; - // Use a stable reference - in React 18+ this helps maintain focus - requestAnimationFrame(() => { - onChange(newConditions); - }); + onChange={(value) => { + updateCondition(index, 'value', value); }} - // Add key to ensure input identity is stable - key={`value-input-${condition.id || index}`} /> )}
diff --git a/client/src/components/dynamic-groups/fixed-condition-builder.tsx b/client/src/components/dynamic-groups/fixed-condition-builder.tsx new file mode 100644 index 0000000..d3e5120 --- /dev/null +++ b/client/src/components/dynamic-groups/fixed-condition-builder.tsx @@ -0,0 +1,694 @@ +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 { Trash, Plus, Move, FolderPlus, ChevronDown, ChevronRight, Copy } from 'lucide-react'; +import { Condition } from './rule-editor'; +import { LdapAttribute } from '@shared/schema'; + +interface ConditionBuilderProps { + conditions: Condition[]; + onChange: (conditions: Condition[]) => void; + connections: number[]; +} + +// Memoized component to maintain focus during renders +const ConditionValue = React.memo(({ value, onChange, placeholder }: { + value: string, + onChange: (value: string) => void, + placeholder?: string +}) => { + const [localValue, setLocalValue] = useState(value || ''); + + useEffect(() => { + if (value !== localValue) { + setLocalValue(value || ''); + } + }, [value]); + + const handleChange = (e: React.ChangeEvent) => { + const newValue = e.target.value; + setLocalValue(newValue); + onChange(newValue); + }; + + return ( + + ); +}); + +ConditionValue.displayName = 'ConditionValue'; + +// Memoized component for custom attribute input +const CustomAttributeInput = React.memo(({ value, onChange, placeholder }: { + value: string, + onChange: (value: string) => void, + placeholder?: string +}) => { + const [localValue, setLocalValue] = useState(value || ''); + + useEffect(() => { + if (value !== localValue) { + setLocalValue(value || ''); + } + }, [value]); + + const handleChange = (e: React.ChangeEvent) => { + const newValue = e.target.value; + setLocalValue(newValue); + onChange(newValue); + }; + + return ( + + ); +}); + +CustomAttributeInput.displayName = 'CustomAttributeInput'; + +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 logicalOperators = [ + { value: 'AND', label: 'AND' }, + { value: 'OR', label: 'OR' } +]; + +const defaultCondition: Condition = { + attribute: '', + operator: '=', + value: '', + logicalOperator: 'AND' +}; + +const defaultGroup: Condition = { + attribute: '', + operator: '', + value: '', + logicalOperator: 'AND', + isGroup: true +}; + +// Non-recursive 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 regular condition (not a group) + const generateRegularFilter = (condition: Condition): string => { + // Get actual attribute name (custom or selected) + const attributeName = condition.attribute === 'custom' && condition.customAttribute + ? condition.customAttribute + : condition.attribute; + + if (!attributeName) return ''; + + // Regular condition + switch (condition.operator) { + case '=': + return `(${attributeName}=${condition.value})`; + case '!=': + return `(!(${attributeName}=${condition.value}))`; + case 'contains': + return `(${attributeName}=*${condition.value}*)`; + case 'startsWith': + return `(${attributeName}=${condition.value}*)`; + case 'endsWith': + return `(${attributeName}=*${condition.value})`; + case 'present': + return `(${attributeName}=*)`; + case 'notPresent': + return `(!(${attributeName}=*))`; + default: + return `(${attributeName}=${condition.value})`; + } + }; + + // Build a map of parent IDs to their child conditions + const childrenByParentId = new Map(); + + // Organize conditions by their parent + for (const condition of conditions) { + const parentId = condition.parentId; + if (!childrenByParentId.has(parentId)) { + childrenByParentId.set(parentId, []); + } + childrenByParentId.get(parentId)!.push(condition); + } + + // Generate filter for a group using an iterative approach + const generateGroupFilter = (parentId: number | null | undefined): string => { + const childConditions = childrenByParentId.get(parentId) || []; + if (childConditions.length === 0) return '(objectClass=*)'; + + // Find the logical operator for this group + const logicalOp = parentId === null || parentId === undefined + ? 'AND' // Default for root level + : conditions.find(c => c.id === parentId)?.logicalOperator || 'AND'; + + const operator = logicalOp === 'AND' ? '&' : '|'; + let groupFilter = `(${operator}`; + + // Track if we've added any conditions to this group + let hasAddedCondition = false; + + // Process regular conditions in this group + for (const condition of childConditions) { + if (!condition.isGroup) { + const filter = generateRegularFilter(condition); + if (filter) { + groupFilter += filter; + hasAddedCondition = true; + } + } + } + + // Process subgroups in this group + for (const condition of childConditions) { + if (condition.isGroup && condition.id !== undefined) { + const subGroupFilter = generateGroupFilter(condition.id); + if (subGroupFilter !== '(objectClass=*)') { + groupFilter += subGroupFilter; + hasAddedCondition = true; + } + } + } + + groupFilter += ')'; + + // If there are no actual conditions, return a default + if (!hasAddedCondition) { + return '(objectClass=*)'; + } + + return groupFilter; + }; + + // Start with the root level conditions + return generateGroupFilter(null); +}; + +// Main component (completely rewritten) +const FixedConditionBuilder: React.FC = ({ + conditions, + onChange, + connections +}) => { + const { toast } = useToast(); + const [expandedGroups, setExpandedGroups] = useState>(new Set()); + const [ldapFilter, setLdapFilter] = useState(''); + + // Load LDAP attributes from connections + const { data: attributesData = [] } = useQuery({ + queryKey: ['/api/ldap-attributes', { connectionIds: connections }], + enabled: connections.length > 0, + staleTime: 60000, + }); + + 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) { + // Extract unique attribute names + const uniqueAttributes = Array.from( + new Set(attributesData.map(attr => attr.name)) + ); + setAvailableAttributes(uniqueAttributes); + } + }, [attributesData]); + + // Update LDAP filter when conditions change + useEffect(() => { + setLdapFilter(generateLdapFilter(conditions)); + }, [conditions]); + + // Handle adding a new condition + const addCondition = (parentId?: number | null) => { + const newCondition: Condition = { + ...defaultCondition, + id: Math.max(0, ...conditions.map(c => c.id || 0)) + 1, // Generate a new ID + 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) => { + // Generate a new ID for the group + const newId = Math.max(0, ...conditions.map(c => c.id || 0)) + 1; + + const newGroup: Condition = { + ...defaultGroup, + id: newId, // Assign the new ID + 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 + setExpandedGroups(prev => { + const expanded = new Set(prev); + expanded.add(newId); + return expanded; + }); + }; + + // 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 or group + const removeCondition = (index: number) => { + const conditionToRemove = conditions[index]; + + // If it's a group, also check for 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; + } + } + + // Make sure we have a valid ID before proceeding + if (conditionToRemove.id !== undefined) { + // Find just this group's children (not all groups with the same parent) + const childrenToRemove = getAllDescendants(conditionToRemove.id); + + // Create a list of IDs to remove (the group and all its descendants) + const idsToRemove = new Set([ + conditionToRemove.id, + ...childrenToRemove.map(c => c.id).filter((id): id is number => id !== undefined) + ]); + + // Only remove this specific group and its descendants + const newConditions = conditions.filter(c => { + // Keep the condition if its ID is not in our removal list + // and its parent ID is not in our removal list + return !idsToRemove.has(c.id) && + !(c.parentId !== null && idsToRemove.has(c.parentId)); + }); + + onChange(newConditions); + } else { + // No valid ID, just remove the condition at this index + const newConditions = conditions.filter((c, i) => i !== index); + onChange(newConditions); + } + } else { + // Just remove the single condition + const newConditions = conditions.filter((c, i) => i !== index); + onChange(newConditions); + } + }; + + // Helper function to get all descendants of a condition group + const getAllDescendants = (parentId: number | undefined): Condition[] => { + if (parentId === undefined) return []; + + const directChildren = conditions.filter(c => c.parentId === parentId); + + // Get all children of groups within this group (recursively) + const groupChildren = directChildren + .filter(c => c.isGroup && c.id !== undefined) + .flatMap(group => getAllDescendants(group.id)); + + return [...directChildren, ...groupChildren]; + }; + + // 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; + }); + }; + + // Render a single condition (regular or group) + const renderCondition = (condition: Condition, index: number, level = 0) => { + // If this is a group condition + if (condition.isGroup) { + // Get all direct children of this group + const childConditions = condition.id + ? conditions.filter(c => c.parentId === condition.id) + : []; + + const isExpanded = condition.id ? expandedGroups.has(condition.id) : false; + + return ( +
+
0 ? `${level * 20}px` : '0' }} + > +
+
+ + + + Condition Group ({condition.logicalOperator}) + +
+ +
+ + + + + + + +
+
+ + {isExpanded && ( +
+ {childConditions.length > 0 ? ( +
+ {childConditions.map((childCondition, i) => { + const childIndex = conditions.indexOf(childCondition); + return renderCondition(childCondition, childIndex, level + 1); + })} +
+ ) : ( +
+ No conditions in this group. Add one using the buttons above. +
+ )} +
+ )} +
+
+ ); + } + + // Regular condition + return ( +
0 ? `${level * 20}px` : '0' }} + > +
+ +
+ + {index > 0 && !condition.isGroup && + condition.parentId === conditions[index-1].parentId && ( +
+ +
+ )} + + {(index === 0 || condition.parentId !== conditions[index-1].parentId) && ( +
+ )} + +
+ + + {condition.attribute === 'custom' && ( + { + updateCondition(index, 'customAttribute', value); + }} + /> + )} +
+ +
+ +
+ +
+ {condition.operator !== 'present' && condition.operator !== 'notPresent' && ( + { + updateCondition(index, 'value', value); + }} + /> + )} +
+ +
+ +
+
+ ); + }; + + // Get top-level conditions + const rootConditions = conditions.filter(c => + c.parentId === null || c.parentId === undefined + ); + + return ( +
+ {conditions.length === 0 ? ( + + +

No conditions defined yet

+
+ + +
+
+
+ ) : ( + <> +
+ {rootConditions.map((condition, i) => { + const index = conditions.indexOf(condition); + return renderCondition(condition, index); + })} +
+ +
+ + +
+ +
+
+ + +
+
+ {ldapFilter} +
+
+ + )} +
+ ); +}; + +export default FixedConditionBuilder; \ 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 e2ce2ae..ea7c7f8 100644 --- a/client/src/components/dynamic-groups/rule-editor.tsx +++ b/client/src/components/dynamic-groups/rule-editor.tsx @@ -23,7 +23,7 @@ import { import { DynamicGroupRule, LdapConnection } from '@shared/schema'; import CronJobBuilder, { ScheduleItem } from './cron-job-builder'; -import ConditionBuilder from './condition-builder'; +import FixedConditionBuilder from './fixed-condition-builder'; import VariableSelector from './variable-selector'; import { useToast } from '@/hooks/use-toast'; import { queryClient, apiRequest } from '@/lib/queryClient';