Source: components/molecules/FormComponent.js

import dom from '../../DOM.js'
import Component from '../../Component.js'
import { throttle } from '../../throttle.js'
import { lt, ld, Localizer } from '../../Localizer.js'

import LabelComponent from '../atoms/LabelComponent.js'
import SwitchComponent from '../atoms/SwitchComponent.js'
import ButtonComponent from '../atoms/ButtonComponent.js'
import HeadingComponent from '../atoms/HeadingComponent.js'
import TextInputComponent from '../atoms/TextInputComponent.js'
import SelectionComponent from '../atoms/SelectionComponent.js'

import WaitComponent from './WaitComponent.js'

/**
FormComponent is meant to be used to modify values of a {@link DataModel}.

FormComponent holds:

- a heading that titles the form
- a set of {@link FormFieldComponent}s that provide input UI for fields in the DataModel


@todo Create a PasswordInputFieldComponent
@todo Create a form error display element and per-field error display elements
@todo Add a `WaitComponent` to show while submitting the form
@todo Add default Submit/Reset/Clear controls

@example
* class SignInComponent extends FormComponent {
* 	constructor(dataObject=null, options={}) {
* 		super(dataObject, Object.assign({
* 			heading: 'Sign-in:'
* 		}, options))
* 		this.addClass('sign-in-component')
* 
* 		this._usernameComponent = new TextInputFieldComponent(
* 			this.dataObject,
* 			{ label: lt('username'), placeholder: lt('Enter your account username') }
* 		).appendTo(this.fieldsComponent)
* 
* 		this._passwordComponent = new PasswordInputFieldComponent(
* 			this.dataObject,
* 			{ label: lt('password'), placeholder: lt('Enter your account password') }
* 		).appendTo(this.fieldsComponent)
* 	}
* }

*/
const FormComponent = class extends Component {
	/**
	@param {DataObject} [dataObject]
	@param {Object} [options] see the {@Component} for inherited options
	@param {string} [options.heading=null] the heading text for this form
	*/
	constructor(dataObject = null, options = {}) {
		super(dataObject, options)
		this.setName('FormComponent')
		this.addClass('form-component')

		this._headingComponent = new HeadingComponent(undefined, { dom: dom.h2(this.options.heading || '') }).appendTo(this)
		if (!this.options.heading) {
			this._headingComponent.hide()
		}

		this._fieldsComponent = new Component().appendTo(this)
		this._fieldsComponent.setName('FieldsComponent')
		this._fieldsComponent.addClass('fields-component')
	}

	/**
	@desc a Component in which you should place your fields' Components
	@type {Component}
	*/
	get fieldsComponent() {
		return this._fieldsComponent
	}
}

/**
FormFieldComponent is the base {@link Component} for fields in a {@link FormComponent}.

It only contains a {@link LabelComponent} as direct sub-classes (listed above) will add input `Component`s.

If you have a unique field type then this is the class to extend with your custom UI.
*/
const FormFieldComponent = class extends Component {
	/**
	@param {string}   [options.label=null] the display text for this input field used in a LabelComponent
	@param {string}   [options.dataField=null] the name of the {@link DataModel} field for this form element 

	@todo Make the label reactive to a DataModel field
	*/
	constructor(dataObject = null, options = {}) {
		super(
			dataObject,
			Object.assign(
				{
					label: null,
					dataField: null,
				},
				options
			)
		)
		this.setName('FormFieldComponent')
		this.addClass('form-field-component')

		this._label = new LabelComponent(undefined, {
			text: this.options.label || '',
		}).appendTo(this)
		if (!this.options.label) this._label.hide()
	}
}

