Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 18 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,14 +88,15 @@ There is no Vapor CI gate until Vue 3.6 is stable; the constraint is upheld by r

- Build class strings with core's `clsx` exclusively. **Never** template interpolation — it
introduces whitespace differences.
- `Label` and `slotToComponent` are **functional components, never SFCs**. An SFC emits whitespace
- `QueryBuilderLabel` and `slotToComponent` are **functional components, never SFCs**. An SFC emits whitespace
text nodes, which breaks byte-level parity.
- Element order and conditional rendering are specified by React's `Rule.tsx` / `RuleGroup.tsx`.
Read them as a spec, not as code to translate.
- Template whitespace is safe between elements — Vue's `condense` mode drops a whitespace-only
text node that contains a newline when it is leading, trailing, or between two elements. It is
**not** safe next to a `{{ }}` interpolation, which condenses to a single space instead. Render
every label through `Label` (a component, so it counts as an element) rather than interpolating.
every label through `QueryBuilderLabel` (a component, so it counts as an element) rather than
interpolating.
- Every default control sets `inheritAttrs: false`. `Rule`/`RuleGroup` hand each subcomponent a
common prop bag (`rule`, `rules`, `ruleOrGroup`, `fieldData`, ...) that most of them do not
declare; without this those land on the DOM as stray attributes React never emits.
Expand All @@ -112,12 +113,23 @@ There is no Vapor CI gate until Vue 3.6 is stable; the constraint is upheld by r
- Resolution order per key: levels props → context → defaults; within a level, keyed slot → keyed
component → bulk slot → bulk component. A `null` entry short-circuits at its own level.

### Module cycles

`defaultControlElements` is in an import cycle (`Rule` → `RuleSubQuery` → the defaults → `Rule`).
Its `rule` and `ruleGroup` entries **must stay accessors, not values**: bundlers and Vite's dev
server tolerate a plain `rule: Rule`, but Node's ESM evaluation order for the published `dist`
binds it to the temporal-dead-zone `undefined`, and the failure is silent — every group renders
and no rule does. `scripts/check-dist-runtime.ts` loads the built artifact through a real ESM
loader and guards it; it runs as part of `check:exports`.

### Reactivity

- The query is a `shallowRef`. A deep proxy defeats reference comparisons and is rejected by the manager's Immer deep-freeze.
- Always `toRaw()` a query before handing it to the manager.
- Likewise `toRaw()` the **manager itself** before calling it. `QueryManager` keeps its history in private class fields, which a reactive `Proxy` cannot read through (`Cannot read private member #past`). `schema` is an ordinary computed value in normal use, but Vue Test Utils wraps mount props in `reactive`, and nothing stops a consumer from doing the same.
- An internal component that receives a `useRule`/`useRuleGroup` return object as a **prop** should unwrap it with `reactive()`. Vue auto-unwraps refs only for top-level `setup` bindings, not through a prop, so the template would otherwise need `.value` everywhere. `reactive()` on a container of refs yields each `.value` directly and leaves plain functions alone. Forward the original object, not the proxy, when passing it further down.
- A `useRule`/`useRuleGroup` return object reaches the internal components through **provide/inject**, not as a prop — see `src/internal/parts.ts`. It is unwrapped with `reactive()` exactly once, at the provider: Vue auto-unwraps refs only for top-level `setup` bindings, and `reactive()` on a container of refs yields each `.value` directly while leaving plain handler functions alone. Consumers inject and destructure; none of them calls `reactive()` itself.
- `RuleSubQuery` provides the subquery's group under **both** the subquery key and the group key, so the `RuleGroupHeader`/`RuleGroupBody` that `RuleComponents` renders resolve the subquery's group rather than the enclosing one. `Rule` correspondingly shadows the subquery key with `undefined`, so a rule nested inside a subquery is not mistaken for one.
- The **public** injection accessors (`useSchema`, `useQueryBuilderActions`, `useCurrentRule`, `useCurrentRuleGroup`, `useCurrentPath`) use their own keys in `src/composables/accessors.ts`. Keep them separate from the internal keys: the internal shape is not public API. `Rule`/`RuleGroup` re-provide schema and actions at every level so a subquery's descendants see the subquery's own.
- Effects that write back into state use `watch` with an **explicit dependency array** and `flush: 'post'` — never `watchEffect`, whose tracked set changes across branches.
- Never pair `immediate: true` with `flush: 'post'`. Vue runs an immediate callback _synchronously at watch creation_, ignoring the flush setting, which would apply a write before first render and break DOM parity. Defer the mount-time run with `nextTick` instead, guarded by an `onScopeDispose` flag.

