Files
ActiveDirectoryManager/client/src/components/dynamic-groups/condition-builder.tsx
T
alphaeusmote d4bf800188 Fix dynamic group condition builder issues
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/f2b51f0f-fba7-47bc-9f6b-0f091ed578ae.jpg
2025-04-10 15:08:55 +00:00

639 lines
22 KiB
TypeScript

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 } from 'lucide-react';
import { Condition } from './rule-editor';
import { LdapAttribute } from '@shared/schema';
interface ConditionBuilderProps {
conditions: Condition[];
onChange: (conditions: Condition[]) => void;
connections: number[];
}
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
};
// This is a completely new implementation of the condition builder to avoid recursive rendering
const ConditionBuilder: React.FC<ConditionBuilderProps> = ({
conditions,
onChange,
connections
}) => {
const { toast } = useToast();
const [expandedGroups, setExpandedGroups] = useState<Set<number>>(new Set());
// Load LDAP attributes from connections
const { data: attributesData = [] } = useQuery<LdapAttribute[]>({
queryKey: ['/api/ldap-attributes', { connectionIds: connections }],
enabled: connections.length > 0,
staleTime: 60000,
});
const [availableAttributes, setAvailableAttributes] = useState<string[]>([
'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]);
// 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 => {
const expanded = new Set(prev);
expanded.add(newGroup.id!);
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<number | undefined>([
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) &&
!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, more carefully
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;
});
};
// 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
);
};
// Render all conditions with a safer, iterative approach
const renderAllConditions = () => {
// Create a separate component for rendering conditions
const ConditionItem = ({ condition, index, level = 0 }: { condition: Condition, index: number, level?: number }) => {
// Get all child conditions if this is a group
const childConditions = condition.isGroup && condition.id
? conditions.filter(c => c.parentId === condition.id)
: [];
const isExpanded = condition.id ? expandedGroups.has(condition.id) : false;
if (condition.isGroup) {
return (
<div className="mb-3">
<div
className="border border-border rounded-md p-3"
style={{ marginLeft: level > 0 ? `${level * 20}px` : '0' }}
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center space-x-2">
<button
type="button"
onClick={() => condition.id && toggleGroupExpand(condition.id)}
className="p-1 hover:bg-accent rounded-sm"
>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</button>
<span className="text-sm font-medium">
Condition Group ({condition.logicalOperator})
</span>
</div>
<div className="flex items-center space-x-2">
<Select
value={condition.logicalOperator || 'AND'}
onValueChange={(value) => updateCondition(index, 'logicalOperator', value)}
>
<SelectTrigger className="w-20 h-8">
<SelectValue placeholder="Operator" />
</SelectTrigger>
<SelectContent>
{logicalOperators.map((op) => (
<SelectItem key={op.value} value={op.value}>
{op.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="ghost"
size="icon"
onClick={() => addCondition(condition.id)}
title="Add Condition"
>
<Plus className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => addConditionGroup(condition.id)}
title="Add Group"
>
<FolderPlus className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => removeCondition(index)}
title="Remove Group"
>
<Trash className="h-4 w-4" />
</Button>
</div>
</div>
{isExpanded && (
<div className="pl-2">
{childConditions.length > 0 ? (
<div>
{childConditions.map(childCondition => {
const childIndex = conditions.indexOf(childCondition);
return (
<ConditionItem
key={`child-${childIndex}`}
condition={childCondition}
index={childIndex}
level={level + 1}
/>
);
})}
</div>
) : (
<div className="text-sm text-muted-foreground p-2">
No conditions in this group. Add one using the buttons above.
</div>
)}
</div>
)}
</div>
</div>
);
}
// Regular condition
return (
<div
className="grid grid-cols-13 gap-2 items-center mb-2 border border-transparent hover:border-border p-2 rounded-md"
style={{ marginLeft: level > 0 ? `${level * 20}px` : '0' }}
>
<div className="col-span-1 flex items-center justify-center">
<Move className="h-4 w-4 text-muted-foreground" />
</div>
{index > 0 && !condition.isGroup && condition.parentId === conditions[index-1].parentId && (
<div className="col-span-1">
<Select
value={condition.logicalOperator || 'AND'}
onValueChange={(value) => updateCondition(index, 'logicalOperator', value)}
>
<SelectTrigger className="h-8">
<SelectValue placeholder="AND" />
</SelectTrigger>
<SelectContent>
{logicalOperators.map((op) => (
<SelectItem key={op.value} value={op.value}>
{op.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{(index === 0 || condition.parentId !== conditions[index-1].parentId) && (
<div className="col-span-1"></div>
)}
<div className="col-span-4">
<Select
value={condition.attribute}
onValueChange={(value) => updateCondition(index, 'attribute', value)}
>
<SelectTrigger>
<SelectValue placeholder="Select attribute" />
</SelectTrigger>
<SelectContent>
{availableAttributes.map((attr) => (
<SelectItem key={attr} value={attr}>
{attr}
</SelectItem>
))}
<SelectItem value="custom">Custom Attribute</SelectItem>
</SelectContent>
</Select>
{condition.attribute === 'custom' && (
<Input
className="mt-1"
placeholder="Enter custom attribute"
value={condition.customAttribute || ''}
onChange={(e) => {
// Use direct update to prevent focus loss
const newConditions = [...conditions];
newConditions[index] = {
...newConditions[index],
customAttribute: e.target.value
};
onChange(newConditions);
}}
/>
)}
</div>
<div className="col-span-3">
<Select
value={condition.operator}
onValueChange={(value) => updateCondition(index, 'operator', value)}
>
<SelectTrigger>
<SelectValue placeholder="Select operator" />
</SelectTrigger>
<SelectContent>
{operators.map((op) => (
<SelectItem key={op.value} value={op.value}>
{op.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="col-span-3">
{condition.operator !== 'present' && condition.operator !== 'notPresent' && (
<Input
placeholder="Value"
value={condition.value || ''}
onChange={(e) => {
// Use a direct update approach for text inputs to prevent focus loss
const newConditions = [...conditions];
newConditions[index] = {
...newConditions[index],
value: e.target.value
};
onChange(newConditions);
}}
/>
)}
</div>
<div className="col-span-1 flex justify-end">
<Button
variant="ghost"
size="icon"
onClick={() => removeCondition(index)}
title="Remove Condition"
>
<Trash className="h-4 w-4" />
</Button>
</div>
</div>
);
};
// Get top-level conditions
const rootConditions = conditions.filter(c =>
c.parentId === null || c.parentId === undefined
);
return (
<div>
{rootConditions.map(condition => {
const index = conditions.indexOf(condition);
return (
<ConditionItem
key={`root-${index}`}
condition={condition}
index={index}
/>
);
})}
</div>
);
};
return (
<div className="space-y-4">
{conditions.length === 0 ? (
<Card>
<CardContent className="p-6 flex flex-col items-center justify-center">
<p className="text-muted-foreground mb-4">No conditions defined yet</p>
<div className="flex space-x-2">
<Button
variant="outline"
onClick={() => addCondition()}
className="flex items-center"
>
<Plus className="mr-2 h-4 w-4" />
Add Condition
</Button>
<Button
variant="outline"
onClick={() => addConditionGroup()}
className="flex items-center"
>
<FolderPlus className="mr-2 h-4 w-4" />
Add Condition Group
</Button>
</div>
</CardContent>
</Card>
) : (
<>
<div className="space-y-2">
{renderAllConditions()}
</div>
<div className="flex space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => addCondition()}
className="flex items-center"
>
<Plus className="mr-2 h-4 w-4" />
Add Condition
</Button>
<Button
variant="outline"
size="sm"
onClick={() => addConditionGroup()}
className="flex items-center"
>
<FolderPlus className="mr-2 h-4 w-4" />
Add Condition Group
</Button>
</div>
<div className="mt-4 p-4 bg-muted rounded-md">
<Label className="text-sm font-medium mb-2 block">Generated LDAP Filter:</Label>
<div className="font-mono text-sm overflow-auto p-2 bg-background rounded border whitespace-pre-wrap">
{generateLdapFilter(conditions)}
</div>
</div>
</>
)}
</div>
);
};
// 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<number | null | undefined, Condition[]>();
// 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 with a stack
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}`;
// Process regular conditions in this group
for (const condition of childConditions) {
if (!condition.isGroup) {
const filter = generateRegularFilter(condition);
if (filter) groupFilter += filter;
}
}
// Process subgroups in this group
for (const condition of childConditions) {
if (condition.isGroup && condition.id) {
// Use another group filter here
const subGroupFilter = generateGroupFilter(condition.id);
if (subGroupFilter !== '(objectClass=*)') {
groupFilter += subGroupFilter;
}
}
}
groupFilter += ')';
// If there are no actual conditions, return a default
if (groupFilter === `(${operator})`) {
return '(objectClass=*)';
}
return groupFilter;
};
// Start with the root level conditions
return generateGroupFilter(null);
};
export default ConditionBuilder;