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