Expand Down Expand Up @@ -168,7 +180,9 @@ friends) is per-instance. Drop the override once `@testing-library/vue` moves to
**Standing rule: every gate must be proven to fail.** When a step adds a gate, deliberately break
it, record that it went red, then revert. A gate that cannot fail is worse than none.

Current gates: `fmt:check`, `build`, `check` (library + examples), `check:exports`, `lint`,
Current gates: `fmt:check`, `build`, `check` (library + examples), `check:exports` (dist relative
specifiers, `exports`-map targets and condition order, built-artifact module-cycle check, `attw`),
`lint`,
`test:coverage` (three thresholds), `conformance` (DOM parity, 232 tests), `test:ssr`, and the
a11y suite (`src/components/a11y.test.ts`, part of the default run).

Expand Down
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,32 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- **`QueryBuilderPlugin`**, a Vue plugin that registers `QueryBuilder` and the ten default
control elements globally. The name prefix defaults to `Qb` (`<QbQueryBuilder>`); pass
`{ prefix: '' }` for the bare names. Entirely optional — the named exports are unchanged, and
nothing about the plugin affects rendering.
- **`@react-querybuilder/vue/resolver`**, a `QueryBuilderResolver` for
[`unplugin-vue-components`](https://github.com/unplugin/unplugin-vue-components), so a prefixed
component in a template needs no import. New subpath export.
- **Injection accessors for replacement controls**: `useSchema`, `useQueryBuilderActions`,
`useCurrentRule`, `useCurrentRuleGroup`, and `useCurrentPath`. Strictly additive — every
subcomponent still receives the same props. _Props for parity, inject for ergonomics._ Each
returns `undefined` when there is no provider, and is safe to call outside a component
instance.

### Removed

- **`Label` is renamed to `QueryBuilderLabel`** (and `LabelProps` to `QueryBuilderLabelProps`).
**Breaking, with no deprecated alias.** `Label` is far too generic for a top-level export and
a likely collision in any globally registered setup.
- **`RuleComponents`, `RuleGroupHeader`, `RuleGroupBody`, and `RuleSubQuery` are no longer
exported**, and their prop types are gone with them. **Breaking.** The docs always described
them as internal; they now read everything they render through injection and cannot be
mounted on their own. Replace a rule or group with the `rule`/`ruleGroup` `controlElements`
key or slot, built on `useRule`/`useRuleGroup`.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

### Changed

- Structural manager options are now reactive. `fields`, `operators`, `combinators`,
Expand All @@ -21,6 +47,11 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### Fixed

- **The published ESM rendered no rules at all under a real Node ESM loader** (SSR, and any
consumer not going through a bundler). `defaultControlElements` sits in an import cycle, and
Node's evaluation order left `rule` bound to the temporal-dead-zone `undefined` — silently, so
every group rendered and no rule did. The self-referential keys are accessors now.
`scripts/check-dist-runtime.ts` loads the built artifact and guards it.
- The query, the undo/redo history, and every manager subscriber now survive a configuration
change: nothing is recreated, so `canUndo`/`canRedo` and pending history entries are
preserved.
Expand Down
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,39 @@ const query = ref<RuleGroupType>({
</template>
```

### Global registration

Named imports are the default and need no setup. If you would rather register the components
globally, install the plugin:

```ts
// main.ts
import { createApp } from 'vue';
import { QueryBuilderPlugin } from '@react-querybuilder/vue';
import '@react-querybuilder/vue/dist/query-builder.css';
import App from './App.vue';

createApp(App).use(QueryBuilderPlugin).mount('#app');
```

`QueryBuilder` and the ten default control elements are registered under a `Qb` prefix —
`<QbQueryBuilder>`, `<QbValueEditor>`, and so on. Pass `{ prefix: '' }` for the bare names.

For [`unplugin-vue-components`](https://github.com/unplugin/unplugin-vue-components), use the
resolver instead, and skip the plugin:

```ts
// vite.config.ts
import vue from '@vitejs/plugin-vue';
import { defineConfig } from 'vite';
import Components from 'unplugin-vue-components/vite';
import { QueryBuilderResolver } from '@react-querybuilder/vue/resolver';

export default defineConfig({
plugins: [vue(), Components({ resolvers: [QueryBuilderResolver()] })],
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
```

## Driving the query

| Approach | Use when |
Expand Down
45 changes: 45 additions & 0 deletions docs/customization.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ reach:
2. **Slots and `controlElements`** — replace an individual control.
3. **Context** — apply either of the above to every query builder in a subtree.

A replacement control reads what it needs either from its props or by injection; see
[Props for parity, inject for ergonomics](#props-for-parity-inject-for-ergonomics).

Before replacing a component, check whether [styling](./styling.md) gets you there.

## Translations
Expand Down Expand Up @@ -198,6 +201,48 @@ anything a replacement does not declare would otherwise land on the DOM as a str
Keep `data-testid`, `class`, and `title` if you want the standard stylesheets — and any tests
written against the standard DOM — to keep working.

### Props for parity, inject for ergonomics

The prop bag is the contract with React Query Builder, and it does not change: a component ported
straight from React Query Builder keeps working. But reaching `schema`, `actions`, `path`, and
the current node through props means declaring roughly ten props you may not otherwise want, so
the same values are also available by injection:

| Accessor | Equivalent prop |
| -------------------------- | ------------------------------------------------ |
| `useSchema()` | `schema` |
| `useQueryBuilderActions()` | `actions` |
| `useCurrentRule()` | `rule` — the rule the control is rendered inside |
| `useCurrentRuleGroup()` | the group the control is rendered inside |
| `useCurrentPath()` | `path` |

Each returns a `ComputedRef`, or `undefined` when there is no `QueryBuilder` above the call site.
Each is also safe to call outside a component instance, where it likewise returns `undefined`.

```vue
<!-- A "clear this rule" button that declares no props at all. -->
<script setup lang="ts">
import { useCurrentPath, useQueryBuilderActions } from '@react-querybuilder/vue';

const actions = useQueryBuilderActions();
const path = useCurrentPath();
</script>

<template>
<button type="button" @click="actions?.value.onPropChange('value', '', path!.value)">
Clear
</button>
</template>
```

`useCurrentRule` and `useCurrentRuleGroup` resolve the _nearest_ enclosing node, and only one of
them is ever defined at a time. Inside a subquery, all five accessors resolve to the subquery's
own state rather than the enclosing query builder's.

This works for a `controlElements` entry as well as a slot: an entry is typed `ControlComponent`,
which accepts any component regardless of the props it declares. The props each control receives
are listed in `ControlPropsMap`.

Replacing `rule` or `ruleGroup` wholesale is a larger job, because those components own the class
names, the accessible description, and the child paths. Rather than recomputing any of that, use
`useRule`/`useRuleGroup`. Both accept a props getter:
Expand Down
26 changes: 22 additions & 4 deletions docs/differences-from-react-querybuilder.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ None of the following is planned for v1:
per-prop fallbacks `RuleGroupProps.combinator`/`rules`/`not` and
`RuleProps.field`/`operator`/`value`/`valueSource`, are all absent. Use `ruleGroup` and `rule`.
- **`ruleGroupHeaderElements` / `ruleGroupBodyElements`.** The equivalent internal components
exist (`RuleGroupHeader`, `RuleGroupBody`) but are not `controlElements` keys.
exist (`RuleGroupHeader`, `RuleGroupBody`) but are not `controlElements` keys, and are not
exported.

## 3. State management

Expand Down Expand Up @@ -195,15 +196,32 @@ Additional deltas:
which is always `"disabled"`.
- **`ValueEditorProps.skipHook` keeps its name** but now refers to the value-editor reset
_watcher_ rather than a React hook.
- **`ControlSlots`** is a mapped type over `Controls`: for every key `K`, a
`Slot<ControlProps<Controls[K]>>`. The slot list and its argument types therefore cannot drift
from the components the slots replace. `QueryBuilderContextProps.slots` carries it.
- **`ControlPropsMap` is the single source of truth for the subcomponent list.** `ControlElementsProp`,
`Controls`, and `ControlSlots` are all mapped types over it, so the key set and the slot argument
types cannot drift apart. `QueryBuilderContextProps.slots` carries `ControlSlots`.
- **A `controlElements` entry is `ControlComponent` — unparameterized — where React Query Builder
has `ComponentType<P>`.** A replacement may declare only the props it uses, or none at all: the
parent always passes the full prop bag, and
`useSchema`/`useQueryBuilderActions`/`useCurrentRule`/`useCurrentRuleGroup`/`useCurrentPath`
reach the rest by injection. Vue's `Component<P>` cannot express that, and does not enforce what
it appears to: it passes `P` through as the constructor member's _instance_ type, so a component
declaring nothing in common with `P` is rejected by TypeScript's weak-type detection, while a
component declaring a prop of the **wrong** type still slips through the options-object member of
the union. Since it rejects the useful case and misses the broken one, the parameter is dropped
rather than kept for show. `ControlPropsMap` documents what each control receives, and slot
arguments _are_ exactly typed.
- **`RuleTypeOf<RG>`** recovers the rule type from a query type. `QueryBuilder` is generic in
`RG`, `F`, `O`, and `C` only — the rule type is determined by the query, not chosen
independently — so the component uses this to fill `QueryBuilderPropsBase`'s explicit `R`.
- **`Rule` and `RuleGroup` are generic too** (`F`/`O`), matching React. The parameters are a
consumer-facing convenience; internally the props are widened to the default instantiation,
because `Schema`'s resolvers are invariant in their option types.
- **`Label` is `QueryBuilderLabel`, and `LabelProps` is `QueryBuilderLabelProps`.** React Query
Builder has no equivalent export; `Label` was too generic a name for a package that can be
registered globally.
- **`RuleComponents`, `RuleGroupHeader`, `RuleGroupBody`, and `RuleSubQuery` are internal.**
They and their prop types are not exported. They read everything they render through
provide/inject and cannot be mounted outside a `Rule`/`RuleGroup`.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
- **A generic SFC's props parameter carries an index signature.** `vue-tsc` types it as
`Props & Record<string, unknown>`, so an interface-typed variable is not directly assignable
when the component is invoked through `h()`. Spread it, or add the index signature. Templates
Expand Down
9 changes: 4 additions & 5 deletions examples/demo/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
import { computed, ref } from 'vue';
import {
formatQuery,
QueryBuilder,
type Field,
type RuleGroupType,
type RuleGroupTypeIC,
Expand Down Expand Up @@ -127,14 +126,14 @@ const output = computed(() => formatQuery(activeQuery.value, format.value));

<div class="demo-layout">
<div>
<!-- `QueryBuilder` is generic in the query type, so an independent-combinators query
binds directly, with no cast. -->
<QueryBuilder
<!-- Registered globally by `QueryBuilderPlugin`; see `main.ts`. `QueryBuilder` is generic
in the query type, so an independent-combinators query binds directly, with no cast. -->
<QbQueryBuilder
v-if="independentCombinators"
v-model:query="queryIC"
:fields="fields"
v-bind="flags" />
<QueryBuilder v-else v-model:query="query" :fields="fields" v-bind="flags" />
<QbQueryBuilder v-else v-model:query="query" :fields="fields" v-bind="flags" />
</div>

<div class="demo-output">
Expand Down
5 changes: 4 additions & 1 deletion examples/demo/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { QueryBuilderPlugin } from '@react-querybuilder/vue';
import { createApp } from 'vue';
// The same import line a real consumer writes. Vite aliases it to the byte-identical
// stylesheet in `@react-querybuilder/core`, so the demo runs without a library build.
import '@react-querybuilder/vue/dist/query-builder.css';
import App from './App.vue';
import './demo.css';

createApp(App).mount('#app');
// The demo renders `<QbQueryBuilder>` rather than importing the component, so the plugin path is
// exercised rather than merely shipped.
createApp(App).use(QueryBuilderPlugin).mount('#app');
6 changes: 5 additions & 1 deletion packages/vue-querybuilder/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./resolver": {
"types": "./dist/resolver.d.ts",
"import": "./dist/resolver.js"
},
"./dist/*.css": "./dist/*.css",
"./dist/*.scss": "./dist/*.scss",
"./package.json": "./package.json"
Expand All @@ -52,7 +56,7 @@
"build:types": "vue-tsc -p tsconfig.build.json --emitDeclarationOnly --declarationDir dist && find dist \\( -name '*.test.*' -o -name '*.test-d.*' -o -name '*.spec.*' \\) -delete && bun ./scripts/normalize-sfc-declarations.ts",
"build:css": "mkdir -p dist/styles && cp ../../node_modules/@react-querybuilder/core/dist/*.scss dist && cp ../../node_modules/@react-querybuilder/core/dist/styles/*.scss dist/styles && cp -f src/styles/*.scss dist/styles 2>/dev/null || true; bun sass --style=compressed dist",
"check": "vue-tsc --noEmit -p tsconfig.json",
"check:exports": "bun run ./scripts/check-dist-specifiers.ts && attw --pack . --profile esm-only",
"check:exports": "bun run ./scripts/check-dist-specifiers.ts && bun run ./scripts/check-dist-runtime.ts && attw --pack . --profile esm-only",
"conformance": "bun run conformance:fetch && bun run conformance:test",
"conformance:fetch": "bun ./scripts/fetch-fixtures.ts",
"conformance:test": "vitest run --config vitest.conformance.config.ts"
Expand Down
Loading