Skip to content

Commit

Permalink
Merge pull request #220 from AtlasOfLivingAustralia/feature/issue216
Browse files Browse the repository at this point in the history
Feature/issue216
  • Loading branch information
salomon-j authored Nov 29, 2023
2 parents 33a76c9 + a950f75 commit c692cc6
Show file tree
Hide file tree
Showing 8 changed files with 486 additions and 115 deletions.
54 changes: 52 additions & 2 deletions grails-app/assets/javascripts/forms-knockout-bindings.js
Original file line number Diff line number Diff line change
Expand Up @@ -679,7 +679,10 @@
var options = _.defaults(valueAccessor() || {}, defaults);

$(element).select2(options).change(function(e) {
model($(element).val());
if (ko.isWritableObservable(model)) { // Don't try and write the value to a computed.
model($(element).val());
}

});

if (options.preserveColumnWidth) {
Expand Down Expand Up @@ -1173,7 +1176,54 @@
});
}); // This is a computed rather than a pureComputed as it has a side effect.
return target;
}
};

ko.bindingHandlers['triggerPrePopulate'] = {
'update': function (element, valueAccessor, allBindings, viewModel, bindingContext) {


var dataModelItem = valueAccessor();
var behaviours = dataModelItem.get('behaviour');
for (var i = 0; i < behaviours.length; i++) {
var behaviour = behaviours[i];

if (behaviour.type == 'pre_populate') {
var config = behaviour.config;
var dataLoaderContext = dataModelItem.context;

var dataLoader = new ecodata.forms.dataLoader(dataLoaderContext, dataModelItem.config);

var dependencyTracker = ko.computed(function () {
dataModelItem(); // register dependency on the observable.
dataLoader.prepop(config).done(function (data) {
data = data || {};
var target = config.target;
if (!target) {
target = viewModel;
}
else {
target = dataModelItem.findNearestByName(target, bindingContext);
}
if (!target) {
throw "Unable to locate target for pre-population: "+target;
}
if (_.isFunction(target.loadData)) {
target.loadData(data);
} else if (_.isFunction(target.load)) {
target.load(data);
} else if (ko.isObservable(target)) {
target(data);
} else {
console.log("Warning: target for pre-populate is invalid");
}

}); // This is a computed rather than a pureComputed as it has a side effect.
});
}
}

}
};

})();

157 changes: 116 additions & 41 deletions grails-app/assets/javascripts/forms.js
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,49 @@ function orEmptyArray(v) {
}
};

/**
* Traverses the model or binding context starting from a nested context and
* working backwards towards the root until a property with the supplied name
* is matched. That property is then passed to the supplied callback.
* Traversing backwards is simpler than forwards as we don't need to take into
* account repeating model values (e.g. for repeating sections and table rows)
* @param targetName the name of the model variable / property to find.
* @param context the starting context
* @param callback a function to invoke when the target variable is found.
*/
ecodata.forms.navigateModel = function(targetName, context, callback) {
if (!context) {
return;
}
if (!_.isUndefined(context[targetName])) {
callback(context[targetName]);
}
// If the context is a knockout binding context, $data will be the current object
// being bound to the view.
else if (context['$data']) {
ecodata.forms.navigateModel(targetName, context['$data'], callback);
}
// The root data model is constructed with fields inside a nested "data" object.
else if (_.isObject(context['data'])) {
ecodata.forms.navigateModel(targetName, context['data'], callback);
}
// Try to evaluate against the parent - the bindingContext uses $parent and the
// ecodata.forms.DataModelItem uses parent
else if (context['$parent']) {
ecodata.forms.navigateModel(targetName, context['$parent'], callback);
}
else if (context['parent']) {
ecodata.forms.navigateModel(targetName, context['parent'], callback);
}
// Try to evaluate against the context - this is setup as a model / binding context
// variable and refers to data external to the form - e.g. the project or activity the
// form is related to.
else if (context['$context']) {
ecodata.forms.navigateModel(targetName, context['$context'], callback);
}

}

