Skip to content

Commit

Permalink
[new] implement nested and ordered choices
Browse files Browse the repository at this point in the history
* adds supports for an array-based choice format to supported ordered
choices
* allows nesting in both object and array based choice formats
* available nesting levels are defined by widget
* describe choice formats in readme
* add tests for new formats and nesting
  • Loading branch information
kmohrf authored and ljharb committed Oct 21, 2016
1 parent 571d84c commit 70f5c5f
Show file tree
Hide file tree
Showing 3 changed files with 301 additions and 74 deletions.
37 changes: 36 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -308,14 +308,49 @@ for highly customised markup.
* ``validators`` - An array of functions which validate the field data
* ``widget`` - A widget object to use when rendering the field
* ``id`` - An optional id to override the default
* ``choices`` - A list of options, used for multiple choice fields
* ``choices`` - A list of options, used for multiple choice fields (see the field.choices section below)
* ``cssClasses`` - A list of CSS classes for label and field wrapper
* ``hideError`` - if true, errors won't be rendered automatically
* ``labelAfterField`` - if true, the label text will be displayed after the field, rather than before
* ``errorAfterField`` - if true, the error message will be displayed after the field, rather than before
* ``fieldsetClasses`` - for widgets with a fieldset (multipleRadio and multipleCheckbox), set classes for the fieldset
* ``legendClasses`` - for widgets with a fieldset (multipleRadio and multipleCheckbox), set classes for the fieldset's legend

#### field.choices

The choices property is used for radio, checkbox, and select fields. Two
formats are supported and in case of select fields the format can be nested once to support option groups.

The first format is based on objects and is easy to write. Object keys are treated as values and object values are treated as labels. If the value is another object and nesting is supported by the widget the key will be used as label and the value as nested list.

The second format is array-based and therefore ordered (object keys are unordered by definition). The array should contain arrays with two values the first being the value and the second being the label. If the label is an array and nesting is supported by the widget the value will be used as label and the label as nested list.

Both formats are demonstrated below:

```
// objects
{
'val-1': 'text-1',
'val-2': 'text-2',
'text-3': {
'nested-val-1': 'nested-text-1',
'nested-val-2': 'nested-text-2',
'nested-val-3': 'nested-text-3'
}
}
// arrays
[
['val-1', 'text-1'],
['val-2', 'text-2'],
['text-3', [
['nested-val-1', 'nested-text-1'],
['nested-val-2', 'nested-text-2'],
['nested-val-3', 'nested-text-3'],
]]
]
```

#### field.parse(rawdata)

Coerces the raw data from the request into the correct format for the field,
Expand Down
181 changes: 113 additions & 68 deletions lib/widgets.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ var tag = require('./tag');

