added support for grouping

This commit is contained in:
undergroundwires
2020-01-09 20:18:20 +01:00
parent 6825001c61
commit ec6b3c5407
16 changed files with 270 additions and 186 deletions

View File

@@ -0,0 +1,63 @@
import { IApplicationState, IUserSelection } from '@/application/State/IApplicationState';
import { ICategory, IScript } from '@/domain/ICategory';
import { INode } from './SelectableTree/INode';
export function parseAllCategories(state: IApplicationState): INode[] | undefined {
const nodes = new Array<INode>();
for (const category of state.app.categories) {
const children = parseCategoryRecursively(category, state.selection);
nodes.push(convertCategoryToNode(category, children));
}
return nodes;
}
export function parseSingleCategory(categoryId: number, state: IApplicationState): INode[] | undefined {
const category = state.app.findCategory(categoryId);
if (!category) {
throw new Error(`Category with id ${categoryId} does not exist`);
}
const tree = parseCategoryRecursively(category, state.selection);
return tree;
}
function parseCategoryRecursively(
parentCategory: ICategory,
selection: IUserSelection): INode[] {
if (!parentCategory) { throw new Error('parentCategory is undefined'); }
if (!selection) { throw new Error('selection is undefined'); }
const nodes = new Array<INode>();
if (parentCategory.subCategories && parentCategory.subCategories.length > 0) {
for (const subCategory of parentCategory.subCategories) {
const subCategoryNodes = parseCategoryRecursively(subCategory, selection);
nodes.push(convertCategoryToNode(subCategory, subCategoryNodes));
}
}
if (parentCategory.scripts && parentCategory.scripts.length > 0) {
for (const script of parentCategory.scripts) {
nodes.push(convertScriptToNode(script, selection));
}
}
return nodes;
}
function convertCategoryToNode(
category: ICategory, children: readonly INode[]): INode {
return {
id: `${category.id}`,
text: category.name,
selected: false,
children,
documentationUrls: category.documentationUrls,
};
}
function convertScriptToNode(script: IScript, selection: IUserSelection): INode {
return {
id: `${script.id}`,
text: script.name,
selected: selection.isSelected(script),
children: undefined,
documentationUrls: script.documentationUrls,
};
}

View File

@@ -0,0 +1,106 @@
<template>
<span id="container">
<span v-if="nodes != null && nodes.length > 0">
<SelectableTree
:nodes="nodes"
:selectedNodeIds="selectedNodeIds"
:filterPredicate="filterPredicate"
:filterText="filterText"
v-on:nodeSelected="checkNodeAsync($event)">
</SelectableTree>
</span>
<span v-else>Nooo 😢</span>
</span>
</template>
<script lang="ts">
import { Component, Prop, Vue, Watch } from 'vue-property-decorator';
import { StatefulVue } from '@/presentation/StatefulVue';
import { Category } from '@/domain/Category';
import { IRepository } from '@/infrastructure/Repository/IRepository';
import { IScript } from '@/domain/IScript';
import { IApplicationState, IUserSelection } from '@/application/State/IApplicationState';
import { IFilterMatches } from '@/application/State/Filter/IFilterMatches';
import { parseAllCategories, parseSingleCategory } from './ScriptNodeParser';
import SelectableTree, { FilterPredicate } from './SelectableTree/SelectableTree.vue';
import { INode } from './SelectableTree/INode';
@Component({
components: {
SelectableTree,
},
})
export default class ScriptsTree extends StatefulVue {
@Prop() public categoryId?: number;
public nodes?: INode[] = null;
public selectedNodeIds?: string[] = null;
public filterText?: string = null;
private matches?: IFilterMatches;
public async mounted() {
// React to state changes
const state = await this.getCurrentStateAsync();
state.selection.changed.on(this.handleSelectionChanged);
state.filter.filterRemoved.on(this.handleFilterRemoved);
state.filter.filtered.on(this.handleFiltered);
// Update initial state
await this.initializeNodesAsync(this.categoryId);
}
public async checkNodeAsync(node: INode) {
if (node.children != null && node.children.length > 0) {
return; // only interested in script nodes
}
const state = await this.getCurrentStateAsync();
if (node.selected) {
state.selection.addSelectedScript(node.id);
} else {
state.selection.removeSelectedScript(node.id);
}
}
@Watch('categoryId')
public async initializeNodesAsync(categoryId?: number) {
const state = await this.getCurrentStateAsync();
if (categoryId) {
this.nodes = parseSingleCategory(categoryId, state);
} else {
this.nodes = parseAllCategories(state);
}
}
public filterPredicate(node: INode): boolean {
return this.matches.scriptMatches.some((script: IScript) => script.id === node.id);
}
private handleSelectionChanged(selectedScripts: ReadonlyArray<IScript>) {
this.nodes = this.nodes.map((node: INode) => updateNodeSelection(node, selectedScripts));
}
private handleFilterRemoved() {
this.filterText = '';
}
private handleFiltered(matches: IFilterMatches) {
this.filterText = matches.query;
this.matches = matches;
}
}
function updateNodeSelection(node: INode, selectedScripts: ReadonlyArray<IScript>): INode {
return {
id: node.id,
text: node.text,
selected: selectedScripts.some((script) => script.id === node.id),
children: node.children ? node.children.map((child) => updateNodeSelection(child, selectedScripts)) : [],
documentationUrls: node.documentationUrls,
};
}
</script>
<style scoped lang="scss">
</style>