/**
SelectionFieldComponent provides a dropdown selector for use in {@link FormComponent}s.
*/
const SelectionFieldComponent = class extends FormFieldComponent {
	/**
	@param {DataModel} dataObject
	@param {Object} options see {@link FormFieldComponent} for more options (like `label`)
	@param {Object[]} options.items an array of [display name, value] to be used as choices in the selector
	*/
	constructor(dataObject, options) {
		super(dataObject, options)
		this.setName('SelectionFieldComponent')
		this.addClass('selection-field-component')

		this._selectionComponent = new SelectionComponent(this.dataObject, {
			items: options.items,
		}).appendTo(this)
		this._selectionComponent.selectedIndex = 0

		if (this.dataObject && this.options.dataField) {
			this._updateInputFromModel()
			this.listenTo(SelectionComponent.SelectionIndexChangedEvent, this._selectionComponent, () => {
				this._updateModelFromInput()
			})
		}
	}

	_updateInputFromModel() {
		if (!this.dataObject || !this.options.dataField) return
		const data = Number.parseInt(this.dataObject.get(this.options.dataField, 0))
		if (Number.isNaN(data)) {
			this._selectionComponent.selectedIndex = 0
			return
		}
		this._selectionComponent.selectedIndex = Number.parseInt(data)
	}

	_updateModelFromInput() {
		if (!this.dataObject || !this.options.dataField) return
		this.dataObject.set(this.options.dataField, this._selectionComponent.selectedIndex)
	}
}

/**
SwitchFieldComponent shows a {@link SwitchComponent} to toggle a boolean field value in a {@link FormComponent}.
*/
const SwitchFieldComponent = class extends FormFieldComponent {
	/**
	@param {DataModel} [dataObject]
	@param {Object}    [options] see {@link FormFieldComponent} for more options
	*/
	constructor(dataObject = null, options = {}) {
		super(dataObject, options)
		this.setName('SwitchFieldComponent')
		this.addClass('switch-field-component')

		this._switchComponent = new SwitchComponent(this.dataObject, {
			dataField: this.options.dataField,
		}).appendTo(this)
	}
}

/**
DateFieldComponent provides day, month, and year input for use in a {@link FormComponent}.
*/
const DateFieldComponent = class extends FormFieldComponent {
	/**
	@param {DataModel} [dataObject]
	@param {Object}    [options] see {@link FormFieldComponent} for more options
	*/
	constructor(dataObject = null, options = {}) {
		super(dataObject, options)
		this.setName('DateFieldComponent')
		this.addClass('date-field-component')
		this._throttledUpdateModelFromInput = throttle(this._updateModelFromInput.bind(this), 1000, false, true)

		this._dayInputComponent = new TextInputComponent(this.dataObject, {
			placeholder: lt('dd'),
		})
			.addClass('day-input-component')
			.setName('DayInputComponent')

		this._monthInputComponent = new TextInputComponent(this.dataObject, {
			placeholder: lt('mm'),
		})
			.addClass('month-input-component')
			.setName('MonthInputComponent')

		this._yearInputComponent = new TextInputComponent(this.dataObject, {
			placeholder: lt('yyyy'),
		})
			.addClass('year-input-component')
			.setName('YearInputComponent')

		// Different places have different orders for their date fields, so handle that here
		Localizer.Singleton.dateFieldOrder.forEach((fieldName) => {
			switch (fieldName) {
				case 'month':
					this.append(this._monthInputComponent)
					break
				case 'day':
					this.append(this._dayInputComponent)
					break
				case 'year':
					this.append(this._yearInputComponent)
					break
			}
		})

		this._dateLabelComponent = new LabelComponent(this.dataObject, {})
			.appendTo(this)
			.addClass('date-label-component')
			.setName('DateLabelComponent')

		if (this.dataObject && this.options.dataField) {
			this._updateInputFromModel()
			this.listenTo(`changed:${this.options.dataField}`, this.dataObject, () => {
				this._updateLabelFromModel()
			})
			this._updateLabelFromModel()
			this.listenTo(TextInputComponent.TextChangeEvent, this._dayInputComponent, this._throttledUpdateModelFromInput)
			this.listenTo(TextInputComponent.TextChangeEvent, this._monthInputComponent, this._throttledUpdateModelFromInput)
			this.listenTo(TextInputComponent.TextChangeEvent, this._yearInputComponent, this._throttledUpdateModelFromInput)
		}
	}

	/** @return {Date?} the date specified in the input fields or null if it is not parsed */
	get inputDate() {
		const day = Number.parseInt(this._dayInputComponent.text)
		if (Number.isNaN(day)) return null
		let month = Number.parseInt(this._monthInputComponent.text)
		if (Number.isNaN(month)) return null
		month = month - 1 // Date uses zero indexed months
		let year = Number.parseInt(this._yearInputComponent.text)
		if (Number.isNaN(year)) return null
		if (year < 100) year = year + 1900 // Fix up two digit years

		// Dates should always be serialized into UTC
		const result = new Date(Date.UTC(year, month, day, 0, 0, 0))
		if (Number.isNaN(result.getTime())) return null
		return result
	}

	_updateLabelFromModel() {
		const modelData = this.dataObject.get(this.options.dataField, null)
		if (modelData === null) {
			this._dateLabelComponent.text = '---------'
			return
		}
		const modelDate = new Date(modelData)
		if (Number.isNaN(modelDate.getTime())) {
			this._dateLabelComponent.text = '---------'
			return
		}
		this._dateLabelComponent.text = ld(
			new Date(modelDate.getUTCFullYear(), modelDate.getUTCMonth(), modelDate.getUTCDate(), 0, 0, 0)
		)
	}

	_updateModelFromInput() {
		if (!this.dataObject || !this.options.dataField) return
		const inputDate = this.inputDate
		// Set the data field to an ISO string date or remove it if the input is invalid
		this.dataObject.set(this.options.dataField, inputDate ? inputDate.toISOString() : undefined)
	}

	_clearInput() {
		this._dateLabelComponent.text = ''
		this._dayInputComponent.text = ''
		this._monthInputComponent.text = ''
		this._yearInputComponent.text = ''
	}

	_updateInputFromModel() {
		if (!this.dataObject || !this.options.dataField) return
		const modelData = this.dataObject.get(this.options.dataField, null)
		if (modelData === null) {
			this._clearInput()
			return
		}
		const modelDate = new Date(modelData)
		if (Number.isNaN(modelDate.getTime())) {
			this._clearInput()
			console.error('invalid date data', modelData)
			return
		}
		this._updateInputFromDate(new Date(modelData))
	}

	/**
	@param {(string | Date)} date
	*/
	_updateInputFromDate(date) {
		if (!date) {
			this._clearInput()
			return
		}
		if (typeof date === 'string') {
			date = new Date(date)
		}
		if (date instanceof Date === false) {
			this._clearInput()
			return
		}
		if (Number.isNaN(date.getTime())) {
			this._clearInput()
			return
		}
		this._dayInputComponent.text = date.getUTCDate()
		this._monthInputComponent.text = date.getUTCMonth() + 1
		this._yearInputComponent.text = date.getUTCFullYear()
	}
}

