├── .browserslistrc
├── .editorconfig
├── .eslintignore
├── .eslintrc.js
├── .github
└── workflows
│ ├── ci-build.yml
│ ├── ci-testing.yml
│ ├── docs.yml
│ └── lint.yml
├── .gitignore
├── LICENSE
├── README.md
├── babel.config.js
├── dev
├── App.vue
├── GroupCtrlSlot.vue
├── Input.vue
├── Number.vue
├── RuleSlot.vue
└── main.ts
├── dist
├── demo.html
├── query-builder-vue.common.js
├── query-builder-vue.common.js.map
├── query-builder-vue.umd.js
├── query-builder-vue.umd.js.map
├── query-builder-vue.umd.min.js
└── query-builder-vue.umd.min.js.map
├── docs
├── .vuepress
│ ├── .gitignore
│ └── config.js
├── README.md
├── configuration.md
├── contributing.md
├── demos.md
├── getting-started.md
└── styling.md
├── jest.config.js
├── package.json
├── postcss.config.js
├── public
└── index.html
├── src
├── MergeTrap.ts
├── QueryBuilder.vue
├── QueryBuilderChild.vue
├── QueryBuilderGroup.vue
├── QueryBuilderRule.vue
├── grip-vertical-solid.svg
├── guards.ts
├── shims-tsx.d.ts
├── shims-vue.d.ts
├── shims-vuedraggable.d.ts
└── types.ts
├── tests
├── components
│ ├── App.vue
│ └── Component.vue
└── unit
│ ├── colored-borders.spec.ts
│ ├── drag-n-drop.spec.ts
│ ├── max-depth.spec.ts
│ ├── query-builder-child.spec.ts
│ ├── query-builder.spec.ts
│ ├── slots.spec.ts
│ └── validation.spec.ts
├── tsconfig.json
├── types
└── index.d.ts
├── vue.config.js
└── yarn.lock
/.browserslistrc:
--------------------------------------------------------------------------------
1 | > 1%
2 | last 2 versions
3 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | [*]
2 | indent_style = space
3 | indent_size = 2
4 | end_of_line = lf
5 | trim_trailing_whitespace = true
6 | insert_final_newline = true
7 | max_line_length = 100
8 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | *.json
2 | dist/
3 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 |
4 | env: {
5 | node: true,
6 | },
7 |
8 | extends: [
9 | 'plugin:vue/essential',
10 | '@vue/airbnb',
11 | '@vue/typescript',
12 | ],
13 |
14 | rules: {
15 | 'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off',
16 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
17 | 'class-methods-use-this': 'warn',
18 | 'no-use-before-define': ['error', { functions: false }],
19 | 'no-param-reassign': ['error', { props: false }],
20 | 'arrow-parens': ['error', 'as-needed'],
21 | },
22 |
23 | parserOptions: {
24 | parser: '@typescript-eslint/parser',
25 | },
26 |
27 | overrides: [
28 | {
29 | files: [
30 | '**/__tests__/*.{j,t}s?(x)',
31 | '**/tests/unit/**/*.spec.{j,t}s?(x)',
32 | ],
33 | env: {
34 | jest: true,
35 | },
36 | },
37 | {
38 | files: ['**/*.ts', '**/*.vue'],
39 | rules: {
40 | 'no-unused-vars': 'off',
41 | '@typescript-eslint/no-unused-vars': ['error'],
42 | },
43 | },
44 | ],
45 | };
46 |
--------------------------------------------------------------------------------
/.github/workflows/ci-build.yml:
--------------------------------------------------------------------------------
1 | name: CI Build
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | pull_request:
8 | branches:
9 | - main
10 |
11 | jobs:
12 | test-build:
13 | name: "CI Build Testing"
14 | timeout-minutes: 5
15 | runs-on: ${{ matrix.os }}
16 |
17 | strategy:
18 | fail-fast: false
19 | matrix:
20 | os:
21 | - "ubuntu-latest"
22 | node:
23 | - "16"
24 | - "14"
25 | - "12"
26 |
27 | steps:
28 | - name: Checkout
29 | uses: actions/checkout@v2
30 |
31 | - name: Setup Node.js version ${{ matrix.node }}
32 | uses: actions/setup-node@v2
33 | with:
34 | node-version: ${{ matrix.node }}
35 | cache: "yarn"
36 |
37 | - name: Install Dependencies
38 | run: yarn install --frozen-lockfile
39 |
40 | - name: Test Build
41 | run: yarn run build
42 |
--------------------------------------------------------------------------------
/.github/workflows/ci-testing.yml:
--------------------------------------------------------------------------------
1 | name: CI Testing
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | pull_request:
8 | branches:
9 | - main
10 |
11 | jobs:
12 | test-unit:
13 | name: "CI Unit Testing"
14 | timeout-minutes: 5
15 | runs-on: ${{ matrix.os }}
16 |
17 | strategy:
18 | fail-fast: false
19 | matrix:
20 | os:
21 | - "ubuntu-latest"
22 | node:
23 | - "16"
24 | - "14"
25 | - "12"
26 |
27 | steps:
28 | - name: Checkout
29 | uses: actions/checkout@v2
30 |
31 | - name: Setup Node.js version ${{ matrix.node }}
32 | uses: actions/setup-node@v2
33 | with:
34 | node-version: ${{ matrix.node }}
35 | cache: "yarn"
36 |
37 | - name: Install Dependencies
38 | run: yarn install --frozen-lockfile
39 |
40 | - name: Unit Test
41 | run: yarn run test:unit --coverage --collectCoverageOnlyFrom ./src/**
42 |
--------------------------------------------------------------------------------
/.github/workflows/docs.yml:
--------------------------------------------------------------------------------
1 | name: Docs
2 |
3 | on: workflow_dispatch
4 |
5 | jobs:
6 | build:
7 | name: Build docs
8 | runs-on: ubuntu-latest
9 |
10 | steps:
11 | - name: Checkout
12 | uses: actions/checkout@v2
13 |
14 | - name: Setup Node.js
15 | uses: actions/setup-node@v2
16 | with:
17 | cache: "yarn"
18 |
19 | - name: Install Dependencies
20 | run: yarn install --frozen-lockfile
21 |
22 | - name: Build Docs
23 | run: yarn run docs:build
24 |
25 | - name: Upload artifact
26 | uses: actions/upload-artifact@v2
27 | with:
28 | name: api-docs
29 | path: docs/.vuepress/dist/
30 |
31 | deploy:
32 | name: Deploy to GitHub Pages
33 | runs-on: ubuntu-latest
34 | environment: github-pages
35 | needs: build
36 |
37 | steps:
38 | - name: Download build artifact
39 | uses: actions/download-artifact@v2
40 | with:
41 | name: api-docs
42 |
43 | - name: Deploy docs
44 | uses: crazy-max/ghaction-github-pages@v2
45 | with:
46 | target_branch: gh-pages
47 | build_dir: .
48 | env:
49 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
50 |
--------------------------------------------------------------------------------
/.github/workflows/lint.yml:
--------------------------------------------------------------------------------
1 | name: Lint
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | pull_request:
8 | branches:
9 | - main
10 |
11 | jobs:
12 | lint:
13 | name: Lint
14 | runs-on: ubuntu-latest
15 |
16 | steps:
17 | - name: Checkout
18 | uses: actions/checkout@v2
19 |
20 | - name: Setup Node.js
21 | uses: actions/setup-node@v2
22 | with:
23 | cache: "yarn"
24 |
25 | - name: Install Dependencies
26 | run: yarn install --frozen-lockfile
27 |
28 | - name: Lint code
29 | run: yarn run lint
30 |
31 | - name: Check Lock File Changes
32 | run: >
33 | yarn
34 | && echo "Listing changed files:"
35 | && git diff --name-only --exit-code
36 | && echo "No files changed during lint."
37 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | node_modules
3 | coverage
4 |
5 | # local env files
6 | .env.local
7 | .env.*.local
8 |
9 | # Log files
10 | npm-debug.log*
11 | yarn-debug.log*
12 | yarn-error.log*
13 |
14 | # Editor directories and files
15 | .idea
16 | .vscode
17 | *.suo
18 | *.ntvs*
19 | *.njsproj
20 | *.sln
21 | *.sw?
22 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Rudolf Tucek
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [](https://github.com/rtucek/vue-query-builder/actions/workflows/ci-testing.yml)
2 | [](https://github.com/rtucek/vue-query-builder/actions/workflows/ci-build.yml)
3 | [](https://github.com/rtucek/vue-query-builder/actions/workflows/lint.yml)
4 | [](https://www.npmjs.com/package/query-builder-vue)
5 | [](https://github.com/rtucek/vue-query-builder/blob/master/LICENSE)
6 |
7 | # Vue Query Builder
8 |
9 | A query-builder for Vue.
10 |
11 |
12 | ## Demos
13 |
14 | Plenty of samples and use cases are covered in the
15 | [documentation](https://rtucek.github.io/vue-query-builder/demos.html).
16 |
17 |
18 | ## Features
19 |
20 | Key features:
21 |
22 | - Re-ordering of rules and groups with drag'n'drop.
23 | - Emphasizing groups with configurable colors.
24 | - Control maximum depth of nested groups.
25 | - Easy to customize with pure CSS and slots.
26 | - Layout can be serialized and restored.
27 | - Vuex compatible.
28 | - TypeScript support.
29 |
30 |
31 | ## Installation
32 |
33 | ```bash
34 | yarn add query-builder-vue
35 | npm install query-builder-vue
36 | ```
37 |
38 | Follow the docs for [minimum
39 | configuration](https://rtucek.github.io/vue-query-builder/getting-started.html#usage).
40 |
41 |
42 | ## Contribution
43 |
44 | [Contribution guidelines](https://rtucek.github.io/vue-query-builder/contributing.html) are located
45 | in the documentation.
46 |
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | presets: [
3 | '@vue/cli-plugin-babel/preset',
4 | ],
5 | };
6 |
--------------------------------------------------------------------------------
/dev/App.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Allow Drag'n'drop
7 |
8 |
13 |
14 |
38 |
70 |
71 |
76 |
80 |
81 | SLOT #groupOperator
82 |
86 | Select an operator
87 |
93 |
94 |
95 |
96 |
97 |
101 |
102 |
103 |
104 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
270 |
271 |
316 |
--------------------------------------------------------------------------------
/dev/GroupCtrlSlot.vue:
--------------------------------------------------------------------------------
1 |
12 |
13 |
14 |
15 | SLOT #groupControl
16 |
20 | Select a rule
21 |
27 |
28 |
33 | Add Rule
34 |
35 |
36 |
37 |
41 | Add Group
42 |
43 |
44 |
45 |
46 |
47 |
66 |
--------------------------------------------------------------------------------
/dev/Input.vue:
--------------------------------------------------------------------------------
1 |
25 |
26 |
27 |
32 |
33 |
--------------------------------------------------------------------------------
/dev/Number.vue:
--------------------------------------------------------------------------------
1 |
27 |
28 |
29 |
34 |
35 |
--------------------------------------------------------------------------------
/dev/RuleSlot.vue:
--------------------------------------------------------------------------------
1 |
18 |
19 |
20 |
21 | SLOT #rule
22 |
26 |
27 |
28 |
29 |
34 |
--------------------------------------------------------------------------------
/dev/main.ts:
--------------------------------------------------------------------------------
1 | import Vue from 'vue';
2 | import App from './App.vue';
3 |
4 | Vue.config.productionTip = false;
5 |
6 | new Vue({
7 | render: h => h(App),
8 | }).$mount('#app');
9 |
--------------------------------------------------------------------------------
/dist/demo.html:
--------------------------------------------------------------------------------
1 |
2 |
query-builder-vue demo
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
20 |
--------------------------------------------------------------------------------
/docs/.vuepress/.gitignore:
--------------------------------------------------------------------------------
1 | dist
2 |
--------------------------------------------------------------------------------
/docs/.vuepress/config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | title: 'Query Builder Vue',
3 | description: 'Query Builder Vue documentation',
4 |
5 | base: '/vue-query-builder/',
6 |
7 | themeConfig: {
8 | nav: [
9 | ],
10 |
11 | repo: 'rtucek/vue-query-builder',
12 | docsDir: 'docs',
13 | editLinks: true,
14 |
15 | sidebar: [
16 | ['/', 'Introduction'],
17 | ['/demos', 'Demos'],
18 | ['/getting-started', 'Getting Started'],
19 | ['/configuration', 'Configuration'],
20 | ['/styling', 'Styling'],
21 | ['/contributing', 'Contributing'],
22 | ],
23 | },
24 | }
25 |
--------------------------------------------------------------------------------
/docs/README.md:
--------------------------------------------------------------------------------
1 | # Vue Query Builder
2 |
3 | A Vue Query Builder library.
4 |
5 |
6 | ## About
7 |
8 | This library got heavily inspired by [Daniel Abernathy's](https://github.com/dabernathy89) awesome
9 | [vue-query-builder](https://github.com/dabernathy89/vue-query-builder) library, which got in turn
10 | inspired by jQuery's [Knockout Query Builder](https://kindohm.github.io/knockout-query-builder/).
11 |
12 | The intention behind building my own was adding some missing features such as providing TypeScript
13 | support and re-ordering with drag'n'drop.
14 |
15 |
22 |
23 |
24 | ## Features
25 |
26 | The query builder has the following key features:
27 |
28 | - Re-ordering of rules and groups with drag'n'drop.
29 | - Emphasizing groups with configurable colors.
30 | - Control maximum depth of nested groups.
31 | - Easy to customize with pure CSS and slots.
32 | - Layout can be serialized and restored.
33 | - Vuex compatible.
34 | - TypeScript support.
35 |
--------------------------------------------------------------------------------
/docs/configuration.md:
--------------------------------------------------------------------------------
1 | # Configuration
2 |
3 | The configuration is performed by the `config` prop. A single object, which shape is defined by the
4 | [QueryBuilderConfig interface](https://github.com/rtucek/vue-query-builder/blob/master/types/index.d.ts#L26).
5 |
6 | Below, we'll cover every key aspect of the config object.
7 |
8 |
9 | ## Operators
10 |
11 | The operators are used for allowing the users to choose how rules within a group should be
12 | evaluated. For instance, you may allow the users select classic boolean operators like *AND* and
13 | *OR* for a group. Additionally, you could also provide less common operators like *ONE OF* for
14 | requiring at least on condition is satisfied for considering an entire group as truthy.
15 | Along your existing operators, you may also provide the negated counterparts like *AND NOT*, *OR
16 | NOT* and *NONE OF* operators.
17 |
18 | Every operator is defined by an unique identifier and a visual text.
19 |
20 |
21 | ```js
22 | {
23 | operators: [
24 | {
25 | name: 'AND',
26 | identifier: 'AND',
27 | },
28 | {
29 | name: 'OR',
30 | identifier: 'OR',
31 | },
32 | {
33 | name: 'OR NOT',
34 | identifier: 'OR_NOT',
35 | },
36 | {
37 | name: 'AND NOT',
38 | identifier: 'AND_NOT',
39 | },
40 | // ...
41 | ],
42 | }
43 | ```
44 |
45 |
46 | ## Rules
47 |
48 | Rules are individual conditions within a group and must be defined as components, adhering to the
49 | [v-model API](https://vuejs.org/v2/guide/components.html#Using-v-model-on-Components).
50 |
51 | Every rule must have an unique identifier, a display name, the underlying component and optionally
52 | an initialization value.
53 |
54 | You may want to wrap an external library with a dedicated component for one of the following
55 | reasons:
56 | - The library doesn't support the v-model API.
57 | - The underlying component requires addition configuration or you may want to provide additional
58 | configuration with props.
59 | - You want to apply custom CSS.
60 |
61 | The `initialValue` may provide any primitive value (string, number or null), however any object or
62 | array must be constructed by a factory function.
63 |
64 | There are several options for assigning a component to a rule:
65 | - JavaScript modules
66 | - Pre-defined Vue Components
67 | - Inline the component's definition directly (requires compiler-included build for templates or use
68 | render functions directly)
69 |
70 | ::: tip
71 | Below, there's a very simple version of employing custom rules. A more advanced version with
72 | custom operators in rules is demonstrated
73 | [on CodeSandbox](https://codesandbox.io/s/slot-rule-advanced-01o6l?fontsize=14&hidenavigation=1&module=%2Fsrc%2FApp.vue&theme=dark).
74 | :::
75 |
76 |
77 | ```js
78 | import InputSelection from './InputSelection.vue';
79 |
80 | Vue.component('NumberSelection', {
81 | template: `
82 |
87 | `,
88 | props: [
89 | 'value',
90 | ],
91 | computed: {
92 | model: {
93 | get() {
94 | return this.value;
95 | },
96 | set(value) {
97 | this.$emit('input', value);
98 | },
99 | },
100 | },
101 | });
102 |
103 | {
104 | rules: [
105 | {
106 | identifier: 'txt',
107 | name: 'Text Selection',
108 | component: InputSelection,
109 | initialValue: '',
110 | },
111 | {
112 | identifier: 'num',
113 | name: 'Number Selection',
114 | component: 'NumberSelection',
115 | initialValue: 10,
116 | },
117 | {
118 | identifier: 'other-num',
119 | name: 'Other Number Selection',
120 | component: {
121 | template: `
122 |
127 | `,
128 | props: [
129 | 'value',
130 | ],
131 | computed: {
132 | model: {
133 | get() {
134 | return this.value;
135 | },
136 | set(value) {
137 | this.$emit('input', value);
138 | },
139 | },
140 | },
141 | },
142 | },
143 | ],
144 | }
145 | ```
146 |
147 |
154 |
155 |
156 | ## Colors
157 |
158 | A complex, deep nested query, can quickly become confusing. In order to keep an overview, nested
159 | groups may be emphasized with colorful hints.
160 |
161 | The `colors` property should be a string array with a minimum length of at least 2, containing any
162 | valid CSS color definition.
163 |
164 |
165 | ```js
166 | {
167 | colors: [
168 | 'hsl(88, 50%, 55%)',
169 | 'hsl(187, 100%, 45%)',
170 | 'hsl(15, 100%, 55%)',
171 | ],
172 | }
173 | ```
174 |
175 |
182 |
183 |
184 | ## Sortable
185 |
186 | Thanks to the excellent [Vue.Draggable](https://github.com/SortableJS/Vue.Draggable) library, the
187 | query builder supports re-sorting rules and groups with drag'n'drop.
188 |
189 | The sortable feature is disabled by default, however you may enable it simply by any [Sortable
190 | options](https://github.com/SortableJS/Sortable#options) on the dragging property.
191 |
192 | ::: warning
193 | There are 2 exceptions on the sortable options object:
194 |
195 | - `groups` are ignored. The main reason is that the query builder will have set the value internally
196 | for allowing nested dragging.
197 |
198 | - All methods with `on` are ignored. From
199 | [Vue.Sortable's documentation](https://github.com/SortableJS/Vue.Draggable#all-sortable-options):
200 | > [...]
201 | >
202 | > This means that all sortable option are valid sortable props with the notable exception of all
203 | > the method starting by "on" as draggable component expose the same API via events.
204 | >
205 | > [...]
206 | :::
207 |
208 | ```js
209 | {
210 | dragging: {
211 | animation: 300,
212 | disabled: false,
213 | dragClass: "sortable-drag"
214 | }
215 | }
216 | ```
217 |
218 |
225 |
226 |
227 | ## Max-Depth
228 |
229 | If `typeof maxDepth === 'undefined'`, users may have an arbitrary depth of nested groups.
230 |
231 | For `typeof maxDepth === 'number' and 0 <= n <= maxDepth`, users are only allowed to create up to n
232 | nested groups. If n is 0, users are effectively not allowed to create any groups at all.
233 |
234 |
235 | ### Runtime change
236 |
237 | It is possible and valid to change `maxDepth` at runtime.
238 | As a special chase, if the given query has a higher nested depth and a config change restricts the
239 | `maxDepth` to a lower depth, the library (intentionally) removes any child groups, exceeding the
240 | present config limit. This ensures consistency with the `maxDepth` policy. It's the component user's
241 | responsibility of checking if setting `maxDepth` may not result in an unwanted side-effect of
242 | removing any child-groups from the given query tree.
243 |
244 |
245 | ### Usage of Sortable
246 |
247 | If drag'n'drop is activated via [Sortable](#sortable) config:
248 | Prior dropping a dragged group into another group, the library checks if the max depth policy would
249 | be violated and prevents dropping. The user will notice that the drag'n'drop preview will not adjust
250 | as usually expected.
251 |
252 |
253 | ### Usage of groupCtrlSlotProps
254 |
255 | For the slot of type [`groupCtrlSlotProps`](styling.html#groupcontrol-slot), the `newGroup()`
256 | callback, passed as slot prop, becomes a noop, if a group has exceeded the `maxDepth` policy.
257 | Additionally, a boolean flag with a `maxDepthExeeded` property is provided to the slot prop object,
258 | so the slot can check and hide a create-new-group handler.
259 |
260 |
266 |
--------------------------------------------------------------------------------
/docs/contributing.md:
--------------------------------------------------------------------------------
1 | # Contributing
2 |
3 | Contributions are welcome and appreciated.
4 |
5 | Please mind the following rules:
6 |
7 | If fixing bug:
8 | * Provide a detailed description of the bug in the PR. Live demo preferred.
9 | * Add appropriate test coverage if applicable.
10 |
11 | If adding a new feature:
12 | * Add accompanying test case.
13 | * Add documentation.
14 | * Provide a convincing reason to add this feature. Ideally, you should open a suggestion issue first
15 | and have it approved before working on it.
16 | * **DO NOT** commit build files for either library nor documentation.
17 |
--------------------------------------------------------------------------------
/docs/demos.md:
--------------------------------------------------------------------------------
1 | # Demos
2 |
3 | The Query Builder has been designed with 2 goals in mind: allowing support of arbitrary components
4 | for selecting values and easy custom styling.
5 |
6 | The following samples demonstrate the capabilities of the library.
7 |
8 |
9 | ## Custom Components
10 |
11 | The Vue Query Builder may be combined with any library, supporting the [v-model
12 | API](https://vuejs.org/v2/guide/components.html#Using-v-model-on-Components).
13 |
14 | In the sample below, we're demonstrating usage of 3 different libraries: range selection
15 | (vue-range-component), select2 (vue-select) and a date-picker (vue-flatpickr-component).
16 |
17 |
18 |
25 |
26 |
27 | ## Theming
28 |
29 | Many work has been put into the ability of overriding the styling and markup for making the Query
30 | Builder agnostic to various CSS frameworks and responsive. Of course, you'll have to ensure by
31 | yourself that Query Builder rules are responsive.
32 |
33 |
34 | ### Bootstrap
35 |
36 | Query Builder with [Bootstrap 4](https://getbootstrap.com/) theme.
37 |
38 |
45 |
46 |
47 | ### Bulma
48 |
49 | Query Builder with [Bulma](https://bulma.io/) theme.
50 |
51 |
58 |
59 |
60 | ### Tailwind CSS
61 |
62 | Query Builder with [Tailwind CSS](https://tailwindcss.com/) theme.
63 |
64 |
71 |
--------------------------------------------------------------------------------
/docs/getting-started.md:
--------------------------------------------------------------------------------
1 | # Getting Started
2 |
3 |
4 | ## Installation
5 |
6 | ::: warning
7 | Please be aware of that at **least Vue v2.6** is required, due to the new scoped slots features that
8 | this library is making use of.
9 | :::
10 |
11 |
12 | Add the library with either npm or yarn - simply run one of the following commands:
13 |
14 |
15 | ```bash
16 | yarn add query-builder-vue
17 | npm install query-builder-vue
18 | ```
19 |
20 | You may also use the pre-transpiled dist files from the Unpkg.com CDN - simply add, but don't forget
21 | to import Vue too!
22 |
23 |
24 | ```html
25 |
26 | ```
27 |
28 |
29 | ## Usage
30 |
31 | Getting started with the QueryBuilder is easy.
32 | It just requires a minimum configuration.
33 |
34 |
41 |
42 |
43 | ```html
44 |
45 |
46 |
47 |
48 |
49 |
50 |
94 |
95 |
107 | ```
108 |
--------------------------------------------------------------------------------
/docs/styling.md:
--------------------------------------------------------------------------------
1 | # Styling
2 |
3 | The Query Builder's default markup and styling has been consciously been kept simple for maximum
4 | customizability.
5 |
6 | Support for styling with slots is available. Below, the following slots may be used for seamless
7 | styling.
8 |
9 | A slot may be a simple inline template or a dedicated component. While inline templates allow for
10 | simplicity, dedicated components allow maintaining an internal state and you may use all of Vue's
11 | features, including methods, computed properties and watchers.
12 |
13 | :::tip
14 | By instinct, you may would like to us `v-model` on some of the slot props. However, this is not
15 | supported by Vue. The props contain a callback which shall be used instead for updating a value.
16 |
17 | Often, you'll have to use `v-bind:value` and `v-on:input` instead.
18 | :::
19 |
20 |
21 | ## groupOperator Slot
22 |
23 | The `groupOperator` slot may be used for changing the markup of a group's operator.
24 |
25 | The slot receives an object with the shape of the [GroupOperatorSlotProps
26 | object](https://github.com/rtucek/vue-query-builder/blob/master/types/index.d.ts#L34).
27 |
28 | ```vue
29 |
30 |
34 |
35 |
36 | Custom #groupOperator slot
37 |
41 | Select an operator
42 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
60 | ```
61 |
62 |
69 |
70 |
71 | ## groupControl slot
72 |
73 | The `groupControl` slot allows for creating a new group or adding a new rule.
74 |
75 | The slot receives an object with the shape of the [GroupCtrlSlotProps
76 | object](https://github.com/rtucek/vue-query-builder/blob/master/types/index.d.ts#L40).
77 |
78 |
85 |
86 |
87 | ## rule slot
88 |
89 | The `rule` slot allows for customizing markup around each rule component.
90 |
91 | The slot receives an object with the shape of the [RuleSlotProps
92 | object](https://github.com/rtucek/vue-query-builder/blob/master/types/index.d.ts#L47).
93 | An exact rule can be identified based on the `ruleCtrl.ruleIdentifier` for dynamic content.
94 |
95 | You'll have to use Vue's [Dynamic
96 | Component](https://vuejs.org/v2/guide/components.html#Dynamic-Components) feature for displaying the
97 | actual rule component.
98 |
99 | ```vue{10-14}
100 |
105 |
106 |
107 |
108 | SLOT #rule
109 |
114 |
115 |
116 | ```
117 |
118 |
125 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | preset: '@vue/cli-plugin-unit-jest/presets/typescript-and-babel',
3 | };
4 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "query-builder-vue",
3 | "version": "1.2.0",
4 | "license": "MIT",
5 | "repository": "https://github.com/rtucek/vue-query-builder",
6 | "description": "A query-builder library for Vue.js",
7 | "author": {
8 | "name": "Rudolf Tucek",
9 | "url": "https://github.com/rtucek/"
10 | },
11 | "keywords": [
12 | "drag'n'drop",
13 | "Query Builder",
14 | "themeable",
15 | "TypeScript",
16 | "Vue",
17 | "Vuex support"
18 | ],
19 | "private": false,
20 | "scripts": {
21 | "preversion-test": "echo \"Running tests for version $npm_package_version...\" && yarn run test:unit",
22 | "preversion": "yarn run preversion-test",
23 | "serve": "vue-cli-service serve dev/main.ts",
24 | "build": "vue-cli-service build --target lib src/QueryBuilder.vue",
25 | "test:unit": "vue-cli-service test:unit",
26 | "lint": "vue-cli-service lint src/ tests/ dev/",
27 | "docs:dev": "vuepress dev docs",
28 | "docs:build": "vuepress build docs"
29 | },
30 | "main": "dist/query-builder-vue.umd.js",
31 | "browser": "dist/query-builder-vue.common.js",
32 | "jsdelivr": "dist/query-builder-vue.umd.min.js",
33 | "unpkg": "dist/query-builder-vue.umd.min.js",
34 | "types": "./types/index.d.ts",
35 | "files": [
36 | "/dist/*.js",
37 | "/dist/*.js.map",
38 | "/types/*.d.ts"
39 | ],
40 | "dependencies": {
41 | "@types/sortablejs": "^1.10.2",
42 | "core-js": "^3.3.2",
43 | "sortablejs": "^1.11.2-alpha.4",
44 | "vue": "^2.6.10",
45 | "vue-class-component": "^7.2.6",
46 | "vue-property-decorator": "^9.0.0",
47 | "vuedraggable": "^2.24.1"
48 | },
49 | "devDependencies": {
50 | "@types/jest": "^27.0.1",
51 | "@typescript-eslint/eslint-plugin": "^4.1.1",
52 | "@typescript-eslint/parser": "^4.1.1",
53 | "@vue/cli-plugin-babel": "^4.0.5",
54 | "@vue/cli-plugin-eslint": "^4.0.5",
55 | "@vue/cli-plugin-typescript": "^4.0.5",
56 | "@vue/cli-plugin-unit-jest": "^4.1.2",
57 | "@vue/cli-service": "^4.0.5",
58 | "@vue/eslint-config-airbnb": "^5.1.0",
59 | "@vue/eslint-config-typescript": "^7.0.0",
60 | "@vue/test-utils": "^1.1.0",
61 | "babel-eslint": "^10.0.1",
62 | "eslint": "^7.9.0",
63 | "eslint-plugin-vue": "^7.1.0",
64 | "lint-staged": "^11.1.2",
65 | "sass": "^1.49.9",
66 | "sass-loader": "^10.2.0",
67 | "typescript": "^4.0.3",
68 | "vue-template-compiler": "^2.5.21",
69 | "vuepress": "^1.2.0"
70 | },
71 | "gitHooks": {
72 | "pre-commit": "lint-staged"
73 | },
74 | "lint-staged": {
75 | "*.{js,vue,ts}": [
76 | "vue-cli-service lint"
77 | ]
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | autoprefixer: {},
4 | },
5 | };
6 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | query-builder
8 |
9 |
10 |
11 | We're sorry but query-builder doesn't work properly without JavaScript enabled. Please enable it to continue.
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/src/MergeTrap.ts:
--------------------------------------------------------------------------------
1 | import Vue from 'vue';
2 | import {
3 | QueryBuilderGroupSym,
4 | RuleSet, QueryBuilderGroup, ComponentRegistration, MergeTrap as MergeTrapInterface, Rule,
5 | } from '@/types';
6 |
7 | function getNextGroup(group: QueryBuilderGroup): QueryBuilderGroup {
8 | if (group.depth < 1) {
9 | return group;
10 | }
11 |
12 | let vm: Vue = group;
13 |
14 | do {
15 | vm = vm.$parent;
16 | } while ((vm as QueryBuilderGroup).type !== QueryBuilderGroupSym);
17 |
18 | return vm as QueryBuilderGroup;
19 | }
20 |
21 | function getCommonAncestor(
22 | nodeA: QueryBuilderGroup,
23 | nodeB: QueryBuilderGroup,
24 | ): QueryBuilderGroup {
25 | let a = nodeA;
26 | let b = nodeB;
27 |
28 | if (a.depth !== b.depth) {
29 | let lower: QueryBuilderGroup = a.depth > b.depth ? a : b;
30 | const higher: QueryBuilderGroup = a.depth < b.depth ? a : b;
31 |
32 | while (lower.depth !== higher.depth) {
33 | lower = getNextGroup(lower);
34 | }
35 |
36 | // Now both operate on the same level.
37 | a = lower;
38 | b = higher;
39 | }
40 |
41 | while (a !== b) {
42 | a = getNextGroup(a);
43 | b = getNextGroup(b);
44 | }
45 |
46 | return a;
47 | }
48 |
49 | function triggerUpdate(adder: ComponentRegistration, remover: ComponentRegistration): void {
50 | const commonAncestor = getCommonAncestor(adder.component, remover.component);
51 |
52 | if (![adder.component, remover.component].includes(commonAncestor)) {
53 | mergeViaParent(commonAncestor, adder, remover);
54 |
55 | return;
56 | }
57 |
58 | mergeViaNode(commonAncestor, adder, remover);
59 | }
60 |
61 | function mergeViaParent(
62 | commonAncestor: QueryBuilderGroup,
63 | adder: ComponentRegistration,
64 | remover: ComponentRegistration,
65 | ): void {
66 | let children: Array | null = null;
67 |
68 | commonAncestor.trap = (position: number, newChild: RuleSet | Rule): void => {
69 | if (children === null) {
70 | children = [...commonAncestor.children];
71 | children.splice(position, 1, newChild);
72 |
73 | return;
74 | }
75 |
76 | commonAncestor.trap = null;
77 |
78 | children.splice(position, 1, newChild);
79 |
80 | commonAncestor.$emit(
81 | 'query-update',
82 | {
83 | operatorIdentifier: commonAncestor.selectedOperator,
84 | children,
85 | } as RuleSet,
86 | );
87 | };
88 |
89 | adder.component.$emit('query-update', adder.ev);
90 | remover.component.$emit('query-update', remover.ev);
91 | }
92 |
93 | function mergeViaNode(
94 | parentEmitter: QueryBuilderGroup,
95 | adder: ComponentRegistration,
96 | remover: ComponentRegistration,
97 | ): void {
98 | const childEmitter = parentEmitter === adder.component ? remover : adder;
99 | const children = [...parentEmitter.children];
100 |
101 | parentEmitter.trap = (position: number, newChild: RuleSet | Rule): void => {
102 | parentEmitter.trap = null; // Release trap
103 | children.splice(position, 1, newChild); // First... accept the update from the child
104 |
105 | // Now we'd need to know if the update on the parent is an add or remove action.
106 | if (parentEmitter === adder.component) {
107 | // Parent emitter is adding and child is removing an item.
108 | //
109 | // First, use the event from the child to patch the current state (see above),
110 | // then use the state from the adder for inserting at the right idx.
111 | children.splice(adder.affectedIdx, 0, adder.ev.children[adder.affectedIdx]);
112 | } else {
113 | // Parent emitter is removing and child is adding an item.
114 | //
115 | // Use the event from the child to patch the current state (see above),
116 | // then use the state from the remover to remove the correct item.
117 | children.splice(remover.affectedIdx, 1);
118 | }
119 |
120 | parentEmitter.$emit(
121 | 'query-update',
122 | {
123 | operatorIdentifier: parentEmitter.selectedOperator,
124 | children,
125 | } as RuleSet,
126 | );
127 | };
128 |
129 | childEmitter.component.$emit('query-update', childEmitter.ev);
130 | }
131 |
132 | export default class MergeTrap implements MergeTrapInterface {
133 | private eventBus: Vue
134 |
135 | constructor() {
136 | this.eventBus = new Vue();
137 |
138 | Promise.all([
139 | new Promise(res => this.eventBus.$once('adder-registered', res)),
140 | new Promise(res => this.eventBus.$once('remover-registered', res)),
141 | ])
142 | .then((args: ComponentRegistration[]) => triggerUpdate(args[0], args[1]));
143 | }
144 |
145 | public registerSortUpdate(update: ComponentRegistration): void {
146 | if (update.adding) {
147 | return this.registerAdder(update);
148 | }
149 |
150 | return this.registerRemover(update);
151 | }
152 |
153 | protected registerAdder(ev: ComponentRegistration): void {
154 | this.eventBus.$emit('adder-registered', ev);
155 | }
156 |
157 | protected registerRemover(ev: ComponentRegistration): void {
158 | this.eventBus.$emit('remover-registered', ev);
159 | }
160 | }
161 |
--------------------------------------------------------------------------------
/src/QueryBuilder.vue:
--------------------------------------------------------------------------------
1 |
88 |
89 |
90 |
97 |
101 |
105 |
106 |
107 |
108 |
109 |
115 |
--------------------------------------------------------------------------------
/src/QueryBuilderChild.vue:
--------------------------------------------------------------------------------
1 |
75 |
76 |
77 |
78 |
86 |
90 |
94 |
95 |
96 |
101 | ×
102 |
103 |
104 |
105 |
106 |
135 |
--------------------------------------------------------------------------------
/src/QueryBuilderGroup.vue:
--------------------------------------------------------------------------------
1 |
377 |
378 |
379 |
380 |
381 |
382 |
383 |
389 |
393 |
394 |
395 |
396 |
397 |
403 |
Operator
404 |
405 | Select an operator
406 |
412 |
413 |
414 |
415 |
416 |
420 |
421 |
422 |
423 |
424 | Select a rule
425 |
431 |
432 |
437 | Add Rule
438 |
439 |
440 |
441 |
445 | Add Group
446 |
447 |
448 |
449 |
450 |
451 |
460 |
470 |
474 |
478 |
479 |
480 |
481 |
482 |
483 |
484 |
546 |
--------------------------------------------------------------------------------
/src/QueryBuilderRule.vue:
--------------------------------------------------------------------------------
1 |
70 |
71 |
72 |
73 |
79 |
80 |
84 |
85 |
86 |
87 |
88 |
92 |
93 |
94 |
95 |
96 |
97 |
130 |
--------------------------------------------------------------------------------
/src/grip-vertical-solid.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/src/guards.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Rule, RuleSet, QueryBuilderConfig, OperatorDefinition, RuleDefinition,
3 | } from '@/types';
4 |
5 | export function isRule(param: any): param is Rule {
6 | if (typeof param !== 'object' || param === null) {
7 | return false;
8 | }
9 |
10 | if (typeof param.identifier !== 'string') {
11 | return false;
12 | }
13 |
14 | const { hasOwnProperty } = Object.prototype;
15 |
16 | return hasOwnProperty.call(param, 'value');
17 | }
18 |
19 | export function isRuleSet(param: any): param is RuleSet {
20 | if (typeof param !== 'object' || param === null) {
21 | return false;
22 | }
23 |
24 | if (typeof param.operatorIdentifier !== 'string') {
25 | return false;
26 | }
27 |
28 | return Array.isArray(param.children)
29 | && param.children.every((child: any) => isRule(child) || isRuleSet(child));
30 | }
31 |
32 | export function isOperatorDefinition(param: any): param is OperatorDefinition {
33 | if (typeof param !== 'object' || param === null) {
34 | return false;
35 | }
36 |
37 | if (typeof param.identifier !== 'string') {
38 | return false;
39 | }
40 |
41 | return typeof param.name === 'string';
42 | }
43 |
44 | export function isRuleDefinition(param: any): param is RuleDefinition {
45 | if (typeof param !== 'object' || param === null) {
46 | return false;
47 | }
48 |
49 | if (typeof param.identifier !== 'string') {
50 | return false;
51 | }
52 |
53 | if (typeof param.name !== 'string') {
54 | return false;
55 | }
56 |
57 | return ['function', 'object', 'string'].includes(typeof param.component);
58 | }
59 |
60 | export function isQueryBuilderConfig(param: any): param is QueryBuilderConfig {
61 | if (typeof param !== 'object' || param === null) {
62 | return false;
63 | }
64 |
65 | return Array.isArray(param.operators)
66 | && param.operators.every((operator: any) => isOperatorDefinition(operator))
67 | && Array.isArray(param.rules)
68 | && param.rules.every((rule: any) => isRuleDefinition(rule))
69 | && (
70 | !param.colors
71 | || (
72 | Array.isArray(param.colors)
73 | && param.colors.every((color: any) => typeof color === 'string')
74 | )
75 | )
76 | && (
77 | typeof param.maxDepth === 'undefined' // optional config value not present
78 | || (
79 | typeof param.maxDepth === 'number'
80 | && param.maxDepth >= 0
81 | )
82 | );
83 | }
84 |
--------------------------------------------------------------------------------
/src/shims-tsx.d.ts:
--------------------------------------------------------------------------------
1 | import Vue, { VNode } from 'vue';
2 |
3 | declare global {
4 | namespace JSX {
5 | // tslint:disable no-empty-interface
6 | interface Element extends VNode {}
7 | // tslint:disable no-empty-interface
8 | interface ElementClass extends Vue {}
9 | interface IntrinsicElements {
10 | [elem: string]: any
11 | }
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/src/shims-vue.d.ts:
--------------------------------------------------------------------------------
1 | declare module '*.vue' {
2 | import Vue from 'vue';
3 |
4 | export default Vue;
5 | }
6 |
--------------------------------------------------------------------------------
/src/shims-vuedraggable.d.ts:
--------------------------------------------------------------------------------
1 | declare module 'vuedraggable' {
2 | type element = {
3 | element: El,
4 | }
5 |
6 | type Older = {
7 | oldIndex: number,
8 | }
9 |
10 | type Newer = {
11 | newIndex: number,
12 | }
13 |
14 | export type Moved = element & Older & Newer
15 |
16 | export type Added = element & Newer
17 |
18 | export type Removed = element & Older
19 |
20 | export type ChangeEvent = {
21 | moved?: Moved,
22 | added?: Added,
23 | removed?: Removed,
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
1 | import Vue, { Component } from 'vue';
2 | import { SortableOptions } from 'sortablejs';
3 |
4 | export interface Rule {
5 | identifier: string,
6 | value: any,
7 | }
8 |
9 | export interface RuleSet {
10 | operatorIdentifier: string,
11 | children: Array,
12 | }
13 |
14 | export interface OperatorDefinition {
15 | identifier: string,
16 | name: string,
17 | }
18 |
19 | export interface RuleDefinition {
20 | identifier: string,
21 | name: string,
22 | component: Component | string,
23 | initialValue?: any,
24 | }
25 |
26 | export interface QueryBuilderConfig {
27 | operators: OperatorDefinition[],
28 | rules: RuleDefinition[],
29 | maxDepth?: number,
30 | colors?: string[],
31 | dragging?: SortableOptions,
32 | }
33 |
34 | export interface GroupOperatorSlotProps {
35 | currentOperator: string,
36 | operators: OperatorDefinition[],
37 | updateCurrentOperator: (newOperator: string) => void,
38 | }
39 |
40 | export interface GroupCtrlSlotProps {
41 | maxDepthExeeded: boolean,
42 | rules: RuleDefinition[],
43 | addRule: (newRule: string) => void,
44 | newGroup: () => void,
45 | }
46 |
47 | export interface RuleSlotProps {
48 | ruleComponent: Component | string,
49 | ruleData: any,
50 | ruleIdentifier: string,
51 | updateRuleData: (newData: any) => void,
52 | }
53 |
54 | export const QueryBuilderGroupSym = Symbol('QueryBuilderGroup');
55 |
56 | export interface QueryBuilderGroup extends Vue {
57 | selectedOperator: string,
58 | depth: number,
59 | trap: ((position: number, newChild: RuleSet | Rule) => void) | null,
60 | children: Array,
61 | type: Symbol,
62 | }
63 |
64 | export interface ComponentRegistration {
65 | component: QueryBuilderGroup,
66 | ev: RuleSet,
67 | adding: boolean,
68 | affectedIdx: number,
69 | }
70 |
71 | export interface MergeTrap {
72 | registerSortUpdate(update: ComponentRegistration): void,
73 | }
74 |
--------------------------------------------------------------------------------
/tests/components/App.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
7 |
8 |
9 |
10 |
11 |
31 |
--------------------------------------------------------------------------------
/tests/components/Component.vue:
--------------------------------------------------------------------------------
1 |
15 |
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/tests/unit/colored-borders.spec.ts:
--------------------------------------------------------------------------------
1 | import { mount } from '@vue/test-utils';
2 | import Draggable from 'vuedraggable';
3 | import { QueryBuilderConfig, RuleSet } from '@/types';
4 | import QueryBuilder from '@/QueryBuilder.vue';
5 | import QueryBuilderGroup from '@/QueryBuilderGroup.vue';
6 | import QueryBuilderChild from '@/QueryBuilderChild.vue';
7 | import Component from '../components/Component.vue';
8 |
9 | describe('Testing drag\'n\'drop related features', () => {
10 | const config: QueryBuilderConfig = {
11 | operators: [
12 | {
13 | name: 'and',
14 | identifier: 'AND',
15 | },
16 | {
17 | name: 'or',
18 | identifier: 'OR',
19 | },
20 | ],
21 | rules: [
22 | {
23 | identifier: 'txt',
24 | name: 'Text Selection',
25 | component: Component,
26 | initialValue: 'foo',
27 | },
28 | {
29 | identifier: 'num',
30 | name: 'Number Selection',
31 | component: Component,
32 | initialValue: 10,
33 | },
34 | ],
35 | };
36 |
37 | const value: RuleSet = {
38 | operatorIdentifier: 'OR',
39 | children: [{
40 | operatorIdentifier: 'AND',
41 | children: [{
42 | identifier: 'txt',
43 | value: 'A',
44 | }, {
45 | identifier: 'txt',
46 | value: 'B',
47 | }, {
48 | identifier: 'txt',
49 | value: 'C',
50 | }, {
51 | operatorIdentifier: 'AND',
52 | children: [{
53 | identifier: 'txt',
54 | value: 'c',
55 | }, {
56 | identifier: 'txt',
57 | value: 'd',
58 | }, {
59 | operatorIdentifier: 'AND',
60 | children: [{
61 | identifier: 'txt',
62 | value: 'a',
63 | }, {
64 | identifier: 'txt',
65 | value: 'b',
66 | }],
67 | }],
68 | }],
69 | }, {
70 | operatorIdentifier: 'AND',
71 | children: [{
72 | identifier: 'txt',
73 | value: 'X',
74 | }, {
75 | identifier: 'txt',
76 | value: 'Y',
77 | }, {
78 | identifier: 'txt',
79 | value: 'Z',
80 | }, {
81 | operatorIdentifier: 'AND',
82 | children: [{
83 | identifier: 'txt',
84 | value: '',
85 | }, {
86 | operatorIdentifier: 'AND',
87 | children: [{
88 | identifier: 'txt',
89 | value: '',
90 | }, {
91 | operatorIdentifier: 'AND',
92 | children: [{
93 | operatorIdentifier: 'AND',
94 | children: [{
95 | identifier: 'txt',
96 | value: '',
97 | }, {
98 | identifier: 'num',
99 | value: 10,
100 | }],
101 | }],
102 | }],
103 | }],
104 | }],
105 | }],
106 | };
107 |
108 | it('asserts nothing happens if colors are not configured', () => {
109 | const app = mount(QueryBuilder, {
110 | propsData: {
111 | value,
112 | config,
113 | },
114 | });
115 |
116 | const groups = app.findAllComponents(QueryBuilderGroup);
117 | expect(groups).toHaveLength(9);
118 |
119 | groups.wrappers
120 | .forEach(w => {
121 | expect(w.vm.$props).toHaveProperty('depth');
122 | const el = (w.findComponent(QueryBuilderChild)).element as HTMLDivElement;
123 | expect(el.style.borderColor).toBeFalsy();
124 | });
125 | });
126 |
127 | it('checks border colors are applied properly', () => {
128 | const colors = [
129 | 'hsl(88, 50%, 55%)',
130 | 'hsl(187, 100%, 45%)',
131 | 'hsl(15, 100%, 55%)',
132 | ];
133 | const newConfig: QueryBuilderConfig = { ...config, colors };
134 |
135 | const app = mount(QueryBuilder, {
136 | propsData: {
137 | value,
138 | config: newConfig,
139 | },
140 | });
141 |
142 | const groups = app.findAllComponents(QueryBuilderGroup);
143 | expect(groups).toHaveLength(9);
144 |
145 | groups.wrappers
146 | .forEach(w => {
147 | expect(w.vm.$props).toHaveProperty('depth');
148 | const el = (w.findComponent(Draggable)).element as HTMLDivElement;
149 | const targetIdx = w.vm.$props.depth % w.vm.$props.config.colors.length;
150 | expect(el.style).toHaveProperty('borderColor', colors[targetIdx]);
151 | });
152 | });
153 | });
154 |
--------------------------------------------------------------------------------
/tests/unit/drag-n-drop.spec.ts:
--------------------------------------------------------------------------------
1 | import { mount } from '@vue/test-utils';
2 | import Draggable, { ChangeEvent } from 'vuedraggable';
3 | import QueryBuilder from '@/QueryBuilder.vue';
4 | import QueryBuilderGroup from '@/QueryBuilderGroup.vue';
5 | import {
6 | RuleSet, Rule, QueryBuilderConfig, QueryBuilderGroup as QueryBuilderGroupInterface,
7 | } from '@/types';
8 | import Component from '../components/Component.vue';
9 |
10 | // Schedule a microtask, so all pending promises can be executed
11 | const flushPromises = (): Promise => new Promise(res => setTimeout(res, 0));
12 |
13 | describe('Test drag\'n\'drop related actions', () => {
14 | const value: RuleSet = {
15 | operatorIdentifier: 'OR',
16 | children: [{
17 | operatorIdentifier: 'AND',
18 | children: [{
19 | identifier: 'txt',
20 | value: 'A',
21 | }, {
22 | identifier: 'txt',
23 | value: 'B',
24 | }, {
25 | identifier: 'txt',
26 | value: 'C',
27 | }, {
28 | operatorIdentifier: 'AND',
29 | children: [{
30 | identifier: 'txt',
31 | value: 'D',
32 | }, {
33 | identifier: 'txt',
34 | value: 'E',
35 | }, {
36 | operatorIdentifier: 'AND',
37 | children: [{
38 | identifier: 'txt',
39 | value: 'F',
40 | }, {
41 | operatorIdentifier: 'AND',
42 | children: [{
43 | identifier: 'txt',
44 | value: 'G',
45 | }],
46 | }, {
47 | identifier: 'txt',
48 | value: 'H',
49 | }],
50 | }],
51 | }],
52 | }, {
53 | operatorIdentifier: 'AND',
54 | children: [{
55 | identifier: 'txt',
56 | value: 'X',
57 | }, {
58 | operatorIdentifier: 'AND',
59 | children: [{
60 | identifier: 'txt',
61 | value: 'T',
62 | }],
63 | }, {
64 | identifier: 'txt',
65 | value: 'Y',
66 | }, {
67 | identifier: 'txt',
68 | value: 'Z',
69 | }],
70 | }],
71 | };
72 |
73 | const config: QueryBuilderConfig = {
74 | operators: [
75 | {
76 | name: 'AND',
77 | identifier: 'AND',
78 | },
79 | {
80 | name: 'OR',
81 | identifier: 'OR',
82 | },
83 | ],
84 | rules: [
85 | {
86 | identifier: 'txt',
87 | name: 'Text Selection',
88 | component: Component,
89 | initialValue: '',
90 | },
91 | {
92 | identifier: 'num',
93 | name: 'Number Selection',
94 | component: Component,
95 | initialValue: 10,
96 | },
97 | ],
98 | dragging: {
99 | animation: 300,
100 | disabled: false,
101 | ghostClass: 'ghost',
102 | },
103 | };
104 |
105 | it('ensures missing drag\'n\'drop configuration does not break the component', () => {
106 | const newConfig: QueryBuilderConfig = {
107 | operators: config.operators,
108 | rules: config.rules,
109 | };
110 |
111 | mount(QueryBuilder, {
112 | propsData: {
113 | value,
114 | config: newConfig,
115 | },
116 | });
117 | });
118 |
119 | it('tests drag\'n\'dropping within the same group', () => {
120 | const app = mount(QueryBuilder, {
121 | propsData: {
122 | value,
123 | config,
124 | },
125 | });
126 |
127 | const qbClone: RuleSet = JSON.parse(JSON.stringify(value));
128 | const { children } = (qbClone.children[0] as RuleSet).children[3] as RuleSet;
129 | children.splice(2, 0, children.splice(0, 1)[0]);
130 | ((qbClone.children[0] as RuleSet).children[3] as RuleSet).children = children;
131 |
132 | const group = app.findAllComponents(QueryBuilderGroup)
133 | .filter(qb => {
134 | const vm = qb.vm as QueryBuilderGroupInterface;
135 |
136 | return vm.selectedOperator === 'AND'
137 | && vm.children.length === 3
138 | && (vm.children[0] as Rule).value === 'D';
139 | })
140 | .at(0);
141 |
142 | const mvEv: ChangeEvent = {
143 | moved: {
144 | element: (group.vm as QueryBuilderGroupInterface).children[0],
145 | oldIndex: 0,
146 | newIndex: 2,
147 | },
148 | };
149 | group.findComponent(Draggable).vm.$emit('change', mvEv);
150 | expect((group.emitted('query-update') as any)[0][0]).toStrictEqual({ operatorIdentifier: 'AND', children });
151 | expect((app.emitted('input') as any)[0][0]).toStrictEqual(qbClone);
152 | });
153 |
154 | it('tests drag\'n\'drop by merging with a parent group', done => {
155 | const app = mount(QueryBuilder, {
156 | propsData: {
157 | value,
158 | config,
159 | },
160 | });
161 |
162 | const qbClone: RuleSet = JSON.parse(JSON.stringify(value));
163 | const remover = qbClone.children[0] as RuleSet;
164 | const adder = qbClone.children[1] as RuleSet;
165 | const element = remover.children.splice(1, 1)[0] as Rule; // Moving element
166 | adder.children.splice(3, 0, element);
167 |
168 | // Component we'd need to assert against
169 | const parent = app.findComponent(QueryBuilderGroup);
170 |
171 | // Removing branch
172 | const removerComponent = app.findAllComponents(QueryBuilderGroup)
173 | .filter(qb => {
174 | const vm = qb.vm as QueryBuilderGroupInterface;
175 |
176 | return vm.selectedOperator === 'AND'
177 | && vm.children.length === 4
178 | && (vm.children[0] as Rule).value === 'A';
179 | })
180 | .at(0);
181 | const rmEv: ChangeEvent = {
182 | removed: {
183 | element,
184 | oldIndex: 1,
185 | },
186 | };
187 | removerComponent.findComponent(Draggable).vm.$emit('change', rmEv);
188 |
189 | // Adding branch
190 | const adderComponent = app.findAllComponents(QueryBuilderGroup)
191 | .filter(qb => {
192 | const vm = qb.vm as QueryBuilderGroupInterface;
193 |
194 | return vm.selectedOperator === 'AND'
195 | && vm.children.length === 4
196 | && (vm.children[0] as Rule).value === 'X';
197 | })
198 | .at(0);
199 | const addEv: ChangeEvent = {
200 | added: {
201 | element,
202 | newIndex: 3,
203 | },
204 | };
205 | adderComponent.findComponent(Draggable).vm.$emit('change', addEv);
206 |
207 | flushPromises()
208 | .then(() => {
209 | expect((parent.emitted('query-update') as any)[0][0]).toStrictEqual(qbClone);
210 | expect((app.emitted('input') as any)[0][0]).toStrictEqual(qbClone);
211 |
212 | done();
213 | });
214 | });
215 |
216 | it('tests drag\'n\'dropping with merging within the adding group', done => {
217 | const app = mount(QueryBuilder, {
218 | propsData: {
219 | value,
220 | config,
221 | },
222 | });
223 |
224 | // Move 'D' to parent group after 'C'
225 | const qbClone: RuleSet = JSON.parse(JSON.stringify(value));
226 | const adder = qbClone.children[0] as RuleSet;
227 | const remover = adder.children[3] as RuleSet;
228 | const element = remover.children.splice(0, 1)[0] as Rule; // Moving element
229 | adder.children.splice(3, 0, element);
230 |
231 | // Removing branch
232 | const removerComponent = app.findAllComponents(QueryBuilderGroup)
233 | .filter(qb => {
234 | const vm = qb.vm as QueryBuilderGroupInterface;
235 |
236 | return vm.selectedOperator === 'AND'
237 | && vm.children.length === 3
238 | && (vm.children[0] as Rule).value === 'D';
239 | })
240 | .at(0);
241 | const rmEv: ChangeEvent = {
242 | removed: {
243 | element,
244 | oldIndex: 0,
245 | },
246 | };
247 | removerComponent.findComponent(Draggable).vm.$emit('change', rmEv);
248 |
249 | // Adding branch
250 | const adderComponent = app.findAllComponents(QueryBuilderGroup)
251 | .filter(qb => {
252 | const vm = qb.vm as QueryBuilderGroupInterface;
253 |
254 | return vm.selectedOperator === 'AND'
255 | && vm.children.length === 4
256 | && (vm.children[0] as Rule).value === 'A';
257 | })
258 | .at(0);
259 | const addEv: ChangeEvent = {
260 | added: {
261 | element,
262 | newIndex: 3,
263 | },
264 | };
265 | adderComponent.findComponent(Draggable).vm.$emit('change', addEv);
266 |
267 | flushPromises()
268 | .then(() => {
269 | expect((adderComponent.emitted('query-update') as any)[0][0]).toStrictEqual(adder);
270 | expect((app.emitted('input') as any)[0][0]).toStrictEqual(qbClone);
271 |
272 | done();
273 | });
274 | });
275 |
276 | it('tests drag\'n\'dropping with merging within the deleting group', done => {
277 | const app = mount(QueryBuilder, {
278 | propsData: {
279 | value,
280 | config,
281 | },
282 | });
283 |
284 | // Move 'A' to child group between 'D' and 'E'
285 | const qbClone: RuleSet = JSON.parse(JSON.stringify(value));
286 | const remover = qbClone.children[0] as RuleSet;
287 | const adder = remover.children[3] as RuleSet;
288 | const element = remover.children.splice(0, 1)[0] as Rule; // Moving element
289 | adder.children.splice(1, 0, element);
290 |
291 | // Removing branch
292 | const removerComponent = app.findAllComponents(QueryBuilderGroup)
293 | .filter(qb => {
294 | const vm = qb.vm as QueryBuilderGroupInterface;
295 |
296 | return vm.selectedOperator === 'AND'
297 | && vm.children.length === 4
298 | && (vm.children[0] as Rule).value === 'A';
299 | })
300 | .at(0);
301 | const rmEv: ChangeEvent = {
302 | removed: {
303 | element,
304 | oldIndex: 0,
305 | },
306 | };
307 | removerComponent.findComponent(Draggable).vm.$emit('change', rmEv);
308 |
309 | // Adding branch
310 | const adderComponent = app.findAllComponents(QueryBuilderGroup)
311 | .filter(qb => {
312 | const vm = qb.vm as QueryBuilderGroupInterface;
313 |
314 | return vm.selectedOperator === 'AND'
315 | && vm.children.length === 3
316 | && (vm.children[0] as Rule).value === 'D';
317 | })
318 | .at(0);
319 | const addEv: ChangeEvent = {
320 | added: {
321 | element,
322 | newIndex: 1,
323 | },
324 | };
325 | adderComponent.findComponent(Draggable).vm.$emit('change', addEv);
326 |
327 | flushPromises()
328 | .then(() => {
329 | expect((removerComponent.emitted('query-update') as any)[0][0]).toStrictEqual(remover);
330 | expect((app.emitted('input') as any)[0][0]).toStrictEqual(qbClone);
331 |
332 | done();
333 | });
334 | });
335 | });
336 |
--------------------------------------------------------------------------------
/tests/unit/max-depth.spec.ts:
--------------------------------------------------------------------------------
1 | import { mount, shallowMount, Wrapper } from '@vue/test-utils';
2 | import Vue from 'vue';
3 | import Sortable, {
4 | GroupOptions, PutResult, SortableEvent, SortableOptions,
5 | } from 'sortablejs';
6 | import QueryBuilder from '@/QueryBuilder.vue';
7 | import QueryBuilderGroup from '@/QueryBuilderGroup.vue';
8 | import QueryBuilderChild from '@/QueryBuilderChild.vue';
9 | import {
10 | RuleSet, QueryBuilderConfig, Rule, GroupCtrlSlotProps,
11 | } from '@/types';
12 | import Component from '../components/Component.vue';
13 |
14 | interface QueryBuilderGroupInterface extends Vue {
15 | depth: number,
16 | maxDepthExeeded: boolean,
17 | dragOptions: SortableOptions,
18 | groupControlSlotProps: GroupCtrlSlotProps,
19 | }
20 |
21 | interface GroupOptionsInterface extends GroupOptions {
22 | put: ((to: Sortable, from: Sortable, dragEl: HTMLElement, event: SortableEvent) => PutResult)
23 | }
24 |
25 | interface DragOptionsInstance extends SortableOptions {
26 | group: GroupOptionsInterface,
27 | }
28 |
29 | describe('Testing max-depth behaviour', () => {
30 | const value: RuleSet = {
31 | operatorIdentifier: 'OR',
32 | children: [{
33 | operatorIdentifier: 'AND',
34 | children: [{
35 | identifier: 'txt',
36 | value: 'A',
37 | }, {
38 | identifier: 'txt',
39 | value: 'B',
40 | }, {
41 | identifier: 'txt',
42 | value: 'C',
43 | }, {
44 | operatorIdentifier: 'AND',
45 | children: [{
46 | identifier: 'txt',
47 | value: 'D',
48 | }, {
49 | identifier: 'txt',
50 | value: 'E',
51 | }, {
52 | operatorIdentifier: 'AND',
53 | children: [{
54 | identifier: 'txt',
55 | value: 'F',
56 | }, {
57 | operatorIdentifier: 'AND',
58 | children: [{
59 | identifier: 'txt',
60 | value: 'G',
61 | }],
62 | }, {
63 | identifier: 'txt',
64 | value: 'H',
65 | }],
66 | }],
67 | }],
68 | }, {
69 | operatorIdentifier: 'AND',
70 | children: [{
71 | identifier: 'txt',
72 | value: 'X',
73 | }, {
74 | operatorIdentifier: 'AND',
75 | children: [{
76 | identifier: 'txt',
77 | value: 'T',
78 | }],
79 | }, {
80 | identifier: 'txt',
81 | value: 'Y',
82 | }, {
83 | identifier: 'txt',
84 | value: 'Z',
85 | }],
86 | }],
87 | };
88 |
89 | const config: QueryBuilderConfig = {
90 | operators: [
91 | {
92 | name: 'AND',
93 | identifier: 'AND',
94 | },
95 | {
96 | name: 'OR',
97 | identifier: 'OR',
98 | },
99 | ],
100 | rules: [
101 | {
102 | identifier: 'txt',
103 | name: 'Text Selection',
104 | component: Component,
105 | initialValue: '',
106 | },
107 | {
108 | identifier: 'num',
109 | name: 'Number Selection',
110 | component: Component,
111 | initialValue: 10,
112 | },
113 | ],
114 | dragging: {
115 | animation: 300,
116 | disabled: false,
117 | ghostClass: 'ghost',
118 | },
119 | maxDepth: 4,
120 | };
121 |
122 | it('verifies not groups are added upon max-depth', () => {
123 | const createApp = () => mount(QueryBuilder, {
124 | propsData: {
125 | value: { ...value },
126 | config: { ...config },
127 | },
128 | });
129 |
130 | // Test non-leave group
131 | let app = createApp();
132 | let group = app.findAllComponents(QueryBuilderGroup)
133 | .wrappers
134 | .filter(g => g.vm.$props.depth === 3)
135 | .shift() as Wrapper;
136 | // Assert button is present
137 | let button = group.find('.query-builder-group__group-adding-button');
138 | expect(button.exists()).toBeTruthy();
139 | button.trigger('click');
140 | let evQueryUpdate = app.emitted('input');
141 | expect(evQueryUpdate).toHaveLength(1);
142 |
143 | // Test "leaf" group
144 | app = createApp();
145 | group = app.findAllComponents(QueryBuilderGroup)
146 | .wrappers
147 | .filter(g => g.vm.$props.depth === 4)
148 | .shift() as Wrapper;
149 | // Assert button is absent
150 | button = group.find('.query-builder-group__group-adding-button');
151 | expect(button.exists()).toBeFalsy();
152 | evQueryUpdate = app.emitted('input');
153 | expect(evQueryUpdate).toBeUndefined();
154 | });
155 |
156 | it('verifies the behaviour of GroupCtrlSlotProps slot', () => {
157 | const createApp = () => mount(QueryBuilder, {
158 | propsData: {
159 | value: { ...value },
160 | config: { ...config },
161 | },
162 | scopedSlots: {
163 | groupControl: `
164 |
168 | SLOT
169 |
170 |
176 |
177 |
181 | Add Rule
182 |
183 |
188 | Add Group
189 |
190 |
191 | `,
192 | },
193 | });
194 |
195 | let app = createApp();
196 | let group = app.findAllComponents(QueryBuilderGroup)
197 | .wrappers
198 | .filter(g => g.vm.$props.depth === 3)
199 | .shift() as Wrapper;
200 | expect(group.vm.groupControlSlotProps.maxDepthExeeded).toBeFalsy();
201 | // Assert button is present
202 | let button = group.find('.slot-new-group');
203 | expect(button.exists()).toBeTruthy();
204 | button.trigger('click');
205 | let evQueryUpdate = app.emitted('input');
206 | expect(evQueryUpdate).toHaveLength(1);
207 |
208 | // Test leaf group
209 | app = createApp();
210 | group = app.findAllComponents(QueryBuilderGroup)
211 | .wrappers
212 | .filter(g => g.vm.$props.depth === 4)
213 | .shift() as Wrapper;
214 | expect(group.vm.groupControlSlotProps.maxDepthExeeded).toBeTruthy();
215 | // Assert button is absent
216 | button = group.find('.slot-new-group');
217 | expect(button.exists()).toBeFalsy();
218 | evQueryUpdate = app.emitted('input');
219 | expect(evQueryUpdate).toBeUndefined();
220 | });
221 |
222 | it('prunes existing branches which are beyond the max-depth setting', async () => {
223 | const app = mount(QueryBuilder, {
224 | propsData: {
225 | value: { ...value },
226 | config: { ...config },
227 | },
228 | });
229 |
230 | const wrapper = app.findComponent(QueryBuilder);
231 |
232 | // Before, ensure nothing has been changed
233 | expect(wrapper.vm.$props.value).toHaveProperty('children.0.children.3.children.2.children.1.children.0.value', 'G');
234 | expect(app.emitted('input')).toBeUndefined();
235 |
236 | // Reduce max depth
237 | await app.setProps({
238 | value: { ...value },
239 | config: { ...config, maxDepth: 3 },
240 | });
241 | expect(app.emitted('input')).toHaveLength(1);
242 | expect((app.emitted('input') as any[])[0]).not.toHaveProperty('0.children.0.children.3.children.2.children.1.children.0.value', 'G');
243 | expect((app.emitted('input') as any[])[0][0].children[0].children[3].children[2].children).toHaveLength(2);
244 | expect((app.emitted('input') as any[])[0]).toHaveProperty('0.children.0.children.3.children.2.children', [{ identifier: 'txt', value: 'F' }, { identifier: 'txt', value: 'H' }]);
245 |
246 | // Don't allow any group children
247 | await app.setProps({
248 | value: { ...value },
249 | config: { ...config, maxDepth: 0 },
250 | });
251 |
252 | expect((app.emitted('input') as any[]).pop()[0].children).toHaveLength(0);
253 | });
254 |
255 | it('asserts no additional group can be created, beyond the mad-depth setting', () => {
256 | const app = mount(QueryBuilder, {
257 | propsData: {
258 | value: { ...value },
259 | config: { ...config },
260 | },
261 | });
262 |
263 | const groups = app.findAllComponents(QueryBuilderGroup).wrappers;
264 |
265 | const group1 = (
266 | groups.filter(g => g.vm.$props.depth === 1)
267 | .shift()
268 | ) as Wrapper;
269 | expect((group1.vm as QueryBuilderGroupInterface).maxDepthExeeded).toBeFalsy();
270 | expect(group1.find('.query-builder-group__group-adding-button').exists()).toBeTruthy();
271 |
272 | const group4 = (
273 | groups.filter(g => g.vm.$props.depth === 4)
274 | .shift()
275 | ) as Wrapper;
276 | expect((group4.vm as QueryBuilderGroupInterface).maxDepthExeeded).toBeTruthy();
277 | expect(group4.find('.query-builder-group__group-adding-button').exists()).toBeFalsy();
278 | });
279 |
280 | it('checks and rejects movements, violating the max depth policy', () => {
281 | const buildDragEl = (r: Rule | RuleSet, c: QueryBuilderConfig): HTMLElement => {
282 | const rChild = shallowMount(QueryBuilderChild, {
283 | propsData: {
284 | query: { ...r },
285 | config: { ...c },
286 | },
287 | });
288 |
289 | const rEl = {
290 | __vue__: rChild.vm,
291 | } as unknown;
292 |
293 | return rEl as HTMLElement;
294 | };
295 |
296 | const buildDragOptions = (ws: Array>): DragOptionsInstance => {
297 | const w = ws.shift() as Wrapper;
298 | const qbgi = w.vm as QueryBuilderGroupInterface;
299 |
300 | return (qbgi.dragOptions as DragOptionsInstance);
301 | };
302 |
303 | const app = mount(QueryBuilder, {
304 | propsData: {
305 | value: { ...value },
306 | config: { ...config },
307 | },
308 | });
309 |
310 | const groups = app.findAllComponents(QueryBuilderGroup).wrappers;
311 | const s = (null as never) as Sortable;
312 | const se = (null as never) as SortableEvent;
313 |
314 | // Moving rule is always fine
315 | const movingRule = (value as any)
316 | .children[0].children[3].children[2].children[1].children[0] as Rule | RuleSet;
317 |
318 | const dragOptions1 = buildDragOptions(groups.filter(g => g.vm.$props.depth === 1));
319 | expect(dragOptions1.group.put(s, s, buildDragEl(movingRule, config), se)).toBeTruthy();
320 |
321 | const dragOptions2 = buildDragOptions(groups.filter(g => g.vm.$props.depth === 4));
322 | expect(dragOptions2.group.put(s, s, buildDragEl(movingRule, config), se)).toBeTruthy();
323 |
324 | // Moving ruleset needs extra check
325 | const movingRuleSet = (value as any).children[1] as Rule | RuleSet;
326 | expect(dragOptions1.group.put(s, s, buildDragEl(movingRuleSet, config), se)).toBeTruthy();
327 | expect(dragOptions2.group.put(s, s, buildDragEl(movingRuleSet, config), se)).toBeFalsy();
328 | });
329 |
330 | it('checks and verifies GroupCtrlSlot\'s props behaviour', async () => {
331 | const newRuleSet = (): RuleSet => ({
332 | operatorIdentifier: 'AND',
333 | children: [],
334 | });
335 |
336 | const getMergeTrap = jest.fn();
337 |
338 | const app = shallowMount(QueryBuilderGroup, {
339 | propsData: {
340 | config: { ...config },
341 | query: newRuleSet(),
342 | depth: 4,
343 | },
344 | provide: {
345 | getMergeTrap,
346 | },
347 | }) as Wrapper;
348 |
349 | const slotPropsLeafGroup = app.vm.groupControlSlotProps;
350 | expect(slotPropsLeafGroup.maxDepthExeeded).toBeTruthy();
351 | slotPropsLeafGroup.newGroup();
352 | expect(app.emitted('query-update')).toBeUndefined();
353 |
354 | // Now try adding another by stepping down 1 depth
355 | app.setProps({
356 | config: { ...config },
357 | query: newRuleSet(),
358 | depth: 3,
359 | });
360 | await app.vm.$nextTick();
361 |
362 | const slotPropsNonLeafGroup = app.vm.groupControlSlotProps;
363 | expect(slotPropsNonLeafGroup.maxDepthExeeded).toBeFalsy();
364 | slotPropsNonLeafGroup.newGroup();
365 | expect(app.emitted('query-update')).toHaveLength(1);
366 |
367 | expect(getMergeTrap).not.toBeCalled();
368 | });
369 | });
370 |
--------------------------------------------------------------------------------
/tests/unit/query-builder-child.spec.ts:
--------------------------------------------------------------------------------
1 | import { shallowMount } from '@vue/test-utils';
2 | import Vue, { Component as VueComponent } from 'vue';
3 | import QueryBuilderChild from '@/QueryBuilderChild.vue';
4 | import {
5 | QueryBuilderConfig, Rule, RuleDefinition, RuleSet,
6 | } from '@/types';
7 | import Component from '../components/Component.vue';
8 |
9 | interface QueryBuilderChildInterface extends Vue {
10 | component: VueComponent,
11 | definition: RuleDefinition | null,
12 | ruleDefinition: RuleDefinition | null,
13 | }
14 |
15 | describe('Testing QueryBuilderChild', () => {
16 | const config: QueryBuilderConfig = {
17 | operators: [
18 | {
19 | name: 'AND',
20 | identifier: 'AND',
21 | },
22 | {
23 | name: 'OR',
24 | identifier: 'OR',
25 | },
26 | ],
27 | rules: [
28 | {
29 | identifier: 'txt',
30 | name: 'Text Selection',
31 | component: Component,
32 | initialValue: '',
33 | },
34 | {
35 | identifier: 'num',
36 | name: 'Number Selection',
37 | component: Component,
38 | initialValue: 10,
39 | },
40 | ],
41 | dragging: {
42 | animation: 300,
43 | disabled: false,
44 | ghostClass: 'ghost',
45 | },
46 | };
47 |
48 | it('tests if QueryBuilderChild handles rule query', () => {
49 | const query: Rule = {
50 | identifier: 'txt',
51 | value: 'A',
52 | };
53 |
54 | const child = shallowMount(QueryBuilderChild, {
55 | propsData: {
56 | config: { ...config },
57 | query: { ...query },
58 | },
59 | });
60 |
61 | expect((child.vm as QueryBuilderChildInterface).component.name).toBe('QueryBuilderRule');
62 | expect((child.vm as QueryBuilderChildInterface).definition).not.toBeNull();
63 | expect((child.vm as QueryBuilderChildInterface).ruleDefinition).not.toBeNull();
64 | });
65 |
66 | it('tests if QueryBuilderChild handles group query', () => {
67 | const query: RuleSet = {
68 | operatorIdentifier: 'AND',
69 | children: [{
70 | identifier: 'txt',
71 | value: 'X',
72 | }],
73 | };
74 |
75 | const child = shallowMount(QueryBuilderChild, {
76 | propsData: {
77 | config: { ...config },
78 | query: { ...query },
79 | },
80 | });
81 |
82 | expect((child.vm as QueryBuilderChildInterface).component.name).toBe('QueryBuilderGroup');
83 | expect((child.vm as QueryBuilderChildInterface).definition).toBeNull();
84 | expect((child.vm as QueryBuilderChildInterface).ruleDefinition).toBeNull();
85 | });
86 | });
87 |
--------------------------------------------------------------------------------
/tests/unit/query-builder.spec.ts:
--------------------------------------------------------------------------------
1 | import { mount } from '@vue/test-utils';
2 | import QueryBuilder from '@/QueryBuilder.vue';
3 | import QueryBuilderGroup from '@/QueryBuilderGroup.vue';
4 | import QueryBuilderRule from '@/QueryBuilderRule.vue';
5 | import { QueryBuilderConfig } from '@/types';
6 | import App from '../components/App.vue';
7 | import Component from '../components/Component.vue';
8 |
9 | interface QueryBuilderTemplate {
10 | value: any,
11 | config: QueryBuilderConfig,
12 | }
13 |
14 | describe('Test basic functionality of QueryBuilder.vue', () => {
15 | const getTemplate = (): QueryBuilderTemplate => ({
16 | value: null,
17 | config: {
18 | operators: [
19 | {
20 | name: 'AND',
21 | identifier: 'and',
22 | },
23 | {
24 | name: 'OR',
25 | identifier: 'or',
26 | },
27 | ],
28 | rules: [
29 | {
30 | identifier: 'txt',
31 | name: 'Text Selection',
32 | component: Component,
33 | initialValue: '',
34 | },
35 | {
36 | identifier: 'num',
37 | name: 'Number Selection',
38 | component: Component,
39 | initialValue: 10,
40 | },
41 | ],
42 | },
43 | });
44 |
45 | it('it renders with blank configuration', () => {
46 | const template = getTemplate();
47 |
48 | template.config.operators = [];
49 | template.config.rules = [];
50 |
51 | // The bare minimum configuration.
52 | // It entirely useless, but according to the spec, this is a valid configuration and show not
53 | // fail.
54 | mount(QueryBuilder, {
55 | propsData: template,
56 | });
57 | });
58 |
59 | it('selects an operator', () => {
60 | const app = mount(App, {
61 | data: getTemplate,
62 | });
63 | const wrapper = app.findComponent(QueryBuilder);
64 |
65 | // Assert operators are available
66 | const options = wrapper.find('.query-builder-group__group-selection select').findAll('option');
67 | expect(options).toHaveLength(3);
68 | expect(options.at(0).text()).toBe('Select an operator');
69 | expect(options.at(0).element.attributes.getNamedItem('disabled')).toBeTruthy();
70 | expect(options.at(1).text()).toBe('AND');
71 | expect((options.at(1).element as HTMLOptionElement).value).toBe('and');
72 | expect(options.at(2).text()).toBe('OR');
73 | expect((options.at(2).element as HTMLOptionElement).value).toBe('or');
74 |
75 | // Assert update has propagated
76 | options.at(2).setSelected();
77 | expect(wrapper.emitted('input')).toHaveLength(1);
78 | expect(wrapper.emitted('input')).toStrictEqual([[{ operatorIdentifier: 'or', children: [] }]]);
79 | });
80 |
81 | it('selects a rule', async () => {
82 | const app = mount(App, {
83 | data: getTemplate,
84 | });
85 | const wrapper = app.findComponent(QueryBuilder);
86 |
87 | // Assert rules are available
88 | const rules = wrapper.find('.query-builder-group__group-control select').findAll('option');
89 | expect(rules).toHaveLength(3);
90 | expect(rules.at(0).text()).toBe('Select a rule');
91 | expect(rules.at(0).element.attributes.getNamedItem('disabled')).toBeTruthy();
92 | expect(rules.at(1).text()).toBe('Text Selection');
93 | expect((rules.at(1).element as HTMLOptionElement).value).toBe('txt');
94 | expect(rules.at(2).text()).toBe('Number Selection');
95 | expect((rules.at(2).element as HTMLOptionElement).value).toBe('num');
96 |
97 | const addRuleBtn = wrapper.find('.query-builder-group__rule-adding-button');
98 | expect((addRuleBtn.element as HTMLButtonElement).disabled).toBeTruthy();
99 |
100 | // Assert update has propagated with default value
101 | rules.at(2).setSelected();
102 | await wrapper.vm.$nextTick();
103 | expect((addRuleBtn.element as HTMLButtonElement).disabled).toBeFalsy();
104 | addRuleBtn.trigger('click');
105 | expect(wrapper.emitted('input')).toHaveLength(1);
106 | expect(wrapper.emitted('input')).toStrictEqual([[{ operatorIdentifier: 'and', children: [{ identifier: 'num', value: 10 }] }]]);
107 |
108 | // Manually update value
109 | await wrapper.vm.$nextTick();
110 | const num = wrapper.findComponent(Component);
111 | num.vm.$emit('input', 20);
112 | expect(wrapper.emitted('input')).toHaveLength(2);
113 | expect((wrapper.emitted('input') as any)[1]).toStrictEqual([{ operatorIdentifier: 'and', children: [{ identifier: 'num', value: 20 }] }]);
114 | });
115 |
116 | it('makes use of an initial value\'s factory function', async () => {
117 | const initialValue = jest.fn(() => 'Hello World');
118 |
119 | const data = getTemplate();
120 | data.config.rules = [
121 | {
122 | identifier: 'txt',
123 | name: 'Text Selection',
124 | component: Component,
125 | initialValue,
126 | },
127 | ];
128 |
129 | const app = mount(App, {
130 | data() {
131 | return { ...data };
132 | },
133 | });
134 | const wrapper = app.findComponent(QueryBuilder);
135 |
136 | // Assert rules are available
137 | const group = wrapper.findComponent(QueryBuilderGroup);
138 | const rules = group.find('.query-builder-group__group-control select').findAll('option');
139 | const addRuleBtn = group.find('.query-builder-group__rule-adding-button');
140 |
141 | // Assert update has propagated with default value
142 | rules.at(1).setSelected();
143 | await group.vm.$nextTick();
144 | addRuleBtn.trigger('click');
145 | expect(group.emitted('query-update')).toHaveLength(1);
146 | expect((group.emitted('query-update') as any)[0]).toStrictEqual([{ operatorIdentifier: 'and', children: [{ identifier: 'txt', value: 'Hello World' }] }]);
147 | expect(wrapper.emitted('input')).toHaveLength(1);
148 | expect((wrapper.emitted('input') as any)[0]).toStrictEqual([{ operatorIdentifier: 'and', children: [{ identifier: 'txt', value: 'Hello World' }] }]);
149 | expect(initialValue).toHaveBeenCalled();
150 | });
151 |
152 | it('deletes a rule', () => {
153 | const data = () => ({
154 | query: {
155 | operatorIdentifier: 'and',
156 | children: [
157 | {
158 | identifier: 'txt',
159 | value: 'A',
160 | },
161 | {
162 | identifier: 'txt',
163 | value: 'B',
164 | },
165 | {
166 | identifier: 'txt',
167 | value: 'C',
168 | },
169 | ],
170 | },
171 | config: {
172 | operators: [
173 | {
174 | name: 'AND',
175 | identifier: 'and',
176 | },
177 | {
178 | name: 'OR',
179 | identifier: 'or',
180 | },
181 | ],
182 | rules: [
183 | {
184 | identifier: 'txt',
185 | name: 'Text Selection',
186 | component: Component,
187 | initialValue: '',
188 | },
189 | {
190 | identifier: 'num',
191 | name: 'Number Selection',
192 | component: Component,
193 | initialValue: 10,
194 | },
195 | ],
196 | },
197 | });
198 |
199 | const app = mount(App, {
200 | data,
201 | });
202 |
203 | app.findAllComponents(QueryBuilderRule)
204 | .filter(({ vm }) => vm.$props.query.identifier === 'txt' && vm.$props.query.value === 'B')
205 | .at(0)
206 | .vm
207 | .$parent
208 | .$emit('delete-child');
209 |
210 | expect(app.vm.$data.query).toStrictEqual({
211 | operatorIdentifier: 'and',
212 | children: [
213 | {
214 | identifier: 'txt',
215 | value: 'A',
216 | },
217 | {
218 | identifier: 'txt',
219 | value: 'C',
220 | },
221 | ],
222 | });
223 | });
224 |
225 | it('renders a complex dataset', async () => {
226 | const data = () => ({
227 | query: {
228 | operatorIdentifier: 'or',
229 | children: [
230 | {
231 | operatorIdentifier: 'and',
232 | children: [
233 | {
234 | identifier: 'txt',
235 | value: 'A',
236 | },
237 | {
238 | identifier: 'txt',
239 | value: 'B',
240 | },
241 | {
242 | identifier: 'txt',
243 | value: 'C',
244 | },
245 | {
246 | operatorIdentifier: 'and', // <-- on this group, we're performing our tests
247 | children: [
248 | {
249 | identifier: 'txt',
250 | value: 'c',
251 | },
252 | {
253 | identifier: 'txt',
254 | value: 'd',
255 | },
256 | {
257 | operatorIdentifier: 'and',
258 | children: [
259 | {
260 | identifier: 'txt',
261 | value: 'a',
262 | },
263 | {
264 | identifier: 'txt',
265 | value: 'b',
266 | },
267 | ],
268 | },
269 | ],
270 | },
271 | ],
272 | },
273 | {
274 | operatorIdentifier: 'and',
275 | children: [
276 | {
277 | identifier: 'txt',
278 | value: 'X',
279 | },
280 | {
281 | identifier: 'txt',
282 | value: 'Y',
283 | },
284 | {
285 | identifier: 'txt',
286 | value: 'Z',
287 | },
288 | ],
289 | },
290 | ],
291 | },
292 | config: {
293 | operators: [
294 | {
295 | name: 'AND',
296 | identifier: 'and',
297 | },
298 | {
299 | name: 'OR',
300 | identifier: 'or',
301 | },
302 | ],
303 | rules: [
304 | {
305 | identifier: 'txt',
306 | name: 'Text Selection',
307 | component: Component,
308 | initialValue: '',
309 | },
310 | {
311 | identifier: 'num',
312 | name: 'Number Selection',
313 | component: Component,
314 | initialValue: 10,
315 | },
316 | ],
317 | },
318 | });
319 |
320 | const app = mount(App, {
321 | data,
322 | });
323 | const wrapper = app.findComponent(QueryBuilder);
324 |
325 | const qbGroup = wrapper.findAllComponents(QueryBuilderGroup)
326 | .filter(
327 | ({ vm }) => (vm as (QueryBuilderGroup & { readonly selectedOperator: string })).selectedOperator === 'and'
328 | && vm.$props.query.children.length === 3
329 | && vm.$props.query.children[0].identifier === 'txt'
330 | && vm.$props.query.children[0].value === 'c',
331 | )
332 | .at(0);
333 |
334 | // Assert operators are available
335 | const options = qbGroup.find('.query-builder-group__group-selection select').findAll('option');
336 | expect(options).toHaveLength(3);
337 | expect(options.at(0).text()).toBe('Select an operator');
338 | expect(options.at(0).element.attributes.getNamedItem('disabled')).toBeTruthy();
339 | expect(options.at(1).text()).toBe('AND');
340 | expect((options.at(1).element as HTMLOptionElement).value).toBe('and');
341 | expect(options.at(2).text()).toBe('OR');
342 | expect((options.at(2).element as HTMLOptionElement).value).toBe('or');
343 |
344 | // Assert update has propagated
345 | options.at(2).setSelected();
346 | expect(app.vm.$data.query).toStrictEqual({
347 | operatorIdentifier: 'or',
348 | children: [
349 | {
350 | operatorIdentifier: 'and',
351 | children: [
352 | {
353 | identifier: 'txt',
354 | value: 'A',
355 | },
356 | {
357 | identifier: 'txt',
358 | value: 'B',
359 | },
360 | {
361 | identifier: 'txt',
362 | value: 'C',
363 | },
364 | {
365 | operatorIdentifier: 'or', // <-- changed
366 | children: [
367 | {
368 | identifier: 'txt',
369 | value: 'c',
370 | },
371 | {
372 | identifier: 'txt',
373 | value: 'd',
374 | },
375 | {
376 | operatorIdentifier: 'and',
377 | children: [
378 | {
379 | identifier: 'txt',
380 | value: 'a',
381 | },
382 | {
383 | identifier: 'txt',
384 | value: 'b',
385 | },
386 | ],
387 | },
388 | ],
389 | },
390 | ],
391 | },
392 | {
393 | operatorIdentifier: 'and',
394 | children: [
395 | {
396 | identifier: 'txt',
397 | value: 'X',
398 | },
399 | {
400 | identifier: 'txt',
401 | value: 'Y',
402 | },
403 | {
404 | identifier: 'txt',
405 | value: 'Z',
406 | },
407 | ],
408 | },
409 | ],
410 | });
411 |
412 | // Edit a rule
413 | expect(qbGroup.vm.$props.query.children).toHaveLength(3);
414 | const rules = qbGroup.findAllComponents(QueryBuilderRule);
415 | expect(rules).toHaveLength(4);
416 | const rule = rules
417 | .filter(({ vm: { $props } }) => $props.query.identifier === 'txt' && $props.query.value === 'd')
418 | .at(0);
419 |
420 | await wrapper.vm.$nextTick();
421 | rule.find('.dummy-component').vm.$emit('input', 'D');
422 | expect(app.vm.$data.query).toStrictEqual({
423 | operatorIdentifier: 'or',
424 | children: [
425 | {
426 | operatorIdentifier: 'and',
427 | children: [
428 | {
429 | identifier: 'txt',
430 | value: 'A',
431 | },
432 | {
433 | identifier: 'txt',
434 | value: 'B',
435 | },
436 | {
437 | identifier: 'txt',
438 | value: 'C',
439 | },
440 | {
441 | operatorIdentifier: 'or',
442 | children: [
443 | {
444 | identifier: 'txt',
445 | value: 'c',
446 | },
447 | {
448 | identifier: 'txt',
449 | value: 'D', // <-- changed
450 | },
451 | {
452 | operatorIdentifier: 'and',
453 | children: [
454 | {
455 | identifier: 'txt',
456 | value: 'a',
457 | },
458 | {
459 | identifier: 'txt',
460 | value: 'b',
461 | },
462 | ],
463 | },
464 | ],
465 | },
466 | ],
467 | },
468 | {
469 | operatorIdentifier: 'and',
470 | children: [
471 | {
472 | identifier: 'txt',
473 | value: 'X',
474 | },
475 | {
476 | identifier: 'txt',
477 | value: 'Y',
478 | },
479 | {
480 | identifier: 'txt',
481 | value: 'Z',
482 | },
483 | ],
484 | },
485 | ],
486 | });
487 |
488 | // Add another group
489 | await wrapper.vm.$nextTick();
490 | qbGroup.find('.query-builder-group__group-adding-button')
491 | .trigger('click');
492 | expect(app.vm.$data.query).toStrictEqual({
493 | operatorIdentifier: 'or',
494 | children: [
495 | {
496 | operatorIdentifier: 'and',
497 | children: [
498 | {
499 | identifier: 'txt',
500 | value: 'A',
501 | },
502 | {
503 | identifier: 'txt',
504 | value: 'B',
505 | },
506 | {
507 | identifier: 'txt',
508 | value: 'C',
509 | },
510 | {
511 | operatorIdentifier: 'or',
512 | children: [
513 | {
514 | identifier: 'txt',
515 | value: 'c',
516 | },
517 | {
518 | identifier: 'txt',
519 | value: 'D',
520 | },
521 | {
522 | operatorIdentifier: 'and',
523 | children: [
524 | {
525 | identifier: 'txt',
526 | value: 'a',
527 | },
528 | {
529 | identifier: 'txt',
530 | value: 'b',
531 | },
532 | ],
533 | },
534 | { // <-- Added
535 | operatorIdentifier: 'and',
536 | children: [],
537 | },
538 | ],
539 | },
540 | ],
541 | },
542 | {
543 | operatorIdentifier: 'and',
544 | children: [
545 | {
546 | identifier: 'txt',
547 | value: 'X',
548 | },
549 | {
550 | identifier: 'txt',
551 | value: 'Y',
552 | },
553 | {
554 | identifier: 'txt',
555 | value: 'Z',
556 | },
557 | ],
558 | },
559 | ],
560 | });
561 |
562 | // Remove a rule
563 | await wrapper.vm.$nextTick();
564 | rule.vm.$parent.$emit('delete-child');
565 | expect(app.vm.$data.query).toStrictEqual({
566 | operatorIdentifier: 'or',
567 | children: [
568 | {
569 | operatorIdentifier: 'and',
570 | children: [
571 | {
572 | identifier: 'txt',
573 | value: 'A',
574 | },
575 | {
576 | identifier: 'txt',
577 | value: 'B',
578 | },
579 | {
580 | identifier: 'txt',
581 | value: 'C',
582 | },
583 | {
584 | operatorIdentifier: 'or',
585 | children: [
586 | {
587 | identifier: 'txt',
588 | value: 'c',
589 | },
590 | // Delete child here
591 | {
592 | operatorIdentifier: 'and',
593 | children: [
594 | {
595 | identifier: 'txt',
596 | value: 'a',
597 | },
598 | {
599 | identifier: 'txt',
600 | value: 'b',
601 | },
602 | ],
603 | },
604 | { // <-- Added
605 | operatorIdentifier: 'and',
606 | children: [],
607 | },
608 | ],
609 | },
610 | ],
611 | },
612 | {
613 | operatorIdentifier: 'and',
614 | children: [
615 | {
616 | identifier: 'txt',
617 | value: 'X',
618 | },
619 | {
620 | identifier: 'txt',
621 | value: 'Y',
622 | },
623 | {
624 | identifier: 'txt',
625 | value: 'Z',
626 | },
627 | ],
628 | },
629 | ],
630 | });
631 | });
632 | });
633 |
--------------------------------------------------------------------------------
/tests/unit/slots.spec.ts:
--------------------------------------------------------------------------------
1 | import { mount } from '@vue/test-utils';
2 | import { RuleSet, Rule, QueryBuilderConfig } from '@/types';
3 | import QueryBuilder from '@/QueryBuilder.vue';
4 | import QueryBuilderGroup from '@/QueryBuilderGroup.vue';
5 | import QueryBuilderRule from '@/QueryBuilderRule.vue';
6 | import Component from '../components/Component.vue';
7 |
8 | describe('Testing slot related features', () => {
9 | const propsData: { value: RuleSet, config: QueryBuilderConfig } = {
10 | value: {
11 | operatorIdentifier: 'OR',
12 | children: [
13 | {
14 | identifier: 'txt',
15 | value: 'A',
16 | },
17 | {
18 | identifier: 'txt',
19 | value: 'B',
20 | },
21 | {
22 | identifier: 'txt',
23 | value: 'C',
24 | },
25 | ],
26 | },
27 | config: {
28 | operators: [
29 | {
30 | name: 'and',
31 | identifier: 'AND',
32 | },
33 | {
34 | name: 'or',
35 | identifier: 'OR',
36 | },
37 | ],
38 | rules: [
39 | {
40 | identifier: 'txt',
41 | name: 'Text Selection',
42 | component: Component,
43 | initialValue: 'foo',
44 | },
45 | {
46 | identifier: 'num',
47 | name: 'Number Selection',
48 | component: Component,
49 | initialValue: 10,
50 | },
51 | ],
52 | },
53 | };
54 |
55 | it('tests the `groupOperator` slot', () => {
56 | const app = mount(QueryBuilder, {
57 | propsData,
58 | scopedSlots: {
59 | groupOperator: `
60 |
64 | SLOT Operator
65 |
70 |
76 |
77 |
78 | `,
79 | },
80 | });
81 |
82 | const group = app.findComponent(QueryBuilderGroup);
83 | const slot = group.find('.slot-wrapper');
84 |
85 | // Check if current operator is selected
86 | const select = slot.find('.slot-select');
87 | expect((select.element as HTMLSelectElement).value).toBe('OR');
88 |
89 | // Check if operators are properly rendered
90 | const options = select.findAll('option');
91 | expect(options).toHaveLength(2);
92 | expect((options.at(0).element as HTMLOptionElement).value).toBe('AND');
93 | expect((options.at(0).element as HTMLOptionElement).text).toBe('and');
94 | expect((options.at(1).element as HTMLOptionElement).value).toBe('OR');
95 | expect((options.at(1).element as HTMLOptionElement).text).toBe('or');
96 |
97 | // Update operator
98 | options.at(0).setSelected();
99 | expect((group.emitted('query-update') as any)[0][0]).toStrictEqual({ operatorIdentifier: 'AND', children: propsData.value.children });
100 | const expected: RuleSet = JSON.parse(JSON.stringify(propsData.value));
101 | expected.operatorIdentifier = 'AND';
102 | expect((app.emitted('input') as any)[0][0]).toStrictEqual(expected);
103 | });
104 |
105 | it('tests the `groupControl` slot', async () => {
106 | const app = mount(QueryBuilder, {
107 | propsData,
108 | scopedSlots: {
109 | groupControl: `
110 |
114 | SLOT
115 |
116 |
122 |
123 |
127 | Add Rule
128 |
129 |
133 | Add Group
134 |
135 |
136 | `,
137 | },
138 | });
139 |
140 | const slot = app.find('.slot-wrapper');
141 | const group = app.findComponent(QueryBuilderGroup);
142 |
143 | // Some data we'll be using for our assertions
144 | const query: RuleSet = JSON.parse(JSON.stringify(propsData.value));
145 | const children = [...propsData.value.children];
146 |
147 | // check if rules are properly rendered
148 | const options = slot.findAll('option');
149 | expect(options).toHaveLength(2);
150 | expect((options.at(0).element as HTMLOptionElement).value).toBe('txt');
151 | expect((options.at(0).element as HTMLOptionElement).text).toBe('Text Selection');
152 | expect((options.at(1).element as HTMLOptionElement).value).toBe('num');
153 | expect((options.at(1).element as HTMLOptionElement).text).toBe('Number Selection');
154 |
155 | // Add a new rule
156 | slot.find('.slot-new-rule').trigger('click');
157 | children.push({ identifier: 'txt', value: 'foo' });
158 | query.children = [...children];
159 | expect((group.emitted('query-update') as any)[0][0]).toStrictEqual({ operatorIdentifier: 'OR', children });
160 | expect((app.emitted('input') as any)[0][0]).toStrictEqual(query);
161 |
162 | // Add new group
163 | app.setProps({ value: { ...query }, config: { ...propsData.config } });
164 | await app.vm.$nextTick();
165 | slot.find('.slot-new-group').trigger('click');
166 | children.push({ operatorIdentifier: 'AND', children: [] });
167 | query.children = [...children];
168 | expect((group.emitted('query-update') as any)[1][0]).toStrictEqual({ operatorIdentifier: 'OR', children });
169 | expect((app.emitted('input') as any)[1][0]).toStrictEqual(query);
170 | });
171 |
172 | it('tests the `rule` slot', () => {
173 | const app = mount(QueryBuilder, {
174 | propsData,
175 | scopedSlots: {
176 | rule: `
177 |
181 | SLOT
182 |
189 |
190 | `,
191 | },
192 | });
193 |
194 | const rule = app.findComponent(QueryBuilderRule);
195 | const slot = rule.find('.slot-wrapper');
196 | const ruleComponent = slot.find('.slot-rule');
197 |
198 | // Verify rule slot is properly rendered
199 | expect(ruleComponent.is(Component)).toBeTruthy();
200 | expect(ruleComponent.vm.$props.value).toBe('A');
201 | expect(rule.vm.$props.query.identifier).toBe('txt');
202 | expect(ruleComponent.vm.$props.identifier).toBe('txt');
203 | ruleComponent.vm.$emit('input', 'a');
204 | expect((rule.emitted('query-update') as any)[0][0]).toStrictEqual({ identifier: 'txt', value: 'a' });
205 |
206 | // Verify update event propagates
207 | const expected: RuleSet = JSON.parse(JSON.stringify(propsData.value));
208 | (expected.children[0] as Rule).value = 'a';
209 | expect((app.emitted('input') as any)[0][0]).toStrictEqual(expected);
210 | });
211 | });
212 |
--------------------------------------------------------------------------------
/tests/unit/validation.spec.ts:
--------------------------------------------------------------------------------
1 | import {
2 | isQueryBuilderConfig, isRule, isRuleSet, isOperatorDefinition, isRuleDefinition,
3 | } from '@/guards';
4 | import Component from '../components/Component.vue';
5 |
6 | describe('Testing component props and guards', () => {
7 | it('checks isRule guard', () => {
8 | // VALID
9 | [
10 | {
11 | identifier: 'foo',
12 | value: null,
13 | },
14 | ].forEach(t => expect(isRule(t)).toBeTruthy());
15 | expect((t: any) => expect(isRule(t)).toBeTruthy());
16 |
17 | // INVALID
18 | [
19 | null,
20 | {},
21 | 'xyz',
22 | { value: null },
23 | { identifier: 'foo' },
24 | { identifier: 123, value: null },
25 | ].forEach((t: any) => expect(isRule(t)).toBeFalsy());
26 | });
27 |
28 | it('checks isRuleSet guard', () => {
29 | // VALID
30 | [
31 | {
32 | operatorIdentifier: 'bar',
33 | children: [],
34 | },
35 |
36 | {
37 | operatorIdentifier: 'foo',
38 | children: [
39 | {
40 | identifier: 'bar',
41 | value: null,
42 | },
43 | {
44 | operatorIdentifier: 'baz',
45 | children: [],
46 | },
47 | ],
48 | },
49 | ].forEach(t => expect(isRuleSet(t)).toBeTruthy());
50 |
51 | // INVALID
52 | [
53 | null,
54 | {},
55 | 'xyz',
56 | { operatorIdentifier: 'bar' },
57 | { children: [] },
58 | { operatorIdentifier: 'bar', children: null },
59 | { operatorIdentifier: 'bar', children: {} },
60 | { identifier: 123, value: null },
61 | { operatorIdentifier: 'foo', children: [{ identifier: 123, value: null }, { operatorIdentifier: 'baz', children: [] }] },
62 | { operatorIdentifier: 'foo', children: [{ identifier: 'bar', value: null }, { operatorIdentifier: 'baz', children: 'invalid' }] },
63 | ].forEach((t: any) => expect(isRuleSet(t)).toBeFalsy());
64 | });
65 |
66 | it('checks isOperatorDefinition guards', () => {
67 | // VALID
68 | [
69 | {
70 | identifier: 'foo',
71 | name: 'foo',
72 | },
73 | ].forEach((t: any) => expect(isOperatorDefinition(t)).toBeTruthy());
74 |
75 | // INVALID
76 | [
77 | { name: 'bar' },
78 | { identifier: 'bar' },
79 | {},
80 | [],
81 | null,
82 | ].forEach((t: any) => expect(isOperatorDefinition(t)).toBeFalsy());
83 | });
84 |
85 | it('checks isRuleDefinition guards', () => {
86 | // VALID
87 | [
88 | {
89 | identifier: 'foo',
90 | name: 'foo',
91 | component: 'foo',
92 | initialValue: 'asdf',
93 | },
94 | {
95 | identifier: 'bar',
96 | name: 'bar',
97 | component: () => {},
98 | },
99 | {
100 | identifier: 'baz',
101 | name: 'baz',
102 | component: Component,
103 | },
104 | ].forEach((t: any) => expect(isRuleDefinition(t)).toBeTruthy());
105 |
106 | // INVALID
107 | [
108 | {},
109 | null,
110 | [],
111 | { name: 'baz', component: Component },
112 | { identifier: 'baz', component: Component },
113 | { identifier: 'baz', name: 'baz', component: 1234 },
114 | ].forEach((t: any) => expect(isRuleDefinition(t)).toBeFalsy());
115 | });
116 |
117 | it('checks isQueryBuilderConfig guard', () => {
118 | // VALID
119 | [
120 | { // Default, minimal example with all valid combinations
121 | operators: [
122 | {
123 | identifier: 'foo',
124 | name: 'foo',
125 | },
126 | ],
127 | rules: [
128 | {
129 | identifier: 'foo',
130 | name: 'foo',
131 | component: 'foo',
132 | },
133 | {
134 | identifier: 'bar',
135 | name: 'bar',
136 | component: () => {},
137 | },
138 | {
139 | identifier: 'baz',
140 | name: 'baz',
141 | component: Component,
142 | },
143 | ],
144 | },
145 |
146 | { // Only checking for valid colors
147 | operators: [],
148 | rules: [],
149 | colors: ['foo', 'bar'],
150 | },
151 |
152 | { // Only checking for maxDepth
153 | operators: [],
154 | rules: [],
155 | maxDepth: undefined,
156 | },
157 | {
158 | operators: [],
159 | rules: [],
160 | maxDepth: 10,
161 | },
162 | ].forEach((t: any) => expect(isQueryBuilderConfig(t)).toBeTruthy());
163 |
164 | // INVALID
165 | [
166 | null, // Check nulled parameter
167 |
168 | { // Invalid operator
169 | operators: [
170 | {
171 | identifier: 'foo',
172 | },
173 | ],
174 | rules: [],
175 | },
176 |
177 | { // Invalid rule
178 | operators: [
179 | ],
180 | rules: [
181 | {
182 | identifier: 'foo',
183 | name: 'foo',
184 | component: 123,
185 | },
186 | ],
187 | },
188 |
189 | { // Invalid color
190 | operators: [],
191 | rules: [],
192 | colors: [
193 | 123,
194 | ],
195 | },
196 |
197 | { // Invalid maxDepth
198 | operators: [],
199 | rules: [],
200 | maxDepth: -1,
201 | },
202 | {
203 | operators: [],
204 | rules: [],
205 | maxDepth: 'asdf',
206 | },
207 | ].forEach((t: any) => expect(isQueryBuilderConfig(t)).toBeFalsy());
208 | expect(isQueryBuilderConfig(null)).toBeFalsy();
209 | });
210 | });
211 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "esnext",
4 | "module": "esnext",
5 | "strict": true,
6 | "jsx": "preserve",
7 | "importHelpers": true,
8 | "moduleResolution": "node",
9 | "experimentalDecorators": true,
10 | "esModuleInterop": true,
11 | "allowSyntheticDefaultImports": true,
12 | "sourceMap": true,
13 | "baseUrl": ".",
14 | "types": [
15 | "webpack-env",
16 | "jest"
17 | ],
18 | "paths": {
19 | "@/*": [
20 | "src/*"
21 | ]
22 | },
23 | "lib": [
24 | "esnext",
25 | "dom",
26 | "dom.iterable",
27 | "scripthost"
28 | ]
29 | },
30 | "include": [
31 | "src/**/*.ts",
32 | "src/**/*.tsx",
33 | "src/**/*.vue",
34 | "dev/**/*.ts",
35 | "dev/**/*.tsx",
36 | "dev/**/*.vue",
37 | "tests/**/*.ts",
38 | "tests/**/*.tsx"
39 | ],
40 | "exclude": [
41 | "node_modules"
42 | ]
43 | }
44 |
--------------------------------------------------------------------------------
/types/index.d.ts:
--------------------------------------------------------------------------------
1 | import { Component } from 'vue';
2 | import { SortableOptions } from 'sortablejs';
3 |
4 | export interface Rule {
5 | identifier: string,
6 | value: any,
7 | }
8 |
9 | export interface RuleSet {
10 | operatorIdentifier: string,
11 | children: Array,
12 | }
13 |
14 | export interface OperatorDefinition {
15 | identifier: string,
16 | name: string,
17 | }
18 |
19 | export interface RuleDefinition {
20 | identifier: string,
21 | name: string,
22 | component: Component | string,
23 | initialValue?: any,
24 | }
25 |
26 | export interface QueryBuilderConfig {
27 | operators: OperatorDefinition[],
28 | rules: RuleDefinition[],
29 | maxDepth?: number,
30 | colors?: string[],
31 | dragging?: SortableOptions,
32 | }
33 |
34 | export interface GroupOperatorSlotProps {
35 | currentOperator: string,
36 | operators: OperatorDefinition[],
37 | updateCurrentOperator: (newOperator: string) => void,
38 | }
39 |
40 | export interface GroupCtrlSlotProps {
41 | maxDepthExeeded: boolean,
42 | rules: RuleDefinition[],
43 | addRule: (newRule: string) => void,
44 | newGroup: () => void,
45 | }
46 |
47 | export interface RuleSlotProps {
48 | ruleComponent: Component | string,
49 | ruleData: any,
50 | ruleIdentifier: string,
51 | updateRuleData: (newData: any) => void,
52 | }
53 |
--------------------------------------------------------------------------------
/vue.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | css: {
3 | extract: false,
4 | },
5 | chainWebpack: config => {
6 | const svgRule = config.module.rule('svg');
7 | svgRule.uses.clear();
8 | svgRule.use('url-loader')
9 | .loader('url-loader')
10 | .tap(options => ({
11 | ...options,
12 | limit: 10240,
13 | }));
14 |
15 | config.module
16 | .rule('eslint')
17 | .use('eslint-loader')
18 | .options({
19 | fix: true,
20 | });
21 | },
22 | };
23 |
--------------------------------------------------------------------------------