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:
undergroundwires
2023-09-09 22:26:21 +02:00
parent 821cc62c4c
commit 65f121c451
120 changed files with 4537 additions and 1203 deletions

View File

@@ -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.
## 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.:
- Variable: `$variable: value;`

11
package-lock.json generated
View File

@@ -19,7 +19,6 @@
"cross-fetch": "^4.0.0",
"electron-progressbar": "^2.1.0",
"file-saver": "^2.0.5",
"liquor-tree": "^0.2.70",
"markdown-it": "^13.0.1",
"npm": "^9.8.1",
"v-tooltip": "2.1.3",
@@ -10762,11 +10761,6 @@
"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": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/listr2/-/listr2-3.14.0.tgz",
@@ -29242,11 +29236,6 @@
"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": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/listr2/-/listr2-3.14.0.tgz",

View File

@@ -44,7 +44,6 @@
"cross-fetch": "^4.0.0",
"electron-progressbar": "^2.1.0",
"file-saver": "^2.0.5",
"liquor-tree": "^0.2.70",
"markdown-it": "^13.0.1",
"npm": "^9.8.1",
"v-tooltip": "2.1.3",

View File

@@ -44,4 +44,10 @@
transform: translateY($offset-upward);
}
}
}
}
@mixin reset-ul {
margin: 0;
padding: 0;
list-style: none;
}

View File

@@ -9,4 +9,3 @@
@forward "./components/card";
@forward "./third-party-extensions/tooltip.scss";
@forward "./third-party-extensions/tree.scss";

View File

@@ -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;
}
}
}
}

View File

@@ -1,4 +1,3 @@
import { TreeBootstrapper } from './Modules/TreeBootstrapper';
import { IconBootstrapper } from './Modules/IconBootstrapper';
import { VueConstructor, IVueBootstrapper } from './IVueBootstrapper';
import { VueBootstrapper } from './Modules/VueBootstrapper';
@@ -17,7 +16,6 @@ export class ApplicationBootstrapper implements IVueBootstrapper {
private static getAllBootstrappers(): IVueBootstrapper[] {
return [
new IconBootstrapper(),
new TreeBootstrapper(),
new VueBootstrapper(),
new TooltipBootstrapper(),
new RuntimeSanityValidator(),

View File

@@ -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);
}
}

View File

@@ -76,7 +76,6 @@ export default defineComponent({
function handleCodeChange(event: ICodeChangedEvent) {
removeCurrentHighlighting();
updateCode(event.code, currentState.value.collection.scripting.language);
editor.setValue(event.code, 1);
if (event.addedScripts?.length > 0) {
reactToChanges(event, event.addedScripts);
} else if (event.changedScripts?.length > 0) {

View File

@@ -53,7 +53,7 @@ import {
inject,
} from 'vue';
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';
export default defineComponent({

View File

@@ -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>

View File

@@ -1,6 +0,0 @@
import { INodeContent } from './Node/INodeContent';
export interface INodeSelectedEvent {
isSelected: boolean;
node: INodeContent;
}

View File

@@ -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;
}

View File

@@ -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
}
}

View File

@@ -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));
}
}

View File

@@ -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');
}
}

View File

@@ -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));
}

View File

@@ -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>

View File

@@ -42,7 +42,7 @@ import {
inject,
} from 'vue';
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 { ViewType } from '@/presentation/components/Scripts/Menu/View/ViewType';
import { IReadOnlyUserFilter } from '@/application/Context/State/Filter/IUserFilter';
@@ -139,7 +139,8 @@ $margin-inner: 4px;
padding-top: 15px;
padding-bottom: 15px;
&--searching {
padding-top: 0px;
background-color: $color-primary-darker;
padding-top: 0px;
}
}
}

View File

