diff --git a/AGENTS.md b/AGENTS.md index 8b675f6..8c9e498 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. @@ -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. @@ -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). diff --git a/CHANGELOG.md b/CHANGELOG.md index 9aa796d..87ff51f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` (``); 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`. + ### Changed - Structural manager options are now reactive. `fields`, `operators`, `combinators`, @@ -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. diff --git a/README.md b/README.md index 1095c71..7bbb1fd 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,39 @@ const query = ref({ ``` +### 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 — +``, ``, 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()] })], +}); +``` + ## Driving the query | Approach | Use when | diff --git a/docs/customization.md b/docs/customization.md index b13f3e0..caee6ed 100644 --- a/docs/customization.md +++ b/docs/customization.md @@ -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 @@ -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 + + + + +``` + +`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: diff --git a/docs/differences-from-react-querybuilder.md b/docs/differences-from-react-querybuilder.md index 25b761b..4b95512 100644 --- a/docs/differences-from-react-querybuilder.md +++ b/docs/differences-from-react-querybuilder.md @@ -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 @@ -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>`. 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

`.** 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

` 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`** 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`. - **A generic SFC's props parameter carries an index signature.** `vue-tsc` types it as `Props & Record`, so an interface-typed variable is not directly assignable when the component is invoked through `h()`. Spread it, or add the index signature. Templates diff --git a/examples/demo/src/App.vue b/examples/demo/src/App.vue index ca90a3d..54547b9 100644 --- a/examples/demo/src/App.vue +++ b/examples/demo/src/App.vue @@ -2,7 +2,6 @@ import { computed, ref } from 'vue'; import { formatQuery, - QueryBuilder, type Field, type RuleGroupType, type RuleGroupTypeIC, @@ -127,14 +126,14 @@ const output = computed(() => formatQuery(activeQuery.value, format.value));

