├── .browserslistrc ├── .gitignore ├── .prettierignore ├── .prettierrc.js ├── CONTRIBUTING.md ├── README.md ├── babel.config.js ├── jest.config.js ├── package.json ├── packages ├── applyer │ ├── README.md │ ├── package.json │ ├── src │ │ └── index.ts │ ├── tsconfig.build.json │ └── tsconfig.json ├── common │ ├── README.md │ ├── package.json │ ├── src │ │ └── index.ts │ ├── tsconfig.build.json │ └── tsconfig.json ├── vue2 │ ├── @types │ │ └── index.d.ts │ ├── README.md │ ├── __tests__ │ │ ├── FilterCondition.spec.ts │ │ ├── FilterGroup.spec.ts │ │ ├── VueVisualFilter.spec.ts │ │ └── __snapshots__ │ │ │ ├── FilterCondition.spec.ts.snap │ │ │ ├── FilterGroup.spec.ts.snap │ │ │ └── VueVisualFilter.spec.ts.snap │ ├── babel.config.js │ ├── dev │ │ ├── Serve.vue │ │ └── serve.js │ ├── index.html │ ├── jest.config.js │ ├── package.json │ ├── src │ │ ├── VueVisualFilter │ │ │ ├── FilterCondition.vue │ │ │ ├── FilterGroup.vue │ │ │ └── index.vue │ │ ├── index.css │ │ └── main.js │ └── vite.config.js └── vue3 │ ├── README.md │ ├── dev │ ├── Serve.vue │ └── serve.js │ ├── index.html │ ├── package.json │ ├── src │ ├── VueVisualFilter │ │ ├── FilterCondition.vue │ │ ├── FilterGroup.vue │ │ └── index.vue │ ├── index.css │ ├── main.js │ └── tests │ │ ├── VueVisualFilter.test.js │ │ └── __snapshots__ │ │ └── VueVisualFilter.test.js.snap │ └── vite.config.js ├── postcss.config.js ├── rollup.config.js ├── tailwind.config.js ├── tsconfig.build.json ├── tsconfig.json ├── vite.config.js └── yarn.lock /.browserslistrc: -------------------------------------------------------------------------------- 1 | current node 2 | last 2 versions and > 2% 3 | ie > 10 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | packages/**/node_modules 4 | packages/**/dist 5 | 6 | *.log 7 | .vscode 8 | todo.md 9 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | packages 2 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: false, 3 | trailingComma: "all", 4 | endOfLine: "auto", 5 | } 6 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | 👍🎉 First off, thanks for taking the time to contribute! 🎉👍 2 | 3 | The project is still new, and I wrote it as an opportunity to learn new things. However, that doesn't mean it's not open for contributions. For now, please follow the following guidelines when contributing: 4 | 5 | - Fork the project repo. 6 | - Write your changes in a separate branch. 7 | - Write your changes in small commits. 8 | - Submit a PR. 9 | - Tab yourself on the back. You've done a great job. 10 | 11 | **Note**: If your PR encapsulates huge modifications, maybe it's best to submit an issue first proposing your changes. 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # The What 2 | 3 | A headless unopinionated advanced Vue visual filtering component. It's built with customizability in mind. 4 | 5 | # Demo and Code Example 6 | 7 | - GIF. 8 | 9 | 10 | 11 |  12 | 13 | # Prerequisites 14 | 15 | - Node version 12.0.0 or higher. 16 | - NPM. 17 | 18 | # Installation, and Setting Up The Component 19 | 20 | - [@visual-filter/vue2](https://github.com/obadakhalili/vue-visual-filter/tree/main/packages/vue2) 21 | - [@visual-filter/vue3](https://github.com/obadakhalili/vue-visual-filter/tree/main/packages/vue3) 22 | 23 | # Usage (for both @visual-filter/vue2, and @visual-filter/vue3) 24 | 25 | Once you're set up, and ready to start using the component. Reference the component's name in your template: 26 | 27 | ```vue 28 | 29 | 33 | 34 | ``` 35 | 36 | ## The `filteringOptions` Prop 37 | 38 | It contains two options: 39 | 40 | - `data`: An array of objects that stores the data to be filtered. Definition: 41 | 42 | ```ts 43 | interface Data { 44 | name: string 45 | type: "numeric" | "nominal" 46 | values: any[] 47 | } 48 | ;[] 49 | ``` 50 | 51 | - `methods`: An object that contains the methods to be used to filter the data. Definition: 52 | 53 | ```ts 54 | interface Methods { 55 | numeric: Record boolean> 56 | nominal: Record boolean> 57 | } 58 | ``` 59 | 60 | ## The `filter-update` Event 61 | 62 | An event prop that receives a function to be called whenever the filter updates. The function contains one parameter, `ctx`, which has in-reactive clones of `filter`, and `data` objects. 63 | 64 | ## Filter `slots` 65 | 66 | Vue provides a content distribution API called slots. And it's leveraged here to build a custom filter that its elements and styling are provided by you. 67 | 68 | Example: 69 | 70 | ```vue 71 | 72 | 76 | 77 | 78 | 83 | 84 | 85 | 86 | 87 | 94 | 95 | 96 | 97 | 102 | {{ filter }} 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 117 | 118 | 119 | 120 | 125 | 130 | 131 | 132 | 133 | 136 | 142 | 147 | 148 | 154 | 159 | 160 | 161 | 162 | 163 | 169 | 170 | 171 | 172 | 179 | 180 | 181 | 182 | ``` 183 | 184 | The example above uses the [element-plus](http://element-plus.org/) UI framework for the filter components. But you can provide whichever content fits your need best. 185 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ["@babel/preset-env"], 3 | } 4 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testRegex: "(/__tests__/.*|(\\.|/)(spec))\\.(ts)$", 3 | transform: { 4 | "\\.ts$": "ts-jest", 5 | "\\.js$": "babel-jest", 6 | }, 7 | } 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "workspaces": [ 4 | "packages/*" 5 | ], 6 | "scripts": { 7 | "format": "yarn run prettier --write .", 8 | "format:all": "yarn run prettier --ignore-path .gitignore --write .", 9 | "sort-pkgjson": "sort-package-json \"package.json\" \"packages/*/package.json\"" 10 | }, 11 | "devDependencies": { 12 | "prettier": "2.7.1", 13 | "sort-package-json": "^1.57.0" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/applyer/README.md: -------------------------------------------------------------------------------- 1 | # @visual-filter/applyer 2 | 3 | A utility for applying a filter (produced by one of the @visual-filter library components) to data. 4 | 5 | # Usage 6 | 7 | ```js 8 | const filterApplyer = require("@visual-filter/applyer") 9 | const newData = filterApplyer(filter, methods, data) 10 | ``` 11 | 12 | **Note**: That this algorithm operates in-place 13 | -------------------------------------------------------------------------------- /packages/applyer/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@visual-filter/applyer", 3 | "version": "0.0.4", 4 | "description": "A utility for applying a filter to data.", 5 | "keywords": [ 6 | "visual-filter" 7 | ], 8 | "homepage": "https://github.com/obadakhalili/vue-visual-filter.git", 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/obadakhalili/vue-visual-filter.git" 12 | }, 13 | "license": "MIT", 14 | "author": "Obada Khalili", 15 | "main": "dist/index.js", 16 | "types": "dist/index.d.ts", 17 | "files": [ 18 | "dist" 19 | ], 20 | "scripts": { 21 | "build": "tsc --project tsconfig.build.json", 22 | "clean": "rm -rf dist", 23 | "dev": "yarn build --watch", 24 | "format": "yarn run prettier --ignore-path ../../.gitignore --write", 25 | "preversion": "yarn build" 26 | }, 27 | "dependencies": { 28 | "@visual-filter/common": "1.0.3" 29 | }, 30 | "devDependencies": { 31 | "prettier": "2.7.1", 32 | "typescript": "4.7.4" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /packages/applyer/src/index.ts: -------------------------------------------------------------------------------- 1 | import { FilterType, GroupType } from "@visual-filter/common" 2 | 3 | export = function applyFilter(filter: any, methods: any, data: any) { 4 | function buildPremiseTree(filter: any) { 5 | if (filter.type === FilterType.CONDITION) { 6 | return data 7 | .find((field: any) => field.name === filter.fieldName) 8 | .values.map((value: any) => { 9 | try { 10 | return methods[filter.dataType][filter.method]( 11 | value, 12 | filter.argument, 13 | ) 14 | } catch { 15 | return false 16 | } 17 | }) 18 | } 19 | return filter.filters.map(buildPremiseTree) 20 | } 21 | 22 | function shouldntDeleteRow(rowIndex: any, premises: any, group: any) { 23 | for ( 24 | let conditionIndex = 0; 25 | conditionIndex < premises.length; 26 | ++conditionIndex 27 | ) { 28 | const currentPremise = 29 | premises[conditionIndex][0]?.constructor === Array 30 | ? shouldntDeleteRow( 31 | rowIndex, 32 | premises[conditionIndex], 33 | group.filters[conditionIndex], 34 | ) 35 | : premises[conditionIndex][rowIndex] 36 | 37 | if (currentPremise === true) { 38 | switch (group.groupType) { 39 | case GroupType.AND: 40 | continue 41 | case GroupType.NOT_AND: 42 | return false 43 | case GroupType.OR: 44 | return true 45 | case GroupType.NOT_OR: 46 | continue 47 | } 48 | } else { 49 | switch (group.groupType) { 50 | case GroupType.AND: 51 | return false 52 | case GroupType.NOT_AND: 53 | continue 54 | case GroupType.OR: 55 | continue 56 | case GroupType.NOT_OR: 57 | return true 58 | } 59 | } 60 | } 61 | 62 | switch (group.groupType) { 63 | case GroupType.AND: 64 | case GroupType.NOT_AND: 65 | return true 66 | case GroupType.OR: 67 | case GroupType.NOT_OR: 68 | return false 69 | } 70 | } 71 | 72 | const premiseTree = buildPremiseTree(filter) 73 | 74 | for ( 75 | let rowIndex = 0, rowsCount = data[0].values.length, deletionCount = 0; 76 | rowIndex < rowsCount; 77 | ++rowIndex 78 | ) { 79 | if (shouldntDeleteRow(rowIndex, premiseTree, filter) === false) { 80 | data.forEach((field: any) => 81 | field.values.splice(rowIndex - deletionCount, 1), 82 | ) 83 | ++deletionCount 84 | } 85 | } 86 | 87 | return data 88 | } 89 | -------------------------------------------------------------------------------- /packages/applyer/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.build.json", 3 | "compilerOptions": { 4 | "outDir": "dist" 5 | }, 6 | "include": ["src"] 7 | } 8 | -------------------------------------------------------------------------------- /packages/applyer/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json" 3 | } 4 | -------------------------------------------------------------------------------- /packages/common/README.md: -------------------------------------------------------------------------------- 1 | # visual-filter/common 2 | 3 | Shared utilities for the @visual-filter packages 4 | -------------------------------------------------------------------------------- /packages/common/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@visual-filter/common", 3 | "version": "1.0.3", 4 | "description": "Shared utilities for the @visual-filter packages", 5 | "keywords": [ 6 | "visual-filter" 7 | ], 8 | "homepage": "https://github.com/obadakhalili/vue-visual-filter.git", 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/obadakhalili/vue-visual-filter.git" 12 | }, 13 | "license": "MIT", 14 | "author": "Obada Khalili", 15 | "main": "dist/index.js", 16 | "types": "dist/index.d.ts", 17 | "files": [ 18 | "dist" 19 | ], 20 | "scripts": { 21 | "build": "tsc --project tsconfig.build.json", 22 | "clean": "rm -rf dist", 23 | "dev": "yarn build --watch", 24 | "format": "yarn run prettier --ignore-path ../../.gitignore --write", 25 | "preversion": "yarn build" 26 | }, 27 | "devDependencies": { 28 | "prettier": "2.7.1", 29 | "typescript": "4.7.4" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /packages/common/src/index.ts: -------------------------------------------------------------------------------- 1 | export enum FilterType { 2 | GROUP = "group", 3 | CONDITION = "condition", 4 | } 5 | 6 | export enum GroupType { 7 | AND = "and", 8 | NOT_AND = "not and", 9 | OR = "or", 10 | NOT_OR = "not or", 11 | } 12 | 13 | export enum DataType { 14 | NUMERIC = "numeric", 15 | NOMINAL = "nominal", 16 | } 17 | 18 | export function deepCopy(src: any): any { 19 | if (src.constructor === Object) { 20 | return Object.entries(src).reduce( 21 | (objCopy, [key, value]) => ({ ...objCopy, [key]: deepCopy(value) }), 22 | {}, 23 | ) 24 | } 25 | if (src.constructor === Array) { 26 | return src.map(deepCopy) 27 | } 28 | return src.valueOf() 29 | } 30 | -------------------------------------------------------------------------------- /packages/common/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.build.json", 3 | "compilerOptions": { 4 | "outDir": "dist" 5 | }, 6 | "include": ["src"] 7 | } 8 | -------------------------------------------------------------------------------- /packages/common/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json" 3 | } 4 | -------------------------------------------------------------------------------- /packages/vue2/@types/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.vue" { 2 | import Vue from "vue" 3 | export default Vue 4 | } 5 | -------------------------------------------------------------------------------- /packages/vue2/README.md: -------------------------------------------------------------------------------- 1 | # Find Intro, Usage, Prerequisites, and other in the [GitHub Repo](https://github.com/obadakhalili/vue-visual-filter) 2 | 3 | # Installation 4 | 5 | - From a package manager: 6 | 7 | ```sh 8 | yarn add @visual-filter/vue2 9 | # OR 10 | npm install @visual-filter/vue2 11 | ``` 12 | 13 | ```js 14 | // JS 15 | import VueVisualFilter from "@visual-filter/vue2" 16 | // OR ESM distro 17 | import VueVisualFilter from "@visual-filter/vue2/dist/component.esm.js" 18 | 19 | // CSS 20 | import "@visual-filter/vue2/dist/styles.css" 21 | ``` 22 | 23 | - For CDN users: 24 | 25 | ```html 26 | 27 | 31 | 32 | 36 | 37 | 38 | 39 | 40 | 41 | ``` 42 | 43 | # Setting Up The Component 44 | 45 | ```js 46 | const app = Vue.createApp({}) 47 | 48 | // Global installation 49 | Vue.use(VueVisualFilter) 50 | // OR 51 | Vue.component(VueVisualFilter.name, VueVisualFilter) 52 | 53 | new Vue({}).$mount("#app") 54 | 55 | // Local installation in component's option 56 | new Vue({ 57 | components: { VueVisualFilter }, 58 | }).$mount("#app") 59 | ``` 60 | -------------------------------------------------------------------------------- /packages/vue2/__tests__/FilterCondition.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount } from "@vue/test-utils" 2 | 3 | import FilterCondition from "../src/VueVisualFilter/FilterCondition.vue" 4 | 5 | function generateProps() { 6 | return { 7 | condition: { 8 | argument: "John", 9 | dataType: "nominal", 10 | fieldName: "First Name", 11 | method: "contains", 12 | type: "condition", 13 | }, 14 | fieldNames: ["First Name", "Last Name", "Grade"], 15 | nominalMethodNames: ["contains", "startsWith", "endsWith"], 16 | numericMethodNames: ["=", ">", "<"], 17 | } 18 | } 19 | 20 | const sharedProps = generateProps() 21 | const wrapper = mount( 22 | FilterCondition, 23 | { propsData: sharedProps }, 24 | ) 25 | 26 | describe("field updation logic", () => { 27 | const updateFieldSpy = jest.spyOn(wrapper.vm, "updateField") 28 | 29 | beforeAll(async () => { 30 | await wrapper.find("select").findAll("option").at(1).setSelected() 31 | }) 32 | 33 | it("should update modeled property", () => { 34 | expect(sharedProps.condition.fieldName).toMatchSnapshot() 35 | }) 36 | 37 | it("should call updateField method on change event", () => { 38 | expect(updateFieldSpy).toHaveBeenCalled() 39 | }) 40 | 41 | it("should emit updateField on change event with correct paramaters", () => { 42 | const emittedEvents = wrapper.emitted() 43 | const [params] = emittedEvents.updateField as unknown[][] 44 | expect(params).toMatchSnapshot() 45 | }) 46 | }) 47 | 48 | describe("method updation logic", () => { 49 | it("should update modeled property", async () => { 50 | const selectedIndex = 1 51 | await wrapper 52 | .findAll("select") 53 | .at(1) 54 | .findAll("option") 55 | .at(selectedIndex) 56 | .setSelected() 57 | expect(sharedProps.condition.method).toMatchSnapshot() 58 | }) 59 | }) 60 | 61 | describe("argument updation logic", () => { 62 | it("should update modeled property", async () => { 63 | const newValue = "new value" 64 | await wrapper.find("input").setValue(newValue) 65 | expect(sharedProps.condition.argument).toBe(newValue) 66 | }) 67 | }) 68 | 69 | describe("condition deletion logic", () => { 70 | it("should emit deleteCondition on click event", async () => { 71 | await wrapper.find("button").trigger("click") 72 | expect(wrapper.emitted()).toHaveProperty("deleteCondition") 73 | }) 74 | }) 75 | 76 | describe("slots", () => { 77 | it("should bound correct values to their corresponding slots", () => { 78 | const props = generateProps() 79 | const wrapper = mount(FilterCondition, { 80 | propsData: props, 81 | scopedSlots: { 82 | fieldUpdation({ condition, fieldNames, updateField }: unknown) { 83 | expect(condition).toBe(props.condition) 84 | expect(fieldNames).toBe(props.fieldNames) 85 | updateField(props.fieldNames[1]) 86 | }, 87 | methodUpdation({ 88 | numericMethodNames, 89 | nominalMethodNames, 90 | condition, 91 | }: unknown) { 92 | expect(numericMethodNames).toBe(false) 93 | expect(nominalMethodNames).toBe(props.nominalMethodNames) 94 | expect(condition).toBe(props.condition) 95 | }, 96 | argumentUpdation({ condition }: unknown) { 97 | expect(condition).toBe(props.condition) 98 | }, 99 | conditionDeletion({ deleteCondition }: unknown) { 100 | deleteCondition(props.condition) 101 | }, 102 | }, 103 | }) 104 | const emittedEvents = wrapper.emitted() 105 | expect(emittedEvents).toHaveProperty("updateField") 106 | expect(emittedEvents).toHaveProperty("deleteCondition") 107 | }) 108 | }) 109 | -------------------------------------------------------------------------------- /packages/vue2/__tests__/FilterGroup.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount } from "@vue/test-utils" 2 | import { FilterType, GroupType } from "@visual-filter/common" 3 | 4 | import FilterGroup from "../src/VueVisualFilter/FilterGroup.vue" 5 | 6 | function generateProps(isGroupRemovable: boolean | string = false) { 7 | return { 8 | group: { 9 | filters: [], 10 | groupType: GroupType.AND, 11 | type: FilterType.GROUP, 12 | }, 13 | removable: isGroupRemovable ? true : false, 14 | } 15 | } 16 | 17 | const sharedProps = generateProps() 18 | const wrapper = mount(FilterGroup, { 19 | propsData: sharedProps, 20 | }) 21 | 22 | describe("group types logic", () => { 23 | it("should update modeled property", async () => { 24 | await wrapper.find("select").findAll("option").at(1).setSelected() 25 | expect(sharedProps.group.groupType).toMatchSnapshot() 26 | }) 27 | }) 28 | 29 | describe("filter addition logic", () => { 30 | const addFilterSpy = jest.spyOn(wrapper.vm, "addFilter") 31 | 32 | beforeAll(async () => { 33 | await wrapper.findAll("select").at(1).findAll("option").at(1).setSelected() 34 | }) 35 | 36 | it("should call addFilter method on change event", () => { 37 | expect(addFilterSpy).toHaveBeenCalled() 38 | }) 39 | 40 | it("should emit addFilter on change event with correct paramaters", () => { 41 | const emittedEvents = wrapper.emitted() 42 | const [params] = emittedEvents.addFilter as unknown[][] 43 | expect(params).toMatchSnapshot() 44 | }) 45 | }) 46 | 47 | describe("filter deletion logic", () => { 48 | it("shouldn't exit", () => { 49 | expect(wrapper.find("button").exists()).toBe(false) 50 | }) 51 | 52 | it("should exist after re-setting props with isGroupRemovable set to a truthy value", async () => { 53 | await wrapper.setProps(generateProps("removableGroup")) 54 | expect(wrapper.find("button").exists()).toBe(true) 55 | }) 56 | 57 | it("should emit deleteGroup on click event", async () => { 58 | await wrapper.find("button").trigger("click") 59 | expect(wrapper.emitted()).toHaveProperty("deleteGroup") 60 | }) 61 | }) 62 | 63 | describe("slots", () => { 64 | it("should bound correct values to their corresponding slots", () => { 65 | const props = generateProps("removableGroup") 66 | const wrapper = mount(FilterGroup, { 67 | propsData: props, 68 | scopedSlots: { 69 | groupTypes({ groupTypes, group }: unknown) { 70 | expect(groupTypes).toEqual(groupTypes) 71 | expect(group).toBe(props.group) 72 | }, 73 | filterAddition({ filterTypes, addFilter }: unknown) { 74 | expect(filterTypes).toMatchSnapshot() 75 | addFilter(FilterType.GROUP) 76 | }, 77 | groupDeletion({ deleteGroup }: unknown) { 78 | deleteGroup() 79 | }, 80 | }, 81 | }) 82 | const emittedEvents = wrapper.emitted() 83 | expect(emittedEvents).toHaveProperty("addFilter") 84 | expect(emittedEvents).toHaveProperty("deleteGroup") 85 | }) 86 | }) 87 | -------------------------------------------------------------------------------- /packages/vue2/__tests__/VueVisualFilter.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount } from "@vue/test-utils" 2 | import { FilterType, deepCopy } from "@visual-filter/common" 3 | import applyFilter from "@visual-filter/applyer" 4 | 5 | import VueVisualFilter from "../src/VueVisualFilter/index.vue" 6 | 7 | const propsData = { 8 | filteringOptions: { 9 | data: [ 10 | { name: "First Name", type: "nominal", values: [] }, 11 | { name: "Last Name", type: "nominal", values: [] }, 12 | { name: "Grade", type: "numeric", values: [] }, 13 | ], 14 | methods: { 15 | numeric: { "=": () => {}, ">": () => {}, "<": () => {} }, 16 | nominal: { contains: () => {}, startsWith: () => {}, endsWith: () => {} }, 17 | }, 18 | }, 19 | } 20 | const wrapper = mount< 21 | VueVisualFilter & { [key: string]: any; $options: { watch: any } } 22 | >(VueVisualFilter, { propsData }) 23 | 24 | jest.mock("@visual-filter/common").mock("@visual-filter/applyer") 25 | 26 | describe("computed properties", () => { 27 | it("computes correct values", () => { 28 | expect(wrapper.vm.fieldNames).toMatchSnapshot() 29 | expect(wrapper.vm.numericMethodNames).toMatchSnapshot() 30 | expect(wrapper.vm.nominalMethodNames).toMatchSnapshot() 31 | }) 32 | }) 33 | 34 | describe("watcher", () => { 35 | it("shouldn't emit update-filter event when no listener is passed", () => { 36 | wrapper.vm.$options.watch.filter.handler.call(wrapper.vm) 37 | expect(wrapper.emitted()).not.toHaveProperty("filter-update") 38 | }) 39 | 40 | it("should emit update-filter event with correct params when listener is passed", async () => { 41 | ;(applyFilter as jest.Mock).mockImplementation( 42 | () => propsData.filteringOptions.data, 43 | ) 44 | ;(deepCopy as jest.Mock).mockImplementation((obj) => obj) 45 | 46 | const wrapper = mount( 47 | VueVisualFilter, 48 | { 49 | propsData, 50 | listeners: { "filter-update": () => {} }, 51 | }, 52 | ) 53 | wrapper.vm.$options.watch.filter.handler.call(wrapper.vm) 54 | const emittedEvents = wrapper.emitted() 55 | const [params] = emittedEvents["filter-update"] as unknown[][] 56 | 57 | expect(emittedEvents).toHaveProperty("filter-update") 58 | expect(params).toMatchSnapshot() 59 | expect(deepCopy).toHaveBeenCalledTimes(2) 60 | expect(applyFilter).toHaveBeenCalledTimes(1) 61 | }) 62 | }) 63 | 64 | describe("methods", () => { 65 | it("adds correct filter objects by addFilter method", () => { 66 | wrapper.vm.addFilter(wrapper.vm.filter.filters, FilterType.GROUP) 67 | wrapper.vm.addFilter(wrapper.vm.filter.filters, FilterType.CONDITION) 68 | expect(wrapper.vm.filter.filters[0]).toMatchSnapshot() 69 | expect(wrapper.vm.filter.filters[1]).toMatchSnapshot() 70 | }) 71 | 72 | it("removes correct filter object by removeFilter method", () => { 73 | wrapper.vm.deleteFilter(0, wrapper.vm.filter.filters) 74 | expect(wrapper.vm.filter.filters).toMatchSnapshot() 75 | }) 76 | 77 | it("doesn't update condition field when changed to same type", () => { 78 | wrapper.vm.updateConditionField( 79 | wrapper.vm.filter.filters[0], 80 | propsData.filteringOptions.data[1].name, 81 | ) 82 | expect(wrapper.vm.filter.filters[0]).toMatchSnapshot() 83 | }) 84 | 85 | it("updates condition field when changed to different type", () => { 86 | wrapper.vm.updateConditionField( 87 | wrapper.vm.filter.filters[0], 88 | propsData.filteringOptions.data[2].name, 89 | ) 90 | expect(wrapper.vm.filter.filters[0]).toMatchSnapshot() 91 | }) 92 | }) 93 | -------------------------------------------------------------------------------- /packages/vue2/__tests__/__snapshots__/FilterCondition.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`field updation logic should emit updateField on change event with correct paramaters 1`] = ` 4 | Array [ 5 | Object { 6 | "argument": "John", 7 | "dataType": "nominal", 8 | "fieldName": "Last Name", 9 | "method": "contains", 10 | "type": "condition", 11 | }, 12 | "Last Name", 13 | ] 14 | `; 15 | 16 | exports[`field updation logic should update modeled property 1`] = `"Last Name"`; 17 | 18 | exports[`method updation logic should update modeled property 1`] = `"startsWith"`; 19 | -------------------------------------------------------------------------------- /packages/vue2/__tests__/__snapshots__/FilterGroup.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`filter addition logic should emit addFilter on change event with correct paramaters 1`] = ` 4 | Array [ 5 | Array [], 6 | "condition", 7 | ] 8 | `; 9 | 10 | exports[`group types logic should update modeled property 1`] = `"not and"`; 11 | 12 | exports[`slots should bound correct values to their corresponding slots 1`] = ` 13 | Array [ 14 | "group", 15 | "condition", 16 | ] 17 | `; 18 | -------------------------------------------------------------------------------- /packages/vue2/__tests__/__snapshots__/VueVisualFilter.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`computed properties computes correct values 1`] = ` 4 | Array [ 5 | "First Name", 6 | "Last Name", 7 | "Grade", 8 | ] 9 | `; 10 | 11 | exports[`computed properties computes correct values 2`] = ` 12 | Array [ 13 | "=", 14 | ">", 15 | "<", 16 | ] 17 | `; 18 | 19 | exports[`computed properties computes correct values 3`] = ` 20 | Array [ 21 | "contains", 22 | "startsWith", 23 | "endsWith", 24 | ] 25 | `; 26 | 27 | exports[`methods adds correct filter objects by addFilter method 1`] = ` 28 | Object { 29 | "filters": Array [], 30 | "groupType": "and", 31 | "type": "group", 32 | } 33 | `; 34 | 35 | exports[`methods adds correct filter objects by addFilter method 2`] = ` 36 | Object { 37 | "argument": "", 38 | "dataType": "nominal", 39 | "fieldName": "First Name", 40 | "method": "contains", 41 | "type": "condition", 42 | } 43 | `; 44 | 45 | exports[`methods doesn't update condition field when changed to same type 1`] = ` 46 | Object { 47 | "argument": "", 48 | "dataType": "nominal", 49 | "fieldName": "First Name", 50 | "method": "contains", 51 | "type": "condition", 52 | } 53 | `; 54 | 55 | exports[`methods removes correct filter object by removeFilter method 1`] = ` 56 | Array [ 57 | Object { 58 | "argument": "", 59 | "dataType": "nominal", 60 | "fieldName": "First Name", 61 | "method": "contains", 62 | "type": "condition", 63 | }, 64 | ] 65 | `; 66 | 67 | exports[`methods updates condition field when changed to different type 1`] = ` 68 | Object { 69 | "argument": "", 70 | "dataType": "numeric", 71 | "fieldName": "First Name", 72 | "method": "=", 73 | "type": "condition", 74 | } 75 | `; 76 | 77 | exports[`watcher should emit update-filter event with correct params when listener is passed 1`] = ` 78 | Array [ 79 | Object { 80 | "data": Array [ 81 | Object { 82 | "name": "First Name", 83 | "type": "nominal", 84 | "values": Array [], 85 | }, 86 | Object { 87 | "name": "Last Name", 88 | "type": "nominal", 89 | "values": Array [], 90 | }, 91 | Object { 92 | "name": "Grade", 93 | "type": "numeric", 94 | "values": Array [], 95 | }, 96 | ], 97 | "filter": Object { 98 | "filters": Array [], 99 | "groupType": "and", 100 | "type": "group", 101 | }, 102 | }, 103 | ] 104 | `; 105 | -------------------------------------------------------------------------------- /packages/vue2/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = require("../../babel.config.js") 2 | -------------------------------------------------------------------------------- /packages/vue2/dev/Serve.vue: -------------------------------------------------------------------------------- 1 | 58 | 59 | 60 | 64 | 65 | -------------------------------------------------------------------------------- /packages/vue2/dev/serve.js: -------------------------------------------------------------------------------- 1 | import Vue from "vue" 2 | 3 | import VueVisualFilter from "@/main.js" 4 | 5 | import Serve from "./Serve.vue" 6 | 7 | Vue.use(VueVisualFilter) 8 | 9 | new Vue({ 10 | render: (h) => h(Serve), 11 | }).$mount("#app") 12 | -------------------------------------------------------------------------------- /packages/vue2/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | @visual-filter/vue2 Playground 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /packages/vue2/jest.config.js: -------------------------------------------------------------------------------- 1 | const baseConfig = require("../../jest.config.js") 2 | 3 | module.exports = { 4 | ...baseConfig, 5 | transform: { 6 | ...baseConfig.transform, 7 | "\\.vue$": "vue-jest", 8 | }, 9 | } 10 | -------------------------------------------------------------------------------- /packages/vue2/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@visual-filter/vue2", 3 | "version": "1.1.3", 4 | "description": "🕵️♂️ An unopinionated advanced visual filtering component for Vue 2", 5 | "keywords": [ 6 | "vue", 7 | "vue2", 8 | "component", 9 | "lib", 10 | "advanced", 11 | "visual", 12 | "filtering" 13 | ], 14 | "homepage": "https://github.com/obadakhalili/vue-visual-filter.git", 15 | "repository": { 16 | "type": "git", 17 | "url": "https://github.com/obadakhalili/vue-visual-filter.git" 18 | }, 19 | "license": "MIT", 20 | "author": "Obada Khalili", 21 | "main": "dist/component.cjs.js", 22 | "unpkg": "dist/component.min.js", 23 | "module": "dist/component.esm.js", 24 | "browser": "dist/component.cjs.js", 25 | "files": [ 26 | "dist/*", 27 | "src/**/*.vue" 28 | ], 29 | "scripts": { 30 | "build": "rollup --config ../../rollup.config.js --environment NODE_ENV:production,package:vue2", 31 | "clean": "rm -rf dist", 32 | "dev": "vite --port 8080", 33 | "dev:rebuild": "yarn run dev --force", 34 | "format": "yarn run prettier --ignore-path ../../.gitignore --write", 35 | "test": "jest", 36 | "test:watch": "jest --watch", 37 | "preversion": "npm run build" 38 | }, 39 | "dependencies": { 40 | "@visual-filter/applyer": "0.0.4", 41 | "@visual-filter/common": "1.0.3" 42 | }, 43 | "devDependencies": { 44 | "@babel/core": "7.18.6", 45 | "@babel/preset-env": "7.18.6", 46 | "@rollup/plugin-babel": "5.3.1", 47 | "@rollup/plugin-commonjs": "22.0.1", 48 | "@rollup/plugin-node-resolve": "13.3.0", 49 | "@types/jest": "26.0.23", 50 | "@vitejs/plugin-vue2": "1.1.2", 51 | "@vue/test-utils": "1.1.3", 52 | "autoprefixer": "10.4.7", 53 | "babel-jest": "^26.0.1", 54 | "jest": "26.6.3", 55 | "postcss": "8.4.14", 56 | "prettier": "2.7.1", 57 | "rollup": "2.75.7", 58 | "rollup-plugin-ignore-import": "1.3.2", 59 | "rollup-plugin-postcss": "4.0.2", 60 | "rollup-plugin-terser": "7.0.2", 61 | "rollup-plugin-vue2": "npm:rollup-plugin-vue@5.1.9", 62 | "rollup-plugin-vue3": "npm:rollup-plugin-vue@6.0.0", 63 | "tailwindcss": "3.1.4", 64 | "ts-jest": "26.5.4", 65 | "typescript": "4.7.4", 66 | "vite": "2.9.13", 67 | "vue": "2.7.3", 68 | "vue-jest": "4.0.1", 69 | "vue-template-compiler": "^2.6.11" 70 | }, 71 | "peerDependencies": { 72 | "vue": "2.7.3" 73 | }, 74 | "engines": { 75 | "node": ">=12" 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /packages/vue2/src/VueVisualFilter/FilterCondition.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 44 | 45 | 46 | 50 | 51 | {{ field }} 52 | 53 | 54 | 55 | 63 | 64 | 69 | {{ method }} 70 | 71 | 72 | 73 | 74 | 75 | 76 | 80 | x 81 | 82 | 83 | 84 | -------------------------------------------------------------------------------- /packages/vue2/src/VueVisualFilter/FilterGroup.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | {{ type }} 42 | 43 | 44 | 45 | 46 | 47 | 48 | {{ type }} 49 | 50 | 51 | 52 | 57 | x 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /packages/vue2/src/VueVisualFilter/index.vue: -------------------------------------------------------------------------------- 1 | 186 | -------------------------------------------------------------------------------- /packages/vue2/src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind utilities; 2 | -------------------------------------------------------------------------------- /packages/vue2/src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from "vue" 2 | 3 | import VueVisualFilter from "./VueVisualFilter/index.vue" 4 | import "./index.css" 5 | 6 | VueVisualFilter.install = () => { 7 | Vue.component(VueVisualFilter.name, VueVisualFilter) 8 | } 9 | 10 | export default VueVisualFilter 11 | -------------------------------------------------------------------------------- /packages/vue2/vite.config.js: -------------------------------------------------------------------------------- 1 | const { defineConfig } = require("vite") 2 | const vue2 = require("@vitejs/plugin-vue2") 3 | 4 | module.exports = defineConfig({ 5 | ...require("../../vite.config.js"), 6 | plugins: [vue2()], 7 | }) 8 | -------------------------------------------------------------------------------- /packages/vue3/README.md: -------------------------------------------------------------------------------- 1 | # Find Intro, Usage, Prerequisites, and other in the [GitHub Repo](https://github.com/obadakhalili/vue-visual-filter) 2 | 3 | # Installation 4 | 5 | - From a package manager: 6 | 7 | ```sh 8 | yarn add @visual-filter/vue3 9 | # OR 10 | npm install @visual-filter/vue3 11 | ``` 12 | 13 | ```js 14 | // JS 15 | import VueVisualFilter from "@visual-filter/vue3" 16 | // OR ESM distro 17 | import VueVisualFilter from "@visual-filter/vue3/dist/component.esm.js" 18 | 19 | // CSS 20 | import "@visual-filter/vue3/dist/styles.css" 21 | ``` 22 | 23 | - For CDN users: 24 | 25 | ```html 26 | 27 | 31 | 32 | 36 | 37 | 38 | 39 | 40 | 41 | ``` 42 | 43 | # Setting Up The Component 44 | 45 | ```js 46 | const app = Vue.createApp({}) 47 | 48 | // Global installation 49 | app.use(VueVisualFilter) 50 | // OR 51 | app.component(VueVisualFilter.name, VueVisualFilter) 52 | 53 | app.mount("#app") 54 | 55 | // Local installation in component's option 56 | const app = Vue.createApp({ 57 | components: { VueVisualFilter }, 58 | }) 59 | 60 | app.mount("#app") 61 | ``` 62 | -------------------------------------------------------------------------------- /packages/vue3/dev/Serve.vue: -------------------------------------------------------------------------------- 1 | 58 | 59 | 60 | 64 | 65 | -------------------------------------------------------------------------------- /packages/vue3/dev/serve.js: -------------------------------------------------------------------------------- 1 | import { createApp, h } from "vue" 2 | 3 | import VueVisualFilter from "@/main.js" 4 | 5 | import Serve from "./Serve.vue" 6 | 7 | createApp({ 8 | render: () => h(Serve), 9 | }) 10 | .use(VueVisualFilter) 11 | .mount("#app") 12 | -------------------------------------------------------------------------------- /packages/vue3/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | @visual-filter/vue3 Playground 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /packages/vue3/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@visual-filter/vue3", 3 | "version": "1.0.2", 4 | "description": "🕵️♂️ An unopinionated advanced visual filtering component for Vue 3", 5 | "keywords": [ 6 | "vue", 7 | "vue3", 8 | "component", 9 | "lib", 10 | "advanced", 11 | "visual", 12 | "filtering" 13 | ], 14 | "homepage": "https://github.com/obadakhalili/vue-visual-filter.git", 15 | "repository": { 16 | "type": "git", 17 | "url": "https://github.com/obadakhalili/vue-visual-filter.git" 18 | }, 19 | "license": "MIT", 20 | "author": "Obada Khalili", 21 | "main": "dist/component.cjs.js", 22 | "unpkg": "dist/component.min.js", 23 | "module": "dist/component.esm.js", 24 | "browser": "dist/component.cjs.js", 25 | "files": [ 26 | "dist/*", 27 | "src/**/*.vue" 28 | ], 29 | "scripts": { 30 | "build": "rollup --config ../../rollup.config.js --environment NODE_ENV:production,package:vue3", 31 | "clean": "rm -rf dist", 32 | "dev": "vite --port 8081", 33 | "dev:rebuild": "yarn run dev --force", 34 | "format": "yarn run prettier --ignore-path ../../.gitignore --write", 35 | "test": "yarn vitest run", 36 | "test:watch": "yarn vitest --ui --open false", 37 | "preversion": "npm run build" 38 | }, 39 | "dependencies": { 40 | "@visual-filter/applyer": "0.0.4", 41 | "@visual-filter/common": "1.0.3" 42 | }, 43 | "devDependencies": { 44 | "@babel/core": "7.18.6", 45 | "@babel/preset-env": "7.18.6", 46 | "@rollup/plugin-babel": "5.3.1", 47 | "@rollup/plugin-commonjs": "22.0.1", 48 | "@rollup/plugin-node-resolve": "13.3.0", 49 | "@testing-library/jest-dom": "^5.16.4", 50 | "@testing-library/vue": "^6.6.1", 51 | "@types/jest": "26.0.23", 52 | "@vitejs/plugin-vue": "2.3.3", 53 | "@vitest/ui": "^0.18.1", 54 | "@vue/compiler-sfc": "3.2.37", 55 | "autoprefixer": "10.4.7", 56 | "jest": "26.6.3", 57 | "postcss": "8.4.14", 58 | "prettier": "2.7.1", 59 | "rollup": "2.75.7", 60 | "rollup-plugin-ignore-import": "1.3.2", 61 | "rollup-plugin-postcss": "4.0.2", 62 | "rollup-plugin-terser": "7.0.2", 63 | "rollup-plugin-vue2": "npm:rollup-plugin-vue@5.1.9", 64 | "rollup-plugin-vue3": "npm:rollup-plugin-vue@6.0.0", 65 | "tailwindcss": "3.1.4", 66 | "ts-jest": "26.5.4", 67 | "typescript": "4.7.4", 68 | "vite": "2.9.13", 69 | "vitest": "^0.18.1", 70 | "vue": "3.2.37" 71 | }, 72 | "peerDependencies": { 73 | "vue": "^3.2.37" 74 | }, 75 | "engines": { 76 | "node": ">=12" 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /packages/vue3/src/VueVisualFilter/FilterCondition.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 44 | 45 | 46 | 51 | 52 | {{ field }} 53 | 54 | 55 | 56 | 64 | 65 | 70 | {{ method }} 71 | 72 | 73 | 74 | 75 | 80 | 81 | 85 | x 86 | 87 | 88 | 89 | -------------------------------------------------------------------------------- /packages/vue3/src/VueVisualFilter/FilterGroup.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | {{ type }} 43 | 44 | 45 | 46 | 47 | 51 | 52 | {{ type }} 53 | 54 | 55 | 56 | 61 | x 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | -------------------------------------------------------------------------------- /packages/vue3/src/VueVisualFilter/index.vue: -------------------------------------------------------------------------------- 1 | 184 | -------------------------------------------------------------------------------- /packages/vue3/src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind utilities; 2 | -------------------------------------------------------------------------------- /packages/vue3/src/main.js: -------------------------------------------------------------------------------- 1 | import VueVisualFilter from "./VueVisualFilter/index.vue" 2 | import "./index.css" 3 | 4 | VueVisualFilter.install = (app) => { 5 | app.component(VueVisualFilter.name, VueVisualFilter) 6 | } 7 | 8 | export default VueVisualFilter 9 | -------------------------------------------------------------------------------- /packages/vue3/src/tests/VueVisualFilter.test.js: -------------------------------------------------------------------------------- 1 | import { render, fireEvent, queryByTestId } from "@testing-library/vue" 2 | import "@testing-library/jest-dom" 3 | import { GroupType, FilterType, DataType } from "@visual-filter/common" 4 | 5 | import VueVisualFilter from "@/VueVisualFilter/index.vue" 6 | 7 | beforeEach((ctx) => { 8 | ctx.commonProps = { 9 | filteringOptions: { 10 | data: [ 11 | { 12 | name: "First Name", 13 | type: "nominal", 14 | values: ["Foo", "Fizz"], 15 | }, 16 | { 17 | name: "Last Name", 18 | type: "nominal", 19 | values: ["Bar", "Buzz"], 20 | }, 21 | { 22 | name: "Age", 23 | type: "numeric", 24 | values: [18, 19], 25 | }, 26 | ], 27 | methods: { 28 | numeric: { 29 | "="(cellValue, argument) { 30 | return cellValue == argument 31 | }, 32 | ">"(cellValue, argument) { 33 | return cellValue > argument 34 | }, 35 | }, 36 | nominal: { 37 | contains(cellValue, argument) { 38 | return cellValue.includes(argument) 39 | }, 40 | }, 41 | }, 42 | }, 43 | } 44 | }) 45 | 46 | test("`filteringOptions` prop validator should work as expected", ({ 47 | commonProps, 48 | }) => { 49 | const filteringOptionsValidator = 50 | VueVisualFilter.props.filteringOptions.validator 51 | 52 | expect(filteringOptionsValidator(undefined)).toBeFalsy() 53 | 54 | expect(filteringOptionsValidator(null)).toBeFalsy() 55 | 56 | expect(filteringOptionsValidator({})).toBeFalsy() 57 | 58 | expect(filteringOptionsValidator({ data: [] })).toBeFalsy() 59 | 60 | expect(filteringOptionsValidator({ data: [{}] })).toBeFalsy() 61 | 62 | expect( 63 | filteringOptionsValidator({ data: [{ name: "Firstname" }] }), 64 | ).toBeFalsy() 65 | 66 | expect( 67 | filteringOptionsValidator({ 68 | data: [{ name: "Firstname", type: "nominal" }], 69 | }), 70 | ).toBeFalsy() 71 | 72 | expect( 73 | filteringOptionsValidator({ 74 | data: [{ name: "Firstname", type: "nominal", values: [] }], 75 | }), 76 | ).toBeFalsy() 77 | 78 | expect( 79 | filteringOptionsValidator({ 80 | data: [{ name: "Firstname", type: "nominal", values: ["Foo"] }], 81 | }), 82 | ).toBeFalsy() 83 | 84 | expect( 85 | filteringOptionsValidator({ 86 | data: [ 87 | { name: "Firstname", type: "nominal", values: ["a", "b"] }, 88 | { name: "Firstname", type: "nominal", values: ["x"] }, 89 | ], 90 | }), 91 | ).toBeFalsy() 92 | 93 | expect( 94 | filteringOptionsValidator({ 95 | data: [{ name: "Firstname", type: "nominal", values: ["a"] }], 96 | methods: { 97 | nominal: null, 98 | }, 99 | }), 100 | ).toBeFalsy() 101 | 102 | expect( 103 | filteringOptionsValidator({ 104 | data: [{ name: "Firstname", type: "nominal", values: ["a"] }], 105 | methods: { 106 | nominal: { a: null }, 107 | }, 108 | }), 109 | ).toBeFalsy() 110 | 111 | expect( 112 | filteringOptionsValidator({ 113 | data: [{ name: "Firstname", type: "nominal", values: ["a"] }], 114 | methods: { 115 | nominal: { 116 | contian: () => {}, 117 | }, 118 | }, 119 | }), 120 | ).toBeFalsy() 121 | 122 | // TODO: should be handled 123 | // expect( 124 | // filteringOptionsValidator({ 125 | // data: [{ name: "Firstname", type: "nominal", values: ["a"] }], 126 | // methods: { 127 | // nominal: { 128 | // contian: () => {}, 129 | // }, 130 | // numeric: 0, 131 | // }, 132 | // }), 133 | // ).toBeFalsy() 134 | 135 | expect( 136 | filteringOptionsValidator({ 137 | data: [{ name: "Firstname", type: "nominal", values: ["a"] }], 138 | methods: { 139 | nominal: { 140 | contian: () => {}, 141 | }, 142 | numeric: { 143 | a: null, 144 | }, 145 | }, 146 | }), 147 | ).toBeFalsy() 148 | 149 | expect(filteringOptionsValidator(commonProps.filteringOptions)).toBeTruthy() 150 | }) 151 | 152 | test("after initial render we should have group type and filter type selects with correct initial options", ({ 153 | commonProps, 154 | }) => { 155 | const methods = render(VueVisualFilter, { 156 | props: commonProps, 157 | }) 158 | 159 | const groupTypeSelect = methods.queryByTestId("group-type-select") 160 | 161 | expect(groupTypeSelect).toBeTruthy() 162 | expect(groupTypeSelect).toHaveValue(GroupType.AND) 163 | 164 | const filterTypeSelect = methods.queryByTestId("filter-type-select") 165 | 166 | expect(filterTypeSelect).toBeTruthy() 167 | expect(filterTypeSelect).toHaveValue(FilterType.GROUP) 168 | }) 169 | 170 | test("expect `onFilterUpdate` event to be called with correct parameters when changing group type select", async ({ 171 | commonProps, 172 | }) => { 173 | const spyFilterUpdateEvent = vi.fn() 174 | const methods = render(VueVisualFilter, { 175 | propsData: { ...commonProps, onFilterUpdate: spyFilterUpdateEvent }, 176 | }) 177 | 178 | const groupTypeSelect = methods.queryByTestId("group-type-select") 179 | 180 | await fireEvent.update(groupTypeSelect, GroupType.NOT_AND) 181 | 182 | expect(groupTypeSelect).toHaveValue(GroupType.NOT_AND) 183 | expect(spyFilterUpdateEvent.calls).toMatchSnapshot() 184 | 185 | await fireEvent.update(groupTypeSelect, GroupType.OR) 186 | 187 | expect(groupTypeSelect).toHaveValue(GroupType.OR) 188 | expect(spyFilterUpdateEvent.calls).toMatchSnapshot() 189 | 190 | await fireEvent.update(groupTypeSelect, GroupType.NOT_OR) 191 | 192 | expect(groupTypeSelect).toHaveValue(GroupType.NOT_OR) 193 | expect(spyFilterUpdateEvent.calls).toMatchSnapshot() 194 | }) 195 | 196 | test("filter of type condition renders as expected on initial render after selecting from group type select, and callback is called with correct parameters", async ({ 197 | commonProps, 198 | }) => { 199 | const spyFilterUpdateEvent = vi.fn() 200 | const methods = render(VueVisualFilter, { 201 | props: { 202 | ...commonProps, 203 | onFilterUpdate: spyFilterUpdateEvent, 204 | }, 205 | }) 206 | 207 | const filterTypeSelect = methods.queryByTestId("filter-type-select") 208 | 209 | await fireEvent.update(filterTypeSelect, FilterType.CONDITION) 210 | 211 | expect(filterTypeSelect).toHaveValue(FilterType.CONDITION) 212 | 213 | const { 214 | data: [ 215 | { 216 | fieldName: firstFieldName, 217 | type: firstFieldType, 218 | values: [firstValue], 219 | }, 220 | ], 221 | methods: methodsOptions, 222 | } = commonProps.filteringOptions 223 | 224 | const fieldNameSelect = methods.queryByTestId("field-name-select") 225 | 226 | expect(fieldNameSelect).toBeTruthy() 227 | expect(fieldNameSelect).toHaveValue(firstFieldName) 228 | 229 | const methodSelect = methods.queryByTestId("method-select") 230 | 231 | expect(methodSelect).toBeTruthy() 232 | expect(methodSelect).toHaveValue( 233 | Object.keys( 234 | firstFieldType === DataType.NOMINAL 235 | ? methodsOptions.nominal 236 | : methodsOptions.numeric, 237 | )[0], 238 | ) 239 | 240 | const argumentInput = methods.queryByTestId("argument-input") 241 | 242 | expect(argumentInput).toBeTruthy() 243 | expect(argumentInput).toHaveValue(firstValue) 244 | 245 | expect(spyFilterUpdateEvent.calls).toMatchSnapshot() 246 | }) 247 | 248 | test("end-to-end test the component", async ({ commonProps }) => { 249 | let latestCallIndex = -1 250 | const spyFilterUpdateEvent = vi.fn(() => { 251 | ++latestCallIndex 252 | }) 253 | const methods = render(VueVisualFilter, { 254 | props: { 255 | ...commonProps, 256 | onFilterUpdate: spyFilterUpdateEvent, 257 | }, 258 | }) 259 | 260 | await fireEvent.update( 261 | methods.queryByTestId("filter-type-select"), 262 | FilterType.CONDITION, 263 | ) 264 | 265 | expect(spyFilterUpdateEvent.calls[latestCallIndex]).toMatchSnapshot() 266 | 267 | await fireEvent.update( 268 | methods.queryByTestId("group-type-select"), 269 | GroupType.OR, 270 | ) 271 | await fireEvent.update( 272 | methods.queryByTestId("filter-type-select"), 273 | FilterType.GROUP, 274 | ) 275 | await fireEvent.update( 276 | methods.queryAllByTestId("filter-type-select")[1], 277 | FilterType.CONDITION, 278 | ) 279 | 280 | await fireEvent.update( 281 | methods.queryAllByTestId("field-name-select")[1], 282 | "Last Name", 283 | ) 284 | await fireEvent.update(methods.queryAllByTestId("argument-input")[1], "Buz") 285 | 286 | expect(spyFilterUpdateEvent.calls[latestCallIndex]).toMatchSnapshot() 287 | 288 | await fireEvent.update( 289 | methods.queryAllByTestId("filter-type-select")[1], 290 | FilterType.CONDITION, 291 | ) 292 | 293 | await fireEvent.update( 294 | methods.queryAllByTestId("field-name-select")[2], 295 | "Age", 296 | ) 297 | await fireEvent.update(methods.queryAllByTestId("method-select")[2], ">") 298 | await fireEvent.update(methods.queryAllByTestId("argument-input")[2], 18) 299 | 300 | expect(spyFilterUpdateEvent.calls[latestCallIndex]).toMatchSnapshot() 301 | 302 | await fireEvent.click(methods.queryByTestId("remove-group-button")) 303 | 304 | expect(methods.queryAllByTestId("group-type-select").length).toBe(1) 305 | expect(methods.queryAllByTestId("filter-type-select").length).toBe(1) 306 | expect(spyFilterUpdateEvent.calls[latestCallIndex]).toMatchSnapshot() 307 | 308 | await fireEvent.click(methods.queryByTestId("remove-condition-button")) 309 | await fireEvent.update(methods.queryByTestId("group-type-select"), GroupType.AND) 310 | 311 | expect(methods.queryAllByTestId("field-name-select").length).toBe(0) 312 | expect(methods.queryAllByTestId("method-select").length).toBe(0) 313 | expect(methods.queryAllByTestId("argument-input").length).toBe(0) 314 | 315 | expect(spyFilterUpdateEvent.calls[latestCallIndex]).toMatchSnapshot() 316 | }) 317 | -------------------------------------------------------------------------------- /packages/vue3/src/tests/__snapshots__/VueVisualFilter.test.js.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1 2 | 3 | exports[`end-to-end test the component 1`] = ` 4 | [ 5 | { 6 | "data": [ 7 | { 8 | "name": "First Name", 9 | "type": "nominal", 10 | "values": [ 11 | "Foo", 12 | ], 13 | }, 14 | { 15 | "name": "Last Name", 16 | "type": "nominal", 17 | "values": [ 18 | "Bar", 19 | ], 20 | }, 21 | { 22 | "name": "Age", 23 | "type": "numeric", 24 | "values": [ 25 | 18, 26 | ], 27 | }, 28 | ], 29 | "filter": { 30 | "filters": [ 31 | { 32 | "argument": "Foo", 33 | "dataType": "nominal", 34 | "fieldName": "First Name", 35 | "method": "contains", 36 | "type": "condition", 37 | }, 38 | ], 39 | "groupType": "and", 40 | "type": "group", 41 | }, 42 | }, 43 | ] 44 | `; 45 | 46 | exports[`end-to-end test the component 2`] = ` 47 | [ 48 | { 49 | "data": [ 50 | { 51 | "name": "First Name", 52 | "type": "nominal", 53 | "values": [ 54 | "Foo", 55 | "Fizz", 56 | ], 57 | }, 58 | { 59 | "name": "Last Name", 60 | "type": "nominal", 61 | "values": [ 62 | "Bar", 63 | "Buzz", 64 | ], 65 | }, 66 | { 67 | "name": "Age", 68 | "type": "numeric", 69 | "values": [ 70 | 18, 71 | 19, 72 | ], 73 | }, 74 | ], 75 | "filter": { 76 | "filters": [ 77 | { 78 | "argument": "Foo", 79 | "dataType": "nominal", 80 | "fieldName": "First Name", 81 | "method": "contains", 82 | "type": "condition", 83 | }, 84 | { 85 | "filters": [ 86 | { 87 | "argument": "Buz", 88 | "dataType": "nominal", 89 | "fieldName": "Last Name", 90 | "method": "contains", 91 | "type": "condition", 92 | }, 93 | ], 94 | "groupType": "and", 95 | "type": "group", 96 | }, 97 | ], 98 | "groupType": "or", 99 | "type": "group", 100 | }, 101 | }, 102 | ] 103 | `; 104 | 105 | exports[`end-to-end test the component 3`] = ` 106 | [ 107 | { 108 | "data": [ 109 | { 110 | "name": "First Name", 111 | "type": "nominal", 112 | "values": [ 113 | "Foo", 114 | "Fizz", 115 | ], 116 | }, 117 | { 118 | "name": "Last Name", 119 | "type": "nominal", 120 | "values": [ 121 | "Bar", 122 | "Buzz", 123 | ], 124 | }, 125 | { 126 | "name": "Age", 127 | "type": "numeric", 128 | "values": [ 129 | 18, 130 | 19, 131 | ], 132 | }, 133 | ], 134 | "filter": { 135 | "filters": [ 136 | { 137 | "argument": "Foo", 138 | "dataType": "nominal", 139 | "fieldName": "First Name", 140 | "method": "contains", 141 | "type": "condition", 142 | }, 143 | { 144 | "filters": [ 145 | { 146 | "argument": "Buz", 147 | "dataType": "nominal", 148 | "fieldName": "Last Name", 149 | "method": "contains", 150 | "type": "condition", 151 | }, 152 | { 153 | "argument": "18", 154 | "dataType": "numeric", 155 | "fieldName": "Age", 156 | "method": ">", 157 | "type": "condition", 158 | }, 159 | ], 160 | "groupType": "and", 161 | "type": "group", 162 | }, 163 | ], 164 | "groupType": "or", 165 | "type": "group", 166 | }, 167 | }, 168 | ] 169 | `; 170 | 171 | exports[`end-to-end test the component 4`] = ` 172 | [ 173 | { 174 | "data": [ 175 | { 176 | "name": "First Name", 177 | "type": "nominal", 178 | "values": [ 179 | "Foo", 180 | ], 181 | }, 182 | { 183 | "name": "Last Name", 184 | "type": "nominal", 185 | "values": [ 186 | "Bar", 187 | ], 188 | }, 189 | { 190 | "name": "Age", 191 | "type": "numeric", 192 | "values": [ 193 | 18, 194 | ], 195 | }, 196 | ], 197 | "filter": { 198 | "filters": [ 199 | { 200 | "argument": "Foo", 201 | "dataType": "nominal", 202 | "fieldName": "First Name", 203 | "method": "contains", 204 | "type": "condition", 205 | }, 206 | ], 207 | "groupType": "or", 208 | "type": "group", 209 | }, 210 | }, 211 | ] 212 | `; 213 | 214 | exports[`end-to-end test the component 5`] = ` 215 | [ 216 | { 217 | "data": [ 218 | { 219 | "name": "First Name", 220 | "type": "nominal", 221 | "values": [ 222 | "Foo", 223 | "Fizz", 224 | ], 225 | }, 226 | { 227 | "name": "Last Name", 228 | "type": "nominal", 229 | "values": [ 230 | "Bar", 231 | "Buzz", 232 | ], 233 | }, 234 | { 235 | "name": "Age", 236 | "type": "numeric", 237 | "values": [ 238 | 18, 239 | 19, 240 | ], 241 | }, 242 | ], 243 | "filter": { 244 | "filters": [], 245 | "groupType": "and", 246 | "type": "group", 247 | }, 248 | }, 249 | ] 250 | `; 251 | 252 | exports[`expect \`onFilterUpdate\` event to be called with correct parameters when changing group type select 1`] = ` 253 | [ 254 | [ 255 | { 256 | "data": [ 257 | { 258 | "name": "First Name", 259 | "type": "nominal", 260 | "values": [ 261 | "Foo", 262 | "Fizz", 263 | ], 264 | }, 265 | { 266 | "name": "Last Name", 267 | "type": "nominal", 268 | "values": [ 269 | "Bar", 270 | "Buzz", 271 | ], 272 | }, 273 | { 274 | "name": "Age", 275 | "type": "numeric", 276 | "values": [ 277 | 18, 278 | 19, 279 | ], 280 | }, 281 | ], 282 | "filter": { 283 | "filters": [], 284 | "groupType": "not and", 285 | "type": "group", 286 | }, 287 | }, 288 | ], 289 | ] 290 | `; 291 | 292 | exports[`expect \`onFilterUpdate\` event to be called with correct parameters when changing group type select 2`] = ` 293 | [ 294 | [ 295 | { 296 | "data": [ 297 | { 298 | "name": "First Name", 299 | "type": "nominal", 300 | "values": [ 301 | "Foo", 302 | "Fizz", 303 | ], 304 | }, 305 | { 306 | "name": "Last Name", 307 | "type": "nominal", 308 | "values": [ 309 | "Bar", 310 | "Buzz", 311 | ], 312 | }, 313 | { 314 | "name": "Age", 315 | "type": "numeric", 316 | "values": [ 317 | 18, 318 | 19, 319 | ], 320 | }, 321 | ], 322 | "filter": { 323 | "filters": [], 324 | "groupType": "not and", 325 | "type": "group", 326 | }, 327 | }, 328 | ], 329 | [ 330 | { 331 | "data": [ 332 | { 333 | "name": "First Name", 334 | "type": "nominal", 335 | "values": [], 336 | }, 337 | { 338 | "name": "Last Name", 339 | "type": "nominal", 340 | "values": [], 341 | }, 342 | { 343 | "name": "Age", 344 | "type": "numeric", 345 | "values": [], 346 | }, 347 | ], 348 | "filter": { 349 | "filters": [], 350 | "groupType": "or", 351 | "type": "group", 352 | }, 353 | }, 354 | ], 355 | ] 356 | `; 357 | 358 | exports[`expect \`onFilterUpdate\` event to be called with correct parameters when changing group type select 3`] = ` 359 | [ 360 | [ 361 | { 362 | "data": [ 363 | { 364 | "name": "First Name", 365 | "type": "nominal", 366 | "values": [ 367 | "Foo", 368 | "Fizz", 369 | ], 370 | }, 371 | { 372 | "name": "Last Name", 373 | "type": "nominal", 374 | "values": [ 375 | "Bar", 376 | "Buzz", 377 | ], 378 | }, 379 | { 380 | "name": "Age", 381 | "type": "numeric", 382 | "values": [ 383 | 18, 384 | 19, 385 | ], 386 | }, 387 | ], 388 | "filter": { 389 | "filters": [], 390 | "groupType": "not and", 391 | "type": "group", 392 | }, 393 | }, 394 | ], 395 | [ 396 | { 397 | "data": [ 398 | { 399 | "name": "First Name", 400 | "type": "nominal", 401 | "values": [], 402 | }, 403 | { 404 | "name": "Last Name", 405 | "type": "nominal", 406 | "values": [], 407 | }, 408 | { 409 | "name": "Age", 410 | "type": "numeric", 411 | "values": [], 412 | }, 413 | ], 414 | "filter": { 415 | "filters": [], 416 | "groupType": "or", 417 | "type": "group", 418 | }, 419 | }, 420 | ], 421 | [ 422 | { 423 | "data": [ 424 | { 425 | "name": "First Name", 426 | "type": "nominal", 427 | "values": [], 428 | }, 429 | { 430 | "name": "Last Name", 431 | "type": "nominal", 432 | "values": [], 433 | }, 434 | { 435 | "name": "Age", 436 | "type": "numeric", 437 | "values": [], 438 | }, 439 | ], 440 | "filter": { 441 | "filters": [], 442 | "groupType": "not or", 443 | "type": "group", 444 | }, 445 | }, 446 | ], 447 | ] 448 | `; 449 | 450 | exports[`filter of type condition renders as expected on initial render after selecting from group type select, and callback is called with correct parameters 1`] = ` 451 | [ 452 | [ 453 | { 454 | "data": [ 455 | { 456 | "name": "First Name", 457 | "type": "nominal", 458 | "values": [ 459 | "Foo", 460 | ], 461 | }, 462 | { 463 | "name": "Last Name", 464 | "type": "nominal", 465 | "values": [ 466 | "Bar", 467 | ], 468 | }, 469 | { 470 | "name": "Age", 471 | "type": "numeric", 472 | "values": [ 473 | 18, 474 | ], 475 | }, 476 | ], 477 | "filter": { 478 | "filters": [ 479 | { 480 | "argument": "Foo", 481 | "dataType": "nominal", 482 | "fieldName": "First Name", 483 | "method": "contains", 484 | "type": "condition", 485 | }, 486 | ], 487 | "groupType": "and", 488 | "type": "group", 489 | }, 490 | }, 491 | ], 492 | ] 493 | `; 494 | -------------------------------------------------------------------------------- /packages/vue3/vite.config.js: -------------------------------------------------------------------------------- 1 | const { defineConfig } = require("vitest/config") 2 | const vue = require("@vitejs/plugin-vue") 3 | 4 | module.exports = defineConfig({ 5 | ...require("../../vite.config.js"), 6 | plugins: [vue()], 7 | test: { 8 | globals: true, 9 | environment: "jsdom", 10 | }, 11 | }) 12 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path") 2 | 3 | module.exports = { 4 | plugins: [ 5 | require("tailwindcss")({ 6 | config: path.resolve("../../tailwind.config.js"), 7 | }), 8 | require("autoprefixer"), 9 | ], 10 | } 11 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | const ignoreImport = require("rollup-plugin-ignore-import") 2 | const { babel } = require("@rollup/plugin-babel") 3 | const { terser } = require("rollup-plugin-terser") 4 | const { nodeResolve } = require("@rollup/plugin-node-resolve") 5 | const commonjs = require("@rollup/plugin-commonjs") 6 | const postcss = require("rollup-plugin-postcss") 7 | 8 | const sharedConfig = { 9 | input: "src/main.js", 10 | external: ["@visual-filter/common", "@visual-filter/applyer"], 11 | } 12 | const buildsConfig = [] 13 | 14 | if (process.env.package.startsWith("vue")) { 15 | sharedConfig.external.push("vue") 16 | 17 | if (process.env.package.endsWith("2")) { 18 | Object.assign(sharedConfig, { 19 | plugins: [ 20 | require("rollup-plugin-vue2")({ 21 | template: { isProduction: true }, 22 | }), 23 | ], 24 | }) 25 | } else { 26 | Object.assign(sharedConfig, { plugins: [require("rollup-plugin-vue3")()] }) 27 | } 28 | } 29 | 30 | sharedConfig.plugins.push( 31 | ignoreImport({ 32 | extensions: [".css"], 33 | }), 34 | babel({ 35 | babelHelpers: "bundled", 36 | extensions: [".js", ".vue", ".ts", ".tsx"], 37 | configFile: "../../babel.config.js", 38 | }), 39 | terser(), 40 | ) 41 | 42 | buildsConfig.push( 43 | { 44 | ...sharedConfig, 45 | output: { 46 | file: "dist/component.esm.js", 47 | format: "esm", 48 | }, 49 | }, 50 | { 51 | ...sharedConfig, 52 | output: { 53 | file: "dist/component.cjs.js", 54 | format: "cjs", 55 | exports: "auto", 56 | }, 57 | }, 58 | { 59 | ...sharedConfig, 60 | external: "vue", 61 | plugins: [...sharedConfig.plugins, nodeResolve(), commonjs()], 62 | output: { 63 | file: "dist/component.min.js", 64 | format: "iife", 65 | name: "VueVisualFilter", 66 | globals: { vue: "Vue" }, 67 | }, 68 | }, 69 | { 70 | input: "src/index.css", 71 | output: { 72 | file: "dist/styles.css", 73 | }, 74 | plugins: [ 75 | postcss({ 76 | extract: true, 77 | minimize: true, 78 | }), 79 | ], 80 | }, 81 | ) 82 | 83 | module.exports = buildsConfig 84 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: ["src/**/*.{vue,tsx}"], 3 | } 4 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "./", 4 | "outDir": "dist", 5 | "target": "ES5", 6 | "lib": ["ESNext", "DOM"], 7 | "module": "CommonJS", 8 | "esModuleInterop": true, 9 | "declaration": true, 10 | "sourceMap": true, 11 | "strict": true 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.build.json", 3 | "compilerOptions": { 4 | "target": "ES6", 5 | "paths": { 6 | "@visual-filter/*": ["packages/*/src"] 7 | } 8 | }, 9 | "exclude": ["packages/vue2/src", "packages/vue2/dev", "packages/vue3"] 10 | } 11 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | const { defineConfig } = require("vite") 2 | 3 | const path = require("path") 4 | 5 | module.exports = defineConfig({ 6 | resolve: { 7 | alias: { "@": path.resolve("src") }, 8 | }, 9 | optimizeDeps: { 10 | include: ["@visual-filter/common", "@visual-filter/applyer"], 11 | }, 12 | }) 13 | --------------------------------------------------------------------------------