Added attribute insertion UI and API

This commit is contained in:
Stefan Zermatten
2019-02-20 14:30:04 +02:00
parent 9e208ad3b3
commit 4f402830d8
7 changed files with 251 additions and 12 deletions

View File

@@ -4,6 +4,7 @@ import schema from '/imports/api/schema.js';
import ColorSchema from "/imports/api/creature/subSchemas/ColorSchema.js"; import ColorSchema from "/imports/api/creature/subSchemas/ColorSchema.js";
import { canEditCreature } from '/imports/api/creature/creaturePermission.js'; import { canEditCreature } from '/imports/api/creature/creaturePermission.js';
import { recomputeCreatureById } from '/imports/api/creature/creatureComputation.js' import { recomputeCreatureById } from '/imports/api/creature/creatureComputation.js'
import { getHighestOrder } from '/imports/api/order.js';
import pickKeysAsOptional from '/imports/api/pickKeysAsOptional.js'; import pickKeysAsOptional from '/imports/api/pickKeysAsOptional.js';
let Attributes = new Mongo.Collection("attributes"); let Attributes = new Mongo.Collection("attributes");
@@ -11,7 +12,7 @@ let Attributes = new Mongo.Collection("attributes");
/* /*
* Attributes are numbered stats of a character * Attributes are numbered stats of a character
*/ */
attributeSchema = schema({ let attributeSchema = schema({
charId: { charId: {
type: String, type: String,
regEx: SimpleSchema.RegEx.Id, regEx: SimpleSchema.RegEx.Id,
@@ -24,6 +25,8 @@ attributeSchema = schema({
// The technical, lowercase, single-word name used in formulae // The technical, lowercase, single-word name used in formulae
variableName: { variableName: {
type: String, type: String,
// Must contain a letter, and be made of word characters only
regEx: /^\w*[a-z]\w*$/i,
index: 1, index: 1,
}, },
// Attributes need to store their order to keep the sheet consistent // Attributes need to store their order to keep the sheet consistent
@@ -99,6 +102,35 @@ let updateAttributeSchema = pickKeysAsOptional(attributeSchema, [
'color', 'color',
]); ]);
const insertAttribute = new ValidatedMethod({
name: "Attributes.methods.insert",
validate: schema({
attribute: {
type: Object,
blackbox: true,
},
}).validator(),
run({attribute}) {
const charId = attribute.charId;
if (canEditCreature(charId, this.userId)){
attribute.order = getHighestOrder({
collection: Attributes,
charId,
}) + 1;
attribute.parent = {
id: charId,
collection: 'Creatures',
};
let attId = Attributes.insert(attribute);
recomputeCreatureById(charId);
return attId;
}
},
});
const updateAttribute = new ValidatedMethod({ const updateAttribute = new ValidatedMethod({
name: "Attributes.methods.update", name: "Attributes.methods.update",
@@ -191,4 +223,4 @@ const adjustAttribute = new ValidatedMethod({
}); });
export default Attributes; export default Attributes;
export { updateAttribute, adjustAttribute }; export { insertAttribute, updateAttribute, adjustAttribute };

71
app/imports/api/order.js Normal file
View File

@@ -0,0 +1,71 @@
const getHighestOrder = function({collection, charId}){
const highestOrderedDoc = collection.findOne({
charId
}, {
fields: {order: 1},
sort: {order: -1},
});
return (highestOrderedDoc && highestOrderedDoc.order) || 0;
}
const moveDocToOrder = function({collection, doc, order}){
const currentOrder = doc.order;
if (currentOrder === order){
return;
} else {
// Move the document to its new order
collection.update(doc._id, {$set: {order}});
let inBetweenSelector, increment;
if (order > currentOrder){
// Move in-between docs backward
inBetweenSelector = [
{$gt: currentOrder},
{$lte: order},
];
increment = -1;
} else if (order < currentOrder){
// Move in-between docs forward
inBetweenSelector = [
{$lt: currentOrder},
{$gte: order},
];
increment = 1;
}
collection.update({
order: {$and: inBetweenSelector},
charId: doc.charId,
}, {
$inc: {order: increment},
}, {
multi: true,
});
}
};
const reorderDocs = function({collection, charId}){
let bulkWrite = [];
collection.find({
charId
}, {
fields: {order: 1},
sort: {order: 1}
}).forEach((doc, index) => {
if (doc.order !== index){
bulkwrite.push({
updateOne : {
filter: {_id: doc._id},
update: {$set: {order: index}},
},
});
}
});
if (Meteor.isServer){
collection.rawCollection().bulkWrite(bulkWrite);
} else {
bulkWrite.forEach(op => {
collection.update(op.filter, op.update);
});
}
};
export { getHighestOrder, moveDocToOrder, reorderDocs };

View File

@@ -57,7 +57,7 @@
</v-card> </v-card>
</div> </div>
<div class="saving-throws"> <div class="saving-throws" v-show="true">
<v-card> <v-card>
<v-list> <v-list>
<v-subheader>Saving Throws</v-subheader> <v-subheader>Saving Throws</v-subheader>
@@ -89,6 +89,16 @@
</column-layout> </column-layout>
<v-btn absolute fab bottom right
color="primary"
@click="insertAttribute"
data-id="insert-attribute-fab"
>
<v-icon>
add
</v-icon>
</v-btn>
</div> </div>
</template> </template>
@@ -97,12 +107,12 @@
import Skills from '/imports/api/creature/properties/Skills.js'; import Skills from '/imports/api/creature/properties/Skills.js';
import AttributeCard from '/imports/ui/components/AttributeCard.vue'; import AttributeCard from '/imports/ui/components/AttributeCard.vue';
import AbilityListTile from '/imports/ui/components/AbilityListTile.vue'; import AbilityListTile from '/imports/ui/components/AbilityListTile.vue';
import ColumnLayout from "/imports/ui/components/ColumnLayout.vue"; import ColumnLayout from '/imports/ui/components/ColumnLayout.vue';
import HealthBarCardContainer from '/imports/ui/components/HealthBarCardContainer.vue'; import HealthBarCardContainer from '/imports/ui/components/HealthBarCardContainer.vue';
import HitDiceListTile from '/imports/ui/components/HitDiceListTile.vue'; import HitDiceListTile from '/imports/ui/components/HitDiceListTile.vue';
import SkillListTile from '/imports/ui/components/SkillListTile.vue'; import SkillListTile from '/imports/ui/components/SkillListTile.vue';
import { adjustAttribute } from '/imports/api/creature/properties/Attributes.js'; import { adjustAttribute, insertAttribute } from '/imports/api/creature/properties/Attributes.js';
const getAttributeOfType = function(charId, type){ const getAttributeOfType = function(charId, type){
return Attributes.find({charId, type}, {sort: {order: 1}}); return Attributes.find({charId, type}, {sort: {order: 1}});
@@ -142,7 +152,7 @@
let dice = diceMatch && +diceMatch[1]; let dice = diceMatch && +diceMatch[1];
let con = Attributes.findOne({ let con = Attributes.findOne({
charId: this.charId, charId: this.charId,
variableName: "constitution" variableName: 'constitution'
}); });
let conMod = con && con.mod; let conMod = con && con.mod;
return { return {
@@ -174,15 +184,15 @@
}, },
methods: { methods: {
clickAttribute({_id}){ clickAttribute({_id}){
this.$store.commit("pushDialogStack", { this.$store.commit('pushDialogStack', {
component: "attribute-dialog-container", component: 'attribute-dialog-container',
elementId: _id, elementId: _id,
data: {_id}, data: {_id},
}); });
}, },
clickSkill({_id}){ clickSkill({_id}){
this.$store.commit("pushDialogStack", { this.$store.commit('pushDialogStack', {
component: "skill-dialog-container", component: 'skill-dialog-container',
elementId: _id, elementId: _id,
data: {_id}, data: {_id},
}); });
@@ -192,6 +202,19 @@
adjustAttribute.call({_id, increment: value}); adjustAttribute.call({_id, increment: value});
} }
}, },
insertAttribute(){
const charId = this.charId;
this.$store.commit('pushDialogStack', {
component: 'attribute-creation-dialog',
elementId: 'insert-attribute-fab',
callback(attribute){
if (!attribute) return;
attribute.charId = charId;
let attId = insertAttribute.call({attribute});
return attId
}
});
},
}, },
}; };
</script> </script>

View File

@@ -0,0 +1,86 @@
<template lang="html">
<dialog-base>
<div slot="toolbar">
New Attribute
</div>
<attribute-edit
:attribute="attribute"
:errors="errors"
@change="change"
:debounce-time="0"
/>
<div slot="actions">
<v-spacer/>
<v-btn
flat
:disabled="!valid"
@click="$store.dispatch('popDialogStack', attribute)"
>
Insert Attribute
</v-btn>
</div>
</dialog-base>
</template>
<script>
import AttributeEdit from '/imports/ui/components/AttributeEdit.vue';
import Attributes from '/imports/api/creature/properties/Attributes.js';
import DialogBase from '/imports/ui/dialogStack/DialogBase.vue';
import { Tracker } from 'meteor/tracker';
export default {
components: {
AttributeEdit,
DialogBase,
},
data(){ return {
attribute: {
name: 'New Attribute',
variableName: 'newAttribute',
type: 'stat',
baseValue: null,
adjustment: null,
decimal: null,
reset: null,
color: '#9E9E9E',
},
valid: true,
}},
methods: {
change(update, ack){
for (key in update){
this.attribute[key] = update[key];
if (key === 'name' && update[key]){
const name = update[key];
this.attribute.variableName = name.toLowerCase().replace(
/\W+(\w?)/g, (match, p1) => p1.toUpperCase()
);
}
}
if (ack) ack();
},
},
created(){
this.validationContext = Attributes.simpleSchema().newContext();
},
computed: {
errors(){
this.valid = true;
let cleanAtt = this.validationContext.clean(this.attribute)
this.validationContext.validate(cleanAtt, {keys: [
'name', 'variableName', 'type', 'baseValue', 'adjustment', 'decimal',
'reset', 'color'
]});
let errors = {};
this.validationContext.validationErrors().forEach(error => {
if (this.valid) this.valid = false;
errors[error.name] = Attributes.simpleSchema().messageForError(error);
});
return errors;
},
},
};
</script>
<style lang="css" scoped>
</style>

View File

@@ -38,9 +38,14 @@
}, },
methods: { methods: {
clickedEffect(e){ clickedEffect(e){
console.log(e); console.log({TODO: e});
}, },
change(update, ack){ change(update, ack){
if(update.name){
update.variableName = update.name.toLowerCase().replace(
/\W+(\w?)/g, (match, p1) => p1.toUpperCase()
);
}
updateAttribute.call({_id: this._id, update}, error => { updateAttribute.call({_id: this._id, update}, error => {
ack(error); ack(error);
}); });

View File

@@ -4,12 +4,16 @@
label="Name" label="Name"
:value="attribute.name" :value="attribute.name"
@change="(name, ack) => $emit('change', {name}, ack)" @change="(name, ack) => $emit('change', {name}, ack)"
:error-messages="errors.name"
:debounce-time="debounceTime"
/> />
<text-field <text-field
label="Variable name" label="Variable name"
:value="attribute.variableName" :value="attribute.variableName"
@change="(variableName, ack) => $emit('change', {variableName}, ack)" @change="(variableName, ack) => $emit('change', {variableName}, ack)"
hint="Use this name in formulae to reference this attribute" hint="Use this name in formulae to reference this attribute"
:error-messages="errors.variableName"
:debounce-time="debounceTime"
/> />
<text-field <text-field
label="Base Value" label="Base Value"
@@ -17,23 +21,30 @@
:value="attribute.baseValue" :value="attribute.baseValue"
@change="(baseValue, ack) => $emit('change', {baseValue: +baseValue}, ack)" @change="(baseValue, ack) => $emit('change', {baseValue: +baseValue}, ack)"
hint="This is the value of the attribute before effects are applied" hint="This is the value of the attribute before effects are applied"
:error-messages="errors.baseValue"
:debounce-time="debounceTime"
/> />
<text-field <text-field
label="Damage" label="Damage"
type="number" type="number"
:value="-attribute.adjustment" :value="-attribute.adjustment"
@change="(damage, ack) => $emit('change', {adjustment: -damage || null}, ack)" @change="(damage, ack) => $emit('change', {adjustment: -damage || null}, ack)"
:error-messages="errors.adjustment"
:debounce-time="debounceTime"
/> />
<smart-select <smart-select
label="Type" label="Type"
:items="attributeTypes" :items="attributeTypes"
:value="attribute.type" :value="attribute.type"
:error-messages="errors.type"
:menu-props="{auto: true, lazy: true}" :menu-props="{auto: true, lazy: true}"
@change="(type, ack) => $emit('change', {type}, ack)" @change="(type, ack) => $emit('change', {type}, ack)"
:debounce-time="debounceTime"
/> />
<v-switch <v-switch
label="Allow decimal values" label="Allow decimal values"
:value="attribute.decimal" :value="attribute.decimal"
:error-messages="errors.decimal"
@change="e => $emit('change', {decimal: !!e})" @change="e => $emit('change', {decimal: !!e})"
/> />
<smart-select <smart-select
@@ -42,15 +53,19 @@
clearable clearable
:items="resetOptions" :items="resetOptions"
:value="attribute.reset" :value="attribute.reset"
:error-messages="errors.reset"
:menu-props="{auto: true, lazy: true}" :menu-props="{auto: true, lazy: true}"
@change="(reset, ack) => $emit('change', {reset}, ack)" @change="(reset, ack) => $emit('change', {reset}, ack)"
:debounce-time="debounceTime"
/> />
<text-field <text-field
label="Reset Multiplier" label="Reset Multiplier"
type="number" type="number"
:value="attribute.resetMultiplier" :value="attribute.resetMultiplier"
:error-messages="errors.resetMultiplier"
@change="(resetMultiplier, ack) => $emit('change', {resetMultiplier: +resetMultiplier}, ack)" @change="(resetMultiplier, ack) => $emit('change', {resetMultiplier: +resetMultiplier}, ack)"
hint="Some attributes, like hit dice, only reset by half their total on a long rest" hint="Some attributes, like hit dice, only reset by half their total on a long rest"
:debounce-time="debounceTime"
/> />
</div> </div>
</template> </template>
@@ -60,8 +75,13 @@
props: { props: {
attribute: { attribute: {
type: Object, type: Object,
default: {}, default: () => ({}),
}, },
errors: {
type: Object,
default: () => ({}),
},
debounceTime: Number,
}, },
data(){ return{ data(){ return{
attributeTypes: [ attributeTypes: [

View File

@@ -1,9 +1,11 @@
import AttributeDialog from '/imports/ui/components/AttributeDialog.vue'; import AttributeDialog from '/imports/ui/components/AttributeDialog.vue';
import AttributeDialogContainer from '/imports/ui/components/AttributeDialogContainer.vue'; import AttributeDialogContainer from '/imports/ui/components/AttributeDialogContainer.vue';
import AttributeCreationDialog from '/imports/ui/components/AttributeCreationDialog.vue';
import SkillDialogContainer from '/imports/ui/components/SkillDialogContainer.vue'; import SkillDialogContainer from '/imports/ui/components/SkillDialogContainer.vue';
export default { export default {
AttributeDialog, AttributeDialog,
AttributeDialogContainer, AttributeDialogContainer,
AttributeCreationDialog,
SkillDialogContainer, SkillDialogContainer,
}; };