Add improved filtering in conditions block of workflows

This commit is contained in:
Dries Augustyns
2025-12-07 17:35:44 +01:00
parent 4d7c68db3a
commit 82e846e993
2 changed files with 292 additions and 95 deletions
+36 -27
View File
@@ -33,20 +33,6 @@ export interface WorkflowExecutionWithDetails extends WorkflowExecution {
}
export class WorkflowService {
/**
* Check if a workflow has active executions
*/
private static async hasActiveExecutions(workflowId: string): Promise<number> {
return prisma.workflowExecution.count({
where: {
workflowId,
status: {
in: [WorkflowExecutionStatus.RUNNING, WorkflowExecutionStatus.WAITING],
},
},
});
}
/**
* Get all workflows for a project with pagination
*/
@@ -400,8 +386,7 @@ export class WorkflowService {
if (activeExecutions > 0) {
// Only allow safe changes: name and position updates
const hasCriticalChanges =
data.config !== undefined || data.templateId !== undefined;
const hasCriticalChanges = data.config !== undefined || data.templateId !== undefined;
if (hasCriticalChanges) {
throw new HttpException(
@@ -930,10 +915,7 @@ export class WorkflowService {
/**
* Cancel all active executions for a workflow
*/
public static async cancelAllExecutions(
projectId: string,
workflowId: string,
): Promise<{cancelled: number}> {
public static async cancelAllExecutions(projectId: string, workflowId: string): Promise<{cancelled: number}> {
// Verify workflow exists and belongs to project
await this.get(projectId, workflowId);
@@ -959,22 +941,49 @@ export class WorkflowService {
* Get all available fields for workflow conditions (contact fields + event fields)
*/
public static async getAvailableFields(projectId: string, eventName?: string) {
// Get contact fields (standard + custom data fields)
// Get contact fields with types (standard + custom data fields)
const contactFieldsWithTypes = await ContactService.getAvailableFields(projectId);
// Extract just the field names and prefix with 'contact.'
const contactFields = contactFieldsWithTypes.map(f => `contact.${f.field}`);
// Build typed field list with 'contact.' prefix
const contactFields = contactFieldsWithTypes.map(f => ({
field: `contact.${f.field}`,
type: f.type,
category: f.field.startsWith('data.') ? 'Custom Data' : 'Contact Fields',
}));
// Get event fields by analyzing actual event data
// This will only show fields that have been seen in actual events
const eventFields = await EventService.getAvailableEventFields(projectId, eventName);
// Event fields are treated as dynamic (unknown type at runtime)
const eventFieldNames = await EventService.getAvailableEventFields(projectId, eventName);
const eventFields = eventFieldNames.map(field => ({
field,
type: 'string' as const, // Event fields default to string, can contain any JSON value
category: 'Event Data',
}));
// Combine all fields
const allFields = [...contactFields, ...eventFields].sort();
const allFields = [...contactFields, ...eventFields].sort((a, b) => a.field.localeCompare(b.field));
// Also return legacy format for backwards compatibility
const fieldNames = allFields.map(f => f.field);
return {
fields: allFields,
fields: fieldNames,
typedFields: allFields,
count: allFields.length,
};
}
/**
* Check if a workflow has active executions
*/
private static async hasActiveExecutions(workflowId: string): Promise<number> {
return prisma.workflowExecution.count({
where: {
workflowId,
status: {
in: [WorkflowExecutionStatus.RUNNING, WorkflowExecutionStatus.WAITING],
},
},
});
}
}
+256 -68
View File
@@ -828,9 +828,32 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo
const [conditionField, setConditionField] = useState('');
const [conditionOperator, setConditionOperator] = useState('equals');
const [conditionValue, setConditionValue] = useState('');
const [availableFields, setAvailableFields] = useState<string[]>([]);
const [availableFields, setAvailableFields] = useState<Array<{field: string; type: string; category: string}>>([]);
const [loadingFields, setLoadingFields] = useState(false);
// Get current field type for smart operator filtering
const currentFieldType = availableFields.find(f => f.field === conditionField)?.type || 'string';
// Get valid operators based on field type
const getOperatorsForType = (fieldType: string) => {
const allOperators = [
{value: 'equals', label: 'Equals', types: ['string', 'number', 'boolean', 'date']},
{value: 'notEquals', label: 'Not Equals', types: ['string', 'number', 'boolean', 'date']},
{value: 'contains', label: 'Contains', types: ['string']},
{value: 'notContains', label: 'Does not contain', types: ['string']},
{value: 'greaterThan', label: 'Greater than', types: ['number', 'date']},
{value: 'lessThan', label: 'Less than', types: ['number', 'date']},
{value: 'greaterThanOrEqual', label: 'Greater than or equal', types: ['number', 'date']},
{value: 'lessThanOrEqual', label: 'Less than or equal', types: ['number', 'date']},
{value: 'exists', label: 'Exists', types: ['string', 'number', 'boolean', 'date']},
{value: 'notExists', label: 'Does not exist', types: ['string', 'number', 'boolean', 'date']},
];
return allOperators.filter(op => op.types.includes(fieldType));
};
const validOperators = getOperatorsForType(currentFieldType);
const needsValue = !['exists', 'notExists'].includes(conditionOperator);
// WAIT_FOR_EVENT fields
const [eventName, setEventName] = useState('');
const [eventTimeoutAmount, setEventTimeoutAmount] = useState('1');
@@ -869,12 +892,17 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo
// Pass eventName as query param to filter event fields
const url = eventName ? `/workflows/fields?eventName=${encodeURIComponent(eventName)}` : '/workflows/fields';
const response = await network.fetch<{fields: string[]}>('GET', url);
setAvailableFields(response.fields);
const response = await network.fetch<{
fields: string[];
typedFields: Array<{field: string; type: string; category: string}>;
}>('GET', url);
setAvailableFields(
response.typedFields || response.fields.map(f => ({field: f, type: 'string', category: 'Unknown'})),
);
// Set default field if available
if (response.fields.length > 0 && !conditionField) {
setConditionField(response.fields[0]!);
if (response.typedFields && response.typedFields.length > 0 && !conditionField) {
setConditionField(response.typedFields[0]!.field);
}
} catch (error) {
console.error('Failed to fetch available fields:', error);
@@ -889,6 +917,26 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [type, open, workflow]);
// Handle condition field change - reset operator if not valid for new type
const handleConditionFieldChange = (newField: string) => {
const newFieldType = availableFields.find(f => f.field === newField)?.type || 'string';
const newValidOperators = getOperatorsForType(newFieldType);
setConditionField(newField);
// Reset operator if current one is not valid for new field type
if (!newValidOperators.some(op => op.value === conditionOperator)) {
setConditionOperator('equals');
}
// Reset value when switching to boolean
if (newFieldType === 'boolean') {
setConditionValue('true');
} else if (currentFieldType === 'boolean' && newFieldType !== 'boolean') {
setConditionValue('');
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
@@ -1196,15 +1244,32 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo
</div>
) : availableFields.length > 0 ? (
<>
<Select value={conditionField} onValueChange={setConditionField} required>
<Select value={conditionField} onValueChange={handleConditionFieldChange} required>
<SelectTrigger id="conditionField">
<SelectValue placeholder="Select a field..." />
</SelectTrigger>
<SelectContent>
{availableFields.map(field => (
<SelectItem key={field} value={field}>
{field}
</SelectItem>
{/* Group fields by category */}
{Object.entries(
availableFields.reduce<Record<string, typeof availableFields>>((acc, field) => {
if (!acc[field.category]) acc[field.category] = [];
acc[field.category]!.push(field);
return acc;
}, {}),
).map(([category, fields]) => (
<div key={category}>
<div className="px-2 py-1.5 text-xs font-semibold text-neutral-500">{category}</div>
{fields.map(field => (
<SelectItem key={field.field} value={field.field}>
<div className="flex items-center gap-2">
<span>{field.field.replace('contact.', '').replace('data.', '')}</span>
<span className="text-xs px-1.5 py-0.5 rounded bg-neutral-200 text-neutral-600 font-mono">
{field.type}
</span>
</div>
</SelectItem>
))}
</div>
))}
</SelectContent>
</Select>
@@ -1221,7 +1286,7 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo
value={conditionField}
onChange={e => setConditionField(e.target.value)}
required
placeholder="e.g., subscribed or data.plan"
placeholder="e.g., contact.subscribed or contact.data.plan"
/>
<p className="text-xs text-neutral-500 mt-1">
No fields found in contacts. Enter a field manually.
@@ -1237,34 +1302,63 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="equals">Equals</SelectItem>
<SelectItem value="notEquals">Not Equals</SelectItem>
<SelectItem value="contains">Contains</SelectItem>
<SelectItem value="notContains">Not Contains</SelectItem>
<SelectItem value="greaterThan">Greater Than</SelectItem>
<SelectItem value="lessThan">Less Than</SelectItem>
<SelectItem value="greaterThanOrEqual">Greater Than or Equal</SelectItem>
<SelectItem value="lessThanOrEqual">Less Than or Equal</SelectItem>
<SelectItem value="exists">Exists</SelectItem>
<SelectItem value="notExists">Not Exists</SelectItem>
{validOperators.map(op => (
<SelectItem key={op.value} value={op.value}>
{op.label}
</SelectItem>
))}
</SelectContent>
</Select>
{currentFieldType && (
<p className="text-xs text-neutral-500 mt-1">
Showing operators for{' '}
<span className="font-mono bg-neutral-200 px-1 rounded">{currentFieldType}</span> fields
</p>
)}
</div>
<div>
<Label htmlFor="conditionValue">Value *</Label>
<Input
id="conditionValue"
type="text"
value={conditionValue}
onChange={e => setConditionValue(e.target.value)}
required
placeholder="e.g., true, false, premium, 100"
/>
<p className="text-xs text-neutral-500 mt-1">
Enter: true/false for booleans, numbers for comparisons, or text for strings
</p>
</div>
{needsValue && (
<div>
<Label htmlFor="conditionValue">Value *</Label>
{currentFieldType === 'boolean' ? (
<Select value={conditionValue || 'true'} onValueChange={setConditionValue}>
<SelectTrigger id="conditionValue">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">True</SelectItem>
<SelectItem value="false">False</SelectItem>
</SelectContent>
</Select>
) : currentFieldType === 'number' ? (
<Input
id="conditionValue"
type="number"
value={conditionValue}
onChange={e => setConditionValue(e.target.value)}
required
placeholder="e.g., 100"
/>
) : currentFieldType === 'date' ? (
<Input
id="conditionValue"
type="datetime-local"
value={conditionValue}
onChange={e => setConditionValue(e.target.value)}
required
/>
) : (
<Input
id="conditionValue"
type="text"
value={conditionValue}
onChange={e => setConditionValue(e.target.value)}
required
placeholder="e.g., premium, active"
/>
)}
</div>
)}
</div>
)}
@@ -1485,9 +1579,52 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
});
const [conditionOperator, setConditionOperator] = useState(String(config?.operator || 'equals'));
const [conditionValue, setConditionValue] = useState(String(config?.value ?? ''));
const [availableFields, setAvailableFields] = useState<string[]>([]);
const [availableFields, setAvailableFields] = useState<Array<{field: string; type: string; category: string}>>([]);
const [loadingFields, setLoadingFields] = useState(false);
// Get current field type for smart operator filtering
const currentFieldType = availableFields.find(f => f.field === conditionField)?.type || 'string';
// Get valid operators based on field type
const getOperatorsForType = (fieldType: string) => {
const allOperators = [
{value: 'equals', label: 'Equals', types: ['string', 'number', 'boolean', 'date']},
{value: 'notEquals', label: 'Not Equals', types: ['string', 'number', 'boolean', 'date']},
{value: 'contains', label: 'Contains', types: ['string']},
{value: 'notContains', label: 'Does not contain', types: ['string']},
{value: 'greaterThan', label: 'Greater than', types: ['number', 'date']},
{value: 'lessThan', label: 'Less than', types: ['number', 'date']},
{value: 'greaterThanOrEqual', label: 'Greater than or equal', types: ['number', 'date']},
{value: 'lessThanOrEqual', label: 'Less than or equal', types: ['number', 'date']},
{value: 'exists', label: 'Exists', types: ['string', 'number', 'boolean', 'date']},
{value: 'notExists', label: 'Does not exist', types: ['string', 'number', 'boolean', 'date']},
];
return allOperators.filter(op => op.types.includes(fieldType));
};
const validOperators = getOperatorsForType(currentFieldType);
const needsValue = !['exists', 'notExists'].includes(conditionOperator);
// Handle condition field change - reset operator if not valid for new type
const handleConditionFieldChange = (newField: string) => {
const newFieldType = availableFields.find(f => f.field === newField)?.type || 'string';
const newValidOperators = getOperatorsForType(newFieldType);
setConditionField(newField);
// Reset operator if current one is not valid for new field type
if (!newValidOperators.some(op => op.value === conditionOperator)) {
setConditionOperator('equals');
}
// Reset value when switching to boolean
if (newFieldType === 'boolean') {
setConditionValue('true');
} else if (currentFieldType === 'boolean' && newFieldType !== 'boolean') {
setConditionValue('');
}
};
// WAIT_FOR_EVENT fields
const [eventName, setEventName] = useState(String(config?.eventName || ''));
const [eventTimeoutAmount, setEventTimeoutAmount] = useState<string>(() => {
@@ -1545,8 +1682,13 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
// Pass eventName as query param to filter event fields
const url = eventName ? `/workflows/fields?eventName=${encodeURIComponent(eventName)}` : '/workflows/fields';
const response = await network.fetch<{fields: string[]}>('GET', url);
setAvailableFields(response.fields);
const response = await network.fetch<{
fields: string[];
typedFields: Array<{field: string; type: string; category: string}>;
}>('GET', url);
setAvailableFields(
response.typedFields || response.fields.map(f => ({field: f, type: 'string', category: 'Unknown'})),
);
} catch (error) {
console.error('Failed to fetch available fields:', error);
setAvailableFields([]);
@@ -1814,15 +1956,32 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
</div>
) : availableFields.length > 0 ? (
<>
<Select value={conditionField} onValueChange={setConditionField} required>
<Select value={conditionField} onValueChange={handleConditionFieldChange} required>
<SelectTrigger id="editConditionField">
<SelectValue placeholder="Select a field..." />
</SelectTrigger>
<SelectContent>
{availableFields.map(field => (
<SelectItem key={field} value={field}>
{field}
</SelectItem>
{/* Group fields by category */}
{Object.entries(
availableFields.reduce<Record<string, typeof availableFields>>((acc, field) => {
if (!acc[field.category]) acc[field.category] = [];
acc[field.category]!.push(field);
return acc;
}, {}),
).map(([category, fields]) => (
<div key={category}>
<div className="px-2 py-1.5 text-xs font-semibold text-neutral-500">{category}</div>
{fields.map(field => (
<SelectItem key={field.field} value={field.field}>
<div className="flex items-center gap-2">
<span>{field.field.replace('contact.', '').replace('data.', '')}</span>
<span className="text-xs px-1.5 py-0.5 rounded bg-neutral-200 text-neutral-600 font-mono">
{field.type}
</span>
</div>
</SelectItem>
))}
</div>
))}
</SelectContent>
</Select>
@@ -1839,7 +1998,7 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
value={conditionField}
onChange={e => setConditionField(e.target.value)}
required
placeholder="e.g., subscribed or data.plan"
placeholder="e.g., contact.subscribed or contact.data.plan"
/>
<p className="text-xs text-neutral-500 mt-1">
No fields found in contacts. Enter a field manually.
@@ -1855,34 +2014,63 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="equals">Equals</SelectItem>
<SelectItem value="notEquals">Not Equals</SelectItem>
<SelectItem value="contains">Contains</SelectItem>
<SelectItem value="notContains">Not Contains</SelectItem>
<SelectItem value="greaterThan">Greater Than</SelectItem>
<SelectItem value="lessThan">Less Than</SelectItem>
<SelectItem value="greaterThanOrEqual">Greater Than or Equal</SelectItem>
<SelectItem value="lessThanOrEqual">Less Than or Equal</SelectItem>
<SelectItem value="exists">Exists</SelectItem>
<SelectItem value="notExists">Not Exists</SelectItem>
{validOperators.map(op => (
<SelectItem key={op.value} value={op.value}>
{op.label}
</SelectItem>
))}
</SelectContent>
</Select>
{currentFieldType && (
<p className="text-xs text-neutral-500 mt-1">
Showing operators for{' '}
<span className="font-mono bg-neutral-200 px-1 rounded">{currentFieldType}</span> fields
</p>
)}
</div>
<div>
<Label htmlFor="editConditionValue">Value *</Label>
<Input
id="editConditionValue"
type="text"
value={conditionValue}
onChange={e => setConditionValue(e.target.value)}
required
placeholder="e.g., true, false, premium, 100"
/>
<p className="text-xs text-neutral-500 mt-1">
Enter: true/false for booleans, numbers for comparisons, or text for strings
</p>
</div>
{needsValue && (
<div>
<Label htmlFor="editConditionValue">Value *</Label>
{currentFieldType === 'boolean' ? (
<Select value={conditionValue || 'true'} onValueChange={setConditionValue}>
<SelectTrigger id="editConditionValue">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">True</SelectItem>
<SelectItem value="false">False</SelectItem>
</SelectContent>
</Select>
) : currentFieldType === 'number' ? (
<Input
id="editConditionValue"
type="number"
value={conditionValue}
onChange={e => setConditionValue(e.target.value)}
required
placeholder="e.g., 100"
/>
) : currentFieldType === 'date' ? (
<Input
id="editConditionValue"
type="datetime-local"
value={conditionValue}
onChange={e => setConditionValue(e.target.value)}
required
/>
) : (
<Input
id="editConditionValue"
type="text"
value={conditionValue}
onChange={e => setConditionValue(e.target.value)}
required
placeholder="e.g., premium, active"
/>
)}
</div>
)}
</div>
)}