├── .eslintrc.js ├── .gitignore ├── Examples └── simple-tree-view.vue ├── LICENSE ├── README.md ├── babel.config.js ├── jest.config.js ├── package-lock.json ├── package.json ├── public ├── favicon.ico └── index.html ├── src ├── App.vue ├── assets │ ├── excel.svg │ ├── folder.svg │ ├── logo.png │ ├── playlist.svg │ └── word.svg ├── businessLogic │ ├── contextMenu │ │ ├── contextMenu.spec.ts │ │ └── contextMenu.ts │ ├── contracts │ │ └── types.ts │ ├── eventHub │ │ ├── explorerEventPublisher.spec.ts │ │ └── explorerEventPublisher.ts │ ├── hierachyTraversal │ │ ├── hierachyTraversal.spec.ts │ │ └── hierachyTraversal.ts │ ├── itemCustomisations │ │ ├── itemCustomisations.spec.ts │ │ └── itemCustomisations.ts │ └── treviewViewModel │ │ ├── treeViewViewModel.spec.ts │ │ └── treeViewViewModel.ts ├── components │ ├── treeView.vue │ │ ├── treeView.spec.ts │ │ └── treeView.vue │ └── treeViewItem │ │ ├── treeViewItemView.vue │ │ └── treeviewItemView.scss ├── constants.ts ├── main.ts ├── plugins │ └── TreeViewPlugin.ts ├── shims-tsx.d.ts ├── shims-vue.d.ts └── styles │ └── style.css └── tsconfig.json /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true 5 | }, 6 | 'extends': [ 7 | 'plugin:vue/essential', 8 | 'eslint:recommended', 9 | '@vue/typescript/recommended' 10 | ], 11 | parserOptions: { 12 | ecmaVersion: 2020 13 | }, 14 | rules: { 15 | '@typescript-eslint/no-non-null-assertion': 'off', 16 | 'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off', 17 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off' 18 | }, 19 | overrides: [ 20 | { 21 | files: [ 22 | '**/__tests__/*.{j,t}s?(x)', 23 | '**/tests/unit/**/*.spec.{j,t}s?(x)' 24 | ], 25 | env: { 26 | jest: true 27 | } 28 | } 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | 6 | # local env files 7 | .env.local 8 | .env.*.local 9 | 10 | # Log files 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | pnpm-debug.log* 15 | 16 | # Editor directories and files 17 | .idea 18 | .vscode 19 | *.suo 20 | *.ntvs* 21 | *.njsproj 22 | *.sln 23 | *.sw? 24 | -------------------------------------------------------------------------------- /Examples/simple-tree-view.vue: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geekhybrid/tree-vue/2ffec7c882eb86ad14c36144ec706b8687350449/Examples/simple-tree-view.vue -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Francis Enyi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tree-Vue 2 | 3 | A light-weight library for management of hierachical content. Most solutions I found did not offer the depth of flexibility I needed with the tree. I decided to solve my problem and also give back to the Vue community. Feel free to log issues, I will jump on them at the slightest opportunity. 😊 4 | 5 | ### How to install. 6 | npm: `npm i v-tree-vue` 7 | 8 | Vue 3 Project: https://github.com/geekhybrid/vue3-tree-vue 9 | 10 | Vue 3 npm package: `npm i vue3-tree-vue` 11 | 12 | 13 | ## Features 14 | 15 | 1. :heavy_check_mark: Hierachical rendering of content. 16 | 2. ✔️ Subscribing to items checked event (based on type) 17 | 3. :heavy_check_mark: Moving Items between folders (drag-and-drop) 18 | 4. :heavy_check_mark: Customising Item Rendering based on item type 19 | 5. ✔️ Rendering selectable items like checkboxes or plain content 20 | 6. ✔️ Double clicking to rename item 21 | 22 | ## Features in Development 23 | 1. Programmatically toggle item visibility based on the `type` property. 24 | 2. Sorting items alphametically or grouping based on types 25 | 3. Disabling and Enabling Item(s) 26 | 4. Programmatically determining what item can be dragged into another item. 27 | 5. -Custom Context Menu depending on item type. 28 | 29 | 30 |  31 | 32 | ### Basic Component Rendering 33 | ``` html 34 | 35 | 36 | 37 | ``` 38 | 39 | ```ts 40 | 147 | ``` 148 | 149 | #### Output 150 | 151 |  152 | 153 | ### Listening to Items Checked 154 | 155 | To carter for advanced cases where `children` of the hierachical tree may be of different types. And you want to perform some further actions whenever something happens to them. You can subscribe for checked events of item types you may be interested in. And perform further actions. 156 | 157 | ### Use case 158 | E.g A school has departments, and you want to check some departments and delete them. 159 | 160 | ### Solution 161 | You can attach callbacks that notify you when departments have been checked on the tree. 162 | 163 | ### How to Use 164 | 165 | ```html 166 | 167 | 168 | 169 | 170 | ``` 171 | ```ts 172 | -------------------------------------------------------------------------------- /src/assets/excel.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 7 | 8 | 10 | 12 | 14 | 16 | 18 | 20 | 22 | 24 | 25 | 28 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /src/assets/folder.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geekhybrid/tree-vue/2ffec7c882eb86ad14c36144ec706b8687350449/src/assets/logo.png -------------------------------------------------------------------------------- /src/assets/playlist.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 11 | 12 | 13 | 14 | 15 | 17 | 18 | 19 | 20 | 21 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /src/assets/word.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 10 | 13 | 14 | 20 | 21 | 23 | 25 | 27 | 29 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /src/businessLogic/contextMenu/contextMenu.spec.ts: -------------------------------------------------------------------------------- 1 | import { contextMenuConfig, ContextMenuConfiguration } from "./contextMenu"; 2 | 3 | describe("ContextMenu", () => { 4 | let configuration: ContextMenuConfiguration | undefined = undefined; 5 | beforeEach(() => { 6 | configuration = JSON.parse(JSON.stringify(contextMenuConfig)); 7 | }); 8 | 9 | it("registerMenuItems() adds context menu items for item type", () => { 10 | const expectedLabel = "Create Schematics"; 11 | const iconPath = "@/foo/bars.png"; 12 | const isDisabled = true; 13 | const expectedCommand = () => new Promise((accept) => {}); 14 | 15 | configuration?.registerMenuItems('docs', [{ 16 | label: expectedLabel, 17 | icon: iconPath, 18 | isDisabled: isDisabled, 19 | command: expectedCommand 20 | }]); 21 | 22 | const commandItem = configuration?.getMenuItems('docs')[0]; 23 | expect(commandItem?.label).toBe(expectedLabel); 24 | expect(commandItem?.icon).toBe(iconPath); 25 | expect(commandItem?.isDisabled).toBe(isDisabled); 26 | expect(commandItem?.command).toBe(expectedCommand); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /src/businessLogic/contextMenu/contextMenu.ts: -------------------------------------------------------------------------------- 1 | import { TreeViewItem } from "@/businessLogic/contracts/types"; 2 | 3 | const contextMenuLookUp: {[type: string]: MenuItem[] } = {} 4 | 5 | export interface MenuItem { 6 | label: string, 7 | icon?: string, 8 | isDisabled?: boolean; 9 | command: (item: TreeViewItem) => Promise; 10 | } 11 | 12 | export interface ContextMenuConfiguration { 13 | registerMenuItems(type: string, menuItems: MenuItem[]): void; 14 | getMenuItems(type: string): MenuItem[]; 15 | } 16 | 17 | export const contextMenuConfig : ContextMenuConfiguration = { 18 | registerMenuItems(type: string, menuItems: MenuItem[]) { 19 | contextMenuLookUp[type.toString()] = menuItems; 20 | }, 21 | 22 | getMenuItems(type: string): MenuItem[] { 23 | return contextMenuLookUp[type.toString()]; 24 | } 25 | } -------------------------------------------------------------------------------- /src/businessLogic/contracts/types.ts: -------------------------------------------------------------------------------- 1 | export interface TreeViewItem { 2 | children?: TreeViewItem[] 3 | type: string 4 | checkedStatus?: CheckedState, 5 | name: string, 6 | id: string, 7 | parentId?: string 8 | } 9 | 10 | export interface ItemTypeCustomisations { 11 | isDropValid(droppedNode: TreeViewItem, dropHost: TreeViewItem): boolean; 12 | makeItemsCheckable(types: string[]): void; 13 | registerItemDeletedHandler(type: string, callback: (item: TreeViewItem) => Promise): void; 14 | registerItemRenamedHandler(type: string, callback: (renamedItem: TreeViewItem) => Promise): void; 15 | registerDragAndDropValidator(canItemMoveCallBack: (movingItem: TreeViewItem, destinationItem: TreeViewItem) => boolean): void; 16 | registerItemMovedHandler(callBack: (movedItem: TreeViewItem) => Promise): void; 17 | 18 | registerAnyItemDeleted(callback: (item: TreeViewItem) => Promise): void; 19 | registerAnyItemRenamed(callback: (item: TreeViewItem) => Promise): void; 20 | registerAnyItemDragAndDrop(): void; 21 | 22 | disableDragAndDrop(): void; 23 | getCustomisation(type: string): Customisations; 24 | getRenameHandler(type: string): (item: TreeViewItem) => Promise; 25 | } 26 | 27 | export interface Customisations { 28 | canRename?: boolean; 29 | isCheckable?: boolean; 30 | } 31 | 32 | export interface EditableItem { 33 | begin(): void; 34 | end(): void; 35 | } 36 | 37 | export interface RenameItemStartedEventArgs { 38 | item: EditableItem; 39 | } 40 | 41 | export interface TreeViewCreatedEventPayload { 42 | itemCustomisations: ItemTypeCustomisations; 43 | eventManager: EventManager; 44 | } 45 | 46 | export interface EventManager { 47 | subscribeToItemChecked(type: string, callback: (item: TreeViewItem[]) => void): void; 48 | subscribeToItemUnchecked(type: string, callback: (item: TreeViewItem[]) => void): void; 49 | } 50 | 51 | export interface EventHub { 52 | onItemChecked(item: TreeViewItem): void; 53 | onItemUnChecked(item: TreeViewItem): void; 54 | onFolderChecked(folder: TreeViewItem): void 55 | onFolderUnChecked(folder: TreeViewItem): void; 56 | setSelectionMode(mode: string): void; 57 | readonly selectedItems: TreeViewItem[]; 58 | } 59 | 60 | export interface TreeViewViewModel { 61 | loadNodes(nodes: TreeViewItem[]): void; 62 | getNodes(): { [id: string]: TreeViewItem }; 63 | removeTreeViewItem(id: string): boolean; 64 | removeFromParentNode(itemToRemove: TreeViewItem): void; 65 | removeChildNodes(node: TreeViewItem): void; 66 | addTreeViewItem(TreeViewItem: TreeViewItem): void; 67 | checkedStatusChanged(item: TreeViewItem): void; 68 | setSelectionMode(mode: SelectionMode): void; 69 | readonly selectedItems: TreeViewItem[]; 70 | } 71 | 72 | export interface ItemCheckedChangedEvent { 73 | item: TreeViewItem, 74 | status: CheckedState 75 | } 76 | 77 | export type CheckedState = 'True' | 'False' | 'Indeterminate'; 78 | export type SelectionMode = 'Single' | 'Multiple'; 79 | -------------------------------------------------------------------------------- /src/businessLogic/eventHub/explorerEventPublisher.spec.ts: -------------------------------------------------------------------------------- 1 | import { TreeViewItem } from "../contracts/types"; 2 | import { eventHub, eventManager } from "./explorerEventPublisher"; 3 | 4 | const itemCheckeckedCallBack = (items: TreeViewItem[]) => {}; 5 | 6 | const callBacks = { 7 | itemCheckeckedCallBack, 8 | } 9 | 10 | 11 | describe("eventPublisher", () => { 12 | it("should notify subscribers of all items checked of a type", () => { 13 | const expectedCheckedItem: TreeViewItem = { 14 | id: '1', 15 | name: 'Test', 16 | type: '.doc', 17 | }; 18 | const subscriberCallBack = jest.spyOn(callBacks, "itemCheckeckedCallBack"); 19 | 20 | eventManager.subscribeToItemChecked('.doc', callBacks.itemCheckeckedCallBack); 21 | eventHub.onItemChecked(expectedCheckedItem) 22 | 23 | expect(subscriberCallBack).toBeCalledWith([expectedCheckedItem]); 24 | }); 25 | 26 | it("onItemChecked() should add item to selectedItems collection", () => { 27 | const expectedSelectedItem: TreeViewItem = { 28 | id: '1', 29 | name: 'Test', 30 | type: 'test' 31 | } 32 | eventHub.onItemChecked(expectedSelectedItem); 33 | expect(eventHub.selectedItems.pop()).toBe(expectedSelectedItem); 34 | }); 35 | 36 | it("onItemUnchecked() should remove item from selectedItems collection", () => { 37 | const expectedSelectedItem: TreeViewItem = { 38 | id: '1', 39 | name: 'Test', 40 | type: 'test' 41 | } 42 | 43 | eventHub.selectedItems.push(expectedSelectedItem); 44 | eventHub.onItemUnChecked(expectedSelectedItem); 45 | 46 | expect(eventHub.selectedItems.indexOf(expectedSelectedItem)).toBe(-1); 47 | }); 48 | }); -------------------------------------------------------------------------------- /src/businessLogic/eventHub/explorerEventPublisher.ts: -------------------------------------------------------------------------------- 1 | import { EventHub, EventManager, SelectionMode, TreeViewItem } from "../contracts/types" 2 | import { findChildrenOfType } from "../hierachyTraversal/hierachyTraversal"; 3 | 4 | let selectedItems: TreeViewItem[] = []; 5 | export const eventManager: EventManager = { 6 | // Add subscriber for item hecked to collection of subscribers 7 | subscribeToItemChecked(type: string, callback: (item: TreeViewItem[]) => void): void { 8 | onCheckedSubscribers[type] = onCheckedSubscribers[type] ?? []; 9 | onCheckedSubscribers[type].push(callback); 10 | }, 11 | 12 | // Add subscriber for item unchecked to collection of subscribers 13 | subscribeToItemUnchecked(type: string, callback: (item: TreeViewItem[]) => void): void { 14 | onUnCheckedSubscribers[type] = onUnCheckedSubscribers[type] ?? []; 15 | onUnCheckedSubscribers[type].push(callback); 16 | }, 17 | } 18 | 19 | export const eventHub: EventHub = { 20 | // Publish events if any subscriber listening for item checked 21 | onItemChecked(item: TreeViewItem): void { 22 | if (selectionMode == 'Single') RemovePreviousSelectedItems(); 23 | 24 | selectedItems.push(item); 25 | 26 | if (item.type == 'folder') { 27 | this.onFolderChecked(item); 28 | } else { 29 | const subscribers = onCheckedSubscribers[item.type.toString()]; 30 | if (subscribers) { 31 | subscribers.forEach(callback => callback([item])); 32 | } 33 | } 34 | }, 35 | 36 | // Publish events if any subscriber listening for item unchecked 37 | onItemUnChecked(item: TreeViewItem): void { 38 | remove(item, selectedItems); 39 | const subscribers = onUnCheckedSubscribers[item.type.toString()]; 40 | if (subscribers) { 41 | subscribers.forEach(callback => callback([item])); 42 | } 43 | }, 44 | 45 | /// Premise: Whenever a folder is checked, it automatically checks all it's decendants. 46 | /// Expected action: When a folder is checked, traverse it's tree to get see if any item has also been checked 47 | // for which a callback listener needs to be called 48 | onFolderChecked(folder: TreeViewItem): void { 49 | if (selectionMode == 'Single') RemovePreviousSelectedItems(); 50 | selectedItems.push(folder); 51 | 52 | if (folder.type != 'folder') return; 53 | 54 | const itemTypesWithListeners = Object.keys(onCheckedSubscribers); 55 | itemTypesWithListeners.forEach(itemType => { 56 | const itemsToPublish = findChildrenOfType(folder, itemType); 57 | onCheckedSubscribers[itemType].forEach(subscriber => subscriber(itemsToPublish)); 58 | }); 59 | }, 60 | 61 | /// Premise: Whenever a folder is checked, it automatically checks all it's decendants. 62 | /// Expected action: When a folder is un-checked, traverse it's tree to get see if any item 63 | // has also been checked for which a listener needs to be called 64 | onFolderUnChecked(folder: TreeViewItem): void { 65 | remove(folder, selectedItems); 66 | if (folder.type != 'folder') return; 67 | 68 | const itemTypesWithListeners = Object.keys(onUnCheckedSubscribers); 69 | itemTypesWithListeners.forEach(itemType => { 70 | const itemsToPublish = findChildrenOfType(folder, itemType); 71 | onUnCheckedSubscribers[itemType].forEach(subscriber => subscriber(itemsToPublish)); 72 | }); 73 | }, 74 | 75 | setSelectionMode(mode: SelectionMode): void { 76 | selectionMode = mode; 77 | }, 78 | 79 | selectedItems 80 | } 81 | 82 | 83 | function RemovePreviousSelectedItems() { 84 | selectedItems.forEach(item => item.checkedStatus = 'False'); 85 | selectedItems = []; 86 | } 87 | 88 | function remove(item: TreeViewItem, collection: TreeViewItem[]) { 89 | const index = collection.indexOf(item); 90 | if (index > -1) collection.splice(index, 1); 91 | } 92 | 93 | const onCheckedSubscribers: {[type: string]: ((items: TreeViewItem[]) => void)[] } = {}; 94 | const onUnCheckedSubscribers: {[type: string]: ((items: TreeViewItem[]) => void)[] } = {}; 95 | 96 | let selectionMode: SelectionMode = 'Multiple'; -------------------------------------------------------------------------------- /src/businessLogic/hierachyTraversal/hierachyTraversal.spec.ts: -------------------------------------------------------------------------------- 1 | import { TreeViewItem } from "../contracts/types" 2 | import { findChildrenOfType, cascadeStateToDescendants, flattenNodes } from "./hierachyTraversal" 3 | 4 | describe("findChildrenOfType()", () => { 5 | it("Should return a collection of n-th children(grand-children) in a folder", () => { 6 | const folder1: TreeViewItem = { 7 | type: 'folder', 8 | id: '1203-390293-1hdklsjdl-903923', 9 | name: "Documents", 10 | children: [ 11 | { 12 | type: 'docs', 13 | name: "Resume", 14 | id: '1203-390293-1hdklsjdl-903923', 15 | } 16 | ] 17 | } 18 | const folder2: TreeViewItem = { 19 | type: 'folder', 20 | name: "FX", 21 | id: '1203-390293-1hdklsjdl-903923', 22 | children: [ 23 | { 24 | type: 'docs', 25 | name: "F-1", 26 | id: '1203-390293-1hdklsjdl-903923', 27 | }, 28 | { 29 | type: 'docs', 30 | name: "F-2", 31 | id: '1203-390293-1hdklsjhdl-903923', 32 | }, 33 | folder1, 34 | ] 35 | } 36 | 37 | const parentFolder: TreeViewItem = { 38 | name: "Nigeria", 39 | children: [folder1, folder2], 40 | type: 'folders', 41 | id: '1203-390293-1hdklsjdl-903923', 42 | } 43 | 44 | const wells = findChildrenOfType(parentFolder, 'docs'); 45 | expect(wells).toHaveLength(4); 46 | }); 47 | 48 | it("cascadeStateToDescendants() Should set all children check state to true", () =>{ 49 | const parentFolder: TreeViewItem = { 50 | type: 'folder', 51 | id: '1203-390293-1hdklsjdl-903923', 52 | name: "Documents", 53 | children: [ 54 | { 55 | type: 'docs', 56 | name: "Resume", 57 | id: '1203-390293-1hdklsjdl-903923', 58 | }, 59 | { 60 | type: 'docs', 61 | id: '1203-390293-1hdklsjdl-903923', 62 | name: "cover-letter" 63 | }, 64 | ] 65 | } 66 | 67 | cascadeStateToDescendants(parentFolder, 'True'); 68 | parentFolder.children?.forEach(childItem => { 69 | expect(childItem.checkedStatus).toBe('True') 70 | }) 71 | 72 | }); 73 | 74 | it("cascadeStateToDescendants() Should set all children check state to false", () =>{ 75 | const parentFolder: TreeViewItem = { 76 | type: 'folder', 77 | name: "Docs", 78 | id: '1203-390293-1hdklsjdl-903923', 79 | children: [ 80 | { 81 | type: 'docs', 82 | name: "cover-letter", 83 | checkedStatus: 'True', 84 | id: '1203-390293-1hdkulsjdl-903923', 85 | }, 86 | { 87 | type: 'docs', 88 | name: "resume", 89 | id: '1203-39v0293-1hdklsjdl-903923', 90 | checkedStatus: 'True' 91 | }, 92 | ] 93 | } 94 | 95 | cascadeStateToDescendants(parentFolder, 'False'); 96 | parentFolder.children?.forEach(childItem => { 97 | expect(childItem.checkedStatus).toBe('False') 98 | }) 99 | 100 | }); 101 | 102 | it("flattenNodes() should return flat array of all nodes", () => { 103 | const ids = ['1203-390293-1hdklsjdl-903923', '1203-390293-1hdkulsjdl-903923', '1203-39v0293-1hdklsjdl-903923'] 104 | const parentFolder: TreeViewItem = { 105 | type: 'folder', 106 | name: "Folder", 107 | id: ids[0], 108 | children: [ 109 | { 110 | type: 'docs', 111 | name: "cover letter", 112 | checkedStatus: 'True', 113 | id: ids[1], 114 | }, 115 | { 116 | type: 'docs', 117 | name: "resume", 118 | id: ids[2], 119 | checkedStatus: 'True' 120 | }, 121 | ] 122 | } 123 | 124 | const flatLookUp = flattenNodes([parentFolder]); 125 | const nodeIds = Object.keys(flatLookUp); 126 | expect(nodeIds).toStrictEqual(ids) 127 | }); 128 | 129 | }); -------------------------------------------------------------------------------- /src/businessLogic/hierachyTraversal/hierachyTraversal.ts: -------------------------------------------------------------------------------- 1 | import { TreeViewItem, CheckedState } from "../contracts/types" 2 | 3 | /// This recursive call is used to traverse a folder looking for all children of a particular type 4 | export const findChildrenOfType = (parent: TreeViewItem, expectedType: string): TreeViewItem[] => { 5 | const children: TreeViewItem[] = []; 6 | if (!parent.children) return children; 7 | 8 | parent.children.forEach(TreeViewItem => { 9 | if (TreeViewItem.type === expectedType) { 10 | children.push(TreeViewItem); 11 | } 12 | 13 | if (TreeViewItem.type === 'folder'){ 14 | children.push(...findChildrenOfType(TreeViewItem, expectedType)); 15 | } 16 | }); 17 | 18 | return children; 19 | } 20 | 21 | export const cascadeStateToDescendants = (item: TreeViewItem, state: CheckedState): void => { 22 | item.children?.forEach(child => { 23 | child.checkedStatus = state; 24 | cascadeStateToDescendants(child, state); 25 | }) 26 | } 27 | 28 | // This returns a single array (flat) of all items in a collection of nodes. 29 | export const flattenNodes = (nodes: TreeViewItem[] | undefined): {[id: string]: TreeViewItem} => { 30 | const flatLookUp: {[id: string]: TreeViewItem} = {}; 31 | if (!nodes) return flatLookUp; 32 | 33 | nodes.forEach(node => { 34 | if (!node.children) { 35 | node.children = []; 36 | } 37 | 38 | flatLookUp[node.id] = node; 39 | Object.assign(flatLookUp, flattenNodes(node.children)); 40 | }) 41 | return flatLookUp; 42 | } -------------------------------------------------------------------------------- /src/businessLogic/itemCustomisations/itemCustomisations.spec.ts: -------------------------------------------------------------------------------- 1 | import { ItemCustomisations } from "./itemCustomisations"; 2 | 3 | describe("ItemCustomisation", () => { 4 | const customisations = ItemCustomisations; 5 | 6 | it("makeItemsCheckable() should make items checkable", () => { 7 | const expectedTypes = [".docs", ".excel"]; 8 | customisations.makeItemsCheckable(expectedTypes); 9 | 10 | [".docs", ".excel"].forEach(type => { 11 | expect(customisations.getCustomisation(type).isCheckable).toBe(true); 12 | }); 13 | }); 14 | }); -------------------------------------------------------------------------------- /src/businessLogic/itemCustomisations/itemCustomisations.ts: -------------------------------------------------------------------------------- 1 | import { ANY_TYPE } from "@/constants"; 2 | import { Customisations, ItemTypeCustomisations, TreeViewItem } from "../contracts/types"; 3 | 4 | const typeCustomisations: {[type: string]: Customisations } = {}; 5 | let canItemDrop: (movingItem: TreeViewItem, destinationItem: TreeViewItem) => boolean; 6 | let isAllowAnyDrop = true; 7 | 8 | let renameHandlers: {[type: string] : (item: TreeViewItem) => Promise } = {} 9 | let deleteHandlers: {[type: string] : (item: TreeViewItem) => Promise } = {} 10 | 11 | export const ItemCustomisations: ItemTypeCustomisations = { 12 | makeItemsCheckable(types: string[]): void { 13 | types.forEach(type => { 14 | if (!typeCustomisations[type]) 15 | typeCustomisations[type] = {} 16 | 17 | typeCustomisations[type].isCheckable = true; 18 | }); 19 | }, 20 | 21 | registerDragAndDropValidator(canItemMoveCallBack: (movingItem: TreeViewItem, destinationItem: TreeViewItem) => boolean): void { 22 | isAllowAnyDrop = false; 23 | canItemDrop = canItemMoveCallBack; 24 | }, 25 | 26 | registerItemDeletedHandler(type: string, callback: (item: TreeViewItem) => Promise): void { 27 | deleteHandlers[type] = callback; 28 | }, 29 | 30 | registerItemRenamedHandler(type: string, callback: (renamedItem: TreeViewItem) => Promise): void { 31 | renameHandlers[type] = callback; 32 | if (typeCustomisations[type]) 33 | { 34 | typeCustomisations[type] 35 | } 36 | else 37 | { 38 | typeCustomisations[type] = { 39 | canRename: true 40 | } 41 | } 42 | }, 43 | 44 | registerItemMovedHandler(callBack: (movedItem: TreeViewItem) => Promise): void { 45 | console.log(callBack); 46 | }, 47 | 48 | registerAnyItemDeleted(callback: (item: TreeViewItem) => Promise): void { 49 | deleteHandlers = {}; 50 | deleteHandlers[ANY_TYPE] = callback; 51 | }, 52 | 53 | registerAnyItemRenamed(callback: (item: TreeViewItem) => Promise): void { 54 | renameHandlers = {}; 55 | renameHandlers[ANY_TYPE] = callback; 56 | }, 57 | 58 | registerAnyItemDragAndDrop(): void { 59 | isAllowAnyDrop = true; 60 | canItemDrop = () => true; 61 | }, 62 | 63 | isDropValid(droppedNode: TreeViewItem, dropHost: TreeViewItem): boolean { 64 | if (!canItemDrop) return false; 65 | return canItemDrop(droppedNode, dropHost); 66 | }, 67 | 68 | disableDragAndDrop(): void { 69 | isAllowAnyDrop = false; 70 | canItemDrop = () => false; 71 | }, 72 | 73 | getCustomisation(type: string): Customisations { 74 | return typeCustomisations[type]; 75 | }, 76 | 77 | getRenameHandler(type: string): (item: TreeViewItem) => Promise { 78 | return renameHandlers[type]; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/businessLogic/treviewViewModel/treeViewViewModel.spec.ts: -------------------------------------------------------------------------------- 1 | import { TreeViewItem } from "../contracts/types"; 2 | import { TreeViewModel } from "./treeViewViewModel"; 3 | 4 | describe('TreeViewModel', () => { 5 | const child1: TreeViewItem = 6 | { 7 | type: "w-file", 8 | name: "Cover Letter", 9 | checkedStatus: 'True', 10 | id: 'child-1', 11 | parentId: 'parent' 12 | }; 13 | 14 | const child2: TreeViewItem = { 15 | type: 'w-file', 16 | name: "Resume", 17 | id: 'child-2', 18 | checkedStatus: 'True', 19 | parentId: 'parent' 20 | }; 21 | 22 | const child3: TreeViewItem = { 23 | type: 'w-file', 24 | name: "Introduction", 25 | id: 'child-3', 26 | checkedStatus: 'True', 27 | parentId: 'parent' 28 | }; 29 | 30 | const parentFolder: TreeViewItem = { 31 | type: 'Folder', 32 | name: "PECON", 33 | checkedStatus: 'False', 34 | id: 'parent', 35 | } 36 | 37 | beforeEach(() => { 38 | parentFolder.checkedStatus = 'False'; 39 | child1.checkedStatus = 'False'; 40 | child2.checkedStatus = 'False'; 41 | parentFolder.children = [ child1, child2 ] 42 | TreeViewModel.loadNodes([parentFolder]); 43 | while(TreeViewModel.selectedItems.length != 0) { 44 | TreeViewModel.selectedItems.pop(); 45 | } 46 | }); 47 | 48 | it('checkedStatusChanged() should set parentFolder to true if all children are checked', () => { 49 | child1.checkedStatus = 'True'; 50 | child2.checkedStatus = 'True'; 51 | 52 | TreeViewModel.checkedStatusChanged(child1); 53 | expect(parentFolder.checkedStatus).toBe('True'); 54 | }); 55 | 56 | it('checkedStatusChanged() should set parentFolder to indeterminate if at least one of the children are unchecked', () => { 57 | child1.checkedStatus = 'True'; 58 | child2.checkedStatus = 'False'; 59 | 60 | TreeViewModel.checkedStatusChanged(child2); 61 | expect(parentFolder.checkedStatus).toBe('Indeterminate'); 62 | }); 63 | 64 | it('notifyParentOfSelection() should set parentFolder to false if all children are unchecked', () => { 65 | child1.checkedStatus = 'False'; 66 | child2.checkedStatus = 'False'; 67 | 68 | TreeViewModel.checkedStatusChanged(child2); 69 | expect(TreeViewModel.selectedItems).toHaveLength(0); 70 | expect(parentFolder.checkedStatus).toBe('False'); 71 | }); 72 | 73 | it('notifyParentOfSelection() should set parentFolder to Indeterminate & add to selectedItems if any child is checked', () => { 74 | child1.checkedStatus = 'True'; 75 | child2.checkedStatus = 'False'; 76 | 77 | TreeViewModel.checkedStatusChanged(child1); 78 | expect(TreeViewModel.selectedItems).toHaveLength(1); 79 | expect(parentFolder.checkedStatus).toBe('Indeterminate'); 80 | }); 81 | 82 | it('removeTreeViewItem() should remove nodes from flatenedLookup', () => { 83 | TreeViewModel.removeTreeViewItem(parentFolder.id); 84 | expect(parentFolder.children?.length).toBe(0); 85 | expect(TreeViewModel.getNodes()[parentFolder.id]).toBe(undefined); 86 | }); 87 | 88 | it('removeTreeViewItem() should remove one node from flatenedLookup', () => { 89 | TreeViewModel.removeTreeViewItem(child2.id); 90 | expect(parentFolder.children?.length).toBe(1); 91 | const nodeIds = Object.keys(TreeViewModel.getNodes()); 92 | expect(nodeIds.find(id=> id == child2.id)).toBe(undefined) 93 | }); 94 | 95 | it('addTreeViewItem() should add node to flatenedLookup', () => { 96 | TreeViewModel.addTreeViewItem(child3); 97 | expect(parentFolder.children?.length).toBe(3) 98 | expect(TreeViewModel.getNodes()[child3.id]).toBe(child3); 99 | }); 100 | }) -------------------------------------------------------------------------------- /src/businessLogic/treviewViewModel/treeViewViewModel.ts: -------------------------------------------------------------------------------- 1 | import { TreeViewItem, CheckedState, SelectionMode, TreeViewViewModel } from "@/businessLogic/contracts/types"; 2 | import { eventHub } from "@/businessLogic/eventHub/explorerEventPublisher"; 3 | import { cascadeStateToDescendants, flattenNodes } from "../hierachyTraversal/hierachyTraversal"; 4 | 5 | let flattenedNodesLookUp: { [id: string]: TreeViewItem } = {}; 6 | 7 | export const notifyParentOfSelection = (item: TreeViewItem): void => { 8 | const parentId = item.parentId as string; 9 | if (!parentId) return; 10 | const parent = flattenedNodesLookUp[parentId]; 11 | 12 | // This solution is inefficient and can be optimised with a single O(n) solution. 13 | // But we can keep this simple and readable for now. :) 14 | 15 | const isEveryChildChecked = parent.children?.every(child => child.checkedStatus == 'True'); 16 | const isAnyIntermediate = parent.children?.some(child => child.checkedStatus == 'Indeterminate'); 17 | const hasAnUncheckedChild = parent.children?.some(child => child.checkedStatus == 'False'); 18 | const hasACheckedChild = parent.children?.some(child => child.checkedStatus == 'True'); 19 | 20 | if (isEveryChildChecked) { 21 | parent.checkedStatus = 'True'; 22 | } 23 | else if (isAnyIntermediate || (hasAnUncheckedChild && hasACheckedChild)) { 24 | parent.checkedStatus = 'Indeterminate'; 25 | } else { 26 | parent.checkedStatus = 'False'; 27 | } 28 | 29 | notifyParentOfSelection(parent); 30 | } 31 | 32 | export const TreeViewModel: TreeViewViewModel = { 33 | loadNodes(nodes: TreeViewItem[]): void { 34 | flattenedNodesLookUp = flattenNodes(nodes); 35 | }, 36 | 37 | getNodes(): { [id: string]: TreeViewItem } { 38 | return flattenedNodesLookUp 39 | }, 40 | 41 | checkedStatusChanged(item: TreeViewItem): void { 42 | if (item.checkedStatus == 'True') 43 | { 44 | eventHub.onItemChecked(item); 45 | } 46 | else 47 | { 48 | eventHub.onItemUnChecked(item); 49 | } 50 | 51 | cascadeStateToDescendants(item, item.checkedStatus as CheckedState); 52 | notifyParentOfSelection(item); 53 | }, 54 | 55 | removeTreeViewItem(id: string): boolean { 56 | const itemToBeRemoved = flattenedNodesLookUp[id]; 57 | 58 | if (!itemToBeRemoved) return false; 59 | 60 | const parentId = itemToBeRemoved.parentId; 61 | if (parentId) this.removeFromParentNode(itemToBeRemoved); 62 | 63 | if (!itemToBeRemoved.children) return false; 64 | 65 | while (itemToBeRemoved.children.length > 0) { 66 | this.removeChildNodes(itemToBeRemoved.children[0]) 67 | } 68 | 69 | delete flattenedNodesLookUp[id]; 70 | return true 71 | }, 72 | 73 | removeFromParentNode(itemToRemove: TreeViewItem): void { 74 | const parentId = itemToRemove.parentId; 75 | if (!parentId) return; 76 | 77 | const parent = flattenedNodesLookUp[parentId]; 78 | const index = parent.children?.findIndex(node => node.id == itemToRemove.id) 79 | if (index! > -1) parent.children?.splice(index!, 1) 80 | }, 81 | 82 | removeChildNodes(node: TreeViewItem) { 83 | if (!node.children) return; 84 | 85 | while (node.children.length > 0) { 86 | this.removeChildNodes(node.children[0]) 87 | } 88 | 89 | this.removeFromParentNode(node) 90 | delete flattenedNodesLookUp[node.id]; 91 | }, 92 | 93 | addTreeViewItem(TreeViewItem: TreeViewItem): void { 94 | flattenedNodesLookUp[TreeViewItem.id] = TreeViewItem; 95 | if(!TreeViewItem.parentId) return; 96 | 97 | const parent = flattenedNodesLookUp[TreeViewItem.parentId] 98 | parent.children?.push(TreeViewItem); 99 | }, 100 | 101 | setSelectionMode(mode: SelectionMode): void { 102 | eventHub.setSelectionMode(mode); 103 | }, 104 | 105 | selectedItems: eventHub.selectedItems 106 | } 107 | -------------------------------------------------------------------------------- /src/components/treeView.vue/treeView.spec.ts: -------------------------------------------------------------------------------- 1 | import { TreeViewCreatedEventPayload } from "@/businessLogic/contracts/types"; 2 | import { mount } from "@vue/test-utils"; 3 | import TreeView from "./treeView.vue"; 4 | 5 | 6 | describe("treeview.vue", () => { 7 | it("onCreated should emit events customisation payload", () => { 8 | const treeView = mount(TreeView); 9 | 10 | const emittedEvent = treeView.emitted().created; 11 | if (!emittedEvent) fail("Did not emit created event"); 12 | 13 | const payload = emittedEvent[0][0] as TreeViewCreatedEventPayload; 14 | expect(payload.itemCustomisations).not.toBe(undefined); 15 | }) 16 | }); -------------------------------------------------------------------------------- /src/components/treeView.vue/treeView.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 22 | 23 | 24 | 25 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 141 | 142 | -------------------------------------------------------------------------------- /src/components/treeViewItem/treeViewItemView.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 10 | {{ item.name }} 11 | 12 | 13 | 14 | {{item.name}} 15 | 16 | 17 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /src/components/treeViewItem/treeviewItemView.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geekhybrid/tree-vue/2ffec7c882eb86ad14c36144ec706b8687350449/src/components/treeViewItem/treeviewItemView.scss -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const ANY_TYPE = "ALL_TYPES"; -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import App from './App.vue' 3 | import '@/styles/style.css'; 4 | 5 | import TreeViewPlugin from './plugins/TreeViewPlugin'; 6 | 7 | Vue.config.productionTip = false 8 | 9 | Vue.use(TreeViewPlugin); 10 | 11 | new Vue({ 12 | render: h => h(App), 13 | }).$mount('#app') 14 | -------------------------------------------------------------------------------- /src/plugins/TreeViewPlugin.ts: -------------------------------------------------------------------------------- 1 | import { VueConstructor } from 'vue'; 2 | 3 | import TreeView from '@/components/treeView.vue/treeView.vue'; 4 | import TreeViewItem from '@/components/treeViewItem/treeViewItemView.vue'; 5 | 6 | const TreeViewPlugin = { 7 | install(Vue: VueConstructor) { 8 | Vue.component('tree-view', TreeView); 9 | Vue.component('treeview-item', TreeViewItem); 10 | } 11 | } 12 | 13 | export default TreeViewPlugin; -------------------------------------------------------------------------------- /src/shims-tsx.d.ts: -------------------------------------------------------------------------------- 1 | import Vue, { VNode } from 'vue' 2 | 3 | declare global { 4 | namespace JSX { 5 | // tslint:disable no-empty-interface 6 | interface Element extends VNode {} 7 | // tslint:disable no-empty-interface 8 | interface ElementClass extends Vue {} 9 | interface IntrinsicElements { 10 | [elem: string]: any 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/shims-vue.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.vue' { 2 | import Vue from 'vue' 3 | export default Vue 4 | } -------------------------------------------------------------------------------- /src/styles/style.css: -------------------------------------------------------------------------------- 1 | * { 2 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; 3 | font-size: 14px; 4 | } 5 | 6 | .d-flex { 7 | display: flex; 8 | } 9 | 10 | .cursor { 11 | cursor: pointer; 12 | } 13 | 14 | .justify-content-center { 15 | justify-content: center; 16 | } 17 | 18 | .align-items-center { 19 | align-items: center; 20 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "strict": true, 6 | "jsx": "preserve", 7 | "importHelpers": true, 8 | "moduleResolution": "node", 9 | "experimentalDecorators": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "sourceMap": true, 14 | "baseUrl": ".", 15 | "types": [ 16 | "webpack-env", 17 | "jest" 18 | ], 19 | "paths": { 20 | "@/*": [ 21 | "src/*" 22 | ] 23 | }, 24 | "lib": [ 25 | "esnext", 26 | "dom", 27 | "dom.iterable", 28 | "scripthost" 29 | ] 30 | }, 31 | "include": [ 32 | "src/**/*.ts", 33 | "src/**/*.tsx", 34 | "src/**/*.vue", 35 | "tests/**/*.ts", 36 | "tests/**/*.tsx" 37 | ], 38 | "exclude": [ 39 | "node_modules" 40 | ] 41 | } 42 | --------------------------------------------------------------------------------