/**
* Helper function for evaluating expressions defined in the metadata. These may be used to compute values
* or make decisions on which constraints to apply to individual data model items.
Expand Down Expand Up @@ -253,6 +296,10 @@ function orEmptyArray(v) {
return _.findWhere(list, obj);
};

parser.functions.deepEquals = function(value1, value2) {
return _.isEqual(value1, value2);
};

var specialBindings = function() {

return {
Expand Down Expand Up @@ -296,27 +343,9 @@ function orEmptyArray(v) {
result = specialBindings[contextVariable];
}
else {
if (!_.isUndefined(context[contextVariable])) {
result = ko.utils.unwrapObservable(context[contextVariable]);
}
else {
// The root view model is constructed with fields inside a nested "data" object.
if (_.isObject(context['data'])) {
result = bindVariable(variable, context['data']);
}
// Try to evaluate against the parent
else if (context['$parent']) {
// If the parent is the output model, we want to evaluate against the "data" property
var parentContext = _.isObject(context['$parent'].data) ? context['$parent'].data : context['$parent'];
result = bindVariable(variable, parentContext);
}
// Try to evaluate against the context - used when we are evaluating pre-pop data with a filter
// expression that references a variable in the form context
else if (context['$context']) {
result = bindVariable(variable, context['$context']);
}
}

ecodata.forms.navigateModel(contextVariable, context, function(target) {
result = ko.utils.unwrapObservable(target);
});
}
return _.isUndefined(result) ? null : result;
}
Expand All @@ -334,14 +363,14 @@ function orEmptyArray(v) {
var expressionCache = {};

function evaluateInternal(expression, context) {
var parsedExpression = expressionCache[expression];
if (!parsedExpression) {
parsedExpression = parser.parse(expression);
expressionCache[expression] = parsedExpression;
}
var parsedExpression = expressionCache[expression];
if (!parsedExpression) {
parsedExpression = parser.parse(expression);
expressionCache[expression] = parsedExpression;
}

var variables = parsedExpression.variables();
var boundVariables = bindVariables(variables, context);
var variables = parsedExpression.variables();
var boundVariables = bindVariables(variables, context);

var result;
try {
Expand Down Expand Up @@ -619,16 +648,32 @@ function orEmptyArray(v) {
if (prepopData) {
var result = prepopData;
var mapping = conf.mapping;
if (conf.filter && conf.filter.expression) {
if (!_.isArray(prepopData)) {
throw "Filtering is only supported for array typed prepop data."

function postProcessPrepopData(processingFunction, processingConfig, data) {
if (!_.isArray(data)) {
throw "Filter/find is only supported for array typed prepop data."
}
result = _.filter(result, function(item) {
var expression = conf.filter.expression;
var itemContext = _.extend({}, item, {$context:context, $config:config});
return ecodata.forms.expressionEvaluator.evaluateBoolean(expression, itemContext);
if (!processingConfig.expression) {
throw "Missing expression attribute in configuration"
}
return processingFunction(data, function(item) {
var expression = processingConfig.expression;
var namespace = processingConfig['namespace'] || 'item';
var evalContext = {};
evalContext[namespace] = item;

var evalContext = _.extend(evalContext, {$context:context, $config:config});
return ecodata.forms.expressionEvaluator.evaluateBoolean(expression, evalContext);
});
}

if (conf.filter) {
result = postProcessPrepopData(_.filter, conf.filter, result);
}
else if (conf.find) {
result = postProcessPrepopData(_.find, conf.find, result);
}

if (mapping) {
result = self.map(mapping, result);
}
Expand Down Expand Up @@ -796,16 +841,46 @@ function orEmptyArray(v) {
function buildPrepopConstraints(constraintsConfig, constraintsDeferred) {
var defaultConstraints = constraintsConfig.defaults || [];
var constraintsObservable = ko.observableArray(defaultConstraints);

return ko.computed(function() {
var dataLoaderContext = _.extend({}, context, {$parent:context.parent});
var dataLoader = ecodata.forms.dataLoader(dataLoaderContext, config);
dataLoader.prepop(constraintsConfig.config).done(function (data) {
var dataLoaderContext = _.extend({}, context, {$parent:context.parent});
var dataLoader = ecodata.forms.dataLoader(dataLoaderContext, config);

ko.computed(function() {
var prepopConf = constraintsConfig.config;
// If the prepop needs to post process the data, we need to execute the post processing
// in the context of the computed so as to register any dependencies on values used in
// the post-processing expressions.
// The dataloader won't execute post processing synchronously as retrieving the data can be done
// via a remote call.
if (prepopConf.filter) {
ecodata.forms.expressionEvaluator.evaluate(prepopConf.filter.expression, dataLoaderContext);
}
if (prepopConf.find) {
ecodata.forms.expressionEvaluator.evaluate(prepopConf.filter.expression, dataLoaderContext);
}
dataLoader.prepop(prepopConf).done(function (data) {
constraintsObservable(data);
constraintsDeferred.resolve();
});
return constraintsObservable();
});

return constraintsObservable;
}

/**
* Finds the model attribute with the specified name searching from the context of this
* DataModelItem if no context is supplied.
* This is so that for nested items we can find the nearest neighbour with the specified name. (e.g.
* when the model represents a repeating section or table row)
*/
self.findNearestByName = function(targetName, context) {
if (!context) {
context = self.context;
}
var result = null;
ecodata.forms.navigateModel(targetName, context, function(target) {
result = target;
})
return result;
}

