Skip to content

Latest commit

 

History

History
297 lines (227 loc) · 10.1 KB

File metadata and controls

297 lines (227 loc) · 10.1 KB

Customization

Every part of the rendered tree can be replaced. There are three levels, in order of increasing reach:

  1. Translations — change the text (or markup) of a label or tooltip.
  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.

Before replacing a component, check whether styling gets you there.

Translations

translations overrides the text of every label, title, and placeholder. Labels are typed LabelNode = VNodeChild | string, so any label can be either plain text or arbitrary renderable Vue content:

<script setup lang="ts">
import { h, ref } from 'vue';
import { QueryBuilder } from '@react-querybuilder/vue';

const query = ref({ combinator: 'and', rules: [] });
const translations = {
  addRule: {
    label: () => [h('span', { 'aria-hidden': 'true' }, ''), ' Add rule'],
    title: 'Add a rule to this group',
  },
  fields: { placeholderLabel: 'Choose a field…' },
};
</script>

<template>
  <QueryBuilder :fields="fields" v-model:query="query" :translations="translations" />
</template>

Titles are plain strings — they end up in a title attribute, which cannot hold markup.

Replacing a control

Each control has two interchangeable customization points: a scoped slot and a controlElements entry.

Slots

For every key x of controlElements there is a slot #x. Its slot props are the props object the default component would have received.

<QueryBuilder :fields="fields" v-model:query="query">
  <template #valueEditor="props">
    <input
      :class="props.className"
      :value="props.value"
      :disabled="props.disabled"
      @input="props.handleOnChange(($event.target as HTMLInputElement).value)" />
  </template>
</QueryBuilder>

Slots are the better fit when the replacement is small, needs values from the surrounding scope, or is only used once. They are fully typed: the slot props come from a mapped type over the control components, so props above is a ValueEditorProps.

controlElements

Pass a component instead. Better fit when the replacement is reusable or needs its own state:

<script setup lang="ts">
import MyValueEditor from './MyValueEditor.vue';
</script>

<template>
  <QueryBuilder
    :fields="fields"
    v-model:query="query"
    :control-elements="{ valueEditor: MyValueEditor }" />
</template>

Passing null renders nothing:

<QueryBuilder :fields="fields" v-model:query="query" :control-elements="{ lockRuleAction: null }" />

There is no null form for a slot — omit the slot to fall through to the next source, or use controlElements to suppress the control.

Bulk overrides

actionElement — the slot or the controlElements key — replaces every button-type control at once (addRuleAction, removeGroupAction, shiftActions, …), and valueSelector replaces every <select>-type control (fieldSelector, operatorSelector, combinatorSelector, valueSourceSelector). Neither applies to valueEditor, rule, ruleGroup, inlineCombinator, notToggle, or matchModeEditor.

<QueryBuilder :fields="fields" v-model:query="query">
  <template #actionElement="{ label, title, className, disabled, handleOnClick }">
    <button type="button" :class="className" :title="title" :disabled="disabled" @click="handleOnClick()">
      {{ label }}
    </button>
  </template>
</QueryBuilder>

Resolution order

Each control key is resolved independently. Levels are tried in order — props, then inherited context, then the package defaults — and within a level:

  1. the keyed slot (#valueEditor)
  2. the keyed component (controlElements.valueEditor), where null means "render nothing" and stops the search
  3. the bulk slot (#valueSelector)
  4. the bulk component (controlElements.valueSelector)

So a slot passed to QueryBuilder beats a component passed to QueryBuilder, which beats anything inherited from context, which beats the default. Note the corollary: a component passed directly to QueryBuilder beats a slot supplied by an outer context provider, because levels are tried before sources within a level.

Slot identity determines component identity, so a slot is wrapped once and cached. Re-renders of the parent do not remount the replaced subtree.

Applying customization to a subtree

Context carries configuration — controlElements, slots, controlClassnames, translations, and the boolean flags — down to every query builder below it, including the subquery builders that match modes create.

<script setup lang="ts">
import { provideQueryBuilderContext } from '@react-querybuilder/vue';
import MyValueEditor from './MyValueEditor.vue';

provideQueryBuilderContext({
  controlElements: { valueEditor: MyValueEditor },
  translations: { addRule: { label: 'Add' } },
  showNotToggle: true,
});
</script>

provideQueryBuilderContext accepts a value, a ref, or a getter, so anything that has to stay reactive can be passed as a getter:

provideQueryBuilderContext(() => ({ showNotToggle: showNotToggle.value }));

To provide slots through context, forward them explicitly — slots is the prop form of the scoped slots:

<script setup lang="ts">
import { useSlots } from 'vue';
import { provideQueryBuilderContext } from '@react-querybuilder/vue';

const slots = useSlots();
provideQueryBuilderContext(() => ({ slots: { valueEditor: slots.valueEditor } }));
</script>

Props always win over context, per key.

Writing a replacement component

Replacement components receive the same props the default does; the types are exported from the package barrel:

<!-- MyValueEditor.vue -->
<script setup lang="ts">
import type { ValueEditorProps } from '@react-querybuilder/vue';

const props = defineProps<ValueEditorProps>();
</script>

<template>
  <input
    :data-testid="props.testID"
    :class="props.className"
    :title="props.title"
    :value="props.value"
    :disabled="props.disabled"
    @input="props.handleOnChange(($event.target as HTMLInputElement).value)" />
</template>

Declaring the full props type is enough: Rule and RuleGroup pass exactly the props each control's type lists, so nothing is left over to fall through, and normal Vue attribute fallthrough stays available for whatever a consumer of your component passes. If you declare only a subset of the props, set inheritAttrs: false so the rest do not land on the DOM as stray attributes.

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.

<!-- 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:

<script setup lang="ts">
import { useRule, type RuleProps } from '@react-querybuilder/vue';

const props = defineProps<RuleProps>();
const parts = useRule(() => props);
</script>

<template>
  <div :class="parts.outerClassName.value" :data-path="JSON.stringify(props.path)">
    <!---->
  </div>
</template>

Driving the query from outside

To manipulate the query from outside the component tree, construct a QueryManager and pass it in:

<script setup lang="ts">
import { QueryBuilder, QueryManager } from '@react-querybuilder/vue';

const manager = new QueryManager({ combinator: 'and', rules: [] }, { history: true });
</script>

<template>
  <button type="button" @click="manager.undo()">Undo</button>
  <QueryBuilder :fields="fields" :manager="manager" />
</template>

Classnames

controlClassnames appends to the standard classes rather than replacing them, so queryBuilder-invalid and friends keep working:

<QueryBuilder
  :fields="fields"
  v-model:query="query"
  :control-classnames="{ rule: 'my-rule', queryBuilder: 'my-qb' }" />

To drop the standard classes entirely, pass suppress-standard-classnames.