var dataRegExp = /^data-[a-z]+([-][a-z]+)*$/;
var ariaRegExp = /^aria-[a-z]+$/;
var legalAttrs = ['autocomplete', 'autocorrect', 'autofocus', 'autosuggest', 'checked', 'dirname', 'disabled', 'tabindex', 'list', 'max', 'maxlength', 'min', 'multiple', 'novalidate', 'pattern', 'placeholder', 'readonly', 'required', 'size', 'step'];
var ignoreAttrs = ['id', 'name', 'class', 'classes', 'type', 'value'];
var legalAttrs = ['autocomplete', 'autocorrect', 'autofocus', 'autosuggest', 'checked', 'dirname', 'disabled', 'tabindex', 'list', 'max', 'maxlength', 'min', 'novalidate', 'pattern', 'placeholder', 'readonly', 'required', 'size', 'step'];
var ignoreAttrs = ['id', 'name', 'class', 'classes', 'type', 'value', 'multiple'];
var getUserAttrs = function (opt) {
return keys(opt).reduce(function (attrs, k) {
if ((ignoreAttrs.indexOf(k) === -1 && legalAttrs.indexOf(k) > -1) || dataRegExp.test(k) || ariaRegExp.test(k)) {
Expand Down Expand Up @@ -44,6 +44,98 @@ var input = function (type) {
};
};

var choiceValueEquals = function (value1, value2) {
return !is.array(value1) && !is.array(value2) && String(value1) === String(value2);
};

var isSelected = function (value, choice) {
return value && (is.array(value) ? value.some(choiceValueEquals.bind(null, choice)) : choiceValueEquals(value, choice));
};

var renderChoices = function (choices, renderer) {
return choices.reduce(function (partialRendered, choice) {
var isNested = is.array(choice[1]);
var renderData = isNested ?
{ isNested: true, label: choice[0], choices: choice[1] } :
{ isNested: false, value: choice[0], label: choice[1] };
return partialRendered + renderer(renderData);
}, '');
};

var isScalar = function (value) {
return !value || is.string(value) || is.number(value) || is.bool(value);
};

var unifyChoices = function (choices, nestingLevel) {
if (nestingLevel < 0) {
throw new RangeError('choices nested too deep');
}

var unifyChoiceArray = function (arrayChoices, currentLevel) {
return arrayChoices.reduce(function (result, choice) {
if (!is.array(choice) || choice.length !== 2) {
throw new TypeError('choice must be array with two elements');
}
if (isScalar(choice[0]) && isScalar(choice[1])) {
result.push(choice);
} else if (isScalar(choice[0]) && (is.array(choice[1]) || is.object(choice[1]))) {
result.push([choice[0], unifyChoices(choice[1], currentLevel - 1)]);
} else {
throw new TypeError('expected primitive value as first and primitive value, object, or array as second element');
}
return result;
}, []);
};

var unifyChoiceObject = function (objectChoices, currentLevel) {
return keys(objectChoices).reduce(function (result, key) {
var label = objectChoices[key];
if (isScalar(label)) {
result.push([key, label]);
} else if (is.array(label) || is.object(label)) {
result.push([key, unifyChoices(label, currentLevel - 1)]);
} else {
throw new TypeError('expected primitive value, object, or array as object value');
}
return result;
}, []);
};

return is.array(choices) ? unifyChoiceArray(choices, nestingLevel) : unifyChoiceObject(choices, nestingLevel);
};

var select = function (isMultiple) {
return function (options) {
var opt = options || {};
var w = {
classes: opt.classes,
type: isMultiple ? 'multipleSelect' : 'select'
};
var userAttrs = getUserAttrs(opt);
w.toHTML = function (name, field) {
var f = field || {};
var choices = unifyChoices(f.choices, 1);
var optionsHTML = renderChoices(choices, function render(choice) {
if (choice.isNested) {
return tag('optgroup', { label: choice.label }, renderChoices(choice.choices, render));
} else {
return tag('option', { value: choice.value, selected: !!isSelected(f.value, choice.value) }, choice.label);
}
});
var attrs = {
name: name,
id: f.id === false ? false : (f.id || true),
classes: w.classes
};
if (isMultiple) {
attrs.multiple = true;
}
return tag('select', [attrs, userAttrs, w.attrs || {}], optionsHTML);
};
return w;
};
};

exports.text = input('text');
exports.email = input('email');
exports.number = input('number');
Expand Down Expand Up @@ -78,6 +170,9 @@ exports.date = function (opt) {
return w;
};

exports.select = select(false);
exports.multipleSelect = select(true);

exports.checkbox = function (options) {
var opt = options || {};
var w = {
Expand All @@ -100,31 +195,6 @@ exports.checkbox = function (options) {
return w;
};

exports.select = function (options) {
var opt = options || {};
var w = {
classes: opt.classes,
type: 'select'
};
var userAttrs = getUserAttrs(opt);
w.toHTML = function (name, field) {
var f = field || {};
var optionsHTML = keys(f.choices).reduce(function (html, k) {
return html + tag('option', {
value: k,
selected: !!(f.value && String(f.value) === String(k))
}, f.choices[k]);
}, '');
var attrs = {
name: name,
id: f.id === false ? false : (f.id || true),
classes: w.classes
};
return tag('select', [attrs, userAttrs, w.attrs || {}], optionsHTML);
};
return w;
};

exports.textarea = function (options) {
var opt = options || {};
var w = {
Expand Down Expand Up @@ -156,26 +226,27 @@ exports.multipleCheckbox = function (options) {
var userAttrs = getUserAttrs(opt);
w.toHTML = function (name, field) {
var f = field || {};
return keys(f.choices).reduce(function (html, k) {
var choices = unifyChoices(f.choices, 0);
return renderChoices(choices, function (choice) {
// input element
var id = f.id === false ? false : (f.id ? f.id + '_' + k : 'id_' + name + '_' + k);
var checked = f.value && (is.array(f.value) ? f.value.some(function (v) { return String(v) === String(k); }) : String(f.value) === String(k));
var id = f.id === false ? false : (f.id ? f.id + '_' + choice.value : 'id_' + name + '_' + choice.value);
var checked = isSelected(f.value, choice.value);

var attrs = {
type: 'checkbox',
name: name,
id: id,
classes: w.classes,
value: k,
value: choice.value,
checked: !!checked
};
var inputHTML = tag('input', [attrs, userAttrs, w.attrs || {}]);

// label element
var labelHTML = tag('label', { 'for': id, classes: w.labelClasses }, f.choices[k]);
var labelHTML = tag('label', { 'for': id, classes: w.labelClasses }, choice.label);

return html + inputHTML + labelHTML;
}, '');
return inputHTML + labelHTML;
});
};
return w;
};
Expand Down Expand Up @@ -204,51 +275,25 @@ exports.multipleRadio = function (options) {
var userAttrs = getUserAttrs(opt);
w.toHTML = function (name, field) {
var f = field || {};
return keys(f.choices).reduce(function (html, k) {
var choices = unifyChoices(f.choices, 0);
return renderChoices(choices, function (choice) {
// input element
var id = f.id === false ? false : (f.id ? f.id + '_' + k : 'id_' + name + '_' + k);
var checked = f.value && (is.array(f.value) ? f.value.some(function (v) { return String(v) === String(k); }) : String(f.value) === String(k));
var id = f.id === false ? false : (f.id ? f.id + '_' + choice.value : 'id_' + name + '_' + choice.value);
var checked = isSelected(f.value, choice.value);
var attrs = {
type: 'radio',
name: name,
id: id,
classes: w.classes,
value: k,
value: choice.value,
checked: !!checked
};
var inputHTML = tag('input', [attrs, userAttrs, w.attrs || {}]);
// label element
var labelHTML = tag('label', { 'for': id, classes: w.labelClasses }, f.choices[k]);
var labelHTML = tag('label', { 'for': id, classes: w.labelClasses }, choice.label);

return html + inputHTML + labelHTML;
}, '');
};
return w;
};

exports.multipleSelect = function (options) {
var opt = options || {};
var w = {
classes: opt.classes,
type: 'multipleSelect'
};
var userAttrs = getUserAttrs(opt);
w.toHTML = function (name, field) {
var f = field || {};
var optionsHTML = keys(f.choices).reduce(function (html, k) {
var selected = f.value && (is.array(f.value) ? f.value.some(function (v) { return String(v) === String(k); }) : String(f.value) === String(k));
return html + tag('option', {
value: k,
selected: !!selected
}, f.choices[k]);
}, '');
var attrs = {
multiple: true,
name: name,
id: f.id === false ? false : (f.id || true),
classes: w.classes
};
return tag('select', [attrs, userAttrs, w.attrs || {}], optionsHTML);
return inputHTML + labelHTML;
});
};
return w;
};
Loading

0 comments on commit 70f5c5f

Please sign in to comment.