diff --git a/README.md b/README.md index 1747eb3..f608355 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,56 @@ first grid layout example above: ![spinner](https://github.com/insin/newforms-bootstrap/raw/master/spinner.gif "Default async validation spinner") +* `static` - set to `true` to render fields using a static display control: `

{value}

` + + This is useful if you want to have readonly forms, and switch to editable given a flag: + + ```html + + ``` + + Provide a `widgetAttrs.convertStatic` function on a field to convert the value when displaying statically. The following + would render a cost value with a dollar sign in front when `static=true`, but would otherwise just render an integer field: + + ```javascript + var ProductForm = forms.Form.extend({ + cost: forms.IntegerField({ + widgetAttrs: { + convertStatic: (value) => '$' + value + } + }) + }) + ``` + +* `horizontal` - An object for specifying `form-horizontal` classes. Keys are one of the bootstrap + sizes (xs, sm, md, lg), and values are the number given to the form control. + + So for example: + + ```html + + ``` + + This would produce fields such as: + + ```html +
+ +
+ +
+
+
+
+
+ +
+
+
+ ``` + ### Bootstrap-compatible choice field renderers The following custom renderers are available for use. Note that the non-inline diff --git a/src/index.jsx b/src/index.jsx index 3ef2bdc..340177a 100644 --- a/src/index.jsx +++ b/src/index.jsx @@ -5,7 +5,8 @@ var React = require('react') var { BooleanField, BoundField, CheckboxChoiceInput, CheckboxFieldRenderer, CheckboxSelectMultiple, ChoiceFieldRenderer, FileField, Form, - MultiValueField, MultiWidget, RadioChoiceInput, RadioFieldRenderer, RadioSelect + MultiValueField, MultiWidget, RadioChoiceInput, RadioFieldRenderer, + RadioSelect, Widget } = require('newforms') var SPINNER = 'data:image/gif;base64,R0lGODlhDgAOANU%2FAJ2rtf39%2FfL09a65wvX2993i5qq2v9Ta35CgrLjCyuTo6%2Bfq7aGvub3Hzs7V2vX3%2BI6eq9rf47rEzOvu8NLZ3ens7u7w8sDJ0ODl6MfP1aazvYqbqNDX3Pr7%2FLW%2Fx4iZpomap%2BPn6vHz9Y2dqqSxu%2FT19%2Bjr7tfd4dvg5KOwuvj5%2BeLm6ae0vd%2Fk5%2Fj5%2BvHz9Nbc4Nbc4Y2dqff4%2Bebp7NXb3%2FDy9Iqbp%2BXp7Pv8%2FL%2FIz%2Fn6%2B7nDy%2FDy84%2Bfq%2F%2F%2F%2FyH%2FC05FVFNDQVBFMi4wAwEAAAAh%2FwtYTVAgRGF0YVhNUDw%2FeHBhY2tldCBiZWdpbj0i77u%2FIiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8%2BIDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMy1jMDExIDY2LjE0NTY2MSwgMjAxMi8wMi8wNi0xNDo1NjoyNyAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENTNiAoTWFjaW50b3NoKSIgeG1wTU06SW5zdGFuY2VJRD0ieG1wLmlpZDoyNzA4MjZFM0EyRUExMUUzQjE2OUQwNUQ1MzZBQ0M2NyIgeG1wTU06RG9jdW1lbnRJRD0ieG1wLmRpZDoyNzA4MjZFNEEyRUExMUUzQjE2OUQwNUQ1MzZBQ0M2NyI%2BIDx4bXBNTTpEZXJpdmVkRnJvbSBzdFJlZjppbnN0YW5jZUlEPSJ4bXAuaWlkOjk2NDkzOTlDQTJBOTExRTNCMTY5RDA1RDUzNkFDQzY3IiBzdFJlZjpkb2N1bWVudElEPSJ4bXAuZGlkOjI3MDgyNkUyQTJFQTExRTNCMTY5RDA1RDUzNkFDQzY3Ii8%2BIDwvcmRmOkRlc2NyaXB0aW9uPiA8L3JkZjpSREY%2BIDwveDp4bXBtZXRhPiA8P3hwYWNrZXQgZW5kPSJyIj8%2BAf%2F%2B%2Ffz7%2Bvn49%2Fb19PPy8fDv7u3s6%2Brp6Ofm5eTj4uHg397d3Nva2djX1tXU09LR0M%2FOzczLysnIx8bFxMPCwcC%2Fvr28u7q5uLe2tbSzsrGwr66trKuqqainpqWko6KhoJ%2BenZybmpmYl5aVlJOSkZCPjo2Mi4qJiIeGhYSDgoGAf359fHt6eXh3dnV0c3JxcG9ubWxramloZ2ZlZGNiYWBfXl1cW1pZWFdWVVRTUlFQT05NTEtKSUhHRkVEQ0JBQD8%2BPTw7Ojk4NzY1NDMyMTAvLi0sKyopKCcmJSQjIiEgHx4dHBsaGRgXFhUUExIREA8ODQwLCgkIBwYFBAMCAQAAIfkEBQMAPwAsAAAAAA4ADgAABhTAn3BILBqPyKRyyWw6n9CodGoMAgAh%2BQQFAwA%2FACwHAAAAAQADAAAGBcCOrRMEACH5BAUDAD8ALAcAAAABAAMAAAYFwNKhFAQAIfkEBQMAPwAsBwAAAAEAAwAABgXABQkXBAAh%2BQQFAwA%2FACwHAAAAAgADAAAGB8DQ7FOLPYIAIfkEBQMAPwAsBwAAAAMAAwAABgrAX%2Bn3%2B0xOmV8QACH5BAUDAD8ALAcAAAAEAAMAAAYLQMxvOCSJfjpNIAgAIfkEBQMAPwAsBwAAAAUABAAABg%2FA0G9I%2FCmGDR%2BoMiRQfkEAIfkEBQMAPwAsBwAAAAYABQAABhNAzG9IHIaGNcnQQXwwPotm7RcEACH5BAUDAD8ALAcAAAAHAAYAAAYVwNVvSCwSTw3ExzgECYkEBMOYMXSCACH5BAUDAD8ALAcAAAAHAAgAAAYcwNBvSCQqij8fiFMkDIXIFPLyERRRn1axl1gEAQAh%2BQQFAwA%2FACwLAAcAAwADAAAGCsDIB3P5CFCeXxAAIfkEBQMAPwAsCgAHAAQABQAABhHAn7Al%2FIkeiNTP8An9MA5hEAAh%2BQQFAwA%2FACwIAAMABgAKAAAGHMCf8LcaGo9II%2BpXOL6MDCGBASrWEKBhjRQaBgEAIfkEBQMAPwAsBgAAAAgADgAABirA3%2BRHLP4YxJCxYGw6i4%2BndEpsPQVGwi%2F1VE5ODd%2BPQxx8Pj9FsRIqNYMAIfkECQMAPwAsAwAAAAkADgAABiLAn%2FA3Gxp%2FjuNw8kMgldAhIUqtWq%2FKC692DLA%2BHyhhdQwCACH5BAkDAD8ALAAAAAAOAA4AAAZGwJ9wKOwQj0QGKYQ8XnwgR5NIYHymxAeCgR1efqLuDyUWkstfYgBJQBAdgPCwCiLWQBAJ7NSAco4VBh%2BDHyQKUw8KISVHQQAh%2BQQJAwA%2FACwAAAAADgAOAAAGUcCfcEgsGn%2BBQehItCBADubwwQCtpMIHgoEVXj6vLupTEH9aP1OE%2BRX8DCORkYBICU0bgHtIqC6FNRsQEicnDT4gHEULGh%2BOHyQKTA8hISVFQQAh%2BQQJAwA%2FACwAAAAADgAOAAAGVsCfcEgsGoe9Y1EBciiHDwYI8xSWEIyqUPexBVQBZeRTWHwoStSn5QIllJeP4GeQvYwEREpY2QBERARSIUMwGyMSMScNPiAcRSYsH5MfJApKDwohJUVBACH5BAkDAD8ALAAAAAAOAA4AAAZRwJ9wSCwaj8ghLTl0gFbMHwGR%2Bs0GCuTlI8B9DkjUp7X4UMJjFyih5f4MspdxWv1VNgARkcAAhYYwGyMSMScNPiAcRSYsH44fJFlHDwohJUVBACH5BAkDAD8ALAAAAAAOAA4AAAZVwJ9wSCwaj8gjIZBk%2FlgaZCb1m30kSN3HhvvUkJFPYfGhIFGflguUQF4%2Bgp9B9jISENRfZQMQEQkMICFDMBsjEjEnDT4gHEUmLB%2BSHyQKSA8KISVFQQAh%2BQQJAwA%2FACwAAAAADgAOAAAGUcCfcEgsGo%2FCCZJo2nCWQsNIBHWBeEvLjvY5IAuf1uJDQaLC1gTy8hH8DLKXkYBICSsbAHVIYIBCQzAbIxIxJw0%2BIE9MLB%2BOHyQKSA8KISVFQQAh%2BQQJAwA%2FACwAAAAADgAOAAAGU8CfcEgsCnNGYw3gSg5NG0DJKWSNetTf7JPI%2FhQfincRdgoUOReom7x8BD%2BD7GV8IBjCSlREJDA%2BIUMwGyMSMScNPiAORSYsH5AfKYFJDwohU0RBACH5BAkDAD8ALAAAAAAOAA4AAAZPwJ9wSCwKFyhjsXYDKIemDUDwFLJG1Orsw6sKcZ%2BD97f4UMYuUGL8M8hexkemI6xIRcQHA7QawjYjEjE1Ej4gDkUmLB%2BMHyQhTw8KGCVFQQAh%2BQQJAwA%2FACwAAAAADgAOAAAGSsCfcEgsChcajJFY20BOS6FpAxBEhYaR6PqbfXjcH%2B5zCC8%2BlLALlAj%2FDLJXuELdDh%2BBImwzksRODQgNRiYsH4cfJCFRDworJUVBACH5BAkDAD8ALAAAAAAOAA4AAAZGwJ9Q2BkajQsN4nisbUaSAFNougEE06FhJMoKZyCeV0j7HMa%2FxYeCdoES6J9B9kJXNoDuGPaUxGA2WSYsH4UZYw8KGARHQQAh%2BQQJAwA%2FACwAAAAADgAOAAAGPMCfUPhQDY%2FDBetzQB5rN4hk4hRWNgBBdWgYibZCFYgHFtKY5d%2B5WRaT091v%2BQqQg6HSV1n5MaV%2FDwFVQQAh%2BQQJAwA%2FACwAAAAADgAOAAAGPMCfUPiwDI%2FDBetjQB5rG4ik5RSaNgBRdWgYabc%2FF4gHFtI%2Bh%2FIP96GoZ5%2BE%2Bsca9dQLrEBdA6HmRnNqQQAh%2BQQJAwA%2FACwAAAAADgAOAAAGN8CfUPgwDY9DE%2BvjQx5jm5Ek4hSaNgBRdWiQvbZCF4gHFtI%2Bh%2FIPh1bPPmS1YURQmxzqvH4%2FDAIAIfkECQMAPwAsAAAAAA4ADgAABjXAn1D4UASGSKGJ9fmokkPYZiSJHaGmDUAERRpkr%2B7QBeKJh4sP5SzEfWrs38yziNvv%2BLw%2BCAAh%2BQQJAwA%2FACwAAAAADgAOAAAGL8CfUPhQBIZIoYn1%2BaiSQ9hmJIkdoaYNQARFGmTcrlAF4omHFhLqzG673%2FC4%2FBwEACH5BAkDAD8ALAAAAAAOAA4AAAYqwJ9Q%2BAgFhkjhQvP5qJLD2gYiOR2hpg1AAEUaRqIu8rESm8%2FotHrNbrODACH5BAkDAD8ALAAAAAAOAA4AAAYowJ9QSFgFhkghTfP5qJLD2g3Cqx2hOQDABk3uSt2weEwum8%2FotBoZBAAh%2BQQJAwA%2FACwAAAAADgAOAAAGI8CfUEgIBYZI4ULz%2BaiSwx1iJDkdoUKTCMvter%2FgsHhMLpeDACH5BAkDAD8ALAAAAAAOAA4AAAYgwJ9QSFgFhkihSvP5qJLJAe9whFqv2Kx2y%2B16v%2BDwMAgAIfkECQMAPwAsAAAAAA4ADgAABh7An1BICAWGyKHl81Eln5nT8UmtWq%2FYrHbL7Xq%2FwyAAIfkECQMAPwAsAAAAAA4ADgAABh3An1D4WAWGSCTno0o6S7Wjc0qtWq%2FYrHbL7XqHQQAh%2BQQFAwA%2FACwAAAAADgAOAAAGGsCfcIgLDI9IgArJ%2FBWb0Kh0Sq1ar9isVhoEACH5BAUDAD8ALAYAAAABAAMAAAYFQAFHEAQAIfkECQMAPwAsBgAAAAEAAwAABgXAnK0TBAAh%2BQQJAwA%2FACwAAAAADgAOAAAGFMCfcEgsGo%2FIpHLJbDqf0Kh0agwCACH5BAUDAD8ALAAAAAAOAA4AAAYUwJ9wSCwaj8ikcslsOp%2FQqHRqDAIAIfkEBQMAPwAsAAAAAAEAAQAABgPAXxAAIfkEBQMAPwAsAAAAAAEAAQAABgPAXxAAIfkEBQMAPwAsAAAAAAEAAQAABgPAXxAAIfkEBQMAPwAsAAAAAAEAAQAABgPAXxAAOw%3D%3D' @@ -149,8 +150,28 @@ var BootstrapRadioInlineRenderer = RadioFieldRenderer.extend({ } }) +var BootstrapStaticWidget = Widget.extend({ + constructor: function BootstrapStaticWidget(kwargs) { + if (!(this instanceof BootstrapStaticWidget)) { return new BootstrapStaticWidget(kwargs) } + this.convertStatic = kwargs && kwargs.convertStatic || ((value) => value); + Widget.call(this, kwargs) + }, + render(name, value, kwargs) { + return ( +

{this.convertStatic(value)}

+ ) + } +}) + // ========================================================= Form Components === +var HorizontalPropType = React.PropTypes.shape({ + xs: React.PropTypes.number, + sm: React.PropTypes.number, + md: React.PropTypes.number, + lg: React.PropTypes.number +}); + var BootstrapForm = React.createClass({ statics: { patchFields(form) { @@ -185,7 +206,9 @@ var BootstrapForm = React.createClass({ propTypes: { form: React.PropTypes.instanceOf(Form).isRequired, - spinner: React.PropTypes.string + spinner: React.PropTypes.string, + static: React.PropTypes.bool, + horizontal: HorizontalPropType }, getDefaultProps() { @@ -211,7 +234,7 @@ var BootstrapForm = React.createClass({ ) } rows.push.apply(rows, form.visibleFields().map(field => - + )) var hiddenFields = form.hiddenFields() if (hiddenFields.length > 0) { @@ -230,53 +253,180 @@ var BootstrapForm = React.createClass({ var BootstrapField = React.createClass({ propTypes: { - field: React.PropTypes.instanceOf(BoundField).isRequired - , spinner: React.PropTypes.string + field: React.PropTypes.instanceOf(BoundField).isRequired, + spinner: React.PropTypes.string, + static: React.PropTypes.bool, + horizontal: HorizontalPropType }, getDefaultProps() { return { - spinner: SPINNER - } + spinner: SPINNER, + horizontal: {} + }; }, render() { - var field = this.props.field - var status = field.status() - var isBooleanField = field.field.constructor === BooleanField - var isFileField = field.field instanceof FileField - var isSpecialCaseWidget = isBooleanField || isFileField - var containerClasses = cx({ - 'checkbox': isBooleanField - , 'form-group': !isBooleanField - , 'has-error': status == 'error' - , 'has-success': status == 'valid' - }) - var widgetAttrs = {attrs: {className: cx({ - 'form-control': !isFileField && - !(field.field.widget instanceof RadioSelect) && - !(field.field.widget instanceof CheckboxSelectMultiple) - })}} + var field = this.props.field; + var status = field.status(); + + return ( +
+ {this.getControlWithLabel(field, status)} + {this.getHelpText(field, status)} + {this.getSpinner(status)} + {this.getError(field, status)} +
+ ); + }, + + getContainerClasses(field, status) { + var isBooleanField = this.isBooleanField(field); + var isHorizontal = this.isHorizontalForm(); + return cx({ + 'checkbox': !isHorizontal && isBooleanField, + 'form-group': isHorizontal || !isBooleanField, + 'has-error': status == 'error', + 'has-success': status == 'valid' + }); + }, + + isBooleanField(field) { + return field.field.constructor === BooleanField; + }, + + isFileField(field) { + return field.field instanceof FileField; + }, + + isRadioSelect(field) { + return field.field.widget instanceof RadioSelect; + }, + + isCheckboxSelectMultipleField(field) { + return field.field.widget instanceof CheckboxSelectMultiple; + }, + + getControlWithLabel(field) { + + if (this.isBooleanField(field)) { + var checkbox = ( + + ); + + if (!this.isHorizontalForm()) { + return checkbox; + } + + return ( +
+
+ {checkbox} +
+
+ ); + } + + if (this.isFileField(field)) { + return ( +
+ {field.labelTag({attrs: {className: 'control-label ' + this.getHorizontalLabelClasses()}})} +
+ {field.asWidget(this.getWidgetAttrs(field))} +
+
+ ); + } + + return ( +
+ {field.labelTag({attrs: {className: 'control-label ' + this.getHorizontalLabelClasses()}})} +
+ {field.asWidget(this.getWidgetAttrs(field))} +
+
+ ); + }, + + getWidgetAttrs(field) { + var widgetAttrs = { + attrs: { + className: cx({ + 'form-control': !this.isFileField(field) + && !this.isRadioSelect(field) + && !this.isCheckboxSelectMultipleField(field) + }) + } + }; + + if (this.props.static) { + widgetAttrs.widget = field.field.widgetAttrs.staticWidget || BootstrapStaticWidget(field.field.widgetAttrs) + } + + return widgetAttrs; + }, + + getHelpText(field, status) { + // Always show help text for empty fields, regardless of status - var showHelpText = field.helpText && (field.isEmpty() || status == 'default') - - return
- {!isBooleanField && field.labelTag({attrs: {className: 'control-label'}})} - {!isSpecialCaseWidget && field.asWidget(widgetAttrs)} - {isBooleanField && } - {isFileField &&
- {field.asWidget(widgetAttrs)} -
} - {showHelpText && field.helpTextTag({attrs: {className: 'help-block'}})} - {status == 'pending' && - Validating… - } - {status == 'error' && field.errors().messages().map(errorMessage)} -
+ var showHelpText = field.helpText && (field.isEmpty() || status == 'default'); + + return showHelpText && field.helpTextTag({attrs: {className: 'help-block'}}); + }, + + getSpinner(status) { + if (status == 'pending') { + return ( + + Validating… + + ); + } + }, + + getError(field, status) { + if (status == 'error') { + return field.errors().messages().map(errorMessage); + } + }, + + isHorizontalForm() { + return this.props.horizontal.length > 0; + }, + + getHorizontalLabelClasses() { + var classes = []; + var h = this.props.horizontal; + + for (var size in h) { + if (h.hasOwnProperty(size)) { + classes.push(`col-${size}-${12-h[size]}`); + } + } + + return classes.join(' '); + }, + + getHorizontalControlClasses(field) { + var classes = []; + var isBooleanField = this.isBooleanField(field); + var h = this.props.horizontal; + + for (var size in h) { + if (h.hasOwnProperty(size)) { + classes.push(`col-${size}-${h[size]}`); + if (isBooleanField) { + classes.push(`col-${size}-offset-${12-h[size]}`); + } + } + } + + return classes.join(' '); } -}) + +}); // ========================================================= Grid Components === @@ -413,13 +563,13 @@ var Container = React.createClass({ , className: React.PropTypes.string , fluid: React.PropTypes.bool , spinner: React.PropTypes.string + , static: React.PropTypes.bool + , horizontal: HorizontalPropType }, getDefaultProps() { return { - autoColumns: null - , fluid: false - , spinner: SPINNER + spinner: SPINNER } }, @@ -432,10 +582,9 @@ var Container = React.createClass({ {formErrors.messages().map(errorMessage)} } {React.Children.map(this.props.children, (row, index) => React.cloneElement(row, { - autoColumns: this.props.autoColumns - , form: this.props.form + ...this.props , index: index - , spinner: this.props.spinner + , className: null // Don't propagate className }))} {form.nonFieldPending() && Validating… @@ -448,6 +597,8 @@ var Row = React.createClass({ propTypes: { autoColumns: React.PropTypes.oneOf(BOOTSTRAP_COLUMN_SIZES) , className: React.PropTypes.string + , static: React.PropTypes.bool + , horizontal: HorizontalPropType }, render() { @@ -465,8 +616,8 @@ var Row = React.createClass({ return
{React.Children.map(this.props.children, (child, index) => { return React.cloneElement(child, extend({ - form: this.props.form - , spinner: this.props.spinner + ...this.props + , className: null // Don't propagate className }, columnProps[index])) })}
@@ -488,12 +639,14 @@ var Field = React.createClass({ propTypes: { name: React.PropTypes.string.isRequired + , static: React.PropTypes.bool + , horizontal: HorizontalPropType }, render() { var field = this.props.form.boundField(this.props.name) return
- +
} }) @@ -513,4 +666,4 @@ extend(BootstrapForm, { , Row }) -module.exports = BootstrapForm \ No newline at end of file +module.exports = BootstrapForm