Skip to content

Commit

Permalink
feat(jexl): allow optional answer transforms with default value
Browse files Browse the repository at this point in the history
This allows the user to write reusable JEXLs using answer transforms
that don't necessarily resolve to a question in a certain form context.
  • Loading branch information
anehx committed Jun 22, 2021
1 parent 20f4e0c commit 1ba4e7e
Show file tree
Hide file tree
Showing 5 changed files with 88 additions and 40 deletions.
28 changes: 3 additions & 25 deletions addon/lib/dependencies.js
@@ -1,4 +1,3 @@
import { assert } from "@ember/debug";
import { computed, get } from "@ember/object";

import { getAST, getTransforms } from "ember-caluma/utils/jexl";
Expand Down Expand Up @@ -47,25 +46,6 @@ export function getDependenciesFromJexl(jexl, expression) {
];
}

/**
* Find a certain field in a document or throw an informative error.
*
* @param {Document} document The document containing the field
* @param {String} slug The question slug of the searched field
* @param {String} expression The expression to append to the error message
* @return {Field} The searched field
*/
export function findField(document, slug, expression) {
const field = document.findField(slug);

assert(
`Field for question \`${slug}\` could not be found in the document \`${document.uuid}\`. Please verify that the jexl expression is correct: \`${expression}\`.`,
field
);

return field;
}

