Introduce new TreeView UI component
Key highlights: - Written from scratch to cater specifically to privacy.sexy's needs and requirements. - The visual look mimics the previous component with minimal changes, but its internal code is completely rewritten. - Lays groundwork for future functionalities like the "expand all" button a flat view mode as discussed in #158. - Facilitates the transition to Vue 3 by omitting the Vue 2.0 dependent `liquour-tree` as part of #230. Improvements and features: - Caching for quicker node queries. - Gradual rendering of nodes that introduces a noticable boost in performance, particularly during search/filtering. - `TreeView` solely governs the check states of branch nodes. Changes: - Keyboard interactions now alter the background color to highlight the focused item. Previously, it was changing the color of the text. - Better state management with clear separation of concerns: - `TreeView` exclusively manages indeterminate states. - `TreeView` solely governs the check states of branch nodes. - Introduce transaction pattern to update state in batches to minimize amount of events handled. - Improve keyboard focus, style background instead of foreground. Use hover/touch color on keyboard focus. - `SelectableTree` has been removed. Instead, `TreeView` is now directly integrated with `ScriptsTree`. - `ScriptsTree` has been refactored to incorporate hooks for clearer code and separation of duties. - Adopt Vue-idiomatic bindings instead of keeping a reference of the tree component. - Simplify and change filter event management. - Abandon global styles in favor of class-scoped styles. - Use global mixins with descriptive names to clarify indended functionality.
This commit is contained in:
@@ -91,7 +91,14 @@ Shared components include:
|
|||||||
|
|
||||||
Desktop builds uses `electron-vite` to bundle the code, and `electron-builder` to build and publish the packages.
|
Desktop builds uses `electron-vite` to bundle the code, and `electron-builder` to build and publish the packages.
|
||||||
|
|
||||||
## Sass naming convention
|
## Styles
|
||||||
|
|
||||||
|
### Style location
|
||||||
|
|
||||||
|
- **Global styles**: The [`assets/styles/`](#structure) directory is reserved for styles that have a broader scope, affecting multiple components or entire layouts. They are generic and should not be tightly coupled to a specific component's functionality.
|
||||||
|
- **Component-specific styles**: Styles closely tied to a particular component's functionality or appearance should reside near the component they are used by. This makes it easier to locate and modify styles when working on a specific component.
|
||||||
|
|
||||||
|
### Sass naming convention
|
||||||
|
|
||||||
- Use lowercase for variables/functions/mixins, e.g.:
|
- Use lowercase for variables/functions/mixins, e.g.:
|
||||||
- Variable: `$variable: value;`
|
- Variable: `$variable: value;`
|
||||||
|
|||||||
11
package-lock.json
generated
11
package-lock.json
generated
@@ -19,7 +19,6 @@
|
|||||||
"cross-fetch": "^4.0.0",
|
"cross-fetch": "^4.0.0",
|
||||||
"electron-progressbar": "^2.1.0",
|
"electron-progressbar": "^2.1.0",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"liquor-tree": "^0.2.70",
|
|
||||||
"markdown-it": "^13.0.1",
|
"markdown-it": "^13.0.1",
|
||||||
"npm": "^9.8.1",
|
"npm": "^9.8.1",
|
||||||
"v-tooltip": "2.1.3",
|
"v-tooltip": "2.1.3",
|
||||||
@@ -10762,11 +10761,6 @@
|
|||||||
"uc.micro": "^1.0.1"
|
"uc.micro": "^1.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/liquor-tree": {
|
|
||||||
"version": "0.2.70",
|
|
||||||
"resolved": "https://registry.npmjs.org/liquor-tree/-/liquor-tree-0.2.70.tgz",
|
|
||||||
"integrity": "sha512-5CiMlDVmuveYwwc27mYe1xZ3J4aHhZBErUhIp9ov4v4wIBso+s5JAByOOit4iOCMCQ5ODd8VggbKymzZREYbBQ=="
|
|
||||||
},
|
|
||||||
"node_modules/listr2": {
|
"node_modules/listr2": {
|
||||||
"version": "3.14.0",
|
"version": "3.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/listr2/-/listr2-3.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/listr2/-/listr2-3.14.0.tgz",
|
||||||
@@ -29242,11 +29236,6 @@
|
|||||||
"uc.micro": "^1.0.1"
|
"uc.micro": "^1.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"liquor-tree": {
|
|
||||||
"version": "0.2.70",
|
|
||||||
"resolved": "https://registry.npmjs.org/liquor-tree/-/liquor-tree-0.2.70.tgz",
|
|
||||||
"integrity": "sha512-5CiMlDVmuveYwwc27mYe1xZ3J4aHhZBErUhIp9ov4v4wIBso+s5JAByOOit4iOCMCQ5ODd8VggbKymzZREYbBQ=="
|
|
||||||
},
|
|
||||||
"listr2": {
|
"listr2": {
|
||||||
"version": "3.14.0",
|
"version": "3.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/listr2/-/listr2-3.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/listr2/-/listr2-3.14.0.tgz",
|
||||||
|
|||||||
@@ -44,7 +44,6 @@
|
|||||||
"cross-fetch": "^4.0.0",
|
"cross-fetch": "^4.0.0",
|
||||||
"electron-progressbar": "^2.1.0",
|
"electron-progressbar": "^2.1.0",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"liquor-tree": "^0.2.70",
|
|
||||||
"markdown-it": "^13.0.1",
|
"markdown-it": "^13.0.1",
|
||||||
"npm": "^9.8.1",
|
"npm": "^9.8.1",
|
||||||
"v-tooltip": "2.1.3",
|
"v-tooltip": "2.1.3",
|
||||||
|
|||||||
@@ -45,3 +45,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@mixin reset-ul {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,4 +9,3 @@
|
|||||||
@forward "./components/card";
|
@forward "./components/card";
|
||||||
|
|
||||||
@forward "./third-party-extensions/tooltip.scss";
|
@forward "./third-party-extensions/tooltip.scss";
|
||||||
@forward "./third-party-extensions/tree.scss";
|
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
// Overrides base styling for LiquorTree
|
|
||||||
@use "@/presentation/assets/styles/colors" as *;
|
|
||||||
@use "@/presentation/assets/styles/mixins" as *;
|
|
||||||
|
|
||||||
$color-tree-bg : $color-primary-darker;
|
|
||||||
$color-node-arrow : $color-on-primary;
|
|
||||||
$color-node-fg : $color-on-primary;
|
|
||||||
$color-node-hover-bg : $color-primary-dark;
|
|
||||||
$color-node-keyboard-bg : $color-surface;
|
|
||||||
$color-node-keyboard-fg : $color-on-surface;
|
|
||||||
$color-node-checkbox-bg-checked : $color-secondary;
|
|
||||||
$color-node-checkbox-bg-unchecked : $color-primary-darkest;
|
|
||||||
$color-node-checkbox-border-checked : $color-secondary;
|
|
||||||
$color-node-checkbox-border-unchecked : $color-on-primary;
|
|
||||||
$color-node-checkbox-tick-checked : $color-on-secondary;
|
|
||||||
|
|
||||||
.tree {
|
|
||||||
background: $color-tree-bg;
|
|
||||||
&-node {
|
|
||||||
white-space: normal !important;
|
|
||||||
> .tree-content {
|
|
||||||
> .tree-anchor {
|
|
||||||
> span {
|
|
||||||
color: $color-node-fg;
|
|
||||||
font-size: 1.5em;
|
|
||||||
}
|
|
||||||
display: block; // so it takes full width to allow aligning items inside
|
|
||||||
}
|
|
||||||
@include hover-or-touch {
|
|
||||||
background: $color-node-hover-bg !important;
|
|
||||||
}
|
|
||||||
background: $color-tree-bg !important; // If not styled, it gets white background on mobile.
|
|
||||||
}
|
|
||||||
&.selected { // When using keyboard navigation it highlights current item and its child items
|
|
||||||
background: $color-node-keyboard-bg;
|
|
||||||
.tree-text {
|
|
||||||
color: $color-node-keyboard-fg !important; // $block
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
&-checkbox {
|
|
||||||
border-color: $color-node-checkbox-border-unchecked !important;
|
|
||||||
&.checked {
|
|
||||||
background: $color-node-checkbox-bg-checked !important;
|
|
||||||
border-color: $color-node-checkbox-border-checked !important;
|
|
||||||
&:after {
|
|
||||||
border-color: $color-node-checkbox-tick-checked !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
&.indeterminate {
|
|
||||||
border-color: $color-node-checkbox-border-unchecked !important;
|
|
||||||
}
|
|
||||||
background: $color-node-checkbox-bg-unchecked !important;
|
|
||||||
}
|
|
||||||
&-arrow {
|
|
||||||
&.has-child {
|
|
||||||
&.rtl:after, &:after {
|
|
||||||
border-color: $color-node-arrow !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { TreeBootstrapper } from './Modules/TreeBootstrapper';
|
|
||||||
import { IconBootstrapper } from './Modules/IconBootstrapper';
|
import { IconBootstrapper } from './Modules/IconBootstrapper';
|
||||||
import { VueConstructor, IVueBootstrapper } from './IVueBootstrapper';
|
import { VueConstructor, IVueBootstrapper } from './IVueBootstrapper';
|
||||||
import { VueBootstrapper } from './Modules/VueBootstrapper';
|
import { VueBootstrapper } from './Modules/VueBootstrapper';
|
||||||
@@ -17,7 +16,6 @@ export class ApplicationBootstrapper implements IVueBootstrapper {
|
|||||||
private static getAllBootstrappers(): IVueBootstrapper[] {
|
private static getAllBootstrappers(): IVueBootstrapper[] {
|
||||||
return [
|
return [
|
||||||
new IconBootstrapper(),
|
new IconBootstrapper(),
|
||||||
new TreeBootstrapper(),
|
|
||||||
new VueBootstrapper(),
|
new VueBootstrapper(),
|
||||||
new TooltipBootstrapper(),
|
new TooltipBootstrapper(),
|
||||||
new RuntimeSanityValidator(),
|
new RuntimeSanityValidator(),
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
import LiquorTree from 'liquor-tree';
|
|
||||||
import { VueConstructor, IVueBootstrapper } from '../IVueBootstrapper';
|
|
||||||
|
|
||||||
export class TreeBootstrapper implements IVueBootstrapper {
|
|
||||||
public bootstrap(vue: VueConstructor): void {
|
|
||||||
vue.use(LiquorTree);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -76,7 +76,6 @@ export default defineComponent({
|
|||||||
function handleCodeChange(event: ICodeChangedEvent) {
|
function handleCodeChange(event: ICodeChangedEvent) {
|
||||||
removeCurrentHighlighting();
|
removeCurrentHighlighting();
|
||||||
updateCode(event.code, currentState.value.collection.scripting.language);
|
updateCode(event.code, currentState.value.collection.scripting.language);
|
||||||
editor.setValue(event.code, 1);
|
|
||||||
if (event.addedScripts?.length > 0) {
|
if (event.addedScripts?.length > 0) {
|
||||||
reactToChanges(event, event.addedScripts);
|
reactToChanges(event, event.addedScripts);
|
||||||
} else if (event.changedScripts?.length > 0) {
|
} else if (event.changedScripts?.length > 0) {
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ import {
|
|||||||
inject,
|
inject,
|
||||||
} from 'vue';
|
} from 'vue';
|
||||||
import { InjectionKeys } from '@/presentation/injectionSymbols';
|
import { InjectionKeys } from '@/presentation/injectionSymbols';
|
||||||
import ScriptsTree from '@/presentation/components/Scripts/View/ScriptsTree/ScriptsTree.vue';
|
import ScriptsTree from '@/presentation/components/Scripts/View/Tree/ScriptsTree.vue';
|
||||||
import { sleep } from '@/infrastructure/Threading/AsyncSleep';
|
import { sleep } from '@/infrastructure/Threading/AsyncSleep';
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
|
|||||||
@@ -1,173 +0,0 @@
|
|||||||
<template>
|
|
||||||
<span id="container">
|
|
||||||
<span v-if="nodes != null && nodes.length > 0">
|
|
||||||
<SelectableTree
|
|
||||||
:initialNodes="nodes"
|
|
||||||
:selectedNodeIds="selectedNodeIds"
|
|
||||||
:filterPredicate="filterPredicate"
|
|
||||||
:filterText="filterText"
|
|
||||||
v-on:nodeSelected="toggleNodeSelection($event)"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
<span v-else>Nooo 😢</span>
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import {
|
|
||||||
defineComponent, watch, ref, inject,
|
|
||||||
} from 'vue';
|
|
||||||
import { InjectionKeys } from '@/presentation/injectionSymbols';
|
|
||||||
import { IScript } from '@/domain/IScript';
|
|
||||||
import { ICategory } from '@/domain/ICategory';
|
|
||||||
import { ICategoryCollectionState, IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
|
|
||||||
import { IFilterResult } from '@/application/Context/State/Filter/IFilterResult';
|
|
||||||
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
|
|
||||||
import { IEventSubscription } from '@/infrastructure/Events/IEventSource';
|
|
||||||
import {
|
|
||||||
parseAllCategories, parseSingleCategory, getScriptNodeId, getCategoryNodeId, getCategoryId,
|
|
||||||
getScriptId,
|
|
||||||
} from './ScriptNodeParser';
|
|
||||||
import SelectableTree from './SelectableTree/SelectableTree.vue';
|
|
||||||
import { INodeContent, NodeType } from './SelectableTree/Node/INodeContent';
|
|
||||||
import { INodeSelectedEvent } from './SelectableTree/INodeSelectedEvent';
|
|
||||||
|
|
||||||
export default defineComponent({
|
|
||||||
props: {
|
|
||||||
categoryId: {
|
|
||||||
type: Number,
|
|
||||||
default: undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
components: {
|
|
||||||
SelectableTree,
|
|
||||||
},
|
|
||||||
setup(props) {
|
|
||||||
const {
|
|
||||||
modifyCurrentState, currentState, onStateChange,
|
|
||||||
} = inject(InjectionKeys.useCollectionState)();
|
|
||||||
const { events } = inject(InjectionKeys.useAutoUnsubscribedEvents)();
|
|
||||||
|
|
||||||
const nodes = ref<ReadonlyArray<INodeContent>>([]);
|
|
||||||
const selectedNodeIds = ref<ReadonlyArray<string>>([]);
|
|
||||||
const filterText = ref<string | undefined>(undefined);
|
|
||||||
|
|
||||||
let filtered: IFilterResult | undefined;
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.categoryId,
|
|
||||||
(newCategoryId) => setNodes(newCategoryId),
|
|
||||||
{ immediate: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
onStateChange((state) => {
|
|
||||||
setCurrentFilter(state.filter.currentFilter);
|
|
||||||
if (!props.categoryId) {
|
|
||||||
nodes.value = parseAllCategories(state.collection);
|
|
||||||
}
|
|
||||||
events.unsubscribeAllAndRegister(subscribeToState(state));
|
|
||||||
}, { immediate: true });
|
|
||||||
|
|
||||||
function toggleNodeSelection(event: INodeSelectedEvent) {
|
|
||||||
modifyCurrentState((state) => {
|
|
||||||
switch (event.node.type) {
|
|
||||||
case NodeType.Category:
|
|
||||||
toggleCategoryNodeSelection(event, state);
|
|
||||||
break;
|
|
||||||
case NodeType.Script:
|
|
||||||
toggleScriptNodeSelection(event, state);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
throw new Error(`Unknown node type: ${event.node.id}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function filterPredicate(node: INodeContent): boolean {
|
|
||||||
return containsScript(node, filtered.scriptMatches)
|
|
||||||
|| containsCategory(node, filtered.categoryMatches);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setNodes(categoryId?: number) {
|
|
||||||
if (categoryId) {
|
|
||||||
nodes.value = parseSingleCategory(categoryId, currentState.value.collection);
|
|
||||||
} else {
|
|
||||||
nodes.value = parseAllCategories(currentState.value.collection);
|
|
||||||
}
|
|
||||||
selectedNodeIds.value = currentState.value.selection.selectedScripts
|
|
||||||
.map((selected) => getScriptNodeId(selected.script));
|
|
||||||
}
|
|
||||||
|
|
||||||
function subscribeToState(
|
|
||||||
state: IReadOnlyCategoryCollectionState,
|
|
||||||
): IEventSubscription[] {
|
|
||||||
return [
|
|
||||||
state.selection.changed.on((scripts) => handleSelectionChanged(scripts)),
|
|
||||||
state.filter.filterChanged.on((event) => {
|
|
||||||
event.visit({
|
|
||||||
onApply: (filter) => {
|
|
||||||
filterText.value = filter.query;
|
|
||||||
filtered = filter;
|
|
||||||
},
|
|
||||||
onClear: () => {
|
|
||||||
filterText.value = '';
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function setCurrentFilter(currentFilter: IFilterResult | undefined) {
|
|
||||||
filtered = currentFilter;
|
|
||||||
filterText.value = currentFilter?.query || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSelectionChanged(selectedScripts: ReadonlyArray<SelectedScript>): void {
|
|
||||||
selectedNodeIds.value = selectedScripts
|
|
||||||
.map((node) => node.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
nodes,
|
|
||||||
selectedNodeIds,
|
|
||||||
filterText,
|
|
||||||
toggleNodeSelection,
|
|
||||||
filterPredicate,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
function containsScript(expected: INodeContent, scripts: readonly IScript[]) {
|
|
||||||
return scripts.some((existing: IScript) => expected.id === getScriptNodeId(existing));
|
|
||||||
}
|
|
||||||
|
|
||||||
function containsCategory(expected: INodeContent, categories: readonly ICategory[]) {
|
|
||||||
return categories.some((existing: ICategory) => expected.id === getCategoryNodeId(existing));
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleCategoryNodeSelection(
|
|
||||||
event: INodeSelectedEvent,
|
|
||||||
state: ICategoryCollectionState,
|
|
||||||
): void {
|
|
||||||
const categoryId = getCategoryId(event.node.id);
|
|
||||||
if (event.isSelected) {
|
|
||||||
state.selection.addOrUpdateAllInCategory(categoryId, false);
|
|
||||||
} else {
|
|
||||||
state.selection.removeAllInCategory(categoryId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleScriptNodeSelection(
|
|
||||||
event: INodeSelectedEvent,
|
|
||||||
state: ICategoryCollectionState,
|
|
||||||
): void {
|
|
||||||
const scriptId = getScriptId(event.node.id);
|
|
||||||
const actualToggleState = state.selection.isSelected(scriptId);
|
|
||||||
const targetToggleState = event.isSelected;
|
|
||||||
if (targetToggleState && !actualToggleState) {
|
|
||||||
state.selection.addSelectedScript(scriptId, false);
|
|
||||||
} else if (!targetToggleState && actualToggleState) {
|
|
||||||
state.selection.removeSelectedScript(scriptId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import { INodeContent } from './Node/INodeContent';
|
|
||||||
|
|
||||||
export interface INodeSelectedEvent {
|
|
||||||
isSelected: boolean;
|
|
||||||
node: INodeContent;
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
declare module 'liquor-tree' {
|
|
||||||
import { PluginObject } from 'vue';
|
|
||||||
|
|
||||||
// https://github.com/amsik/liquor-tree/blob/master/src/lib/Tree.js
|
|
||||||
export interface ILiquorTree {
|
|
||||||
readonly model: ReadonlyArray<ILiquorTreeExistingNode>;
|
|
||||||
filter(query: string): void;
|
|
||||||
clearFilter(): void;
|
|
||||||
setModel(nodes: ReadonlyArray<ILiquorTreeNewNode>): void;
|
|
||||||
// getNodeById(id: string): ILiquorTreeExistingNode;
|
|
||||||
recurseDown(fn: (node: ILiquorTreeExistingNode) => void): void;
|
|
||||||
}
|
|
||||||
export interface ICustomLiquorTreeData {
|
|
||||||
type: number;
|
|
||||||
docs: ReadonlyArray<string>;
|
|
||||||
isReversible: boolean;
|
|
||||||
}
|
|
||||||
// https://github.com/amsik/liquor-tree/blob/master/src/lib/Node.js
|
|
||||||
export interface ILiquorTreeNodeState {
|
|
||||||
checked: boolean;
|
|
||||||
indeterminate: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ILiquorTreeNode {
|
|
||||||
id: string;
|
|
||||||
data: ICustomLiquorTreeData;
|
|
||||||
children: ReadonlyArray<ILiquorTreeNode> | undefined;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
Returned from Node tree view events.
|
|
||||||
See constructor in https://github.com/amsik/liquor-tree/blob/master/src/lib/Node.js
|
|
||||||
*/
|
|
||||||
export interface ILiquorTreeExistingNode extends ILiquorTreeNode {
|
|
||||||
data: ILiquorTreeNodeData;
|
|
||||||
states: ILiquorTreeNodeState | undefined;
|
|
||||||
children: ReadonlyArray<ILiquorTreeExistingNode> | undefined;
|
|
||||||
// expand(): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
Sent to liquor tree to define of new nodes.
|
|
||||||
https://github.com/amsik/liquor-tree/blob/master/src/lib/Node.js
|
|
||||||
*/
|
|
||||||
export interface ILiquorTreeNewNode extends ILiquorTreeNode {
|
|
||||||
text: string;
|
|
||||||
state: ILiquorTreeNodeState | undefined;
|
|
||||||
children: ReadonlyArray<ILiquorTreeNewNode> | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
// https://amsik.github.io/liquor-tree/#Component-Options
|
|
||||||
export interface ILiquorTreeOptions {
|
|
||||||
multiple: boolean;
|
|
||||||
checkbox: boolean;
|
|
||||||
checkOnSelect: boolean;
|
|
||||||
autoCheckChildren: boolean;
|
|
||||||
parentSelect: boolean;
|
|
||||||
keyboardNavigation: boolean;
|
|
||||||
filter: ILiquorTreeFilter;
|
|
||||||
deletion(node: ILiquorTreeNode): boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ILiquorTreeNodeData extends ICustomLiquorTreeData {
|
|
||||||
text: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// https://github.com/amsik/liquor-tree/blob/master/src/components/TreeRoot.vue
|
|
||||||
export interface ILiquorTreeFilter {
|
|
||||||
emptyText: string;
|
|
||||||
matcher(query: string, node: ILiquorTreeExistingNode): boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LiquorTreeVueComponent extends PluginObject<Vue> {
|
|
||||||
install(Vue: VueConstructor<Vue>, options?: unknown);
|
|
||||||
}
|
|
||||||
export default LiquorTree;
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import { ILiquorTreeOptions, ILiquorTreeFilter, ILiquorTreeExistingNode } from 'liquor-tree';
|
|
||||||
|
|
||||||
export class LiquorTreeOptions implements ILiquorTreeOptions {
|
|
||||||
public readonly multiple = true;
|
|
||||||
|
|
||||||
public readonly checkbox = true;
|
|
||||||
|
|
||||||
public readonly checkOnSelect = true;
|
|
||||||
|
|
||||||
/*
|
|
||||||
For checkbox mode only. Children will have the same checked state as their parent.
|
|
||||||
⚠️ Setting this false does not prevent updating indeterminate state of nodes.
|
|
||||||
It's set to false anyway because state is handled manually, and this way batch selections can
|
|
||||||
be done in more performant way.
|
|
||||||
*/
|
|
||||||
public readonly autoCheckChildren = false;
|
|
||||||
|
|
||||||
public readonly parentSelect = true;
|
|
||||||
|
|
||||||
public readonly keyboardNavigation = true;
|
|
||||||
|
|
||||||
/*
|
|
||||||
Filter is wrapped in an arrow function because setting filter directly does not work with
|
|
||||||
underling JavaScript APIs.
|
|
||||||
*/
|
|
||||||
public readonly filter = {
|
|
||||||
emptyText: this.liquorTreeFilter.emptyText,
|
|
||||||
matcher: (query: string, node: ILiquorTreeExistingNode) => {
|
|
||||||
return this.liquorTreeFilter.matcher(query, node);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
constructor(private readonly liquorTreeFilter: ILiquorTreeFilter) { }
|
|
||||||
|
|
||||||
public deletion(): boolean {
|
|
||||||
return false; // no op
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { ILiquorTreeFilter, ILiquorTreeExistingNode } from 'liquor-tree';
|
|
||||||
import { INodeContent } from '../../Node/INodeContent';
|
|
||||||
import { convertExistingToNode } from './NodeTranslator';
|
|
||||||
|
|
||||||
export type FilterPredicate = (node: INodeContent) => boolean;
|
|
||||||
|
|
||||||
export class NodePredicateFilter implements ILiquorTreeFilter {
|
|
||||||
public emptyText = ''; // Does not matter as a custom message is shown
|
|
||||||
|
|
||||||
constructor(private readonly filterPredicate: FilterPredicate) {
|
|
||||||
if (!filterPredicate) {
|
|
||||||
throw new Error('filterPredicate is undefined');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public matcher(query: string, node: ILiquorTreeExistingNode): boolean {
|
|
||||||
return this.filterPredicate(convertExistingToNode(node));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
import { ILiquorTreeNode, ILiquorTreeNodeState } from 'liquor-tree';
|
|
||||||
import { NodeType } from '../../Node/INodeContent';
|
|
||||||
|
|
||||||
export function getNewState(
|
|
||||||
node: ILiquorTreeNode,
|
|
||||||
selectedNodeIds: ReadonlyArray<string>,
|
|
||||||
): ILiquorTreeNodeState {
|
|
||||||
const checked = getNewCheckedState(node, selectedNodeIds);
|
|
||||||
const indeterminate = !checked && getNewIndeterminateState(node, selectedNodeIds);
|
|
||||||
return {
|
|
||||||
checked, indeterminate,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getNewIndeterminateState(
|
|
||||||
node: ILiquorTreeNode,
|
|
||||||
selectedNodeIds: ReadonlyArray<string>,
|
|
||||||
): boolean {
|
|
||||||
switch (node.data.type) {
|
|
||||||
case NodeType.Script:
|
|
||||||
return false;
|
|
||||||
case NodeType.Category:
|
|
||||||
return parseAllSubScriptIds(node).some((id) => selectedNodeIds.includes(id));
|
|
||||||
default:
|
|
||||||
throw new Error('Unknown node type');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getNewCheckedState(
|
|
||||||
node: ILiquorTreeNode,
|
|
||||||
selectedNodeIds: ReadonlyArray<string>,
|
|
||||||
): boolean {
|
|
||||||
switch (node.data.type) {
|
|
||||||
case NodeType.Script:
|
|
||||||
return selectedNodeIds.some((id) => id === node.id);
|
|
||||||
case NodeType.Category:
|
|
||||||
return parseAllSubScriptIds(node).every((id) => selectedNodeIds.includes(id));
|
|
||||||
default:
|
|
||||||
throw new Error('Unknown node type');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseAllSubScriptIds(categoryNode: ILiquorTreeNode): ReadonlyArray<string> {
|
|
||||||
if (categoryNode.data.type !== NodeType.Category) {
|
|
||||||
throw new Error('Not a category node');
|
|
||||||
}
|
|
||||||
if (!categoryNode.children) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return categoryNode
|
|
||||||
.children
|
|
||||||
.flatMap((child) => getNodeIds(child));
|
|
||||||
}
|
|
||||||
|
|
||||||
function getNodeIds(node: ILiquorTreeNode): ReadonlyArray<string> {
|
|
||||||
switch (node.data.type) {
|
|
||||||
case NodeType.Script:
|
|
||||||
return [node.id];
|
|
||||||
case NodeType.Category:
|
|
||||||
return parseAllSubScriptIds(node);
|
|
||||||
default:
|
|
||||||
throw new Error('Unknown node type');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import { ILiquorTreeNewNode, ILiquorTreeExistingNode } from 'liquor-tree';
|
|
||||||
import { INodeContent } from '../../Node/INodeContent';
|
|
||||||
|
|
||||||
// Functions to translate INode to LiqourTree models and vice versa for anti-corruption
|
|
||||||
|
|
||||||
export function convertExistingToNode(liquorTreeNode: ILiquorTreeExistingNode): INodeContent {
|
|
||||||
if (!liquorTreeNode) { throw new Error('liquorTreeNode is undefined'); }
|
|
||||||
return {
|
|
||||||
id: liquorTreeNode.id,
|
|
||||||
type: liquorTreeNode.data.type,
|
|
||||||
text: liquorTreeNode.data.text,
|
|
||||||
// selected: liquorTreeNode.states && liquorTreeNode.states.checked,
|
|
||||||
children: convertChildren(liquorTreeNode.children, convertExistingToNode),
|
|
||||||
docs: liquorTreeNode.data.docs,
|
|
||||||
isReversible: liquorTreeNode.data.isReversible,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toNewLiquorTreeNode(node: INodeContent): ILiquorTreeNewNode {
|
|
||||||
if (!node) { throw new Error('node is undefined'); }
|
|
||||||
return {
|
|
||||||
id: node.id,
|
|
||||||
text: node.text,
|
|
||||||
state: {
|
|
||||||
checked: false,
|
|
||||||
indeterminate: false,
|
|
||||||
},
|
|
||||||
children: convertChildren(node.children, toNewLiquorTreeNode),
|
|
||||||
data: {
|
|
||||||
docs: node.docs,
|
|
||||||
isReversible: node.isReversible,
|
|
||||||
type: node.type,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function convertChildren<TOldNode, TNewNode>(
|
|
||||||
oldChildren: readonly TOldNode[],
|
|
||||||
callback: (value: TOldNode) => TNewNode,
|
|
||||||
): TNewNode[] {
|
|
||||||
if (!oldChildren || oldChildren.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return oldChildren.map((childNode) => callback(childNode));
|
|
||||||
}
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
<template>
|
|
||||||
<span>
|
|
||||||
<span v-if="initialLiquorTreeNodes?.length > 0">
|
|
||||||
<LiquorTree
|
|
||||||
:options="liquorTreeOptions"
|
|
||||||
:data="initialLiquorTreeNodes"
|
|
||||||
@node:checked="nodeSelected($event)"
|
|
||||||
@node:unchecked="nodeSelected($event)"
|
|
||||||
ref="liquorTree"
|
|
||||||
>
|
|
||||||
<template v-slot:default="{ node }">
|
|
||||||
<span class="tree-text">
|
|
||||||
<NodeContent :data="convertExistingToNode(node)" />
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
</LiquorTree>
|
|
||||||
</span>
|
|
||||||
<span v-else>Nooo 😢</span>
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import {
|
|
||||||
PropType, defineComponent, ref, watch,
|
|
||||||
} from 'vue';
|
|
||||||
import LiquorTree, {
|
|
||||||
ILiquorTreeNewNode, ILiquorTreeExistingNode, ILiquorTree, ILiquorTreeNode, ILiquorTreeNodeState,
|
|
||||||
} from 'liquor-tree';
|
|
||||||
import { sleep } from '@/infrastructure/Threading/AsyncSleep';
|
|
||||||
import NodeContent from './Node/NodeContent.vue';
|
|
||||||
import { INodeContent } from './Node/INodeContent';
|
|
||||||
import { convertExistingToNode, toNewLiquorTreeNode } from './LiquorTree/NodeWrapper/NodeTranslator';
|
|
||||||
import { INodeSelectedEvent } from './INodeSelectedEvent';
|
|
||||||
import { getNewState } from './LiquorTree/NodeWrapper/NodeStateUpdater';
|
|
||||||
import { LiquorTreeOptions } from './LiquorTree/LiquorTreeOptions';
|
|
||||||
import { FilterPredicate, NodePredicateFilter } from './LiquorTree/NodeWrapper/NodePredicateFilter';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wrapper for Liquor Tree, reveals only abstracted INode for communication.
|
|
||||||
* Stateless to make it easier to switch out Liquor Tree to another component.
|
|
||||||
*/
|
|
||||||
export default defineComponent({
|
|
||||||
components: {
|
|
||||||
LiquorTree,
|
|
||||||
NodeContent,
|
|
||||||
},
|
|
||||||
props: {
|
|
||||||
filterPredicate: {
|
|
||||||
type: Function as PropType<FilterPredicate>,
|
|
||||||
default: undefined,
|
|
||||||
},
|
|
||||||
filterText: {
|
|
||||||
type: String,
|
|
||||||
default: undefined,
|
|
||||||
},
|
|
||||||
selectedNodeIds: {
|
|
||||||
type: Array as PropType<ReadonlyArray<string>>,
|
|
||||||
default: undefined,
|
|
||||||
},
|
|
||||||
initialNodes: {
|
|
||||||
type: Array as PropType<ReadonlyArray<INodeContent>>,
|
|
||||||
default: undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
setup(props, { emit }) {
|
|
||||||
const liquorTree = ref< { tree: ILiquorTree }>();
|
|
||||||
const initialLiquorTreeNodes = ref<ReadonlyArray<ILiquorTreeNewNode>>();
|
|
||||||
const liquorTreeOptions = new LiquorTreeOptions(
|
|
||||||
new NodePredicateFilter((node) => props.filterPredicate(node)),
|
|
||||||
);
|
|
||||||
|
|
||||||
function nodeSelected(node: ILiquorTreeExistingNode) {
|
|
||||||
const event: INodeSelectedEvent = {
|
|
||||||
node: convertExistingToNode(node),
|
|
||||||
isSelected: node.states.checked,
|
|
||||||
};
|
|
||||||
emit('nodeSelected', event);
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.initialNodes,
|
|
||||||
(nodes) => setInitialNodes(nodes),
|
|
||||||
{ immediate: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.filterText,
|
|
||||||
(filterText) => setFilterText(filterText),
|
|
||||||
{ immediate: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.selectedNodeIds,
|
|
||||||
(selectedNodeIds) => setSelectedStatus(selectedNodeIds),
|
|
||||||
);
|
|
||||||
|
|
||||||
async function setInitialNodes(nodes: readonly INodeContent[]) {
|
|
||||||
if (!nodes) {
|
|
||||||
throw new Error('missing initial nodes');
|
|
||||||
}
|
|
||||||
const initialNodes = nodes.map((node) => toNewLiquorTreeNode(node));
|
|
||||||
if (props.selectedNodeIds) {
|
|
||||||
recurseDown(
|
|
||||||
initialNodes,
|
|
||||||
(node) => {
|
|
||||||
node.state = updateState(node.state, node, props.selectedNodeIds);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
initialLiquorTreeNodes.value = initialNodes;
|
|
||||||
const api = await getLiquorTreeApi();
|
|
||||||
api.setModel(initialLiquorTreeNodes.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setFilterText(filterText?: string) {
|
|
||||||
const api = await getLiquorTreeApi();
|
|
||||||
if (!filterText) {
|
|
||||||
api.clearFilter();
|
|
||||||
} else {
|
|
||||||
api.filter('filtered'); // text does not matter, it'll trigger the filterPredicate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setSelectedStatus(selectedNodeIds: ReadonlyArray<string>) {
|
|
||||||
if (!selectedNodeIds) {
|
|
||||||
throw new Error('Selected recurseDown nodes are undefined');
|
|
||||||
}
|
|
||||||
const tree = await getLiquorTreeApi();
|
|
||||||
tree.recurseDown(
|
|
||||||
(node) => {
|
|
||||||
node.states = updateState(node.states, node, selectedNodeIds);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getLiquorTreeApi(): Promise<ILiquorTree> {
|
|
||||||
const tree = await tryUntilDefined(
|
|
||||||
() => liquorTree.value?.tree,
|
|
||||||
5,
|
|
||||||
20,
|
|
||||||
);
|
|
||||||
if (!tree) {
|
|
||||||
throw Error('Referenced tree element cannot be found. Perhaps it\'s not yet rendered?');
|
|
||||||
}
|
|
||||||
return tree;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
liquorTreeOptions,
|
|
||||||
initialLiquorTreeNodes,
|
|
||||||
convertExistingToNode,
|
|
||||||
nodeSelected,
|
|
||||||
liquorTree,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
function updateState(
|
|
||||||
old: ILiquorTreeNodeState,
|
|
||||||
node: ILiquorTreeNode,
|
|
||||||
selectedNodeIds: ReadonlyArray<string>,
|
|
||||||
): ILiquorTreeNodeState {
|
|
||||||
return { ...old, ...getNewState(node, selectedNodeIds) };
|
|
||||||
}
|
|
||||||
|
|
||||||
function recurseDown(
|
|
||||||
nodes: ReadonlyArray<ILiquorTreeNewNode>,
|
|
||||||
handler: (node: ILiquorTreeNewNode) => void,
|
|
||||||
) {
|
|
||||||
for (const node of nodes) {
|
|
||||||
handler(node);
|
|
||||||
if (node.children) {
|
|
||||||
recurseDown(node.children, handler);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function tryUntilDefined<T>(
|
|
||||||
accessor: () => T | undefined,
|
|
||||||
delayInMs: number,
|
|
||||||
maxTries: number,
|
|
||||||
): Promise<T | undefined> {
|
|
||||||
let triesLeft = maxTries;
|
|
||||||
let value: T;
|
|
||||||
while (triesLeft !== 0) {
|
|
||||||
value = accessor();
|
|
||||||
if (value) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
triesLeft--;
|
|
||||||
// eslint-disable-next-line no-await-in-loop
|
|
||||||
await sleep(delayInMs);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -42,7 +42,7 @@ import {
|
|||||||
inject,
|
inject,
|
||||||
} from 'vue';
|
} from 'vue';
|
||||||
import { InjectionKeys } from '@/presentation/injectionSymbols';
|
import { InjectionKeys } from '@/presentation/injectionSymbols';
|
||||||
import ScriptsTree from '@/presentation/components/Scripts/View/ScriptsTree/ScriptsTree.vue';
|
import ScriptsTree from '@/presentation/components/Scripts/View/Tree/ScriptsTree.vue';
|
||||||
import CardList from '@/presentation/components/Scripts/View/Cards/CardList.vue';
|
import CardList from '@/presentation/components/Scripts/View/Cards/CardList.vue';
|
||||||
import { ViewType } from '@/presentation/components/Scripts/Menu/View/ViewType';
|
import { ViewType } from '@/presentation/components/Scripts/Menu/View/ViewType';
|
||||||
import { IReadOnlyUserFilter } from '@/application/Context/State/Filter/IUserFilter';
|
import { IReadOnlyUserFilter } from '@/application/Context/State/Filter/IUserFilter';
|
||||||
@@ -139,6 +139,7 @@ $margin-inner: 4px;
|
|||||||
padding-top: 15px;
|
padding-top: 15px;
|
||||||
padding-bottom: 15px;
|
padding-bottom: 15px;
|
||||||
&--searching {
|
&--searching {
|
||||||
|
background-color: $color-primary-darker;
|
||||||
padding-top: 0px;
|
padding-top: 0px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
<template>
|
<template>
|
||||||
<DocumentableNode :docs="data.docs">
|
<DocumentableNode :docs="nodeMetadata.docs">
|
||||||
<div id="node">
|
<div id="node">
|
||||||
<div class="item text">{{ data.text }}</div>
|
<div class="item text">{{ nodeMetadata.text }}</div>
|
||||||
<RevertToggle
|
<RevertToggle
|
||||||
class="item"
|
class="item"
|
||||||
v-if="data.isReversible"
|
v-if="nodeMetadata.isReversible"
|
||||||
:node="data" />
|
:node="nodeMetadata" />
|
||||||
</div>
|
</div>
|
||||||
</DocumentableNode>
|
</DocumentableNode>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent, PropType } from 'vue';
|
import { defineComponent, PropType } from 'vue';
|
||||||
import { INodeContent } from './INodeContent';
|
import { NodeMetadata } from './NodeMetadata';
|
||||||
import RevertToggle from './RevertToggle.vue';
|
import RevertToggle from './RevertToggle.vue';
|
||||||
import DocumentableNode from './Documentation/DocumentableNode.vue';
|
import DocumentableNode from './Documentation/DocumentableNode.vue';
|
||||||
|
|
||||||
@@ -22,8 +22,8 @@ export default defineComponent({
|
|||||||
DocumentableNode,
|
DocumentableNode,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
data: {
|
nodeMetadata: {
|
||||||
type: Object as PropType<INodeContent>,
|
type: Object as PropType<NodeMetadata>,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -3,11 +3,11 @@ export enum NodeType {
|
|||||||
Category,
|
Category,
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface INodeContent {
|
export interface NodeMetadata {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly text: string;
|
readonly text: string;
|
||||||
readonly isReversible: boolean;
|
readonly isReversible: boolean;
|
||||||
readonly docs: ReadonlyArray<string>;
|
readonly docs: ReadonlyArray<string>;
|
||||||
readonly children?: ReadonlyArray<INodeContent>;
|
readonly children?: ReadonlyArray<NodeMetadata>;
|
||||||
readonly type: NodeType;
|
readonly type: NodeType;
|
||||||
}
|
}
|
||||||
@@ -13,8 +13,8 @@ import {
|
|||||||
} from 'vue';
|
} from 'vue';
|
||||||
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
|
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
|
||||||
import { InjectionKeys } from '@/presentation/injectionSymbols';
|
import { InjectionKeys } from '@/presentation/injectionSymbols';
|
||||||
|
import { NodeMetadata } from '@/presentation/components/Scripts/View/Tree/NodeContent/NodeMetadata';
|
||||||
import { IReverter } from './Reverter/IReverter';
|
import { IReverter } from './Reverter/IReverter';
|
||||||
import { INodeContent } from './INodeContent';
|
|
||||||
import { getReverter } from './Reverter/ReverterFactory';
|
import { getReverter } from './Reverter/ReverterFactory';
|
||||||
import ToggleSwitch from './ToggleSwitch.vue';
|
import ToggleSwitch from './ToggleSwitch.vue';
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
node: {
|
node: {
|
||||||
type: Object as PropType<INodeContent>,
|
type: Object as PropType<NodeMetadata>,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -51,7 +51,7 @@ export default defineComponent({
|
|||||||
]);
|
]);
|
||||||
}, { immediate: true });
|
}, { immediate: true });
|
||||||
|
|
||||||
function onNodeChanged(node: INodeContent) {
|
function onNodeChanged(node: NodeMetadata) {
|
||||||
handler = getReverter(node, currentState.value.collection);
|
handler = getReverter(node, currentState.value.collection);
|
||||||
updateRevertStatusFromState(currentState.value.selection.selectedScripts);
|
updateRevertStatusFromState(currentState.value.selection.selectedScripts);
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
|
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
|
||||||
import { IUserSelection } from '@/application/Context/State/Selection/IUserSelection';
|
import { IUserSelection } from '@/application/Context/State/Selection/IUserSelection';
|
||||||
import { ICategoryCollection } from '@/domain/ICategoryCollection';
|
import { ICategoryCollection } from '@/domain/ICategoryCollection';
|
||||||
import { getCategoryId } from '../../../ScriptNodeParser';
|
import { getCategoryId } from '../../TreeViewAdapter/CategoryNodeMetadataConverter';
|
||||||
import { IReverter } from './IReverter';
|
import { IReverter } from './IReverter';
|
||||||
import { ScriptReverter } from './ScriptReverter';
|
import { ScriptReverter } from './ScriptReverter';
|
||||||
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import { ICategoryCollection } from '@/domain/ICategoryCollection';
|
import { ICategoryCollection } from '@/domain/ICategoryCollection';
|
||||||
import { INodeContent, NodeType } from '../INodeContent';
|
import { NodeMetadata, NodeType } from '../NodeMetadata';
|
||||||
import { IReverter } from './IReverter';
|
import { IReverter } from './IReverter';
|
||||||
import { ScriptReverter } from './ScriptReverter';
|
import { ScriptReverter } from './ScriptReverter';
|
||||||
import { CategoryReverter } from './CategoryReverter';
|
import { CategoryReverter } from './CategoryReverter';
|
||||||
|
|
||||||
export function getReverter(node: INodeContent, collection: ICategoryCollection): IReverter {
|
export function getReverter(node: NodeMetadata, collection: ICategoryCollection): IReverter {
|
||||||
switch (node.type) {
|
switch (node.type) {
|
||||||
case NodeType.Category:
|
case NodeType.Category:
|
||||||
return new CategoryReverter(node.id, collection);
|
return new CategoryReverter(node.id, collection);
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
|
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
|
||||||
import { IUserSelection } from '@/application/Context/State/Selection/IUserSelection';
|
import { IUserSelection } from '@/application/Context/State/Selection/IUserSelection';
|
||||||
import { getScriptId } from '../../../ScriptNodeParser';
|
import { getScriptId } from '../../TreeViewAdapter/CategoryNodeMetadataConverter';
|
||||||
import { IReverter } from './IReverter';
|
import { IReverter } from './IReverter';
|
||||||
|
|
||||||
export class ScriptReverter implements IReverter {
|
export class ScriptReverter implements IReverter {
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<template>
|
||||||
|
<span id="container">
|
||||||
|
<span v-if="initialNodes.length">
|
||||||
|
<TreeView
|
||||||
|
:initialNodes="initialNodes"
|
||||||
|
:selectedLeafNodeIds="selectedScriptNodeIds"
|
||||||
|
:latestFilterEvent="latestFilterEvent"
|
||||||
|
@nodeStateChanged="handleNodeChangedEvent($event)"
|
||||||
|
>
|
||||||
|
<template v-slot:node-content="{ nodeMetadata }">
|
||||||
|
<NodeContent :nodeMetadata="nodeMetadata" />
|
||||||
|
</template>
|
||||||
|
</TreeView>
|
||||||
|
</span>
|
||||||
|
<span v-else>Nooo 😢</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent } from 'vue';
|
||||||
|
import TreeView from './TreeView/TreeView.vue';
|
||||||
|
import NodeContent from './NodeContent/NodeContent.vue';
|
||||||
|
import { useTreeViewFilterEvent } from './TreeViewAdapter/UseTreeViewFilterEvent';
|
||||||
|
import { useTreeViewNodeInput } from './TreeViewAdapter/UseTreeViewNodeInput';
|
||||||
|
import { useCollectionSelectionStateUpdater } from './TreeViewAdapter/UseCollectionSelectionStateUpdater';
|
||||||
|
import { TreeNodeStateChangedEmittedEvent } from './TreeView/Bindings/TreeNodeStateChangedEmittedEvent';
|
||||||
|
import { useSelectedScriptNodeIds } from './TreeViewAdapter/UseSelectedScriptNodeIds';
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
props: {
|
||||||
|
categoryId: {
|
||||||
|
type: [Number, undefined],
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
TreeView,
|
||||||
|
NodeContent,
|
||||||
|
},
|
||||||
|
setup(props) {
|
||||||
|
const { selectedScriptNodeIds } = useSelectedScriptNodeIds();
|
||||||
|
const { latestFilterEvent } = useTreeViewFilterEvent();
|
||||||
|
const { treeViewInputNodes } = useTreeViewNodeInput(() => props.categoryId);
|
||||||
|
const { updateNodeSelection } = useCollectionSelectionStateUpdater();
|
||||||
|
|
||||||
|
function handleNodeChangedEvent(event: TreeNodeStateChangedEmittedEvent) {
|
||||||
|
updateNodeSelection(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
initialNodes: treeViewInputNodes,
|
||||||
|
selectedScriptNodeIds,
|
||||||
|
latestFilterEvent,
|
||||||
|
handleNodeChangedEvent,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import type { ReadOnlyTreeNode } from '../Node/TreeNode';
|
||||||
|
|
||||||
|
export interface TreeViewFilterEvent {
|
||||||
|
readonly action: TreeViewFilterAction;
|
||||||
|
/**
|
||||||
|
* A simple numeric value to ensure uniqueness of each event.
|
||||||
|
*
|
||||||
|
* This property is used to guarantee that the watch function will trigger
|
||||||
|
* even if the same filter action value is emitted consecutively.
|
||||||
|
*/
|
||||||
|
readonly timestamp: Date;
|
||||||
|
|
||||||
|
readonly predicate?: TreeViewFilterPredicate;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum TreeViewFilterAction {
|
||||||
|
Triggered,
|
||||||
|
Removed,
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TreeViewFilterPredicate = (node: ReadOnlyTreeNode) => boolean;
|
||||||
|
|
||||||
|
export function createFilterTriggeredEvent(
|
||||||
|
predicate: TreeViewFilterPredicate,
|
||||||
|
): TreeViewFilterEvent {
|
||||||
|
return {
|
||||||
|
action: TreeViewFilterAction.Triggered,
|
||||||
|
timestamp: new Date(),
|
||||||
|
predicate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFilterRemovedEvent(): TreeViewFilterEvent {
|
||||||
|
return {
|
||||||
|
action: TreeViewFilterAction.Removed,
|
||||||
|
timestamp: new Date(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export interface TreeInputNodeData {
|
||||||
|
readonly id: string;
|
||||||
|
readonly children?: readonly TreeInputNodeData[];
|
||||||
|
readonly parent?: TreeInputNodeData | null;
|
||||||
|
readonly data?: object;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { NodeStateChangedEvent } from '../Node/State/StateAccess';
|
||||||
|
import { ReadOnlyTreeNode } from '../Node/TreeNode';
|
||||||
|
|
||||||
|
export interface TreeNodeStateChangedEmittedEvent {
|
||||||
|
readonly change: NodeStateChangedEvent;
|
||||||
|
readonly node: ReadOnlyTreeNode;
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
<template>
|
||||||
|
<div class="wrapper" v-if="currentNode">
|
||||||
|
<div
|
||||||
|
class="expansible-node"
|
||||||
|
@click="toggleCheck"
|
||||||
|
:style="{
|
||||||
|
'padding-left': `${currentNode.hierarchy.depthInTree * 24}px`,
|
||||||
|
}">
|
||||||
|
<div
|
||||||
|
class="expand-collapse-arrow"
|
||||||
|
:class="{
|
||||||
|
expanded: expanded,
|
||||||
|
'has-children': hasChildren,
|
||||||
|
}"
|
||||||
|
@click.stop="toggleExpand"
|
||||||
|
/>
|
||||||
|
<LeafTreeNode
|
||||||
|
:nodeId="nodeId"
|
||||||
|
:treeRoot="treeRoot"
|
||||||
|
>
|
||||||
|
<template v-slot:node-content="slotProps">
|
||||||
|
<slot name="node-content" v-bind="slotProps" />
|
||||||
|
</template>
|
||||||
|
</LeafTreeNode>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<transition name="children-transition">
|
||||||
|
<ul
|
||||||
|
v-if="hasChildren && expanded"
|
||||||
|
class="children"
|
||||||
|
>
|
||||||
|
<HierarchicalTreeNode
|
||||||
|
v-for="id in renderedNodeIds"
|
||||||
|
:key="id"
|
||||||
|
:nodeId="id"
|
||||||
|
:treeRoot="treeRoot"
|
||||||
|
:renderingStrategy="renderingStrategy"
|
||||||
|
>
|
||||||
|
<template v-slot:node-content="slotProps">
|
||||||
|
<slot name="node-content" v-bind="slotProps" />
|
||||||
|
</template>
|
||||||
|
</HierarchicalTreeNode>
|
||||||
|
</ul>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
defineComponent, computed, PropType,
|
||||||
|
} from 'vue';
|
||||||
|
import { TreeRoot } from '../TreeRoot/TreeRoot';
|
||||||
|
import { useCurrentTreeNodes } from '../UseCurrentTreeNodes';
|
||||||
|
import { NodeRenderingStrategy } from '../Rendering/NodeRenderingStrategy';
|
||||||
|
import { useNodeState } from './UseNodeState';
|
||||||
|
import { TreeNode } from './TreeNode';
|
||||||
|
import LeafTreeNode from './LeafTreeNode.vue';
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'HierarchicalTreeNode', // Needed due to recursion
|
||||||
|
components: {
|
||||||
|
LeafTreeNode,
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
nodeId: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
treeRoot: {
|
||||||
|
type: Object as PropType<TreeRoot>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
renderingStrategy: {
|
||||||
|
type: Object as PropType<NodeRenderingStrategy>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
setup(props) {
|
||||||
|
const { nodes } = useCurrentTreeNodes(() => props.treeRoot);
|
||||||
|
const currentNode = computed<TreeNode | undefined>(
|
||||||
|
() => nodes.value?.getNodeById(props.nodeId),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { state } = useNodeState(() => currentNode.value);
|
||||||
|
const expanded = computed<boolean>(() => state.value?.isExpanded ?? false);
|
||||||
|
|
||||||
|
const renderedNodeIds = computed<readonly string[]>(
|
||||||
|
() => currentNode.value
|
||||||
|
?.hierarchy
|
||||||
|
.children
|
||||||
|
.filter((child) => props.renderingStrategy.shouldRender(child))
|
||||||
|
.map((child) => child.id)
|
||||||
|
?? [],
|
||||||
|
);
|
||||||
|
|
||||||
|
function toggleExpand() {
|
||||||
|
currentNode.value?.state.toggleExpand();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCheck() {
|
||||||
|
currentNode.value?.state.toggleCheck();
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasChildren = computed<boolean>(
|
||||||
|
() => currentNode.value?.hierarchy.isBranchNode,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
renderedNodeIds,
|
||||||
|
expanded,
|
||||||
|
toggleCheck,
|
||||||
|
toggleExpand,
|
||||||
|
currentNode,
|
||||||
|
hasChildren,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
@use "@/presentation/assets/styles/main" as *;
|
||||||
|
@use "./../tree-colors" as *;
|
||||||
|
|
||||||
|
.wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
.children {
|
||||||
|
@include reset-ul;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.expansible-node {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
@include hover-or-touch {
|
||||||
|
background: $color-node-highlight-bg;
|
||||||
|
}
|
||||||
|
.expand-collapse-arrow {
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: 30px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-left: 30px;
|
||||||
|
width: 0;
|
||||||
|
|
||||||
|
&:after {
|
||||||
|
position: absolute;
|
||||||
|
display: block;
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
&.has-children {
|
||||||
|
margin-left: 0;
|
||||||
|
width: 30px;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&:after {
|
||||||
|
border: 1.5px solid $color-node-arrow;
|
||||||
|
position: absolute;
|
||||||
|
border-left: 0;
|
||||||
|
border-top: 0;
|
||||||
|
left: 9px;
|
||||||
|
top: 50%;
|
||||||
|
height: 9px;
|
||||||
|
width: 9px;
|
||||||
|
transform: rotate(-45deg) translateY(-50%) translateX(0);
|
||||||
|
transition: transform .25s;
|
||||||
|
transform-origin: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.expanded:after {
|
||||||
|
transform: rotate(45deg) translateY(-50%) translateX(-5px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin left-fade-transition($name) {
|
||||||
|
.#{$name}-enter-active,
|
||||||
|
.#{$name}-leave-active {
|
||||||
|
transition: opacity .3s, transform .3s;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.#{$name}-enter,
|
||||||
|
// Vue 2.X compatibility
|
||||||
|
.#{$name}-enter-from,
|
||||||
|
// Vue 3.X compatibility
|
||||||
|
.#{$name}-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-2em);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@include left-fade-transition('children-transition');
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { ReadOnlyTreeNode, TreeNode } from '../TreeNode';
|
||||||
|
|
||||||
|
export interface HierarchyReader {
|
||||||
|
readonly depthInTree: number;
|
||||||
|
readonly parent: ReadOnlyTreeNode | undefined;
|
||||||
|
readonly children: readonly ReadOnlyTreeNode[];
|
||||||
|
readonly isLeafNode: boolean;
|
||||||
|
readonly isBranchNode: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HierarchyWriter {
|
||||||
|
setParent(parent: TreeNode): void;
|
||||||
|
setChildren(children: readonly TreeNode[]): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HierarchyAccess extends HierarchyReader, HierarchyWriter {
|
||||||
|
readonly parent: TreeNode | undefined;
|
||||||
|
readonly children: readonly TreeNode[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { TreeNode } from '../TreeNode';
|
||||||
|
import { HierarchyAccess } from './HierarchyAccess';
|
||||||
|
|
||||||
|
export class TreeNodeHierarchy implements HierarchyAccess {
|
||||||
|
public parent: TreeNode | undefined = undefined;
|
||||||
|
|
||||||
|
public get depthInTree(): number {
|
||||||
|
if (!this.parent) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return this.parent.hierarchy.depthInTree + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get isLeafNode(): boolean {
|
||||||
|
return this.children.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get isBranchNode(): boolean {
|
||||||
|
return this.children.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public children: readonly TreeNode[];
|
||||||
|
|
||||||
|
public setChildren(children: readonly TreeNode[]): void {
|
||||||
|
this.children = children;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setParent(parent: TreeNode): void {
|
||||||
|
this.parent = parent;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
<template>
|
||||||
|
<li
|
||||||
|
v-if="currentNode"
|
||||||
|
class="wrapper"
|
||||||
|
@click.stop="toggleCheckState"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="node focusable"
|
||||||
|
@focus="onNodeFocus"
|
||||||
|
tabindex="-1"
|
||||||
|
:class="{
|
||||||
|
'keyboard-focus': hasKeyboardFocus,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="checkbox"
|
||||||
|
:class="{
|
||||||
|
checked: checked,
|
||||||
|
indeterminate: indeterminate,
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<slot
|
||||||
|
name="node-content"
|
||||||
|
:nodeMetadata="currentNode.metadata"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
defineComponent, computed, PropType,
|
||||||
|
} from 'vue';
|
||||||
|
import { TreeRoot } from '../TreeRoot/TreeRoot';
|
||||||
|
import { useCurrentTreeNodes } from '../UseCurrentTreeNodes';
|
||||||
|
import { useNodeState } from './UseNodeState';
|
||||||
|
import { useKeyboardInteractionState } from './UseKeyboardInteractionState';
|
||||||
|
import { TreeNode } from './TreeNode';
|
||||||
|
import { TreeNodeCheckState } from './State/CheckState';
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
props: {
|
||||||
|
nodeId: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
treeRoot: {
|
||||||
|
type: Object as PropType<TreeRoot>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
setup(props) {
|
||||||
|
const { isKeyboardBeingUsed } = useKeyboardInteractionState();
|
||||||
|
const { nodes } = useCurrentTreeNodes(() => props.treeRoot);
|
||||||
|
const currentNode = computed<TreeNode | undefined>(
|
||||||
|
() => nodes.value?.getNodeById(props.nodeId),
|
||||||
|
);
|
||||||
|
const { state } = useNodeState(() => currentNode.value);
|
||||||
|
|
||||||
|
const hasFocus = computed<boolean>(() => state.value?.isFocused ?? false);
|
||||||
|
const checked = computed<boolean>(() => state.value?.checkState === TreeNodeCheckState.Checked);
|
||||||
|
const indeterminate = computed<boolean>(
|
||||||
|
() => state.value?.checkState === TreeNodeCheckState.Indeterminate,
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasKeyboardFocus = computed<boolean>(() => {
|
||||||
|
if (!isKeyboardBeingUsed.value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return hasFocus.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const onNodeFocus = () => {
|
||||||
|
if (!currentNode.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
props.treeRoot.focus.setSingleFocus(currentNode.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
function toggleCheckState() {
|
||||||
|
currentNode.value?.state.toggleCheck();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
onNodeFocus,
|
||||||
|
toggleCheckState,
|
||||||
|
indeterminate,
|
||||||
|
checked,
|
||||||
|
currentNode,
|
||||||
|
hasKeyboardFocus,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
@use "@/presentation/assets/styles/main" as *;
|
||||||
|
@use "./../tree-colors" as *;
|
||||||
|
|
||||||
|
.wrapper {
|
||||||
|
flex: 1;
|
||||||
|
padding-bottom: 3px;
|
||||||
|
padding-top: 3px;
|
||||||
|
.focusable {
|
||||||
|
outline: none; // We handle keyboard focus through own styling
|
||||||
|
}
|
||||||
|
.node {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding-bottom: 3px;
|
||||||
|
padding-top: 3px;
|
||||||
|
padding-right: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
&.keyboard-focus {
|
||||||
|
background: $color-node-highlight-bg;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include hover-or-touch {
|
||||||
|
background: $color-node-highlight-bg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox {
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1px solid $color-node-checkbox-border-unchecked;
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: border-color .25s, background-color .25s;
|
||||||
|
background: $color-node-checkbox-bg-unchecked;
|
||||||
|
|
||||||
|
&:after {
|
||||||
|
position: absolute;
|
||||||
|
display: block;
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
&.indeterminate {
|
||||||
|
border-color: $color-node-checkbox-border-unchecked;
|
||||||
|
|
||||||
|
&:after {
|
||||||
|
background-color: $color-node-checkbox-border-indeterminate;
|
||||||
|
top: 50%;
|
||||||
|
left: 20%;
|
||||||
|
right: 20%;
|
||||||
|
height: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.checked {
|
||||||
|
background: $color-node-checkbox-bg-checked;
|
||||||
|
border-color: $color-node-checkbox-border-checked;
|
||||||
|
|
||||||
|
&:after {
|
||||||
|
box-sizing: content-box;
|
||||||
|
border: 1.5px solid $color-node-checkbox-tick-checked;
|
||||||
|
/* probably width would be rounded in most cases */
|
||||||
|
border-left: 0;
|
||||||
|
border-top: 0;
|
||||||
|
left: 9px;
|
||||||
|
top: 3px;
|
||||||
|
height: 15px;
|
||||||
|
width: 8px;
|
||||||
|
transform: rotate(45deg) scaleY(1);
|
||||||
|
transition: transform .25s;
|
||||||
|
transform-origin: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding-left: 9px;
|
||||||
|
padding-right: 6px;
|
||||||
|
flex-grow: 2;
|
||||||
|
text-decoration: none;
|
||||||
|
color: $color-node-fg;
|
||||||
|
line-height: 24px;
|
||||||
|
user-select: none;
|
||||||
|
font-size: 1.5em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export enum TreeNodeCheckState {
|
||||||
|
Unchecked = 0,
|
||||||
|
Checked = 1,
|
||||||
|
Indeterminate = 2,
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { IEventSource } from '@/infrastructure/Events/IEventSource';
|
||||||
|
import { TreeNodeStateDescriptor } from './StateDescriptor';
|
||||||
|
import { TreeNodeCheckState } from './CheckState';
|
||||||
|
|
||||||
|
export interface NodeStateChangedEvent {
|
||||||
|
readonly oldState: TreeNodeStateDescriptor;
|
||||||
|
readonly newState: TreeNodeStateDescriptor;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TreeNodeStateReader {
|
||||||
|
readonly current: TreeNodeStateDescriptor;
|
||||||
|
readonly changed: IEventSource<NodeStateChangedEvent>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
The transactional approach allows for batched state changes.
|
||||||
|
Instead of firing a state change event for every single operation,
|
||||||
|
multiple changes can be batched into a single transaction.
|
||||||
|
This ensures that listeners to the state change event are
|
||||||
|
only notified once per batch of changes, optimizing performance
|
||||||
|
and reducing potential event handling overhead.
|
||||||
|
*/
|
||||||
|
export interface TreeNodeStateTransactor {
|
||||||
|
beginTransaction(): TreeNodeStateTransaction;
|
||||||
|
commitTransaction(transaction: TreeNodeStateTransaction): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TreeNodeStateTransaction {
|
||||||
|
withExpansionState(isExpanded: boolean): TreeNodeStateTransaction;
|
||||||
|
withMatchState(isMatched: boolean): TreeNodeStateTransaction;
|
||||||
|
withFocusState(isFocused: boolean): TreeNodeStateTransaction;
|
||||||
|
withVisibilityState(isVisible: boolean): TreeNodeStateTransaction;
|
||||||
|
withCheckState(checkState: TreeNodeCheckState): TreeNodeStateTransaction;
|
||||||
|
readonly updatedState: Partial<TreeNodeStateDescriptor>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TreeNodeStateWriter extends TreeNodeStateTransactor {
|
||||||
|
toggleCheck(): void;
|
||||||
|
toggleExpand(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TreeNodeStateAccess
|
||||||
|
extends TreeNodeStateReader, TreeNodeStateWriter { }
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { TreeNodeCheckState } from './CheckState';
|
||||||
|
|
||||||
|
export interface TreeNodeStateDescriptor {
|
||||||
|
readonly checkState: TreeNodeCheckState;
|
||||||
|
readonly isExpanded: boolean;
|
||||||
|
readonly isVisible: boolean;
|
||||||
|
readonly isMatched: boolean;
|
||||||
|
readonly isFocused: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { EventSource } from '@/infrastructure/Events/EventSource';
|
||||||
|
import { NodeStateChangedEvent, TreeNodeStateAccess, TreeNodeStateTransaction } from './StateAccess';
|
||||||
|
import { TreeNodeStateDescriptor } from './StateDescriptor';
|
||||||
|
import { TreeNodeCheckState } from './CheckState';
|
||||||
|
import { TreeNodeStateTransactionDescriber } from './TreeNodeStateTransactionDescriber';
|
||||||
|
|
||||||
|
export class TreeNodeState implements TreeNodeStateAccess {
|
||||||
|
public current: TreeNodeStateDescriptor = {
|
||||||
|
checkState: TreeNodeCheckState.Unchecked,
|
||||||
|
isExpanded: false,
|
||||||
|
isVisible: true,
|
||||||
|
isMatched: false,
|
||||||
|
isFocused: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
public readonly changed = new EventSource<NodeStateChangedEvent>();
|
||||||
|
|
||||||
|
public beginTransaction(): TreeNodeStateTransaction {
|
||||||
|
return new TreeNodeStateTransactionDescriber();
|
||||||
|
}
|
||||||
|
|
||||||
|
public commitTransaction(transaction: TreeNodeStateTransaction): void {
|
||||||
|
const oldState = this.current;
|
||||||
|
const newState: TreeNodeStateDescriptor = {
|
||||||
|
...this.current,
|
||||||
|
...transaction.updatedState,
|
||||||
|
};
|
||||||
|
if (areEqual(oldState, newState)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.current = newState;
|
||||||
|
const event: NodeStateChangedEvent = {
|
||||||
|
oldState,
|
||||||
|
newState,
|
||||||
|
};
|
||||||
|
this.changed.notify(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
public toggleCheck(): void {
|
||||||
|
const checkStateTransitions: {
|
||||||
|
readonly [K in TreeNodeCheckState]: TreeNodeCheckState;
|
||||||
|
} = {
|
||||||
|
[TreeNodeCheckState.Checked]: TreeNodeCheckState.Unchecked,
|
||||||
|
[TreeNodeCheckState.Unchecked]: TreeNodeCheckState.Checked,
|
||||||
|
[TreeNodeCheckState.Indeterminate]: TreeNodeCheckState.Unchecked,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.commitTransaction(
|
||||||
|
this.beginTransaction().withCheckState(checkStateTransitions[this.current.checkState]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public toggleExpand(): void {
|
||||||
|
this.commitTransaction(
|
||||||
|
this.beginTransaction().withExpansionState(!this.current.isExpanded),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function areEqual(first: TreeNodeStateDescriptor, second: TreeNodeStateDescriptor): boolean {
|
||||||
|
return first.isFocused === second.isFocused
|
||||||
|
&& first.isMatched === second.isMatched
|
||||||
|
&& first.isVisible === second.isVisible
|
||||||
|
&& first.isExpanded === second.isExpanded
|
||||||
|
&& first.checkState === second.checkState;
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { TreeNodeCheckState } from './CheckState';
|
||||||
|
import { TreeNodeStateTransaction } from './StateAccess';
|
||||||
|
import { TreeNodeStateDescriptor } from './StateDescriptor';
|
||||||
|
|
||||||
|
export class TreeNodeStateTransactionDescriber implements TreeNodeStateTransaction {
|
||||||
|
constructor(public updatedState: Partial<TreeNodeStateDescriptor> = {}) { }
|
||||||
|
|
||||||
|
public withExpansionState(isExpanded: boolean): TreeNodeStateTransaction {
|
||||||
|
return this.describeChange({
|
||||||
|
isExpanded,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public withMatchState(isMatched: boolean): TreeNodeStateTransaction {
|
||||||
|
return this.describeChange({
|
||||||
|
isMatched,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public withFocusState(isFocused: boolean): TreeNodeStateTransaction {
|
||||||
|
return this.describeChange({
|
||||||
|
isFocused,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public withVisibilityState(isVisible: boolean): TreeNodeStateTransaction {
|
||||||
|
return this.describeChange({
|
||||||
|
isVisible,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public withCheckState(checkState: TreeNodeCheckState): TreeNodeStateTransaction {
|
||||||
|
return this.describeChange({
|
||||||
|
checkState,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private describeChange(changedState: Partial<TreeNodeStateDescriptor>): TreeNodeStateTransaction {
|
||||||
|
return new TreeNodeStateTransactionDescriber({
|
||||||
|
...this.updatedState,
|
||||||
|
...changedState,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { HierarchyAccess, HierarchyReader } from './Hierarchy/HierarchyAccess';
|
||||||
|
import type { TreeNodeStateAccess, TreeNodeStateReader } from './State/StateAccess';
|
||||||
|
|
||||||
|
export interface ReadOnlyTreeNode {
|
||||||
|
readonly id: string;
|
||||||
|
readonly state: TreeNodeStateReader;
|
||||||
|
readonly hierarchy: HierarchyReader;
|
||||||
|
readonly metadata?: object;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TreeNode extends ReadOnlyTreeNode {
|
||||||
|
readonly state: TreeNodeStateAccess;
|
||||||
|
readonly hierarchy: HierarchyAccess;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { TreeNode } from './TreeNode';
|
||||||
|
import { TreeNodeStateAccess } from './State/StateAccess';
|
||||||
|
import { TreeNodeState } from './State/TreeNodeState';
|
||||||
|
import { HierarchyAccess } from './Hierarchy/HierarchyAccess';
|
||||||
|
import { TreeNodeHierarchy } from './Hierarchy/TreeNodeHierarchy';
|
||||||
|
|
||||||
|
export class TreeNodeManager implements TreeNode {
|
||||||
|
public readonly state: TreeNodeStateAccess;
|
||||||
|
|
||||||
|
public readonly hierarchy: HierarchyAccess;
|
||||||
|
|
||||||
|
constructor(public readonly id: string, public readonly metadata?: object) {
|
||||||
|
if (!id) {
|
||||||
|
throw new Error('missing id');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.hierarchy = new TreeNodeHierarchy();
|
||||||
|
|
||||||
|
this.state = new TreeNodeState();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { ref, onMounted, onUnmounted } from 'vue';
|
||||||
|
|
||||||
|
export function useKeyboardInteractionState(window: WindowWithEventListeners = globalThis.window) {
|
||||||
|
const isKeyboardBeingUsed = ref(false);
|
||||||
|
|
||||||
|
const enableKeyboardFocus = () => {
|
||||||
|
if (isKeyboardBeingUsed.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isKeyboardBeingUsed.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const disableKeyboardFocus = () => {
|
||||||
|
if (!isKeyboardBeingUsed.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isKeyboardBeingUsed.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('keydown', enableKeyboardFocus, true);
|
||||||
|
window.addEventListener('click', disableKeyboardFocus, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('keydown', enableKeyboardFocus);
|
||||||
|
window.removeEventListener('click', disableKeyboardFocus);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { isKeyboardBeingUsed };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WindowWithEventListeners {
|
||||||
|
addEventListener: typeof global.window.addEventListener;
|
||||||
|
removeEventListener: typeof global.window.removeEventListener;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import {
|
||||||
|
WatchSource, inject, ref, watch,
|
||||||
|
} from 'vue';
|
||||||
|
import { InjectionKeys } from '@/presentation/injectionSymbols';
|
||||||
|
import { ReadOnlyTreeNode } from './TreeNode';
|
||||||
|
import { TreeNodeStateDescriptor } from './State/StateDescriptor';
|
||||||
|
|
||||||
|
export function useNodeState(
|
||||||
|
nodeWatcher: WatchSource<ReadOnlyTreeNode | undefined>,
|
||||||
|
) {
|
||||||
|
const { events } = inject(InjectionKeys.useAutoUnsubscribedEvents)();
|
||||||
|
|
||||||
|
const state = ref<TreeNodeStateDescriptor>();
|
||||||
|
|
||||||
|
watch(nodeWatcher, (node: ReadOnlyTreeNode) => {
|
||||||
|
if (!node) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.value = node.state.current;
|
||||||
|
events.unsubscribeAllAndRegister([
|
||||||
|
node.state.changed.on((change) => {
|
||||||
|
state.value = change.newState;
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { TreeNode } from '../Node/TreeNode';
|
||||||
|
|
||||||
|
export interface NodeRenderingStrategy {
|
||||||
|
shouldRender(node: TreeNode): boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import {
|
||||||
|
WatchSource, computed, shallowRef, triggerRef, watch,
|
||||||
|
} from 'vue';
|
||||||
|
import { ReadOnlyTreeNode } from '../Node/TreeNode';
|
||||||
|
import { useNodeStateChangeAggregator } from '../UseNodeStateChangeAggregator';
|
||||||
|
import { TreeRoot } from '../TreeRoot/TreeRoot';
|
||||||
|
import { useCurrentTreeNodes } from '../UseCurrentTreeNodes';
|
||||||
|
import { NodeRenderingStrategy } from './NodeRenderingStrategy';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders tree nodes gradually to prevent UI freeze when loading large amounts of nodes.
|
||||||
|
*/
|
||||||
|
export function useGradualNodeRendering(
|
||||||
|
treeWatcher: WatchSource<TreeRoot>,
|
||||||
|
): NodeRenderingStrategy {
|
||||||
|
const nodesToRender = new Set<ReadOnlyTreeNode>();
|
||||||
|
const nodesBeingRendered = shallowRef(new Set<ReadOnlyTreeNode>());
|
||||||
|
let isFirstRender = true;
|
||||||
|
let isRenderingInProgress = false;
|
||||||
|
const renderingDelayInMs = 50;
|
||||||
|
const initialBatchSize = 30;
|
||||||
|
const subsequentBatchSize = 5;
|
||||||
|
|
||||||
|
const { onNodeStateChange } = useNodeStateChangeAggregator(treeWatcher);
|
||||||
|
const { nodes } = useCurrentTreeNodes(treeWatcher);
|
||||||
|
|
||||||
|
const orderedNodes = computed<readonly ReadOnlyTreeNode[]>(() => nodes.value.flattenedNodes);
|
||||||
|
|
||||||
|
watch(() => orderedNodes.value, (newNodes) => {
|
||||||
|
newNodes.forEach((node) => updateNodeRenderQueue(node));
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
function updateNodeRenderQueue(node: ReadOnlyTreeNode) {
|
||||||
|
if (node.state.current.isVisible
|
||||||
|
&& !nodesToRender.has(node)
|
||||||
|
&& !nodesBeingRendered.value.has(node)) {
|
||||||
|
nodesToRender.add(node);
|
||||||
|
if (!isRenderingInProgress) {
|
||||||
|
scheduleRendering();
|
||||||
|
}
|
||||||
|
} else if (!node.state.current.isVisible) {
|
||||||
|
if (nodesToRender.has(node)) {
|
||||||
|
nodesToRender.delete(node);
|
||||||
|
}
|
||||||
|
if (nodesBeingRendered.value.has(node)) {
|
||||||
|
nodesBeingRendered.value.delete(node);
|
||||||
|
triggerRef(nodesBeingRendered);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onNodeStateChange((node, change) => {
|
||||||
|
if (change.newState.isVisible === change.oldState.isVisible) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateNodeRenderQueue(node);
|
||||||
|
});
|
||||||
|
|
||||||
|
scheduleRendering();
|
||||||
|
|
||||||
|
function scheduleRendering() {
|
||||||
|
if (isFirstRender) {
|
||||||
|
renderNodeBatch();
|
||||||
|
isFirstRender = false;
|
||||||
|
} else {
|
||||||
|
const delayScheduler = new DelayScheduler(renderingDelayInMs);
|
||||||
|
delayScheduler.schedule(renderNodeBatch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNodeBatch() {
|
||||||
|
if (nodesToRender.size === 0) {
|
||||||
|
isRenderingInProgress = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isRenderingInProgress = true;
|
||||||
|
const batchSize = isFirstRender ? initialBatchSize : subsequentBatchSize;
|
||||||
|
const sortedNodes = Array.from(nodesToRender).sort(
|
||||||
|
(a, b) => orderedNodes.value.indexOf(a) - orderedNodes.value.indexOf(b),
|
||||||
|
);
|
||||||
|
const currentBatch = sortedNodes.slice(0, batchSize);
|
||||||
|
currentBatch.forEach((node) => {
|
||||||
|
nodesToRender.delete(node);
|
||||||
|
nodesBeingRendered.value.add(node);
|
||||||
|
});
|
||||||
|
triggerRef(nodesBeingRendered);
|
||||||
|
if (nodesToRender.size > 0) {
|
||||||
|
scheduleRendering();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldNodeBeRendered(node: ReadOnlyTreeNode) {
|
||||||
|
return nodesBeingRendered.value.has(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
shouldRender: shouldNodeBeRendered,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class DelayScheduler {
|
||||||
|
private timeoutId: ReturnType<typeof setTimeout> = null;
|
||||||
|
|
||||||
|
constructor(private delay: number) {}
|
||||||
|
|
||||||
|
schedule(callback: () => void) {
|
||||||
|
this.clear();
|
||||||
|
this.timeoutId = setTimeout(callback, this.delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
if (this.timeoutId) {
|
||||||
|
clearTimeout(this.timeoutId);
|
||||||
|
this.timeoutId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { ReadOnlyTreeNode, TreeNode } from '../../Node/TreeNode';
|
||||||
|
import { TreeNodeCollection } from '../NodeCollection/TreeNodeCollection';
|
||||||
|
import { SingleNodeFocusManager } from './SingleNodeFocusManager';
|
||||||
|
|
||||||
|
export class SingleNodeCollectionFocusManager implements SingleNodeFocusManager {
|
||||||
|
public get currentSingleFocusedNode(): TreeNode | undefined {
|
||||||
|
const focusedNodes = this.collection.nodes.flattenedNodes.filter(
|
||||||
|
(node) => node.state.current.isFocused,
|
||||||
|
);
|
||||||
|
return focusedNodes.length === 1 ? focusedNodes[0] : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setSingleFocus(focusedNode: ReadOnlyTreeNode): void {
|
||||||
|
this.collection.nodes.flattenedNodes.forEach((node) => {
|
||||||
|
const isFocused = node === focusedNode;
|
||||||
|
node.state.commitTransaction(node.state.beginTransaction().withFocusState(isFocused));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(private readonly collection: TreeNodeCollection) { }
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { ReadOnlyTreeNode, TreeNode } from '../../Node/TreeNode';
|
||||||
|
|
||||||
|
export interface SingleNodeFocusManager {
|
||||||
|
readonly currentSingleFocusedNode: TreeNode | undefined;
|
||||||
|
setSingleFocus(focusedNode: ReadOnlyTreeNode): void;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { ReadOnlyTreeNode, TreeNode } from '../../../Node/TreeNode';
|
||||||
|
|
||||||
|
export interface ReadOnlyQueryableNodes {
|
||||||
|
readonly rootNodes: readonly ReadOnlyTreeNode[];
|
||||||
|
readonly flattenedNodes: readonly ReadOnlyTreeNode[];
|
||||||
|
|
||||||
|
getNodeById(id: string): ReadOnlyTreeNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueryableNodes extends ReadOnlyQueryableNodes {
|
||||||
|
readonly rootNodes: readonly TreeNode[];
|
||||||
|
readonly flattenedNodes: readonly TreeNode[];
|
||||||
|
|
||||||
|
getNodeById(id: string): TreeNode;
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { TreeNode } from '../../../Node/TreeNode';
|
||||||
|
import { QueryableNodes } from './QueryableNodes';
|
||||||
|
|
||||||
|
export class TreeNodeNavigator implements QueryableNodes {
|
||||||
|
public readonly flattenedNodes: readonly TreeNode[];
|
||||||
|
|
||||||
|
constructor(public readonly rootNodes: readonly TreeNode[]) {
|
||||||
|
this.flattenedNodes = flattenNodes(rootNodes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getNodeById(id: string): TreeNode {
|
||||||
|
const foundNode = this.flattenedNodes.find((node) => node.id === id);
|
||||||
|
if (!foundNode) {
|
||||||
|
throw new Error(`Node could not be found: ${id}`);
|
||||||
|
}
|
||||||
|
return foundNode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenNodes(nodes: readonly TreeNode[]): TreeNode[] {
|
||||||
|
return nodes.reduce((flattenedNodes, node) => {
|
||||||
|
flattenedNodes.push(node);
|
||||||
|
if (node.hierarchy.children) {
|
||||||
|
flattenedNodes.push(...flattenNodes(node.hierarchy.children));
|
||||||
|
}
|
||||||
|
return flattenedNodes;
|
||||||
|
}, new Array<TreeNode>());
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { TreeInputNodeData } from '../../Bindings/TreeInputNodeData';
|
||||||
|
import { TreeNode } from '../../Node/TreeNode';
|
||||||
|
import { TreeNodeManager } from '../../Node/TreeNodeManager';
|
||||||
|
|
||||||
|
export function parseTreeInput(
|
||||||
|
input: readonly TreeInputNodeData[],
|
||||||
|
): TreeNode[] {
|
||||||
|
if (!input) {
|
||||||
|
throw new Error('missing input');
|
||||||
|
}
|
||||||
|
if (!Array.isArray(input)) {
|
||||||
|
throw new Error('input data must be an array');
|
||||||
|
}
|
||||||
|
const nodes = input.map((nodeData) => createNode(nodeData));
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createNode(input: TreeInputNodeData): TreeNode {
|
||||||
|
const node = new TreeNodeManager(input.id, input.data);
|
||||||
|
node.hierarchy.setChildren(input.children?.map((child) => {
|
||||||
|
const childNode = createNode(child);
|
||||||
|
childNode.hierarchy.setParent(node);
|
||||||
|
return childNode;
|
||||||
|
}) ?? []);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { IEventSource } from '@/infrastructure/Events/IEventSource';
|
||||||
|
import { TreeInputNodeData } from '../../Bindings/TreeInputNodeData';
|
||||||
|
import { QueryableNodes, ReadOnlyQueryableNodes } from './Query/QueryableNodes';
|
||||||
|
|
||||||
|
export interface ReadOnlyTreeNodeCollection {
|
||||||
|
readonly nodes: ReadOnlyQueryableNodes;
|
||||||
|
readonly nodesUpdated: IEventSource<ReadOnlyQueryableNodes>;
|
||||||
|
updateRootNodes(rootNodes: readonly TreeInputNodeData[]): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TreeNodeCollection extends ReadOnlyTreeNodeCollection {
|
||||||
|
readonly nodes: QueryableNodes;
|
||||||
|
readonly nodesUpdated: IEventSource<QueryableNodes>;
|
||||||
|
updateRootNodes(rootNodes: readonly TreeInputNodeData[]): void;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { EventSource } from '@/infrastructure/Events/EventSource';
|
||||||
|
import { TreeInputNodeData } from '../../Bindings/TreeInputNodeData';
|
||||||
|
import { TreeNodeCollection } from './TreeNodeCollection';
|
||||||
|
import { parseTreeInput } from './TreeInputParser';
|
||||||
|
import { TreeNodeNavigator } from './Query/TreeNodeNavigator';
|
||||||
|
import { QueryableNodes } from './Query/QueryableNodes';
|
||||||
|
|
||||||
|
export class TreeNodeInitializerAndUpdater implements TreeNodeCollection {
|
||||||
|
public nodes: QueryableNodes = new TreeNodeNavigator([]);
|
||||||
|
|
||||||
|
public nodesUpdated = new EventSource<QueryableNodes>();
|
||||||
|
|
||||||
|
public updateRootNodes(rootNodesData: readonly TreeInputNodeData[]): void {
|
||||||
|
if (!rootNodesData?.length) {
|
||||||
|
throw new Error('missing data');
|
||||||
|
}
|
||||||
|
const rootNodes = this.treeNodeParser(rootNodesData);
|
||||||
|
this.nodes = new TreeNodeNavigator(rootNodes);
|
||||||
|
this.nodesUpdated.notify(this.nodes);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(private readonly treeNodeParser = parseTreeInput) { }
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { SingleNodeFocusManager } from './Focus/SingleNodeFocusManager';
|
||||||
|
import { TreeNodeCollection } from './NodeCollection/TreeNodeCollection';
|
||||||
|
|
||||||
|
export interface TreeRoot {
|
||||||
|
readonly collection: TreeNodeCollection;
|
||||||
|
readonly focus: SingleNodeFocusManager;
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<template>
|
||||||
|
<ul
|
||||||
|
class="tree-root"
|
||||||
|
>
|
||||||
|
<HierarchicalTreeNode
|
||||||
|
v-for="nodeId in renderedNodeIds"
|
||||||
|
:key="nodeId"
|
||||||
|
:nodeId="nodeId"
|
||||||
|
:treeRoot="treeRoot"
|
||||||
|
:renderingStrategy="renderingStrategy"
|
||||||
|
>
|
||||||
|
<template v-slot:node-content="slotProps">
|
||||||
|
<slot v-bind="slotProps" />
|
||||||
|
</template>
|
||||||
|
</HierarchicalTreeNode>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
defineComponent, computed, PropType,
|
||||||
|
} from 'vue';
|
||||||
|
import HierarchicalTreeNode from '../Node/HierarchicalTreeNode.vue';
|
||||||
|
import { useCurrentTreeNodes } from '../UseCurrentTreeNodes';
|
||||||
|
import { NodeRenderingStrategy } from '../Rendering/NodeRenderingStrategy';
|
||||||
|
import { TreeRoot } from './TreeRoot';
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
components: {
|
||||||
|
HierarchicalTreeNode,
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
treeRoot: {
|
||||||
|
type: Object as PropType<TreeRoot>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
renderingStrategy: {
|
||||||
|
type: Object as PropType<NodeRenderingStrategy>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
setup(props) {
|
||||||
|
const { nodes } = useCurrentTreeNodes(() => props.treeRoot);
|
||||||
|
|
||||||
|
const renderedNodeIds = computed<string[]>(() => {
|
||||||
|
return nodes
|
||||||
|
.value
|
||||||
|
.rootNodes
|
||||||
|
.filter((node) => props.renderingStrategy.shouldRender(node))
|
||||||
|
.map((node) => node.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
renderedNodeIds,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
@use "@/presentation/assets/styles/main" as *;
|
||||||
|
|
||||||
|
.tree-root {
|
||||||
|
@include reset-ul;
|
||||||
|
margin-block-start: 1em;
|
||||||
|
margin-block-end: 1em;
|
||||||
|
padding-inline-start: 3px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { TreeRoot } from './TreeRoot';
|
||||||
|
import { TreeNodeInitializerAndUpdater } from './NodeCollection/TreeNodeInitializerAndUpdater';
|
||||||
|
import { TreeNodeCollection } from './NodeCollection/TreeNodeCollection';
|
||||||
|
import { SingleNodeFocusManager } from './Focus/SingleNodeFocusManager';
|
||||||
|
import { SingleNodeCollectionFocusManager } from './Focus/SingleNodeCollectionFocusManager';
|
||||||
|
|
||||||
|
export class TreeRootManager implements TreeRoot {
|
||||||
|
public readonly collection: TreeNodeCollection;
|
||||||
|
|
||||||
|
public readonly focus: SingleNodeFocusManager;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
collection: TreeNodeCollection = new TreeNodeInitializerAndUpdater(),
|
||||||
|
createFocusManager: (
|
||||||
|
collection: TreeNodeCollection
|
||||||
|
) => SingleNodeFocusManager = (nodes) => new SingleNodeCollectionFocusManager(nodes),
|
||||||
|
) {
|
||||||
|
this.collection = collection;
|
||||||
|
this.focus = createFocusManager(this.collection);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="tree"
|
||||||
|
ref="treeContainerElement"
|
||||||
|
>
|
||||||
|
<TreeRoot :treeRoot="tree" :renderingStrategy="nodeRenderingScheduler">
|
||||||
|
<template v-slot="slotProps">
|
||||||
|
<slot name="node-content" v-bind="slotProps" />
|
||||||
|
</template>
|
||||||
|
</TreeRoot>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
defineComponent, onMounted, watch,
|
||||||
|
ref, PropType,
|
||||||
|
} from 'vue';
|
||||||
|
import { TreeRootManager } from './TreeRoot/TreeRootManager';
|
||||||
|
import TreeRoot from './TreeRoot/TreeRoot.vue';
|
||||||
|
import { TreeInputNodeData } from './Bindings/TreeInputNodeData';
|
||||||
|
import { TreeViewFilterEvent } from './Bindings/TreeInputFilterEvent';
|
||||||
|
import { useTreeQueryFilter } from './UseTreeQueryFilter';
|
||||||
|
import { useTreeKeyboardNavigation } from './UseTreeKeyboardNavigation';
|
||||||
|
import { useNodeStateChangeAggregator } from './UseNodeStateChangeAggregator';
|
||||||
|
import { useLeafNodeCheckedStateUpdater } from './UseLeafNodeCheckedStateUpdater';
|
||||||
|
import { TreeNodeStateChangedEmittedEvent } from './Bindings/TreeNodeStateChangedEmittedEvent';
|
||||||
|
import { useAutoUpdateParentCheckState } from './UseAutoUpdateParentCheckState';
|
||||||
|
import { useAutoUpdateChildrenCheckState } from './UseAutoUpdateChildrenCheckState';
|
||||||
|
import { useGradualNodeRendering } from './Rendering/UseGradualNodeRendering';
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
components: {
|
||||||
|
TreeRoot,
|
||||||
|
},
|
||||||
|
emits: {
|
||||||
|
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||||
|
nodeStateChanged: (node: TreeNodeStateChangedEmittedEvent) => true,
|
||||||
|
/* eslint-enable @typescript-eslint/no-unused-vars */
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
initialNodes: {
|
||||||
|
type: Array as PropType<readonly TreeInputNodeData[]>,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
latestFilterEvent: {
|
||||||
|
type: Object as PropType<TreeViewFilterEvent | undefined>,
|
||||||
|
default: () => undefined,
|
||||||
|
},
|
||||||
|
selectedLeafNodeIds: {
|
||||||
|
type: Array as PropType<ReadonlyArray<string>>,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
setup(props, { emit }) {
|
||||||
|
const treeContainerElement = ref<HTMLElement | undefined>();
|
||||||
|
|
||||||
|
const tree = new TreeRootManager();
|
||||||
|
|
||||||
|
useTreeKeyboardNavigation(tree, treeContainerElement);
|
||||||
|
useTreeQueryFilter(
|
||||||
|
() => props.latestFilterEvent,
|
||||||
|
() => tree,
|
||||||
|
);
|
||||||
|
useLeafNodeCheckedStateUpdater(() => tree, () => props.selectedLeafNodeIds);
|
||||||
|
useAutoUpdateParentCheckState(() => tree);
|
||||||
|
useAutoUpdateChildrenCheckState(() => tree);
|
||||||
|
const nodeRenderingScheduler = useGradualNodeRendering(() => tree);
|
||||||
|
|
||||||
|
const { onNodeStateChange } = useNodeStateChangeAggregator(() => tree);
|
||||||
|
onNodeStateChange((node, change) => {
|
||||||
|
emit('nodeStateChanged', { node, change });
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
watch(() => props.initialNodes, (nodes) => {
|
||||||
|
tree.collection.updateRootNodes(nodes);
|
||||||
|
}, { immediate: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
treeContainerElement,
|
||||||
|
nodeRenderingScheduler,
|
||||||
|
tree,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
@use "./tree-colors" as *;
|
||||||
|
|
||||||
|
.tree {
|
||||||
|
overflow: auto;
|
||||||
|
background: $color-tree-bg;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { WatchSource } from 'vue';
|
||||||
|
import { TreeRoot } from './TreeRoot/TreeRoot';
|
||||||
|
import { useNodeStateChangeAggregator } from './UseNodeStateChangeAggregator';
|
||||||
|
import { HierarchyAccess } from './Node/Hierarchy/HierarchyAccess';
|
||||||
|
import { TreeNodeCheckState } from './Node/State/CheckState';
|
||||||
|
|
||||||
|
export function useAutoUpdateChildrenCheckState(
|
||||||
|
treeWatcher: WatchSource<TreeRoot>,
|
||||||
|
) {
|
||||||
|
const { onNodeStateChange } = useNodeStateChangeAggregator(treeWatcher);
|
||||||
|
|
||||||
|
onNodeStateChange((node, change) => {
|
||||||
|
if (change.newState.checkState === change.oldState.checkState) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateChildrenCheckedState(node.hierarchy, change.newState.checkState);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateChildrenCheckedState(
|
||||||
|
node: HierarchyAccess,
|
||||||
|
newParentState: TreeNodeCheckState,
|
||||||
|
) {
|
||||||
|
if (node.isLeafNode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!shouldUpdateChildren(newParentState)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { children } = node;
|
||||||
|
children.forEach((childNode) => {
|
||||||
|
if (childNode.state.current.checkState === newParentState) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
childNode.state.commitTransaction(
|
||||||
|
childNode.state.beginTransaction().withCheckState(newParentState),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldUpdateChildren(newParentState: TreeNodeCheckState) {
|
||||||
|
return newParentState === TreeNodeCheckState.Checked
|
||||||
|
|| newParentState === TreeNodeCheckState.Unchecked;
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { WatchSource } from 'vue';
|
||||||
|
import { TreeRoot } from './TreeRoot/TreeRoot';
|
||||||
|
import { useNodeStateChangeAggregator } from './UseNodeStateChangeAggregator';
|
||||||
|
import { HierarchyAccess } from './Node/Hierarchy/HierarchyAccess';
|
||||||
|
import { TreeNodeCheckState } from './Node/State/CheckState';
|
||||||
|
import { ReadOnlyTreeNode } from './Node/TreeNode';
|
||||||
|
|
||||||
|
export function useAutoUpdateParentCheckState(
|
||||||
|
treeWatcher: WatchSource<TreeRoot>,
|
||||||
|
) {
|
||||||
|
const { onNodeStateChange } = useNodeStateChangeAggregator(treeWatcher);
|
||||||
|
|
||||||
|
onNodeStateChange((node, change) => {
|
||||||
|
if (change.newState.checkState === change.oldState.checkState) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateNodeParentCheckedState(node.hierarchy);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateNodeParentCheckedState(
|
||||||
|
node: HierarchyAccess,
|
||||||
|
) {
|
||||||
|
const { parent } = node;
|
||||||
|
if (!parent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newState = getNewStateCheckedStateBasedOnChildren(parent);
|
||||||
|
if (newState === parent.state.current.checkState) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
parent.state.commitTransaction(
|
||||||
|
parent.state.beginTransaction().withCheckState(newState),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNewStateCheckedStateBasedOnChildren(node: ReadOnlyTreeNode): TreeNodeCheckState {
|
||||||
|
const { children } = node.hierarchy;
|
||||||
|
const childrenStates = children.map((child) => child.state.current.checkState);
|
||||||
|
if (childrenStates.every((state) => state === TreeNodeCheckState.Unchecked)) {
|
||||||
|
return TreeNodeCheckState.Unchecked;
|
||||||
|
}
|
||||||
|
if (childrenStates.every((state) => state === TreeNodeCheckState.Checked)) {
|
||||||
|
return TreeNodeCheckState.Checked;
|
||||||
|
}
|
||||||
|
return TreeNodeCheckState.Indeterminate;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import {
|
||||||
|
WatchSource, watch, inject, readonly, ref,
|
||||||
|
} from 'vue';
|
||||||
|
import { InjectionKeys } from '@/presentation/injectionSymbols';
|
||||||
|
import { TreeRoot } from './TreeRoot/TreeRoot';
|
||||||
|
import { QueryableNodes } from './TreeRoot/NodeCollection/Query/QueryableNodes';
|
||||||
|
|
||||||
|
export function useCurrentTreeNodes(treeWatcher: WatchSource<TreeRoot>) {
|
||||||
|
const { events } = inject(InjectionKeys.useAutoUnsubscribedEvents)();
|
||||||
|
|
||||||
|
const tree = ref<TreeRoot>();
|
||||||
|
const nodes = ref<QueryableNodes | undefined>();
|
||||||
|
|
||||||
|
watch(treeWatcher, (newTree) => {
|
||||||
|
tree.value = newTree;
|
||||||
|
nodes.value = newTree.collection.nodes;
|
||||||
|
events.unsubscribeAllAndRegister([
|
||||||
|
newTree.collection.nodesUpdated.on((newNodes) => {
|
||||||
|
nodes.value = newNodes;
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodes: readonly(nodes),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { WatchSource, watch } from 'vue';
|
||||||
|
import { TreeRoot } from './TreeRoot/TreeRoot';
|
||||||
|
import { TreeNode } from './Node/TreeNode';
|
||||||
|
import { QueryableNodes } from './TreeRoot/NodeCollection/Query/QueryableNodes';
|
||||||
|
import { useCurrentTreeNodes } from './UseCurrentTreeNodes';
|
||||||
|
import { TreeNodeCheckState } from './Node/State/CheckState';
|
||||||
|
|
||||||
|
export function useLeafNodeCheckedStateUpdater(
|
||||||
|
treeWatcher: WatchSource<TreeRoot>,
|
||||||
|
leafNodeIdsWatcher: WatchSource<readonly string[]>,
|
||||||
|
) {
|
||||||
|
const { nodes } = useCurrentTreeNodes(treeWatcher);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[leafNodeIdsWatcher, () => nodes.value],
|
||||||
|
([nodeIds, actualNodes]) => {
|
||||||
|
updateNodeSelections(actualNodes, nodeIds);
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateNodeSelections(
|
||||||
|
nodes: QueryableNodes,
|
||||||
|
selectedNodeIds: readonly string[],
|
||||||
|
) {
|
||||||
|
nodes.flattenedNodes.forEach((node) => {
|
||||||
|
updateNodeSelection(node, selectedNodeIds);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateNodeSelection(
|
||||||
|
node: TreeNode,
|
||||||
|
selectedNodeIds: readonly string[],
|
||||||
|
) {
|
||||||
|
if (!node.hierarchy.isLeafNode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newState = selectedNodeIds.includes(node.id)
|
||||||
|
? TreeNodeCheckState.Checked
|
||||||
|
: TreeNodeCheckState.Unchecked;
|
||||||
|
node.state.commitTransaction(node.state.beginTransaction().withCheckState(newState));
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { WatchSource, inject, watch } from 'vue';
|
||||||
|
import { InjectionKeys } from '@/presentation/injectionSymbols';
|
||||||
|
import { TreeRoot } from './TreeRoot/TreeRoot';
|
||||||
|
import { TreeNode } from './Node/TreeNode';
|
||||||
|
import { useCurrentTreeNodes } from './UseCurrentTreeNodes';
|
||||||
|
import { NodeStateChangedEvent } from './Node/State/StateAccess';
|
||||||
|
|
||||||
|
type NodeStateChangeEventCallback = (
|
||||||
|
node: TreeNode,
|
||||||
|
stateChange: NodeStateChangedEvent,
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
export function useNodeStateChangeAggregator(treeWatcher: WatchSource<TreeRoot>) {
|
||||||
|
const { nodes } = useCurrentTreeNodes(treeWatcher);
|
||||||
|
const { events } = inject(InjectionKeys.useAutoUnsubscribedEvents)();
|
||||||
|
|
||||||
|
const onNodeChangeCallbacks = new Array<NodeStateChangeEventCallback>();
|
||||||
|
|
||||||
|
watch(() => nodes.value, (newNodes) => {
|
||||||
|
events.unsubscribeAll();
|
||||||
|
newNodes.flattenedNodes.forEach((node) => {
|
||||||
|
events.register([
|
||||||
|
node.state.changed.on((stateChange) => {
|
||||||
|
onNodeChangeCallbacks.forEach((callback) => callback(node, stateChange));
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
onNodeStateChange: (
|
||||||
|
callback: NodeStateChangeEventCallback,
|
||||||
|
) => onNodeChangeCallbacks.push(callback),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { onMounted, onUnmounted, Ref } from 'vue';
|
||||||
|
import { TreeRoot } from './TreeRoot/TreeRoot';
|
||||||
|
import { TreeNodeCheckState } from './Node/State/CheckState';
|
||||||
|
import { SingleNodeFocusManager } from './TreeRoot/Focus/SingleNodeFocusManager';
|
||||||
|
import { QueryableNodes } from './TreeRoot/NodeCollection/Query/QueryableNodes';
|
||||||
|
import { TreeNode } from './Node/TreeNode';
|
||||||
|
|
||||||
|
type TreeNavigationKeyCodes = 'ArrowLeft' | 'ArrowUp' | 'ArrowRight' | 'ArrowDown' | ' ' | 'Enter';
|
||||||
|
|
||||||
|
export function useTreeKeyboardNavigation(
|
||||||
|
treeRoot: TreeRoot,
|
||||||
|
treeElementRef: Ref<HTMLElement | undefined>,
|
||||||
|
) {
|
||||||
|
useKeyboardListener(treeElementRef, (event) => {
|
||||||
|
if (!treeElementRef.value) {
|
||||||
|
return; // Not yet initialized?
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyCode = event.key as TreeNavigationKeyCodes;
|
||||||
|
|
||||||
|
if (!treeRoot.focus.currentSingleFocusedNode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const action = KeyToActionMapping[keyCode];
|
||||||
|
|
||||||
|
if (!action) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
action({
|
||||||
|
focus: treeRoot.focus,
|
||||||
|
nodes: treeRoot.collection.nodes,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function useKeyboardListener(
|
||||||
|
elementRef: Ref<HTMLElement | undefined>,
|
||||||
|
handleKeyboardEvent: (event: KeyboardEvent) => void,
|
||||||
|
) {
|
||||||
|
onMounted(() => {
|
||||||
|
elementRef.value?.addEventListener('keydown', handleKeyboardEvent, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
elementRef.value?.removeEventListener('keydown', handleKeyboardEvent);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TreeNavigationContext {
|
||||||
|
readonly focus: SingleNodeFocusManager;
|
||||||
|
readonly nodes: QueryableNodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
const KeyToActionMapping: Record<
|
||||||
|
TreeNavigationKeyCodes,
|
||||||
|
(context: TreeNavigationContext) => void
|
||||||
|
> = {
|
||||||
|
ArrowLeft: collapseNodeOrFocusParent,
|
||||||
|
ArrowUp: focusPreviousVisibleNode,
|
||||||
|
ArrowRight: expandNodeOrFocusFirstChild,
|
||||||
|
ArrowDown: focusNextVisibleNode,
|
||||||
|
' ': toggleTreeNodeCheckStatus,
|
||||||
|
Enter: toggleTreeNodeCheckStatus,
|
||||||
|
};
|
||||||
|
|
||||||
|
function focusPreviousVisibleNode(context: TreeNavigationContext): void {
|
||||||
|
const previousVisibleNode = findPreviousVisibleNode(
|
||||||
|
context.focus.currentSingleFocusedNode,
|
||||||
|
context.nodes,
|
||||||
|
);
|
||||||
|
if (!previousVisibleNode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
context.focus.setSingleFocus(previousVisibleNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusNextVisibleNode(context: TreeNavigationContext): void {
|
||||||
|
const focusedNode = context.focus.currentSingleFocusedNode;
|
||||||
|
const nextVisibleNode = findNextVisibleNode(focusedNode, context.nodes);
|
||||||
|
if (!nextVisibleNode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
context.focus.setSingleFocus(nextVisibleNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTreeNodeCheckStatus(context: TreeNavigationContext): void {
|
||||||
|
const focusedNode = context.focus.currentSingleFocusedNode;
|
||||||
|
const nodeState = focusedNode.state;
|
||||||
|
let transaction = nodeState.beginTransaction();
|
||||||
|
if (nodeState.current.checkState === TreeNodeCheckState.Checked) {
|
||||||
|
transaction = transaction.withCheckState(TreeNodeCheckState.Unchecked);
|
||||||
|
} else {
|
||||||
|
transaction = transaction.withCheckState(TreeNodeCheckState.Checked);
|
||||||
|
}
|
||||||
|
nodeState.commitTransaction(transaction);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collapseNodeOrFocusParent(context: TreeNavigationContext): void {
|
||||||
|
const focusedNode = context.focus.currentSingleFocusedNode;
|
||||||
|
const nodeState = focusedNode.state;
|
||||||
|
const parentNode = focusedNode.hierarchy.parent;
|
||||||
|
if (focusedNode.hierarchy.isBranchNode && nodeState.current.isExpanded) {
|
||||||
|
nodeState.commitTransaction(
|
||||||
|
nodeState.beginTransaction().withExpansionState(false),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
context.focus.setSingleFocus(parentNode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandNodeOrFocusFirstChild(context: TreeNavigationContext): void {
|
||||||
|
const focusedNode = context.focus.currentSingleFocusedNode;
|
||||||
|
const nodeState = focusedNode.state;
|
||||||
|
if (focusedNode.hierarchy.isBranchNode && !nodeState.current.isExpanded) {
|
||||||
|
nodeState.commitTransaction(
|
||||||
|
nodeState.beginTransaction().withExpansionState(true),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (focusedNode.hierarchy.children.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const firstChildNode = focusedNode.hierarchy.children[0];
|
||||||
|
if (firstChildNode) {
|
||||||
|
context.focus.setSingleFocus(firstChildNode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function findNextVisibleNode(node: TreeNode, nodes: QueryableNodes): TreeNode | undefined {
|
||||||
|
if (node.hierarchy.children.length && node.state.current.isExpanded) {
|
||||||
|
return node.hierarchy.children[0];
|
||||||
|
}
|
||||||
|
const nextNode = findNextNode(node, nodes);
|
||||||
|
const parentNode = node.hierarchy.parent;
|
||||||
|
if (!nextNode && parentNode) {
|
||||||
|
const nextSibling = findNextNode(parentNode, nodes);
|
||||||
|
return nextSibling;
|
||||||
|
}
|
||||||
|
return nextNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findNextNode(node: TreeNode, nodes: QueryableNodes): TreeNode | undefined {
|
||||||
|
const index = nodes.flattenedNodes.indexOf(node);
|
||||||
|
return nodes.flattenedNodes[index + 1] || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findPreviousVisibleNode(node: TreeNode, nodes: QueryableNodes): TreeNode | undefined {
|
||||||
|
const previousNode = findPreviousNode(node, nodes);
|
||||||
|
if (!previousNode) {
|
||||||
|
return node.hierarchy.parent;
|
||||||
|
}
|
||||||
|
if (previousNode.hierarchy.children.length && previousNode.state.current.isExpanded) {
|
||||||
|
return previousNode.hierarchy.children[previousNode.hierarchy.children.length - 1];
|
||||||
|
}
|
||||||
|
return previousNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findPreviousNode(node: TreeNode, nodes: QueryableNodes): TreeNode | undefined {
|
||||||
|
const index = nodes.flattenedNodes.indexOf(node);
|
||||||
|
return nodes.flattenedNodes[index - 1] || undefined;
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import { WatchSource, watch } from 'vue';
|
||||||
|
import { TreeViewFilterAction, TreeViewFilterEvent, TreeViewFilterPredicate } from './Bindings/TreeInputFilterEvent';
|
||||||
|
import { ReadOnlyTreeNode, TreeNode } from './Node/TreeNode';
|
||||||
|
import { TreeRoot } from './TreeRoot/TreeRoot';
|
||||||
|
import { useCurrentTreeNodes } from './UseCurrentTreeNodes';
|
||||||
|
import { QueryableNodes, ReadOnlyQueryableNodes } from './TreeRoot/NodeCollection/Query/QueryableNodes';
|
||||||
|
import { TreeNodeStateTransaction } from './Node/State/StateAccess';
|
||||||
|
import { TreeNodeStateDescriptor } from './Node/State/StateDescriptor';
|
||||||
|
|
||||||
|
export function useTreeQueryFilter(
|
||||||
|
latestFilterEventWatcher: WatchSource<TreeViewFilterEvent | undefined>,
|
||||||
|
treeWatcher: WatchSource<TreeRoot>,
|
||||||
|
) {
|
||||||
|
const { nodes } = useCurrentTreeNodes(treeWatcher);
|
||||||
|
|
||||||
|
let isFiltering = false;
|
||||||
|
const statesBeforeFiltering = new NodeStateRestorer();
|
||||||
|
statesBeforeFiltering.saveStateBeforeFilter(nodes.value);
|
||||||
|
|
||||||
|
setupWatchers({
|
||||||
|
filterEventWatcher: latestFilterEventWatcher,
|
||||||
|
nodesWatcher: () => nodes.value,
|
||||||
|
onFilterTrigger: (predicate, newNodes) => runFilter(
|
||||||
|
newNodes,
|
||||||
|
predicate,
|
||||||
|
),
|
||||||
|
onFilterReset: () => resetFilter(nodes.value),
|
||||||
|
});
|
||||||
|
|
||||||
|
function resetFilter(currentNodes: QueryableNodes) {
|
||||||
|
if (!isFiltering) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isFiltering = false;
|
||||||
|
currentNodes.flattenedNodes.forEach((node: TreeNode) => {
|
||||||
|
let transaction = node.state.beginTransaction()
|
||||||
|
.withMatchState(false);
|
||||||
|
transaction = statesBeforeFiltering.applyOriginalState(node, transaction);
|
||||||
|
node.state.commitTransaction(transaction);
|
||||||
|
});
|
||||||
|
statesBeforeFiltering.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
function runFilter(currentNodes: QueryableNodes, predicate: TreeViewFilterPredicate) {
|
||||||
|
if (!isFiltering) {
|
||||||
|
statesBeforeFiltering.saveStateBeforeFilter(currentNodes);
|
||||||
|
isFiltering = true;
|
||||||
|
}
|
||||||
|
const { matchedNodes, unmatchedNodes } = partitionNodesByMatchCriteria(currentNodes, predicate);
|
||||||
|
const nodeTransactions = getNodeChangeTransactions(matchedNodes, unmatchedNodes);
|
||||||
|
|
||||||
|
nodeTransactions.forEach((transaction, node) => {
|
||||||
|
node.state.commitTransaction(transaction);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNodeChangeTransactions(
|
||||||
|
matchedNodes: Iterable<TreeNode>,
|
||||||
|
unmatchedNodes: Iterable<TreeNode>,
|
||||||
|
) {
|
||||||
|
const transactions = new Map<TreeNode, TreeNodeStateTransaction>();
|
||||||
|
|
||||||
|
for (const unmatchedNode of unmatchedNodes) {
|
||||||
|
addOrUpdateTransaction(unmatchedNode, (builder) => builder
|
||||||
|
.withVisibilityState(false)
|
||||||
|
.withMatchState(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const matchedNode of matchedNodes) {
|
||||||
|
addOrUpdateTransaction(matchedNode, (builder) => {
|
||||||
|
let transaction = builder
|
||||||
|
.withVisibilityState(true)
|
||||||
|
.withMatchState(true);
|
||||||
|
if (matchedNode.hierarchy.isBranchNode) {
|
||||||
|
transaction = transaction.withExpansionState(false);
|
||||||
|
}
|
||||||
|
return transaction;
|
||||||
|
});
|
||||||
|
|
||||||
|
traverseAllChildren(matchedNode, (childNode) => {
|
||||||
|
addOrUpdateTransaction(childNode, (builder) => builder
|
||||||
|
.withVisibilityState(true));
|
||||||
|
});
|
||||||
|
|
||||||
|
traverseAllParents(matchedNode, (parentNode) => {
|
||||||
|
addOrUpdateTransaction(parentNode, (builder) => builder
|
||||||
|
.withVisibilityState(true)
|
||||||
|
.withExpansionState(true));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addOrUpdateTransaction(
|
||||||
|
node: TreeNode,
|
||||||
|
builder: (transaction: TreeNodeStateTransaction) => TreeNodeStateTransaction,
|
||||||
|
) {
|
||||||
|
let transaction = transactions.get(node) ?? node.state.beginTransaction();
|
||||||
|
transaction = builder(transaction);
|
||||||
|
transactions.set(node, transaction);
|
||||||
|
}
|
||||||
|
|
||||||
|
return transactions;
|
||||||
|
}
|
||||||
|
|
||||||
|
function partitionNodesByMatchCriteria(
|
||||||
|
currentNodes: QueryableNodes,
|
||||||
|
predicate: TreeViewFilterPredicate,
|
||||||
|
) {
|
||||||
|
const matchedNodes = new Set<TreeNode>();
|
||||||
|
const unmatchedNodes = new Set<TreeNode>();
|
||||||
|
currentNodes.flattenedNodes.forEach((node) => {
|
||||||
|
if (predicate(node)) {
|
||||||
|
matchedNodes.add(node);
|
||||||
|
} else {
|
||||||
|
unmatchedNodes.add(node);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
matchedNodes,
|
||||||
|
unmatchedNodes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function traverseAllParents(node: TreeNode, handler: (node: TreeNode) => void) {
|
||||||
|
const parentNode = node.hierarchy.parent;
|
||||||
|
if (parentNode) {
|
||||||
|
handler(parentNode);
|
||||||
|
traverseAllParents(parentNode, handler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function traverseAllChildren(node: TreeNode, handler: (node: TreeNode) => void) {
|
||||||
|
node.hierarchy.children.forEach((childNode) => {
|
||||||
|
handler(childNode);
|
||||||
|
traverseAllChildren(childNode, handler);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class NodeStateRestorer {
|
||||||
|
private readonly originalStates = new Map<ReadOnlyTreeNode, Partial<TreeNodeStateDescriptor>>();
|
||||||
|
|
||||||
|
public saveStateBeforeFilter(nodes: ReadOnlyQueryableNodes) {
|
||||||
|
nodes
|
||||||
|
.flattenedNodes
|
||||||
|
.forEach((node) => {
|
||||||
|
this.originalStates.set(node, {
|
||||||
|
isExpanded: node.state.current.isExpanded,
|
||||||
|
isVisible: node.state.current.isVisible,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public applyOriginalState(
|
||||||
|
node: TreeNode,
|
||||||
|
transaction: TreeNodeStateTransaction,
|
||||||
|
): TreeNodeStateTransaction {
|
||||||
|
if (!this.originalStates.has(node)) {
|
||||||
|
return transaction;
|
||||||
|
}
|
||||||
|
const originalState = this.originalStates.get(node);
|
||||||
|
if (originalState.isExpanded !== undefined) {
|
||||||
|
transaction = transaction.withExpansionState(originalState.isExpanded);
|
||||||
|
}
|
||||||
|
transaction = transaction.withVisibilityState(originalState.isVisible);
|
||||||
|
return transaction;
|
||||||
|
}
|
||||||
|
|
||||||
|
public clear() {
|
||||||
|
this.originalStates.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupWatchers(options: {
|
||||||
|
filterEventWatcher: WatchSource<TreeViewFilterEvent | undefined>,
|
||||||
|
nodesWatcher: WatchSource<QueryableNodes>,
|
||||||
|
onFilterReset: () => void,
|
||||||
|
onFilterTrigger: (
|
||||||
|
predicate: TreeViewFilterPredicate,
|
||||||
|
nodes: QueryableNodes,
|
||||||
|
) => void,
|
||||||
|
}) {
|
||||||
|
watch(
|
||||||
|
[
|
||||||
|
options.filterEventWatcher,
|
||||||
|
options.nodesWatcher,
|
||||||
|
],
|
||||||
|
([filterEvent, nodes]) => {
|
||||||
|
if (!filterEvent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
switch (filterEvent.action) {
|
||||||
|
case TreeViewFilterAction.Triggered:
|
||||||
|
options.onFilterTrigger(filterEvent.predicate, nodes);
|
||||||
|
break;
|
||||||
|
case TreeViewFilterAction.Removed:
|
||||||
|
options.onFilterReset();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown action: ${TreeViewFilterAction[filterEvent.action]}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
@use "@/presentation/assets/styles/main" as *;
|
||||||
|
|
||||||
|
/* Tree colors, based on global colors */
|
||||||
|
$color-tree-bg : $color-primary-darker;
|
||||||
|
$color-node-arrow : $color-on-primary;
|
||||||
|
$color-node-fg : $color-on-primary;
|
||||||
|
$color-node-highlight-bg : $color-primary-dark;
|
||||||
|
$color-node-checkbox-bg-checked : $color-secondary;
|
||||||
|
$color-node-checkbox-bg-unchecked : $color-primary-darkest;
|
||||||
|
$color-node-checkbox-border-checked : $color-secondary;
|
||||||
|
$color-node-checkbox-border-unchecked : $color-on-primary;
|
||||||
|
$color-node-checkbox-border-indeterminate : $color-on-primary;
|
||||||
|
$color-node-checkbox-tick-checked : $color-on-secondary;
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
import { ICategory, IScript } from '@/domain/ICategory';
|
import { ICategory, IScript } from '@/domain/ICategory';
|
||||||
import { ICategoryCollection } from '@/domain/ICategoryCollection';
|
import { ICategoryCollection } from '@/domain/ICategoryCollection';
|
||||||
import { INodeContent, NodeType } from './SelectableTree/Node/INodeContent';
|
import { NodeMetadata, NodeType } from '../NodeContent/NodeMetadata';
|
||||||
|
|
||||||
export function parseAllCategories(collection: ICategoryCollection): INodeContent[] | undefined {
|
export function parseAllCategories(collection: ICategoryCollection): NodeMetadata[] | undefined {
|
||||||
return createCategoryNodes(collection.actions);
|
return createCategoryNodes(collection.actions);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseSingleCategory(
|
export function parseSingleCategory(
|
||||||
categoryId: number,
|
categoryId: number,
|
||||||
collection: ICategoryCollection,
|
collection: ICategoryCollection,
|
||||||
): INodeContent[] | undefined {
|
): NodeMetadata[] | undefined {
|
||||||
const category = collection.findCategory(categoryId);
|
const category = collection.findCategory(categoryId);
|
||||||
if (!category) {
|
if (!category) {
|
||||||
throw new Error(`Category with id ${categoryId} does not exist`);
|
throw new Error(`Category with id ${categoryId} does not exist`);
|
||||||
@@ -21,9 +21,11 @@ export function parseSingleCategory(
|
|||||||
export function getScriptNodeId(script: IScript): string {
|
export function getScriptNodeId(script: IScript): string {
|
||||||
return script.id;
|
return script.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getScriptId(nodeId: string): string {
|
export function getScriptId(nodeId: string): string {
|
||||||
return nodeId;
|
return nodeId;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCategoryId(nodeId: string): number {
|
export function getCategoryId(nodeId: string): number {
|
||||||
return +nodeId;
|
return +nodeId;
|
||||||
}
|
}
|
||||||
@@ -34,22 +36,19 @@ export function getCategoryNodeId(category: ICategory): string {
|
|||||||
|
|
||||||
function parseCategoryRecursively(
|
function parseCategoryRecursively(
|
||||||
parentCategory: ICategory,
|
parentCategory: ICategory,
|
||||||
): INodeContent[] {
|
): NodeMetadata[] {
|
||||||
if (!parentCategory) {
|
|
||||||
throw new Error('parentCategory is undefined');
|
|
||||||
}
|
|
||||||
return [
|
return [
|
||||||
...createCategoryNodes(parentCategory.subCategories),
|
...createCategoryNodes(parentCategory.subCategories),
|
||||||
...createScriptNodes(parentCategory.scripts),
|
...createScriptNodes(parentCategory.scripts),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function createScriptNodes(scripts: ReadonlyArray<IScript>): INodeContent[] {
|
function createScriptNodes(scripts: ReadonlyArray<IScript>): NodeMetadata[] {
|
||||||
return (scripts || [])
|
return (scripts || [])
|
||||||
.map((script) => convertScriptToNode(script));
|
.map((script) => convertScriptToNode(script));
|
||||||
}
|
}
|
||||||
|
|
||||||
function createCategoryNodes(categories: ReadonlyArray<ICategory>): INodeContent[] {
|
function createCategoryNodes(categories: ReadonlyArray<ICategory>): NodeMetadata[] {
|
||||||
return (categories || [])
|
return (categories || [])
|
||||||
.map((category) => ({ category, children: parseCategoryRecursively(category) }))
|
.map((category) => ({ category, children: parseCategoryRecursively(category) }))
|
||||||
.map((data) => convertCategoryToNode(data.category, data.children));
|
.map((data) => convertCategoryToNode(data.category, data.children));
|
||||||
@@ -57,8 +56,8 @@ function createCategoryNodes(categories: ReadonlyArray<ICategory>): INodeContent
|
|||||||
|
|
||||||
function convertCategoryToNode(
|
function convertCategoryToNode(
|
||||||
category: ICategory,
|
category: ICategory,
|
||||||
children: readonly INodeContent[],
|
children: readonly NodeMetadata[],
|
||||||
): INodeContent {
|
): NodeMetadata {
|
||||||
return {
|
return {
|
||||||
id: getCategoryNodeId(category),
|
id: getCategoryNodeId(category),
|
||||||
type: NodeType.Category,
|
type: NodeType.Category,
|
||||||
@@ -69,7 +68,7 @@ function convertCategoryToNode(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function convertScriptToNode(script: IScript): INodeContent {
|
function convertScriptToNode(script: IScript): NodeMetadata {
|
||||||
return {
|
return {
|
||||||
id: getScriptNodeId(script),
|
id: getScriptNodeId(script),
|
||||||
type: NodeType.Script,
|
type: NodeType.Script,
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { NodeMetadata } from '../NodeContent/NodeMetadata';
|
||||||
|
import { ReadOnlyTreeNode } from '../TreeView/Node/TreeNode';
|
||||||
|
import { TreeInputNodeData } from '../TreeView/Bindings/TreeInputNodeData';
|
||||||
|
|
||||||
|
export function getNodeMetadata(
|
||||||
|
treeNode: ReadOnlyTreeNode,
|
||||||
|
): NodeMetadata {
|
||||||
|
if (!treeNode) { throw new Error('missing tree node'); }
|
||||||
|
const data = treeNode.metadata as NodeMetadata;
|
||||||
|
if (!data) {
|
||||||
|
throw new Error('Provided node does not contain the expected metadata.');
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function convertToNodeInput(metadata: NodeMetadata): TreeInputNodeData {
|
||||||
|
if (!metadata) { throw new Error('missing metadata'); }
|
||||||
|
return {
|
||||||
|
id: metadata.id,
|
||||||
|
children: convertChildren(metadata.children, convertToNodeInput),
|
||||||
|
data: metadata,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertChildren<TOldNode, TNewNode>(
|
||||||
|
oldChildren: readonly TOldNode[],
|
||||||
|
callback: (value: TOldNode) => TNewNode,
|
||||||
|
): TNewNode[] {
|
||||||
|
if (!oldChildren || oldChildren.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return oldChildren.map((childNode) => callback(childNode));
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { inject } from 'vue';
|
||||||
|
import { InjectionKeys } from '@/presentation/injectionSymbols';
|
||||||
|
import { TreeNodeCheckState } from '../TreeView/Node/State/CheckState';
|
||||||
|
import { TreeNodeStateChangedEmittedEvent } from '../TreeView/Bindings/TreeNodeStateChangedEmittedEvent';
|
||||||
|
|
||||||
|
export function useCollectionSelectionStateUpdater() {
|
||||||
|
const { modifyCurrentState, currentState } = inject(InjectionKeys.useCollectionState)();
|
||||||
|
|
||||||
|
const updateNodeSelection = (event: TreeNodeStateChangedEmittedEvent) => {
|
||||||
|
const { node } = event;
|
||||||
|
if (node.hierarchy.isBranchNode) {
|
||||||
|
return; // A category, let TreeView handle this
|
||||||
|
}
|
||||||
|
if (event.change.oldState.checkState === event.change.newState.checkState) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (node.state.current.checkState === TreeNodeCheckState.Checked) {
|
||||||
|
if (currentState.value.selection.isSelected(node.id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
modifyCurrentState((state) => {
|
||||||
|
state.selection.addSelectedScript(node.id, false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (node.state.current.checkState === TreeNodeCheckState.Unchecked) {
|
||||||
|
if (!currentState.value.selection.isSelected(node.id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
modifyCurrentState((state) => {
|
||||||
|
state.selection.removeSelectedScript(node.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
updateNodeSelection,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import {
|
||||||
|
computed, inject, readonly, ref,
|
||||||
|
} from 'vue';
|
||||||
|
import { InjectionKeys } from '@/presentation/injectionSymbols';
|
||||||
|
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
|
||||||
|
import { getScriptNodeId } from './CategoryNodeMetadataConverter';
|
||||||
|
|
||||||
|
export function useSelectedScriptNodeIds(scriptNodeIdParser = getScriptNodeId) {
|
||||||
|
const { selectedScripts } = useSelectedScripts();
|
||||||
|
|
||||||
|
const selectedNodeIds = computed<readonly string[]>(() => {
|
||||||
|
return selectedScripts
|
||||||
|
.value
|
||||||
|
.map((selected) => scriptNodeIdParser(selected.script));
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
selectedScriptNodeIds: readonly(selectedNodeIds),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function useSelectedScripts() {
|
||||||
|
const { events } = inject(InjectionKeys.useAutoUnsubscribedEvents)();
|
||||||
|
const { onStateChange } = inject(InjectionKeys.useCollectionState)();
|
||||||
|
|
||||||
|
const selectedScripts = ref<readonly SelectedScript[]>([]);
|
||||||
|
|
||||||
|
onStateChange((state) => {
|
||||||
|
selectedScripts.value = state.selection.selectedScripts;
|
||||||
|
events.unsubscribeAllAndRegister([
|
||||||
|
state.selection.changed.on((scripts) => {
|
||||||
|
selectedScripts.value = scripts;
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
return {
|
||||||
|
selectedScripts: readonly(selectedScripts),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import {
|
||||||
|
Ref, inject, readonly, ref,
|
||||||
|
} from 'vue';
|
||||||
|
import { IScript } from '@/domain/IScript';
|
||||||
|
import { ICategory } from '@/domain/ICategory';
|
||||||
|
import { InjectionKeys } from '@/presentation/injectionSymbols';
|
||||||
|
import { IReadOnlyUserFilter } from '@/application/Context/State/Filter/IUserFilter';
|
||||||
|
import { IFilterResult } from '@/application/Context/State/Filter/IFilterResult';
|
||||||
|
import { TreeViewFilterEvent, createFilterRemovedEvent, createFilterTriggeredEvent } from '../TreeView/Bindings/TreeInputFilterEvent';
|
||||||
|
import { NodeMetadata } from '../NodeContent/NodeMetadata';
|
||||||
|
import { ReadOnlyTreeNode } from '../TreeView/Node/TreeNode';
|
||||||
|
import { getNodeMetadata } from './TreeNodeMetadataConverter';
|
||||||
|
import { getCategoryNodeId, getScriptNodeId } from './CategoryNodeMetadataConverter';
|
||||||
|
|
||||||
|
type TreeNodeFilterResultPredicate = (
|
||||||
|
node: ReadOnlyTreeNode,
|
||||||
|
filterResult: IFilterResult,
|
||||||
|
) => boolean;
|
||||||
|
|
||||||
|
export function useTreeViewFilterEvent() {
|
||||||
|
const { onStateChange } = inject(InjectionKeys.useCollectionState)();
|
||||||
|
const { events } = inject(InjectionKeys.useAutoUnsubscribedEvents)();
|
||||||
|
|
||||||
|
const latestFilterEvent = ref<TreeViewFilterEvent | undefined>(undefined);
|
||||||
|
|
||||||
|
const treeNodePredicate: TreeNodeFilterResultPredicate = (node, filterResult) => filterMatches(
|
||||||
|
getNodeMetadata(node),
|
||||||
|
filterResult,
|
||||||
|
);
|
||||||
|
|
||||||
|
onStateChange((newState) => {
|
||||||
|
latestFilterEvent.value = createFilterEvent(newState.filter.currentFilter, treeNodePredicate);
|
||||||
|
events.unsubscribeAllAndRegister([
|
||||||
|
subscribeToFilterChanges(newState.filter, latestFilterEvent, treeNodePredicate),
|
||||||
|
]);
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
return {
|
||||||
|
latestFilterEvent: readonly(latestFilterEvent),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribeToFilterChanges(
|
||||||
|
filter: IReadOnlyUserFilter,
|
||||||
|
latestFilterEvent: Ref<TreeViewFilterEvent>,
|
||||||
|
filterPredicate: TreeNodeFilterResultPredicate,
|
||||||
|
) {
|
||||||
|
return filter.filterChanged.on((event) => {
|
||||||
|
event.visit({
|
||||||
|
onApply: (result) => {
|
||||||
|
latestFilterEvent.value = createFilterTriggeredEvent(
|
||||||
|
(node) => filterPredicate(node, result),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onClear: () => {
|
||||||
|
latestFilterEvent.value = createFilterRemovedEvent();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFilterEvent(
|
||||||
|
filter: IFilterResult | undefined,
|
||||||
|
filterPredicate: TreeNodeFilterResultPredicate,
|
||||||
|
): TreeViewFilterEvent {
|
||||||
|
if (!filter) {
|
||||||
|
return createFilterRemovedEvent();
|
||||||
|
}
|
||||||
|
return createFilterTriggeredEvent(
|
||||||
|
(node) => filterPredicate(node, filter),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterMatches(node: NodeMetadata, filter: IFilterResult): boolean {
|
||||||
|
return containsScript(node, filter.scriptMatches)
|
||||||
|
|| containsCategory(node, filter.categoryMatches);
|
||||||
|
}
|
||||||
|
|
||||||
|
function containsScript(expected: NodeMetadata, scripts: readonly IScript[]) {
|
||||||
|
return scripts.some((existing: IScript) => expected.id === getScriptNodeId(existing));
|
||||||
|
}
|
||||||
|
|
||||||
|
function containsCategory(expected: NodeMetadata, categories: readonly ICategory[]) {
|
||||||
|
return categories.some((existing: ICategory) => expected.id === getCategoryNodeId(existing));
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import {
|
||||||
|
WatchSource, computed, inject,
|
||||||
|
ref, watch,
|
||||||
|
} from 'vue';
|
||||||
|
import { ICategoryCollection } from '@/domain/ICategoryCollection';
|
||||||
|
import { InjectionKeys } from '@/presentation/injectionSymbols';
|
||||||
|
import { TreeInputNodeData } from '../TreeView/Bindings/TreeInputNodeData';
|
||||||
|
import { NodeMetadata } from '../NodeContent/NodeMetadata';
|
||||||
|
import { convertToNodeInput } from './TreeNodeMetadataConverter';
|
||||||
|
import { parseSingleCategory, parseAllCategories } from './CategoryNodeMetadataConverter';
|
||||||
|
|
||||||
|
export function useTreeViewNodeInput(
|
||||||
|
categoryIdWatcher: WatchSource<number | undefined>,
|
||||||
|
parser: CategoryNodeParser = {
|
||||||
|
parseSingle: parseSingleCategory,
|
||||||
|
parseAll: parseAllCategories,
|
||||||
|
},
|
||||||
|
nodeConverter = convertToNodeInput,
|
||||||
|
) {
|
||||||
|
const { currentState } = inject(InjectionKeys.useCollectionState)();
|
||||||
|
|
||||||
|
const categoryId = ref<number | undefined>();
|
||||||
|
|
||||||
|
watch(categoryIdWatcher, (newCategoryId) => {
|
||||||
|
categoryId.value = newCategoryId;
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
const nodes = computed<readonly TreeInputNodeData[]>(() => {
|
||||||
|
const nodeMetadataList = parseNodes(categoryId.value, currentState.value.collection, parser);
|
||||||
|
const nodeInputs = nodeMetadataList.map((node) => nodeConverter(node));
|
||||||
|
return nodeInputs;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
treeViewInputNodes: nodes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNodes(
|
||||||
|
categoryId: number | undefined,
|
||||||
|
categoryCollection: ICategoryCollection,
|
||||||
|
parser: CategoryNodeParser,
|
||||||
|
): NodeMetadata[] {
|
||||||
|
if (categoryId !== undefined) {
|
||||||
|
return parser.parseSingle(categoryId, categoryCollection);
|
||||||
|
}
|
||||||
|
return parser.parseAll(categoryCollection);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CategoryNodeParser {
|
||||||
|
readonly parseSingle: typeof parseSingleCategory;
|
||||||
|
readonly parseAll: typeof parseAllCategories;
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { parseApplication } from '@/application/Parser/ApplicationParser';
|
import { parseApplication } from '@/application/Parser/ApplicationParser';
|
||||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||||
import { createRenderer } from '@/presentation/components/Scripts/View/ScriptsTree/SelectableTree/Node/Documentation/MarkdownRenderer';
|
import { createRenderer } from '@/presentation/components/Scripts/View/Tree/NodeContent/Documentation/MarkdownRenderer';
|
||||||
|
|
||||||
describe('MarkdownRenderer', () => {
|
describe('MarkdownRenderer', () => {
|
||||||
describe('can render all docs', () => {
|
describe('can render all docs', () => {
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import {
|
||||||
|
describe, it, expect,
|
||||||
|
} from 'vitest';
|
||||||
|
import { mount } from '@vue/test-utils';
|
||||||
|
import { defineComponent, ref } from 'vue';
|
||||||
|
import TreeView from '@/presentation/components/Scripts/View/Tree/TreeView/TreeView.vue';
|
||||||
|
import { TreeInputNodeData } from '@/presentation/components/Scripts/View/Tree/TreeView/Bindings/TreeInputNodeData';
|
||||||
|
import { provideDependencies } from '@/presentation/bootstrapping/DependencyProvider';
|
||||||
|
import { ApplicationContextStub } from '@tests/unit/shared/Stubs/ApplicationContextStub';
|
||||||
|
|
||||||
|
describe('TreeView', () => {
|
||||||
|
it('should render all provided root nodes correctly', async () => {
|
||||||
|
// arrange
|
||||||
|
const nodes = createSampleNodes();
|
||||||
|
const wrapper = createTreeViewWrapper(nodes);
|
||||||
|
// act
|
||||||
|
await waitForStableDom(wrapper.element);
|
||||||
|
// assert
|
||||||
|
const expectedTotalRootNodes = nodes.length;
|
||||||
|
expect(wrapper.findAll('.node').length).to.equal(expectedTotalRootNodes, wrapper.html());
|
||||||
|
const rootNodeTexts = nodes.map((node) => node.data.label);
|
||||||
|
rootNodeTexts.forEach((label) => {
|
||||||
|
expect(wrapper.text()).to.include(label);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function createTreeViewWrapper(initialNodeData: readonly TreeInputNodeData[]) {
|
||||||
|
return mount(defineComponent({
|
||||||
|
components: {
|
||||||
|
TreeView,
|
||||||
|
},
|
||||||
|
setup() {
|
||||||
|
provideDependencies(new ApplicationContextStub());
|
||||||
|
|
||||||
|
const initialNodes = ref(initialNodeData);
|
||||||
|
const selectedLeafNodeIds = ref<readonly string[]>([]);
|
||||||
|
return {
|
||||||
|
initialNodes,
|
||||||
|
selectedLeafNodeIds,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<TreeView
|
||||||
|
:initialNodes="initialNodes"
|
||||||
|
:selectedLeafNodeIds="selectedLeafNodeIds"
|
||||||
|
>
|
||||||
|
<template v-slot:node-content="{ nodeMetadata }">
|
||||||
|
{{ nodeMetadata.label }}
|
||||||
|
</template>
|
||||||
|
</TreeView>`,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSampleNodes() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'root1',
|
||||||
|
data: {
|
||||||
|
label: 'Root 1',
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: 'child1',
|
||||||
|
data: {
|
||||||
|
label: 'Child 1',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'child2',
|
||||||
|
data: {
|
||||||
|
label: 'Child 2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'root2',
|
||||||
|
data: {
|
||||||
|
label: 'Root 2',
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: 'child3',
|
||||||
|
data: {
|
||||||
|
label: 'Child 3',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForStableDom(rootElement, timeout = 3000, interval = 200): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let lastTimeoutId;
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
if (lastTimeoutId) {
|
||||||
|
clearTimeout(lastTimeoutId);
|
||||||
|
}
|
||||||
|
|
||||||
|
lastTimeoutId = setTimeout(() => {
|
||||||
|
observer.disconnect();
|
||||||
|
resolve();
|
||||||
|
}, interval);
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(rootElement, {
|
||||||
|
attributes: true,
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
characterData: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
observer.disconnect();
|
||||||
|
reject(new Error('Timeout waiting for DOM to stabilize'));
|
||||||
|
}, timeout);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { Wrapper, shallowMount } from '@vue/test-utils';
|
import { Wrapper, shallowMount } from '@vue/test-utils';
|
||||||
import TheScriptsView from '@/presentation/components/Scripts/View/TheScriptsView.vue';
|
import TheScriptsView from '@/presentation/components/Scripts/View/TheScriptsView.vue';
|
||||||
import ScriptsTree from '@/presentation/components/Scripts/View/ScriptsTree/ScriptsTree.vue';
|
import ScriptsTree from '@/presentation/components/Scripts/View/Tree/ScriptsTree.vue';
|
||||||
import CardList from '@/presentation/components/Scripts/View/Cards/CardList.vue';
|
import CardList from '@/presentation/components/Scripts/View/Cards/CardList.vue';
|
||||||
import { ViewType } from '@/presentation/components/Scripts/Menu/View/ViewType';
|
import { ViewType } from '@/presentation/components/Scripts/Menu/View/ViewType';
|
||||||
import { useCollectionState } from '@/presentation/components/Shared/Hooks/UseCollectionState';
|
import { useCollectionState } from '@/presentation/components/Shared/Hooks/UseCollectionState';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { createRenderer } from '@/presentation/components/Scripts/View/ScriptsTree/SelectableTree/Node/Documentation/MarkdownRenderer';
|
import { createRenderer } from '@/presentation/components/Scripts/View/Tree/NodeContent/Documentation/MarkdownRenderer';
|
||||||
|
|
||||||
describe('MarkdownRenderer', () => {
|
describe('MarkdownRenderer', () => {
|
||||||
describe('createRenderer', () => {
|
describe('createRenderer', () => {
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { CategoryReverter } from '@/presentation/components/Scripts/View/ScriptsTree/SelectableTree/Node/Reverter/CategoryReverter';
|
import { CategoryReverter } from '@/presentation/components/Scripts/View/Tree/NodeContent/Reverter/CategoryReverter';
|
||||||
import { getCategoryNodeId } from '@/presentation/components/Scripts/View/ScriptsTree/ScriptNodeParser';
|
|
||||||
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
|
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
|
||||||
import { UserSelection } from '@/application/Context/State/Selection/UserSelection';
|
import { UserSelection } from '@/application/Context/State/Selection/UserSelection';
|
||||||
import { CategoryStub } from '@tests/unit/shared/Stubs/CategoryStub';
|
import { CategoryStub } from '@tests/unit/shared/Stubs/CategoryStub';
|
||||||
import { CategoryCollectionStub } from '@tests/unit/shared/Stubs/CategoryCollectionStub';
|
import { CategoryCollectionStub } from '@tests/unit/shared/Stubs/CategoryCollectionStub';
|
||||||
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
|
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
|
||||||
|
import { getCategoryNodeId } from '@/presentation/components/Scripts/View/Tree/TreeViewAdapter/CategoryNodeMetadataConverter';
|
||||||
|
|
||||||
describe('CategoryReverter', () => {
|
describe('CategoryReverter', () => {
|
||||||
describe('getState', () => {
|
describe('getState', () => {
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { INodeContent, NodeType } from '@/presentation/components/Scripts/View/ScriptsTree/SelectableTree/Node/INodeContent';
|
import { getReverter } from '@/presentation/components/Scripts/View/Tree/NodeContent/Reverter/ReverterFactory';
|
||||||
import { getReverter } from '@/presentation/components/Scripts/View/ScriptsTree/SelectableTree/Node/Reverter/ReverterFactory';
|
import { ScriptReverter } from '@/presentation/components/Scripts/View/Tree/NodeContent/Reverter/ScriptReverter';
|
||||||
import { ScriptReverter } from '@/presentation/components/Scripts/View/ScriptsTree/SelectableTree/Node/Reverter/ScriptReverter';
|
import { CategoryReverter } from '@/presentation/components/Scripts/View/Tree/NodeContent/Reverter/CategoryReverter';
|
||||||
import { CategoryReverter } from '@/presentation/components/Scripts/View/ScriptsTree/SelectableTree/Node/Reverter/CategoryReverter';
|
|
||||||
import { getScriptNodeId, getCategoryNodeId } from '@/presentation/components/Scripts/View/ScriptsTree/ScriptNodeParser';
|
|
||||||
import { CategoryCollectionStub } from '@tests/unit/shared/Stubs/CategoryCollectionStub';
|
import { CategoryCollectionStub } from '@tests/unit/shared/Stubs/CategoryCollectionStub';
|
||||||
import { CategoryStub } from '@tests/unit/shared/Stubs/CategoryStub';
|
import { CategoryStub } from '@tests/unit/shared/Stubs/CategoryStub';
|
||||||
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
|
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
|
||||||
|
import { getCategoryNodeId, getScriptNodeId } from '@/presentation/components/Scripts/View/Tree/TreeViewAdapter/CategoryNodeMetadataConverter';
|
||||||
|
import { NodeType } from '@/application/Parser/NodeValidation/NodeType';
|
||||||
|
import { NodeMetadata } from '@/presentation/components/Scripts/View/Tree/NodeContent/NodeMetadata';
|
||||||
|
|
||||||
describe('ReverterFactory', () => {
|
describe('ReverterFactory', () => {
|
||||||
describe('getReverter', () => {
|
describe('getReverter', () => {
|
||||||
@@ -33,7 +34,7 @@ describe('ReverterFactory', () => {
|
|||||||
expect(result instanceof ScriptReverter).to.equal(true);
|
expect(result instanceof ScriptReverter).to.equal(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
function getNodeContentStub(nodeId: string, type: NodeType): INodeContent {
|
function getNodeContentStub(nodeId: string, type: NodeType): NodeMetadata {
|
||||||
return {
|
return {
|
||||||
id: nodeId,
|
id: nodeId,
|
||||||
text: 'text',
|
text: 'text',
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { ScriptReverter } from '@/presentation/components/Scripts/View/ScriptsTree/SelectableTree/Node/Reverter/ScriptReverter';
|
import { ScriptReverter } from '@/presentation/components/Scripts/View/Tree/NodeContent/Reverter/ScriptReverter';
|
||||||
import { getScriptNodeId } from '@/presentation/components/Scripts/View/ScriptsTree/ScriptNodeParser';
|
|
||||||
import { UserSelection } from '@/application/Context/State/Selection/UserSelection';
|
import { UserSelection } from '@/application/Context/State/Selection/UserSelection';
|
||||||
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
|
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
|
||||||
import { CategoryCollectionStub } from '@tests/unit/shared/Stubs/CategoryCollectionStub';
|
import { CategoryCollectionStub } from '@tests/unit/shared/Stubs/CategoryCollectionStub';
|
||||||
import { CategoryStub } from '@tests/unit/shared/Stubs/CategoryStub';
|
import { CategoryStub } from '@tests/unit/shared/Stubs/CategoryStub';
|
||||||
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
|
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
|
||||||
import { SelectedScriptStub } from '@tests/unit/shared/Stubs/SelectedScriptStub';
|
import { SelectedScriptStub } from '@tests/unit/shared/Stubs/SelectedScriptStub';
|
||||||
|
import { getScriptNodeId } from '@/presentation/components/Scripts/View/Tree/TreeViewAdapter/CategoryNodeMetadataConverter';
|
||||||
|
|
||||||
describe('ScriptReverter', () => {
|
describe('ScriptReverter', () => {
|
||||||
describe('getState', () => {
|
describe('getState', () => {
|
||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
mount,
|
mount,
|
||||||
} from '@vue/test-utils';
|
} from '@vue/test-utils';
|
||||||
import { nextTick, defineComponent } from 'vue';
|
import { nextTick, defineComponent } from 'vue';
|
||||||
import ToggleSwitch from '@/presentation/components/Scripts/View/ScriptsTree/SelectableTree/Node/ToggleSwitch.vue';
|
import ToggleSwitch from '@/presentation/components/Scripts/View/Tree/NodeContent/ToggleSwitch.vue';
|
||||||
|
|
||||||
const DOM_INPUT_TOGGLE_CHECKBOX_SELECTOR = 'input.toggle-input';
|
const DOM_INPUT_TOGGLE_CHECKBOX_SELECTOR = 'input.toggle-input';
|
||||||
const DOM_INPUT_TOGGLE_LABEL_OFF_SELECTOR = 'span.label-off';
|
const DOM_INPUT_TOGGLE_LABEL_OFF_SELECTOR = 'span.label-off';
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import { ILiquorTreeExistingNode } from 'liquor-tree';
|
|
||||||
import { NodeType, INodeContent } from '@/presentation/components/Scripts/View/ScriptsTree/SelectableTree/Node/INodeContent';
|
|
||||||
import { NodePredicateFilter } from '@/presentation/components/Scripts/View/ScriptsTree/SelectableTree/LiquorTree/NodeWrapper/NodePredicateFilter';
|
|
||||||
|
|
||||||
describe('NodePredicateFilter', () => {
|
|
||||||
it('calls predicate with expected node', () => {
|
|
||||||
// arrange
|
|
||||||
const object: ILiquorTreeExistingNode = {
|
|
||||||
id: 'script',
|
|
||||||
data: {
|
|
||||||
text: 'script-text',
|
|
||||||
type: NodeType.Script,
|
|
||||||
docs: [],
|
|
||||||
isReversible: false,
|
|
||||||
},
|
|
||||||
states: undefined,
|
|
||||||
children: [],
|
|
||||||
};
|
|
||||||
const expected: INodeContent = {
|
|
||||||
id: 'script',
|
|
||||||
text: 'script-text',
|
|
||||||
isReversible: false,
|
|
||||||
docs: [],
|
|
||||||
children: [],
|
|
||||||
type: NodeType.Script,
|
|
||||||
};
|
|
||||||
let actual: INodeContent;
|
|
||||||
const predicate = (node: INodeContent) => { actual = node; return true; };
|
|
||||||
const sut = new NodePredicateFilter(predicate);
|
|
||||||
// act
|
|
||||||
sut.matcher('nop query', object);
|
|
||||||
// assert
|
|
||||||
expect(actual).to.deep.equal(expected);
|
|
||||||
});
|
|
||||||
describe('returns result from the predicate', () => {
|
|
||||||
for (const expected of [false, true]) {
|
|
||||||
it(expected.toString(), () => {
|
|
||||||
// arrange
|
|
||||||
const sut = new NodePredicateFilter(() => expected);
|
|
||||||
// act
|
|
||||||
const actual = sut.matcher('nop query', getExistingNode());
|
|
||||||
// assert
|
|
||||||
expect(actual).to.equal(expected);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function getExistingNode(): ILiquorTreeExistingNode {
|
|
||||||
return {
|
|
||||||
id: 'script',
|
|
||||||
data: {
|
|
||||||
text: 'script-text',
|
|
||||||
type: NodeType.Script,
|
|
||||||
docs: [],
|
|
||||||
isReversible: false,
|
|
||||||
},
|
|
||||||
states: undefined,
|
|
||||||
children: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,212 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import { ILiquorTreeNode } from 'liquor-tree';
|
|
||||||
import { NodeType } from '@/presentation/components/Scripts/View/ScriptsTree/SelectableTree/Node/INodeContent';
|
|
||||||
import { getNewState } from '@/presentation/components/Scripts/View/ScriptsTree/SelectableTree/LiquorTree/NodeWrapper/NodeStateUpdater';
|
|
||||||
|
|
||||||
describe('NodeStateUpdater', () => {
|
|
||||||
describe('getNewState', () => {
|
|
||||||
describe('checked', () => {
|
|
||||||
describe('script node', () => {
|
|
||||||
it('true when selected', () => {
|
|
||||||
// arrange
|
|
||||||
const node = getScriptNode();
|
|
||||||
const selectedScriptNodeIds = ['a', 'b', node.id, 'c'];
|
|
||||||
// act
|
|
||||||
const state = getNewState(node, selectedScriptNodeIds);
|
|
||||||
// assert
|
|
||||||
expect(state.checked).to.equal(true);
|
|
||||||
});
|
|
||||||
it('false when unselected', () => {
|
|
||||||
// arrange
|
|
||||||
const node = getScriptNode();
|
|
||||||
const selectedScriptNodeIds = ['a', 'b', 'c'];
|
|
||||||
// act
|
|
||||||
const state = getNewState(node, selectedScriptNodeIds);
|
|
||||||
// assert
|
|
||||||
expect(state.checked).to.equal(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
describe('category node', () => {
|
|
||||||
it('true when every child selected', () => {
|
|
||||||
// arrange
|
|
||||||
const node = {
|
|
||||||
id: '1',
|
|
||||||
data: { type: NodeType.Category, docs: [], isReversible: false },
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
id: '2',
|
|
||||||
data: { type: NodeType.Category, docs: [], isReversible: false },
|
|
||||||
children: [getScriptNode('a'), getScriptNode('b')],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '3',
|
|
||||||
data: { type: NodeType.Category, docs: [], isReversible: false },
|
|
||||||
children: [getScriptNode('c')],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const selectedScriptNodeIds = ['a', 'b', 'c'];
|
|
||||||
// act
|
|
||||||
const state = getNewState(node, selectedScriptNodeIds);
|
|
||||||
// assert
|
|
||||||
expect(state.checked).to.equal(true);
|
|
||||||
});
|
|
||||||
it('false when none of the children is selected', () => {
|
|
||||||
// arrange
|
|
||||||
const node = {
|
|
||||||
id: '1',
|
|
||||||
data: { type: NodeType.Category, docs: [], isReversible: false },
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
id: '2',
|
|
||||||
data: { type: NodeType.Category, docs: [], isReversible: false },
|
|
||||||
children: [getScriptNode('a'), getScriptNode('b')],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '3',
|
|
||||||
data: { type: NodeType.Category, docs: [], isReversible: false },
|
|
||||||
children: [getScriptNode('c')],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const selectedScriptNodeIds = ['none', 'of', 'them', 'are', 'selected'];
|
|
||||||
// act
|
|
||||||
const state = getNewState(node, selectedScriptNodeIds);
|
|
||||||
// assert
|
|
||||||
expect(state.checked).to.equal(false);
|
|
||||||
});
|
|
||||||
it('false when some of the children is selected', () => {
|
|
||||||
// arrange
|
|
||||||
const node = {
|
|
||||||
id: '1',
|
|
||||||
data: { type: NodeType.Category, docs: [], isReversible: false },
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
id: '2',
|
|
||||||
data: { type: NodeType.Category, docs: [], isReversible: false },
|
|
||||||
children: [getScriptNode('a'), getScriptNode('b')],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '3',
|
|
||||||
data: { type: NodeType.Category, docs: [], isReversible: false },
|
|
||||||
children: [getScriptNode('c')],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const selectedScriptNodeIds = ['a', 'c', 'unrelated'];
|
|
||||||
// act
|
|
||||||
const state = getNewState(node, selectedScriptNodeIds);
|
|
||||||
// assert
|
|
||||||
expect(state.checked).to.equal(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
describe('indeterminate', () => {
|
|
||||||
describe('script node', () => {
|
|
||||||
it('false when selected', () => {
|
|
||||||
// arrange
|
|
||||||
const node = getScriptNode();
|
|
||||||
const selectedScriptNodeIds = ['a', 'b', node.id, 'c'];
|
|
||||||
// act
|
|
||||||
const state = getNewState(node, selectedScriptNodeIds);
|
|
||||||
// assert
|
|
||||||
expect(state.indeterminate).to.equal(false);
|
|
||||||
});
|
|
||||||
it('false when not selected', () => {
|
|
||||||
// arrange
|
|
||||||
const node = getScriptNode();
|
|
||||||
const selectedScriptNodeIds = ['a', 'b', 'c'];
|
|
||||||
// act
|
|
||||||
const state = getNewState(node, selectedScriptNodeIds);
|
|
||||||
// assert
|
|
||||||
expect(state.indeterminate).to.equal(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
describe('category node', () => {
|
|
||||||
it('false when all children are selected', () => {
|
|
||||||
// arrange
|
|
||||||
const node = {
|
|
||||||
id: '1',
|
|
||||||
data: { type: NodeType.Category, docs: [], isReversible: false },
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
id: '2',
|
|
||||||
data: { type: NodeType.Category, docs: [], isReversible: false },
|
|
||||||
children: [getScriptNode('a'), getScriptNode('b')],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '3',
|
|
||||||
data: { type: NodeType.Category, docs: [], isReversible: false },
|
|
||||||
children: [getScriptNode('c')],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const selectedScriptNodeIds = ['a', 'b', 'c'];
|
|
||||||
// act
|
|
||||||
const state = getNewState(node, selectedScriptNodeIds);
|
|
||||||
// assert
|
|
||||||
expect(state.indeterminate).to.equal(false);
|
|
||||||
});
|
|
||||||
it('true when all some are selected', () => {
|
|
||||||
// arrange
|
|
||||||
const node = {
|
|
||||||
id: '1',
|
|
||||||
data: { type: NodeType.Category, docs: [], isReversible: false },
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
id: '2',
|
|
||||||
data: { type: NodeType.Category, docs: [], isReversible: false },
|
|
||||||
children: [getScriptNode('a'), getScriptNode('b')],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '3',
|
|
||||||
data: { type: NodeType.Category, docs: [], isReversible: false },
|
|
||||||
children: [getScriptNode('c')],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const selectedScriptNodeIds = ['a'];
|
|
||||||
// act
|
|
||||||
const state = getNewState(node, selectedScriptNodeIds);
|
|
||||||
// assert
|
|
||||||
expect(state.indeterminate).to.equal(true);
|
|
||||||
});
|
|
||||||
it('false when no children are selected', () => {
|
|
||||||
// arrange
|
|
||||||
const node = {
|
|
||||||
id: '1',
|
|
||||||
data: { type: NodeType.Category, docs: [], isReversible: false },
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
id: '2',
|
|
||||||
data: { type: NodeType.Category, docs: [], isReversible: false },
|
|
||||||
children: [getScriptNode('a'), getScriptNode('b')],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '3',
|
|
||||||
data: { type: NodeType.Category, docs: [], isReversible: false },
|
|
||||||
children: [getScriptNode('c')],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const selectedScriptNodeIds = ['none', 'of', 'them', 'are', 'selected'];
|
|
||||||
// act
|
|
||||||
const state = getNewState(node, selectedScriptNodeIds);
|
|
||||||
// assert
|
|
||||||
expect(state.indeterminate).to.equal(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
function getScriptNode(scriptNodeId = 'script'): ILiquorTreeNode {
|
|
||||||
return {
|
|
||||||
id: scriptNodeId,
|
|
||||||
data: {
|
|
||||||
type: NodeType.Script,
|
|
||||||
docs: [],
|
|
||||||
isReversible: false,
|
|
||||||
},
|
|
||||||
children: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import {
|
|
||||||
ILiquorTreeExistingNode, ILiquorTreeNewNode, ILiquorTreeNodeData, ICustomLiquorTreeData,
|
|
||||||
} from 'liquor-tree';
|
|
||||||
import { NodeType, INodeContent } from '@/presentation/components/Scripts/View/ScriptsTree/SelectableTree/Node/INodeContent';
|
|
||||||
import { convertExistingToNode, toNewLiquorTreeNode } from '@/presentation/components/Scripts/View/ScriptsTree/SelectableTree/LiquorTree/NodeWrapper/NodeTranslator';
|
|
||||||
|
|
||||||
describe('NodeTranslator', () => {
|
|
||||||
it('convertExistingToNode', () => {
|
|
||||||
// arrange
|
|
||||||
const existingNode = getExistingNode();
|
|
||||||
const expected = getNode();
|
|
||||||
// act
|
|
||||||
const actual = convertExistingToNode(existingNode);
|
|
||||||
// assert
|
|
||||||
expect(actual).to.deep.equal(expected);
|
|
||||||
});
|
|
||||||
it('toNewLiquorTreeNode', () => {
|
|
||||||
// arrange
|
|
||||||
const node = getNode();
|
|
||||||
const expected = getNewNode();
|
|
||||||
// act
|
|
||||||
const actual = toNewLiquorTreeNode(node);
|
|
||||||
// assert
|
|
||||||
expect(actual).to.deep.equal(expected);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function getNode(): INodeContent {
|
|
||||||
return {
|
|
||||||
id: '1',
|
|
||||||
text: 'parentcategory',
|
|
||||||
isReversible: true,
|
|
||||||
type: NodeType.Category,
|
|
||||||
docs: ['parentcategory-doc1', 'parentcategory-doc2'],
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
id: '2',
|
|
||||||
text: 'subcategory',
|
|
||||||
isReversible: true,
|
|
||||||
docs: ['subcategory-doc1', 'subcategory-doc2'],
|
|
||||||
type: NodeType.Category,
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
id: 'script1',
|
|
||||||
text: 'cool script 1',
|
|
||||||
isReversible: true,
|
|
||||||
docs: ['script1-doc1', 'script1-doc2'],
|
|
||||||
children: [],
|
|
||||||
type: NodeType.Script,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'script2',
|
|
||||||
text: 'cool script 2',
|
|
||||||
isReversible: true,
|
|
||||||
docs: ['script2-doc1', 'script2-doc2'],
|
|
||||||
children: [],
|
|
||||||
type: NodeType.Script,
|
|
||||||
}],
|
|
||||||
}],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getExpectedExistingNodeData(node: INodeContent): ILiquorTreeNodeData {
|
|
||||||
return {
|
|
||||||
text: node.text,
|
|
||||||
type: node.type,
|
|
||||||
docs: node.docs,
|
|
||||||
isReversible: node.isReversible,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getExpectedNewNodeData(node: INodeContent): ICustomLiquorTreeData {
|
|
||||||
return {
|
|
||||||
type: node.type,
|
|
||||||
docs: node.docs,
|
|
||||||
isReversible: node.isReversible,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getExistingNode(): ILiquorTreeExistingNode {
|
|
||||||
const base = getNode();
|
|
||||||
return {
|
|
||||||
id: base.id,
|
|
||||||
data: getExpectedExistingNodeData(base),
|
|
||||||
states: undefined,
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
id: base.children[0].id,
|
|
||||||
data: getExpectedExistingNodeData(base.children[0]),
|
|
||||||
states: undefined,
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
id: base.children[0].children[0].id,
|
|
||||||
data: getExpectedExistingNodeData(base.children[0].children[0]),
|
|
||||||
states: undefined,
|
|
||||||
children: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: base.children[0].children[1].id,
|
|
||||||
data: getExpectedExistingNodeData(base.children[0].children[1]),
|
|
||||||
states: undefined,
|
|
||||||
children: [],
|
|
||||||
}],
|
|
||||||
}],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getNewNode(): ILiquorTreeNewNode {
|
|
||||||
const base = getNode();
|
|
||||||
const commonState = {
|
|
||||||
checked: false,
|
|
||||||
indeterminate: false,
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
id: base.id,
|
|
||||||
text: base.text,
|
|
||||||
data: getExpectedNewNodeData(base),
|
|
||||||
state: commonState,
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
id: base.children[0].id,
|
|
||||||
text: base.children[0].text,
|
|
||||||
data: getExpectedNewNodeData(base.children[0]),
|
|
||||||
state: commonState,
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
id: base.children[0].children[0].id,
|
|
||||||
text: base.children[0].children[0].text,
|
|
||||||
data: getExpectedNewNodeData(base.children[0].children[0]),
|
|
||||||
state: commonState,
|
|
||||||
children: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: base.children[0].children[1].id,
|
|
||||||
text: base.children[0].children[1].text,
|
|
||||||
data: getExpectedNewNodeData(base.children[0].children[1]),
|
|
||||||
state: commonState,
|
|
||||||
children: [],
|
|
||||||
}],
|
|
||||||
}],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
TreeViewFilterAction, TreeViewFilterEvent, TreeViewFilterPredicate,
|
||||||
|
createFilterRemovedEvent, createFilterTriggeredEvent,
|
||||||
|
} from '@/presentation/components/Scripts/View/Tree/TreeView/Bindings/TreeInputFilterEvent';
|
||||||
|
|
||||||
|
describe('TreeViewFilterEvent', () => {
|
||||||
|
describe('createFilterTriggeredEvent', () => {
|
||||||
|
it('returns expected action', () => {
|
||||||
|
// arrange
|
||||||
|
const expectedAction = TreeViewFilterAction.Triggered;
|
||||||
|
// act
|
||||||
|
const event = createFilterTriggeredEvent(createPredicateStub());
|
||||||
|
// expect
|
||||||
|
expect(event.action).to.equal(expectedAction);
|
||||||
|
});
|
||||||
|
describe('returns expected predicate', () => {
|
||||||
|
const testCases: ReadonlyArray<{
|
||||||
|
readonly name: string,
|
||||||
|
readonly givenPredicate: TreeViewFilterPredicate,
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
name: 'given a real predicate',
|
||||||
|
givenPredicate: createPredicateStub(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'given undefined predicate',
|
||||||
|
givenPredicate: undefined,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
testCases.forEach(({ name, givenPredicate }) => {
|
||||||
|
it(name, () => {
|
||||||
|
// arrange
|
||||||
|
const expectedPredicate = givenPredicate;
|
||||||
|
// act
|
||||||
|
const event = createFilterTriggeredEvent(expectedPredicate);
|
||||||
|
// assert
|
||||||
|
expect(event.predicate).to.equal(expectedPredicate);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('returns event even without predicate', () => {
|
||||||
|
// act
|
||||||
|
const predicate = null as TreeViewFilterPredicate;
|
||||||
|
// assert
|
||||||
|
const event = createFilterTriggeredEvent(predicate);
|
||||||
|
// expect
|
||||||
|
expect(event.predicate).to.equal(predicate);
|
||||||
|
});
|
||||||
|
it('returns unique timestamp', () => {
|
||||||
|
// arrange
|
||||||
|
const instances = new Array<TreeViewFilterEvent>();
|
||||||
|
// act
|
||||||
|
instances.push(
|
||||||
|
createFilterTriggeredEvent(createPredicateStub()),
|
||||||
|
createFilterTriggeredEvent(createPredicateStub()),
|
||||||
|
createFilterTriggeredEvent(createPredicateStub()),
|
||||||
|
);
|
||||||
|
// assert
|
||||||
|
const uniqueDates = new Set(instances.map((instance) => instance.timestamp));
|
||||||
|
expect(uniqueDates).to.have.length(instances.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createFilterRemovedEvent', () => {
|
||||||
|
it('returns expected action', () => {
|
||||||
|
// arrange
|
||||||
|
const expectedAction = TreeViewFilterAction.Removed;
|
||||||
|
// act
|
||||||
|
const event = createFilterRemovedEvent();
|
||||||
|
// expect
|
||||||
|
expect(event.action).to.equal(expectedAction);
|
||||||
|
});
|
||||||
|
it('returns without predicate', () => {
|
||||||
|
// arrange
|
||||||
|
const expected = undefined;
|
||||||
|
// act
|
||||||
|
const event = createFilterRemovedEvent();
|
||||||
|
// assert
|
||||||
|
expect(event.predicate).to.equal(expected);
|
||||||
|
});
|
||||||
|
it('returns unique timestamp', () => {
|
||||||
|
// arrange
|
||||||
|
const instances = new Array<TreeViewFilterEvent>();
|
||||||
|
// act
|
||||||
|
instances.push(
|
||||||
|
createFilterRemovedEvent(),
|
||||||
|
createFilterRemovedEvent(),
|
||||||
|
createFilterRemovedEvent(),
|
||||||
|
);
|
||||||
|
// assert
|
||||||
|
const uniqueDates = new Set(instances.map((instance) => instance.timestamp));
|
||||||
|
expect(uniqueDates).to.have.length(instances.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function createPredicateStub(): TreeViewFilterPredicate {
|
||||||
|
return () => true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { TreeNodeHierarchy } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/Hierarchy/TreeNodeHierarchy';
|
||||||
|
import { TreeNode } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/TreeNode';
|
||||||
|
import { TreeNodeStub } from '@tests/unit/shared/Stubs/TreeNodeStub';
|
||||||
|
import { HierarchyAccessStub } from '@tests/unit/shared/Stubs/HierarchyAccessStub';
|
||||||
|
|
||||||
|
describe('TreeNodeHierarchy', () => {
|
||||||
|
describe('setChildren', () => {
|
||||||
|
it('sets the provided children correctly', () => {
|
||||||
|
// arrange
|
||||||
|
const hierarchy = new TreeNodeHierarchy();
|
||||||
|
const expectedChildren = [new TreeNodeStub(), new TreeNodeStub()];
|
||||||
|
// act
|
||||||
|
hierarchy.setChildren(expectedChildren);
|
||||||
|
// assert
|
||||||
|
expect(hierarchy.children).to.deep.equal(expectedChildren);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setParent', () => {
|
||||||
|
it('sets the provided children correctly', () => {
|
||||||
|
// arrange
|
||||||
|
const hierarchy = new TreeNodeHierarchy();
|
||||||
|
const expectedParent = new TreeNodeStub();
|
||||||
|
// act
|
||||||
|
hierarchy.setParent(expectedParent);
|
||||||
|
// assert
|
||||||
|
expect(hierarchy.parent).to.deep.equal(expectedParent);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isLeafNode', () => {
|
||||||
|
it('returns `true` without children', () => {
|
||||||
|
// arrange
|
||||||
|
const hierarchy = new TreeNodeHierarchy();
|
||||||
|
const children = [];
|
||||||
|
// act
|
||||||
|
hierarchy.setChildren(children);
|
||||||
|
// assert
|
||||||
|
expect(hierarchy.isLeafNode).to.equal(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns `false` with children', () => {
|
||||||
|
// arrange
|
||||||
|
const hierarchy = new TreeNodeHierarchy();
|
||||||
|
const children = [new TreeNodeStub()];
|
||||||
|
// act
|
||||||
|
hierarchy.setChildren(children);
|
||||||
|
// assert
|
||||||
|
expect(hierarchy.isLeafNode).to.equal(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isBranchNode', () => {
|
||||||
|
it('returns `false` without children', () => {
|
||||||
|
// arrange
|
||||||
|
const hierarchy = new TreeNodeHierarchy();
|
||||||
|
const children = [];
|
||||||
|
// act
|
||||||
|
hierarchy.setChildren(children);
|
||||||
|
// assert
|
||||||
|
expect(hierarchy.isBranchNode).to.equal(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns `true` with children', () => {
|
||||||
|
// arrange
|
||||||
|
const hierarchy = new TreeNodeHierarchy();
|
||||||
|
const children = [new TreeNodeStub()];
|
||||||
|
// act
|
||||||
|
hierarchy.setChildren(children);
|
||||||
|
// assert
|
||||||
|
expect(hierarchy.isBranchNode).to.equal(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('depthInTree', () => {
|
||||||
|
interface DepthTestScenario {
|
||||||
|
readonly parentNode: TreeNode,
|
||||||
|
readonly expectedDepth: number;
|
||||||
|
}
|
||||||
|
const testCases: readonly DepthTestScenario[] = [
|
||||||
|
{
|
||||||
|
parentNode: undefined,
|
||||||
|
expectedDepth: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
parentNode: new TreeNodeStub()
|
||||||
|
.withHierarchy(
|
||||||
|
new HierarchyAccessStub()
|
||||||
|
.withDepthInTree(0)
|
||||||
|
.withParent(undefined),
|
||||||
|
),
|
||||||
|
expectedDepth: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
parentNode: new TreeNodeStub().withHierarchy(
|
||||||
|
new HierarchyAccessStub()
|
||||||
|
.withDepthInTree(1)
|
||||||
|
.withParent(new TreeNodeStub()),
|
||||||
|
),
|
||||||
|
expectedDepth: 2,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
testCases.forEach(({ parentNode, expectedDepth }) => {
|
||||||
|
it(`when depth is expected to be ${expectedDepth}`, () => {
|
||||||
|
// arrange
|
||||||
|
const hierarchy = new TreeNodeHierarchy();
|
||||||
|
// act
|
||||||
|
hierarchy.setParent(parentNode);
|
||||||
|
// assert
|
||||||
|
expect(hierarchy.depthInTree).to.equal(expectedDepth);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import { expect } from 'vitest';
|
||||||
|
import { TreeNodeState } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/State/TreeNodeState';
|
||||||
|
import { TreeNodeStateDescriptor } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/State/StateDescriptor';
|
||||||
|
import { TreeNodeCheckState } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/State/CheckState';
|
||||||
|
import { NodeStateChangedEvent, TreeNodeStateTransaction } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/State/StateAccess';
|
||||||
|
import { PropertyKeys } from '@/TypeHelpers';
|
||||||
|
|
||||||
|
describe('TreeNodeState', () => {
|
||||||
|
describe('beginTransaction', () => {
|
||||||
|
it('begins empty transaction', () => {
|
||||||
|
// arrange
|
||||||
|
const treeNodeState = new TreeNodeState();
|
||||||
|
// act
|
||||||
|
const transaction = treeNodeState.beginTransaction();
|
||||||
|
// assert
|
||||||
|
expect(Object.keys(transaction.updatedState)).to.have.lengthOf(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('commitTransaction', () => {
|
||||||
|
it('should update the current state with transaction changes', () => {
|
||||||
|
// arrange
|
||||||
|
const treeNodeState = new TreeNodeState();
|
||||||
|
const initialState: TreeNodeStateDescriptor = treeNodeState.current;
|
||||||
|
const transaction = treeNodeState
|
||||||
|
.beginTransaction()
|
||||||
|
.withCheckState(TreeNodeCheckState.Checked);
|
||||||
|
// act
|
||||||
|
treeNodeState.commitTransaction(transaction);
|
||||||
|
// assert
|
||||||
|
expect(treeNodeState.current.checkState).to.equal(TreeNodeCheckState.Checked);
|
||||||
|
expect(treeNodeState.current.isExpanded).to.equal(initialState.isExpanded);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should notify when state changes', () => {
|
||||||
|
// arrange
|
||||||
|
const treeNodeState = new TreeNodeState();
|
||||||
|
const transaction = treeNodeState
|
||||||
|
.beginTransaction()
|
||||||
|
.withCheckState(TreeNodeCheckState.Checked);
|
||||||
|
let notifiedEvent: NodeStateChangedEvent;
|
||||||
|
// act
|
||||||
|
treeNodeState.changed.on((event) => {
|
||||||
|
notifiedEvent = event;
|
||||||
|
});
|
||||||
|
treeNodeState.commitTransaction(transaction);
|
||||||
|
// assert
|
||||||
|
expect(notifiedEvent).to.not.equal(undefined);
|
||||||
|
expect(notifiedEvent.oldState.checkState).to.equal(TreeNodeCheckState.Unchecked);
|
||||||
|
expect(notifiedEvent.newState.checkState).to.equal(TreeNodeCheckState.Checked);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not notify when state does not change', () => {
|
||||||
|
// arrange
|
||||||
|
const treeNodeState = new TreeNodeState();
|
||||||
|
const currentState = treeNodeState.current;
|
||||||
|
let transaction = treeNodeState
|
||||||
|
.beginTransaction();
|
||||||
|
const updateActions: {
|
||||||
|
readonly [K in PropertyKeys<TreeNodeStateDescriptor>]: (
|
||||||
|
describer: TreeNodeStateTransaction,
|
||||||
|
) => TreeNodeStateTransaction;
|
||||||
|
} = {
|
||||||
|
checkState: (describer) => describer.withCheckState(currentState.checkState),
|
||||||
|
isExpanded: (describer) => describer.withExpansionState(currentState.isExpanded),
|
||||||
|
isFocused: (describer) => describer.withFocusState(currentState.isFocused),
|
||||||
|
isVisible: (describer) => describer.withVisibilityState(currentState.isVisible),
|
||||||
|
isMatched: (describer) => describer.withMatchState(currentState.isMatched),
|
||||||
|
};
|
||||||
|
Object.values(updateActions).forEach((updateTransaction) => {
|
||||||
|
transaction = updateTransaction(transaction);
|
||||||
|
});
|
||||||
|
let isNotified = false;
|
||||||
|
// act
|
||||||
|
treeNodeState.changed.on(() => {
|
||||||
|
isNotified = true;
|
||||||
|
});
|
||||||
|
treeNodeState.commitTransaction(transaction);
|
||||||
|
// assert
|
||||||
|
expect(isNotified).to.equal(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toggleCheck', () => {
|
||||||
|
describe('transitions state as expected', () => {
|
||||||
|
interface ToggleCheckTestScenario {
|
||||||
|
readonly initialState: TreeNodeCheckState;
|
||||||
|
readonly expectedState: TreeNodeCheckState;
|
||||||
|
}
|
||||||
|
const testCases: readonly ToggleCheckTestScenario[] = [
|
||||||
|
{
|
||||||
|
initialState: TreeNodeCheckState.Unchecked,
|
||||||
|
expectedState: TreeNodeCheckState.Checked,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
initialState: TreeNodeCheckState.Checked,
|
||||||
|
expectedState: TreeNodeCheckState.Unchecked,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
initialState: TreeNodeCheckState.Indeterminate,
|
||||||
|
expectedState: TreeNodeCheckState.Unchecked,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
testCases.forEach(({ initialState, expectedState }) => {
|
||||||
|
it(`should toggle checkState from ${TreeNodeCheckState[initialState]} to ${TreeNodeCheckState[expectedState]}`, () => {
|
||||||
|
// arrange
|
||||||
|
const treeNodeState = new TreeNodeState();
|
||||||
|
treeNodeState.commitTransaction(
|
||||||
|
treeNodeState.beginTransaction().withCheckState(initialState),
|
||||||
|
);
|
||||||
|
// act
|
||||||
|
treeNodeState.toggleCheck();
|
||||||
|
// assert
|
||||||
|
expect(treeNodeState.current.checkState).to.equal(expectedState);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('should emit changed event', () => {
|
||||||
|
// arrange
|
||||||
|
const treeNodeState = new TreeNodeState();
|
||||||
|
let isNotified = false;
|
||||||
|
treeNodeState.changed.on(() => {
|
||||||
|
isNotified = true;
|
||||||
|
});
|
||||||
|
// act
|
||||||
|
treeNodeState.toggleCheck();
|
||||||
|
// assert
|
||||||
|
expect(isNotified).to.equal(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toggleExpand', () => {
|
||||||
|
describe('transitions state as expected', () => {
|
||||||
|
interface ToggleExpandTestScenario {
|
||||||
|
readonly initialState: boolean;
|
||||||
|
readonly expectedState: boolean;
|
||||||
|
}
|
||||||
|
const testCases: readonly ToggleExpandTestScenario[] = [
|
||||||
|
{
|
||||||
|
initialState: true,
|
||||||
|
expectedState: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
initialState: false,
|
||||||
|
expectedState: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
testCases.forEach(({ initialState, expectedState }) => {
|
||||||
|
it(`should toggle isExpanded from ${initialState} to ${expectedState}`, () => {
|
||||||
|
// arrange
|
||||||
|
const treeNodeState = new TreeNodeState();
|
||||||
|
treeNodeState.commitTransaction(
|
||||||
|
treeNodeState.beginTransaction().withExpansionState(initialState),
|
||||||
|
);
|
||||||
|
// act
|
||||||
|
treeNodeState.toggleExpand();
|
||||||
|
// assert
|
||||||
|
expect(treeNodeState.current.isExpanded).to.equal(expectedState);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('should emit changed event', () => {
|
||||||
|
// arrange
|
||||||
|
const treeNodeState = new TreeNodeState();
|
||||||
|
let isNotified = false;
|
||||||
|
treeNodeState.changed.on(() => {
|
||||||
|
isNotified = true;
|
||||||
|
});
|
||||||
|
// act
|
||||||
|
treeNodeState.toggleExpand();
|
||||||
|
// assert
|
||||||
|
expect(isNotified).to.equal(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { TreeNodeStateTransactionDescriber } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/State/TreeNodeStateTransactionDescriber';
|
||||||
|
import { TreeNodeStateDescriptor } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/State/StateDescriptor';
|
||||||
|
import { PropertyKeys } from '@/TypeHelpers';
|
||||||
|
import { TreeNodeStateTransaction } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/State/StateAccess';
|
||||||
|
import { TreeNodeCheckState } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/State/CheckState';
|
||||||
|
|
||||||
|
describe('TreeNodeStateTransactionDescriber', () => {
|
||||||
|
describe('sets state as expected', () => {
|
||||||
|
const testScenarios: {
|
||||||
|
readonly [K in PropertyKeys<TreeNodeStateDescriptor>]: {
|
||||||
|
readonly applyStateChange: (
|
||||||
|
describer: TreeNodeStateTransaction,
|
||||||
|
) => TreeNodeStateTransaction,
|
||||||
|
readonly extractStateValue: (descriptor: Partial<TreeNodeStateDescriptor>) => unknown,
|
||||||
|
readonly expectedValue: unknown,
|
||||||
|
}
|
||||||
|
} = {
|
||||||
|
checkState: {
|
||||||
|
applyStateChange: (describer) => describer.withCheckState(TreeNodeCheckState.Indeterminate),
|
||||||
|
extractStateValue: (descriptor) => descriptor.checkState,
|
||||||
|
expectedValue: TreeNodeCheckState.Indeterminate,
|
||||||
|
},
|
||||||
|
isExpanded: {
|
||||||
|
applyStateChange: (describer) => describer.withExpansionState(true),
|
||||||
|
extractStateValue: (descriptor) => descriptor.isExpanded,
|
||||||
|
expectedValue: true,
|
||||||
|
},
|
||||||
|
isVisible: {
|
||||||
|
applyStateChange: (describer) => describer.withVisibilityState(true),
|
||||||
|
extractStateValue: (descriptor) => descriptor.isVisible,
|
||||||
|
expectedValue: true,
|
||||||
|
},
|
||||||
|
isMatched: {
|
||||||
|
applyStateChange: (describer) => describer.withMatchState(true),
|
||||||
|
extractStateValue: (descriptor) => descriptor.isMatched,
|
||||||
|
expectedValue: true,
|
||||||
|
},
|
||||||
|
isFocused: {
|
||||||
|
applyStateChange: (describer) => describer.withFocusState(true),
|
||||||
|
extractStateValue: (descriptor) => descriptor.isFocused,
|
||||||
|
expectedValue: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
describe('sets single state as expected', () => {
|
||||||
|
Object.entries(testScenarios).forEach(([stateKey, {
|
||||||
|
applyStateChange, extractStateValue, expectedValue,
|
||||||
|
}]) => {
|
||||||
|
it(stateKey, () => {
|
||||||
|
// arrange
|
||||||
|
let describer: TreeNodeStateTransaction = new TreeNodeStateTransactionDescriber();
|
||||||
|
// act
|
||||||
|
describer = applyStateChange(describer);
|
||||||
|
// assert
|
||||||
|
const actualValue = extractStateValue(describer.updatedState);
|
||||||
|
expect(actualValue).to.equal(expectedValue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('chains multiple state setters correctly', () => {
|
||||||
|
// arrange
|
||||||
|
let describer: TreeNodeStateTransaction = new TreeNodeStateTransactionDescriber();
|
||||||
|
// act
|
||||||
|
Object.values(testScenarios).forEach(({ applyStateChange }) => {
|
||||||
|
describer = applyStateChange(describer);
|
||||||
|
});
|
||||||
|
// assert
|
||||||
|
Object.values(testScenarios).forEach(({ extractStateValue, expectedValue }) => {
|
||||||
|
const actualValue = extractStateValue(describer.updatedState);
|
||||||
|
expect(actualValue).to.equal(expectedValue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { TreeNodeManager } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/TreeNodeManager';
|
||||||
|
import { itEachAbsentObjectValue, itEachAbsentStringValue } from '@tests/unit/shared/TestCases/AbsentTests';
|
||||||
|
import { TreeNodeHierarchy } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/Hierarchy/TreeNodeHierarchy';
|
||||||
|
import { TreeNodeState } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/State/TreeNodeState';
|
||||||
|
|
||||||
|
describe('TreeNodeManager', () => {
|
||||||
|
describe('constructor', () => {
|
||||||
|
describe('id', () => {
|
||||||
|
it('should initialize with the provided id', () => {
|
||||||
|
// arrange
|
||||||
|
const expectedId = 'test-id';
|
||||||
|
// act
|
||||||
|
const node = new TreeNodeManager(expectedId);
|
||||||
|
// assert
|
||||||
|
expect(node.id).to.equal(expectedId);
|
||||||
|
});
|
||||||
|
describe('should throw an error if id is not provided', () => {
|
||||||
|
itEachAbsentStringValue((absentId) => {
|
||||||
|
// arrange
|
||||||
|
const expectedError = 'missing id';
|
||||||
|
// act
|
||||||
|
const act = () => new TreeNodeManager(absentId);
|
||||||
|
// assert
|
||||||
|
expect(act).to.throw(expectedError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('metadata', () => {
|
||||||
|
it('should initialize with the provided metadata', () => {
|
||||||
|
// arrange
|
||||||
|
const expectedMetadata = { key: 'value' };
|
||||||
|
// act
|
||||||
|
const node = new TreeNodeManager('id', expectedMetadata);
|
||||||
|
// assert
|
||||||
|
expect(node.metadata).to.equal(expectedMetadata);
|
||||||
|
});
|
||||||
|
describe('should accept absent metadata', () => {
|
||||||
|
itEachAbsentObjectValue((absentMetadata) => {
|
||||||
|
// arrange
|
||||||
|
const expectedMetadata = absentMetadata;
|
||||||
|
// act
|
||||||
|
const node = new TreeNodeManager('id', expectedMetadata);
|
||||||
|
// assert
|
||||||
|
expect(node.metadata).to.equal(absentMetadata);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('hierarchy', () => {
|
||||||
|
it(`should initialize as an instance of ${TreeNodeHierarchy.name}`, () => {
|
||||||
|
// arrange
|
||||||
|
const expectedType = TreeNodeHierarchy;
|
||||||
|
// act
|
||||||
|
const node = new TreeNodeManager('id');
|
||||||
|
// assert
|
||||||
|
expect(node.hierarchy).to.be.an.instanceOf(expectedType);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('state', () => {
|
||||||
|
it(`should initialize as an instance of ${TreeNodeState.name}`, () => {
|
||||||
|
// arrange
|
||||||
|
const expectedType = TreeNodeState;
|
||||||
|
// act
|
||||||
|
const node = new TreeNodeManager('id');
|
||||||
|
// assert
|
||||||
|
expect(node.state).to.be.an.instanceOf(expectedType);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { shallowMount } from '@vue/test-utils';
|
||||||
|
import { defineComponent, nextTick } from 'vue';
|
||||||
|
import {
|
||||||
|
WindowWithEventListeners, useKeyboardInteractionState,
|
||||||
|
} from '@/presentation/components/Scripts/View/Tree/TreeView/Node/UseKeyboardInteractionState';
|
||||||
|
|
||||||
|
describe('useKeyboardInteractionState', () => {
|
||||||
|
describe('isKeyboardBeingUsed', () => {
|
||||||
|
it('should initialize as `false`', () => {
|
||||||
|
// arrange
|
||||||
|
const { windowStub } = createWindowStub();
|
||||||
|
// act
|
||||||
|
const { returnObject } = mountWrapperComponent(windowStub);
|
||||||
|
// assert
|
||||||
|
expect(returnObject.isKeyboardBeingUsed.value).to.equal(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set to `true` on keydown event', () => {
|
||||||
|
// arrange
|
||||||
|
const { triggerEvent, windowStub } = createWindowStub();
|
||||||
|
// act
|
||||||
|
const { returnObject } = mountWrapperComponent(windowStub);
|
||||||
|
triggerEvent('keydown');
|
||||||
|
// assert
|
||||||
|
expect(returnObject.isKeyboardBeingUsed.value).to.equal(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should stay `false` on click event', () => {
|
||||||
|
// arrange
|
||||||
|
const { triggerEvent, windowStub } = createWindowStub();
|
||||||
|
// act
|
||||||
|
const { returnObject } = mountWrapperComponent(windowStub);
|
||||||
|
triggerEvent('click');
|
||||||
|
// assert
|
||||||
|
expect(returnObject.isKeyboardBeingUsed.value).to.equal(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should transition to `false` on click event after keydown event', () => {
|
||||||
|
// arrange
|
||||||
|
const { triggerEvent, windowStub } = createWindowStub();
|
||||||
|
// act
|
||||||
|
const { returnObject } = mountWrapperComponent(windowStub);
|
||||||
|
triggerEvent('keydown');
|
||||||
|
triggerEvent('click');
|
||||||
|
// assert
|
||||||
|
expect(returnObject.isKeyboardBeingUsed.value).to.equal(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('attach/detach', () => {
|
||||||
|
it('should attach keydown and click events on mounted', () => {
|
||||||
|
// arrange
|
||||||
|
const { listeners, windowStub } = createWindowStub();
|
||||||
|
// act
|
||||||
|
mountWrapperComponent(windowStub);
|
||||||
|
// assert
|
||||||
|
expect(listeners.keydown).to.have.lengthOf(1);
|
||||||
|
expect(listeners.click).to.have.lengthOf(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detach keydown and click events on unmounted', async () => {
|
||||||
|
// arrange
|
||||||
|
const { listeners, windowStub } = createWindowStub();
|
||||||
|
// act
|
||||||
|
const { wrapper } = mountWrapperComponent(windowStub);
|
||||||
|
wrapper.destroy();
|
||||||
|
await nextTick();
|
||||||
|
// assert
|
||||||
|
expect(listeners.keydown).to.have.lengthOf(0);
|
||||||
|
expect(listeners.click).to.have.lengthOf(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function mountWrapperComponent(window: WindowWithEventListeners) {
|
||||||
|
let returnObject: ReturnType<typeof useKeyboardInteractionState>;
|
||||||
|
const wrapper = shallowMount(defineComponent({
|
||||||
|
setup() {
|
||||||
|
returnObject = useKeyboardInteractionState(window);
|
||||||
|
},
|
||||||
|
template: '<div></div>',
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
returnObject,
|
||||||
|
wrapper,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type EventListenerWindowFunction = (ev: Event) => unknown;
|
||||||
|
type WindowEventKey = keyof WindowEventMap;
|
||||||
|
|
||||||
|
function createWindowStub() {
|
||||||
|
const listeners: Partial<Record<WindowEventKey, EventListenerWindowFunction[]>> = {};
|
||||||
|
const windowStub: WindowWithEventListeners = {
|
||||||
|
addEventListener: (eventName: string, fn: EventListenerWindowFunction) => {
|
||||||
|
if (!listeners[eventName]) {
|
||||||
|
listeners[eventName] = [];
|
||||||
|
}
|
||||||
|
listeners[eventName].push(fn);
|
||||||
|
},
|
||||||
|
removeEventListener: (eventName: string, fn: EventListenerWindowFunction) => {
|
||||||
|
if (!listeners[eventName]) return;
|
||||||
|
const index = listeners[eventName].indexOf(fn);
|
||||||
|
if (index > -1) {
|
||||||
|
listeners[eventName].splice(index, 1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
windowStub,
|
||||||
|
triggerEvent: (eventName: WindowEventKey) => {
|
||||||
|
listeners[eventName]?.forEach((fn) => fn(new Event(eventName)));
|
||||||
|
},
|
||||||
|
listeners,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
ref, defineComponent, WatchSource, nextTick,
|
||||||
|
} from 'vue';
|
||||||
|
import { shallowMount } from '@vue/test-utils';
|
||||||
|
import { ReadOnlyTreeNode } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/TreeNode';
|
||||||
|
import { useNodeState } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/UseNodeState';
|
||||||
|
import { TreeNodeStateDescriptorStub } from '@tests/unit/shared/Stubs/TreeNodeStateDescriptorStub';
|
||||||
|
import { TreeNodeStub } from '@tests/unit/shared/Stubs/TreeNodeStub';
|
||||||
|
import { TreeNodeStateAccessStub } from '@tests/unit/shared/Stubs/TreeNodeStateAccessStub';
|
||||||
|
import { NodeStateChangedEventStub } from '@tests/unit/shared/Stubs/NodeStateChangedEventStub';
|
||||||
|
import { InjectionKeys } from '@/presentation/injectionSymbols';
|
||||||
|
import { UseAutoUnsubscribedEventsStub } from '@tests/unit/shared/Stubs/UseAutoUnsubscribedEventsStub';
|
||||||
|
|
||||||
|
describe('useNodeState', () => {
|
||||||
|
it('should set state on immediate invocation if node exists', () => {
|
||||||
|
// arrange
|
||||||
|
const expectedState = new TreeNodeStateDescriptorStub();
|
||||||
|
const nodeWatcher = ref<ReadOnlyTreeNode | undefined>(undefined);
|
||||||
|
nodeWatcher.value = new TreeNodeStub()
|
||||||
|
.withState(new TreeNodeStateAccessStub().withCurrent(expectedState));
|
||||||
|
// act
|
||||||
|
const { returnObject } = mountWrapperComponent(nodeWatcher);
|
||||||
|
// assert
|
||||||
|
expect(returnObject.state.value).to.equal(expectedState);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not set state on immediate invocation if node is undefined', () => {
|
||||||
|
// arrange
|
||||||
|
const nodeWatcher = ref<ReadOnlyTreeNode | undefined>(undefined);
|
||||||
|
// act
|
||||||
|
const { returnObject } = mountWrapperComponent(nodeWatcher);
|
||||||
|
// assert
|
||||||
|
expect(returnObject.state.value).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update state when nodeWatcher changes', async () => {
|
||||||
|
// arrange
|
||||||
|
const expectedNewState = new TreeNodeStateDescriptorStub();
|
||||||
|
const nodeWatcher = ref<ReadOnlyTreeNode | undefined>(undefined);
|
||||||
|
const { returnObject } = mountWrapperComponent(nodeWatcher);
|
||||||
|
// act
|
||||||
|
nodeWatcher.value = new TreeNodeStub()
|
||||||
|
.withState(new TreeNodeStateAccessStub().withCurrent(expectedNewState));
|
||||||
|
await nextTick();
|
||||||
|
// assert
|
||||||
|
expect(returnObject.state.value).to.equal(expectedNewState);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update state when node state changes', () => {
|
||||||
|
// arrange
|
||||||
|
const nodeWatcher = ref<ReadOnlyTreeNode | undefined>(undefined);
|
||||||
|
const stateAccessStub = new TreeNodeStateAccessStub();
|
||||||
|
const expectedChangedState = new TreeNodeStateDescriptorStub();
|
||||||
|
nodeWatcher.value = new TreeNodeStub()
|
||||||
|
.withState(stateAccessStub);
|
||||||
|
|
||||||
|
// act
|
||||||
|
const { returnObject } = mountWrapperComponent(nodeWatcher);
|
||||||
|
stateAccessStub.triggerStateChangedEvent(
|
||||||
|
new NodeStateChangedEventStub()
|
||||||
|
.withNewState(expectedChangedState),
|
||||||
|
);
|
||||||
|
|
||||||
|
// assert
|
||||||
|
expect(returnObject.state.value).to.equal(expectedChangedState);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function mountWrapperComponent(nodeWatcher: WatchSource<ReadOnlyTreeNode | undefined>) {
|
||||||
|
let returnObject: ReturnType<typeof useNodeState>;
|
||||||
|
const wrapper = shallowMount(
|
||||||
|
defineComponent({
|
||||||
|
setup() {
|
||||||
|
returnObject = useNodeState(nodeWatcher);
|
||||||
|
},
|
||||||
|
template: '<div></div>',
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
provide: {
|
||||||
|
[InjectionKeys.useAutoUnsubscribedEvents as symbol]:
|
||||||
|
() => new UseAutoUnsubscribedEventsStub().get(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
wrapper,
|
||||||
|
returnObject,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { expect, describe, it } from 'vitest';
|
||||||
|
import { SingleNodeCollectionFocusManager } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/Focus/SingleNodeCollectionFocusManager';
|
||||||
|
import { TreeNodeCollectionStub } from '@tests/unit/shared/Stubs/TreeNodeCollectionStub';
|
||||||
|
import { QueryableNodesStub } from '@tests/unit/shared/Stubs/QueryableNodesStub';
|
||||||
|
import { TreeNodeStub } from '@tests/unit/shared/Stubs/TreeNodeStub';
|
||||||
|
import { TreeNodeStateAccessStub } from '@tests/unit/shared/Stubs/TreeNodeStateAccessStub';
|
||||||
|
import { TreeNodeStateDescriptorStub } from '@tests/unit/shared/Stubs/TreeNodeStateDescriptorStub';
|
||||||
|
import { TreeNode } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/TreeNode';
|
||||||
|
|
||||||
|
describe('SingleNodeCollectionFocusManager', () => {
|
||||||
|
describe('currentSingleFocusedNode', () => {
|
||||||
|
const testCases: ReadonlyArray<{
|
||||||
|
readonly name: string,
|
||||||
|
readonly nodes: TreeNode[],
|
||||||
|
readonly expectedValue: TreeNode | undefined,
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
name: 'should return undefined if no node is focused',
|
||||||
|
nodes: [],
|
||||||
|
expectedValue: undefined,
|
||||||
|
},
|
||||||
|
(() => {
|
||||||
|
const unfocusedNode = getNodeWithFocusState(false);
|
||||||
|
const focusedNode = getNodeWithFocusState(true);
|
||||||
|
return {
|
||||||
|
name: 'should return the single focused node',
|
||||||
|
nodes: [focusedNode, unfocusedNode],
|
||||||
|
expectedValue: focusedNode,
|
||||||
|
};
|
||||||
|
})(),
|
||||||
|
{
|
||||||
|
name: 'should return undefined if multiple nodes are focused',
|
||||||
|
nodes: [getNodeWithFocusState(true), getNodeWithFocusState(true)],
|
||||||
|
expectedValue: undefined,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
testCases.forEach(({ name, nodes, expectedValue }) => {
|
||||||
|
it(name, () => {
|
||||||
|
// arrange
|
||||||
|
const collection = new TreeNodeCollectionStub()
|
||||||
|
.withNodes(new QueryableNodesStub().withFlattenedNodes(nodes));
|
||||||
|
const focusManager = new SingleNodeCollectionFocusManager(collection);
|
||||||
|
// act
|
||||||
|
const singleFocusedNode = focusManager.currentSingleFocusedNode;
|
||||||
|
// assert
|
||||||
|
expect(singleFocusedNode).to.equal(expectedValue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setSingleFocus', () => {
|
||||||
|
it('should set focus on given node and remove focus from all others', () => {
|
||||||
|
// arrange
|
||||||
|
const node1 = getNodeWithFocusState(true);
|
||||||
|
const node2 = getNodeWithFocusState(true);
|
||||||
|
const node3 = getNodeWithFocusState(false);
|
||||||
|
const collection = new TreeNodeCollectionStub()
|
||||||
|
.withNodes(new QueryableNodesStub().withFlattenedNodes([node1, node2, node3]));
|
||||||
|
// act
|
||||||
|
const focusManager = new SingleNodeCollectionFocusManager(collection);
|
||||||
|
focusManager.setSingleFocus(node3);
|
||||||
|
// assert
|
||||||
|
expect(node1.state.current.isFocused).toBeFalsy();
|
||||||
|
expect(node2.state.current.isFocused).toBeFalsy();
|
||||||
|
expect(node3.state.current.isFocused).toBeTruthy();
|
||||||
|
});
|
||||||
|
it('should set currentSingleFocusedNode as expected', () => {
|
||||||
|
// arrange
|
||||||
|
const nodeToFocus = getNodeWithFocusState(false);
|
||||||
|
const collection = new TreeNodeCollectionStub()
|
||||||
|
.withNodes(new QueryableNodesStub().withFlattenedNodes([
|
||||||
|
getNodeWithFocusState(false),
|
||||||
|
getNodeWithFocusState(true),
|
||||||
|
nodeToFocus,
|
||||||
|
getNodeWithFocusState(false),
|
||||||
|
getNodeWithFocusState(true),
|
||||||
|
]));
|
||||||
|
// act
|
||||||
|
const focusManager = new SingleNodeCollectionFocusManager(collection);
|
||||||
|
focusManager.setSingleFocus(nodeToFocus);
|
||||||
|
// assert
|
||||||
|
expect(focusManager.currentSingleFocusedNode).toEqual(nodeToFocus);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function getNodeWithFocusState(isFocused: boolean): TreeNodeStub {
|
||||||
|
return new TreeNodeStub()
|
||||||
|
.withState(new TreeNodeStateAccessStub().withCurrent(
|
||||||
|
new TreeNodeStateDescriptorStub().withFocusState(isFocused),
|
||||||
|
));
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { expect } from 'vitest';
|
||||||
|
import { TreeNodeStub } from '@tests/unit/shared/Stubs/TreeNodeStub';
|
||||||
|
import { HierarchyAccessStub } from '@tests/unit/shared/Stubs/HierarchyAccessStub';
|
||||||
|
import { TreeNodeNavigator } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/NodeCollection/Query/TreeNodeNavigator';
|
||||||
|
|
||||||
|
describe('TreeNodeNavigator', () => {
|
||||||
|
describe('flattenedNodes', () => {
|
||||||
|
it('should flatten root nodes correctly', () => {
|
||||||
|
// arrange
|
||||||
|
const rootNode1 = new TreeNodeStub();
|
||||||
|
const rootNode2 = new TreeNodeStub();
|
||||||
|
const rootNode3 = new TreeNodeStub();
|
||||||
|
// act
|
||||||
|
const navigator = new TreeNodeNavigator([rootNode1, rootNode2, rootNode3]);
|
||||||
|
// assert
|
||||||
|
expect(navigator.flattenedNodes).to.have.length(3);
|
||||||
|
expect(navigator.flattenedNodes).to.include.members([rootNode1, rootNode2, rootNode3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should flatten nested nodes correctly', () => {
|
||||||
|
// arrange
|
||||||
|
const nestedNode = new TreeNodeStub();
|
||||||
|
const nestedNode2 = new TreeNodeStub();
|
||||||
|
const rootNode = new TreeNodeStub()
|
||||||
|
.withHierarchy(new HierarchyAccessStub().withChildren([nestedNode, nestedNode2]));
|
||||||
|
// act
|
||||||
|
const navigator = new TreeNodeNavigator([rootNode]);
|
||||||
|
// assert
|
||||||
|
expect(navigator.flattenedNodes).to.have.length(3);
|
||||||
|
expect(navigator.flattenedNodes).to.include.members([nestedNode, nestedNode2, rootNode]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should flatten deeply nested nodes correctly', () => {
|
||||||
|
// arrange
|
||||||
|
const deepNestedNode1 = new TreeNodeStub();
|
||||||
|
const deepNestedNode2 = new TreeNodeStub();
|
||||||
|
const nestedNode = new TreeNodeStub()
|
||||||
|
.withHierarchy(new HierarchyAccessStub().withChildren([deepNestedNode1, deepNestedNode2]));
|
||||||
|
const rootNode = new TreeNodeStub()
|
||||||
|
.withHierarchy(new HierarchyAccessStub().withChildren([nestedNode]));
|
||||||
|
// act
|
||||||
|
const navigator = new TreeNodeNavigator([rootNode]);
|
||||||
|
// assert
|
||||||
|
expect(navigator.flattenedNodes).to.have.length(4);
|
||||||
|
expect(navigator.flattenedNodes).to.include.members([
|
||||||
|
rootNode, nestedNode, deepNestedNode1, deepNestedNode2,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('rootNodes', () => {
|
||||||
|
it('should initialize with expected root nodes', () => {
|
||||||
|
// arrange
|
||||||
|
const nestedNode = new TreeNodeStub();
|
||||||
|
const rootNode = new TreeNodeStub()
|
||||||
|
.withHierarchy(new HierarchyAccessStub().withChildren([nestedNode]));
|
||||||
|
// act
|
||||||
|
const navigator = new TreeNodeNavigator([rootNode]);
|
||||||
|
// assert
|
||||||
|
expect(navigator.rootNodes).to.have.length(1);
|
||||||
|
expect(navigator.flattenedNodes).to.include.members([rootNode]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getNodeById', () => {
|
||||||
|
it('should find nested node by id', () => {
|
||||||
|
// arrange
|
||||||
|
const nodeId = 'nested-node-id';
|
||||||
|
const expectedNode = new TreeNodeStub().withId(nodeId);
|
||||||
|
const rootNode = new TreeNodeStub()
|
||||||
|
.withHierarchy(new HierarchyAccessStub().withChildren([expectedNode]));
|
||||||
|
const navigator = new TreeNodeNavigator([rootNode]);
|
||||||
|
// act
|
||||||
|
const actualNode = navigator.getNodeById(nodeId);
|
||||||
|
// assert
|
||||||
|
expect(actualNode).to.equal(expectedNode);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should find root node by id', () => {
|
||||||
|
// arrange
|
||||||
|
const nodeId = 'root-node-id';
|
||||||
|
const expectedRootNode = new TreeNodeStub().withId(nodeId);
|
||||||
|
const navigator = new TreeNodeNavigator([
|
||||||
|
new TreeNodeStub(),
|
||||||
|
expectedRootNode,
|
||||||
|
new TreeNodeStub(),
|
||||||
|
]);
|
||||||
|
// act
|
||||||
|
const actualNode = navigator.getNodeById(nodeId);
|
||||||
|
// assert
|
||||||
|
expect(actualNode).to.equal(expectedRootNode);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw an error if node cannot be found', () => {
|
||||||
|
// arrange
|
||||||
|
const absentNodeId = 'absent-node-id';
|
||||||
|
const expectedError = `Node could not be found: ${absentNodeId}`;
|
||||||
|
const navigator = new TreeNodeNavigator([
|
||||||
|
new TreeNodeStub(),
|
||||||
|
new TreeNodeStub(),
|
||||||
|
]);
|
||||||
|
// act
|
||||||
|
const act = () => navigator.getNodeById(absentNodeId);
|
||||||
|
// assert
|
||||||
|
expect(act).to.throw(expectedError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { parseTreeInput } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/NodeCollection/TreeInputParser';
|
||||||
|
import { TreeInputNodeData } from '@/presentation/components/Scripts/View/Tree/TreeView/Bindings/TreeInputNodeData';
|
||||||
|
import { TreeNodeManager } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/TreeNodeManager';
|
||||||
|
import { TreeInputNodeDataStub } from '@tests/unit/shared/Stubs/TreeInputNodeDataStub';
|
||||||
|
import { itEachAbsentObjectValue } from '@tests/unit/shared/TestCases/AbsentTests';
|
||||||
|
|
||||||
|
describe('parseTreeInput', () => {
|
||||||
|
it('throws if input data is not an array', () => {
|
||||||
|
// arrange
|
||||||
|
const expectedError = 'input data must be an array';
|
||||||
|
const invalidInput = 'invalid-input' as unknown as TreeInputNodeData[];
|
||||||
|
// act
|
||||||
|
const act = () => parseTreeInput(invalidInput);
|
||||||
|
// assert
|
||||||
|
expect(act).to.throw(expectedError);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('throws if input data is absent', () => {
|
||||||
|
itEachAbsentObjectValue((absentValue) => {
|
||||||
|
// arrange
|
||||||
|
const expectedError = 'missing input';
|
||||||
|
const invalidInput = absentValue;
|
||||||
|
// act
|
||||||
|
const act = () => parseTreeInput(invalidInput);
|
||||||
|
// assert
|
||||||
|
expect(act).to.throw(expectedError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty array if given an empty array', () => {
|
||||||
|
// arrange
|
||||||
|
const input = [];
|
||||||
|
// act
|
||||||
|
const nodes = parseTreeInput(input);
|
||||||
|
// assert
|
||||||
|
expect(nodes).to.have.lengthOf(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`creates ${TreeNodeManager.name} for each node`, () => {
|
||||||
|
// arrange
|
||||||
|
const input: TreeInputNodeData[] = [
|
||||||
|
new TreeInputNodeDataStub(),
|
||||||
|
new TreeInputNodeDataStub(),
|
||||||
|
];
|
||||||
|
// act
|
||||||
|
const nodes = parseTreeInput(input);
|
||||||
|
// assert
|
||||||
|
expect(nodes).have.lengthOf(2);
|
||||||
|
expect(nodes[0]).to.be.instanceOf(TreeNodeManager);
|
||||||
|
expect(nodes[1]).to.be.instanceOf(TreeNodeManager);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts flat input array to flat node array', () => {
|
||||||
|
// arrange
|
||||||
|
const inputNodes: TreeInputNodeData[] = [
|
||||||
|
new TreeInputNodeDataStub().withId('1'),
|
||||||
|
new TreeInputNodeDataStub().withId('2'),
|
||||||
|
];
|
||||||
|
// act
|
||||||
|
const nodes = parseTreeInput(inputNodes);
|
||||||
|
// assert
|
||||||
|
expect(nodes).have.lengthOf(2);
|
||||||
|
expect(nodes[0].id).equal(inputNodes[0].id);
|
||||||
|
expect(nodes[0].hierarchy.children).to.have.lengthOf(0);
|
||||||
|
expect(nodes[0].hierarchy.parent).to.toBeUndefined();
|
||||||
|
expect(nodes[1].id).equal(inputNodes[1].id);
|
||||||
|
expect(nodes[1].hierarchy.children).to.have.lengthOf(0);
|
||||||
|
expect(nodes[1].hierarchy.parent).to.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('correctly parses nested data with correct hierarchy', () => {
|
||||||
|
// arrange;
|
||||||
|
const grandChildData = new TreeInputNodeDataStub().withId('1-1-1');
|
||||||
|
const childData = new TreeInputNodeDataStub().withId('1-1').withChildren([grandChildData]);
|
||||||
|
const parentNodeData = new TreeInputNodeDataStub().withId('1').withChildren([childData]);
|
||||||
|
const inputData: TreeInputNodeData[] = [parentNodeData];
|
||||||
|
|
||||||
|
// act
|
||||||
|
const nodes = parseTreeInput(inputData);
|
||||||
|
|
||||||
|
// assert
|
||||||
|
expect(nodes).to.have.lengthOf(1);
|
||||||
|
expect(nodes[0].id).to.equal(parentNodeData.id);
|
||||||
|
expect(nodes[0].hierarchy.children).to.have.lengthOf(1);
|
||||||
|
|
||||||
|
const childNode = nodes[0].hierarchy.children[0];
|
||||||
|
expect(childNode.id).to.equal(childData.id);
|
||||||
|
expect(childNode.hierarchy.children).to.have.lengthOf(1);
|
||||||
|
expect(childNode.hierarchy.parent).to.equal(nodes[0]);
|
||||||
|
|
||||||
|
const grandChildNode = childNode.hierarchy.children[0];
|
||||||
|
expect(grandChildNode.id).to.equal(grandChildData.id);
|
||||||
|
expect(grandChildNode.hierarchy.children).to.have.lengthOf(0);
|
||||||
|
expect(grandChildNode.hierarchy.parent).to.equal(childNode);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { TreeNodeInitializerAndUpdater } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/NodeCollection/TreeNodeInitializerAndUpdater';
|
||||||
|
import { TreeNodeNavigator } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/NodeCollection/Query/TreeNodeNavigator';
|
||||||
|
import { itEachAbsentCollectionValue } from '@tests/unit/shared/TestCases/AbsentTests';
|
||||||
|
import { createTreeNodeParserStub } from '@tests/unit/shared/Stubs/TreeNodeParserStub';
|
||||||
|
import { TreeNodeStub } from '@tests/unit/shared/Stubs/TreeNodeStub';
|
||||||
|
import { TreeInputNodeDataStub } from '@tests/unit/shared/Stubs/TreeInputNodeDataStub';
|
||||||
|
import { QueryableNodes } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/NodeCollection/Query/QueryableNodes';
|
||||||
|
|
||||||
|
describe('TreeNodeInitializerAndUpdater', () => {
|
||||||
|
describe('updateRootNodes', () => {
|
||||||
|
it('should throw an error if no data is provided', () => {
|
||||||
|
itEachAbsentCollectionValue((absentValue) => {
|
||||||
|
// arrange
|
||||||
|
const expectedError = 'missing data';
|
||||||
|
const initializer = new TreeNodeInitializerAndUpdaterBuilder()
|
||||||
|
.build();
|
||||||
|
// act
|
||||||
|
const act = () => initializer.updateRootNodes(absentValue);
|
||||||
|
// expect
|
||||||
|
expect(act).to.throw(expectedError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update nodes when valid data is provided', () => {
|
||||||
|
// arrange
|
||||||
|
const expectedData = [new TreeNodeStub(), new TreeNodeStub()];
|
||||||
|
const inputData = [new TreeInputNodeDataStub(), new TreeInputNodeDataStub()];
|
||||||
|
const builder = new TreeNodeInitializerAndUpdaterBuilder();
|
||||||
|
builder.parserStub.registerScenario({
|
||||||
|
given: inputData,
|
||||||
|
result: expectedData,
|
||||||
|
});
|
||||||
|
const initializer = builder.build();
|
||||||
|
// act
|
||||||
|
initializer.updateRootNodes(inputData);
|
||||||
|
// assert
|
||||||
|
expect(initializer.nodes).to.be.instanceOf(TreeNodeNavigator);
|
||||||
|
expect(initializer.nodes.rootNodes).to.have.members(expectedData);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should notify when nodes are updated', () => {
|
||||||
|
// arrange
|
||||||
|
let notifiedNodes: QueryableNodes | undefined;
|
||||||
|
const inputData = [new TreeInputNodeDataStub(), new TreeInputNodeDataStub()];
|
||||||
|
const expectedData = [new TreeNodeStub(), new TreeNodeStub()];
|
||||||
|
const builder = new TreeNodeInitializerAndUpdaterBuilder();
|
||||||
|
builder.parserStub.registerScenario({
|
||||||
|
given: inputData,
|
||||||
|
result: expectedData,
|
||||||
|
});
|
||||||
|
const initializer = builder.build();
|
||||||
|
initializer.nodesUpdated.on((nodes) => {
|
||||||
|
notifiedNodes = nodes;
|
||||||
|
});
|
||||||
|
// act
|
||||||
|
initializer.updateRootNodes(inputData);
|
||||||
|
// assert
|
||||||
|
expect(notifiedNodes).to.toBeTruthy();
|
||||||
|
expect(initializer.nodes.rootNodes).to.have.members(expectedData);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
class TreeNodeInitializerAndUpdaterBuilder {
|
||||||
|
public readonly parserStub = createTreeNodeParserStub();
|
||||||
|
|
||||||
|
public build() {
|
||||||
|
return new TreeNodeInitializerAndUpdater(this.parserStub.parseTreeInputStub);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { SingleNodeCollectionFocusManager } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/Focus/SingleNodeCollectionFocusManager';
|
||||||
|
import { TreeNodeCollection } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/NodeCollection/TreeNodeCollection';
|
||||||
|
import { TreeNodeInitializerAndUpdater } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/NodeCollection/TreeNodeInitializerAndUpdater';
|
||||||
|
import { TreeRootManager } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/TreeRootManager';
|
||||||
|
import { SingleNodeFocusManagerStub } from '@tests/unit/shared/Stubs/SingleNodeFocusManagerStub';
|
||||||
|
import { TreeNodeCollectionStub } from '@tests/unit/shared/Stubs/TreeNodeCollectionStub';
|
||||||
|
|
||||||
|
describe('TreeRootManager', () => {
|
||||||
|
describe('collection', () => {
|
||||||
|
it(`defaults to ${TreeNodeInitializerAndUpdater.name}`, () => {
|
||||||
|
// arrange
|
||||||
|
const expectedCollectionType = TreeNodeInitializerAndUpdater;
|
||||||
|
const sut = new TreeRootManager();
|
||||||
|
// act
|
||||||
|
const actualCollection = sut.collection;
|
||||||
|
// assert
|
||||||
|
expect(actualCollection).to.be.instanceOf(expectedCollectionType);
|
||||||
|
});
|
||||||
|
it('set by constructor as expected', () => {
|
||||||
|
// arrange
|
||||||
|
const expectedCollection = new TreeNodeCollectionStub();
|
||||||
|
const sut = new TreeRootManager();
|
||||||
|
// act
|
||||||
|
const actualCollection = sut.collection;
|
||||||
|
// assert
|
||||||
|
expect(actualCollection).to.equal(expectedCollection);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('focus', () => {
|
||||||
|
it(`defaults to instance of ${SingleNodeCollectionFocusManager.name}`, () => {
|
||||||
|
// arrange
|
||||||
|
const expectedFocusType = SingleNodeCollectionFocusManager;
|
||||||
|
const sut = new TreeRootManager();
|
||||||
|
// act
|
||||||
|
const actualFocusType = sut.focus;
|
||||||
|
// assert
|
||||||
|
expect(actualFocusType).to.be.instanceOf(expectedFocusType);
|
||||||
|
});
|
||||||
|
it('creates with same collection it uses', () => {
|
||||||
|
// arrange
|
||||||
|
let usedCollection: TreeNodeCollection | undefined;
|
||||||
|
const factoryMock = (collection) => {
|
||||||
|
usedCollection = collection;
|
||||||
|
return new SingleNodeFocusManagerStub();
|
||||||
|
};
|
||||||
|
const sut = new TreeRootManager(new TreeNodeCollectionStub(), factoryMock);
|
||||||
|
// act
|
||||||
|
const expected = sut.collection;
|
||||||
|
// assert
|
||||||
|
expect(usedCollection).to.equal(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
ref, defineComponent, WatchSource, nextTick,
|
||||||
|
} from 'vue';
|
||||||
|
import { shallowMount } from '@vue/test-utils';
|
||||||
|
import { TreeRoot } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/TreeRoot';
|
||||||
|
import { InjectionKeys } from '@/presentation/injectionSymbols';
|
||||||
|
import { UseAutoUnsubscribedEventsStub } from '@tests/unit/shared/Stubs/UseAutoUnsubscribedEventsStub';
|
||||||
|
import { QueryableNodesStub } from '@tests/unit/shared/Stubs/QueryableNodesStub';
|
||||||
|
import { TreeNodeCollectionStub } from '@tests/unit/shared/Stubs/TreeNodeCollectionStub';
|
||||||
|
import { TreeRootStub } from '@tests/unit/shared/Stubs/TreeRootStub';
|
||||||
|
import { useCurrentTreeNodes } from '@/presentation/components/Scripts/View/Tree/TreeView/UseCurrentTreeNodes';
|
||||||
|
|
||||||
|
describe('useCurrentTreeNodes', () => {
|
||||||
|
it('should set nodes on immediate invocation', () => {
|
||||||
|
// arrange
|
||||||
|
const expectedNodes = new QueryableNodesStub();
|
||||||
|
const treeWatcher = ref<TreeRoot>(new TreeRootStub().withCollection(
|
||||||
|
new TreeNodeCollectionStub().withNodes(expectedNodes),
|
||||||
|
));
|
||||||
|
// act
|
||||||
|
const { returnObject } = mountWrapperComponent(treeWatcher);
|
||||||
|
// assert
|
||||||
|
expect(returnObject.nodes.value).to.deep.equal(expectedNodes);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update nodes when treeWatcher changes', async () => {
|
||||||
|
// arrange
|
||||||
|
const initialNodes = new QueryableNodesStub();
|
||||||
|
const treeWatcher = ref(
|
||||||
|
new TreeRootStub().withCollection(new TreeNodeCollectionStub().withNodes(initialNodes)),
|
||||||
|
);
|
||||||
|
const { returnObject } = mountWrapperComponent(treeWatcher);
|
||||||
|
const newExpectedNodes = new QueryableNodesStub();
|
||||||
|
// act
|
||||||
|
treeWatcher.value = new TreeRootStub().withCollection(
|
||||||
|
new TreeNodeCollectionStub().withNodes(newExpectedNodes),
|
||||||
|
);
|
||||||
|
await nextTick();
|
||||||
|
// assert
|
||||||
|
expect(returnObject.nodes.value).to.deep.equal(newExpectedNodes);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update nodes when tree collection nodesUpdated event is triggered', async () => {
|
||||||
|
// arrange
|
||||||
|
const initialNodes = new QueryableNodesStub();
|
||||||
|
const treeCollectionStub = new TreeNodeCollectionStub().withNodes(initialNodes);
|
||||||
|
const treeWatcher = ref(new TreeRootStub().withCollection(treeCollectionStub));
|
||||||
|
|
||||||
|
const { returnObject } = mountWrapperComponent(treeWatcher);
|
||||||
|
|
||||||
|
const newExpectedNodes = new QueryableNodesStub();
|
||||||
|
// act
|
||||||
|
treeCollectionStub.triggerNodesUpdatedEvent(newExpectedNodes);
|
||||||
|
await nextTick();
|
||||||
|
// assert
|
||||||
|
expect(returnObject.nodes.value).to.deep.equal(newExpectedNodes);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function mountWrapperComponent(treeWatcher: WatchSource<TreeRoot | undefined>) {
|
||||||
|
let returnObject: ReturnType<typeof useCurrentTreeNodes>;
|
||||||
|
const wrapper = shallowMount(
|
||||||
|
defineComponent({
|
||||||
|
setup() {
|
||||||
|
returnObject = useCurrentTreeNodes(treeWatcher);
|
||||||
|
},
|
||||||
|
template: '<div></div>',
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
provide: {
|
||||||
|
[InjectionKeys.useAutoUnsubscribedEvents as symbol]:
|
||||||
|
() => new UseAutoUnsubscribedEventsStub().get(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
wrapper,
|
||||||
|
returnObject,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,16 +1,17 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import {
|
|
||||||
getScriptNodeId, getScriptId, getCategoryNodeId, getCategoryId, parseSingleCategory,
|
|
||||||
parseAllCategories,
|
|
||||||
} from '@/presentation/components/Scripts/View/ScriptsTree/ScriptNodeParser';
|
|
||||||
import { INodeContent, NodeType } from '@/presentation/components/Scripts/View/ScriptsTree/SelectableTree/Node/INodeContent';
|
|
||||||
import { IScript } from '@/domain/IScript';
|
import { IScript } from '@/domain/IScript';
|
||||||
import { ICategory } from '@/domain/ICategory';
|
import { ICategory } from '@/domain/ICategory';
|
||||||
import { CategoryStub } from '@tests/unit/shared/Stubs/CategoryStub';
|
import { CategoryStub } from '@tests/unit/shared/Stubs/CategoryStub';
|
||||||
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
|
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
|
||||||
import { CategoryCollectionStub } from '@tests/unit/shared/Stubs/CategoryCollectionStub';
|
import { CategoryCollectionStub } from '@tests/unit/shared/Stubs/CategoryCollectionStub';
|
||||||
|
import {
|
||||||
|
getCategoryId, getCategoryNodeId, getScriptId,
|
||||||
|
getScriptNodeId, parseAllCategories, parseSingleCategory,
|
||||||
|
} from '@/presentation/components/Scripts/View/Tree/TreeViewAdapter/CategoryNodeMetadataConverter';
|
||||||
|
import { NodeType } from '@/application/Parser/NodeValidation/NodeType';
|
||||||
|
import { NodeMetadata } from '@/presentation/components/Scripts/View/Tree/NodeContent/NodeMetadata';
|
||||||
|
|
||||||
describe('ScriptNodeParser', () => {
|
describe('CategoryNodeMetadataConverter', () => {
|
||||||
it('can convert script id and back', () => {
|
it('can convert script id and back', () => {
|
||||||
// arrange
|
// arrange
|
||||||
const script = new ScriptStub('test');
|
const script = new ScriptStub('test');
|
||||||
@@ -30,6 +31,16 @@ describe('ScriptNodeParser', () => {
|
|||||||
expect(scriptId).to.equal(category.id);
|
expect(scriptId).to.equal(category.id);
|
||||||
});
|
});
|
||||||
describe('parseSingleCategory', () => {
|
describe('parseSingleCategory', () => {
|
||||||
|
it('throws error when parent category does not exist', () => {
|
||||||
|
// arrange
|
||||||
|
const categoryId = 33;
|
||||||
|
const expectedError = `Category with id ${categoryId} does not exist`;
|
||||||
|
const collection = new CategoryCollectionStub();
|
||||||
|
// act
|
||||||
|
const act = () => parseSingleCategory(categoryId, collection);
|
||||||
|
// assert
|
||||||
|
expect(act).to.throw(expectedError);
|
||||||
|
});
|
||||||
it('can parse when category has sub categories', () => {
|
it('can parse when category has sub categories', () => {
|
||||||
// arrange
|
// arrange
|
||||||
const categoryId = 31;
|
const categoryId = 31;
|
||||||
@@ -86,7 +97,7 @@ function isReversible(category: ICategory): boolean {
|
|||||||
return category.subCategories.every((c) => isReversible(c));
|
return category.subCategories.every((c) => isReversible(c));
|
||||||
}
|
}
|
||||||
|
|
||||||
function expectSameCategory(node: INodeContent, category: ICategory): void {
|
function expectSameCategory(node: NodeMetadata, category: ICategory): void {
|
||||||
expect(node.type).to.equal(NodeType.Category, getErrorMessage('type'));
|
expect(node.type).to.equal(NodeType.Category, getErrorMessage('type'));
|
||||||
expect(node.id).to.equal(getCategoryNodeId(category), getErrorMessage('id'));
|
expect(node.id).to.equal(getCategoryNodeId(category), getErrorMessage('id'));
|
||||||
expect(node.docs).to.equal(category.docs, getErrorMessage('docs'));
|
expect(node.docs).to.equal(category.docs, getErrorMessage('docs'));
|
||||||
@@ -106,7 +117,7 @@ function expectSameCategory(node: INodeContent, category: ICategory): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function expectSameScript(node: INodeContent, script: IScript): void {
|
function expectSameScript(node: NodeMetadata, script: IScript): void {
|
||||||
expect(node.type).to.equal(NodeType.Script, getErrorMessage('type'));
|
expect(node.type).to.equal(NodeType.Script, getErrorMessage('type'));
|
||||||
expect(node.id).to.equal(getScriptNodeId(script), getErrorMessage('id'));
|
expect(node.id).to.equal(getScriptNodeId(script), getErrorMessage('id'));
|
||||||
expect(node.docs).to.equal(script.docs, getErrorMessage('docs'));
|
expect(node.docs).to.equal(script.docs, getErrorMessage('docs'));
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user