Skip to content
Permalink
Browse files

fix(performance): improve performance of state calculations

* Use jexl's `evalSync` instead of `eval`
* Remove events in favor of computed properties
  • Loading branch information...
anehx committed Jul 22, 2019
1 parent be38c8d commit cb46c5b9c4e1b0bcfa767e88bb1e72b33cc58a19
@@ -1,21 +1,19 @@
import Base from "ember-caluma/lib/base";
import { computed } from "@ember/object";
import { camelize } from "@ember/string";
import { next } from "@ember/runloop";
import { assert } from "@ember/debug";
import { inject as service } from "@ember/service";
import { decodeId } from "ember-caluma/helpers/decode-id";
import { getOwner } from "@ember/application";
import Document from "ember-caluma/lib/document";
import { parseDocument } from "ember-caluma/lib/parsers";
import Evented from "@ember/object/evented";

/**
* Object which represents an answer in context of a field
*
* @class Answer
*/
export default Base.extend(Evented, {
export default Base.extend({
calumaStore: service(),

init() {
@@ -101,8 +99,6 @@ export default Base.extend(Evented, {
this.set(this._valueKey, value);
}

next(this, () => this.trigger("valueChanged", value));

return value;
}
}
@@ -1,5 +1,5 @@
import Base from "ember-caluma/lib/base";
import { computed, get, defineProperty } from "@ember/object";
import { computed, defineProperty } from "@ember/object";
import { assert } from "@ember/debug";
import { getOwner } from "@ember/application";
import { decodeId } from "ember-caluma/helpers/decode-id";
@@ -30,10 +30,10 @@ export default Base.extend({

this._super(...arguments);

this.set("fieldsets", []);

this._createRootForm();
this._createFieldsets();

this._registerFieldHandlers();
},

_createRootForm() {
@@ -57,7 +57,7 @@ export default Base.extend({
);
});

fieldsets.forEach(fieldset => this.fieldsets.push(fieldset));
this.set("fieldsets", fieldsets);
},

/**
@@ -84,7 +84,7 @@ export default Base.extend({
* @property {Fieldset[]} fieldsets
* @accessor
*/
fieldsets: computed(() => []),
fieldsets: null,

/**
* All fields of all fieldsets of this document
@@ -170,64 +170,5 @@ export default Base.extend({
*/
findField(slug) {
return this.fields.find(field => field.question.slug === slug);
},

/**
* Register update handlers for all fields on the document
*
* @method _registerFieldHandlers
* @private
*/
_registerFieldHandlers() {
this.fields.forEach(field => {
// validate all expressions
field._validateExpressions();

// initialize hidden and optional state
field.hiddenTask.perform();
field.optionalTask.perform();

// add handler for visibility and value changes which reruns hidden and
// optional states of fields that depend on the changed field
const refreshDependents = () => {
const hiddenDependents = this.fields.filter(f =>
f.hiddenDependencies.includes(field.question.slug)
);

const optionalDependents = this.fields.filter(f =>
f.optionalDependencies.includes(field.question.slug)
);

hiddenDependents.forEach(f => f.hiddenTask.perform());
optionalDependents.forEach(f => f.optionalTask.perform());
};

// if the field is a form question, the fields of the linked fieldset must
// be updated when the field's hidden state changes
const refreshFieldset = () => {
const fieldsets = this.fieldsets.filter(
fs => fs.form.slug === get(field, "question.subForm.slug")
);

fieldsets.forEach(fs =>
fs.fields.forEach(f => {
f.hiddenTask.perform();
f.optionalTask.perform();
})
);
};

field.on("hiddenChanged", () => {
refreshDependents();
refreshFieldset();
});

if (field.answer) {
// there are fields without an answer (e.g static or form questions)
field.answer.on("valueChanged", () => {
refreshDependents();
});
}
});
}
});
@@ -8,11 +8,7 @@ import { camelize } from "@ember/string";
import { task } from "ember-concurrency";
import { all, resolve } from "rsvp";
import { validate } from "ember-validators";
import Evented from "@ember/object/evented";

import { next } from "@ember/runloop";
import { lastValue } from "ember-caluma/utils/concurrency";
import { getAST, getTransforms } from "ember-caluma/utils/jexl";
import Answer from "ember-caluma/lib/answer";
import Question from "ember-caluma/lib/question";
import { decodeId } from "ember-caluma/helpers/decode-id";
@@ -25,6 +21,7 @@ import saveDocumentFileAnswerMutation from "ember-caluma/gql/mutations/save-docu
import saveDocumentDateAnswerMutation from "ember-caluma/gql/mutations/save-document-date-answer";
import saveDocumentTableAnswerMutation from "ember-caluma/gql/mutations/save-document-table-answer";
import removeAnswerMutation from "ember-caluma/gql/mutations/remove-answer";
import { getAST, getTransforms } from "ember-caluma/utils/jexl";

const TYPE_MAP = {
TextQuestion: "StringAnswer",
@@ -65,7 +62,7 @@ const getDependenciesFromJexl = expression => {
*
* @class Field
*/
export default Base.extend(Evented, {
export default Base.extend({
saveDocumentFloatAnswerMutation,
saveDocumentIntegerAnswerMutation,
saveDocumentStringAnswerMutation,
@@ -196,110 +193,120 @@ export default Base.extend(Evented, {
document: reads("fieldset.document"),

/**
* Boolean which tells whether the question is hidden or not
*
* @property {Boolean} hidden
* @accessor
*/
hidden: lastValue("hiddenTask"),

/**
* Boolean which tells whether the question is optional or not
* (opposite of "required")
* The value of the field
*
* @property {Boolean} optional
* @property {*} value
* @accessor
*/
optional: lastValue("optionalTask"),
value: reads("answer.value"),

/**
* Question slugs that are used in the `isHidden` JEXL expression
* Fields that are referenced in the `isHidden` JEXL expression
*
* If the value or visibility of any of these fields is changed, the JEXL
* If the value or hidden state of any of these fields change, the JEXL
* expression needs to be re-evaluated.
*
* @property {String[]} hiddenDependencies
* @property {Field[]} hiddenDependencies
* @accessor
*/
hiddenDependencies: computed("question.hiddenExpression", function() {
return getDependenciesFromJexl(this.question.hiddenExpression);
}),
hiddenDependencies: computed(
"document.fields.[]",
"question.hiddenExpression",
function() {
return getDependenciesFromJexl(this.question.hiddenExpression).map(
slug => {
const f = this.document.findField(slug);

assert(
`Field for question \`${slug}\` was not found in this document. Please check the \`isHidden\` jexl expression: \`${this.question.hiddenExpression}\`.`,
f
);

return f;
}
);
}
),

/**
* Question slugs that are used in the `isRequired` JEXL expression
* Fields that are referenced in the `isRequired` JEXL expression
*
* If the value or visibility of any of these fields is changed, the JEXL
* If the value or hidden state of any of these fields change, the JEXL
* expression needs to be re-evaluated.
*
* @property {String[]} optionalDependencies
* @property {Field[]} optionalDependencies
* @accessor
*/
optionalDependencies: computed("question.requiredExpression", function() {
return getDependenciesFromJexl(this.question.requiredExpression);
}),
optionalDependencies: computed(
"document.fields.[]",
"question.requiredExpression",
function() {
return getDependenciesFromJexl(this.question.requiredExpression).map(
slug => {
const f = this.document.findField(slug);

assert(
`Field for question \`${slug}\` was not found in this document. Please check the \`isRequired\` jexl expression: \`${this.question.requiredExpression}\`.`,
f
);

return f;
}
);
}
),

/**
* Evaluate the fields hidden state.
* The field's hidden state
*
* A question is hidden if:
* - The form question field of the fieldset is hidden
* - A depending field (used in the expression) is hidden
* - All depending field (used in the expression) are hidden
* - The evaluated `question.isHidden` expression returns `true`
*
* @method hiddenTask.perform
* @return {Boolean}
* @property {Boolean} hidden
*/
hiddenTask: task(function*() {
const fieldsetHidden = getWithDefault(this, "fieldset.field.hidden", false);
const dependingHidden =
this.hiddenDependencies.length &&
this.hiddenDependencies.every(slug =>
fieldIsHidden(this.document.findField(slug))
hidden: computed(
"fieldset.field.hidden",
"hiddenDependencies.@each.{hidden,value}",
function() {
return (
getWithDefault(this, "fieldset.field.hidden", false) ||
(this.hiddenDependencies.length &&
this.hiddenDependencies.every(fieldIsHidden)) ||
this.document.jexl.evalSync(
this.question.hiddenExpression,
this.document.jexlContext
)
);

const hidden =
fieldsetHidden ||
dependingHidden ||
(yield this.document.jexl.eval(
this.question.hiddenExpression,
this.document.jexlContext
));

if (this.get("hiddenTask.lastSuccessful.value") !== hidden) {
next(this, () => this.trigger("hiddenChanged"));
}

return hidden;
}).restartable(),
),

/**
* Evaluate the fields optional state.
* The field's optional state
*
* The field is optional if:
* - The form question field of the fieldset is hidden
* - A depending field (used in the expression) is hidden
* - All depending field (used in the expression) are hidden
* - The evaluated `question.isRequired` expression returns `false`
*
* @method optionalTask.perform
* @return {Boolean}
* @property {Boolean} optional
*/
optionalTask: task(function*() {
const fieldsetHidden = getWithDefault(this, "fieldset.field.hidden", false);
const dependingHidden =
this.optionalDependencies.length &&
this.optionalDependencies.every(slug =>
fieldIsHidden(this.document.findField(slug))
optional: computed(
"fieldset.field.hidden",
"optionalDependencies.@each.{hidden,value}",
function() {
return (
getWithDefault(this, "fieldset.field.hidden", false) ||
(this.optionalDependencies.length &&
this.optionalDependencies.every(fieldIsHidden)) ||
!this.document.jexl.evalSync(
this.question.requiredExpression,
this.document.jexlContext
)
);

return (
fieldsetHidden ||
dependingHidden ||
!(yield this.document.jexl.eval(
this.question.requiredExpression,
this.document.jexlContext
))
);
}).restartable(),
}
),

/**
* Task to save a field. This uses a different mutation for every answer
@@ -631,24 +638,5 @@ export default Base.extend(Evented, {
*/
_validateFormQuestion() {
return resolve(true);
},

/**
* Validate that every dependent field of this field exists in the document
*
* @method _validateExpressions
* @private
*/
_validateExpressions() {
const dependencies = [
...new Set([...this.hiddenDependencies, ...this.optionalDependencies])
];

dependencies.forEach(slug => {
assert(
`Field for question \`${slug}\` was not found in this document. Please check the jexl expressions of the question \`${this.question.slug}\`.`,
this.document.findField(slug)
);
});
}
});

0 comments on commit cb46c5b

Please sign in to comment.
You can’t perform that action at this time.