- - + - +
diff --git a/examples/demo/src/main.ts b/examples/demo/src/main.ts index 5d75bf7..a7ddc7a 100644 --- a/examples/demo/src/main.ts +++ b/examples/demo/src/main.ts @@ -1,3 +1,4 @@ +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. @@ -5,4 +6,6 @@ import '@react-querybuilder/vue/dist/query-builder.css'; import App from './App.vue'; import './demo.css'; -createApp(App).mount('#app'); +// The demo renders `` rather than importing the component, so the plugin path is +// exercised rather than merely shipped. +createApp(App).use(QueryBuilderPlugin).mount('#app'); diff --git a/packages/vue-querybuilder/package.json b/packages/vue-querybuilder/package.json index f06cea4..5552d79 100644 --- a/packages/vue-querybuilder/package.json +++ b/packages/vue-querybuilder/package.json @@ -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" @@ -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" diff --git a/packages/vue-querybuilder/scripts/check-dist-runtime.ts b/packages/vue-querybuilder/scripts/check-dist-runtime.ts new file mode 100644 index 0000000..9c5e359 --- /dev/null +++ b/packages/vue-querybuilder/scripts/check-dist-runtime.ts @@ -0,0 +1,25 @@ +/** + * Loads the built ESM the way Node loads it and asserts the module graph settles. + * + * `defaultControlElements` sits in an import cycle: `Rule` renders `RuleSubQuery`, which needs + * the defaults for the subquery's own query builder. A bundler and Vite's dev server evaluate + * that graph in an order where a plain `rule: Rule` happens to work; Node's ESM order for the + * published `dist` does not, and the failure is silent — the query builder renders every group + * and no rules. Nothing else in CI runs the *built* artifact through a real ESM loader. + */ +import { defaultControlElements } from '../dist/index.js'; + +const missing = Object.entries(defaultControlElements as Record) + .filter(([, component]) => !component) + .map(([key]) => key); + +if (missing.length > 0) { + console.error( + `dist/: defaultControlElements resolved to undefined for: ${missing.join(', ')}.\n` + + 'This is an import cycle evaluating in the wrong order — the affected keys must be ' + + 'accessors, not values, so they read their live binding at first access.' + ); + process.exit(1); +} + +console.log(`dist/ default control elements OK (${Object.keys(defaultControlElements).length}).`); diff --git a/packages/vue-querybuilder/scripts/check-dist-specifiers.ts b/packages/vue-querybuilder/scripts/check-dist-specifiers.ts index 86530c1..8597a6e 100644 --- a/packages/vue-querybuilder/scripts/check-dist-specifiers.ts +++ b/packages/vue-querybuilder/scripts/check-dist-specifiers.ts @@ -9,12 +9,18 @@ * `.vue` specifiers are rejected outright: `scripts/normalize-sfc-declarations.ts` rewrites them * to the `./Foo.js` form that both `tsc` and `vue-tsc` can resolve, so one surviving in `dist` * means that step did not run or did not cover a case. + * + * Also checks the `exports` map itself: every target must resolve inside `dist/` and exist, and every conditional + * entry must list `types` first. `attw` catches a missing `types` condition but not a target that + * was never built, and condition order is significant — the first match wins, so a `types` after + * `import` is never consulted. */ import { Glob } from 'bun'; import { existsSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; +import { dirname, resolve, sep } from 'node:path'; -const distDir = resolve(new URL('..', import.meta.url).pathname, 'dist'); +const packageDir = resolve(new URL('..', import.meta.url).pathname); +const distDir = resolve(packageDir, 'dist'); if (!existsSync(distDir)) { console.error('dist/ not found — run `bun run build` first.'); @@ -43,6 +49,45 @@ const resolvesTo = (from: string, spec: string): boolean => { const failures: string[] = []; +// ---- `exports` map ------------------------------------------------------------------------- + +const packageJsonPath = resolve(packageDir, 'package.json'); +const exportsMap = (await Bun.file(packageJsonPath).json()).exports as Record< + string, + string | Record +>; + +for (const [subpath, entry] of Object.entries(exportsMap)) { + // Wildcard entries (the stylesheets) and `./package.json` are not build outputs. + if (subpath.includes('*') || subpath === './package.json') continue; + + if (typeof entry === 'string') { + failures.push(`exports["${subpath}"]: must be a conditional object with a \`types\` key`); + continue; + } + + const conditions = Object.keys(entry); + if (conditions[0] !== 'types') { + failures.push( + `exports["${subpath}"]: 'types' must be the first condition (found '${conditions[0]}')` + ); + } + for (const [condition, target] of Object.entries(entry)) { + const abs = resolve(packageDir, target); + // Containment first: a target outside `dist/` is not a build output, so merely existing + // (e.g. `./src/index.ts`) must not pass. + if (abs !== distDir && !abs.startsWith(`${distDir}${sep}`)) { + failures.push(`exports["${subpath}"].${condition}: '${target}' is outside dist/`); + continue; + } + if (!existsSync(abs)) { + failures.push(`exports["${subpath}"].${condition}: '${target}' does not exist`); + } + } +} + +// ---- relative specifiers ------------------------------------------------------------------- + for await (const rel of new Glob('**/*.{js,d.ts}').scan(distDir)) { const file = resolve(distDir, rel); const source = await Bun.file(file).text(); @@ -59,12 +104,13 @@ for await (const rel of new Glob('**/*.{js,d.ts}').scan(distDir)) { } if (failures.length > 0) { - console.error('Unresolvable relative specifiers in dist/:\n'); + console.error('Problems in the published surface:\n'); for (const f of failures) console.error(` ${f}`); console.error( - `\n${failures.length} problem(s). Relative imports in src must carry explicit extensions.` + `\n${failures.length} problem(s). Relative imports in src must carry explicit extensions, ` + + 'and every `exports` entry must point at a built file with `types` listed first.' ); process.exit(1); } -console.log('dist/ relative specifiers OK.'); +console.log('dist/ exports map and relative specifiers OK.'); diff --git a/packages/vue-querybuilder/src/components/ActionElement.vue b/packages/vue-querybuilder/src/components/ActionElement.vue index b07c120..9a784e6 100644 --- a/packages/vue-querybuilder/src/components/ActionElement.vue +++ b/packages/vue-querybuilder/src/components/ActionElement.vue @@ -1,6 +1,6 @@ diff --git a/packages/vue-querybuilder/src/components/Rule.vue b/packages/vue-querybuilder/src/components/Rule.vue index c4938ea..a04b093 100644 --- a/packages/vue-querybuilder/src/components/Rule.vue +++ b/packages/vue-querybuilder/src/components/Rule.vue @@ -1,9 +1,11 @@ diff --git a/packages/vue-querybuilder/src/components/RuleGroup.vue b/packages/vue-querybuilder/src/components/RuleGroup.vue index c8e9f2d..6620866 100644 --- a/packages/vue-querybuilder/src/components/RuleGroup.vue +++ b/packages/vue-querybuilder/src/components/RuleGroup.vue @@ -1,10 +1,12 @@ diff --git a/packages/vue-querybuilder/src/components/ShiftActions.vue b/packages/vue-querybuilder/src/components/ShiftActions.vue index 803b132..ebb216a 100644 --- a/packages/vue-querybuilder/src/components/ShiftActions.vue +++ b/packages/vue-querybuilder/src/components/ShiftActions.vue @@ -1,5 +1,5 @@ diff --git a/packages/vue-querybuilder/src/internal/parts.test.ts b/packages/vue-querybuilder/src/internal/parts.test.ts new file mode 100644 index 0000000..f6eec29 --- /dev/null +++ b/packages/vue-querybuilder/src/internal/parts.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { mountBare } from '../../test/support.js'; +import RuleComponents from './RuleComponents.vue'; +import RuleGroupBody from './RuleGroupBody.vue'; +import RuleGroupHeader from './RuleGroupHeader.vue'; +import RuleSubQuery from './RuleSubQuery.vue'; + +/** + * The four internal components read everything they render through injection, so mounting one + * outside its provider is a programming error and must fail loudly rather than render a + * half-empty tree. These are the guards from `parts.ts`. + */ +describe('internal parts injection guards', () => { + it.each([ + ['RuleComponents', RuleComponents], + ['RuleSubQuery', RuleSubQuery], + ])('%s throws when mounted outside a `Rule`', (_name, component) => { + expect(() => mountBare(component)).toThrow(/rule internals used outside of `Rule`/); + }); + + it.each([ + ['RuleGroupHeader', RuleGroupHeader], + ['RuleGroupBody', RuleGroupBody], + ])('%s throws when mounted outside a `RuleGroup`', (_name, component) => { + expect(() => mountBare(component)).toThrow(/group internals used outside of `RuleGroup`/); + }); +}); diff --git a/packages/vue-querybuilder/src/internal/parts.ts b/packages/vue-querybuilder/src/internal/parts.ts new file mode 100644 index 0000000..5e5120c --- /dev/null +++ b/packages/vue-querybuilder/src/internal/parts.ts @@ -0,0 +1,102 @@ +import type { ComputedRef, InjectionKey, MaybeRefOrGetter, Reactive } from 'vue'; +import { computed, inject, provide, reactive, toValue } from 'vue'; +import type { UseRuleReturn } from '../composables/useRule.js'; +import type { UseRuleGroupReturn } from '../composables/useRuleGroup.js'; +import type { RuleGroupProps, RuleProps } from '../types/props.js'; + +/** + * The internal channel between `Rule`/`RuleGroup` and the presentational components they are + * composed of (`RuleComponents`, `RuleSubQuery`, `RuleGroupHeader`, `RuleGroupBody`). + * + * Not public API. The keys are module-private symbols, reachable only through the provide/use + * pairs below, and this module is not re-exported from the package entry point. + * + * `parts` is unwrapped with `reactive` once here, at the provider, rather than once per consumer: + * `useRule`/`useRuleGroup` return a container of refs, and a `reactive` proxy of that container + * yields each `.value` on access while leaving the plain handler functions alone. + */ + +interface Internals { + /** The owning component's props. A `ComputedRef`, so it auto-unwraps in a template. */ + readonly props: ComputedRef

