From ce79597d948f98e1a0fdf503d7c312d80bf579ce Mon Sep 17 00:00:00 2001 From: Clint Tseng Date: Mon, 24 Jul 2017 16:33:24 -0700 Subject: [PATCH] convert/new: add support for outputting cascading selects. * see opendatakit/build#162 for details. --- lib/convert.js | 54 +++++++++++++-- spec/src/convert-form-spec.ls | 107 ++++++++++++++++++++++++++++++ spec/src/convert-question-spec.ls | 31 +++++++++ src/convert.ls | 31 ++++++++- 4 files changed, 216 insertions(+), 7 deletions(-) diff --git a/lib/convert.js b/lib/convert.js index e433325..d6942b2 100644 --- a/lib/convert.js +++ b/lib/convert.js @@ -20,7 +20,7 @@ return value; } }; - surveyFields = ['type', 'name', 'label', 'hint', 'required', 'read_only', 'default', 'constraint', 'constraint_message', 'relevant', 'calculation', 'parameters', 'appearance']; + surveyFields = ['type', 'name', 'label', 'hint', 'required', 'read_only', 'default', 'constraint', 'constraint_message', 'relevant', 'calculation', 'choice_filter', 'parameters', 'appearance']; choicesFields = ['list name', 'name', 'label']; multilingualFields = ['label', 'hint', 'constraint_message']; pruneFalse = ['required', 'read_only', 'range', 'length', 'count']; @@ -85,7 +85,7 @@ return [self + " >" + (range.minInclusive === true ? '=' : '') + " " + exprValue(range.min), self + " <" + (range.maxInclusive === true ? '=' : '') + " " + exprValue(range.max)]; }; convertQuestion = function(question, context, prefix){ - var choiceId, frm, ref$, too, ref1$, key, value, length, range, count, successorRelevance, other, selectRange, destination, res$, i$, len$, child, this$ = this; + var choiceId, frm, ref$, too, ref1$, key, value, length, range, count, successorRelevance, other, name, i$, len$, option, res$, j$, len1$, idx, selectRange, destination, child, this$ = this; prefix == null && (prefix = []); question = deepcopy(question); prefix = prefix.concat([question.name]); @@ -153,6 +153,32 @@ })( other)); } + if (question.cascading === true || context.cascade != null) { + context.cascade == null && (context.cascade = []); + question.choice_filter = (function(){ + var i$, ref$, len$, results$ = []; + for (i$ = 0, len$ = (ref$ = context.cascade).length; i$ < len$; ++i$) { + name = ref$[i$]; + results$.push(name + " = ${" + name + "}"); + } + return results$; + }()).join(' and '); + for (i$ = 0, len$ = (ref$ = question.options).length; i$ < len$; ++i$) { + option = ref$[i$]; + res$ = {}; + for (j$ = 0, len1$ = (ref1$ = option.cascade).length; j$ < len1$; ++j$) { + idx = j$; + value = ref1$[j$]; + res$[context.cascade[idx]] = value; + } + option.cascade = res$; + } + context.cascade.push(question.name); + if (question.cascading !== true) { + delete context.cascade; + } + delete question.cascading; + } if (question.options != null) { if (context.choices[choiceId] != null) { context.warnings = context.warnings.concat(["Multiple choice lists have the ID '" + choiceId + "'. The last one encountered is used."]); @@ -257,7 +283,7 @@ return result; }; convertForm = function(form){ - var context, intermediate, res$, i$, ref$, len$, question, languages, languageNames, language, seenFields, choices, warnings, expandLanguages, genSchema, surveySchema, choicesSchema, genLang, surveySimpleFields, genRows, surveyRows, choicesRows, name, entries, entry, warning, this$ = this; + var context, intermediate, res$, i$, ref$, len$, question, languages, languageNames, language, seenFields, choices, warnings, expandLanguages, genSchema, surveySchema, choicesSchema, genLang, surveySimpleFields, genRows, surveyRows, additionalChoiceCols, _, entries, key, pullCascadeValues, choicesRows, name, entry, warning, this$ = this; context = newContext(); res$ = []; for (i$ = 0, len$ = (ref$ = form.controls).length; i$ < len$; ++i$) { @@ -340,12 +366,32 @@ } }; surveyRows = genRows(intermediate); + additionalChoiceCols = []; + for (_ in choices) { + entries = choices[_]; + if (((ref$ = entries[0]) != null ? ref$.cascade : void 8) != null) { + for (key in entries[0].cascade) { + if (!in$(key, additionalChoiceCols)) { + additionalChoiceCols.push(key); + } + } + } + } + choicesSchema = choicesSchema.concat(additionalChoiceCols); + pullCascadeValues = function(entry){ + var i$, ref$, len$, col, results$ = []; + for (i$ = 0, len$ = (ref$ = additionalChoiceCols).length; i$ < len$; ++i$) { + col = ref$[i$]; + results$.push(entry.cascade[col]); + } + return results$; + }; res$ = []; for (name in choices) { entries = choices[name]; for (i$ = 0, len$ = entries.length; i$ < len$; ++i$) { entry = entries[i$]; - res$.push([name, entry.val].concat(genLang(entry.text))); + res$.push([name, entry.val].concat(genLang(entry.text), pullCascadeValues(entry))); } } choicesRows = res$; diff --git a/spec/src/convert-form-spec.ls b/spec/src/convert-form-spec.ls index 5d3319c..ef20bc4 100644 --- a/spec/src/convert-form-spec.ls +++ b/spec/src/convert-form-spec.ls @@ -226,6 +226,113 @@ describe 'row generation:' -> [ \choices_testselect, \delta, undefined, undefined ] ]) + test 'single cascading select', -> + result = convert-form( + metadata: + activeLanguages: { 0: \en, _counter: 0 } + controls: [ + { type: \inputSelectOne, name: \universe, cascading: true, options: [ + { val: \known, cascade: [], text: { 0: 'Known' } } + ] } + { type: \inputSelectOne, name: \galaxy, cascading: true, options: [ + { val: \milkyway, cascade: [ \known ], text: { 0: 'Milky Way' } } + { val: \andromeda, cascade: [ \known ], text: { 0: 'Andromeda' } } + ] } + { type: \inputSelectOne, name: \star, options: [ + { val: \sol, cascade: [ \known, \milkyway ], text: { 0: 'Sol' } } + { val: \an, cascade: [ \known, \andromeda ], text: { 0: 'AN And' } } + ] } + ] + ) + + expect(result[1].data).toEqual([ + [ 'list name', \name, \label::en, \universe, \galaxy ] + [ \choices_universe, \known, 'Known', undefined, undefined ] + [ \choices_galaxy, \milkyway, 'Milky Way', \known, undefined ] + [ \choices_galaxy, \andromeda, 'Andromeda', \known, undefined ] + [ \choices_star, \sol, 'Sol', \known, \milkyway ] + [ \choices_star, \an, 'AN And', \known, \andromeda ] + ]) + + test 'single multilingual cascading select', -> + result = convert-form( + metadata: + activeLanguages: { 0: \en, 1: \nyan, _counter: 0 } + controls: [ + { type: \inputSelectOne, name: \universe, cascading: true, options: [ + { val: \known, cascade: [], text: { 0: 'Known', 1: 'Nyown' } } + ] } + { type: \inputSelectOne, name: \galaxy, cascading: true, options: [ + { val: \milkyway, cascade: [ \known ], text: { 0: 'Milky Way', 1: 'Milky Nyan' } } + { val: \andromeda, cascade: [ \known ], text: { 0: 'Andromeda', 1: 'Nyandromeda' } } + ] } + { type: \inputSelectOne, name: \star, options: [ + { val: \sol, cascade: [ \known, \milkyway ], text: { 0: 'Sol', 1: 'Nyan Sol' } } + { val: \an, cascade: [ \known, \andromeda ], text: { 0: 'AN And', 1: 'NyAN NyAnd' } } + ] } + ] + ) + + expect(result[1].data).toEqual([ + [ 'list name', \name, \label::en, \label::nyan, \universe, \galaxy ] + [ \choices_universe, \known, 'Known', 'Nyown', undefined, undefined ] + [ \choices_galaxy, \milkyway, 'Milky Way', 'Milky Nyan', \known, undefined ] + [ \choices_galaxy, \andromeda, 'Andromeda', 'Nyandromeda', \known, undefined ] + [ \choices_star, \sol, 'Sol', 'Nyan Sol', \known, \milkyway ] + [ \choices_star, \an, 'AN And', 'NyAN NyAnd', \known, \andromeda ] + ]) + + test 'multiple cascading selects', -> + result = convert-form( + metadata: + activeLanguages: { 0: \en, _counter: 0 } + controls: [ + { type: \inputSelectOne, name: \universe, cascading: true, options: [ + { val: \known, cascade: [], text: { 0: 'Known' } } + ] } + { type: \inputSelectOne, name: \galaxy, cascading: true, options: [ + { val: \milkyway, cascade: [ \known ], text: { 0: 'Milky Way' } } + { val: \andromeda, cascade: [ \known ], text: { 0: 'Andromeda' } } + ] } + { type: \inputSelectOne, name: \star, options: [ + { val: \sol, cascade: [ \known, \milkyway ], text: { 0: 'Sol' } } + { val: \an, cascade: [ \known, \andromeda ], text: { 0: 'AN And' } } + ] } + + { type: \inputSelectOne, name: \kingdom, cascading: true, options: [ + { val: \animalia, cascade: [], text: { 0: 'Animalia' } } + { val: \plantae, cascade: [], text: { 0: 'Plantae' } } + ] } + { type: \inputSelectOne, name: \phylum, cascading: true, options: [ + { val: \arthropoda, cascade: [ \animalia ], text: { 0: 'Arthropoda' } } + { val: \chordata, cascade: [ \animalia ], text: { 0: 'Chordata' } } + { val: \magnoliophyta, cascade: [ \plantae ], text: { 0: 'Magnoliophyta' } } + ] } + { type: \inputSelectOne, name: \class, options: [ + { val: \insecta, cascade: [ \animalia, \arthropoda ], text: { 0: 'Insecta' } } + { val: \mammalia, cascade: [ \animalia, \chordata ], text: { 0: 'Mammalia' } } + { val: \magnoliopsida, cascade: [ \plantae, \magnoliophyta ], text: { 0: 'Magnoliopsida' } } + ] } + ] + ) + + expect(result[1].data).toEqual([ + [ 'list name', \name, \label::en, \universe, \galaxy, \kingdom, \phylum ] + [ \choices_universe, \known, 'Known', undefined, undefined, undefined, undefined ] + [ \choices_galaxy, \milkyway, 'Milky Way', \known, undefined, undefined, undefined ] + [ \choices_galaxy, \andromeda, 'Andromeda', \known, undefined, undefined, undefined ] + [ \choices_star, \sol, 'Sol', \known, \milkyway, undefined, undefined ] + [ \choices_star, \an, 'AN And', \known, \andromeda, undefined, undefined ] + [ \choices_kingdom, \animalia, 'Animalia', undefined, undefined, undefined, undefined ] + [ \choices_kingdom, \plantae, 'Plantae', undefined, undefined, undefined, undefined ] + [ \choices_phylum, \arthropoda, 'Arthropoda', undefined, undefined, \animalia, undefined ] + [ \choices_phylum, \chordata, 'Chordata', undefined, undefined, \animalia, undefined ] + [ \choices_phylum, \magnoliophyta, 'Magnoliophyta', undefined, undefined, \plantae, undefined ] + [ \choices_class, \insecta, 'Insecta', undefined, undefined, \animalia, \arthropoda ] + [ \choices_class, \mammalia, 'Mammalia', undefined, undefined, \animalia, \chordata ] + [ \choices_class, \magnoliopsida, 'Magnoliopsida', undefined, undefined, \plantae, \magnoliophyta ] + ]) + describe 'complex row generation' -> test 'generates begin and end rows for groups' -> result = convert-form( diff --git a/spec/src/convert-question-spec.ls b/spec/src/convert-question-spec.ls index c3a8505..9556518 100644 --- a/spec/src/convert-question-spec.ls +++ b/spec/src/convert-question-spec.ls @@ -405,6 +405,37 @@ describe 'options' -> # TODO: no test for choice id collision. +describe 'cascading' -> + test 'cascading selects generate choice_filters' -> + context = new-context() + first = convert-question({ type: \inputSelectOne, name: \universe, cascading: true, options: [] }, context) + expect(first.choice_filter).toBe('') + second = convert-question({ type: \inputSelectOne, name: \galaxy, cascading: true, options: [] }, context) + expect(second.choice_filter).toBe('universe = ${universe}') + third = convert-question({ type: \inputSelectOne, name: \star, options: [] }, context) + expect(third.choice_filter).toBe('universe = ${universe} and galaxy = ${galaxy}') + + test 'cascade ends appropriately' -> + context = new-context() + convert-question({ type: \inputSelectOne, name: \universe, cascading: true, options: [] }, context) + convert-question({ type: \inputSelectOne, name: \galaxy, cascading: true, options: [] }, context) + convert-question({ type: \inputSelectOne, name: \star, options: [] }, context) + innocent = convert-question({ type: \inputSelectOne, name: \something_else, options: [] }, context) + expect(innocent.choice_filter).toBe(undefined) + + test 'cascade options are reformatted to dict lookups' -> + context = new-context() + convert-question({ type: \inputSelectOne, name: \universe, cascading: true, options: [] }, context) + convert-question({ type: \inputSelectOne, name: \galaxy, cascading: true, options: [] }, context) + convert-question({ type: \inputSelectOne, name: \star, options: [ + { val: \sol, cascade: [ \known, \milkyway ], text: {} } + { val: \alphacentauri, cascade: [ \known, \milkyway ], text: {} } + ] }, context) + expect(context.choices.choices_star).toEqual([ + { val: \sol, cascade: { universe: \known, galaxy: \milkyway }, text: {} } + { val: \alphacentauri, cascade: { universe: \known, galaxy: \milkyway }, text: {} } + ]) + # questions nested in groups are recursively processed: describe 'group children' -> # use required flag mutation as a sign that processing happened. diff --git a/src/convert.ls b/src/convert.ls index 52df457..d755d52 100644 --- a/src/convert.ls +++ b/src/convert.ls @@ -12,7 +12,7 @@ expr-value = (value) -> | otherwise => value # conversion constants. -survey-fields = <[ type name label hint required read_only default constraint constraint_message relevant calculation parameters appearance ]> +survey-fields = <[ type name label hint required read_only default constraint constraint_message relevant calculation choice_filter parameters appearance ]> choices-fields = [ 'list name', \name, \label ] multilingual-fields = <[ label hint constraint_message ]> # these fields have ::lang syntax/support. @@ -124,6 +124,23 @@ convert-question = (question, context, prefix = []) -> if (other = delete question.other)? context.successor-relevance = other |> map(-> "selected(#{question.name}, '#it')") |> join(' or ') + # deal with cascades. + if (question.cascading is true) or context.cascade? + context.cascade ?= [] + + # add a choice filter column value. + question.choice_filter = [ "#name = ${#name}" for name in context.cascade ].join(' and ') + + # munge our options to have cascade dicts rather than arrays. + for option in question.options + option.cascade = { [ context.cascade[idx], value ] for value, idx in option.cascade } + + # push our context now that we are done. drop the whole thing if we are at the tail. + context.cascade.push(question.name) + if question.cascading isnt true + delete context.cascade + delete question.cascading + # deal with choices. life is hard. if question.options? context.warnings ++= [ "Multiple choice lists have the ID '#choice-id'. The last one encountered is used." ] if context.choices[choice-id]? @@ -246,8 +263,16 @@ convert-form = (form) -> [ [ (if field in multilingual-fields then gen-lang(question[field]) else question[field]) for field in survey-simple-fields ] |> flatten ] survey-rows = gen-rows(intermediate) - # choices serialize straight out. - choices-rows = [ [ name, entry.val ] ++ gen-lang(entry.text) for name, entries of choices for entry in entries ] + # choices might gain columns if cascades are involved. + additional-choice-cols = [] + for _, entries of choices when entries[0]?.cascade? + for key of entries[0].cascade when key not in additional-choice-cols + additional-choice-cols.push(key) + choices-schema ++= additional-choice-cols + + # once we know the additional fields we can send them all out. + pull-cascade-values = (entry) -> [ entry.cascade[col] for col in additional-choice-cols ] + choices-rows = [ [ name, entry.val ] ++ gen-lang(entry.text) ++ pull-cascade-values(entry) for name, entries of choices for entry in entries ] # return sheets. [