Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/issue216 #220

Merged
merged 11 commits into from
Nov 29, 2023
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
Loading