function attachIncludeExclude(constraints) {
Expand Down
76 changes: 75 additions & 1 deletion grails-app/conf/example_models/behavioursExample.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,47 @@
}
}
]
},
{
"dataType": "text",
"name": "item5",
"behaviour": [
{
"config": {
"source": {
"url": "/preview/prepopulate",
"params": [
{
"name": "param",
"type": "computed",
"expression": "item5"
},
{
"name": "item5",
"type": "computed",
"expression": "item5"
}
]
},
"mapping": [
{
"source-path": "param",
"target": "item6"
},
{
"source-path": "item5",
"target": "item5"
}
],
"target": "$data"
},
"type": "pre_populate"
}
]
},
{
"dataType": "text",
"name": "item6"
}
],
"viewModel": [
Expand Down Expand Up @@ -69,7 +110,40 @@
"title": "Item 4"
}
]

},
{
"type": "row",
"items": [
{
"type": "col",
"items": [
{
"type": "literal",
"source": "Note for this example, data entered into item5 will trigger a pre-pop call and be mapped back to item5 and item6. Note that the target of the pre-pop is $data which is the current binding context (or the root object in this case). A current limitation is the load method is used, which means if the pre-pop result does not contain keys for all data in the target object, the data for missing fields will be set to undefined. A planned enhancement is to only replace data where keys in the pre-pop data exist."
}
]
}
]
},
{
"type": "row",
"items": [
{
"type": "col",
"items": [
{
"preLabel": "Item 5",
"source": "item5",
"type": "text"
},
{
"preLabel": "Item 6",
"source": "item6",
"type": "text"
}
]
}
]
}
],
"title": "Behaviours example"
Expand Down
16 changes: 14 additions & 2 deletions grails-app/conf/example_models/constraintsExample.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,19 @@
"type": "pre-populated",
"config": {
"source": {
"url": "/preview/prepopulateConstraints"
"url": "/preview/prepopulateConstraints",
"params" : [
{
"name":"p1",
"value":"1"
},
{
"name": "p2",
"type": "computed",
"expression": "number1"
}

]
}
},
"excludePath": "list.value1"
Expand Down Expand Up @@ -65,7 +77,7 @@
"items": [
{
"type": "literal",
"source": "<p>This example illustrates the use of computed constraints</p><p>The 'Value 2' field will only allow each item in the dropdown to be selected once, no matter how many times 'Value 2' appears on the page.</p><p>For each selection made in a 'Value 2' field, that value will be added as a selectable option in the 'Value 3' field.</p>"
"source": "<p>This example illustrates the use of computed constraints</p><p>The 'Value 2' field will only allow each item in the dropdown to be selected once, no matter how many times 'Value 2' appears on the page.</p><p>For each selection made in a 'Value 2' field, that value will be added as a selectable option in the 'Value 3' field.</p><p>Note also that the constraints for 'value1' include a parameter that references a form variable. When that variable changes, the constraint pre-population is re-executed</p>"
}
]
},
Expand Down
Loading

0 comments on commit c692cc6

Please sign in to comment.