diff --git a/package-lock.json b/package-lock.json index 982da3e..6569644 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "devDependencies": { "@types/jest": "^29.5.8", "@vitejs/plugin-vue": "^4.2.3", - "@vitest/coverage-istanbul": "^1.0.4", + "@vitest/coverage-istanbul": "^1.2.2", "@vue/test-utils": "^2.4.1", "feather-icons": "^4.29.1", "jsdom": "^22.1.0", @@ -1277,9 +1277,9 @@ } }, "node_modules/@vitest/coverage-istanbul": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@vitest/coverage-istanbul/-/coverage-istanbul-1.0.4.tgz", - "integrity": "sha512-6qoSzTR+sanwY/dREqu6OFJupo/mHzCcboh03rLwqH2V2B39505lDr9FpqaLwU1vQgeUKNA+CdHPkpNpusxkDw==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@vitest/coverage-istanbul/-/coverage-istanbul-1.2.2.tgz", + "integrity": "sha512-tJybwO8JT4H9ANz0T0/tJ1M5g3BkuHKYF1w5YO3z9sAiHBdGANrxN9c5lomJx1WSnLzCxQR5xxlJ4TLKbzrR3w==", "dev": true, "dependencies": { "debug": "^4.3.4", @@ -1288,7 +1288,7 @@ "istanbul-lib-report": "^3.0.1", "istanbul-lib-source-maps": "^4.0.1", "istanbul-reports": "^3.1.6", - "magicast": "^0.3.2", + "magicast": "^0.3.3", "picocolors": "^1.0.0", "test-exclude": "^6.0.0" }, @@ -2745,13 +2745,13 @@ } }, "node_modules/magicast": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.2.tgz", - "integrity": "sha512-Fjwkl6a0syt9TFN0JSYpOybxiMCkYNEeOTnOTNRbjphirLakznZXAqrXgj/7GG3D1dvETONNwrBfinvAbpunDg==", + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.3.tgz", + "integrity": "sha512-ZbrP1Qxnpoes8sz47AM0z08U+jW6TyRgZzcWy3Ma3vDhJttwMwAFDMMQFobwdBxByBD46JYmxRzeF7w2+wJEuw==", "dev": true, "dependencies": { - "@babel/parser": "^7.23.3", - "@babel/types": "^7.23.3", + "@babel/parser": "^7.23.6", + "@babel/types": "^7.23.6", "source-map-js": "^1.0.2" } }, diff --git a/package.json b/package.json index 432bab1..cfaea29 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "devDependencies": { "@types/jest": "^29.5.8", "@vitejs/plugin-vue": "^4.2.3", - "@vitest/coverage-istanbul": "^1.0.4", + "@vitest/coverage-istanbul": "^1.2.2", "@vue/test-utils": "^2.4.1", "feather-icons": "^4.29.1", "jsdom": "^22.1.0", diff --git a/src/components/BaseSelect.vue b/src/components/BaseSelect.vue new file mode 100644 index 0000000..0eae287 --- /dev/null +++ b/src/components/BaseSelect.vue @@ -0,0 +1,123 @@ + + + + + \ No newline at end of file diff --git a/src/mixins/useBaseSelect.ts b/src/mixins/useBaseSelect.ts new file mode 100644 index 0000000..9f8114e --- /dev/null +++ b/src/mixins/useBaseSelect.ts @@ -0,0 +1,21 @@ +import { ref } from "vue"; +import { FlatOption, Option } from "../types"; +import { collapseOptions, expandOptions, getFlattenedOptions } from "../utils.ts"; + +export default (options: Option[]) => { + const flatOptions = ref(getFlattenedOptions(options)); + + const expand = (optionPath: string[]) => { + expandOptions(flatOptions.value, optionPath); + }; + + const collapse = (optionPath: string[]) => { + collapseOptions(flatOptions.value, optionPath); + }; + + return { + flatOptions, + expand, + collapse + } +} \ No newline at end of file diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..89e8e80 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,73 @@ +import { FlatOption, Option } from "./types"; + +export const getFlattenedOptions = (options: Option[]) => { + const flatOptions: FlatOption[] = []; + flattenOptions(flatOptions, options) + return flatOptions; +}; + +export const flattenOptions = (array: FlatOption[], options: Option[], path: string[] = [], show: boolean = true) => { + options.forEach(op => { + const hasChildren = !!(op.hasOwnProperty("children") && op.children && op.children.length > 0); + const newPath = [...path, op.id]; + + const baseOption = { + id: op.id, + label: op.label, + path: newPath, + show + }; + + if (hasChildren) { + array.push({...baseOption, hasChildren, open: false}); + flattenOptions(array, op.children!, newPath, false); + } else { + array.push({...baseOption, hasChildren}); + } + }); +}; + +const arraysAreEqual = (array1: string[], array2: string[]) => { + return JSON.stringify(array1) === JSON.stringify(array2); +}; + +export const expandOptions = (array: FlatOption[], optionPath: string[]) => { + const openOptionsPaths: string[][] = [optionPath]; + let index = array.findIndex(op => arraysAreEqual(op.path, optionPath)); + + const currentOption = array[index]; + if (currentOption.hasChildren) { + currentOption.open = true; + } + index++; + + while (index < array.length && array[index].path.length > optionPath.length) { + const currentOption = array[index]; + const show = openOptionsPaths.some(openOpPath => { + return openOpPath.length === array[index].path.length - 1 && + arraysAreEqual(openOpPath, array[index].path.slice(0, -1)); + }); + currentOption.show = show; + + if (currentOption.hasChildren && currentOption.open) { + openOptionsPaths.push(currentOption.path); + } + + index++; + } +}; + +export const collapseOptions = (array: FlatOption[], optionPath: string[]) => { + let index = array.findIndex(op => arraysAreEqual(op.path, optionPath)); + + const currentOption = array[index]; + if (currentOption.hasChildren) { + currentOption.open = false; + } + index++; + + while (index < array.length && array[index].path.length > optionPath.length) { + array[index].show = false; + index++; + }; +}; diff --git a/tests/components/baseSelect.spec.ts b/tests/components/baseSelect.spec.ts new file mode 100644 index 0000000..e110466 --- /dev/null +++ b/tests/components/baseSelect.spec.ts @@ -0,0 +1,114 @@ +import { mount } from "@vue/test-utils"; +import BaseSelect from "../../src/components/BaseSelect.vue"; +import { CDropdown, CDropdownItem, CDropdownMenu, CDropdownToggle } from "@coreui/vue"; +import DropdownItem from "../../src/components/DropdownItem.vue"; + +const options = [ + { + id: "id1", + label: "parent1", + children: [ + { + id: "id1_1", + label: "child1" + } + ] + }, + { + id: "id2", + label: "parent2" + } +]; + +const flatOptions = [ + { + id: 'id1', + label: 'parent1', + path: ['id1'], + show: true, + hasChildren: true, + open: false + }, + { + id: 'id1_1', + label: 'child1', + path: ['id1', 'id1_1'], + show: false, + hasChildren: false + }, + { + id: 'id2', + label: 'parent2', + path: ['id2'], + show: true, + hasChildren: false + }, +]; + +describe("base select tests", () => { + const getWrapper = (emptyOptions: boolean = false) => { + return mount(BaseSelect, { + props: { + options: emptyOptions ? undefined : options + }, + slots: { + default: '
Random
' + } + }); + }; + + test("renders as expected for single select", () => { + const wrapper = getWrapper(); + + const cDropdown = wrapper.findComponent(CDropdown); + expect(cDropdown.props("autoClose")).toBe("outside"); + expect(cDropdown.props("popper")).toBe(false); + expect(cDropdown.classes()).toContain("vnm-dropdown"); + + const cDropdownToggle = wrapper.findComponent(CDropdownToggle); + expect(cDropdownToggle.find("#slot").text()).toBe("Random"); + + const cDropdownMenu = wrapper.findComponent(CDropdownMenu); + expect(cDropdownMenu.exists()).toBe(true); + expect(cDropdownMenu.classes()).toContain("vnm-menu"); + + const cDropdownItems = wrapper.findAllComponents(CDropdownItem); + // middle item is a child so not visible right now + expect(cDropdownItems.every((item, index) => index === 1 ? !item.isVisible() : item.isVisible())) + .toBe(true); + expect(cDropdownItems.every(item => expect(item.classes()).toContain("vnm-item"))).toBe(true); + + const dropdownItem = wrapper.findAllComponents(DropdownItem); + dropdownItem.forEach((item, index) => { + expect(item.props("option")).toStrictEqual(flatOptions[index]); + expect(item.props("checked")).toBe(undefined); + }); + }); + + test("hide event in cDropdown emits hide", () => { + const wrapper = getWrapper(); + const cDropdown = wrapper.findComponent(CDropdown); + cDropdown.vm.$emit("hide"); + expect(wrapper.emitted("hide")![0]).toStrictEqual([]); + }); + + test("click event in cDropdownToggle emits toggle-click", () => { + const wrapper = getWrapper(); + const cDropdownToggle = wrapper.findComponent(CDropdownToggle); + cDropdownToggle.vm.$emit("click"); + expect(wrapper.emitted("toggle-click")![0]).toStrictEqual([]); + }); + + test("select item emit works as expected", () => { + const wrapper = getWrapper(); + const dropdownItem1 = wrapper.findAllComponents(DropdownItem)[0]; + dropdownItem1.vm.$emit("select-item", "test"); + expect(wrapper.emitted("select-item")![0][0]).toBe("test"); + }); + + test("empty options render nothing", () => { + const wrapper = getWrapper(true); + const dropdownItems = wrapper.findAllComponents(DropdownItem); + expect(dropdownItems.length).toBe(0); + }); +}); \ No newline at end of file diff --git a/tests/mixins/useBaseSelect/Dummy.vue b/tests/mixins/useBaseSelect/Dummy.vue new file mode 100644 index 0000000..fc40ebb --- /dev/null +++ b/tests/mixins/useBaseSelect/Dummy.vue @@ -0,0 +1,30 @@ + + + \ No newline at end of file diff --git a/tests/mixins/useBaseSelect/useBaseSelect.spec.ts b/tests/mixins/useBaseSelect/useBaseSelect.spec.ts new file mode 100644 index 0000000..4bde8d2 --- /dev/null +++ b/tests/mixins/useBaseSelect/useBaseSelect.spec.ts @@ -0,0 +1,52 @@ +import { shallowMount } from "@vue/test-utils"; +import Dummy from "./Dummy.vue"; + +const expectedFlatOptions = [ + { + id: 'id1', + label: 'parent1', + path: ['id1'], + show: true, + hasChildren: true, + open: false + }, + { + id: 'id1_1', + label: 'child1', + path: ['id1', 'id1_1'], + show: false, + hasChildren: false + }, + { + id: 'id2', + label: 'parent2', + path: ['id2'], + show: true, + hasChildren: false + }, +] + +describe("useBaseSelect mixin tests", () => { + const getWrapper = () => { + return shallowMount(Dummy) + }; + + test("flatOptions works as expected", () => { + const wrapper = getWrapper(); + expect(wrapper.vm.flatOptions).toStrictEqual(expectedFlatOptions); + }); + + test("expand works as expected", () => { + const wrapper = getWrapper(); + wrapper.vm.expand(["id1"]); + expect(wrapper.vm.flatOptions[1].show).toBe(true); + }); + + test("collapse works as expected", () => { + const wrapper = getWrapper(); + wrapper.vm.expand(["id1"]); + expect(wrapper.vm.flatOptions[1].show).toBe(true); + wrapper.vm.collapse(["id1"]); + expect(wrapper.vm.flatOptions[1].show).toBe(false); + }); +}); \ No newline at end of file diff --git a/tests/utils.spec.ts b/tests/utils.spec.ts new file mode 100644 index 0000000..d84fed4 --- /dev/null +++ b/tests/utils.spec.ts @@ -0,0 +1,127 @@ +import { FlatOption } from "../src/types"; +import { collapseOptions, expandOptions, flattenOptions } from "../src/utils"; + +describe("Utils tests", () => { + const dummyOptions = [ + { + id: "id1", + label: "parent1", + children: [ + { + id: "id1_1", + label: "child1", + children: [ + { + id: "id_1_1", + label: "grandchild1" + } + ] + } + ] + }, + { + id: "id2", + label: "parent2" + } + ]; + + const flatOptions = [ + { + id: 'id1', + label: 'parent1', + path: ['id1'], + show: true, + hasChildren: true, + open: false + }, + { + id: 'id1_1', + label: 'child1', + path: ['id1', 'id1_1'], + show: false, + hasChildren: true, + open: false + }, + { + id: 'id_1_1', + label: 'grandchild1', + path: ['id1', 'id1_1', 'id_1_1'], + show: false, + hasChildren: false + }, + { + id: 'id2', + label: 'parent2', + path: ['id2'], + show: true, + hasChildren: false + } + ] as FlatOption[]; + + const flatOptionsExpanded = [ + { + id: 'id1', + label: 'parent1', + path: ['id1'], + show: true, + hasChildren: true, + open: true + }, + { + id: 'id1_1', + label: 'child1', + path: ['id1', 'id1_1'], + show: true, + hasChildren: true, + open: true + }, + { + id: 'id_1_1', + label: 'grandchild1', + path: ['id1', 'id1_1', 'id_1_1'], + show: true, + hasChildren: false + }, + { + id: 'id2', + label: 'parent2', + path: ['id2'], + show: true, + hasChildren: false + } + ] as FlatOption[]; + + it("flattens options", () => { + const array: FlatOption[] = []; + flattenOptions(array, dummyOptions); + expect(array).toStrictEqual(flatOptions); + }); + + it("expands options", () => { + // need to create a deep copy since we are mutating elements + const array: FlatOption[] = JSON.parse(JSON.stringify(flatOptions)); + expandOptions(array, ["id1", "id1_1"]); + const openedOption = array.find(op => op.id === "id_1_1"); + expect(openedOption?.show).toBe(true); + }); + + it("expands children's options if they have been previous opened", () => { + const array: FlatOption[] = JSON.parse(JSON.stringify(flatOptions)); + (array[1] as any).open = true; + expandOptions(array, ["id1"]); + const openedOption = array.find(op => op.id === "id_1_1"); + expect(openedOption?.show).toBe(true); + }); + + it("collapses options", () => { + const array: FlatOption[] = JSON.parse(JSON.stringify(flatOptionsExpanded)); + collapseOptions(array, ["id1"]); + array.forEach(op => { + if (op.id === "id1" || op.id === "id2") { + expect(op.show).toBe(true); + } else { + expect(op.show).toBe(false); + } + }); + }); +}); \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index c0456f0..2cb0ea6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,7 +22,7 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, - "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue", "demo/main.ts"], + "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue", "demo/main.ts", "tests/**/*.ts", "tests/**/*.vue"], "references": [{ "path": "./tsconfig.node.json" }], "exclude": ["demo/App.vue"] } diff --git a/vite.config.ts b/vite.config.ts index 07e1a70..4e23568 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -10,7 +10,7 @@ export default defineConfig({ css: true, coverage: { provider: "istanbul", - include: ["src/components"], + include: ["src"] } }, build: {