From 8b958dd23274533b51066acad0fa5c68a5b35fb3 Mon Sep 17 00:00:00 2001 From: Matt Smith Date: Tue, 6 Oct 2020 17:20:00 +0000 Subject: [PATCH] 10: Refactor actor sheet code - Refactored actor-sheet.js to move most of its logic into a separate helper.js module as static methods on a new SimpleHelper class. This doesn't add anything functional at the moment, but it's in preparation of re-using the same code for the item-sheet.js file. --- lang/en.json | 2 + module/actor-sheet.js | 403 ++----------------- module/actor.js | 139 ++++--- module/helper.js | 545 ++++++++++++++++++++++++++ module/item-sheet.js | 125 +++--- templates/actor-sheet.html | 37 +- templates/item-sheet.html | 23 +- templates/parts/sheet-attributes.html | 2 +- 8 files changed, 748 insertions(+), 528 deletions(-) create mode 100644 module/helper.js diff --git a/lang/en.json b/lang/en.json index e08325c..05254bd 100644 --- a/lang/en.json +++ b/lang/en.json @@ -7,7 +7,9 @@ "SIMPLE.NotifyInitFormulaUpdated": "Initiative formula was updated to:", "SIMPLE.NotifyInitFormulaInvalid": "Initiative formula was invalid:", "SIMPLE.NotifyGroupDuplicate": "Attribute group already exists.", + "SIMPLE.NotifyGroupAttrDuplicate": "Attribute group already exists as an attribute.", "SIMPLE.NotifyGroupAlphanumeric": "Attribute group names may not contain spaces or periods.", + "SIMPLE.NotifyAttrDuplicate": "Attribute key already exists as a group.", "SIMPLE.ResourceMin": "Min", "SIMPLE.ResourceValue": "Value", diff --git a/module/actor-sheet.js b/module/actor-sheet.js index f73829b..dc7a9ca 100644 --- a/module/actor-sheet.js +++ b/module/actor-sheet.js @@ -1,4 +1,4 @@ -import { ATTRIBUTE_TYPES } from "./constants.js"; +import { EntitySheetHelper } from "./helper.js"; /** * Extend the basic ActorSheet with some very simple modifications @@ -24,45 +24,9 @@ export class SimpleActorSheet extends ActorSheet { /** @override */ getData() { const data = super.getData(); - data.dtypes = ATTRIBUTE_TYPES; - for ( let attr of Object.values(data.data.attributes) ) { - if ( attr.dtype ) { - attr.isCheckbox = attr.dtype === "Boolean"; - attr.isResource = attr.dtype === "Resource"; - } - } - // Initialize ungrouped attributes for later. - data.data.ungroupedAttributes = {}; - - // Build an array of sorted group keys. - const groups = data.data.groups || {}; - let groupKeys = Object.keys(groups).sort((a, b) => { - let aSort = groups[a].label ?? a; - let bSort = groups[b].label ?? b; - return aSort.localeCompare(bSort); - }); - - // Iterate over the sorted groups to add their attributes. - for ( let key of groupKeys ) { - let group = data.data.attributes[key] || {}; - - // Initialize the attributes container for this group. - if ( !data.data.groups[key]['attributes'] ) data.data.groups[key]['attributes'] = {}; - - // Sort the attributes within the group, and then iterate over them. - Object.keys(group).sort((a, b) => a.localeCompare(b)).forEach(attr => { - // For each attribute, determine whether it's a checkbox or resource, and then add it to the group's attributes list. - group[attr]['isCheckbox'] = group[attr]['dtype'] === 'Boolean'; - group[attr]['isResource'] = group[attr]['dtype'] === 'Resource'; - data.data.groups[key]['attributes'][attr] = group[attr]; - }); - } - - // Sort the remaining attributes attributes. - Object.keys(data.data.attributes).filter(a => !groupKeys.includes(a)).sort((a, b) => a.localeCompare(b)).forEach(key => { - data.data.ungroupedAttributes[key] = data.data.attributes[key]; - }); + // Handle attribute groups. + EntitySheetHelper.getAttributeData(data); // Add shorthand. data.shorthand = !!game.settings.get("worldbuilding", "macroShorthand"); @@ -79,7 +43,7 @@ export class SimpleActorSheet extends ActorSheet { html.find(".items .rollable").on("click", this._onItemRoll.bind(this)); // Handle rollable attributes. - html.find(".attributes").on("click", "a.attribute-roll", this._onAttributeRoll.bind(this)); + html.find(".attributes").on("click", "a.attribute-roll", EntitySheetHelper.onAttributeRoll.bind(this)); // Everything below here is only needed if the sheet is editable if ( !this.options.editable ) return; @@ -98,12 +62,6 @@ export class SimpleActorSheet extends ActorSheet { li.slideUp(200, () => this.render(false)); }); - // Add or Remove Attribute - html.find(".attributes").on("click", ".attribute-control", this._onClickAttributeControl.bind(this)); - - // Add attribute groups. - html.find(".groups").on("click", ".group-control", this._onClickAttributeGroupControl.bind(this)); - // Add draggable for macros. html.find(".attributes a.attribute-roll").each((i, a) => { a.setAttribute("draggable", true); @@ -112,38 +70,30 @@ export class SimpleActorSheet extends ActorSheet { ev.dataTransfer.setData('text/plain', JSON.stringify(dragData)); }, false); }); + + // Add or Remove Attribute + html.find(".attributes").on("click", ".attribute-control", EntitySheetHelper.onClickAttributeControl.bind(this)); + + // Add attribute groups. + html.find(".groups").on("click", ".group-control", EntitySheetHelper.onClickAttributeGroupControl.bind(this)); } /* -------------------------------------------- */ /** @override */ async _onSubmit(event, {updateData=null, preventClose=false, preventRender=false}={}) { - // Exit early if this isn't a named attribute. - if ( event.currentTarget ) { - if ( event.currentTarget.tagName.toLowerCase() == 'input' && !event.currentTarget.hasAttribute('name')) { - return; - } - } + let attr = EntitySheetHelper.onSubmit(event); - let self = $(event.currentTarget); - let attr = null; - - // If this is the attribute key, we need to make a note of it so that we can restore focus when its recreated. - if ( self.hasClass('attribute-key') ) { - let val = self.val(); - let oldVal = self.parents('.attribute').data('attribute'); - oldVal = oldVal.includes('.') ? oldVal.split('.')[1] : oldVal; - attr = self.attr('name').replace(oldVal, val); - } - - // Submit the form. - await super._onSubmit(event, {updateData: updateData, preventClose: preventClose, preventRender: preventRender}); - - // If this was the attribute key, set a very short timeout and retrigger focus after the original element is deleted and the new one is inserted. + // Submit the form if attr is true or an attr key. if ( attr ) { - setTimeout(() => { - $(`input[name="${attr}"]`).parents('.attribute').find('.attribute-value').focus(); - }, 10); + await super._onSubmit(event, {updateData: updateData, preventClose: preventClose, preventRender: preventRender}); + + // If attr is a key and not just true, set a very short timeout and retrigger focus after the original element is deleted and the new one is inserted. + if ( attr !== true) { + setTimeout(() => { + $(`input[name="${attr}"]`).parents('.attribute').find('.attribute-value').focus(); + }, 10); + } } } @@ -176,324 +126,15 @@ export class SimpleActorSheet extends ActorSheet { }); } - /** - * Listen for the roll button on attributes. - * @param {MouseEvent} event The originating left click event - */ - _onAttributeRoll(event) { - event.preventDefault(); - const button = event.currentTarget; - const formula = button.closest(".attribute").querySelector(".attribute-value")?.value; - const label = button.closest(".attribute").querySelector(".attribute-label")?.value; - const chatLabel = label ?? button.parentElement.querySelector(".attribute-key").value; - - // If there's a formula, attempt to roll it. - if ( formula ) { - let r = new Roll(formula, this.actor.getRollData()); - r.roll().toMessage({ - user: game.user._id, - speaker: ChatMessage.getSpeaker({ actor: this.actor }), - flavor: `${chatLabel}` - }); - } - } - - /** - * Listen for click events on an attribute control to modify the composition of attributes in the sheet - * @param {MouseEvent} event The originating left click event - * @private - */ - async _onClickAttributeControl(event) { - event.preventDefault(); - const a = event.currentTarget; - const action = a.dataset.action; - const group = a.dataset.group; - let dtype = a.dataset.dtype; - const attrs = this.object.data.data.attributes; - const groups = this.object.data.data.groups; - const form = this.form; - - // Add new attribute - if ( action === "create" ) { - // Determine the new attribute key for ungrouped attributes. - let objKeys = Object.keys(attrs).filter(k => !Object.keys(groups).includes(k)); - let nk = Object.keys(attrs).length + 1; - let newValue = `attr${nk}`; - let newKey = document.createElement("div"); - while ( objKeys.includes(newValue) ) { - ++nk; - newValue = `attr${nk}`; - }; - - // Build options for construction HTML inputs. - let htmlItems = { - key: { - type: "text", - value: newValue - } - }; - - // Grouped attributes. - if ( group ) { - objKeys = attrs[group] ? Object.keys(attrs[group]) : []; - nk = objKeys.length + 1; - newValue = `attr${nk}`; - while ( objKeys.includes(newValue) ) { - ++nk; - newValue = `attr${nk}`; - } - - // Update the HTML options used to build the new input. - htmlItems.key.value = newValue; - htmlItems.group = { - type: "hidden", - value: group - }; - htmlItems.dtype = { - type: "hidden", - value: dtype - }; - } - // Ungrouped attributes. - else { - // Choose a default dtype based on the last attribute, fall back to "String". - if (!dtype) { - let lastAttr = document.querySelector('.attributes > .attributes-group .attribute:last-child .attribute-dtype')?.value; - dtype = lastAttr ? lastAttr : "String"; - htmlItems.dtype = { - type: "hidden", - value: dtype - }; - } - } - - // Build the form elements used to create the new grouped attribute. - newKey.innerHTML = this._getAttributeHtml(htmlItems, nk, group); - - // Append the form element and submit the form. - newKey = newKey.children[0]; - form.appendChild(newKey); - await this._onSubmit(event); - } - - // Remove existing attribute - else if ( action === "delete" ) { - const li = a.closest(".attribute"); - li.parentElement.removeChild(li); - await this._onSubmit(event); - } - } - - /** - * Listen for click events and modify attribute groups. - * @param {MouseEvent} event The originating left click event - */ - async _onClickAttributeGroupControl(event) { - event.preventDefault(); - const a = event.currentTarget; - const action = a.dataset.action; - const form = this.form; - - // Add new attribute group. - if ( action === "create-group" ) { - let newValue = $(a).siblings('.group-prefix').val(); - // Verify the new group key is valid, and use it to create the group. - if ( newValue.length > 0 && this._validateGroup(newValue) ) { - let newKey = document.createElement("div"); - newKey.innerHTML = ``; - // Append the form element and submit the form. - newKey = newKey.children[0]; - form.appendChild(newKey); - await this._onSubmit(event); - } - - } - - // Remove existing attribute - else if ( action === "delete-group" ) { - let groupHeader = a.closest(".group-header"); - let group = $(groupHeader).find('.group-key'); - // Create a dialog to confirm group deletion. - new Dialog({ - title: game.i18n.localize("SIMPLE.DeleteGroup"), - content: `${game.i18n.localize("SIMPLE.DeleteGroupContent")} ${group.val()}`, - buttons: { - confirm: { - icon: '', - label: game.i18n.localize("Yes"), - callback: async () => { - groupHeader.parentElement.removeChild(groupHeader); - await this._onSubmit(event); - } - }, - cancel: { - icon: '', - label: game.i18n.localize("No"), - } - } - }).render(true); - } - } - - /* -------------------------------------------- */ - - - /** - * Return HTML for a new attribute to be applied to the form for submission. - * - * @param {Object} items Keyed object where each item has a "type" and "value" property. - * @param {string} index Numeric index or key of the new attribute. - * @param {string|boolean} group String key of the group, or false. - * - * @returns {string} Html string. - */ - _getAttributeHtml(items, index, group = false) { - // Initialize the HTML. - let result = '
'; - // Iterate over the supplied keys and build their inputs (including whether or not they need a group key). - for (let [key, item] of Object.entries(items)) { - result = result + ``; - } - // Close the HTML and return. - return result + '
'; - } - - /* -------------------------------------------- */ - - /** - * Validate whether or not a group name can be used. - * @param {string} groupName Groupname to validate - * @returns {boolean} - */ - _validateGroup(groupName) { - let groups = Object.keys(this.actor.data.data.groups); - - // Check for duplicate group keys. - if ( groups.includes(groupName) ) { - ui.notifications.error(game.i18n.localize("SIMPLE.NotifyGroupDuplicate") + ` (${groupName})`); - return false; - } - - // Check for whitespace or periods. - if ( groupName.match(/[\s|\.]/i) ) { - ui.notifications.error(game.i18n.localize("SIMPLE.NotifyGroupAlphanumeric")); - return false; - } - - return true; - } - /** @override */ _updateObject(event, formData) { // Handle attribute and group updates. - formData = this._updateAttributes(formData); - formData = this._updateGroups(formData); + formData = EntitySheetHelper.updateAttributes(formData, this); + formData = EntitySheetHelper.updateGroups(formData, this); // Update the Actor with the new form values. return this.object.update(formData); } - /** - * Update attributes when updating an actor object. - * - * @param {Object} formData Form data object to modify keys and values for. - * @returns {Object} updated formData object. - */ - _updateAttributes(formData) { - let groupKeys = []; - - // Handle the free-form attributes list - const formAttrs = expandObject(formData).data.attributes || {}; - const attributes = Object.values(formAttrs).reduce((obj, v) => { - let attrs = []; - let group = null; - // Handle attribute keys for grouped attributes. - if ( !v["key"] ) { - attrs = Object.keys(v); - attrs.forEach(attrKey => { - group = v[attrKey]['group']; - groupKeys.push(group); - let attr = v[attrKey]; - let k = v[attrKey]["key"] ? v[attrKey]["key"].trim() : attrKey.trim(); - if ( /[\s\.]/.test(k) ) return ui.notifications.error("Attribute keys may not contain spaces or periods"); - delete attr["key"]; - // Add the new attribute if it's grouped, but we need to build the nested structure first. - if ( !obj[group] ) { - obj[group] = {}; - } - obj[group][k] = attr; - }); - } - // Handle attribute keys for ungrouped attributes. - else { - let k = v["key"].trim(); - if ( /[\s\.]/.test(k) ) return ui.notifications.error("Attribute keys may not contain spaces or periods"); - delete v["key"]; - // Add the new attribute only if it's ungrouped. - if ( !group ) { - obj[k] = v; - } - } - return obj; - }, {}); - - // Remove attributes which are no longer used - for ( let k of Object.keys(this.object.data.data.attributes) ) { - if ( !attributes.hasOwnProperty(k) ) attributes[`-=${k}`] = null; - } - - // Remove grouped attributes which are no longer used. - for ( let group of groupKeys) { - if ( this.object.data.data.attributes[group] ) { - for ( let k of Object.keys(this.object.data.data.attributes[group]) ) { - if ( !attributes[group].hasOwnProperty(k) ) attributes[group][`-=${k}`] = null; - } - } - } - - // Re-combine formData - formData = Object.entries(formData).filter(e => !e[0].startsWith("data.attributes")).reduce((obj, e) => { - obj[e[0]] = e[1]; - return obj; - }, {_id: this.object._id, "data.attributes": attributes}); - - return formData; - } - - /** - * Update attribute groups when updating an actor object. - * - * @param {Object} formData Form data object to modify keys and values for. - * @returns {Object} updated formData object. - */ - _updateGroups(formData) { - // Handle the free-form groups list - const formGroups = expandObject(formData).data.groups || {}; - const groups = Object.values(formGroups).reduce((obj, v) => { - // If there are duplicate groups, collapse them. - if ( Array.isArray(v["key"]) ) { - v["key"] = v["key"][0]; - } - // Trim and clean up. - let k = v["key"].trim(); - if ( /[\s\.]/.test(k) ) return ui.notifications.error("Group keys may not contain spaces or periods"); - delete v["key"]; - obj[k] = v; - return obj; - }, {}); - - // Remove groups which are no longer used - for ( let k of Object.keys(this.object.data.data.groups) ) { - if ( !groups.hasOwnProperty(k) ) groups[`-=${k}`] = null; - } - - // Re-combine formData - formData = Object.entries(formData).filter(e => !e[0].startsWith("data.groups")).reduce((obj, e) => { - obj[e[0]] = e[1]; - return obj; - }, {_id: this.object._id, "data.groups": groups}); - - return formData; - } } diff --git a/module/actor.js b/module/actor.js index 1f6eb5a..3b667aa 100644 --- a/module/actor.js +++ b/module/actor.js @@ -1,3 +1,5 @@ +import { EntitySheetHelper } from "./helper.js"; + /** * Extend the base Actor entity by defining a custom roll data structure which is ideal for the Simple system. * @extends {Actor} @@ -18,15 +20,18 @@ export class SimpleActor extends Actor { const data = super.getRollData(); const shorthand = game.settings.get("worldbuilding", "macroShorthand"); const formulaAttributes = []; + const itemAttributes = []; // Handle formula attributes when the short syntax is disabled. this._applyShorthand(data, formulaAttributes, shorthand); // Map all items data using their slugified names - this._applyItems(data, shorthand); + this._applyItems(data, itemAttributes, shorthand); - // Evaluate formula attributes after all other attributes have been handled, - // including items. + // Evaluate formula replacements on items. + this._applyItemsFormulaReplacements(data, itemAttributes, shorthand); + + // Evaluate formula attributes after all other attributes have been handled, including items. this._applyFormulaReplacements(data, formulaAttributes, shorthand); // Remove the attributes if necessary. @@ -61,9 +66,9 @@ export class SimpleActor extends Actor { // Grouped attributes. else { data[k] = {}; - for ( let [attrKey, attrValue] of Object.entries(v) ) { - data[k][attrKey] = attrValue.value; - if ( attrValue.dtype == "Formula" ) formulaAttributes.push(`${k}.${attrKey}`); + for ( let [gk, gv] of Object.entries(v) ) { + data[k][gk] = gv.value; + if ( gv.dtype == "Formula" ) formulaAttributes.push(`${k}.${gk}`); } } } @@ -77,37 +82,42 @@ export class SimpleActor extends Actor { * @param {Object} data The actor's data object. * @param {Boolean} shorthand Whether or not the shorthand syntax is used. */ - _applyItems(data, shorthand) { + _applyItems(data, itemAttributes, shorthand) { // Map all items data using their slugified names data.items = this.data.items.reduce((obj, i) => { let key = i.name.slugify({strict: true}); let itemData = duplicate(i.data); - const itemAttributes = []; // Add items to shorthand and note which ones are formula attributes. for ( let [k, v] of Object.entries(itemData.attributes) ) { - if ( v.dtype == "Formula" ) itemAttributes.push(k); + // When building the attribute list, prepend the item name for later use. + if ( v.dtype == "Formula" ) itemAttributes.push(`${key}..${k}`); // Add shortened version of the attributes. if ( !!shorthand ) { if ( !(k in itemData) ) { - itemData[k] = v.value; + // Non-grouped item attributes. + if ( v.dtype ) { + itemData[k] = v.value; + } + // Grouped item attributes. + else { + if ( !itemData[k] ) itemData[k] = {}; + for ( let [gk, gv] of Object.entries(v) ) { + itemData[k][gk] = gv.value; + if ( gv.dtype == "Formula" ) itemAttributes.push(`${key}..${k}.${gk}`); + } + } } } - } - - // Evaluate formula attributes after all other attributes have been handled. - for ( let k of itemAttributes ) { - if ( itemData.attributes[k].value ) { - itemData.attributes[k].value = this._replaceData(itemData.attributes[k].value, itemData); - itemData.attributes[k].value = this._replaceData(itemData.attributes[k].value, data, {missing: "0"}); - // TODO: Replace with: - // itemData.attributes[k].value = Roll.replaceFormulaData(itemData.attributes[k].value, itemData); - // itemData.attributes[k].value = Roll.replaceFormulaData(itemData.attributes[k].value, data, {missing: "0"}); - } - - // Duplicate values to shorthand. - if ( !!shorthand ) { - itemData[k] = itemData.attributes[k].value; + // Handle non-shorthand version of grouped attributes. + else { + if ( !v.dtype ) { + if ( !itemData[k] ) itemData[k] = {}; + for ( let [gk, gv] of Object.entries(v) ) { + itemData[k][gk] = gv.value; + if ( gv.dtype == "Formula" ) itemAttributes.push(`${key}..${k}.${gk}`); + } + } } } @@ -121,6 +131,50 @@ export class SimpleActor extends Actor { }, {}); } + _applyItemsFormulaReplacements(data, itemAttributes, shorthand) { + for ( let k of itemAttributes ) { + // Get the item name and separate the key. + let item = null; + let itemKey = k.split('..'); + item = itemKey[0]; + k = itemKey[1]; + + // Handle group keys. + let gk = null; + if ( k.includes('.') ) { + let attrKey = k.split('.'); + k = attrKey[0]; + gk = attrKey[1]; + } + + let formula = ''; + if ( !!shorthand ) { + // Handle grouped attributes first. + if ( data.items[item][k][gk] ) { + formula = data.items[item][k][gk]; + data.items[item][k][gk] = EntitySheetHelper.replaceData(formula.replace('@item.', `@items.${item}.`), data, {missing: "0"}); + } + // Handle non-grouped attributes. + else if ( data.items[item][k] ) { + formula = data.items[item][k]; + data.items[item][k] = EntitySheetHelper.replaceData(formula.replace('@item.', `@items.${item}.`), data, {missing: "0"}); + } + } + else { + // Handle grouped attributes first. + if ( data.items[item]['attributes'][k][gk] ) { + formula = data.items[item]['attributes'][k][gk]['value']; + data.items[item]['attributes'][k][gk]['value'] = EntitySheetHelper.replaceData(formula.replace('@item.', `@items.${item}.attributes.`), data, {missing: "0"}); + } + // Handle non-grouped attributes. + else if ( data.items[item]['attributes'][k]['value'] ) { + formula = data.items[item]['attributes'][k]['value']; + data.items[item]['attributes'][k]['value'] = EntitySheetHelper.replaceData(formula.replace('@item.', `@items.${item}.attributes.`), data, {missing: "0"}); + } + } + } + } + /** * Apply replacements for derived formula attributes. * @param {Object} data The actor's data object. @@ -140,22 +194,22 @@ export class SimpleActor extends Actor { attr = attrKey[1]; } // Non-grouped attributes. - if ( data.attributes[k].value ) { - data.attributes[k].value = this._replaceData(data.attributes[k].value, data, {missing: "0"}); + if ( data.attributes[k]?.value ) { + data.attributes[k].value = EntitySheetHelper.replaceData(data.attributes[k].value, data, {missing: "0"}); // TODO: Replace with: // data.attributes[k].value = Roll.replaceFormulaData(data.attributes[k].value, data, {missing: "0"}); } // Grouped attributes. else { if ( attr ) { - data.attributes[k][attr].value = this._replaceData(data.attributes[k][attr].value, data, {missing: "0"}); + data.attributes[k][attr].value = EntitySheetHelper.replaceData(data.attributes[k][attr].value, data, {missing: "0"}); } } // Duplicate values to shorthand. if ( !!shorthand ) { // Non-grouped attributes. - if ( data.attributes[k].value ) { + if ( data.attributes[k]?.value ) { data[k] = data.attributes[k].value; } // Grouped attributes. @@ -171,31 +225,4 @@ export class SimpleActor extends Actor { } } } - - /** - * Replace referenced data attributes in the roll formula with the syntax `@attr` with the corresponding key from - * the provided `data` object. This is a temporary helper function that will be replaced with Roll.replaceFormulaData() - * in Foundry 0.7.1. - * - * @param {String} formula The original formula within which to replace. - * @param {Object} data Data object to use for value replacements. - * @param {Object} missing Value to use as missing replacements, such as {missing: "0"}. - * @return {String} The formula with attributes replaced with values. - */ - _replaceData(formula, data, {missing=null}={}) { - // Exit early if the formula is invalid. - if ( typeof formula != "string" ) { - return 0; - } - - // Replace attributes with their numeric equivalents. - let dataRgx = new RegExp(/@([a-z.0-9_\-]+)/gi); - let rollFormula = formula.replace(dataRgx, (match, term) => { - // Replace matches with the value, or the missing value. - let value = getProperty(data, term); - return value ? String(value).trim() : (missing != null ? missing : `@${term}`); - }); - - return rollFormula; - } } diff --git a/module/helper.js b/module/helper.js new file mode 100644 index 0000000..d06504e --- /dev/null +++ b/module/helper.js @@ -0,0 +1,545 @@ +import { ATTRIBUTE_TYPES } from "./constants.js"; + +export class EntitySheetHelper { + + static getAttributeData(data) { + data.dtypes = ATTRIBUTE_TYPES; + + // Determine attribute type. + for ( let attr of Object.values(data.data.attributes) ) { + if ( attr.dtype ) { + attr.isCheckbox = attr.dtype === "Boolean"; + attr.isResource = attr.dtype === "Resource"; + attr.isFormula = attr.dtype === "Formula"; + } + } + + // Initialize ungrouped attributes for later. + data.data.ungroupedAttributes = {}; + + // Build an array of sorted group keys. + const groups = data.data.groups || {}; + let groupKeys = Object.keys(groups).sort((a, b) => { + let aSort = groups[a].label ?? a; + let bSort = groups[b].label ?? b; + return aSort.localeCompare(bSort); + }); + + // Iterate over the sorted groups to add their attributes. + for ( let key of groupKeys ) { + let group = data.data.attributes[key] || {}; + + // Initialize the attributes container for this group. + if ( !data.data.groups[key]['attributes'] ) data.data.groups[key]['attributes'] = {}; + + // Sort the attributes within the group, and then iterate over them. + Object.keys(group).sort((a, b) => a.localeCompare(b)).forEach(attr => { + // Avoid errors if this is an invalid group. + if ( typeof group[attr] != "object" || !group[attr]) return; + // For each attribute, determine whether it's a checkbox or resource, and then add it to the group's attributes list. + group[attr]['isCheckbox'] = group[attr]['dtype'] === 'Boolean'; + group[attr]['isResource'] = group[attr]['dtype'] === 'Resource'; + group[attr]['isFormula'] = group[attr]['dtype'] === 'Formula'; + data.data.groups[key]['attributes'][attr] = group[attr]; + }); + } + + // Sort the remaining attributes attributes. + Object.keys(data.data.attributes).filter(a => !groupKeys.includes(a)).sort((a, b) => a.localeCompare(b)).forEach(key => { + data.data.ungroupedAttributes[key] = data.data.attributes[key]; + }); + + // Modify attributes on items. + if ( data.items ) { + data.items.forEach(item => { + // Iterate over attributes. + for ( let [k, v] of Object.entries(item.data.attributes) ) { + // Grouped attributes. + if ( !v.dtype ) { + for ( let [gk, gv] of Object.entries(v) ) { + if ( gv.dtype ) { + // Add label fallback. + if ( !gv.label ) gv.label = gk; + // Add formula bool. + if ( gv.dtype == "Formula" ) { + gv.isFormula = true; + } + else { + gv.isFormula = false; + } + } + } + } + // Ungrouped attributes. + else { + // Add label fallback. + if ( !v.label ) v.label = k; + // Add formula bool. + if ( v.dtype == "Formula" ) { + v.isFormula = true; + } + else { + v.isFormula = false; + } + } + } + }); + } + } + + /* -------------------------------------------- */ + + /** @override */ + static onSubmit(event) { + // Closing the form/sheet will also trigger a submit, so only evaluate if this is an event. + if ( event.currentTarget ) { + // Exit early if this isn't a named attribute. + if ( event.currentTarget.tagName.toLowerCase() == 'input' && !event.currentTarget.hasAttribute('name')) { + return false; + } + + let attr = false; + // If this is the attribute key, we need to make a note of it so that we can restore focus when its recreated. + const el = event.currentTarget; + if ( el.classList.contains("attribute-key") ) { + let val = el.value; + let oldVal = el.closest(".attribute").dataset.attribute; + let attrError = false; + // Prevent attributes that already exist as groups. + let groups = document.querySelectorAll('.group-key'); + for ( let i = 0; i < groups.length; i++ ) { + if (groups[i].value == val) { + ui.notifications.error(game.i18n.localize("SIMPLE.NotifyAttrDuplicate") + ` (${val})`); + el.value = oldVal; + attrError = true; + break; + } + } + // Handle value and name replacement otherwise. + if ( !attrError ) { + oldVal = oldVal.includes('.') ? oldVal.split('.')[1] : oldVal; + attr = $(el).attr('name').replace(oldVal, val); + } + } + + // Return the attribute key if set, or true to confirm the submission should be triggered. + return attr ? attr : true; + } + } + + /* -------------------------------------------- */ + + /** + * Listen for click events on an attribute control to modify the composition of attributes in the sheet + * @param {MouseEvent} event The originating left click event + * @private + */ + static async onClickAttributeControl(event) { + event.preventDefault(); + const a = event.currentTarget; + const action = a.dataset.action; + + // Perform create and delete actions. + switch ( action ) { + case "create": + EntitySheetHelper.createAttribute(event, this); + break; + case "delete": + EntitySheetHelper.deleteAttribute(event, this); + break; + } + } + + /** + * Listen for click events and modify attribute groups. + * @param {MouseEvent} event The originating left click event + */ + static async onClickAttributeGroupControl(event) { + event.preventDefault(); + const a = event.currentTarget; + const action = a.dataset.action; + + switch ( action ) { + case "create-group": + EntitySheetHelper.createAttributeGroup(event, this); + break; + case "delete-group": + EntitySheetHelper.deleteAttributeGroup(event, this); + break; + } + } + + /* -------------------------------------------- */ + + /** + * Listen for the roll button on attributes. + * @param {MouseEvent} event The originating left click event + */ + static onAttributeRoll(event) { + event.preventDefault(); + const button = event.currentTarget; + const label = button.closest(".attribute").querySelector(".attribute-label")?.value; + const chatLabel = label ?? button.parentElement.querySelector(".attribute-key").value; + const shorthand = game.settings.get("worldbuilding", "macroShorthand"); + const rollData = this.actor.getRollData(); + let formula = button.closest(".attribute").querySelector(".attribute-value")?.value; + + // If there's a formula, attempt to roll it. + if ( formula ) { + // Get the machine safe version of the item name. + let replacement = null; + if ( formula.includes('@item.') && this.item ) { + let itemName = this.item.name.slugify({strict: true}); + replacement = !!shorthand ? `@items.${itemName}.` : `@items.${itemName}.attributes.`; + formula = formula.replace('@item.', replacement); + } + formula = EntitySheetHelper.replaceData(formula, rollData, {missing: null}); + // Replace `@item` shorthand with the item name and make the roll. + let r = new Roll(formula, rollData); + r.roll().toMessage({ + user: game.user._id, + speaker: ChatMessage.getSpeaker({ actor: this.actor }), + flavor: `${chatLabel}` + }); + } + } + + /* -------------------------------------------- */ + + /** + * Return HTML for a new attribute to be applied to the form for submission. + * + * @param {Object} items Keyed object where each item has a "type" and "value" property. + * @param {string} index Numeric index or key of the new attribute. + * @param {string|boolean} group String key of the group, or false. + * + * @returns {string} Html string. + */ + static getAttributeHtml(items, index, group = false) { + // Initialize the HTML. + let result = '
'; + // Iterate over the supplied keys and build their inputs (including whether or not they need a group key). + for (let [key, item] of Object.entries(items)) { + result = result + ``; + } + // Close the HTML and return. + return result + '
'; + } + + /* -------------------------------------------- */ + + /** + * Validate whether or not a group name can be used. + * @param {string} groupName Groupname to validate + * @returns {boolean} + */ + static validateGroup(groupName, entity) { + let groups = Object.keys(entity.object.data.data.groups); + let attributes = Object.keys(entity.object.data.data.attributes).filter(a => !groups.includes(a)); + + // Check for duplicate group keys. + if ( groups.includes(groupName) ) { + ui.notifications.error(game.i18n.localize("SIMPLE.NotifyGroupDuplicate") + ` (${groupName})`); + return false; + } + + // Check for group keys that match attribute keys. + if ( attributes.includes(groupName) ) { + ui.notifications.error(game.i18n.localize("SIMPLE.NotifyGroupAttrDuplicate") + ` (${groupName})`); + return false; + } + + // Check for whitespace or periods. + if ( groupName.match(/[\s|\.]/i) ) { + ui.notifications.error(game.i18n.localize("SIMPLE.NotifyGroupAlphanumeric")); + return false; + } + + return true; + } + + /* -------------------------------------------- */ + + /** + * Create new attributes. + * @param {MouseEvent} event The originating left click event + * @param {Object} app The form application object. + * @private + */ + static async createAttribute(event, app) { + const a = event.currentTarget; + const group = a.dataset.group; + let dtype = a.dataset.dtype; + const attrs = app.object.data.data.attributes; + const groups = app.object.data.data.groups; + const form = app.form; + + // Determine the new attribute key for ungrouped attributes. + let objKeys = Object.keys(attrs).filter(k => !Object.keys(groups).includes(k)); + let nk = Object.keys(attrs).length + 1; + let newValue = `attr${nk}`; + let newKey = document.createElement("div"); + while ( objKeys.includes(newValue) ) { + ++nk; + newValue = `attr${nk}`; + }; + + // Build options for construction HTML inputs. + let htmlItems = { + key: { + type: "text", + value: newValue + } + }; + + // Grouped attributes. + if ( group ) { + objKeys = attrs[group] ? Object.keys(attrs[group]) : []; + nk = objKeys.length + 1; + newValue = `attr${nk}`; + while ( objKeys.includes(newValue) ) { + ++nk; + newValue = `attr${nk}`; + } + + // Update the HTML options used to build the new input. + htmlItems.key.value = newValue; + htmlItems.group = { + type: "hidden", + value: group + }; + htmlItems.dtype = { + type: "hidden", + value: dtype + }; + } + // Ungrouped attributes. + else { + // Choose a default dtype based on the last attribute, fall back to "String". + if (!dtype) { + let lastAttr = document.querySelector('.attributes > .attributes-group .attribute:last-child .attribute-dtype')?.value; + dtype = lastAttr ? lastAttr : "String"; + htmlItems.dtype = { + type: "hidden", + value: dtype + }; + } + } + + // Build the form elements used to create the new grouped attribute. + newKey.innerHTML = EntitySheetHelper.getAttributeHtml(htmlItems, nk, group); + + // Append the form element and submit the form. + newKey = newKey.children[0]; + form.appendChild(newKey); + await app._onSubmit(event); + } + + /** + * Delete an attribute. + * @param {MouseEvent} event The originating left click event + * @param {Object} app The form application object. + * @private + */ + static async deleteAttribute(event, app) { + const a = event.currentTarget; + const li = a.closest(".attribute"); + if ( li ) { + li.parentElement.removeChild(li); + await app._onSubmit(event); + } + } + + /* -------------------------------------------- */ + + /** + * Create new attribute groups. + * @param {MouseEvent} event The originating left click event + * @param {Object} app The form application object. + * @private + */ + static async createAttributeGroup(event, app) { + const a = event.currentTarget; + const form = app.form; + let newValue = $(a).siblings('.group-prefix').val(); + // Verify the new group key is valid, and use it to create the group. + if ( newValue.length > 0 && EntitySheetHelper.validateGroup(newValue, app) ) { + let newKey = document.createElement("div"); + newKey.innerHTML = ``; + // Append the form element and submit the form. + newKey = newKey.children[0]; + form.appendChild(newKey); + await app._onSubmit(event); + } + } + + /** + * Delete an attribute group. + * @param {MouseEvent} event The originating left click event + * @param {Object} app The form application object. + * @private + */ + static async deleteAttributeGroup(event, app) { + const a = event.currentTarget; + let groupHeader = a.closest(".group-header"); + let groupContainer = groupHeader.closest(".group"); + let group = $(groupHeader).find('.group-key'); + // Create a dialog to confirm group deletion. + new Dialog({ + title: game.i18n.localize("SIMPLE.DeleteGroup"), + content: `${game.i18n.localize("SIMPLE.DeleteGroupContent")} ${group.val()}`, + buttons: { + confirm: { + icon: '', + label: game.i18n.localize("Yes"), + callback: async () => { + groupContainer.parentElement.removeChild(groupContainer); + await app._onSubmit(event); + } + }, + cancel: { + icon: '', + label: game.i18n.localize("No"), + } + } + }).render(true); + } + + /* -------------------------------------------- */ + + /** + * Update attributes when updating an actor object. + * + * @param {Object} formData Form data object to modify keys and values for. + * @returns {Object} updated formData object. + */ + static updateAttributes(formData, entity) { + let groupKeys = []; + + // Handle the free-form attributes list + const formAttrs = expandObject(formData).data.attributes || {}; + const attributes = Object.values(formAttrs).reduce((obj, v) => { + let attrs = []; + let group = null; + // Handle attribute keys for grouped attributes. + if ( !v["key"] ) { + attrs = Object.keys(v); + attrs.forEach(attrKey => { + group = v[attrKey]['group']; + groupKeys.push(group); + let attr = v[attrKey]; + let k = v[attrKey]["key"] ? v[attrKey]["key"].trim() : attrKey.trim(); + if ( /[\s\.]/.test(k) ) return ui.notifications.error("Attribute keys may not contain spaces or periods"); + delete attr["key"]; + // Add the new attribute if it's grouped, but we need to build the nested structure first. + if ( !obj[group] ) { + obj[group] = {}; + } + obj[group][k] = attr; + }); + } + // Handle attribute keys for ungrouped attributes. + else { + let k = v["key"].trim(); + if ( /[\s\.]/.test(k) ) return ui.notifications.error("Attribute keys may not contain spaces or periods"); + delete v["key"]; + // Add the new attribute only if it's ungrouped. + if ( !group ) { + obj[k] = v; + } + } + return obj; + }, {}); + + // Remove attributes which are no longer used + for ( let k of Object.keys(entity.object.data.data.attributes) ) { + if ( !attributes.hasOwnProperty(k) ) attributes[`-=${k}`] = null; + } + + // Remove grouped attributes which are no longer used. + for ( let group of groupKeys) { + if ( entity.object.data.data.attributes[group] ) { + for ( let k of Object.keys(entity.object.data.data.attributes[group]) ) { + if ( !attributes[group].hasOwnProperty(k) ) attributes[group][`-=${k}`] = null; + } + } + } + + // Re-combine formData + formData = Object.entries(formData).filter(e => !e[0].startsWith("data.attributes")).reduce((obj, e) => { + obj[e[0]] = e[1]; + return obj; + }, {_id: entity.object._id, "data.attributes": attributes}); + + return formData; + } + + /** + * Update attribute groups when updating an actor object. + * + * @param {Object} formData Form data object to modify keys and values for. + * @returns {Object} updated formData object. + */ + static updateGroups(formData, entity) { + // Handle the free-form groups list + const formGroups = expandObject(formData).data.groups || {}; + const groups = Object.values(formGroups).reduce((obj, v) => { + // If there are duplicate groups, collapse them. + if ( Array.isArray(v["key"]) ) { + v["key"] = v["key"][0]; + } + // Trim and clean up. + let k = v["key"].trim(); + if ( /[\s\.]/.test(k) ) return ui.notifications.error("Group keys may not contain spaces or periods"); + delete v["key"]; + obj[k] = v; + return obj; + }, {}); + + // Remove groups which are no longer used + for ( let k of Object.keys(entity.object.data.data.groups) ) { + if ( !groups.hasOwnProperty(k) ) groups[`-=${k}`] = null; + } + + // Re-combine formData + formData = Object.entries(formData).filter(e => !e[0].startsWith("data.groups")).reduce((obj, e) => { + obj[e[0]] = e[1]; + return obj; + }, {_id: entity.object._id, "data.groups": groups}); + + return formData; + } + + /* -------------------------------------------- */ + + /** + * Replace referenced data attributes in the roll formula with the syntax `@attr` with the corresponding key from + * the provided `data` object. This is a temporary helper function that will be replaced with Roll.replaceFormulaData() + * in Foundry 0.7.1. + * + * @param {String} formula The original formula within which to replace. + * @param {Object} data Data object to use for value replacements. + * @param {Object} missing Value to use as missing replacements, such as {missing: "0"}. + * @return {String} The formula with attributes replaced with values. + */ + static replaceData(formula, data, {missing=null,depth=1}={}) { + // Exit early if the formula is invalid. + if ( typeof formula != "string" || depth < 1) { + return 0; + } + + // Replace attributes with their numeric equivalents. + let dataRgx = new RegExp(/@([a-z.0-9_\-]+)/gi); + let rollFormula = formula.replace(dataRgx, (match, term) => { + // Replace matches with the value, or the missing value. + let value = getProperty(data, term); + value = value ? String(value).trim() : (missing != null ? missing : `@${term}`); + // If there's still an attribute in the returned string, nest it in parentheses so that it's evaluated first in the roll. + value = value && value.includes('@') ? `(${value})` : value; + return value; + }); + + return rollFormula; + } + +} \ No newline at end of file diff --git a/module/item-sheet.js b/module/item-sheet.js index fed2f96..248cd2c 100644 --- a/module/item-sheet.js +++ b/module/item-sheet.js @@ -1,4 +1,4 @@ -import { ATTRIBUTE_TYPES } from "./constants.js"; +import { EntitySheetHelper } from "./helper.js"; /** * Extend the basic ItemSheet with some very simple modifications @@ -7,14 +7,15 @@ import { ATTRIBUTE_TYPES } from "./constants.js"; export class SimpleItemSheet extends ItemSheet { /** @override */ - static get defaultOptions() { - return mergeObject(super.defaultOptions, { - classes: ["worldbuilding", "sheet", "item"], - template: "systems/worldbuilding/templates/item-sheet.html", - width: 520, - height: 480, - tabs: [{navSelector: ".sheet-tabs", contentSelector: ".sheet-body", initial: "description"}] - }); + static get defaultOptions() { + return mergeObject(super.defaultOptions, { + classes: ["worldbuilding", "sheet", "item"], + template: "systems/worldbuilding/templates/item-sheet.html", + width: 520, + height: 480, + tabs: [{navSelector: ".sheet-tabs", contentSelector: ".sheet-body", initial: "description"}], + scrollY: [".attributes"], + }); } /* -------------------------------------------- */ @@ -22,16 +23,34 @@ export class SimpleItemSheet extends ItemSheet { /** @override */ getData() { const data = super.getData(); - data.dtypes = ATTRIBUTE_TYPES; - for ( let attr of Object.values(data.data.attributes) ) { - attr.isCheckbox = attr.dtype === "Boolean"; - attr.isResource = attr.dtype === "Resource"; - } + + // Handle attribute groups. + EntitySheetHelper.getAttributeData(data); + return data; } /* -------------------------------------------- */ + /** @override */ + async _onSubmit(event, {updateData=null, preventClose=false, preventRender=false}={}) { + let attr = EntitySheetHelper.onSubmit(event); + + // Submit the form if attr is true or an attr key. + if ( attr ) { + await super._onSubmit(event, {updateData: updateData, preventClose: preventClose, preventRender: preventRender}); + + // If attr is a key and not just true, set a very short timeout and retrigger focus after the original element is deleted and the new one is inserted. + if ( attr !== true) { + setTimeout(() => { + $(`input[name="${attr}"]`).parents('.attribute').find('.attribute-value').focus(); + }, 10); + } + } + } + + /* -------------------------------------------- */ + /** @override */ setPosition(options={}) { const position = super.setPosition(options); @@ -47,49 +66,26 @@ export class SimpleItemSheet extends ItemSheet { activateListeners(html) { super.activateListeners(html); + // Handle rollable attributes. + html.find(".attributes").on("click", "a.attribute-roll", EntitySheetHelper.onAttributeRoll.bind(this)); + // Everything below here is only needed if the sheet is editable if (!this.options.editable) return; + // Add draggable for macros. + html.find(".attributes a.attribute-roll").each((i, a) => { + a.setAttribute("draggable", true); + a.addEventListener("dragstart", ev => { + let dragData = ev.currentTarget.dataset; + ev.dataTransfer.setData('text/plain', JSON.stringify(dragData)); + }, false); + }); + // Add or Remove Attribute - html.find(".attributes").on("click", ".attribute-control", this._onClickAttributeControl.bind(this)); - } + html.find(".attributes").on("click", ".attribute-control", EntitySheetHelper.onClickAttributeControl.bind(this)); - /* -------------------------------------------- */ - - /** - * Listen for click events on an attribute control to modify the composition of attributes in the sheet - * @param {MouseEvent} event The originating left click event - * @private - */ - async _onClickAttributeControl(event) { - event.preventDefault(); - const a = event.currentTarget; - const action = a.dataset.action; - const attrs = this.object.data.data.attributes; - const form = this.form; - - // Add new attribute - if ( action === "create" ) { - const objKeys = Object.keys(attrs); - let nk = Object.keys(attrs).length + 1; - let newValue = `attr${nk}`; - let newKey = document.createElement("div"); - while ( objKeys.includes(newValue) ) { - ++nk; - newValue = `attr${nk}`; - } - newKey.innerHTML = ``; - newKey = newKey.children[0]; - form.appendChild(newKey); - await this._onSubmit(event); - } - - // Remove existing attribute - else if ( action === "delete" ) { - const li = a.closest(".attribute"); - li.parentElement.removeChild(li); - await this._onSubmit(event); - } + // Add attribute groups. + html.find(".groups").on("click", ".group-control", EntitySheetHelper.onClickAttributeGroupControl.bind(this)); } /* -------------------------------------------- */ @@ -97,28 +93,11 @@ export class SimpleItemSheet extends ItemSheet { /** @override */ _updateObject(event, formData) { - // Handle the free-form attributes list - const formAttrs = expandObject(formData).data.attributes || {}; - const attributes = Object.values(formAttrs).reduce((obj, v) => { - let k = v["key"].trim(); - if ( /[\s\.]/.test(k) ) return ui.notifications.error("Attribute keys may not contain spaces or periods"); - delete v["key"]; - obj[k] = v; - return obj; - }, {}); + // Handle attribute and group updates. + formData = EntitySheetHelper.updateAttributes(formData, this); + formData = EntitySheetHelper.updateGroups(formData, this); - // Remove attributes which are no longer used - for ( let k of Object.keys(this.object.data.data.attributes) ) { - if ( !attributes.hasOwnProperty(k) ) attributes[`-=${k}`] = null; - } - - // Re-combine formData - formData = Object.entries(formData).filter(e => !e[0].startsWith("data.attributes")).reduce((obj, e) => { - obj[e[0]] = e[1]; - return obj; - }, {_id: this.object._id, "data.attributes": attributes}); - - // Update the Item + // Update the Actor with the new form values. return this.object.update(formData); } } diff --git a/templates/actor-sheet.html b/templates/actor-sheet.html index f953b5d..bac456b 100644 --- a/templates/actor-sheet.html +++ b/templates/actor-sheet.html @@ -43,17 +43,34 @@ {{!-- Iterate through all attributes on the item and output buttons for any that are formula. --}}
{{#each item.data.attributes as |itemAttr key|}} - {{#if (eq itemAttr.dtype "Formula")}} - {{!-- Use the items.name.key format for shorthand. --}} - {{#if ../../shorthand}} - - {{!-- Use the items.name.attributes.key.value format otherwise. --}} + {{#if itemAttr.dtype}} + {{#if itemAttr.isFormula}} + {{!-- Use the items.name.key format for shorthand. --}} + {{#if ../../shorthand}} + + {{!-- Use the items.name.attributes.key.value format otherwise. --}} + {{else}} + + {{/if}} + {{/if}} {{else}} - - {{/if}} + {{#each itemAttr as |itemGroupedAttr groupedKey|}} + {{#if itemGroupedAttr.isFormula}} + {{!-- Use the items.name.key format for shorthand. --}} + {{#if ../../../shorthand}} + + {{!-- Use the items.name.attributes.key.value format otherwise. --}} + {{else}} + + {{/if}} + {{/if}} + {{/each}} {{/if}} {{/each}}
diff --git a/templates/item-sheet.html b/templates/item-sheet.html index 9a27bf7..cb0e915 100644 --- a/templates/item-sheet.html +++ b/templates/item-sheet.html @@ -31,16 +31,25 @@ {{!-- Attributes Tab --}}
- - Attribute Key - Value - Label - Data Type - + {{localize "SIMPLE.AttributeKey"}} + {{localize "SIMPLE.AttributeValue"}} + {{localize "SIMPLE.AttributeLabel"}} + {{localize "SIMPLE.AttributeDtype"}} +
{{!-- Render the attribute list partial. --}} - {{> "systems/worldbuilding/templates/parts/sheet-attributes.html" attributes=data.attributes dtypes=dtypes}} + {{> "systems/worldbuilding/templates/parts/sheet-attributes.html" attributes=data.ungroupedAttributes dtypes=dtypes}} + + {{!-- Render the grouped attributes partial and control. --}} +
+ {{> "systems/worldbuilding/templates/parts/sheet-groups.html" attributes=data.groupedAttributes groups=data.groups dtypes=dtypes}} + + +
\ No newline at end of file diff --git a/templates/parts/sheet-attributes.html b/templates/parts/sheet-attributes.html index 764cfdf..983e4e3 100644 --- a/templates/parts/sheet-attributes.html +++ b/templates/parts/sheet-attributes.html @@ -3,7 +3,7 @@ {{#each attributes as |attr key|}}
  • - {{#if (eq attr.dtype "Formula")}} + {{#if attr.isFormula}} {{/if}}