Expression fields as choices: values from a list, conditions in words #35

Merged
jcoffey-dev merged 1 commits from feature/expression-simple-mode into main 2026-09-28 21:54:18 +00:00
6 changed files with 607 additions and 3 deletions

No files matched your search

+35 -3
View File
@@ -1,7 +1,10 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
import { useState } from 'react';
@@ -12,7 +15,9 @@ import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { X, Plus } from 'lucide-react';
import { X, Plus, ListChecks } from 'lucide-react';
import { SimpleExpressionEditor } from './SimpleExpressionEditor';
import type { ExpressionHints } from './simple';
function BufferedExprInput({
value,
@@ -48,14 +53,29 @@ interface ExpressionEditorProps {
};
onChange: (value: { match: Record<string, { if: string; then: string }>; else: string }) => void;
readOnly?: boolean;
/** inbuxa: what the field accepts, from the schema; with it, the field opens in plain form. */
hints?: ExpressionHints;
}
export function ExpressionEditor({ value, onChange, readOnly = false }: ExpressionEditorProps) {
export function ExpressionEditor({ value, onChange, readOnly = false, hints }: ExpressionEditorProps) {
const { t } = useTranslation();
const [nextIndex, setNextIndex] = useState(() => {
const keys = Object.keys(value.match).map(Number).filter(Number.isFinite);
return keys.length > 0 ? Math.max(...keys) + 1 : 0;
});
const [asText, setAsText] = useState(false);
if (hints && !asText) {
return (
<SimpleExpressionEditor
value={value}
onChange={onChange}
hints={hints}
readOnly={readOnly}
onEditAsText={() => setAsText(true)}
/>
);
}
const matchEntries = Object.entries(value.match);
@@ -135,7 +155,19 @@ export function ExpressionEditor({ value, onChange, readOnly = false }: Expressi
))}
{!readOnly && (
<div className="flex justify-end">
<div className={hints ? 'flex justify-between' : 'flex justify-end'}>
{hints && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setAsText(false)}
className="h-7 gap-1 px-2 text-xs text-muted-foreground hover:text-foreground"
>
<ListChecks className="h-3 w-3" />
{t('expression.editAsChoices', 'Edit as choices')}
</Button>
)}
<Button
type="button"
variant="ghost"
@@ -0,0 +1,299 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* inbuxa: an expression field as choices rather than code (settings-reorg).
* A value that is one of the field's constants is a dropdown; a condition
* that is one comparison is "variable / is / value". What can't be shown that
* way stays text, and "Edit as text" always gives the full IF / THEN / ELSE
* editor back.
*/
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Code2, Plus, X } from 'lucide-react';
import { useBufferedValue } from '@/hooks/useBufferedValue';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select';
import { cn } from '@/lib/utils';
import {
constantLabel,
formatCondition,
OPERATORS,
operatorLabel,
parseCondition,
summarize,
variableLabel,
type ExpressionHints,
type Operator,
} from './simple';
type Value = { match: Record<string, { if: string; then: string }>; else: string };
const CUSTOM = '__custom__';
function TextInput({
value,
onCommit,
className,
...rest
}: { value: string; onCommit: (v: string) => void } & Omit<
React.InputHTMLAttributes<HTMLInputElement>,
'onChange' | 'value'
>) {
const [local, setLocal] = useBufferedValue(value);
const commit = () => {
if (local !== value) onCommit(local);
};
return (
<Input
{...rest}
className={cn('font-mono text-[13px]', className)}
value={local}
onChange={(e) => setLocal(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === 'Enter') commit();
}}
/>
);
}
/** A THEN / ELSE value: a choice among the constants, or text when it's something else. */
function ValueField({
value,
hints,
onCommit,
readOnly,
}: {
value: string;
hints: ExpressionHints;
onCommit: (v: string) => void;
readOnly: boolean;
}) {
const { t } = useTranslation();
const [custom, setCustom] = useState(false);
const isConstant = value.trim() === '' || hints.constants.includes(value.trim());
if (hints.constants.length === 0 || custom || !isConstant) {
return (
<div className="flex flex-1 items-center gap-2">
<TextInput
value={value}
onCommit={(v) => {
onCommit(v);
if (hints.constants.includes(v.trim())) setCustom(false);
}}
disabled={readOnly}
placeholder={t('expression.value', 'Value')}
className="flex-1"
/>
</div>
);
}
return (
<Select
value={value.trim() || undefined}
disabled={readOnly}
onValueChange={(v) => (v === CUSTOM ? setCustom(true) : onCommit(v))}
>
<SelectTrigger className="flex-1">
<SelectValue placeholder={t('expression.choose', 'Choose…')} />
</SelectTrigger>
<SelectContent>
{hints.constants.map((c) => (
<SelectItem key={c} value={c}>
{constantLabel(c)}
</SelectItem>
))}
<SelectSeparator />
<SelectItem value={CUSTOM}>{t('expression.customValue', 'Custom expression…')}</SelectItem>
</SelectContent>
</Select>
);
}
/** An IF: "variable / is / value" when it's one comparison, text otherwise. */
function ConditionField({
text,
hints,
onCommit,
readOnly,
}: {
text: string;
hints: ExpressionHints;
onCommit: (v: string) => void;
readOnly: boolean;
}) {
const { t } = useTranslation();
const parsed = parseCondition(text, hints.variables);
// A new, empty condition starts in the simple form, held here until it has a value to write.
const [draft, setDraft] = useState<{ variable: string; op: Operator }>({ variable: '', op: '==' });
if (!parsed && text.trim() !== '') {
return (
<TextInput
value={text}
onCommit={onCommit}
disabled={readOnly}
placeholder={t('expression.condition', 'Condition')}
className="flex-1"
/>
);
}
const variable = parsed?.variable ?? draft.variable;
const op: Operator = parsed?.op ?? draft.op;
const literal = parsed?.value ?? '';
const write = (next: { variable?: string; op?: Operator; value?: string }) => {
const c = { variable, op, value: literal, ...next };
if (!c.variable) return;
if (c.value === '') {
setDraft({ variable: c.variable, op: c.op });
return;
}
onCommit(formatCondition(c));
};
return (
<div className="flex flex-1 flex-wrap items-center gap-2">
<Select value={variable || undefined} disabled={readOnly} onValueChange={(v) => write({ variable: v })}>
<SelectTrigger className="w-auto min-w-44 flex-1">
<SelectValue placeholder={t('expression.chooseVariable', 'Choose what to check…')} />
</SelectTrigger>
<SelectContent>
{hints.variables.map((v) => (
<SelectItem key={v} value={v}>
{variableLabel(v)}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={op} disabled={readOnly || !variable} onValueChange={(v) => write({ op: v as Operator })}>
<SelectTrigger className="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
{OPERATORS.map((o) => (
<SelectItem key={o} value={o}>
{operatorLabel(o)}
</SelectItem>
))}
</SelectContent>
</Select>
<TextInput
value={literal}
onCommit={(v) => write({ value: v })}
disabled={readOnly || !variable}
placeholder={t('expression.conditionValue', 'Value')}
className="w-40 font-sans"
/>
</div>
);
}
export function SimpleExpressionEditor({
value,
onChange,
hints,
readOnly = false,
onEditAsText,
}: {
value: Value;
onChange: (v: Value) => void;
hints: ExpressionHints;
readOnly?: boolean;
onEditAsText: () => void;
}) {
const { t } = useTranslation();
const entries = Object.entries(value.match);
const summary = entries.length > 0 ? summarize(value, hints) : null;
const setRule = (key: string, part: 'if' | 'then', v: string) =>
onChange({ ...value, match: { ...value.match, [key]: { ...value.match[key], [part]: v } } });
const addRule = () => {
const keys = Object.keys(value.match).map(Number).filter(Number.isFinite);
const key = String(keys.length > 0 ? Math.max(...keys) + 1 : 0);
onChange({ ...value, match: { ...value.match, [key]: { if: '', then: '' } } });
};
const removeRule = (key: string) => {
const { [key]: _gone, ...rest } = value.match;
void _gone;
onChange({ ...value, match: rest });
};
const label = 'w-20 shrink-0 text-xs font-medium text-muted-foreground';
return (
<div className="space-y-3 rounded-lg border bg-card p-4">
{summary && <p className="text-sm text-muted-foreground">{summary}</p>}
{entries.map(([key, rule]) => (
<div key={key} className="space-y-2 rounded-md border bg-muted/30 p-3">
<div className="flex items-start gap-2">
<span className={cn(label, 'pt-2.5')}>{t('expression.when', 'When')}</span>
<ConditionField text={rule.if} hints={hints} onCommit={(v) => setRule(key, 'if', v)} readOnly={readOnly} />
{!readOnly && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => removeRule(key)}
aria-label={t('expression.removeCondition', 'Remove condition')}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
<div className="flex items-center gap-2">
<span className={label}>{t('expression.use', 'Use')}</span>
<ValueField value={rule.then} hints={hints} onCommit={(v) => setRule(key, 'then', v)} readOnly={readOnly} />
{!readOnly && <div className="w-9 shrink-0" />}
</div>
</div>
))}
<div className="flex items-center gap-2">
{entries.length > 0 && <span className={label}>{t('expression.otherwise', 'Otherwise')}</span>}
<ValueField
value={value.else}
hints={hints}
onCommit={(v) => onChange({ ...value, else: v })}
readOnly={readOnly}
/>
{!readOnly && entries.length > 0 && <div className="w-9 shrink-0" />}
</div>
{!readOnly && (
<div className="flex items-center justify-between">
<Button
type="button"
variant="ghost"
size="sm"
onClick={addRule}
className="h-7 gap-1 px-2 text-xs text-muted-foreground hover:text-foreground"
>
<Plus className="h-3 w-3" />
{t('expression.addACondition', 'Add a condition')}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={onEditAsText}
className="h-7 gap-1 px-2 text-xs text-muted-foreground hover:text-foreground"
>
<Code2 className="h-3 w-3" />
{t('expression.editAsText', 'Edit as text')}
</Button>
</div>
)}
</div>
);
}
+77
View File
@@ -0,0 +1,77 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { describe, it, expect } from 'vitest';
import { describeValue, formatCondition, parseCondition, summarize, writeLiteral } from './simple';
const hints = {
constants: ['relaxed', 'strict', 'disable'],
variables: ['local_port', 'listener', 'sender_domain', 'remote_ip', 'is_tls'],
};
describe('parseCondition', () => {
it('reads one comparison of an allowed variable with a literal', () => {
expect(parseCondition('local_port == 25', hints.variables)).toEqual({
variable: 'local_port',
op: '==',
value: '25',
});
expect(parseCondition("listener != 'smtp'", hints.variables)).toEqual({
variable: 'listener',
op: '!=',
value: 'smtp',
});
expect(parseCondition('is_tls == true', hints.variables)).toEqual({ variable: 'is_tls', op: '==', value: 'true' });
});
it('leaves anything more complex alone', () => {
expect(parseCondition('is_local_domain(sender_domain)', hints.variables)).toBeNull();
expect(parseCondition('local_port == 25 && is_tls', hints.variables)).toBeNull();
expect(parseCondition('local_port == sender_domain', hints.variables)).toBeNull();
expect(parseCondition('unknown_var == 1', hints.variables)).toBeNull();
});
});
describe('literals', () => {
it('quotes words and leaves numbers, durations and booleans bare', () => {
expect(writeLiteral('smtp')).toBe("'smtp'");
expect(writeLiteral('25')).toBe('25');
expect(writeLiteral('5m')).toBe('5m');
expect(writeLiteral('false')).toBe('false');
expect(writeLiteral("it's")).toBe("'it\\'s'");
});
it('round-trips a condition', () => {
const c = parseCondition("listener == 'smtp'", hints.variables)!;
expect(formatCondition(c)).toBe("listener == 'smtp'");
expect(
parseCondition(formatCondition({ variable: 'listener', op: '==', value: "it's" }), hints.variables)?.value,
).toBe("it's");
});
});
describe('describeValue', () => {
it('names constants and unquotes literals, but not expressions', () => {
expect(describeValue('disable', hints)).toBe('Off');
expect(describeValue("'mx.example.com'", hints)).toBe('mx.example.com');
expect(describeValue('sender_domain', hints)).toBeNull();
});
});
describe('summarize', () => {
it('says the whole expression in words when it can', () => {
expect(summarize({ match: {}, else: 'relaxed' }, hints)).toBe('Relaxed');
expect(summarize({ match: { '0': { if: 'local_port == 25', then: 'relaxed' } }, else: 'disable' }, hints)).toBe(
'Relaxed when port on this server is 25; otherwise off',
);
});
it('gives up rather than guess', () => {
expect(
summarize({ match: { '0': { if: 'is_local_domain(sender_domain)', then: 'relaxed' } }, else: 'disable' }, hints),
).toBeNull();
});
});
+185
View File
@@ -0,0 +1,185 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* inbuxa: expression fields in plain words (settings-reorg, expression simple
* mode). The server's schema says, for each expression field, which constants
* it may evaluate to and which variables its conditions may read. With that,
* a value that is one of the constants becomes a choice ("Relaxed", "Off"),
* and a condition of the form `variable op literal` becomes three controls
* ("Port on this server" "is" "25"). Anything else stays text: the simple
* form never rewrites what it can't read.
*/
import { humanize } from '@/lib/humanize';
export interface ExpressionHints {
constants: string[];
variables: string[];
}
export type Operator = '==' | '!=' | '>' | '<' | '>=' | '<=';
export const OPERATORS: Operator[] = ['==', '!=', '>', '<', '>=', '<='];
export interface SimpleCondition {
variable: string;
op: Operator;
/** The literal as a person types it: no quotes. */
value: string;
}
const CONSTANT_LABELS: Record<string, string> = {
relaxed: 'Relaxed',
strict: 'Strict',
disable: 'Off',
optional: 'Optional',
require: 'Required',
ipv4_only: 'IPv4 only',
ipv6_only: 'IPv6 only',
ipv6_then_ipv4: 'IPv6, then IPv4',
ipv4_then_ipv6: 'IPv4, then IPv6',
hourly: 'Hourly',
daily: 'Daily',
weekly: 'Weekly',
login: 'LOGIN',
plain: 'PLAIN',
xoauth2: 'XOAUTH2',
oauthbearer: 'OAUTHBEARER',
mixer: 'MIXER',
stanag4406: 'STANAG 4406',
nsep: 'NSEP',
};
const VARIABLE_LABELS: Record<string, string> = {
local_port: 'Port on this server',
local_ip: 'IP on this server',
remote_ip: 'Remote IP',
remote_port: 'Remote port',
'remote_ip.ptr': 'Remote host name (PTR)',
listener: 'Listener',
protocol: 'Protocol',
is_tls: 'Connection uses TLS',
authenticated_as: 'Signed-in account',
sender: 'Sender',
sender_domain: 'Sender’s domain',
rcpt: 'Recipient',
rcpt_domain: 'Recipient’s domain',
recipients: 'Recipients',
helo_domain: 'Greeting name (EHLO)',
asn: 'Network (ASN)',
country: 'Country',
priority: 'Priority',
size: 'Message size',
mx: 'Mail exchanger',
host: 'Host',
domain: 'Domain',
ip: 'IP address',
is_v4: 'Address is IPv4',
is_v6: 'Address is IPv6',
reverse_ip: 'Reverse IP',
queue_name: 'Queue',
queue_age: 'Time in queue',
retry_num: 'Retries so far',
notify_num: 'Notices sent so far',
last_status: 'Last status',
last_error: 'Last error',
expires_in: 'Time until expiry',
source: 'Source',
received_from_ip: 'Received from IP',
received_via_port: 'Received on port',
env_from: 'Envelope sender',
env_to: 'Envelope recipient',
};
const OPERATOR_LABELS: Record<Operator, string> = {
'==': 'is',
'!=': 'is not',
'>': 'is more than',
'<': 'is less than',
'>=': 'is at least',
'<=': 'is at most',
};
export function constantLabel(name: string): string {
return CONSTANT_LABELS[name] ?? humanize(name);
}
export function variableLabel(name: string): string {
return VARIABLE_LABELS[name] ?? humanize(name);
}
export function operatorLabel(op: Operator): string {
return OPERATOR_LABELS[op];
}
const BARE_LITERAL = /^(?:-?\d+(?:\.\d+)?(?:ms|s|m|h|d)?|true|false)$/;
/** A literal from the expression, unquoted for display; null if it isn't a plain literal. */
function readLiteral(raw: string): string | null {
const s = raw.trim();
const quoted = /^(['"])((?:(?!\1)[^\\]|\\.)*)\1$/.exec(s);
if (quoted) return quoted[2].replace(/\\(.)/g, '$1');
return BARE_LITERAL.test(s) ? s : null;
}
/** A value as the expression needs it: numbers, durations and booleans bare, everything else quoted. */
export function writeLiteral(value: string): string {
const s = value.trim();
if (BARE_LITERAL.test(s)) return s;
return `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
}
/** `local_port == 25` → {local_port, ==, 25}; null for anything more than one comparison. */
export function parseCondition(text: string, variables: string[]): SimpleCondition | null {
const m = /^\s*([a-z_][a-z0-9_.]*)\s*(==|!=|>=|<=|>|<)\s*(.+?)\s*$/.exec(text);
if (!m || !variables.includes(m[1])) return null;
const value = readLiteral(m[3]);
if (value === null) return null;
return { variable: m[1], op: m[2] as Operator, value };
}
export function formatCondition(c: SimpleCondition): string {
return `${c.variable} ${c.op} ${writeLiteral(c.value)}`;
}
/** A value (THEN / ELSE) in words: a constant's label, a literal unquoted, or null if it's an expression. */
export function describeValue(text: string, hints: ExpressionHints): string | null {
const s = text.trim();
if (hints.constants.includes(s)) return constantLabel(s);
return readLiteral(s);
}
/** A label inside a sentence: "Remote port" → "remote port", but "IP on this server" stays. */
function midSentence(label: string): string {
return /^[A-Z][a-z]/.test(label) ? label[0].toLowerCase() + label.slice(1) : label;
}
export function describeCondition(text: string, hints: ExpressionHints): string | null {
const c = parseCondition(text, hints.variables);
return c ? `${midSentence(variableLabel(c.variable))} ${operatorLabel(c.op)} ${c.value}` : null;
}
/**
* The whole expression in one sentence, when every part of it can be said
* in words: "Relaxed when port on this server is 25; otherwise Off".
*/
export function summarize(
value: { match: Record<string, { if: string; then: string }>; else: string },
hints: ExpressionHints,
): string | null {
const rules = Object.values(value.match);
const otherwise = describeValue(value.else, hints);
if (otherwise === null) return null;
if (rules.length === 0) return otherwise;
const parts: string[] = [];
for (const rule of rules) {
const when = describeCondition(rule.if, hints);
const then = describeValue(rule.then, hints);
if (when === null || then === null) return null;
parts.push(`${then} when ${when}`);
}
return `${parts.join('; ')}; otherwise ${midSentence(otherwise)}`;
}
+6
View File
@@ -30,6 +30,7 @@ import { Calendar } from '@/components/ui/calendar';
import { Plus, X, Eye, EyeOff, Loader2, Search, Check, ChevronRight, Calendar as CalendarIcon } from 'lucide-react';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { ExpressionEditor } from '@/components/expression/ExpressionEditor';
import type { ExpressionHints } from '@/components/expression/simple';
import { OtpAuthField } from '@/components/forms/OtpAuthField';
import { SievepadButton } from '@/components/forms/SievepadButton';
import {
@@ -179,6 +180,7 @@ export function FieldWidget(props: FieldWidgetProps) {
schema={schema}
formField={formField}
nullable={ft.nullable}
expression={ft.expression}
/>
);
case 'objectList':
@@ -1395,6 +1397,8 @@ interface EmbeddedObjectFieldProps {
schema: Schema;
formField: FormField;
nullable?: boolean;
/** inbuxa: an expression field's allowed constants and variables, from the schema. */
expression?: ExpressionHints;
}
function EmbeddedObjectField({
@@ -1405,6 +1409,7 @@ function EmbeddedObjectField({
schema,
formField,
nullable,
expression,
}: EmbeddedObjectFieldProps) {
const { t } = useTranslation();
const objValue = (value as Record<string, unknown>) ?? {};
@@ -1418,6 +1423,7 @@ function EmbeddedObjectField({
}}
onChange={(v) => onChange(v)}
readOnly={readOnly}
hints={expression}
/>
);
}
+5
View File
@@ -1,7 +1,10 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
import type { Dashboard } from '@/features/dashboard/types/schema';
@@ -128,6 +131,8 @@ export interface FieldTypeObject {
type: 'object';
objectName: string;
nullable?: boolean;
/** inbuxa: on an x:Expression, the constants it may evaluate to and the variables it may read. */
expression?: { constants: string[]; variables: string[] };
}
export interface FieldTypeObjectList {