A schema-driven visual query builder that lets you construct complex nested database queries through a graphical interface — no raw syntax required. Built for the Frontend Wizards Stage 8 challenge.
Origami lets you visually compose queries with nested AND/OR logic, preview the output in SQL, MongoDB, or JSON format, and execute against a live mock dataset — all in real time.
It supports unlimited nesting depth, schema-aware input controls, drag-and-drop reordering, query history, saved presets, export/import, and keyboard-first workflows.
- Visual query builder — add rules, nest groups, combine AND/OR logic
- Schema-driven rendering — fields, operators, and input types adapt per schema (Users, Orders, Products)
- Live query preview — SQL, MongoDB filter, and JSON output update in real time
- Query execution — filter mock datasets (50 rows each) and inspect results
- Three output formats — SQL, MongoDB (
$and/$or), JSON filter object
- Unlimited nesting — condition groups nest to any depth
- Drag-and-drop reordering — reorder rules and groups within any parent
- Query history — last 20 executed queries, persisted to localStorage
- Saved presets — name, save, load, and delete query presets
- Export / Import — export query as
.json, import with structural validation - Keyboard shortcuts — execute, save, copy, undo, toggle panel, help modal
- Collapsible bottom panel — preview and results collapse with
Cmd+J - Collapsible sidebar — icon-only mode on desktop, off-canvas on mobile
- Dark / Light mode — system default detection with manual toggle
- Responsive — landing page fully responsive, builder gracefully degraded on mobile
regex— with client-side pattern validationisNull/isNotNull— fieldtype-independentbefore/after/between— date comparison operators
| Concern | Choice |
|---|---|
| Framework | Next.js 16 (App Router) |
| Language | TypeScript (strict) |
| Styling | TailwindCSS v4 + CSS variables |
| Components | shadcn/ui (Origami theme) |
| State | Zustand + Immer |
| Drag and drop | DnD Kit |
| Animation | Framer Motion |
| Icons | Hugeicons React |
| Font | Geist Sans + Geist Mono |
| Virtualization | TanStack Virtual (results > 100 rows) |
| Testing | Vitest + React Testing Library |
| Deployment | Vercel |
npm install
npm run devnpm run test
npm run test:coverage| Route | Description |
|---|---|
/ |
Landing page |
/builder |
Query builder |
/builder/schema |
Schema reference — fields, types, valid operators |
/builder/history |
Query history — last 20 executed queries |
/builder/presets |
Saved queries — save, load, delete presets |
src/
├── app/
│ ├── page.tsx # Landing page
│ ├── builder/
│ │ ├── page.tsx # Query builder
│ │ ├── schema/page.tsx # Schema reference
│ │ ├── history/page.tsx # Query history
│ │ └── presets/page.tsx # Saved presets
│ ├── layout.tsx
│ └── globals.css
├── components/
│ ├── builder/ # Query builder UI components
│ ├── landing/ # Landing page sections
│ ├── preview/ # Query preview panel
│ ├── results/ # Results table and states
│ ├── sidebar/ # Sidebar panels (legacy)
│ └── ui/ # Shared UI primitives
├── store/
│ ├── queryStore.ts # Query tree state
│ ├── schemaStore.ts # Active schema state
│ └── uiStore.ts # UI state, history, presets, results
├── lib/
│ ├── queryEngine/
│ │ ├── types.ts # RuleNode, GroupNode, QueryTree
│ │ ├── generator.ts # Tree → SQL / Mongo / JSON
│ │ ├── executor.ts # Tree → filter dataset
│ │ ├── validator.ts # Tree → ValidationError[]
│ │ └── serializer.ts # Export / import JSON
│ ├── schemas/ # Schema definitions
│ ├── mockData/ # 50-row mock datasets
│ └── utils/
│ ├── operators.ts # Type → operator compatibility map
│ └── nodeHelpers.ts # Pure tree manipulation functions
├── hooks/
│ └── useKeyboardShortcuts.ts
└── __tests__/ # Unit and integration tests
The query builder is built around a recursive QueryNode type:
type QueryNode = RuleNode | GroupNode
type GroupNode = { id: string; type: 'group'; logic: 'AND' | 'OR'; children: QueryNode[] }
type RuleNode = { id: string; type: 'rule'; field: string; operator: Operator; value: unknown }The <ConditionGroup> component renders itself recursively:
{group.children.map(child =>
child.type === 'rule'
? <RuleRow key={child.id} ruleId={child.id} />
: <ConditionGroup key={child.id} groupId={child.id} depth={depth + 1} />
)}The same recursive pattern is used in the query engine — generateSQL, executeQuery, and validateTree all traverse the tree with typed recursive functions, avoiding switch-case sprawl and remaining extensible.
The application uses three Zustand stores:
queryStore — owns the query tree and validation state
- Tree mutations use Immer for immutable updates
- Validation runs after every mutation via
revalidate(schema) - Touched nodes tracked separately so errors only show after user interaction
schemaStore — tracks the active schema key
- Schema definitions and mock datasets are static imports, not stored state
- Switching schema resets the query tree
uiStore — owns everything else
- Query history (last 20, persisted to localStorage)
- Saved presets (persisted to localStorage)
- Execution result (persisted across panel collapse/expand)
- Panel open/closed state
isPanelOpendrives the collapsible bottom panel
All store actions are pure functions. No side effects inside reducers.
The query engine lives entirely in src/lib/queryEngine/ and has zero UI dependencies. It is a set of pure functions over the QueryTree type.
Generator (generator.ts)
generateSQL(tree, tableName)— produces parenthesized SQL WHERE clausegenerateMongo(tree)— produces$and/$ornested MongoDB filtergenerateJSONFilter(tree)— produces normalized JSON filter object- All three skip incomplete rules (no field or no value) rather than erroring
Executor (executor.ts)
executeQuery(tree, dataset)— filters an array of rows against the query tree- Incomplete rules are pass-through (
true) so partial forms don't silently filter out all rows - Invalid regex patterns return
falserather than throwing
Validator (validator.ts)
validateTree(tree, schema)— returnsValidationError[]with nodeId and message per error- Checks: empty groups, missing fields/values, operator/type incompatibility, invalid regex, missing
valueToforbetween - Only errors for nodes in
touchedNodesare surfaced in the UI
Serializer (serializer.ts)
exportQuery(tree, schemaKey)— JSON with version, schemaKey, exportedAt, treeimportQuery(json)— parses and structurally validates; returnsImportErroron failure- Validates: required keys, node types, max nesting depth of 10, array children
React.memoon all builder components —RuleRow,ConditionGroup,FieldSelector,OperatorSelector,ValueInputuseCallbackon all event handlers inConditionGroupto prevent child re-rendersuseMemofor query generation inQueryPreview— only recomputes when tree changes- Stable nanoid node IDs prevent full subtree re-mounts on reorder
- TanStack Virtual for result sets over 100 rows — only ~15 DOM nodes rendered at once
- Validation filtered to touched nodes only — no error churn during tree construction
| Shortcut | Action |
|---|---|
Cmd/Ctrl + Enter |
Execute query (opens panel first if collapsed) |
Cmd/Ctrl + J |
Toggle output panel |
Cmd/Ctrl + S |
Save preset |
Cmd/Ctrl + Shift + C |
Copy current query |
? |
Open shortcuts help modal |
No real database connectivity — the executor runs entirely in-memory against mock datasets. This keeps the app self-contained and deployable without a backend, at the cost of not being able to test against real data.
Validation on touch, not on submit — errors surface after a field is interacted with rather than on form submit. This is more forgiving during query construction but means a partially filled query can be executed with silent incomplete rules (which are treated as pass-through).
Single-file query tree — the entire query tree lives in one Zustand store slice. For very deep trees (10+ levels) with many rules, this means every tree mutation triggers a full re-render of the root. Mitigated with memoization but not eliminated entirely.
DnD within parent only — drag-and-drop reordering works within the same parent group. Cross-group drag (moving a rule from one group into another) is not supported. This keeps the DnD implementation straightforward while covering the most common reorder use case.
localStorage for persistence — history and presets persist to localStorage. This is simple and requires no backend, but data is lost if the user clears browser storage and is not shared across devices.
Auto-deployed to Vercel on merge to main. Pull requests generate preview deployments automatically.
npm run build # verify before merge