/**
TextInputFieldComponent provides a text field, natch
*/
const TextInputFieldComponent = class extends FormFieldComponent {
	/**
	@param {DataModel} [dataObject]
	@param {Object}    [options] see {@link FormFieldComponent} for more options
	@param {string}    [options.placeholder] the text displayed in the field when there is no value text
	*/
	constructor(dataObject = null, options = {}) {
		super(
			dataObject,
			Object.assign(
				{
					placeholder: null,
				},
				options
			)
		)
		this.setName('TextInputFieldComponent')
		this.addClass('text-input-field-component')

		this._textInputComponent = new TextInputComponent(dataObject, {
			placeholder: this.options.placeholder,
			dataField: this.options.dataField,
		}).appendTo(this)
		this.listenTo(TextInputComponent.TextChangeEvent, this._textInputComponent, (eventName, value) => {
			this._handleInputChange(value)
		})

		if (this.dataObject && this.options.dataField) {
			this.listenTo(`changed:${this.options.dataField}`, this.dataObject, (eventName, model, dataField, value) => {
				this._handleModelChange(value)
			})
		}
	}

	_handleInputChange(value) {
		if (!this.dataObject || !this.options.dataField) return
		if (this.dataObject.get(this.options.dataField) === value) return
		this.dataObject.set(this.options.dataField, value)
	}

	_handleModelChange(value) {
		if (this._textInputComponent.text === value) return
		this._textInputComponent.text = value
	}
}

export default FormComponent
export {
	FormComponent,
	FormFieldComponent,
	DateFieldComponent,
	SwitchFieldComponent,
	SelectionFieldComponent,
	TextInputFieldComponent,
}