v1.0

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

PropTypeDefaultDescription
fields*FilterField[]—
groups*ConditionGroup[]—
matchMode*MatchMode—
onChange*(groups: ConditionGroup[], matchMode: MatchMode) => void—
emptyTextstring'No conditions — matches every row.'What the builder says when there are no groups at all.