@@ -1,18 +1,18 @@
<template>
<DocumentableNode :docs="data.docs">
<DocumentableNode :docs="nodeMetadata.docs">
<div id="node">
<div class="item text">{{ data.text }}</div>
<div class="item text">{{ nodeMetadata.text }}</div>
<RevertToggle
class="item"
v-if="data.isReversible"
:node="data" />
v-if="nodeMetadata.isReversible"
:node="nodeMetadata" />
</div>
</DocumentableNode>
</template>
<script lang="ts">
import { defineComponent, PropType } from 'vue';
import { INodeContent } from './INodeContent';
import { NodeMetadata } from './NodeMetadata';
import RevertToggle from './RevertToggle.vue';
import DocumentableNode from './Documentation/DocumentableNode.vue';
@@ -22,8 +22,8 @@ export default defineComponent({
DocumentableNode,
},
props: {
data: {
type: Object as PropType<INodeContent>,
nodeMetadata: {
type: Object as PropType<NodeMetadata>,
required: true,
},
},

View File

@@ -3,11 +3,11 @@ export enum NodeType {
Category,
}
export interface INodeContent {
export interface NodeMetadata {
readonly id: string;
readonly text: string;
readonly isReversible: boolean;
readonly docs: ReadonlyArray<string>;
readonly children?: ReadonlyArray<INodeContent>;
readonly children?: ReadonlyArray<NodeMetadata>;
readonly type: NodeType;
}

View File

@@ -13,8 +13,8 @@ import {
} from 'vue';
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import { NodeMetadata } from '@/presentation/components/Scripts/View/Tree/NodeContent/NodeMetadata';
import { IReverter } from './Reverter/IReverter';
import { INodeContent } from './INodeContent';
import { getReverter } from './Reverter/ReverterFactory';
import ToggleSwitch from './ToggleSwitch.vue';
@@ -24,7 +24,7 @@ export default defineComponent({
},
props: {
node: {
type: Object as PropType<INodeContent>,
type: Object as PropType<NodeMetadata>,
required: true,
},
},
@@ -51,7 +51,7 @@ export default defineComponent({
]);
}, { immediate: true });
function onNodeChanged(node: INodeContent) {
function onNodeChanged(node: NodeMetadata) {
handler = getReverter(node, currentState.value.collection);
updateRevertStatusFromState(currentState.value.selection.selectedScripts);
}

View File

@@ -1,7 +1,7 @@
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
import { IUserSelection } from '@/application/Context/State/Selection/IUserSelection';
import { ICategoryCollection } from '@/domain/ICategoryCollection';
import { getCategoryId } from '../../../ScriptNodeParser';
import { getCategoryId } from '../../TreeViewAdapter/CategoryNodeMetadataConverter';
import { IReverter } from './IReverter';
import { ScriptReverter } from './ScriptReverter';

View File

@@ -1,10 +1,10 @@
import { ICategoryCollection } from '@/domain/ICategoryCollection';
import { INodeContent, NodeType } from '../INodeContent';
import { NodeMetadata, NodeType } from '../NodeMetadata';
import { IReverter } from './IReverter';
import { ScriptReverter } from './ScriptReverter';
import { CategoryReverter } from './CategoryReverter';
export function getReverter(node: INodeContent, collection: ICategoryCollection): IReverter {
export function getReverter(node: NodeMetadata, collection: ICategoryCollection): IReverter {
switch (node.type) {
case NodeType.Category:
return new CategoryReverter(node.id, collection);

View File

@@ -1,6 +1,6 @@
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
import { IUserSelection } from '@/application/Context/State/Selection/IUserSelection';
import { getScriptId } from '../../../ScriptNodeParser';
import { getScriptId } from '../../TreeViewAdapter/CategoryNodeMetadataConverter';
import { IReverter } from './IReverter';
export class ScriptReverter implements IReverter {

View File

@@ -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>

View File

@@ -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(),
};
}

View File

@@ -0,0 +1,6 @@
export interface TreeInputNodeData {
readonly id: string;
readonly children?: readonly TreeInputNodeData[];
readonly parent?: TreeInputNodeData | null;
readonly data?: object;
}

View File

@@ -0,0 +1,7 @@
import { NodeStateChangedEvent } from '../Node/State/StateAccess';
import { ReadOnlyTreeNode } from '../Node/TreeNode';
export interface TreeNodeStateChangedEmittedEvent {
readonly change: NodeStateChangedEvent;
readonly node: ReadOnlyTreeNode;
}

View File

@@ -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>

View File

@@ -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[];
}

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -0,0 +1,5 @@
export enum TreeNodeCheckState {
Unchecked = 0,
Checked = 1,
Indeterminate = 2,
}

View File

@@ -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 { }

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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,
});
}
}

View File

@@ -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;
}

View File

@@ -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();
}
}

View File

@@ -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;
}

View File

@@ -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,
};
}

View File

@@ -0,0 +1,5 @@
import { TreeNode } from '../Node/TreeNode';
export interface NodeRenderingStrategy {
shouldRender(node: TreeNode): boolean;
}

View File

@@ -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;
}
}
}

View File

@@ -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) { }
}

View File