/**
* Computed property to get all nested dependency parents of an expression. A
* nested dependency parent would be a table field that is used with a mapby
Expand Down Expand Up @@ -123,16 +103,14 @@ export function dependencies(
return null;
}

const field = findField(this.document, fieldSlug, expression);
const field = this.document.findField(fieldSlug);

if (!onlyNestedParents && nestedSlug && field.value) {
if (!onlyNestedParents && nestedSlug && field?.value) {
// Get the nested fields from the parents value (rows)
const childFields =
nestedSlug === "__all__"
? field.value.flatMap((row) => row.fields)
: field.value.map((row) =>
findField(row, nestedSlug, expression)
);
: field.value.map((row) => row.findField(nestedSlug));

return [field, ...childFields];
}
Expand Down
22 changes: 18 additions & 4 deletions addon/lib/document.js
Expand Up @@ -121,7 +121,9 @@ export default Base.extend({
jexl: computed(function () {
const documentJexl = new jexl.Jexl();

documentJexl.addTransform("answer", (slug) => this.findAnswer(slug));
documentJexl.addTransform("answer", (slug, defaultValue) =>
this.findAnswer(slug, defaultValue)
);
documentJexl.addTransform("mapby", mapby);
documentJexl.addBinaryOp("intersects", 20, intersects);
documentJexl.addTransform("debug", (any, label = "JEXL Debug") => {
Expand Down Expand Up @@ -171,6 +173,9 @@ export default Base.extend({
if (this.parentDocument) return this.parentDocument.jexlContext;

return {
// JEXL interprets null in an expression as variable instead of a
// primitive. This resolves that issue.
null: null,
form: this.rootForm.slug,
info: {
root: { form: this.rootForm.slug, formMeta: this.rootForm.meta },
Expand Down Expand Up @@ -210,13 +215,22 @@ export default Base.extend({
* Find an answer for a given question slug
*
* @param {String} slug The slug of the question to find the answer for
* @param {*} defaultValue The value that will be returned if the question doesn't exist
* @return {*} The answer to the given question
*/
findAnswer(slug) {
findAnswer(slug, defaultValue) {
const field = this.findField(slug);

if (!field || field.hidden || [undefined, null].includes(field.value)) {
return field?.question.isMultipleChoice ? [] : null;
if (!field) {
if (defaultValue === undefined) {
throw new Error(`Field for question \`${slug}\` could not be found`);
}

return defaultValue;
}

if (field.hidden || [undefined, null].includes(field.value)) {
return field.question.isMultipleChoice ? [] : null;
}

if (field.question.__typename === "TableQuestion") {
Expand Down
46 changes: 36 additions & 10 deletions addon/lib/field.js
Expand Up @@ -525,13 +525,26 @@ export default Base.extend({
"hiddenDependencies.@each.{hidden,value}",
"jexlContext",
"question.isHidden",
"pk",
function () {
return (
this.get("fieldset.field.hidden") ||
if (
this.fieldset.field?.hidden ||
(this.hiddenDependencies.length &&
this.hiddenDependencies.every(fieldIsHidden)) ||
this.document.jexl.evalSync(this.question.isHidden, this.jexlContext)
);
this.hiddenDependencies.every(fieldIsHidden))
) {
return true;
}

try {
return this.document.jexl.evalSync(
this.question.isHidden,
this.jexlContext
);
} catch (error) {
throw new Error(
`Error while evaluating \`isHidden\` expression on field \`${this.pk}\`: ${error.message}`
);
}
}
),

Expand All @@ -551,13 +564,26 @@ export default Base.extend({
"jexlContext",
"optionalDependencies.@each.{hidden,value}",
"question.isRequired",
"pk",
function () {
return (
this.get("fieldset.field.hidden") ||
if (
this.fieldset.field?.hidden ||
(this.optionalDependencies.length &&
this.optionalDependencies.every(fieldIsHidden)) ||
!this.document.jexl.evalSync(this.question.isRequired, this.jexlContext)
);
this.optionalDependencies.every(fieldIsHidden))
) {
return true;
}

try {
return !this.document.jexl.evalSync(
this.question.isRequired,
this.jexlContext
);
} catch (error) {
throw new Error(
`Error while evaluating \`isRequired\` expression on field \`${this.pk}\`: ${error.message}`
);
}
}
),

Expand Down
1 change: 1 addition & 0 deletions tests/unit/lib/document-test.js
Expand Up @@ -237,6 +237,7 @@ module("Unit | Library | document", function (hooks) {
assert.expect(1);

assert.deepEqual(this.document.jexlContext, {
null: null,
form: "form",
info: {
root: { form: "form", formMeta: { "is-top-form": true, level: 0 } },
Expand Down
31 changes: 30 additions & 1 deletion tests/unit/lib/field-test.js
Expand Up @@ -121,14 +121,15 @@ module("Unit | Library | field", function (hooks) {
assert.deepEqual(field.optionalDependencies, [dependentField]);
});

test("computes the correct Jexl context", async function (assert) {
test("computes the correct jexl context", async function (assert) {
assert.expect(1);

const field = this.document
.findField("table")
.value[0].findField("table-form-question");

assert.deepEqual(field.jexlContext, {
null: null,
form: "form",
info: {
form: "table-form",
Expand Down Expand Up @@ -435,6 +436,34 @@ module("Unit | Library | field", function (hooks) {
]);
});

test("it can handle optional 'answer' transforms", async function (assert) {
assert.expect(4);

const field = this.addField({
question: {
__typename: "TextQuestion",
isHidden: "'nonexistent'|answer('default') == 'default'",
isRequired: "false",
},
answer: null,
});

assert.ok(field.hidden);

field.question.set("isHidden", "'nonexistent'|answer(null) == null");
assert.ok(field.hidden);

assert.throws(() => {
field.question.set("isHidden", "'nonexistent'|answer == null");
field.hidden;
}, /(Error while evaluating `isHidden` expression).*(Field for question `nonexistent` could not be found)/);

assert.throws(() => {
field.question.set("isRequired", "'nonexistent'|answer == null");
field.optional;
}, /(Error while evaluating `isRequired` expression).*(Field for question `nonexistent` could not be found)/);
});

module("dependencies", function () {
test("calculates mapby dependencies correctly", async function (assert) {
this.field = this.document.findField("json-dependency");
Expand Down

0 comments on commit 1ba4e7e

Please sign in to comment.