ConditionGroupsBuilder
Preview
Basic
Loading…
Preview
Code
ts
import ConditionGroupsBuilder from '@/components/form/ConditionGroupsBuilder';src/components/form/ConditionGroupsBuilder.tsx
AI prompt
text
Build an OR-of-AND condition-groups builder (Lark Base automation style) component in React + TypeScript + Tailwind CSS.
## Look
- Container `space-y-2`. When there are groups, a right-aligned line (`text-[11px] text-slate-500`): "Matching [all ▾] of the conditions in each group" with a compact select (`w-auto py-1 text-xs`).
- No groups: an empty strip `rounded-lg bg-slate-50 dark:bg-slate-900/40 px-3 py-4 text-center text-xs text-slate-400` — "No conditions — matches every row."
- Each group is a light-grey card `rounded-lg bg-slate-100 p-2 dark:bg-slate-900/50`: rows on the left (`flex-1 min-w-0 space-y-1.5`), a remove-group X top-right (`p-1 rounded text-slate-400 hover:bg-slate-200 hover:text-rose-600`, dark hover bg slate-700). An empty group shows `rounded bg-white dark:bg-slate-800/60 px-2 py-2 text-center text-[11px] text-slate-400` — "No conditions in this group." Under the rows, an add button reading "+ And" or "+ Or" to match the mode (12px Plus).
- Between groups (`my-1.5`): two `h-px flex-1 bg-slate-200 dark:bg-slate-700` rules around an "OR" label (11px medium uppercase tracking-wide slate-400). A LABEL, never a control — groups are always OR'd.
- At the foot: "+ Add condition group".
- Add buttons: `inline-flex items-center gap-1 rounded px-2 py-1 text-xs font-medium text-indigo-600 hover:bg-indigo-50`, dark `text-indigo-400 hover:bg-indigo-950/40`, disabled 40%.
- A condition row: `flex items-center gap-1.5` — joiner (`w-10 text-right text-[11px] text-slate-400`; blank on a group's first row, the card already says where it sits; then "and" / "or"), field select `w-44`, operator select `w-36`, value control `flex-1 min-w-0`, remove X (`p-1 rounded text-slate-400 hover:bg-slate-100 hover:text-rose-600`). Controls are house inputs at `py-1.5 text-xs`.
## Row rules
- Operators by kind — text / select: is, is not, contains, doesn't contain, is empty, is not empty; number: is, is not, >, ≥, <, ≤, is empty, is not empty; date: is, is before, is after, is empty, is not empty; bool: is; ref: is, is not, is empty, is not empty.
- Value control: a slate-400 "—" for is empty / is not empty; a select of the options when the field has them (for a select field with contains / doesn't contain, a multi-pick dropdown of coloured option pills with a search box, stored comma-joined); Checked / Unchecked for bool; for date + is, a mode select (Exact date, Today, Tomorrow, Yesterday, This / Last week, This / Last month, past / next 7 and 30 days) with a date input only for Exact date (relative stored as `rel:<mode>`); otherwise a date / number / text input, "Enter a value".
- A new row takes the first field and its first operator. Changing the field keeps the operator if the new kind allows it, else its first; the value clears. Changing the operator clears the value.
## Behaviour
- STATELESS and controlled: every edit calls `onChange(groups, matchMode)`; the owner keeps the draft and decides what a change means. New groups start empty.
- One shared `matchMode` joins the rows inside every group. Limits: 10 groups, 20 rows per group — the add buttons disable there, and when there are no fields.
- Ship the evaluator alongside: a row set matches if any group's rows match all / any; no groups match everything; an EMPTY group also matches everything, so allow it while editing but have an owner that saves refuse it. Half-built rows (valued operator, empty value) are skipped.
## API
`fields: { id; label; kind: 'text' | 'number' | 'date' | 'select' | 'bool' | 'ref'; options?: { value; label; tone? }[] }[]`, `groups: { conditions: { field; op; value: string }[] }[]`, `matchMode: 'all' | 'any'`, `onChange(groups, matchMode)`, `emptyText = 'No conditions — matches every row.'`.
## Demo
Over a task list's fields (Task, Status, Priority, Team, Owner, Tags, Estimate, Due, Billable), in `max-w-2xl`: "Priority is Urgent" OR ("Status is In review" and "Billable is Checked"), with "N of 16 tasks match." underneath.
## House style (applies to everything above)
- Stack: React 19 + TypeScript + Tailwind CSS v4, icons from lucide-react. One self-contained file; default-export the component and named-export its types. `'use client'` if it has state, refs or handlers.
- Font Inter; palette indigo on slate. Primary accent indigo-600 (hover indigo-700, dark mode indigo-400). Body text slate-700 / dark slate-200; secondary slate-500 / dark slate-400.
- Dark mode is a `.dark` class on <html> (not prefers-color-scheme). Every colour needs its `dark:` pair.
- Compact admin scale: text-xs (12px) for controls and body, 10–11px for meta, rounded-lg (8px) controls, rounded-2xl (16px) cards.
- Card surface ("panel"): `bg-white/60 dark:bg-slate-800/60 backdrop-blur-xl border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-lg`, on a soft slate gradient page background.
- Floating surfaces (dropdowns, popovers, menus) are OPAQUE: `bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no backdrop blur (it creates a stacking context that traps the popover's z-index). In-flow popovers are z-50; portalled overlays z-200.
- Text inputs and select triggers: `w-full px-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500`.
- Field labels: 11px semibold slate-600. Section titles: 10px semibold uppercase wide-tracking slate-500.
- Primary button: indigo-600 fill, white 12px semibold text, rounded-lg, px-3 py-2, disabled at 50% opacity. Ghost button: slate-600 text, hover slate-100.
- Popovers close on outside click AND on Escape (listen to both; include the portalled panel's element in the outside-click check).
- Don't nest scroll containers around popovers: an ancestor with overflow hidden/auto clips an absolutely-positioned dropdown. Portal the panel to <body> when it must escape a scroller, and reposition it on scroll and resize.
- Accessible by default: visible focus rings, keyboard support that matches the WAI-ARIA pattern for the widget, `aria-label` on icon-only buttons, `min-w-0` so text truncates instead of overflowing.Source
tsx
'use client';
/* Origin: ticket-management (96S2) `tickets/ConditionGroupsBuilder.tsx`. */
import { Plus, X } from 'lucide-react';
import {
MAX_CONDITION_GROUPS,
MAX_CONDITIONS,
type ConditionGroup,
type FilterField,
type MatchMode,
type Operator,
} from '@/lib/conditions';
import { PANEL_EMPTY_CLASS, panelAddButtonClass } from '@/lib/toolbar';
import { ConditionRow, blankCondition, nextConditionForField } from './FilterPanel';
/** The first row of a GROUP carries no joiner word — the group's own card and
* the "Or" divider above it already say where it sits. */
function groupJoiner(index: number, matchMode: MatchMode): string {
return index === 0 ? '' : matchMode === 'any' ? 'or' : 'and';
}
/**
* OR-of-AND condition GROUPS — Lark Base's automation shape. Each group is a
* light-grey card of one or more `[field] [operator] [value]` rows, built from
* `FilterPanel`'s own exported `ConditionRow` (never a second implementation
* of it); rows within a group are joined by the SHARED `matchMode`; a plain
* "Or" LABEL sits between groups — never a control, because groups are
* always OR'd.
*
* Stateless, like `ConditionRow`: the owner keeps the draft and decides what a
* change means. An empty group beside a filled one is allowed here as a DRAFT
* state (the editor is mid-edit); `groupsMatch` reads an empty group as
* "matches everything", so an owner that saves should refuse that shape.
*/
export default function ConditionGroupsBuilder({
fields,
groups,
matchMode,
onChange,
emptyText = 'No conditions — matches every row.',
}: {
fields: FilterField[];
groups: ConditionGroup[];
matchMode: MatchMode;
onChange: (groups: ConditionGroup[], matchMode: MatchMode) => void;
/** What the builder says when there are no groups at all. */
emptyText?: string;
}) {
const setGroupConditions = (gi: number, conditions: ConditionGroup['conditions']) =>
onChange(groups.map((g, i) => (i === gi ? { conditions } : g)), matchMode);
const addGroup = () => {
if (groups.length >= MAX_CONDITION_GROUPS) return;
onChange([...groups, { conditions: [] }], matchMode);
};
const removeGroup = (gi: number) => onChange(groups.filter((_, i) => i !== gi), matchMode);
const addRow = (gi: number) => {
const blank = blankCondition(fields);
if (blank) setGroupConditions(gi, [...groups[gi].conditions, blank]);
};
const changeRowField = (gi: number, ci: number, fieldId: string) => {
const next = nextConditionForField(fields, groups[gi].conditions[ci], fieldId);
if (next) setGroupConditions(gi, groups[gi].conditions.map((c, i) => (i === ci ? next : c)));
};
const changeRowOp = (gi: number, ci: number, op: Operator) =>
setGroupConditions(gi, groups[gi].conditions.map((c, i) => (i === ci ? { ...c, op, value: '' } : c)));
const changeRowValue = (gi: number, ci: number, value: string) =>
setGroupConditions(gi, groups[gi].conditions.map((c, i) => (i === ci ? { ...c, value } : c)));
const removeRow = (gi: number, ci: number) =>
setGroupConditions(gi, groups[gi].conditions.filter((_, i) => i !== ci));
return (
<div className="space-y-2">
{groups.length > 0 && (
<div className="flex items-center justify-end gap-1.5 text-[11px] text-slate-500 dark:text-slate-400">
Matching
<select value={matchMode} onChange={(e) => onChange(groups, e.target.value as MatchMode)} className="field-input w-auto py-1 text-xs">
<option value="all">all</option>
<option value="any">any</option>
</select>
of the conditions in each group
</div>
)}
{groups.length === 0 ? (
<p className={PANEL_EMPTY_CLASS}>{emptyText}</p>
) : (
<div className="space-y-1.5">
{groups.map((group, gi) => (
<div key={gi}>
{gi > 0 && (
<div className="my-1.5 flex items-center gap-2">
<div className="h-px flex-1 bg-slate-200 dark:bg-slate-700" />
<span className="text-[11px] font-medium uppercase tracking-wide text-slate-400">Or</span>
<div className="h-px flex-1 bg-slate-200 dark:bg-slate-700" />
</div>
)}
<div className="rounded-lg bg-slate-100 p-2 dark:bg-slate-900/50">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1 space-y-1.5">
{group.conditions.length === 0 ? (
<p className="rounded bg-white px-2 py-2 text-center text-[11px] text-slate-400 dark:bg-slate-800/60">
No conditions in this group.
</p>
) : (
group.conditions.map((c, ci) => (
<ConditionRow
key={ci}
fields={fields}
condition={c}
joiner={groupJoiner(ci, matchMode)}
onChangeField={(fieldId) => changeRowField(gi, ci, fieldId)}
onChangeOp={(op) => changeRowOp(gi, ci, op)}
onChangeValue={(v) => changeRowValue(gi, ci, v)}
onRemove={() => removeRow(gi, ci)}
/>
))
)}
<button
type="button"
onClick={() => addRow(gi)}
disabled={group.conditions.length >= MAX_CONDITIONS || fields.length === 0}
className={panelAddButtonClass()}
>
<Plus className="h-3 w-3" /> {matchMode === 'any' ? 'Or' : 'And'}
</button>
</div>
<button
type="button"
aria-label="Remove this condition group"
onClick={() => removeGroup(gi)}
className="shrink-0 rounded p-1 text-slate-400 hover:bg-slate-200 hover:text-rose-600 dark:hover:bg-slate-700"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
</div>
</div>
))}
</div>
)}
<button
type="button"
onClick={addGroup}
disabled={groups.length >= MAX_CONDITION_GROUPS || fields.length === 0}
className={panelAddButtonClass()}
>
<Plus className="h-3.5 w-3.5" /> Add condition group
</button>
</div>
);
}
Props
| Prop | Type | Default | Description |
|---|---|---|---|
fields* | FilterField[] | — | |
groups* | ConditionGroup[] | — | |
matchMode* | MatchMode | — | |
onChange* | (groups: ConditionGroup[], matchMode: MatchMode) => void | — | |
emptyText | string | 'No conditions — matches every row.' | What the builder says when there are no groups at all. |