Expression fields as choices: values from a list, conditions in words #35
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user