New UX for inserting properties from libraries including text search and multi-add

This commit is contained in:
Stefan Zermatten
2021-07-31 21:49:15 +02:00
parent 02434de34c
commit 36bb3c3181
14 changed files with 635 additions and 106 deletions

View File

@@ -21,7 +21,11 @@ import fetchDocByRef from '/imports/api/parenting/fetchDocByRef.js';
const insertPropertyFromLibraryNode = new ValidatedMethod({
name: 'creatureProperties.insertPropertyFromLibraryNode',
validate: new SimpleSchema({
nodeId: {
nodeIds: {
type: Array,
max: 20,
},
'nodeIds.$': {
type: String,
regEx: SimpleSchema.RegEx.Id,
},
@@ -38,7 +42,7 @@ const insertPropertyFromLibraryNode = new ValidatedMethod({
numRequests: 5,
timeInterval: 5000,
},
run({nodeId, parentRef, order}) {
run({nodeIds, parentRef, order}) {
// get the new ancestry for the properties
let {parentDoc, ancestors} = getAncestry({parentRef});
@@ -53,54 +57,15 @@ const insertPropertyFromLibraryNode = new ValidatedMethod({
}
assertEditPermission(rootCreature, this.userId);
// Fetch the library node and its decendents, provided they have not been
// removed
// TODO: Check permission to read the library this node is in
let node = LibraryNodes.findOne({
_id: nodeId,
removed: {$ne: true},
});
if (!node) throw `Node not found for nodeId: ${nodeId}`;
let oldParent = node.parent;
let nodes = LibraryNodes.find({
'ancestors.id': nodeId,
removed: {$ne: true},
}).fetch();
// {libraryId: hasViewPermission}
//let libraryPermissionMemoir = {};
let node;
nodeIds.forEach(nodeId => {
// TODO: Check library view permission for each node before starting
node = insertPropertyFromNode(nodeId, ancestors, order);
});
// Convert all references into actual nodes
nodes = reifyNodeReferences(nodes);
// The root node is first in the array of nodes
// It must get the first generated ID to prevent flickering
nodes = [node, ...nodes];
// re-map all the ancestors
setLineageOfDocs({
docArray: nodes,
newAncestry: ancestors,
oldParent,
});
// Give the docs new IDs without breaking internal references
renewDocIds({
docArray: nodes,
collectionMap: {'libraryNodes': 'creatureProperties'}
});
// Order the root node
if (order === undefined){
setDocToLastOrder({
collection: CreatureProperties,
doc: node,
});
} else {
node.order = order;
}
// Insert the creature properties
CreatureProperties.batchInsert(nodes);
// get the root inserted doc
// get one of the root inserted docs
let rootId = node._id;
// Tree structure changed by inserts, reorder the tree
@@ -110,7 +75,7 @@ const insertPropertyFromLibraryNode = new ValidatedMethod({
});
// The library properties need to denormalise which of them are inactive
recomputeInactiveProperties(rootId);
recomputeInactiveProperties(rootCreature._id);
// Some of the library properties may be items or containers
recomputeInventory(rootCreature._id);
// Inserting a creature property invalidates dependencies: full recompute
@@ -120,6 +85,56 @@ const insertPropertyFromLibraryNode = new ValidatedMethod({
},
});
function insertPropertyFromNode(nodeId, ancestors, order){
// Fetch the library node and its decendents, provided they have not been
// removed
// TODO: Check permission to read the library this node is in
let node = LibraryNodes.findOne({
_id: nodeId,
removed: {$ne: true},
});
if (!node) throw `Node not found for nodeId: ${nodeId}`;
let oldParent = node.parent;
let nodes = LibraryNodes.find({
'ancestors.id': nodeId,
removed: {$ne: true},
}).fetch();
// Convert all references into actual nodes
nodes = reifyNodeReferences(nodes);
// The root node is first in the array of nodes
// It must get the first generated ID to prevent flickering
nodes = [node, ...nodes];
// re-map all the ancestors
setLineageOfDocs({
docArray: nodes,
newAncestry: ancestors,
oldParent,
});
// Give the docs new IDs without breaking internal references
renewDocIds({
docArray: nodes,
collectionMap: {'libraryNodes': 'creatureProperties'}
});
// Order the root node
if (order === undefined){
setDocToLastOrder({
collection: CreatureProperties,
doc: node,
});
} else {
node.order = order;
}
// Insert the creature properties
CreatureProperties.batchInsert(nodes);
return node;
}
// Covert node references into actual nodes
// TODO: check permissions for each library a reference node references
function reifyNodeReferences(nodes, visitedRefs = new Set(), depth = 0){
@@ -194,7 +209,7 @@ function reifyNodeReferences(nodes, visitedRefs = new Set(), depth = 0){
// TODO: Force the referencedNode to take the old id of the reference
// such that the reference's children can be kept
// Give the new referenced sub-tree new ids
renewDocIds({
docArray: addedNodes,

View File

@@ -57,7 +57,7 @@ const insertCreature = new ValidatedMethod({
if (Meteor.isServer){
// Insert the 5e ruleset as the default base
insertPropertyFromLibraryNode.call({
nodeId: 'iHbhfcg3AL5isSWbw',
nodeIds: ['iHbhfcg3AL5isSWbw'],
parentRef: {id: baseId, collection: 'creatureProperties'},
order: 0.5,
});

View File

@@ -9,3 +9,4 @@ import '/imports/server/publications/tabletops.js';
import '/imports/server/publications/slotFillers.js';
import '/imports/server/publications/ownedDocuments.js';
import '/imports/server/publications/archivedCreatures.js';
import '/imports/server/publications/searchLibraryNodes.js';

View File

@@ -2,13 +2,6 @@ import SimpleSchema from 'simpl-schema';
import Libraries from '/imports/api/library/Libraries.js';
import LibraryNodes from '/imports/api/library/LibraryNodes.js';
import { assertViewPermission } from '/imports/api/sharing/sharingPermissions.js';
const standardLibraryIds = [
'SRDLibraryGA3XWsd',
];
Meteor.publish('standardLibraries', function(){
return Libraries.find({_id: {$in: standardLibraryIds}});
});
Meteor.publish('libraries', function(){
this.autorun(function (){
@@ -75,3 +68,24 @@ Meteor.publish('libraryNodes', function(libraryId){
];
});
});
Meteor.publish('descendantLibraryNodes', function(nodeId){
let node = LibraryNodes.findOne(nodeId);
let libraryId = node?.ancestors[0]?.id;
if (!libraryId) return [];
this.autorun(function (){
let userId = this.userId;
let library = Libraries.findOne(libraryId);
try { assertViewPermission(library, userId) }
catch(e){
return this.error(e);
}
return [
LibraryNodes.find({
'ancestors.id': nodeId,
}, {
sort: {order: 1},
}),
];
});
});

View File

@@ -0,0 +1,116 @@
import { check } from 'meteor/check';
import Libraries from '/imports/api/library/Libraries.js';
import LibraryNodes from '/imports/api/library/LibraryNodes.js';
Meteor.publish('searchLibraryNodes', function(){
let self = this;
this.autorun(function (){
let type = self.data('type');
if (!type) return [];
let userId = this.userId;
if (!userId) {
return [];
}
// Get all the ids of libraries the user can access
const user = Meteor.users.findOne(userId, {
fields: {subscribedLibraries: 1}
});
if (!user) return [];
const subs = user.subscribedLibraries || [];
let libraries = Libraries.find({
$or: [
{owner: this.userId},
{writers: this.userId},
{readers: this.userId},
{_id: {$in: subs}},
]
}, {
fields: {_id: 1, name: 1},
});
let libraryIds = libraries.map(lib => lib._id);
// Build a filter for nodes in those libraries that match the type
let filter = {
'ancestors.id': {$in: libraryIds},
removed: {$ne: true},
tags: {$ne: []}, // Only tagged library nodes are considered
};
if (type){
filter.$or = [{
type,
},{
type: 'slotFiller',
slotFillerType: type,
}];
}
this.autorun(function(){
// Get the limit of the documents the user can fetch
var limit = self.data('limit') || 32;
check(limit, Number);
// Get the search term
let searchTerm = self.data('searchTerm') || '';
check(searchTerm, String);
let options = undefined;
if (searchTerm){
filter.$text = {$search: searchTerm};
options = {
// relevant documents have a higher score.
fields: {
score: { $meta: 'textScore' }
},
sort: {
// `score` property specified in the projection fields above.
score: { $meta: 'textScore' },
'ancestors.0.id': 1,
name: 1,
order: 1,
}
}
} else {
delete filter.$text
options = {sort: {
'ancestors.0.id': 1,
name: 1,
order: 1,
}};
}
options.limit = limit;
this.autorun(function () {
self.setData('countAll', LibraryNodes.find(filter).count());
});
let cursor = LibraryNodes.find(filter, options);
Mongo.Collection._publishCursor(libraries, self, 'libraries');
let observeHandle = cursor.observeChanges({
added: function (id, fields) {
fields._searchResult = true;
self.added('libraryNodes', id, fields);
},
changed: function (id, fields) {
self.changed('libraryNodes', id, fields);
},
removed: function (id) {
self.removed('libraryNodes', id);
}
},
// Publications don't mutate the documents
{ nonMutatingCallbacks: true }
);
// register stop callback (expects lambda w/ no args).
this.onStop(function () {
observeHandle.stop();
});
// this.ready();
});
});
});

View File

@@ -38,22 +38,13 @@
/>
<template v-if="tabNumber === 5">
<labeled-fab
key="property"
key="add-property"
color="primary"
data-id="insert-creature-property-btn"
label="New Property"
data-id="add-creature-property-btn"
label="Add Property"
icon="mdi-pencil"
:disabled="!editPermission"
@click="insertTreeProperty"
/>
<labeled-fab
key="property"
color="primary"
data-id="insert-creature-property-from-library-btn"
label="Property From Library"
icon="mdi-library-shelves"
:disabled="!editPermission"
@click="propertyFromLibrary"
@click="addProperty"
/>
</template>
</v-speed-dial>
@@ -234,11 +225,39 @@
let nodeId = libraryNode._id;
let {parentRef, order } = getParentAndOrderFromSelectedTreeNode(creatureId);
let id = insertPropertyFromLibraryNode.call({nodeId, parentRef, order});
let id = insertPropertyFromLibraryNode.call({nodeIds: [nodeId], parentRef, order});
return `tree-node-${id}`;
}
});
},
addProperty(){
let creatureId = this.creatureId;
let fab = hideFab();
this.$store.commit('pushDialogStack', {
component: 'add-creature-property-dialog',
elementId: 'add-creature-property-btn',
callback(result){
revealFab(fab);
if (!result){
return 'insert-creature-property-fab';
}
let {parentRef, order } = getParentAndOrderFromSelectedTreeNode(creatureId);
if (Array.isArray(result)){
let nodeIds = result;
let id = insertPropertyFromLibraryNode.call({nodeIds, parentRef, order});
return `tree-node-${id}`;
} else {
let creatureProperty = result;
// Get order and parent
creatureProperty.order = order;
// Insert the property
let id = insertProperty.call({creatureProperty, parentRef});
return `tree-node-${id}`;
}
}
});
},
}
}
</script>

View File

@@ -0,0 +1,301 @@
<template lang="html">
<selectable-property-dialog
:value="type"
no-library-only-props
@input="e => type = e"
>
<dialog-base
:override-back-button="back"
>
<template slot="toolbar">
<v-toolbar-title class="mr-4">
<template v-if="customProperty">
New
</template>{{ typeName }}
</v-toolbar-title>
<v-slide-x-transition>
<text-field
v-if="!customProperty"
prepend-inner-icon="mdi-magnify"
regular
hide-details
:value="searchValue"
:debounce="400"
@change="searchChanged"
/>
</v-slide-x-transition>
<v-scale-transition>
<v-btn
v-if="!customProperty"
fab
small
elevation="0"
class="mr-2"
color="accent"
@click="customProperty = true"
>
<v-icon>mdi-plus</v-icon>
</v-btn>
</v-scale-transition>
</template>
<v-slide-x-transition
class="unwrapped-content"
leave-absolute
>
<div
v-if="customProperty"
key="custom-property-form"
>
<component
:is="type"
v-if="type"
class="creature-property-form"
:model="model"
:errors="errors"
@change="change"
@push="push"
@pull="pull"
/>
</div>
<div
v-else
key="library-search"
>
<v-expansion-panels
multiple
inset
>
<v-expansion-panel
v-for="libraryNode in libraryNodes"
:key="libraryNode._id"
:model="libraryNode"
:data-id="libraryNode._id"
>
<v-expansion-panel-header>
<template #default="{ open }">
<v-checkbox
v-model="selectedNodeIds"
class="my-0 py-0 mr-2"
hide-details
:value="libraryNode._id"
:disabled="!selectedNodeIds.includes(libraryNode._id) &&
selectedNodeIds.length >= 20"
@click.stop
/>
<v-layout column>
<tree-node-view :model="libraryNode" />
<div class="text-caption">
{{ libraryNames[libraryNode.ancestors[0].id ] }}
</div>
</v-layout>
<v-spacer />
<v-btn
v-if="open"
icon
class="flex-grow-0"
@click.stop="openPropertyDetails(libraryNode._id)"
>
<v-icon>mdi-pencil</v-icon>
</v-btn>
</template>
</v-expansion-panel-header>
<v-expansion-panel-content>
<library-node-expansion-content :model="libraryNode" />
</v-expansion-panel-content>
</v-expansion-panel>
</v-expansion-panels>
<v-layout
justify-center
>
<v-fade-transition mode="out-in">
<div
v-if="currentLimit < countAll"
class="layout justify-center align-stretch"
>
<v-btn
v-if="currentLimit < countAll"
key="load-more-btn"
:loading="!$subReady.searchLibraryNodes"
color="accent"
class="ma-4"
@click="loadMore"
>
Load More
</v-btn>
</div>
</v-fade-transition>
</v-layout>
</div>
</v-slide-x-transition>
<template slot="actions">
<v-btn
text
@click="$store.dispatch('popDialogStack')"
>
Cancel
</v-btn>
<v-spacer />
<v-btn
v-if="customProperty"
text
color="primary"
:disabled="!valid"
@click="$store.dispatch('popDialogStack', model)"
>
create
</v-btn>
<v-btn
v-else
text
color="primary"
:disabled="!selectedNodeIds.length"
@click="$store.dispatch('popDialogStack', selectedNodeIds)"
>
<template v-if="selectedNodeIds.length >= 15">
{{ selectedNodeIds.length }}/20
</template>
Insert
</v-btn>
</template>
</dialog-base>
</selectable-property-dialog>
</template>
<script lang="js">
import SelectablePropertyDialog from '/imports/ui/properties/shared/SelectablePropertyDialog.vue';
import LibraryNodes from '/imports/api/library/LibraryNodes.js';
import DialogBase from '/imports/ui/dialogStack/DialogBase.vue';
import { getPropertyName } from '/imports/constants/PROPERTIES.js';
import TreeNodeView from '/imports/ui/properties/treeNodeViews/TreeNodeView.vue';
import LibraryNodeExpansionContent from '/imports/ui/library/LibraryNodeExpansionContent.vue';
import schemaFormMixin from '/imports/ui/properties/forms/shared/schemaFormMixin.js';
import propertyFormIndex from '/imports/ui/properties/forms/shared/propertyFormIndex.js';
import propertySchemasIndex from '/imports/api/properties/propertySchemasIndex.js';
import Libraries from '/imports/api/library/Libraries.js';
export default {
components: {
...propertyFormIndex,
SelectablePropertyDialog,
DialogBase,
TreeNodeView,
LibraryNodeExpansionContent,
},
mixins: [schemaFormMixin],
props: {
forcedType: {
type: String,
default: undefined,
},
suggestedType: {
type: String,
default: undefined,
},
parentDoc: {
type: Object,
default: undefined,
},
},
reactiveProvide: {
name: 'context',
include: ['debounceTime'],
},
data(){return {
selectedNodeIds: [],
type: this.forcedType || this.suggestedType,
model: {
type: this.type,
},
searchValue: undefined,
customProperty: false,
debounceTime: 0,
};},
computed: {
typeName(){
return getPropertyName(this.type) || 'Property';
},
},
watch: {
type(newType){
this.changeType(newType);
},
},
mounted(){
this.changeType(this.type);
},
methods: {
back(){
if (this.customProperty){
this.customProperty = false;
} else if (this.forcedType){
this.$store.dispatch('popDialogStack');
} else {
this.type = undefined;
}
},
searchChanged(val, ack){
this._subs.searchLibraryNodes.setData('searchTerm', val);
this._subs.searchLibraryNodes.setData('limit', undefined);
this.selectedNode = undefined;
this.searchValue = val;
setTimeout(ack, 200);
},
loadMore(){
if (this.currentLimit >= this.countAll) return;
this._subs.searchLibraryNodes.setData('limit', this.currentLimit + 32);
},
insert(){
if (!this.selectedNodeIds.length) return;
this.$store.dispatch('popDialogStack', this.selectedNodeIds);
},
changeType(type){
this._subs.searchLibraryNodes.setData('type', type);
if (!type) return;
this.schema = propertySchemasIndex[type];
this.validationContext = this.schema.newContext();
let model = this.schema.clean({});
model.type = type;
this.model = model;
},
openPropertyDetails(id){
this.$store.commit('pushDialogStack', {
component: 'library-node-dialog',
elementId: id,
data: {
_id: id,
},
});
}
},
meteor: {
'$subscribe':{
'searchLibraryNodes': [],
},
currentLimit(){
return this._subs.searchLibraryNodes.data('limit') || 32;
},
countAll(){
return this._subs.searchLibraryNodes.data('countAll');
},
libraryNodes(){
return LibraryNodes.find({
_searchResult: true
},{
sort: {
'ancestors.0.id': 1,
name: 1,
order: 1,
},
});
},
libraryNames(){
let names = {};
Libraries.find().forEach(lib => names[lib._id] = lib.name)
return names;
}
}
};
</script>
<style lang="css" scoped>
</style>

View File

@@ -46,44 +46,44 @@ import ColorPicker from '/imports/ui/components/ColorPicker.vue';
import schemaFormMixin from '/imports/ui/properties/forms/shared/schemaFormMixin.js';
export default {
components: {
...propertyFormIndex,
DialogBase,
components: {
...propertyFormIndex,
DialogBase,
ColorPicker,
},
mixins: [schemaFormMixin],
props: {
propertyName: String,
type: String,
},
},
mixins: [schemaFormMixin],
props: {
propertyName: String,
type: String,
},
reactiveProvide: {
name: 'context',
include: ['debounceTime'],
},
data(){return {
model: {
type: this.type,
},
schema: undefined,
validationContext: undefined,
data(){return {
model: {
type: this.type,
},
schema: undefined,
validationContext: undefined,
debounceTime: 0,
};},
watch: {
type(newType){
};},
watch: {
type(newType){
this.changeType(newType);
},
},
},
},
mounted(){
this.changeType(this.type);
},
methods:{
changeType(type){
if (!type) return;
this.schema = propertySchemasIndex[type];
this.validationContext = this.schema.newContext();
let model = this.schema.clean({});
model.type = type;
this.model = model;
this.schema = propertySchemasIndex[type];
this.validationContext = this.schema.newContext();
let model = this.schema.clean({});
model.type = type;
this.model = model;
}
},
}

View File

@@ -9,7 +9,7 @@
</v-toolbar-title>
<v-spacer />
<text-field
prepend-inner-icon="mdi-search"
prepend-inner-icon="mdi-magnify"
regular
hide-details
:value="searchValue"

View File

@@ -93,7 +93,7 @@ export default {
callback(node){
if(!node) return;
let newPropertyId = insertPropertyFromLibraryNode.call({
nodeId: node._id,
nodeIds: [node._id],
parentRef: {
'id': slotId,
'collection': 'creatureProperties',

View File

@@ -1,3 +1,4 @@
import AddCreaturePropertyDialog from '/imports/ui/creature/creatureProperties/AddCreaturePropertyDialog.vue';
import ArchiveDialog from '/imports/ui/creature/archive/ArchiveDialog.vue';
import CastSpellWithSlotDialog from '/imports/ui/properties/components/spells/CastSpellWithSlotDialog.vue';
import CreatureFormDialog from '/imports/ui/creature/CreatureFormDialog.vue';
@@ -23,6 +24,7 @@ import TierTooLowDialog from '/imports/ui/user/TierTooLowDialog.vue';
import UsernameDialog from '/imports/ui/user/UsernameDialog.vue';
export default {
AddCreaturePropertyDialog,
ArchiveDialog,
CastSpellWithSlotDialog,
CreatureFormDialog,

View File

@@ -22,17 +22,17 @@ const dialogStackStore = {
});
updateHistory();
},
replaceDialog(state, {component, data, elementId, callback}){
const _id = Random.id();
replaceDialog(state, {component, data}){
if (!state.dialogs.length){
throw new Meteor.Error('can\'t replace dialog if no dialogs are open');
}
let currentDialog = state.dialogs[state.dialogs.length - 1]
Vue.set(state.dialogs, state.dialogs.length - 1, {
_id,
_id: currentDialog._id,
component,
data,
elementId,
callback,
elementId: currentDialog.elementId,
callback: currentDialog.callback,
});
},
popDialogStackMutation (state, result){

View File

@@ -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/nodesToTree.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';

View File

@@ -0,0 +1,61 @@
<template lang="html">
<div>
<component
:is="model.type"
:model="model"
class="property-viewer"
/>
<tree-node-list
group="library-node-expansion"
:children="propertyChildren"
@selected="clickChild"
/>
</div>
</template>
<script lang="js">
import nodesToTree from '/imports/api/parenting/nodesToTree.js'
import LibraryNodes from '/imports/api/library/LibraryNodes.js';
import propertyViewerIndex from '/imports/ui/properties/viewers/shared/propertyViewerIndex.js';
import TreeNodeList from '/imports/ui/components/tree/TreeNodeList.vue';
export default {
components: {
TreeNodeList,
...propertyViewerIndex,
},
props: {
model: {
type: Object,
required: true,
},
},
methods: {
clickChild(id){
this.$store.commit('pushDialogStack', {
component: 'library-node-dialog',
elementId: `tree-node-${id}`,
data: {
_id: id,
},
});
},
},
meteor: {
$subscribe: {
descendantLibraryNodes(){
return [this.model._id];
},
},
propertyChildren(){
return nodesToTree({
collection: LibraryNodes,
ancestorId: this.model._id
});
},
},
}
</script>
<style lang="css" scoped>
</style>