@@ -0,0 +1,6 @@
import { ReadOnlyTreeNode, TreeNode } from '../../Node/TreeNode';
export interface SingleNodeFocusManager {
readonly currentSingleFocusedNode: TreeNode | undefined;
setSingleFocus(focusedNode: ReadOnlyTreeNode): void;
}

View File

@@ -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;
}

View File

@@ -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>());
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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) { }
}

View File

@@ -0,0 +1,7 @@
import { SingleNodeFocusManager } from './Focus/SingleNodeFocusManager';
import { TreeNodeCollection } from './NodeCollection/TreeNodeCollection';
export interface TreeRoot {
readonly collection: TreeNodeCollection;
readonly focus: SingleNodeFocusManager;
}

View File

@@ -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>

View File

@@ -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);
}
}

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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),
};
}

View File

@@ -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));
}

View File

@@ -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),
};
}

View File

@@ -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;
}

View File

@@ -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 },
);
}

View File

@@ -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;

View File

@@ -1,15 +1,15 @@
import { ICategory, IScript } from '@/domain/ICategory';
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);
}
export function parseSingleCategory(
categoryId: number,
collection: ICategoryCollection,
): INodeContent[] | undefined {
): NodeMetadata[] | undefined {
const category = collection.findCategory(categoryId);
if (!category) {
throw new Error(`Category with id ${categoryId} does not exist`);
@@ -21,9 +21,11 @@ export function parseSingleCategory(
export function getScriptNodeId(script: IScript): string {
return script.id;
}
export function getScriptId(nodeId: string): string {
return nodeId;
}
export function getCategoryId(nodeId: string): number {
return +nodeId;
}
@@ -34,22 +36,19 @@ export function getCategoryNodeId(category: ICategory): string {
function parseCategoryRecursively(
parentCategory: ICategory,
): INodeContent[] {
if (!parentCategory) {
throw new Error('parentCategory is undefined');
}
): NodeMetadata[] {
return [
...createCategoryNodes(parentCategory.subCategories),
...createScriptNodes(parentCategory.scripts),
];
}
function createScriptNodes(scripts: ReadonlyArray<IScript>): INodeContent[] {
function createScriptNodes(scripts: ReadonlyArray<IScript>): NodeMetadata[] {
return (scripts || [])
.map((script) => convertScriptToNode(script));
}
function createCategoryNodes(categories: ReadonlyArray<ICategory>): INodeContent[] {
function createCategoryNodes(categories: ReadonlyArray<ICategory>): NodeMetadata[] {
return (categories || [])
.map((category) => ({ category, children: parseCategoryRecursively(category) }))
.map((data) => convertCategoryToNode(data.category, data.children));
@@ -57,8 +56,8 @@ function createCategoryNodes(categories: ReadonlyArray<ICategory>): INodeContent
function convertCategoryToNode(
category: ICategory,
children: readonly INodeContent[],
): INodeContent {
children: readonly NodeMetadata[],
): NodeMetadata {
return {
id: getCategoryNodeId(category),
type: NodeType.Category,
@@ -69,7 +68,7 @@ function convertCategoryToNode(
};
}
function convertScriptToNode(script: IScript): INodeContent {
function convertScriptToNode(script: IScript): NodeMetadata {
return {
id: getScriptNodeId(script),
type: NodeType.Script,

View File

@@ -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));
}

View File

@@ -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,
};
}

View File

@@ -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),
};
}

View File

@@ -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));
}

View File

@@ -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;
}

View File

@@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest';
import { parseApplication } from '@/application/Parser/ApplicationParser';
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('can render all docs', () => {

View File

@@ -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);
});
}

View File

@@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest';
import { Wrapper, shallowMount } from '@vue/test-utils';
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 { ViewType } from '@/presentation/components/Scripts/Menu/View/ViewType';
import { useCollectionState } from '@/presentation/components/Shared/Hooks/UseCollectionState';

View File

