import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action, get } from '@ember/object';
import { assert } from '@ember/debug';
import { isBlank, isPresent, typeOf } from '@ember/utils';
import { A, isArray } from '@ember/array';
import { getOwner } from '@ember/application';
import { guidFor } from '@ember/object/internals';
import { ref } from 'ember-ref-bucket';
import ControlInput from './element/control/input';
import ControlCheckbox from './element/control/checkbox';
import ControlTextarea from './element/control/textarea';
import ControlRadio from './element/control/radio';
import ControlSwitch from './element/control/switch';
import arg from 'ember-bootstrap/utils/decorators/arg';
import { dedupeTracked } from 'tracked-toolbox';
/**
Subclass of `Components.FormGroup` that adds automatic form layout markup and form validation features.
### Form layout
The appropriate Bootstrap markup for the given `formLayout` and `controlType` is automatically generated to easily
create forms without coding the default Bootstrap form markup by hand:
```handlebars
<BsForm @formLayout="horizontal" @model={{this}} @onSubmit={{action "submit"}} as |form|>
<form.element @controlType="email" @label="Email" @value={{this.email}}" />
<form.element @controlType="password" @label="Password" @value={{this.password}} />
<form.element @controlType="checkbox" @label="Remember me" @value={{this.rememberMe}} />
<BsButton @defaultText="Submit" @type="primary" type="submit" />
</BsForm>
```
### Control types
The following control types are supported out of the box:
* Inputs (simple `text`, or any other HTML5 supported input types like `password`, `email` etc.)
* Checkbox (single)
* Radio Button (group)
* Textarea
* Switch (BS4 Only)
#### Radio Buttons
For a group of mutually exclusive radio buttons to work, you must supply the `options` property with an array of
options, each of which will be rendered with an appropriate radio button and its label. It can be either a simple array
of strings or objects. In the latter case, you would have to set `optionLabelPath` to the property, that contains the
label on these objects.
```hbs
<BsForm @model={{this}} @onSubmit={{action "submit"}} as |form|>
<form.element @controlType="radio" @label="Gender" @options={{this.genderOptions}} @optionLabelPath="title" @property="gender" />
</BsForm>
```
The default layout for radios is stacked, but Bootstrap's inline layout is also supported using the `inline` property
of the yielded control component:
```hbs
<BsForm @model={{this}} @onSubmit={{action "submit"}} as |form|>
<form.element @controlType="radio" @label="Gender" @options={{this.genderOptions}} @property="gender" as |el|>
<el.control @inline={{true}} />
</form.element>
</BsForm>
```
#### Custom controls
Apart from the standard built-in browser controls (see the `controlType` property), you can use any custom control simply
by invoking the component with a block template. Use whatever control you might want, for example a `<PikadayInput>`
component (from the [ember-pikaday addon](https://github.com/adopted-ember-addons/ember-pikaday)):
```hbs
<BsForm @model={{this}} @onSubmit={{action "submit"}} as |form|>
<form.element @label="Select-2" @property="gender" as |el|>
<PikadayInput @value={{el.value}} @onSelection={{action el.setValue}} id={{el.id}} />
</form.element>
</BsForm>
```
The component yields a hash with the following properties:
* `control`: the component that would be used for rendering the form control based on the given `controlType`
* `id`: id to be used for the form control, so it matches the labels `for` attribute
* `value`: the value of the form element
* `setValue`: function to change the value of the form element
* `validation`: the validation state of the element, `null` if no validation is to be shown, otherwise 'success', 'error' or 'warning'
If you just want to customize the existing control component, you can use the aforementioned yielded `control` component
to customize that existing component:
```hbs
<BsForm @model={{this}} @onSubmit={{action "submit"}} as |form|>
<form.element @label="Email" @property="email" as |el|>
<el.control class="input-lg" placeholder="Email" />
</form.element>
</BsForm>
```
If you are using the custom control quite often, you should consider writing an integration plugin like
[`ember-bootstrap-power-select`](https://github.com/kaliber5/ember-bootstrap-power-select).
To do so, you need to provide a component `{{bs-form/element/control/my-custom-control}}` which extends
[`Components.FormElementControl`](Components.FormElementControl.html).
### Form validation
In the following example the control elements of the three form elements value will be bound to the properties
(given by `property`) of the form's `model`, which in this case is its controller (see `model=this`):
```handlebars
<BsForm @formLayout="horizontal" @model={{this}} @onSubmit={{action "submit"}} as |form|>
<form.element @controlType="email" @label="Email" @property="email" />
<form.element @controlType="password" @label="Password" @property="password" />
<form.element @controlType="checkbox" @label="Remember me" @property="rememberMe" />
<BsButton @defaultText="Submit" @type="primary" @buttonType="submit" />
</BsForm>
```
By using this indirection in comparison to directly binding the `value` property, you get the benefit of automatic
form validation, given that your `model` has a supported means of validating itself.
See [Components.Form](Components.Form.html) for details on how to enable form validation.
In the example above the `model` was our controller itself, so the control elements were bound to the appropriate
properties of our controller. A controller implementing validations on those properties could look like this:
```js
import Ember from 'ember';
import EmberValidations from 'ember-validations';
export default Ember.Controller.extend(EmberValidations,{
email: null,
password: null,
rememberMe: false,
validations: {
email: {
presence: true,
format: {
with: /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,4}$/
}
},
password: {
presence: true,
length: { minimum: 6, maximum: 10}
},
comments: {
length: { minimum: 5, maximum: 20}
}
}
});
```
If the `showValidation` property is `true` (which is automatically the case if a `focusOut` event is captured from the
control element or the containing `Components.Form` was submitted with its `model` failing validation) and there are
validation errors for the `model`'s `property`, the appropriate Bootstrap validation markup (see
http://getbootstrap.com/css/#forms-control-validation) is applied:
* `validation` is set to 'error', which will set the `has-error` CSS class
* the `errorIcon` feedback icon is displayed if `controlType` is a text field
* the validation messages are displayed as Bootstrap `help-block`s in BS3 and `form-control-feedback` in BS4
The same applies for warning messages, if the used validation library supports this. (Currently only
[ember-cp-validations](https://github.com/offirgolan/ember-cp-validations))
As soon as the validation is successful again...
* `validation` is set to 'success', which will set the `has-success` CSS class
* the `successIcon` feedback icon is displayed if `controlType` is a text field
* the validation messages are removed
In case you want to display some error or warning message that is independent of the model's validation, for
example to display a failure message on a login form after a failed authentication attempt (so not coming from
the validation library), you can use the `customError` or `customWarning` properties to do so.
### HTML attributes
To set HTML attributes on the control element provided by this component when using the modern angle bracket invocation,
you can pass them to the yielded `control` component:
```hbs
<BsForm @formLayout="horizontal" @model={{this}} @onSubmit={{action "submit"}} as |form|>
<form.element @controlType="email" @label="Email" @property="email" as |el|>
<el.control
placeholder="Email"
tabindex={{5}}
multiple
required
/>
</form.element>
...
</BsForm>
```
@class FormElement
@namespace Components
@extends Components.FormGroup
@public
*/
export default class FormElement extends Component {
/**
* @property _element
* @type null | HTMLElement
* @private
*/
@ref('mainNode') _element = null;
/**
* Text to display within a `<label>` tag.
*
* You should include a label for every form input cause otherwise screen readers
* will have trouble with your forms. Use `invisibleLabel` property if you want
* to hide them.
*
* @property label
* @type string
* @public
*/
/**
* Controls label visibility by adding 'sr-only' class.
*
* @property invisibleLabel
* @type boolean
* @default false
* @public
*/
/**
* The type of the control widget.
* Supported types:
*
* * 'text'
* * 'checkbox'
* * 'radio'
* * 'switch'
* * 'textarea'
* * any other type will use an input tag with the `controlType` value as the type attribute (for e.g. HTML5 input
* types like 'email'), and the same layout as the 'text' type
*
* @property controlType
* @type string
* @default 'text'
* @public
*/
@arg
controlType = 'text';
/**
* The value of the control element is bound to this property:
*
* ```hbs
* <form.element @controlType="email" @label="Email" @value={{this.email}} />
* ```
*
* Note two things:
* * the binding is uni-directional (DDAU), so you would have to use the `onChange` action to subscribe to changes.
* * you lose the ability to validate this form element by directly binding to its value. It is recommended
* to use the `property` feature instead.
*
* @property value
* @public
*/
get value() {
assert(
'You can not set both property and value on a form element',
isBlank(this.args.property) || isBlank(this.args.value)
);
if (this.args.property && this.args.model) {
return get(this.args.model, this.args.property);
}
return this.args.value;
}
/**
The property name of the form element's `model` (by default the `model` of its parent `Components.Form`) that this
form element should represent. The control element's value will automatically be bound to the model property's
value.
Using this property enables form validation on this element.
@property property
@type string
@public
*/
/**
* The model used for validation. Defaults to the parent `Components.Form`'s `model`
*
* @property model
* @public
*/
/**
* Show a help text next to the control
*
* @property helpText
* @type {string}
* @public
*/
/**
* Only if there is a validator, this property makes all errors to be displayed at once
* inside a scrollable container.
*
* @default false
* @property showMultipleErrors
* @public
* @type {Boolean}
*/
@arg
showMultipleErrors = false;
/**
* Array of options for control types that show a selection (e.g. radio button groups)
* Can be an array of simple strings or objects. For objects use `optionLabelPath` to specify the path containing the
* label.
*
* @property options
* @type {Array}
* @public
*/
/**
* Property path (e.g. 'title' or 'related.name') to render the label of a selection option. See `options`.
*
* @property optionLabelPath
* @type {String}
* @public
*/
/**
* The array of error messages from the `model`'s validation.
*
* @property errors
* @type array
* @protected
*/
// This shouldn't be an argument. It's only an argument because tests rely on
// setting it as an argument. See https://github.com/kaliber5/ember-bootstrap/issues/1338
// for details.
@arg errors;
/**
* @property hasErrors
* @type boolean
* @readonly
* @private
*/
get hasErrors() {
return Array.isArray(this.errors) && this.errors.length > 0;
}
/**
* The array of warning messages from the `model`'s validation.
*
* @property warnings
* @type array
* @protected
*/
// This shouldn't be an argument. It's only an argument because tests rely on
// setting it as an argument. See https://github.com/kaliber5/ember-bootstrap/issues/1338
// for details.
@arg warnings;
/**
* @property hasWarnings
* @type boolean
* @readonly
* @private
*/
get hasWarnings() {
return Array.isArray(this.warnings) && this.warnings.length > 0;
}
/**
* Show a custom error message that does not come from the model's validation. Will be immediately shown, regardless
* of any user interaction (i.e. no `focusOut` event required)
*
* @property customError
* @type string
* @public
*/
/**
* @property hasCustomError
* @type boolean
* @readonly
* @private
*/
get hasCustomError() {
return isPresent(this.args.customError);
}
/**
* Show a custom warning message that does not come from the model's validation. Will be immediately shown, regardless
* of any user interaction (i.e. no `focusOut` event required). If the model's validation has an error then the error
* will be shown in place of this warning.
*
* @property customWarning
* @type string
* @public
*/
/**
* @property hasCustomWarning
* @type boolean
* @readonly
* @private
*/
get hasCustomWarning() {
return isPresent(this.args.customWarning);
}
/**
* Property for size styling, set to 'lg', 'sm' or 'xs' (the latter only for BS3)
*
* @property size
* @type String
* @public
*/
/**
* The array of validation messages (either errors or warnings) from either custom error/warnings or , if we are showing model validation messages, the model's validation
*
* @property validationMessages
* @type array
* @private
*/
get validationMessages() {
if (this.hasCustomError) {
return A([this.args.customError]);
}
if (this.hasErrors && this.showModelValidation) {
return A(this.errors);
}
if (this.hasCustomWarning) {
return A([this.args.customWarning]);
}
if (this.hasWarnings && this.showModelValidation) {
return A(this.warnings);
}
return null;
}
/**
* @property hasValidationMessages
* @type boolean
* @readonly
* @private
*/
get hasValidationMessages() {
return Array.isArray(this.validationMessages) && this.validationMessages.length > 0;
}
/**
* Set a validating state for async validations
*
* @property isValidating
* @type boolean
* @default false
* @protected
*/
@tracked isValidating = false;
/**
* If `true` form validation markup is rendered (requires a validatable `model`).
*
* @property showValidation
* @type boolean
* @default false
* @private
*/
get showValidation() {
return this.showOwnValidation || this.showAllValidations || this.hasCustomError || this.hasCustomWarning;
}
/**
* @property showOwnValidation
* @type boolean
* @default false
* @private
*/
@dedupeTracked showOwnValidation = false;
/**
* @property showAllValidations
* @type boolean
* @default false
* @private
*/
@arg
showAllValidations = false;
/*
* Resets `showOwnValidation` if `@showAllValidations` argument is changed to `false`.
* Must be called whenever `@showAllValidations` argument changes.
*/
@action
handleShowAllValidationsChange() {
if (this.args.showAllValidations === false) {
this.showOwnValidation = false;
}
}
/**
* @property showModelValidations
* @type boolean
* @readonly
* @private
*/
get showModelValidation() {
return this.showOwnValidation || this.showAllValidations;
}
/**
* @property showValidationMessages
* @type boolean
* @readonly
* @private
*/
get showValidationMessages() {
return this.showValidation && this.hasValidationMessages;
}
/**
* Event or list of events which enable form validation markup rendering.
* Supported events: ['focusout', 'change', 'input']
*
* @property showValidationOn
* @type string|array
* @default ['focusout']
* @public
*/
@arg
showValidationOn = ['focusOut'];
/**
* @property _showValidationOn
* @type array
* @readonly
* @private
*/
get _showValidationOn() {
let showValidationOn = this.showValidationOn;
assert(
'showValidationOn must be a String or an Array',
isArray(showValidationOn) || typeOf(showValidationOn) === 'string'
);
if (isArray(showValidationOn)) {
return showValidationOn.map((type) => {
return type.toLowerCase();
});
}
if (typeof showValidationOn.toString === 'function') {
return [showValidationOn.toLowerCase()];
}
return [];
}
/**
* @method showValidationOnHandler
* @param {Event} event
* @private
*/
@action
showValidationOnHandler({ target, type }) {
// Should not do anything if
if (
// validations should not be shown for this event type or
this._showValidationOn.indexOf(type) === -1 ||
// validation should not be shown for this event target
(isArray(this.doNotShowValidationForEventTargets) &&
this.doNotShowValidationForEventTargets.length > 0 &&
this._element &&
[...this._element.querySelectorAll(this.doNotShowValidationForEventTargets.join(','))].some((el) =>
el.contains(target)
))
) {
return;
}
this.showOwnValidation = true;
}
/**
* Controls if validation should be shown for specified event targets.
*
* It expects an array of query selectors. If event target is a children of an event that matches
* these selectors, an event triggered for it will not trigger validation errors to be shown.
*
* By default, events fired on elements inside an input group are skipped.
*
* If `null` or an empty array is passed validation errors are shown for all events regardless
* of event target.
*
* @property doNotShowValidationForEventTargets
* @type ?array
* @public
*/
@arg
doNotShowValidationForEventTargets = ['.input-group-append', '.input-group-prepend'];
/**
* The validation ("error" (BS3)/"danger" (BS4), "warning", or "success") or null if no validation is to be shown. Automatically computed from the
* model's validation state.
*
* @property validation
* @readonly
* @type string
* @private
*/
get validation() {
const shouldShowValidationState =
this.showModelValidation && this.hasValidator && !this.isValidating && !this.args._disabled;
if (
/* custom errors should be always shown */
this.hasCustomError ||
/* validation error should be shown in preference to warnings */
(shouldShowValidationState && this.hasErrors)
) {
return 'error';
}
if (
/* custom warning should be always shown unless there is a validation error */
this.hasCustomWarning ||
(shouldShowValidationState && this.hasWarnings)
) {
return 'warning';
}
if (shouldShowValidationState) {
return 'success';
}
return null;
}
/**
* The form layout used for the markup generation (see http://getbootstrap.com/css/#forms):
*
* * 'horizontal'
* * 'vertical'
* * 'inline'
*
* Defaults to the parent `form`'s `formLayout` property.
*
* @property formLayout
* @type string
* @default 'vertical'
* @public
*/
/**
* The Bootstrap grid class for form labels within a horizontal layout form. Defaults to the value of the same
* property of the parent form. The corresponding grid class for form controls is automatically computed.
*
* @property horizontalLabelGridClass
* @type string
* @public
*/
_elementId = guidFor(this);
/**
* ID for input field and the corresponding label's "for" attribute
*
* @property formElementId
* @type string
* @private
*/
get formElementId() {
return `${this._elementId}-field`;
}
/**
* ID of the helpText, used for aria-describedby attribute of the control element
*
* @property ariaDescribedBy
* @type string
* @private
*/
get ariaDescribedBy() {
return `${this._elementId}-help`;
}
/**
* @property layoutComponent
* @type {String}
* @private
*/
/**
* @property controlComponent
* @private
*/
get controlComponent() {
let owner = getOwner(this);
let componentClass = owner.resolveRegistration(`component:bs-form/element/control/${this.controlType}`);
if (componentClass) {
return componentClass;
}
if (this.controlType === 'checkbox') {
return ControlCheckbox;
} else if (this.controlType === 'textarea') {
return ControlTextarea;
} else if (this.controlType === 'radio') {
return ControlRadio;
} else if (this.controlType === 'switch') {
return ControlSwitch;
} else {
return ControlInput;
}
}
/**
* @property errorsComponent
* @type {String}
* @private
*/
/**
* @property feedbackIconComponent
* @type {String}
* @private
*/
/**
* @property labelComponent
* @type {String}
* @private
*/
/**
* @property helpTextComponent
* @type {String}
* @private
*/
/**
* Setup validation properties. This method acts as a hook for external validation
* libraries to overwrite. In case of failed validations the `errors` property should contain an array of error messages.
*
* @method setupValidations
* @private
*/
/**
* The action is called whenever the input value is changed, e.g. by typing text
*
* @event onChange
* @param {String} value The new value of the form control
* @param {Object} model The form element's model
* @param {String} property The value of `property`
* @public
*/
/**
* Private duplicate of onChange event used for internal state handling between form and it's elements.
*
* @event _onChange
* @private
*/
constructor() {
super(...arguments);
if (!isBlank(this.args.property)) {
this.setupValidations?.();
}
}
@action
doChange(value) {
let { onChange, model, property, _onChange } = this.args;
onChange?.(value, model, property);
_onChange?.();
}
}