; + /** The `useRule`/`useRuleGroup` return object, ref-unwrapped. */ + readonly parts: Reactive; +} + +export type RuleInternals = Internals; +export type RuleGroupInternals = Internals; + +const ruleInternalsKey = Symbol('@react-querybuilder/vue:rule') as InjectionKey; + +const ruleGroupInternalsKey = Symbol( + '@react-querybuilder/vue:ruleGroup' +) as InjectionKey; + +/** + * The subquery group rendered *inside* a rule, when the rule's field supports match modes. + * Distinct from {@link ruleGroupInternalsKey} because `RuleComponents` needs to know whether it + * is in subquery mode at all, which a shadowed group key cannot express. + */ +const subQueryInternalsKey = Symbol('@react-querybuilder/vue:subQuery') as InjectionKey< + RuleGroupInternals | undefined +>; + +const makeInternals = ( + props: MaybeRefOrGetter

, + parts: R +): Internals => ({ props: computed(() => toValue(props)), parts: reactive(parts) }); + +/** Provides the current rule's props and derived parts. Call from `Rule`'s `setup`. */ +export const provideRuleInternals = ( + props: MaybeRefOrGetter, + parts: UseRuleReturn +): void => { + provide(ruleInternalsKey, makeInternals(props, parts)); + // Shadows any enclosing rule's subquery: a rule nested inside a subquery is not itself one. + // `RuleSubQuery` renders below this and overrides it for its own subtree. + provide(subQueryInternalsKey, undefined); +}; + +/** Provides the current group's props and derived parts. Call from `RuleGroup`'s `setup`. */ +export const provideRuleGroupInternals = ( + props: MaybeRefOrGetter, + parts: UseRuleGroupReturn +): void => { + provide(ruleGroupInternalsKey, makeInternals(props, parts)); +}; + +/** + * Provides a rule's subquery group under both the subquery key and the group key — the latter so + * that the `RuleGroupHeader`/`RuleGroupBody` rendered by `RuleComponents` resolve the subquery's + * group rather than the enclosing group. Call from `RuleSubQuery`'s `setup`. + */ +export const provideSubQueryInternals = ( + props: MaybeRefOrGetter, + parts: UseRuleGroupReturn +): void => { + const internals = makeInternals(props, parts); + provide(subQueryInternalsKey, internals); + provide(ruleGroupInternalsKey, internals); +}; + +/** @throws if called outside a `Rule`. */ +export const useRuleInternals = (): RuleInternals => { + const internals = inject(ruleInternalsKey); + if (!internals) { + throw new Error('[@react-querybuilder/vue] rule internals used outside of `Rule`'); + } + return internals; +}; + +/** @throws if called outside a `RuleGroup` or a `RuleSubQuery`. */ +export const useRuleGroupInternals = (): RuleGroupInternals => { + const internals = inject(ruleGroupInternalsKey); + if (!internals) { + throw new Error('[@react-querybuilder/vue] group internals used outside of `RuleGroup`'); + } + return internals; +}; + +/** The enclosing rule's subquery group, or `undefined` when the rule has no subquery. */ +export const useSubQueryInternals = (): RuleGroupInternals | undefined => + inject(subQueryInternalsKey, undefined); diff --git a/packages/vue-querybuilder/src/internal/slotToComponent.ts b/packages/vue-querybuilder/src/internal/slotToComponent.ts index c040da9..132d733 100644 --- a/packages/vue-querybuilder/src/internal/slotToComponent.ts +++ b/packages/vue-querybuilder/src/internal/slotToComponent.ts @@ -16,7 +16,7 @@ const wrapperCache = new WeakMap, Component>(); * forwards its props object straight through. The object itself is forwarded rather than a * spread copy, so nothing is lost on the way. * - * Deliberately a functional component rather than an SFC, for the same reason as `Label`: an SFC + * Deliberately a functional component rather than an SFC, for the same reason as `QueryBuilderLabel`: an SFC * template emits whitespace text nodes, and the conformance suite asserts byte-level DOM parity. * A functional component renders exactly what the slot returns — no wrapper element, no * whitespace — and works identically in client and server modes. diff --git a/packages/vue-querybuilder/src/plugin.test.ts b/packages/vue-querybuilder/src/plugin.test.ts new file mode 100644 index 0000000..2066c01 --- /dev/null +++ b/packages/vue-querybuilder/src/plugin.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest'; +import { createApp, h } from 'vue'; +import { mountWithPlugin } from '../test/support.js'; +import { + defaultComponentPrefix, + queryBuilderComponentNames, + queryBuilderComponents, + QueryBuilderPlugin, + resolveComponentName, +} from './plugin.js'; +import { QueryBuilderResolver } from './resolver.js'; + +describe('QueryBuilderPlugin', () => { + it('registers every component under the default `Qb` prefix', () => { + const app = createApp({ render: () => h('div') }); + app.use(QueryBuilderPlugin); + for (const name of queryBuilderComponentNames) { + expect(app.component(`Qb${name}`)).toBe(queryBuilderComponents[name]); + } + }); + + it('does not register the bare names by default', () => { + const app = createApp({ render: () => h('div') }); + app.use(QueryBuilderPlugin); + expect(app.component('QueryBuilder')).toBeUndefined(); + }); + + it('registers under a custom prefix', () => { + const app = createApp({ render: () => h('div') }); + app.use(QueryBuilderPlugin, { prefix: 'X' }); + expect(app.component('XQueryBuilder')).toBe(queryBuilderComponents.QueryBuilder); + expect(app.component('QbQueryBuilder')).toBeUndefined(); + }); + + it('registers under bare names when the prefix is empty', () => { + const app = createApp({ render: () => h('div') }); + app.use(QueryBuilderPlugin, { prefix: '' }); + expect(app.component('QueryBuilder')).toBe(queryBuilderComponents.QueryBuilder); + }); + + it('renders a globally registered `QbQueryBuilder`', () => { + const { getByTestId } = mountWithPlugin(''); + expect(getByTestId('rule-group')).toBeInTheDocument(); + }); + + it('renders a globally registered control on its own', () => { + const { getByTestId } = mountWithPlugin( + '' + ); + expect(getByTestId('add-rule')).toHaveTextContent('+'); + }); + + it('omits the internal composition components', () => { + expect(queryBuilderComponentNames).not.toContain('RuleComponents'); + expect(queryBuilderComponentNames).not.toContain('RuleGroupHeader'); + expect(queryBuilderComponentNames).not.toContain('RuleGroupBody'); + expect(queryBuilderComponentNames).not.toContain('RuleSubQuery'); + }); +}); + +describe('resolveComponentName', () => { + it('strips the prefix from a known component', () => { + expect(resolveComponentName('QbValueEditor')).toBe('ValueEditor'); + }); + + it('rejects an unprefixed name', () => { + expect(resolveComponentName('ValueEditor')).toBeUndefined(); + }); + + it('rejects an unknown component', () => { + expect(resolveComponentName('QbSomethingElse')).toBeUndefined(); + }); + + it('honors a custom prefix', () => { + expect(resolveComponentName('XRule', 'X')).toBe('Rule'); + expect(resolveComponentName('QbRule', 'X')).toBeUndefined(); + }); + + it('does not resolve inherited Object properties', () => { + expect(resolveComponentName(`${defaultComponentPrefix}toString`)).toBeUndefined(); + }); +}); + +describe('QueryBuilderResolver', () => { + it('resolves a prefixed name to a named export of the package', () => { + expect(QueryBuilderResolver().resolve('QbQueryBuilder')).toEqual({ + name: 'QueryBuilder', + from: '@react-querybuilder/vue', + }); + }); + + it('declares itself a component resolver', () => { + expect(QueryBuilderResolver().type).toBe('component'); + }); + + it('returns undefined for anything else', () => { + expect(QueryBuilderResolver().resolve('SomeOtherComponent')).toBeUndefined(); + }); + + it('honors a custom prefix', () => { + expect(QueryBuilderResolver({ prefix: 'X' }).resolve('XRuleGroup')).toEqual({ + name: 'RuleGroup', + from: '@react-querybuilder/vue', + }); + }); +}); diff --git a/packages/vue-querybuilder/src/plugin.ts b/packages/vue-querybuilder/src/plugin.ts new file mode 100644 index 0000000..e00121a --- /dev/null +++ b/packages/vue-querybuilder/src/plugin.ts @@ -0,0 +1,116 @@ +import type { App, Component, Plugin } from 'vue'; +import ActionElement from './components/ActionElement.vue'; +import InlineCombinator from './components/InlineCombinator.vue'; +import MatchModeEditor from './components/MatchModeEditor.vue'; +import NotToggle from './components/NotToggle.vue'; +import QueryBuilder from './components/QueryBuilder.vue'; +import Rule from './components/Rule.vue'; +import RuleGroup from './components/RuleGroup.vue'; +import ShiftActions from './components/ShiftActions.vue'; +import UndoRedoActions from './components/UndoRedoActions.vue'; +import ValueEditor from './components/ValueEditor.vue'; +import ValueSelector from './components/ValueSelector.vue'; + +/** + * Every component the plugin registers, keyed by its unprefixed name: `QueryBuilder` plus the + * ten default control elements, which are the components a consumer can meaningfully place in a + * template or pass to `controlElements`. + * + * The internal composition pieces (`RuleComponents`, `RuleGroupHeader`, `RuleGroupBody`, + * `RuleSubQuery`) are deliberately absent: they read everything they render from through + * injection and cannot be mounted on their own. + */ +export const queryBuilderComponents = { + QueryBuilder, + ActionElement, + InlineCombinator, + MatchModeEditor, + NotToggle, + Rule, + RuleGroup, + ShiftActions, + UndoRedoActions, + ValueEditor, + ValueSelector, + // oxlint-disable-next-line typescript/no-explicit-any +} as unknown as Record>; + +/** The unprefixed names of the components {@link QueryBuilderPlugin} registers. */ +export const queryBuilderComponentNames: readonly string[] = Object.keys(queryBuilderComponents); + +/** Options for {@link QueryBuilderPlugin}. */ +export interface QueryBuilderPluginOptions { + /** + * Prepended to every registered component name, so that `QueryBuilder` registers as + * `QbQueryBuilder` and `ValueEditor` as `QbValueEditor`. + * + * Defaults to `'Qb'`, which keeps generic names like `Rule` and `ValueEditor` out of the + * global registry. Pass `''` to register the components under their bare names. + * + * @default 'Qb' + */ + prefix?: string; +} + +/** The default {@link QueryBuilderPluginOptions.prefix}. */ +export const defaultComponentPrefix = 'Qb'; + +/** + * The prefixed global name of a component, or `undefined` if `name` is not one of this package's. + * + * Shared by the plugin and the `unplugin-vue-components` resolver so the two cannot disagree. + */ +export const resolveComponentName = ( + name: string, + prefix: string = defaultComponentPrefix +): string | undefined => + name.startsWith(prefix) && Object.hasOwn(queryBuilderComponents, name.slice(prefix.length)) + ? name.slice(prefix.length) + : undefined; + +/** + * Registers this package's components globally. + * + * ```ts + * import { createApp } from 'vue'; + * import { QueryBuilderPlugin } from '@react-querybuilder/vue'; + * import '@react-querybuilder/vue/dist/query-builder.css'; + * + * createApp(App).use(QueryBuilderPlugin).mount('#app'); + * ``` + * + * Entirely optional — the named exports work without it, and are the better choice in an + * application that prefers explicit imports. Nothing about the plugin affects rendering; it only + * populates `app.component`. + */ +export const QueryBuilderPlugin: Plugin<[QueryBuilderPluginOptions?]> = { + install(app: App, options: QueryBuilderPluginOptions = {}): void { + const prefix = options.prefix ?? defaultComponentPrefix; + for (const [name, component] of Object.entries(queryBuilderComponents)) { + app.component(`${prefix}${name}`, component); + } + }, +}; + +declare module 'vue' { + /** + * Types the globally registered components for a template that uses {@link QueryBuilderPlugin} + * with the default prefix. + * + * Declared unconditionally, so `` typechecks in any project that depends on + * this package. A custom `prefix` needs its own augmentation. + */ + export interface GlobalComponents { + QbQueryBuilder: typeof QueryBuilder; + QbActionElement: typeof ActionElement; + QbInlineCombinator: typeof InlineCombinator; + QbMatchModeEditor: typeof MatchModeEditor; + QbNotToggle: typeof NotToggle; + QbRule: typeof Rule; + QbRuleGroup: typeof RuleGroup; + QbShiftActions: typeof ShiftActions; + QbUndoRedoActions: typeof UndoRedoActions; + QbValueEditor: typeof ValueEditor; + QbValueSelector: typeof ValueSelector; + } +} diff --git a/packages/vue-querybuilder/src/resolver.ts b/packages/vue-querybuilder/src/resolver.ts new file mode 100644 index 0000000..b048b57 --- /dev/null +++ b/packages/vue-querybuilder/src/resolver.ts @@ -0,0 +1,58 @@ +import { defaultComponentPrefix, resolveComponentName } from './plugin.js'; + +/** + * A component resolver for + * [`unplugin-vue-components`](https://github.com/unplugin/unplugin-vue-components), so that + * `` in a template needs no import. + * + * ```ts + * // vite.config.ts + * import Components from 'unplugin-vue-components/vite'; + * import { QueryBuilderResolver } from '@react-querybuilder/vue/resolver'; + * + * export default defineConfig({ + * plugins: [vue(), Components({ resolvers: [QueryBuilderResolver()] })], + * }); + * ``` + * + * The stylesheet is not auto-imported: this package ships several, and which one a consumer + * wants is not inferable. Import it once, by hand. + */ + +/** The shape `unplugin-vue-components` expects a resolver to return. */ +export interface QueryBuilderComponentResolved { + name: string; + from: string; +} + +/** Options for {@link QueryBuilderResolver}. */ +export interface QueryBuilderResolverOptions { + /** + * The prefix used in templates. Must match the plugin's. + * + * @default 'Qb' + */ + prefix?: string; +} + +/** @see {@link QueryBuilderResolver} */ +export interface QueryBuilderComponentResolver { + type: 'component'; + resolve: (name: string) => QueryBuilderComponentResolved | undefined; +} + +/** + * Builds the resolver. See the module documentation for usage. + */ +export const QueryBuilderResolver = ( + options: QueryBuilderResolverOptions = {} +): QueryBuilderComponentResolver => { + const prefix = options.prefix ?? defaultComponentPrefix; + return { + type: 'component', + resolve: (name: string) => { + const resolved = resolveComponentName(name, prefix); + return resolved ? { name: resolved, from: '@react-querybuilder/vue' } : undefined; + }, + }; +}; diff --git a/packages/vue-querybuilder/src/types/controls.ts b/packages/vue-querybuilder/src/types/controls.ts index 71f258e..6769db1 100644 --- a/packages/vue-querybuilder/src/types/controls.ts +++ b/packages/vue-querybuilder/src/types/controls.ts @@ -18,7 +18,11 @@ import type { } from './props.js'; /** - * Subcomponents. + * The props each subcomponent receives. + * + * The single source of truth for the subcomponent list: {@link ControlElementsProp}, + * {@link Controls}, and {@link ControlSlots} are all derived from it, so the key set, the + * component prop types, and the slot argument types cannot drift apart. * * There is no `dragHandle` entry: drag-and-drop is a non-goal. There are no * `ruleGroupHeaderElements`/`ruleGroupBodyElements` entries either; to customize the contents of @@ -26,152 +30,197 @@ import type { * * @group Props */ -export type ControlElementsProp = Partial<{ +export type ControlPropsMap = { /** * Default component for all button-type controls. * * @default ActionElement */ - actionElement: Component; + actionElement: ActionProps; /** * Adds a sub-group to the current group. * * @default ActionElement */ - addGroupAction: Component | null; + addGroupAction: ActionProps; /** * Adds a rule to the current group. * * @default ActionElement */ - addRuleAction: Component | null; + addRuleAction: ActionProps; /** * Clones the current group. * * @default ActionElement */ - cloneGroupAction: Component | null; + cloneGroupAction: ActionProps; /** * Clones the current rule. * * @default ActionElement */ - cloneRuleAction: Component | null; + cloneRuleAction: ActionProps; /** * Selects the `combinator` property for the current group, or the current independent * combinator value. * * @default ValueSelector */ - combinatorSelector: Component | null; + combinatorSelector: CombinatorSelectorProps; /** * Selects the `field` property for the current rule. * * @default ValueSelector */ - fieldSelector: Component> | null; + fieldSelector: FieldSelectorProps; /** * A small wrapper around the `combinatorSelector` component. * * @default InlineCombinator */ - inlineCombinator: Component | null; + inlineCombinator: InlineCombinatorProps; /** * Locks the current group (sets the `disabled` property to `true`). * * @default ActionElement */ - lockGroupAction: Component | null; + lockGroupAction: ActionProps; /** * Locks the current rule (sets the `disabled` property to `true`). * * @default ActionElement */ - lockRuleAction: Component | null; + lockRuleAction: ActionProps; /** * Mutes the current group (sets the `muted` property to `true`). * * @default ActionElement */ - muteGroupAction: Component | null; + muteGroupAction: ActionProps; /** * Mutes the current rule (sets the `muted` property to `true`). * * @default ActionElement */ - muteRuleAction: Component | null; + muteRuleAction: ActionProps; /** * Selects the `match` property for the current rule. * * @default MatchModeEditor */ - matchModeEditor: Component | null; + matchModeEditor: MatchModeEditorProps; /** * Toggles the `not` property of the current group between `true` and `false`. * * @default NotToggle */ - notToggle: Component | null; + notToggle: NotToggleProps; /** * Selects the `operator` property for the current rule. * * @default ValueSelector */ - operatorSelector: Component | null; + operatorSelector: OperatorSelectorProps; /** * Removes the current group from its parent group's `rules` array. * * @default ActionElement */ - removeGroupAction: Component | null; + removeGroupAction: ActionProps; /** * Removes the current rule from its parent group's `rules` array. * * @default ActionElement */ - removeRuleAction: Component | null; + removeRuleAction: ActionProps; /** * Rule layout component. * * @default Rule */ - rule: Component; + rule: RuleProps; /** * Rule group layout component. * * @default RuleGroup */ - ruleGroup: Component>; + ruleGroup: RuleGroupProps; /** * Shifts the current rule/group up or down in the query hierarchy. * * @default ShiftActions */ - shiftActions: Component | null; + shiftActions: ShiftActionsProps; /** * Undo/redo buttons for the outermost group, rendered when the `showUndoRedo` prop is `true`. * * @default UndoRedoActions */ - undoRedoActions: Component | null; + undoRedoActions: UndoRedoActionsProps; /** * Updates the `value` property for the current rule. * * @default ValueEditor */ - valueEditor: Component> | null; + valueEditor: ValueEditorProps; /** * Default component for all value selector controls. * * @default ValueSelector */ - valueSelector: Component; + valueSelector: ValueSelectorProps; /** * Selects the `valueSource` property for the current rule. * * @default ValueSelector */ - valueSourceSelector: Component | null; + valueSourceSelector: ValueSourceSelectorProps; +}; + +/** + * The keys of {@link ControlPropsMap} that cannot be set to `null`. + * + * `rule`, `ruleGroup`, and the two defaults-for-a-family entries always have to render something. + * + * @group Props + */ +export type NonNullableControlKey = 'actionElement' | 'rule' | 'ruleGroup' | 'valueSelector'; + +/** + * The type of a replacement subcomponent. + * + * Deliberately unparameterized. The rendering parent always passes the full prop bag, so a + * replacement is free to declare only the props it uses — or none at all, reaching `schema`, + * `actions`, and the current node through `useSchema`, `useQueryBuilderActions`, + * `useCurrentRule`, `useCurrentRuleGroup`, and `useCurrentPath` instead. + * + * A `Component

` cannot express that, and does not enforce what it appears to. Vue passes that + * type argument through as the *instance* type of the constructor member, so a component + * declaring nothing in common with `P` trips TypeScript's weak-type detection and is rejected — + * while a component declaring a prop of the **wrong** type still slips through the + * options-object member of the union. The check rejects the useful case and misses the broken + * one, so it is not worth having. + * + * {@link ControlPropsMap} is the contract instead. It is enforced where enforcement works: on + * the slot arguments in {@link ControlSlots}, and on the props each default control declares. + * + * @group Props + */ +export type ControlComponent = Component; + +/** + * Subcomponents. + * + * Derived from {@link ControlPropsMap}. Every entry accepts `null` — rendering nothing in that + * position — except {@link NonNullableControlKey}. + * + * @group Props + */ +export type ControlElementsProp = Partial<{ + [K in keyof ControlPropsMap]: + | ControlComponent + | (K extends NonNullableControlKey ? never : null); }>; /** @@ -185,19 +234,9 @@ export type ControlElementsProp = Partial * @group Props */ export type Controls = { - [K in keyof Required>]-?: NonNullable< - Required>[K] - >; + [K in keyof ControlPropsMap]-?: ControlComponent; }; -/** - * The props a {@link Controls} entry accepts. - * - * Vue does not export a `ComponentProps` helper as of 3.5, so this recovers the type argument - * from the `Component

` the entry is declared as. - */ -export type ControlProps = C extends Component ? P : never; - /** * Slot-based alternatives to {@link ControlElementsProp}. * @@ -209,11 +248,12 @@ export type ControlProps = C extends Component ? P : never; * There is no `null` form: omit the slot to fall through to the next source, or pass * `controlElements: { x: null }` to render nothing. * - * A mapped type over {@link Controls}, so the slot list and the slot argument types cannot - * drift from the components they replace. + * Unlike a {@link ControlComponent} entry, a slot's arguments are exactly typed: the slot list + * and its argument types are both derived from {@link ControlPropsMap}, so neither can drift from + * the components the slots replace. * * @group Props */ export type ControlSlots = Partial<{ - [K in keyof Controls]: Slot[K]>>; + [K in keyof ControlPropsMap]: Slot[K]>; }>; diff --git a/packages/vue-querybuilder/src/types/props.ts b/packages/vue-querybuilder/src/types/props.ts index 63f6ba4..8666cba 100644 --- a/packages/vue-querybuilder/src/types/props.ts +++ b/packages/vue-querybuilder/src/types/props.ts @@ -37,8 +37,7 @@ import type { ValueSourceFlexibleOptions, ValueSources, } from '@react-querybuilder/core'; -import type { Component } from 'vue'; -import type { ControlElementsProp, ControlSlots } from './controls.js'; +import type { ControlComponent, ControlElementsProp, ControlSlots } from './controls.js'; import type { Schema } from './schema.js'; import type { LabelNode, Translations, TranslationWithLabel } from './translations.js'; @@ -157,8 +156,10 @@ export interface FieldSelectorProps export interface MatchModeEditorProps extends BaseSelectorProps, CommonRuleSubComponentProps { match: MatchConfig; - selectorComponent?: Component; - numericEditorComponent?: Component; + /** Receives {@link ValueSelectorProps}. */ + selectorComponent?: ControlComponent; + /** Receives {@link ValueEditorProps}. */ + numericEditorComponent?: ControlComponent; thresholdPlaceholder?: string; classNames: { matchMode: string; matchThreshold: string }; options: FullOptionList>; @@ -317,7 +318,8 @@ export interface ShiftActionsProps extends CommonSubComponentProps { * @group Props */ export interface InlineCombinatorProps extends CombinatorSelectorProps { - component: Component; + /** Receives {@link CombinatorSelectorProps}. */ + component: ControlComponent; } /** @@ -341,7 +343,8 @@ export interface ValueEditorProps; + /** Receives {@link ValueSelectorProps}. */ + selectorComponent?: ControlComponent; /** * Only pass `true` if the value editor reset watcher (`useValueEditorReset`) has already run * in a parent/ancestor component. diff --git a/packages/vue-querybuilder/src/types/types.test-d.ts b/packages/vue-querybuilder/src/types/types.test-d.ts index 177f099..fcba005 100644 --- a/packages/vue-querybuilder/src/types/types.test-d.ts +++ b/packages/vue-querybuilder/src/types/types.test-d.ts @@ -12,9 +12,10 @@ import type { RuleGroupTypeIC, RuleType, } from '@react-querybuilder/core'; -import type { Component, Slot } from 'vue'; +import type { DefineComponent, FunctionalComponent, Slot } from 'vue'; import type { ActionProps, + ControlComponent, ControlElementsProp, ControlSlots, Controls, @@ -72,7 +73,7 @@ assertType(stdProps.independentCombinators); // #region Controls declare const controls: Controls; // Every entry is present and non-nullable after finalization, including `undoRedoActions`. -assertType>(controls.actionElement); +assertType(controls.actionElement); assertType>(controls.undoRedoActions); assertType>(controls.valueEditor); // @ts-expect-error finalized controls are never nullish @@ -89,6 +90,31 @@ assertType(controlElements.dragHandle); assertType(controlElements.ruleGroupHeaderElements); // @ts-expect-error `ruleGroupBodyElements` is not a control element in this package assertType(controlElements.ruleGroupBodyElements); +// `null` is rejected for the entries that always have to render something. +// @ts-expect-error `actionElement` is not nullable +controlElements.actionElement = null; +// @ts-expect-error `rule` is not nullable +controlElements.rule = null; +// @ts-expect-error `ruleGroup` is not nullable +controlElements.ruleGroup = null; +// @ts-expect-error `valueSelector` is not nullable +controlElements.valueSelector = null; + +// A replacement may declare only the props it uses, or none at all — the parent always passes +// the full bag, and the rest is reachable by injection. +declare const noPropsControl: DefineComponent<{}>; +declare const somePropsControl: DefineComponent<{ label?: string | undefined }>; +declare const fnControl: FunctionalComponent; +controlElements.actionElement = noPropsControl; +controlElements.actionElement = somePropsControl; +controlElements.actionElement = fnControl; +controlElements.rule = noPropsControl; +controlElements.valueEditor = noPropsControl; +// Still has to be a component. +// @ts-expect-error +controlElements.actionElement = 42; +// @ts-expect-error +controlElements.actionElement = 'ActionElement'; // #endregion // #region Rule/RuleGroup props — no deprecated per-prop fallbacks diff --git a/packages/vue-querybuilder/test/support.ts b/packages/vue-querybuilder/test/support.ts index 3080bc2..837893d 100644 --- a/packages/vue-querybuilder/test/support.ts +++ b/packages/vue-querybuilder/test/support.ts @@ -5,8 +5,11 @@ import type { RuleType, } from '@react-querybuilder/core'; import { defaultTranslations } from '@react-querybuilder/core'; +import type { RenderResult } from '@testing-library/vue'; +import { render } from '@testing-library/vue'; import type { EffectScope } from 'vue'; -import { effectScope } from 'vue'; +import { defineComponent, effectScope, h } from 'vue'; +import { QueryBuilderPlugin } from '../src/plugin.js'; import type { QueryBuilderProps, RuleGroupProps, RuleProps } from '../src/types/index.js'; /** @@ -122,3 +125,13 @@ export const ruleGroupProps = ( translations: defaultTranslations as never, ...overrides, }); + +/** Mounts a component with no providers, for the internal parts' injection guards. */ +export const mountBare = (component: unknown): RenderResult => + render(defineComponent({ render: () => h(component as never) })); + +/** Mounts `template` in an app with {@link QueryBuilderPlugin} installed. */ +export const mountWithPlugin = (template: string, options?: { prefix?: string }): RenderResult => + render(defineComponent({ template }), { + global: { plugins: [options ? [QueryBuilderPlugin, options] : QueryBuilderPlugin] }, + }); diff --git a/packages/vue-querybuilder/vite.config.ts b/packages/vue-querybuilder/vite.config.ts index dcb5451..e0aa564 100644 --- a/packages/vue-querybuilder/vite.config.ts +++ b/packages/vue-querybuilder/vite.config.ts @@ -6,7 +6,12 @@ export default defineConfig({ plugins: [vue()], build: { lib: { - entry: resolve(import.meta.dirname, 'src/index.ts'), + entry: [ + resolve(import.meta.dirname, 'src/index.ts'), + // A second entry so that `@react-querybuilder/vue/resolver` is a real subpath export + // with its own declaration file, rather than a source file shipped as-is. + resolve(import.meta.dirname, 'src/resolver.ts'), + ], formats: ['es'], }, rollupOptions: {