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 getRootCreatureAncestor from '/imports/api/creature/creatureProperties/getRootCreatureAncestor.js';
import { assertEditPermission } from '/imports/api/creature/creatures/creaturePermissions.js'; import { assertEditPermission } from '/imports/api/creature/creatures/creaturePermissions.js';
import { recomputeCreatureByDoc } from '/imports/api/creature/computation/methods/recomputeCreature.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 applyProperties from '/imports/api/creature/actions/applyProperties.js';
import recomputeInventory from '/imports/api/creature/denormalise/recomputeInventory.js'; import recomputeInventory from '/imports/api/creature/denormalise/recomputeInventory.js';
import recomputeInactiveProperties from '/imports/api/creature/denormalise/recomputeInactiveProperties.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 CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js';
import Creatures from '/imports/api/creature/creatures/Creatures.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){ export default function recomputeInventory(creatureId){
let inventoryForest = nodesToTree({ 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({ export default function getDescendantsInDepthFirstOrder({
collection, 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 fetchDocByRef from '/imports/api/parenting/fetchDocByRef.js';
import getCollectionByName from '/imports/api/parenting/getCollectionByName.js'; import getCollectionByName from '/imports/api/parenting/getCollectionByName.js';
import { flatten, findLast } from 'lodash'; import { flatten } from 'lodash';
const generalParents = [ const generalParents = [
'attribute', 'attribute',
@@ -217,41 +217,3 @@ export function getName(doc){
if (doc.ancestors[i].name) return doc.ancestors[i].name; 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"> <template lang="html">
<v-sheet <v-sheet
class="tree-node" class="tree-node"
:class="!hasChildren ? 'empty' : null" :class="{
'empty': !hasChildren,
'found': node._matchedDocumentFilter,
}"
:data-id="`tree-node-${node._id}`" :data-id="`tree-node-${node._id}`"
> >
<div <div
@@ -52,7 +55,7 @@
:children="computedChildren" :children="computedChildren"
:group="group" :group="group"
:organize="organize" :organize="organize"
:selected-node-id="selectedNodeId" :selected-node="selectedNode"
@reordered="e => $emit('reordered', e)" @reordered="e => $emit('reordered', e)"
@reorganized="e => $emit('reorganized', e)" @reorganized="e => $emit('reorganized', e)"
@selected="e => $emit('selected', e)" @selected="e => $emit('selected', e)"
@@ -80,6 +83,7 @@
import { canBeParent } from '/imports/api/parenting/parenting.js'; import { canBeParent } from '/imports/api/parenting/parenting.js';
import { getPropertyIcon } from '/imports/constants/PROPERTIES.js'; import { getPropertyIcon } from '/imports/constants/PROPERTIES.js';
import TreeNodeView from '/imports/ui/properties/treeNodeViews/TreeNodeView.vue'; import TreeNodeView from '/imports/ui/properties/treeNodeViews/TreeNodeView.vue';
import { some } from 'lodash';
export default { export default {
name: 'TreeNode', name: 'TreeNode',
@@ -87,16 +91,33 @@
TreeNodeView, TreeNodeView,
}, },
props: { props: {
node: Object, node: {
group: String, type: Object,
required: true,
},
group: {
type: String,
required: true,
},
organize: Boolean, organize: Boolean,
children: Array, children: {
getChildren: Function, type: Array,
selectedNodeId: String, default: () => [],
},
getChildren: {
type: Function,
default: undefined,
},
selectedNode: {
type: Object,
default: undefined,
},
selected: Boolean, selected: Boolean,
}, },
data(){ return { data(){return {
expanded: false, expanded: this.node._ancestorOfMatchedDocument ||
some(this.selectedNode?.ancestors, ref => ref.id === this.node._id) ||
false,
}}, }},
computed: { computed: {
hasChildren(){ hasChildren(){
@@ -119,6 +140,15 @@
return canBeParent(this.node.type); 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() { beforeCreate() {
this.$options.components.TreeNodeList = require('./TreeNodeList.vue').default this.$options.components.TreeNodeList = require('./TreeNodeList.vue').default
}, },
@@ -148,9 +178,12 @@
.empty .v-btn { .empty .v-btn {
opacity: 0.4; opacity: 0.4;
} }
.found {
background: rgba(200, 0, 0, 0.1);
}
.ghost { .ghost {
opacity: 0.5; opacity: 0.5;
background: #fbc8c8; background: rgba(251, 0, 0, 0.3);
} }
.v-icon.v-icon--disabled { .v-icon.v-icon--disabled {
opacity: 0; opacity: 0;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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