Drastically improved tree tab search UX for locating parts of the sheet

This commit is contained in:
Stefan Zermatten
2021-07-31 15:19:54 +02:00
parent 0dc0bea53e
commit 02434de34c
13 changed files with 215 additions and 88 deletions

View File

@@ -7,7 +7,7 @@ import { CreatureLogSchema, insertCreatureLogWork } from '/imports/api/creature/
import getRootCreatureAncestor from '/imports/api/creature/creatureProperties/getRootCreatureAncestor.js';
import { assertEditPermission } from '/imports/api/creature/creatures/creaturePermissions.js';
import { recomputeCreatureByDoc } from '/imports/api/creature/computation/methods/recomputeCreature.js';
import { nodesToTree } from '/imports/api/parenting/parenting.js';
import nodesToTree from '/imports/api/parenting/nodesToTree.js';
import applyProperties from '/imports/api/creature/actions/applyProperties.js';
import recomputeInventory from '/imports/api/creature/denormalise/recomputeInventory.js';
import recomputeInactiveProperties from '/imports/api/creature/denormalise/recomputeInactiveProperties.js';

View File

@@ -1,6 +1,6 @@
import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js';
import Creatures from '/imports/api/creature/creatures/Creatures.js';
import { nodesToTree } from '/imports/api/parenting/parenting.js';
import nodesToTree from '/imports/api/parenting/nodesToTree.js';
export default function recomputeInventory(creatureId){
let inventoryForest = nodesToTree({

View File

@@ -1,4 +1,4 @@
import { nodesToTree } from '/imports/api/parenting/parenting.js';
import nodesToTree from '/imports/api/parenting/nodesToTree.js';
export default function getDescendantsInDepthFirstOrder({
collection,

View File

@@ -0,0 +1,115 @@
import { union, difference, sortBy, findLast } from 'lodash';
export function nodeArrayToTree(nodes){
// Store a dict and list of all the nodes
let nodeIndex = {};
let nodeList = [];
nodes.forEach( node => {
let treeNode = {
node: node,
children: [],
};
nodeIndex[node._id] = treeNode;
nodeList.push(treeNode);
});
// Create a forest of trees
let forest = [];
// Either the node is a child of its nearest found ancestor, or in the forest as a root
nodeList.forEach(treeNode => {
let ancestorInForest = findLast(
treeNode.node.ancestors,
ancestor => !!nodeIndex[ancestor.id]
);
if (ancestorInForest){
nodeIndex[ancestorInForest.id].children.push(treeNode);
} else {
forest.push(treeNode);
}
});
return forest;
}
// Fetch the documents from a collection, and return the tree of those documents
export default function nodesToTree({
collection, ancestorId, filter, options = {},
includeFilteredDocAncestors = false, includeFilteredDocDescendants = false
}){
// Setup the filter
let collectionFilter = {
'ancestors.id': ancestorId,
'removed': {$ne: true},
};
if (filter){
collectionFilter = {
...collectionFilter,
...filter,
}
}
// Set up the options
let collectionSort = {
order: 1
};
if (options && options.sort){
collectionSort = {
...collectionSort,
...options.sort,
}
}
let collectionOptions = {
sort: collectionSort,
}
if (options){
collectionOptions = {
...collectionOptions,
...options,
}
}
// Find all the nodes that match the filter
let docs = collection.find(collectionFilter, collectionOptions).map(doc => {
if (!filter) return doc;
// Mark the nodes that were found by the custom filter
doc._matchedDocumentFilter = true;
return doc;
});
let ancestors = [];
let ancestorIds = [];
let docIds = [];
if (filter && (includeFilteredDocAncestors || includeFilteredDocDescendants)){
docIds = docs.map(doc => doc._id)
}
if (filter && includeFilteredDocAncestors){
// Add all ancestor ids to an array
docs.forEach(doc => {
ancestorIds = union(ancestorIds, doc.ancestors.map(ref => ref.id));
});
// Remove the IDs of docs we have already found
ancestorIds = difference(ancestorIds, docIds);
// Get the docs from the collection, don't worry about `removed` docs,
// if their descendant was not removed, neither are they
ancestors = collection.find({_id: {$in: ancestorIds}}).map(doc => {
// Mark that the nodes are ancestors of the found nodes
doc._ancestorOfMatchedDocument = true;
return doc;
});
}
let descendants = [];
if (filter && includeFilteredDocDescendants){
let exludeIds = union(ancestorIds, docIds);
descendants = collection.find({
'_id': {$nin: exludeIds},
'ancestors.id': {$in: docIds},
'removed': {$ne: true},
}).map(doc => {
// Mark that the nodes are descendants of the found nodes
doc._descendantOfMatchedDocument = true;
return doc;
});
}
let nodes = sortBy([
...ancestors,
...docs,
...descendants
], 'order');
// Find all the nodes
return nodeArrayToTree(nodes);
}

View File

@@ -1,6 +1,6 @@
import fetchDocByRef from '/imports/api/parenting/fetchDocByRef.js';
import getCollectionByName from '/imports/api/parenting/getCollectionByName.js';
import { flatten, findLast } from 'lodash';
import { flatten } from 'lodash';
const generalParents = [
'attribute',
@@ -217,41 +217,3 @@ export function getName(doc){
if (doc.ancestors[i].name) return doc.ancestors[i].name;
}
}
export function nodeArrayToTree(nodes){
// Store a dict and list of all the nodes
let nodeIndex = {};
let nodeList = [];
nodes.forEach( node => {
let treeNode = {
node: node,
children: [],
};
nodeIndex[node._id] = treeNode;
nodeList.push(treeNode);
});
// Create a forest of trees
let forest = [];
// Either the node is a child of its nearest found ancestor, or in the forest as a root
nodeList.forEach(treeNode => {
let ancestorInForest = findLast(
treeNode.node.ancestors,
ancestor => !!nodeIndex[ancestor.id]
);
if (ancestorInForest){
nodeIndex[ancestorInForest.id].children.push(treeNode);
} else {
forest.push(treeNode);
}
});
return forest;
}
export function nodesToTree({collection, ancestorId, filter = {}, options = {}}){
if (!('ancestors.id' in filter)) filter['ancestors.id'] = ancestorId;
if (!('removed' in filter)) filter['removed'] = {$ne: true};
if (!options.sort) options.sort = {order: 1};
if (!('order' in options.sort)) options.sort.order = 1;
let nodes = collection.find(filter, options);
return nodeArrayToTree(nodes);
}

View File

@@ -1,7 +1,10 @@
<template lang="html">
<v-sheet
class="tree-node"
:class="!hasChildren ? 'empty' : null"
:class="{
'empty': !hasChildren,
'found': node._matchedDocumentFilter,
}"
:data-id="`tree-node-${node._id}`"
>
<div
@@ -52,7 +55,7 @@
:children="computedChildren"
:group="group"
:organize="organize"
:selected-node-id="selectedNodeId"
:selected-node="selectedNode"
@reordered="e => $emit('reordered', e)"
@reorganized="e => $emit('reorganized', e)"
@selected="e => $emit('selected', e)"
@@ -80,6 +83,7 @@
import { canBeParent } from '/imports/api/parenting/parenting.js';
import { getPropertyIcon } from '/imports/constants/PROPERTIES.js';
import TreeNodeView from '/imports/ui/properties/treeNodeViews/TreeNodeView.vue';
import { some } from 'lodash';
export default {
name: 'TreeNode',
@@ -87,16 +91,33 @@
TreeNodeView,
},
props: {
node: Object,
group: String,
node: {
type: Object,
required: true,
},
group: {
type: String,
required: true,
},
organize: Boolean,
children: Array,
getChildren: Function,
selectedNodeId: String,
children: {
type: Array,
default: () => [],
},
getChildren: {
type: Function,
default: undefined,
},
selectedNode: {
type: Object,
default: undefined,
},
selected: Boolean,
},
data(){ return {
expanded: false,
data(){return {
expanded: this.node._ancestorOfMatchedDocument ||
some(this.selectedNode?.ancestors, ref => ref.id === this.node._id) ||
false,
}},
computed: {
hasChildren(){
@@ -119,6 +140,15 @@
return canBeParent(this.node.type);
},
},
watch: {
'node._ancestorOfMatchedDocument'(value){
this.expanded = !!value ||
some(this.selectedNode?.ancestors, ref => ref.id === this.node._id);
},
'selectedNode.ancestors'(value){
this.expanded = !!some(value, ref => ref.id === this.node._id) || this.expanded;
},
},
beforeCreate() {
this.$options.components.TreeNodeList = require('./TreeNodeList.vue').default
},
@@ -148,9 +178,12 @@
.empty .v-btn {
opacity: 0.4;
}
.found {
background: rgba(200, 0, 0, 0.1);
}
.ghost {
opacity: 0.5;
background: #fbc8c8;
background: rgba(251, 0, 0, 0.3);
}
.v-icon.v-icon--disabled {
opacity: 0;

View File

@@ -18,8 +18,9 @@
:node="child.node"
:children="child.children"
:group="group"
:selected-node-id="selectedNodeId"
:selected="selectedNodeId === child.node._id"
:selected-node="selectedNode"
:selected="selectedNode && selectedNode._id === child.node._id"
:ancestors-of-selected-node="ancestorsOfSelectedNode"
:organize="organize"
:lazy="lazy"
@selected="e => $emit('selected', e)"
@@ -49,7 +50,14 @@
type: Array,
default: () => [],
},
selectedNodeId: String,
selectedNode: {
type: Object,
default: undefined,
},
ancestorsOfSelectedNode: {
type: Array,
default: () => [],
},
},
data(){ return {
expanded: false,

View File

@@ -43,7 +43,7 @@
style="overflow-y: auto;"
:root="{collection: 'creatures', id: creatureId}"
:organize="organize"
:selected-node-id="selected"
:selected-node="selectedNode"
:filter="filter"
@selected="clickNode"
/>
@@ -51,9 +51,9 @@
<template slot="detail">
<creature-property-dialog
embedded
:_id="selected"
@removed="selected = undefined"
@duplicated="id => selected = id"
:_id="selectedNodeId"
@removed="selectedNodeId = undefined"
@duplicated="id => selectedNodeId = id"
/>
</template>
</tree-detail-layout>
@@ -87,7 +87,7 @@
data(){ return {
organize: false,
organizeDisabled: false,
selected: undefined,
selectedNodeId: undefined,
fab: false,
filterString: '',
filterOptions: [
@@ -144,14 +144,14 @@
},
'$vuetify.breakpoint.mdAndUp'(mdAndUp){
if (!mdAndUp){
this.selected = undefined;
this.selectedNodeId = undefined;
}
},
},
methods: {
clickNode(id){
if (this.$vuetify.breakpoint.mdAndUp){
this.selected = id;
this.selectedNodeId = id;
} else {
this.$store.commit('pushDialogStack', {
component: 'creature-property-dialog',
@@ -167,7 +167,7 @@
component: 'creature-property-dialog',
elementId: 'selected-node-card',
data: {
_id: this.selected,
_id: this.selectedNodeId,
startInEditTab: true,
},
});
@@ -175,9 +175,9 @@
getPropertyName,
},
meteor: {
selectedProperty(){
selectedNode(){
return CreatureProperties.findOne({
_id: this.selected,
_id: this.selectedNodeId,
removed: {$ne: true}
});
}

View File

@@ -4,7 +4,7 @@
:children="children"
:group="group"
:organize="organize"
:selected-node-id="selectedNodeId"
:selected-node="selectedNode"
@selected="e => $emit('selected', e)"
@reordered="reordered"
@reorganized="reorganized"
@@ -13,7 +13,7 @@
<script lang="js">
import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js';
import { nodesToTree } from '/imports/api/parenting/parenting.js'
import nodesToTree from '/imports/api/parenting/nodesToTree.js'
import TreeNodeList from '/imports/ui/components/tree/TreeNodeList.vue';
import { organizeDoc, reorderDoc } from '/imports/api/parenting/organizeMethods.js';
@@ -24,7 +24,10 @@
props: {
root: Object,
organize: Boolean,
selectedNodeId: String,
selectedNode: {
type: Object,
default: undefined,
},
filter: Object,
group: {
type: String,
@@ -37,6 +40,8 @@
collection: CreatureProperties,
ancestorId: this.root.id,
filter: this.filter,
includeFilteredDocAncestors: true,
includeFilteredDocDescendants: true,
});
},
},

View File

@@ -234,7 +234,8 @@ export default {
},
});
},
selectSubProperty(_id){
selectSubProperty(doc){
let _id = doc && doc._id
this.$store.commit('pushDialogStack', {
component: 'creature-property-dialog',
elementId: `tree-node-${_id}`,

View File

@@ -30,8 +30,8 @@
style="bottom: -32px"
fab
:library-id="libraryId"
:selected-node-id="selected"
@selected="id => {if ($vuetify.breakpoint.mdAndUp) selected = id}"
:selected-node-id="selectedNodeId"
@selected="id => {if ($vuetify.breakpoint.mdAndUp) selectedNodeId = id}"
/>
</v-toolbar>
<div
@@ -41,7 +41,7 @@
<library-contents-container
:library-id="libraryId"
:organize-mode="organize"
:selected-node-id="selected"
:selected-node="selectedNode"
should-subscribe
@selected="clickNode"
/>
@@ -50,7 +50,7 @@
v-else
edit-mode
:organize-mode="organize"
:selected-node-id="selected"
:selected-node="selectedNode"
style="overflow-y: auto; padding: 12px;"
@selected="clickNode"
/>
@@ -61,10 +61,10 @@
style="overflow: hidden;"
>
<library-node-dialog
:_id="selected"
:_id="selectedNodeId"
embedded
@removed="selected = undefined"
@duplicated="id => {if ($vuetify.breakpoint.mdAndUp) selected = id}"
@removed="selectedNodeId = undefined"
@duplicated="id => {if ($vuetify.breakpoint.mdAndUp) selectedNodeId = id}"
/>
</div>
</tree-detail-layout>
@@ -100,7 +100,7 @@ export default {
},
data(){ return {
organize: false,
selected: undefined,
selectedNodeId: undefined,
};},
computed: {
isToolbarDark(){
@@ -120,12 +120,12 @@ export default {
this.$store.commit('pushDialogStack', {
component: 'library-node-edit-dialog',
elementId: 'selected-node-card',
data: {_id: this.selected},
data: {_id: this.selectedNodeId},
});
},
clickNode(id){
if (this.$vuetify.breakpoint.mdAndUp){
this.selected = id;
this.selectedNodeId = id;
} else {
this.$store.commit('pushDialogStack', {
component: 'library-node-dialog',
@@ -136,7 +136,7 @@ export default {
},
callback: result => {
if (result){
this.selected = id;
this.selectedNodeId = id;
}
},
});
@@ -175,7 +175,7 @@ export default {
},
selectedNode(){
return LibraryNodes.findOne({
_id: this.selected,
_id: this.selectedNodeId,
removed: {$ne: true}
});
},

View File

@@ -30,7 +30,7 @@
<insert-library-node-button
v-if="editPermission(library)"
:library-id="library._id"
:selected-node-id="selectedNodeId"
:selected-node-id="selectedNode && selectedNode._id"
@selected="e => $emit('selected', e)"
/>
<v-btn
@@ -46,7 +46,7 @@
:library-id="library._id"
:organize-mode="organizeMode && editPermission(library)"
:edit-mode="editMode"
:selected-node-id="selectedNodeId"
:selected-node="selectedNode"
should-subscribe
@selected="e => $emit('selected', e)"
/>
@@ -83,8 +83,8 @@ export default {
props: {
organizeMode: Boolean,
editMode: Boolean,
selectedNodeId: {
type: String,
selectedNode: {
type: Object,
default: undefined,
},
},

View File

@@ -7,7 +7,7 @@
group="library"
:children="libraryChildren"
:organize="organizeMode"
:selected-node-id="selectedNodeId"
:selected-node="selectedNode"
@selected="e => $emit('selected', e)"
@reordered="reordered"
@reorganized="reorganized"
@@ -29,7 +29,7 @@
<script lang="js">
import Libraries from '/imports/api/library/Libraries.js';
import LibraryNodes from '/imports/api/library/LibraryNodes.js';
import { nodesToTree } from '/imports/api/parenting/parenting.js'
import nodesToTree from '/imports/api/parenting/nodesToTree.js'
import TreeNodeList from '/imports/ui/components/tree/TreeNodeList.vue';
import { organizeDoc, reorderDoc } from '/imports/api/parenting/organizeMethods.js';
@@ -40,7 +40,10 @@
props: {
libraryId: String,
organizeMode: Boolean,
selectedNodeId: String,
selectedNode: {
type: Object,
default: undefined,
},
shouldSubscribe: Boolean,
},
data(){return {