@@ -1,5 +1,5 @@
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('createRenderer', () => {

View File

@@ -1,11 +1,11 @@
import { describe, it, expect } from 'vitest';
import { CategoryReverter } from '@/presentation/components/Scripts/View/ScriptsTree/SelectableTree/Node/Reverter/CategoryReverter';
import { getCategoryNodeId } from '@/presentation/components/Scripts/View/ScriptsTree/ScriptNodeParser';
import { CategoryReverter } from '@/presentation/components/Scripts/View/Tree/NodeContent/Reverter/CategoryReverter';
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
import { UserSelection } from '@/application/Context/State/Selection/UserSelection';
import { CategoryStub } from '@tests/unit/shared/Stubs/CategoryStub';
import { CategoryCollectionStub } from '@tests/unit/shared/Stubs/CategoryCollectionStub';
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
import { getCategoryNodeId } from '@/presentation/components/Scripts/View/Tree/TreeViewAdapter/CategoryNodeMetadataConverter';
describe('CategoryReverter', () => {
describe('getState', () => {

View File

@@ -1,12 +1,13 @@
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/ScriptsTree/SelectableTree/Node/Reverter/ReverterFactory';
import { ScriptReverter } from '@/presentation/components/Scripts/View/ScriptsTree/SelectableTree/Node/Reverter/ScriptReverter';
import { CategoryReverter } from '@/presentation/components/Scripts/View/ScriptsTree/SelectableTree/Node/Reverter/CategoryReverter';
import { getScriptNodeId, getCategoryNodeId } from '@/presentation/components/Scripts/View/ScriptsTree/ScriptNodeParser';
import { getReverter } from '@/presentation/components/Scripts/View/Tree/NodeContent/Reverter/ReverterFactory';
import { ScriptReverter } from '@/presentation/components/Scripts/View/Tree/NodeContent/Reverter/ScriptReverter';
import { CategoryReverter } from '@/presentation/components/Scripts/View/Tree/NodeContent/Reverter/CategoryReverter';
import { CategoryCollectionStub } from '@tests/unit/shared/Stubs/CategoryCollectionStub';
import { CategoryStub } from '@tests/unit/shared/Stubs/CategoryStub';
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('getReverter', () => {
@@ -33,7 +34,7 @@ describe('ReverterFactory', () => {
expect(result instanceof ScriptReverter).to.equal(true);
});
});
function getNodeContentStub(nodeId: string, type: NodeType): INodeContent {
function getNodeContentStub(nodeId: string, type: NodeType): NodeMetadata {
return {
id: nodeId,
text: 'text',

View File

@@ -1,12 +1,12 @@
import { describe, it, expect } from 'vitest';
import { ScriptReverter } from '@/presentation/components/Scripts/View/ScriptsTree/SelectableTree/Node/Reverter/ScriptReverter';
import { getScriptNodeId } from '@/presentation/components/Scripts/View/ScriptsTree/ScriptNodeParser';
import { ScriptReverter } from '@/presentation/components/Scripts/View/Tree/NodeContent/Reverter/ScriptReverter';
import { UserSelection } from '@/application/Context/State/Selection/UserSelection';
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
import { CategoryCollectionStub } from '@tests/unit/shared/Stubs/CategoryCollectionStub';
import { CategoryStub } from '@tests/unit/shared/Stubs/CategoryStub';
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
import { SelectedScriptStub } from '@tests/unit/shared/Stubs/SelectedScriptStub';
import { getScriptNodeId } from '@/presentation/components/Scripts/View/Tree/TreeViewAdapter/CategoryNodeMetadataConverter';
describe('ScriptReverter', () => {
describe('getState', () => {

View File

@@ -4,7 +4,7 @@ import {
mount,
} from '@vue/test-utils';
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_LABEL_OFF_SELECTOR = 'span.label-off';

View File

@@ -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: [],
};
}

View File

@@ -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: [],
};
}
});

View File

@@ -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: [],
}],
}],
};
}

View File

@@ -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;
}

View File

@@ -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);
});
});
});
});

View File

@@ -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);
});
});
});

View File

@@ -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);
});
});
});
});

View File

@@ -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);
});
});
});
});

View File

@@ -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,
};
}

View File

@@ -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,
};
}

View File

@@ -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),
));
}

View File

@@ -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);
});
});
});

View File

@@ -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);
});
});

View File

@@ -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);
}
}

View File

@@ -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);
});
});
});

View File

@@ -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,
};
}

View File

@@ -1,16 +1,17 @@
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 { ICategory } from '@/domain/ICategory';
import { CategoryStub } from '@tests/unit/shared/Stubs/CategoryStub';
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
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', () => {
// arrange
const script = new ScriptStub('test');
@@ -30,6 +31,16 @@ describe('ScriptNodeParser', () => {
expect(scriptId).to.equal(category.id);
});
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', () => {
// arrange
const categoryId = 31;
@@ -86,7 +97,7 @@ function isReversible(category: ICategory): boolean {
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.id).to.equal(getCategoryNodeId(category), getErrorMessage('id'));
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.id).to.equal(getScriptNodeId(script), getErrorMessage('id'));
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