Skip to content

Commit

Permalink
Merge pull request #33834 from dimagi/akj/ush-3855-group-per-row-attr
Browse files Browse the repository at this point in the history
[USH-3855] N-per-row attribute for Groups
  • Loading branch information
Akash Jain committed Jan 23, 2024
2 parents 0dd223e + f91a9e2 commit 332ff2b
Show file tree
Hide file tree
Showing 10 changed files with 170 additions and 70 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ hqDefine("cloudcare/js/form_entry/const", function () {
GROUP_TYPE: 'sub-group',
REPEAT_TYPE: 'repeat-juncture',
QUESTION_TYPE: 'question',
GROUPED_QUESTION_TILE_ROW_TYPE: 'grouped-question-tile-row',
GROUPED_ELEMENT_TILE_ROW_TYPE: 'grouped-element-tile-row',

// Entry types
STRING: 'str',
Expand Down
107 changes: 63 additions & 44 deletions corehq/apps/cloudcare/static/cloudcare/js/form_entry/form_ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ hqDefine("cloudcare/js/form_entry/form_ui", function () {
}

/**
* Base abstract prototype for Repeat, Group, GroupedQuestionTileRow, and Form. Adds methods to
* Base abstract prototype for Repeat, Group, GroupedElementTileRow, and Form. Adds methods to
* objects that contain a children array for rendering nested questions.
* @param {Object} json - The JSON returned from touchforms to represent the container
*/
Expand Down Expand Up @@ -171,7 +171,7 @@ hqDefine("cloudcare/js/form_entry/form_ui", function () {
var self = this;

if (!json.type) {
Container.groupQuestions(json);
Container.groupElements(json);
}

var mapping = {
Expand All @@ -190,8 +190,8 @@ hqDefine("cloudcare/js/form_entry/form_ui", function () {
},
children: {
create: function (options) {
if (options.data.type === constants.GROUPED_QUESTION_TILE_ROW_TYPE) {
return new GroupedQuestionTileRow(options.data, self);
if (options.data.type === constants.GROUPED_ELEMENT_TILE_ROW_TYPE) {
return new GroupedElementTileRow(options.data, self);
} else if (options.data.type === constants.QUESTION_TYPE) {
return new Question(options.data, self);
} else if (options.data.type === constants.GROUP_TYPE) {
Expand Down Expand Up @@ -271,17 +271,18 @@ hqDefine("cloudcare/js/form_entry/form_ui", function () {
};

/**
* Recursively groups sequential "question" items in a nested JSON structure.
* Recursively groups sequential "Question" or "Group" items in a nested JSON structure.
*
* This function takes a JSON object as input and searches for sequential "question"
* items within the 'children' arrays of the input and its nested 'group' objects.
* It groups these sequential "question" items into "GroupedQuestionTileRow" objects while
* maintaining the original structure of the JSON.
* This function takes a JSON object as input and searches for sequential "Question" or "Group"
* items within the 'children' arrays of the input and its nested "Group" objects.
* It groups the sequential "Question" items and "Group"
* items into "GroupedElementTileRow" objects while maintaining the original structure of the JSON.
*
* @param {Object} json - The JSON object to process, containing 'children' arrays.
* @returns {Object} - A new JSON object with sequential "question" items grouped into "GroupedQuestionTileRow".
* @returns {Object} - A new JSON object with sequential "Question" items and sequential
* "Group" items grouped into "GroupedElementTileRow".
*/
Container.groupQuestions = function (json) {
Container.groupElements = function (json) {
if (!json || !json.children || !Array.isArray(json.children)) {
return json;
}
Expand All @@ -293,7 +294,7 @@ hqDefine("cloudcare/js/form_entry/form_ui", function () {
function addToCurrentGroup(child) {
if (!currentGroup) {
currentGroup = {
type: constants.GROUPED_QUESTION_TILE_ROW_TYPE,
type: constants.GROUPED_ELEMENT_TILE_ROW_TYPE,
children: [],
ix: null,
};
Expand All @@ -312,16 +313,19 @@ hqDefine("cloudcare/js/form_entry/form_ui", function () {
}

for (let child of json.children) {
if (child.type === constants.QUESTION_TYPE) {
const questionTileWidth = Question.calculateColumnWidthForPerRowStyle(child.style);
usedWidth += questionTileWidth;
if (child.type === constants.QUESTION_TYPE || child.type === constants.GROUP_TYPE) {
const elementTileWidth = GroupedElementTileRow.calculateElementWidth(child.style);
usedWidth += elementTileWidth;
if (usedWidth > constants.GRID_COLUMNS) {
resetCurrentGroup();
usedWidth += questionTileWidth;
usedWidth += elementTileWidth;
}
if (child.type === constants.GROUP_TYPE) {
child = Container.groupElements(child);
}
addToCurrentGroup(child);
} else if (child.type === constants.GROUP_TYPE || child.type === constants.REPEAT_TYPE) {
const newGroup = Container.groupQuestions(child);
} else if (child.type === constants.REPEAT_TYPE) {
const newGroup = Container.groupElements(child);
newChildren.push(newGroup);
resetCurrentGroup();
} else {
Expand Down Expand Up @@ -575,7 +579,7 @@ hqDefine("cloudcare/js/form_entry/form_ui", function () {
Form.prototype.constructor = Container;

/**
* Represents a group of GroupedQuestionTileRow which contains questions.
* Represents a group of GroupedElementTileRow which contains Question or Group objects.
* @param {Object} json - The JSON returned from touchforms to represent a Form
* @param {Object} parent - The object's parent. Either a Form, Group, or Repeat.
*/
Expand All @@ -586,7 +590,7 @@ hqDefine("cloudcare/js/form_entry/form_ui", function () {

self.groupId = groupNum++;
self.rel_ix = ko.observable(relativeIndex(self.ix()));
self.isRepetition = parent instanceof Repeat;
self.isRepetition = parent.parent instanceof Repeat;
let parentForm = getParentForm(self);
let oneQuestionPerScreen = parentForm.displayOptions.oneQuestionPerScreen !== undefined && parentForm.displayOptions.oneQuestionPerScreen();

Expand Down Expand Up @@ -652,9 +656,9 @@ hqDefine("cloudcare/js/form_entry/form_ui", function () {

self.hasAnyNestedQuestions = function () {
return _.any(self.children(), function (d) {
if (d.type() === constants.QUESTION_TYPE || d.type() === constants.REPEAT_TYPE || d.type() === constants.GROUPED_QUESTION_TILE_ROW_TYPE) {
if (d.type() === constants.REPEAT_TYPE) {
return true;
} else if (d.type() === constants.GROUP_TYPE) {
} else if (d.type() === constants.GROUPED_ELEMENT_TILE_ROW_TYPE) {
return d.hasAnyNestedQuestions();
}
});
Expand All @@ -672,12 +676,17 @@ hqDefine("cloudcare/js/form_entry/form_ui", function () {
}
return Container.prototype.headerBackgroundColor.call(self);
};

let columnWidth = GroupedElementTileRow.calculateElementWidth(this.style);
this.elementTile = `col-sm-${columnWidth}`;
}

Group.prototype = Object.create(Container.prototype);
Group.prototype.constructor = Container;

/**
* Represents a repeat group. A repeat only has Group objects as children. Each child Group contains GroupedQuestionTileRow
* Represents a repeat group. A repeat only has Group objects as children, which are contained
* within a GroupedElementTileRow. Each child Group contains GroupedElementTileRow
* objects which contains the child questions to be rendered
* @param {Object} json - The JSON returned from touchforms to represent a Form
* @param {Object} parent - The object's parent. Either a Form, Group, or Repeat.
Expand Down Expand Up @@ -708,25 +717,50 @@ hqDefine("cloudcare/js/form_entry/form_ui", function () {
Repeat.prototype.constructor = Container;

/**
* Represents a group of questions. Questions are grouped such that all questions are
* Represents a group of Questions. Questions are grouped such that all questions are
* contained in the same row.
* @param {Object} json - The JSON returned from touchforms to represent a Form
* @param {Object} parent - The object's parent. Either a Form, Group, or Repeat.
*/
function GroupedQuestionTileRow(json, parent) {
function GroupedElementTileRow(json, parent) {
var self = this;
self.parent = parent;
Container.call(self, json);

self.hasAnyNestedQuestions = function () {
return _.any(self.children(), function (d) {
if (d.type() === constants.QUESTION_TYPE) {
return true;
} else if (d.type() === constants.GROUP_TYPE) {
return d.hasAnyNestedQuestions();
}
});
};

self.required = ko.observable(0);
self.childrenRequired = ko.computed(function () {
return _.find(self.children(), function (child) {
return child.required();
return child.required() || child.childrenRequired && child.childrenRequired();
});
});
}
GroupedQuestionTileRow.prototype = Object.create(Container.prototype);
GroupedQuestionTileRow.prototype.constructor = Container;
GroupedElementTileRow.prototype = Object.create(Container.prototype);
GroupedElementTileRow.prototype.constructor = Container;

/**
* Matches "<n>-per-row" style attributes. If a match if found, it calculates the column width
* based on Bootstrap's 12 column grid system and returns the column width.
* @param {Object} style - the appearance attributes
*/
GroupedElementTileRow.calculateElementWidth = function (style) {
const styleStr = (style) ? ko.utils.unwrapObservable(style.raw) : null;
const perRowPattern = new RegExp(`\\d+${constants.PER_ROW}(\\s|$)`);
const matchingPerRowStyles = getMatchingStyles(perRowPattern, styleStr);
const perRowStyle = matchingPerRowStyles.length === 0 ? null : matchingPerRowStyles[0];
const itemsPerRow = perRowStyle !== null ? parseInt(perRowStyle.split("-")[0], 10) : null;

return itemsPerRow !== null ? Math.round(constants.GRID_COLUMNS / itemsPerRow) : constants.GRID_COLUMNS;
};

/**
* Represents a Question. A Question contains an Entry which is the widget that is displayed for that question
Expand Down Expand Up @@ -892,7 +926,7 @@ hqDefine("cloudcare/js/form_entry/form_ui", function () {

Question.prototype.setWidths = function (hasLabel) {
const self = this;
const columnWidth = Question.calculateColumnWidthForPerRowStyle(self.style);
const columnWidth = GroupedElementTileRow.calculateElementWidth(self.style);
const perRowPattern = new RegExp(`\\d+${constants.PER_ROW}(\\s|$)`);

if (self.stylesContains(perRowPattern)) {
Expand All @@ -915,21 +949,6 @@ hqDefine("cloudcare/js/form_entry/form_ui", function () {
}
};

/**
* Matches "<n>-per-row" style attributes. If a match if found, it calculates the column width
* based on Bootstrap's 12 column grid system and returns the column width.
* @param {Object} style - the appearance attributes
*/
Question.calculateColumnWidthForPerRowStyle = function (style) {
const styleStr = (style) ? ko.utils.unwrapObservable(style.raw) : null;
const perRowPattern = new RegExp(`\\d+${constants.PER_ROW}(\\s|$)`);
const matchingPerRowStyles = getMatchingStyles(perRowPattern, styleStr);
const perRowStyle = matchingPerRowStyles.length === 0 ? null : matchingPerRowStyles[0];
const itemsPerRow = perRowStyle !== null ? parseInt(perRowStyle.split("-")[0], 10) : null;

return itemsPerRow !== null ? Math.round(constants.GRID_COLUMNS / itemsPerRow) : constants.GRID_COLUMNS;
};

return {
getIx: getIx,
getForIx: getForIx,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,11 @@ hqDefine("cloudcare/js/form_entry/spec/form_ui_spec", function () {
// Each repeat is a group with questions
assert.equal(form.children()[0].type(), constants.REPEAT_TYPE);
assert.equal(form.children()[0].children().length, 1);
assert.equal(form.children()[0].children()[0].type(), constants.GROUP_TYPE);
assert.isTrue(form.children()[0].children()[0].isRepetition);
assert.equal(form.children()[0].children()[0].children()[0].type(), constants.GROUPED_QUESTION_TILE_ROW_TYPE);
assert.equal(form.children()[0].children()[0].children()[0].children()[0].type(), constants.QUESTION_TYPE);
assert.equal(form.children()[0].children()[0].type(), constants.GROUPED_ELEMENT_TILE_ROW_TYPE);
assert.equal(form.children()[0].children()[0].children()[0].type(), constants.GROUP_TYPE);
assert.isTrue(form.children()[0].children()[0].children()[0].isRepetition);
assert.equal(form.children()[0].children()[0].children()[0].children()[0].type(), constants.GROUPED_ELEMENT_TILE_ROW_TYPE);
assert.equal(form.children()[0].children()[0].children()[0].children()[0].children()[0].type(), constants.QUESTION_TYPE);
});

it('Should render questions grouped by row', function () {
Expand Down Expand Up @@ -105,14 +106,69 @@ hqDefine("cloudcare/js/form_entry/spec/form_ui_spec", function () {
formJSON.tree = [q0, g0, q1, q2, q3];
let form = formUI.Form(formJSON);

// Expected structure (where gq signifies type "grouped-question-tile-row")
assert.equal(form.children().length, 4); // [gq, g, gq, gq]
// Expected structure (where ge signifies type "grouped-element-tile-row")
assert.equal(form.children().length, 4); // [ge, g, ge, ge]
assert.equal(form.children()[0].children().length, 1); // [q0]
assert.equal(form.children()[1].children()[0].children()[0].children().length, 2); // [q(ix=2,3), q(ix=2,4)]
assert.equal(form.children()[1].children()[0].children()[0].children()[0].children()[0].children().length, 2); // [q(ix=2,3), q(ix=2,4)]
assert.equal(form.children()[2].children().length, 2); // [q1, q2]
assert.equal(form.children()[3].children().length, 1); // [q3]
});

it('Should render groups and question grouped by row', function () {
let styleObj = {raw: '3-per-row'};
let styleObj2 = {raw: '2-per-row'};

let g0 = fixtures.groupJSON({
style: styleObj,
ix: "0",
});
let g1 = fixtures.groupJSON({
style: styleObj,
ix: "1",
});
let q2 = fixtures.labelJSON({
style: styleObj,
ix: "3",
});

g0.children[0].children[0].style = styleObj2;
g0.children[0].children[1].style = styleObj2;

formJSON.tree = [g0,g1,q2];
let form = formUI.Form(formJSON);

/* Group-Element-Tile-Row
-Group
-Group-Element-Tile-Row
-Group
-Group-Element-Tile-Row
-Question
-Question
-Group
-Group-Element-Tile-Row
-Group
-Group-Element-Tile-Row
-Question
-Group-Element-Tile-Row
-Question
-Question
*/

// Expected structure (where ge signifies type "grouped-element-tile-row")
assert.equal(form.children().length, 1); // [ge]
assert.equal(form.children()[0].children().length, 3); // [g0,g1,q2]
assert.equal(form.children()[0].children()[0].children().length, 1); // [ge]
assert.equal(form.children()[0].children()[0].children()[0].children().length, 1); // [group]
assert.equal(form.children()[0].children()[0].children()[0].children()[0].children().length, 1); // [ge]
assert.equal(form.children()[0].children()[0].children()[0].children()[0].children()[0].children().length, 2); // [q,q]

assert.equal(form.children()[0].children()[1].children().length, 1); // [ge]
assert.equal(form.children()[0].children()[1].children()[0].children().length, 1); // [group]
assert.equal(form.children()[0].children()[1].children()[0].children()[0].children().length, 2); // [ge,ge]
assert.equal(form.children()[0].children()[1].children()[0].children()[0].children()[0].children().length, 1); // [q]
assert.equal(form.children()[0].children()[1].children()[0].children()[0].children()[1].children().length, 1); // [q]
});

it('Should calculate nested background header color', function () {
let styleObj = {raw: 'group-collapse'};
let g0 = fixtures.groupJSON({
Expand All @@ -123,7 +179,7 @@ hqDefine("cloudcare/js/form_entry/spec/form_ui_spec", function () {
});
let r1 = fixtures.repeatNestJSON();
g1.children[0].style = styleObj;
r1.children[0].style = styleObj;
r1.children[0].children[0].style = styleObj;
g1.children[0].children.push(r1);
g0.children[0].children.push(g1);

Expand All @@ -142,12 +198,12 @@ hqDefine("cloudcare/js/form_entry/spec/form_ui_spec", function () {
formJSON.tree = [g0];
let form = formUI.Form(formJSON);

assert.equal(form.children()[0].headerBackgroundColor(), '#002f71'); //[g0]
assert.equal(form.children()[0].children()[0].headerBackgroundColor(), ''); //[g0-0]
assert.equal(form.children()[0].children()[0].children()[2].headerBackgroundColor(), '#003e96'); //[g1]
assert.equal(form.children()[0].children()[0].children()[2].children()[0].headerBackgroundColor(), '#004EBC'); //[g1-0]
assert.equal(form.children()[0].children()[0].children()[2].children()[0].children()[2].headerBackgroundColor(), '#002f71'); //[r1]
assert.equal(form.children()[0].children()[0].children()[2].children()[0].children()[2].children()[0].headerBackgroundColor(), ''); //[r1-0]
assert.equal(form.children()[0].children()[0].headerBackgroundColor(), '#002f71'); //[g0]
assert.equal(form.children()[0].children()[0].children()[0].children()[0].headerBackgroundColor(), ''); //[g0-0]
assert.equal(form.children()[0].children()[0].children()[0].children()[0].children()[2].children()[0].headerBackgroundColor(), '#003e96'); //[g1]
assert.equal(form.children()[0].children()[0].children()[0].children()[0].children()[2].children()[0].children()[0].children()[0].headerBackgroundColor(), '#004EBC'); //[g1-0]
assert.equal(form.children()[0].children()[0].children()[0].children()[0].children()[2].children()[0].children()[0].children()[0].children()[2].headerBackgroundColor(), '#002f71'); //[r1]
assert.equal(form.children()[0].children()[0].children()[0].children()[0].children()[2].children()[0].children()[0].children()[0].children()[2].children()[0].children()[0].headerBackgroundColor(), ''); //[r1-0]
});

it('Should reconcile question choices', function () {
Expand Down Expand Up @@ -265,8 +321,8 @@ hqDefine("cloudcare/js/form_entry/spec/form_ui_spec", function () {

it('Should find nested questions', function () {
var form = formUI.Form(nestedGroupJSON);
assert.isTrue(form.children()[0].hasAnyNestedQuestions());
assert.isFalse(form.children()[1].hasAnyNestedQuestions());
assert.isTrue(form.children()[0].children()[0].hasAnyNestedQuestions());
assert.isFalse(form.children()[1].children()[0].hasAnyNestedQuestions());
});

it('Should not reconcile outdated data', function () {
Expand Down

0 comments on commit 332ff2b

Please sign in to comment.