View File

@@ -0,0 +1,7 @@
export interface INode {
readonly id: string;
readonly text: string;
readonly documentationUrls: ReadonlyArray<string>;
readonly children?: ReadonlyArray<INode>;
readonly selected: boolean;
}

View File

@@ -0,0 +1,46 @@
<template>
<div id="node">
<div>{{ this.data.text }}</div>
<div
v-for="url of this.data.documentationUrls"
v-bind:key="url">
<a :href="url"
:alt="url"
target="_blank" class="docs"
v-tooltip.top-center="url"
v-on:click.stop>
<font-awesome-icon :icon="['fas', 'info-circle']" />
</a>
</div>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import { INode } from './INode';
/** Wrapper for Liquor Tree, reveals only abstracted INode for communication */
@Component
export default class Node extends Vue {
@Prop() public data: INode;
}
</script>
<style scoped lang="scss">
@import "@/presentation/styles/colors.scss";
#node {
display:flex;
flex-direction: row;
flex-wrap: wrap;
.docs {
color: $gray;
cursor: pointer;
margin-left:5px;
&:hover {
color: $slate;
}
}
}
</style>

View File

@@ -0,0 +1,181 @@
<template>
<span>
<span v-if="initialNodes != null && initialNodes.length > 0">
<tree :options="liquorTreeOptions"
:data="this.initialNodes"
v-on:node:checked="nodeSelected($event)"
v-on:node:unchecked="nodeSelected($event)"
ref="treeElement"
>
<span class="tree-text" slot-scope="{ node }">
<Node :data="convertExistingToNode(node)"/>
</span>
</tree>
</span>
<span v-else>Nooo 😢</span>
</span>
</template>
<script lang="ts">
import { Component, Prop, Vue, Emit, Watch } from 'vue-property-decorator';
import LiquorTree, { ILiquorTreeNewNode, ILiquorTreeExistingNode, ILiquorTree } from 'liquor-tree';
import Node from './Node.vue';
import { INode } from './INode';
export type FilterPredicate = (node: INode) => boolean;
/** Wrapper for Liquor Tree, reveals only abstracted INode for communication */
@Component({
components: {
LiquorTree,
Node,
},
})
export default class SelectableTree extends Vue {
@Prop() public filterPredicate?: FilterPredicate;
@Prop() public filterText?: string;
@Prop() public nodes?: INode[];
public initialNodes?: ILiquorTreeNewNode[] = null;
public liquorTreeOptions = this.getLiquorTreeOptions();
public mounted() {
// console.log('Mounted', 'initial nodes', this.nodes);
// console.log('Mounted', 'initial model', this.getLiquorTreeApi().model);
if (this.nodes) {
this.initialNodes = this.nodes.map((node) => this.toLiquorTreeNode(node));
} else {
throw new Error('Initial nodes are null or empty');
}
if (this.filterText) {
this.updateFilterText(this.filterText);
}
}
public nodeSelected(node: ILiquorTreeExistingNode) {
this.$emit('nodeSelected', this.convertExistingToNode(node));
return;
}
@Watch('filterText')
public updateFilterText(filterText: |string) {
const api = this.getLiquorTreeApi();
if (!filterText) {
api.clearFilter();
} else {
api.filter('filtered'); // text does not matter, it'll trigger the predicate
}
}
@Watch('nodes', {deep: true})
public setSelectedStatus(nodes: |ReadonlyArray<INode>) {
if (!nodes || nodes.length === 0) {
throw new Error('Updated nodes are null or empty');
}
// Update old node properties, re-setting it changes expanded status etc.
// It'll not be needed when this is merged: https://github.com/amsik/liquor-tree/pull/141
const updateCheckedState = (
oldNodes: ReadonlyArray<ILiquorTreeExistingNode>,
updatedNodes: ReadonlyArray<INode>): ILiquorTreeNewNode[] => {
const newNodes = new Array<ILiquorTreeNewNode>();
for (const oldNode of oldNodes) {
for (const updatedNode of updatedNodes) {
if (oldNode.id === updatedNode.id) {
const newState = oldNode.states;
newState.checked = updatedNode.selected;
newNodes.push({
id: oldNode.id,
text: updatedNode.text,
children: oldNode.children == null ? [] :
updateCheckedState(
oldNode.children,
updatedNode.children),
state: newState,
data: {
documentationUrls: oldNode.data.documentationUrls,
},
});
}
}
}
return newNodes;
};
const newModel = updateCheckedState(
this.getLiquorTreeApi().model, nodes);
this.getLiquorTreeApi().setModel(newModel);
}
private convertItem(liquorTreeNode: ILiquorTreeNewNode): INode {
if (!liquorTreeNode) { throw new Error('liquorTreeNode is undefined'); }
return {
id: liquorTreeNode.id,
text: liquorTreeNode.text,
selected: liquorTreeNode.state && liquorTreeNode.state.checked,
children: (!liquorTreeNode.children || liquorTreeNode.children.length === 0)
? [] : liquorTreeNode.children.map((childNode) => this.convertItem(childNode)),
documentationUrls: liquorTreeNode.data.documentationUrls,
};
}
private convertExistingToNode(liquorTreeNode: ILiquorTreeExistingNode): INode {
if (!liquorTreeNode) { throw new Error('liquorTreeNode is undefined'); }
return {
id: liquorTreeNode.id,
text: liquorTreeNode.data.text,
selected: liquorTreeNode.states && liquorTreeNode.states.checked,
children: (!liquorTreeNode.children || liquorTreeNode.children.length === 0)
? [] : liquorTreeNode.children.map((childNode) => this.convertExistingToNode(childNode)),
documentationUrls: liquorTreeNode.data.documentationUrls,
};
}
private toLiquorTreeNode(node: INode): ILiquorTreeNewNode {
if (!node) { throw new Error('node is undefined'); }
return {
id: node.id,
text: node.text,
state: {
checked: node.selected,
},
children: (!node.children || node.children.length === 0) ? [] :
node.children.map((childNode) => this.toLiquorTreeNode(childNode)),
data: {
documentationUrls: node.documentationUrls,
},
};
}
private getLiquorTreeOptions(): any {
return {
checkbox: true,
checkOnSelect: true,
deletion: (node) => !node.children || node.children.length === 0,
filter: {
matcher: (query: string, node: ILiquorTreeExistingNode) => {
if (!this.filterPredicate) {
throw new Error('Cannot filter as predicate is null');
}
return this.filterPredicate(this.convertExistingToNode(node));
},
emptyText: '🕵Hmm.. Can not see one 🧐',
},
};
}
private getLiquorTreeApi(): ILiquorTree {
if (!this.$refs.treeElement) {
throw new Error('Referenced tree element cannot be found. Probably it\'s not rendered?');
}
return (this.$refs.treeElement as any).tree;
}
}
</script>
<style scoped lang="scss">
@import "@/presentation/styles/colors.scss";
</style>