From e96db46fbc654f7e505096e300ae51846cd59a88 Mon Sep 17 00:00:00 2001 From: Chris Thielen Date: Fri, 19 May 2017 16:11:19 -0700 Subject: [PATCH] refactor(core/entityTag): Refactor Entity Tags to React (#3717) - Introduce imperative React Modal API - Introduce Formsy validation for React - chore(travis): run yarn --- .../confirmationModal.service.ts | 2 + .../core/src/entityTag/EntityTagEditor.tsx | 342 ++++++++++++++++++ .../entityTag/addEntityTagLinks.component.ts | 56 ++- .../entityTag/clusterTargetBuilder.service.ts | 7 +- .../entityTag/entityTagDetails.component.less | 2 +- .../entityTag/entityTagEditor.controller.ts | 69 ---- .../src/entityTag/entityTagEditor.modal.html | 61 ---- .../src/entityTag/entityTagEditor.modal.less | 4 +- .../src/entityTag/entityUiTags.component.ts | 61 ++-- .../modules/core/src/entityTag/index.ts | 1 + .../modules/core/src/help/HelpField.tsx | 92 ++++- .../modules/core/src/help/help.contents.ts | 4 + app/scripts/modules/core/src/help/index.ts | 3 + .../modules/core/src/modal/modals.less | 2 +- .../src/presentation/HoverablePopover.tsx | 6 +- .../core/src/presentation/ModalService.tsx | 41 +++ .../presentation/formsy/BasicFieldLayout.tsx | 50 +++ .../src/presentation/formsy/FormComponent.ts | 130 +++++++ .../presentation/formsy/components/Input.tsx | 35 ++ .../formsy/components/TextArea.tsx | 75 ++++ .../presentation/formsy/components/index.ts | 2 + .../presentation/formsy/formFieldLayout.ts | 11 + .../src/presentation/formsy/formsy-react.d.ts | 91 +++++ .../core/src/presentation/formsy/index.ts | 6 + .../modules/core/src/presentation/index.ts | 2 + .../modules/core/src/reactShims/ngReact.ts | 30 +- .../core/src/reactShims/react.injector.ts | 38 +- .../core/src/task/monitor/TaskMonitor.tsx | 6 + .../src/task/monitor/taskMonitor.directive.js | 26 -- .../src/task/monitor/taskMonitor.directive.ts | 36 ++ .../src/task/monitor/taskMonitor.module.js | 2 +- .../src/task/monitor/taskMonitorService.js | 7 +- app/scripts/modules/core/src/utils/index.ts | 9 +- app/scripts/modules/core/src/utils/noop.ts | 3 + gradle/installViaTravis.sh | 1 + package.json | 7 +- tslint.json | 2 +- yarn.lock | 47 ++- 38 files changed, 1089 insertions(+), 280 deletions(-) create mode 100644 app/scripts/modules/core/src/entityTag/EntityTagEditor.tsx delete mode 100644 app/scripts/modules/core/src/entityTag/entityTagEditor.controller.ts delete mode 100644 app/scripts/modules/core/src/entityTag/entityTagEditor.modal.html create mode 100644 app/scripts/modules/core/src/presentation/ModalService.tsx create mode 100644 app/scripts/modules/core/src/presentation/formsy/BasicFieldLayout.tsx create mode 100644 app/scripts/modules/core/src/presentation/formsy/FormComponent.ts create mode 100644 app/scripts/modules/core/src/presentation/formsy/components/Input.tsx create mode 100644 app/scripts/modules/core/src/presentation/formsy/components/TextArea.tsx create mode 100644 app/scripts/modules/core/src/presentation/formsy/components/index.ts create mode 100644 app/scripts/modules/core/src/presentation/formsy/formFieldLayout.ts create mode 100644 app/scripts/modules/core/src/presentation/formsy/formsy-react.d.ts create mode 100644 app/scripts/modules/core/src/presentation/formsy/index.ts create mode 100644 app/scripts/modules/core/src/task/monitor/TaskMonitor.tsx delete mode 100644 app/scripts/modules/core/src/task/monitor/taskMonitor.directive.js create mode 100644 app/scripts/modules/core/src/task/monitor/taskMonitor.directive.ts create mode 100644 app/scripts/modules/core/src/utils/noop.ts diff --git a/app/scripts/modules/core/src/confirmationModal/confirmationModal.service.ts b/app/scripts/modules/core/src/confirmationModal/confirmationModal.service.ts index 898141cbf82..65397c4fc78 100644 --- a/app/scripts/modules/core/src/confirmationModal/confirmationModal.service.ts +++ b/app/scripts/modules/core/src/confirmationModal/confirmationModal.service.ts @@ -3,6 +3,7 @@ import {IModalService, IModalSettings} from 'angular-ui-bootstrap'; export interface IConfirmationModalParams { account?: string; + applicationName?: string; askForReason?: boolean; body?: string; buttonText?: string; @@ -12,6 +13,7 @@ export interface IConfirmationModalParams { multiTaskTitle?: string; platformHealthOnlyShowOverride?: boolean; platformHealthType?: string; + provider?: string; reason?: string; size?: string; submitJustWithReason?: boolean; diff --git a/app/scripts/modules/core/src/entityTag/EntityTagEditor.tsx b/app/scripts/modules/core/src/entityTag/EntityTagEditor.tsx new file mode 100644 index 00000000000..f9fca094868 --- /dev/null +++ b/app/scripts/modules/core/src/entityTag/EntityTagEditor.tsx @@ -0,0 +1,342 @@ +import { IDeferred } from 'angular'; +import { $q } from 'ngimport'; +import * as Formsy from 'formsy-react'; +import * as marked from 'marked'; +import * as React from 'react'; +import { Modal } from 'react-bootstrap'; +import { IModalServiceInstance } from 'angular-ui-bootstrap'; +import autoBindMethods from 'class-autobind-decorator'; +import * as DOMPurify from 'dompurify'; + +import { + UUIDGenerator, Application, EntityTagWriter, HelpField, IEntityRef, IEntityTag, + ReactInjector, TaskMonitor, TaskMonitorBuilder, SubmitButton +} from 'core'; + +import { BasicFieldLayout, TextArea, ReactModal } from 'core/presentation'; +import { NgReact } from 'core/reactShims/ngReact'; +import { EntityRefBuilder } from './entityRef.builder'; +import { noop } from 'core/utils'; + +import './entityTagEditor.modal.less'; +import { Form } from 'formsy-react'; + +export interface IOwnerOption { + label: string; + type: string; + owner: any; + isDefault: boolean; +} + +export interface IEntityTagEditorProps { + owner: any; + application: Application; + entityType: string; + tag: IEntityTag; + ownerOptions: IOwnerOption[]; + entityRef: IEntityRef; + isNew: boolean; + show?: boolean; + onHide?(event: any): void; + onUpdate?(): any; +} + +export interface IEntityTagEditorState { + taskMonitor: TaskMonitor; + message: string; + show: boolean; + isValid: boolean; + isSubmitting: boolean; + owner: any; + entityType: string; +} + +@autoBindMethods +export class EntityTagEditor extends React.Component { + public static defaultProps: Partial = { + onHide: noop, + onUpdate: noop, + }; + + private taskMonitorBuilder: TaskMonitorBuilder = ReactInjector.taskMonitorBuilder; + private entityTagWriter: EntityTagWriter = ReactInjector.entityTagWriter; + private $uibModalInstanceEmulation: IModalServiceInstance & { deferred?: IDeferred }; + private form: Form; + + /** Shows the Entity Tag Editor modal */ + public static show(props: IEntityTagEditorProps): Promise { + return ReactModal.show(React.createElement(EntityTagEditor, props)); + } + + constructor(props: IEntityTagEditorProps) { + super(props); + + const { ownerOptions, tag, entityType } = this.props; + const owner = this.props.owner || (ownerOptions && ownerOptions.length && ownerOptions[0]); + tag.name = tag.name || `spinnaker_ui_${tag.value.type}:${UUIDGenerator.generateUuid()}`; + + this.state = { + taskMonitor: null, + message: tag.value && tag.value.message, + show: true, + isValid: false, + isSubmitting: false, + owner: owner, + entityType: entityType, + }; + + const deferred = $q.defer(); + const promise = deferred.promise; + this.$uibModalInstanceEmulation = { + result: promise, + close: () => this.setState({ show: false }), + dismiss: () => this.setState({ show: false }), + } as IModalServiceInstance; + Object.assign(this.$uibModalInstanceEmulation, { deferred }); + } + + private handleMessageChanged(message: string): void { + this.setState({ message }); + } + + private handleOwnerOptionChanged(option: IOwnerOption): void { + this.setState({ owner: option.owner, entityType: option.type }); + } + + private onValid(): void { + this.setState({ isValid: true }); + } + + private onInvalid(): void { + this.setState({ isValid: false }); + } + + private onHide(): void { + this.setState({ show: false }); + this.props.onHide.apply(null, arguments); + this.$uibModalInstanceEmulation.deferred.resolve(); + } + + private upsertTag(data: { message: string; }): void { + const { application, isNew, tag, onUpdate } = this.props; + const { owner, entityType } = this.state; + const entityRef: IEntityRef = this.props.entityRef || EntityRefBuilder.getBuilder(entityType)(owner); + + tag.value.message = data.message; + + const taskMonitor = this.taskMonitorBuilder.buildTaskMonitor({ + application: application, + title: `${isNew ? 'Create' : 'Update'} ${this.props.tag.value.type} for ${entityRef.entityId}`, + modalInstance: this.$uibModalInstanceEmulation, + onTaskComplete: () => onUpdate(), + }); + + const submitMethod = () => { + const promise = this.entityTagWriter.upsertEntityTag(application, tag, entityRef, isNew); + const done = () => this.setState({ isSubmitting: false }); + promise.then(done, done); + return promise; + }; + + taskMonitor.submit(submitMethod); + + this.setState({ taskMonitor, isSubmitting: true }); + } + + private refCallback(form: Form): void { + this.form = form; + } + + private submit(): void { + this.form.submit(); + } + + public render() { + const { isNew, tag, ownerOptions } = this.props; + const { isValid, isSubmitting } = this.state; + const message = this.state.message || ''; + + const closeButton = ( +
+ + + +
+ ); + + const submitLabel = `${isNew ? ' Create' : ' Update'} ${tag.value.type}`; + const cancelButton = ; + + const { TaskMonitorWrapper } = NgReact; + + return ( + + + + + + +

{isNew ? 'Create' : 'Update'} {tag.value.type}

+ {closeButton} +
+ + + + + { ownerOptions && ownerOptions.length && ( + + ) } + + + + {cancelButton} + + + + +
+ +
+ ); + } +} + + + +interface IEntityTagMessageProps { + message: string; + onMessageChanged(message: string): void; +} + +@autoBindMethods +class EntityTagMessage extends React.Component { + private handleTextareaChanged(event: React.FormEvent): void { + this.props.onMessageChanged(event.currentTarget.value); + } + + public render() { + const { message } = this.props; + + return ( +
+
+ + -
Markdown is okay
-
-
-
-
- Preview -
-
-
-
-
- - -
-
-
-
-
- Applies to -
-
-
-
- -
-
-
-
-
-
-
- - - - diff --git a/app/scripts/modules/core/src/entityTag/entityTagEditor.modal.less b/app/scripts/modules/core/src/entityTag/entityTagEditor.modal.less index eb56a2d643b..e72a777b918 100644 --- a/app/scripts/modules/core/src/entityTag/entityTagEditor.modal.less +++ b/app/scripts/modules/core/src/entityTag/entityTagEditor.modal.less @@ -3,12 +3,12 @@ .entity-tag-editor-modal { .preview { background-color: lighten(@light_blue_background, 10%); - [marked] { + .marked, [marked] { padding-top: 5px; } } label { - [marked] { + .marked, [marked] { p { margin-bottom: 0; } diff --git a/app/scripts/modules/core/src/entityTag/entityUiTags.component.ts b/app/scripts/modules/core/src/entityTag/entityUiTags.component.ts index 14104c31044..46ac383bb78 100644 --- a/app/scripts/modules/core/src/entityTag/entityUiTags.component.ts +++ b/app/scripts/modules/core/src/entityTag/entityUiTags.component.ts @@ -1,12 +1,11 @@ -import { IComponentController, IComponentOptions, module } from 'angular'; -import { IModalService } from 'angular-ui-bootstrap'; +import { ITimeoutService, IComponentController, IComponentOptions, module } from 'angular'; -import { Application } from 'core/application/application.model'; -import { IEntityRef, IEntityTag } from 'core/domain'; -import { EntityTagEditorCtrl } from './entityTagEditor.controller'; -import { EntityTagWriter, ENTITY_TAG_WRITER } from './entityTags.write.service'; -import './entityUiTags.component.less'; +import { Application, EntityTagWriter, IEntityTag, IEntityTagEditorProps } from 'core'; + +import { EntityTagEditor, ENTITY_TAG_WRITER } from 'core/entityTag'; +import { ConfirmationModalService } from 'core/confirmationModal'; +import './entityUiTags.component.less'; import './entityUiTags.popover.less'; class EntityUiTagsCtrl implements IComponentController { @@ -25,8 +24,9 @@ class EntityUiTagsCtrl implements IComponentController { private component: any; - public constructor(private $timeout: ng.ITimeoutService, private $uibModal: IModalService, - private confirmationModalService: any, private entityTagWriter: EntityTagWriter) { + public constructor(private $timeout: ITimeoutService, + private confirmationModalService: ConfirmationModalService, + private entityTagWriter: EntityTagWriter) { 'ngInject'; } @@ -80,29 +80,26 @@ class EntityUiTagsCtrl implements IComponentController { } public editTag(tag: IEntityTag) { - this.$uibModal.open({ - templateUrl: require('./entityTagEditor.modal.html'), - controller: EntityTagEditorCtrl, - controllerAs: '$ctrl', - resolve: { - tag: (): IEntityTag => { - return { - name: tag.name, - value: { - message: tag.value['message'], - type: tag.value['type'] - } - }; - }, - isNew: (): boolean => false, - owner: (): any => this.component, - entityType: (): string => this.entityType, - application: (): Application => this.application, - onUpdate: (): any => this.onUpdate, - ownerOptions: (): any => null, - entityRef: (): IEntityRef => this.component.entityTags.entityRef, + const _tag = { + name: tag.name, + value: { + message: tag.value['message'], + type: tag.value['type'], } - }); + }; + + const props: IEntityTagEditorProps = { + tag: _tag, + isNew: false, + owner: this.component, + entityType: this.entityType, + application: this.application, + onUpdate: this.onUpdate, + ownerOptions: null, + entityRef: this.component.entityTags.entityRef, + }; + + EntityTagEditor.show(props); } @@ -199,6 +196,6 @@ export class EntityUiTagsWrapperComponent implements IComponentOptions { } export const ENTITY_UI_TAGS_COMPONENT = 'spinnaker.core.entityTags.uiTags.component'; -module(ENTITY_UI_TAGS_COMPONENT, [ENTITY_TAG_WRITER]) +module(ENTITY_UI_TAGS_COMPONENT, [ ENTITY_TAG_WRITER ]) .component('entityUiTags', new EntityUiTagsComponent()) .component('entityUiTagsWrapper', new EntityUiTagsWrapperComponent()); diff --git a/app/scripts/modules/core/src/entityTag/index.ts b/app/scripts/modules/core/src/entityTag/index.ts index 23a91a986b2..986af95a7c6 100644 --- a/app/scripts/modules/core/src/entityTag/index.ts +++ b/app/scripts/modules/core/src/entityTag/index.ts @@ -1,3 +1,4 @@ export * from './entityTags.read.service'; export * from './entityTags.write.service'; export * from './clusterTargetBuilder.service'; +export * from './EntityTagEditor'; diff --git a/app/scripts/modules/core/src/help/HelpField.tsx b/app/scripts/modules/core/src/help/HelpField.tsx index 6d4fd672407..52f7f703115 100644 --- a/app/scripts/modules/core/src/help/HelpField.tsx +++ b/app/scripts/modules/core/src/help/HelpField.tsx @@ -1,8 +1,98 @@ +import * as React from 'react'; +import * as ReactGA from 'react-ga'; +import * as DOMPurify from 'dompurify'; +import autoBindMethods from 'class-autobind-decorator'; +import { $injector } from 'ngimport'; + +import { HelpContentsRegistry, IHelpContents } from 'core/help'; +import { HoverablePopover } from 'core/presentation'; +import { Placement } from 'core/presentation/Placement'; + export interface IHelpFieldProps { id?: string; fallback?: string; content?: string; - placement?: string; + placement?: Placement; expand?: boolean; label?: string; } + +export interface IState { + contents: React.ReactElement; +} + +@autoBindMethods +export class HelpField extends React.Component { + public static defaultProps: IHelpFieldProps = { + placement: 'top', + }; + + private helpContentsRegistry: HelpContentsRegistry; + private helpContents: IHelpContents; + private popoverShownStart: number; + + constructor(props: IHelpFieldProps) { + super(props); + this.helpContentsRegistry = $injector.get('helpContentsRegistry') as any; + this.helpContents = $injector.get('helpContents') as any; + + this.state = this.getState(); + } + + private getState(): IState { + const { id, fallback, content } = this.props; + let contentString = content; + if (id && !contentString) { + contentString = this.helpContentsRegistry.getHelpField(id) || this.helpContents[id] || fallback; + } + + return { + contents:
, + } + } + + public componentWillReceiveProps(): void { + this.setState(this.getState()); + } + + private onShow(): void { + this.popoverShownStart = Date.now(); + } + + private onHide(): void { + if (Date.now() - this.popoverShownStart > 500) { + ReactGA.event({ action: 'Help contents viewed', category: 'Help', label: this.props.id || this.props.content }); + } + } + + public render() { + const { placement, label, expand } = this.props; + const { contents } = this.state; + + const icon = ; + + const popover = ( + + {label || icon} + + ); + + + if (label) { + return ( +
+ {!expand && contents && popover} +
+ ); + } else { + const expanded =
{contents}
; + + return ( +
+ {!expand && contents && popover} + {expand && contents && expanded} +
+ ); + } + } +} diff --git a/app/scripts/modules/core/src/help/help.contents.ts b/app/scripts/modules/core/src/help/help.contents.ts index 0347e0b26f3..f5ad8f1c753 100644 --- a/app/scripts/modules/core/src/help/help.contents.ts +++ b/app/scripts/modules/core/src/help/help.contents.ts @@ -1,5 +1,9 @@ import { module } from 'angular'; +export interface IHelpContents { + [key: string]: string; +} + export const HELP_CONTENTS = 'spinnaker.core.help.contents'; module(HELP_CONTENTS, []) .constant('helpContents', { diff --git a/app/scripts/modules/core/src/help/index.ts b/app/scripts/modules/core/src/help/index.ts index 769b7af69a3..961237aabe6 100644 --- a/app/scripts/modules/core/src/help/index.ts +++ b/app/scripts/modules/core/src/help/index.ts @@ -1 +1,4 @@ export * from './helpContents.registry'; +export * from './help.contents'; +export { HelpField } from './HelpField'; + diff --git a/app/scripts/modules/core/src/modal/modals.less b/app/scripts/modules/core/src/modal/modals.less index 98e002d7418..bdc9bbea7f7 100644 --- a/app/scripts/modules/core/src/modal/modals.less +++ b/app/scripts/modules/core/src/modal/modals.less @@ -15,7 +15,7 @@ h2, h3, h4, h5 { font-weight: 600; } - modal-close { + .modal-close, modal-close { display: block; position: absolute; right: 0; diff --git a/app/scripts/modules/core/src/presentation/HoverablePopover.tsx b/app/scripts/modules/core/src/presentation/HoverablePopover.tsx index 64989f0cd92..999757f7326 100644 --- a/app/scripts/modules/core/src/presentation/HoverablePopover.tsx +++ b/app/scripts/modules/core/src/presentation/HoverablePopover.tsx @@ -3,13 +3,15 @@ import {Overlay, Popover} from 'react-bootstrap'; import autoBindMethods from 'class-autobind-decorator'; import {Placement} from 'core/presentation/Placement'; -import {UUIDGenerator} from '../utils/uuid.service'; +import {UUIDGenerator} from 'core/utils'; export interface IProps { value?: string; template?: JSX.Element; placement?: Placement; id?: string; + onShow?: () => void; + onHide?: () => void; } export interface IState { @@ -40,6 +42,7 @@ export class HoverablePopover extends React.Component { private showPopover(e: React.MouseEvent): void { this.clearPopoverCancel(); this.setState({popoverIsOpen: true, target: e.target}); + this.props.onShow && this.props.onShow(); } private deferHidePopover(): void { @@ -49,6 +52,7 @@ export class HoverablePopover extends React.Component { private hidePopover(): void { this.clearPopoverCancel(); this.setState({popoverIsOpen: false}); + this.props.onHide && this.props.onHide(); } private clearPopoverCancel(): void { diff --git a/app/scripts/modules/core/src/presentation/ModalService.tsx b/app/scripts/modules/core/src/presentation/ModalService.tsx new file mode 100644 index 00000000000..fbcb49586f5 --- /dev/null +++ b/app/scripts/modules/core/src/presentation/ModalService.tsx @@ -0,0 +1,41 @@ +import { ReactElement } from 'react'; +import * as React from 'react'; +import * as ReactDOM from 'react-dom'; + +/** An imperative service for showing a react component as a modal */ +export class ReactModal { + public static show(modal: ReactElement): Promise { + return new Promise((resolve) => { + let mountNode = document.createElement('div'); + let show = true; + + render(); + + function onExited() { + if (!mountNode) { + return; + } + + ReactDOM.unmountComponentAtNode(mountNode); + mountNode = null; + } + + function onHide(action: any) { + show = false; + resolve(action); + render(); + } + + function render() { + ReactDOM.render( + React.cloneElement(modal, { + show, + onExited, + onHide, + }), + mountNode + ); + } + }); + } +} diff --git a/app/scripts/modules/core/src/presentation/formsy/BasicFieldLayout.tsx b/app/scripts/modules/core/src/presentation/formsy/BasicFieldLayout.tsx new file mode 100644 index 00000000000..53bc235ceb2 --- /dev/null +++ b/app/scripts/modules/core/src/presentation/formsy/BasicFieldLayout.tsx @@ -0,0 +1,50 @@ +import * as React from 'react'; +import { IFormFieldLayoutProps } from './formFieldLayout'; + +/** + * A Form Field Layout component for Formsy Form Field components + * + * Accepts four react elements as props (_label, _input, _help, _error) and lays them out using bootstrap grid. + * + * +----------------div.form-group----------------------------+ + * | +-------------div.col-md-9--------------+| + * | |+-------------------------------------+|| + * | ||input element ||| + * | |+-------------------------------------+|| + * | | || + * | | || + * | | || + * | | || + * | | || + * | +---------------------------------------+| + * +----------------------------------------------------------+ + */ +export class BasicFieldLayout extends React.Component { + constructor(props: IFormFieldLayoutProps) { + super(props); + } + + public render() { + const { Label, Input, Help, Error, showRequired, showError } = this.props; + + const LabelDiv = Label &&
{Label}
; + const HelpDiv = Help &&
{Help}
; + const ErrorDiv = Error &&
{Error}
; + + const InputGroup = Input && ( +
+ {Input} + {HelpDiv} + {ErrorDiv} +
+ ); + + const className = `form-group ${showRequired || showError ? 'ng-invalid' : ''}`; + return ( +
+ {LabelDiv} + {InputGroup} +
+ ); + } +} diff --git a/app/scripts/modules/core/src/presentation/formsy/FormComponent.ts b/app/scripts/modules/core/src/presentation/formsy/FormComponent.ts new file mode 100644 index 00000000000..af03558e242 --- /dev/null +++ b/app/scripts/modules/core/src/presentation/formsy/FormComponent.ts @@ -0,0 +1,130 @@ +// https://github.com/christianalfoni/formsy-react/issues/191#issuecomment-144872142 + +import { Mixin, ValidationErrors } from 'formsy-react'; +import { omit } from 'lodash'; +import * as React from 'react'; + +/* + * Here the Formsy.Mixin is wrapped as an abstract class, so that it can + * be used effectively with TypeScript. + */ +export interface IFormComponentProps { + name?: string; + value?: string | string[] | number; + validations?: string; + validationError?: string; + validationErrors?: ValidationErrors; + required?: boolean; + _validate?: Function; +} + +export interface IFormComponentState { + _value: any; + _isRequired: boolean; + _isValid: boolean; + _isPristine: boolean; + _pristineValue: any, + _validationError: string; + _externalError: string; + _formSubmitted: boolean; +} + +/** A base class for Formsy form components */ +export abstract class FormComponent extends React.Component { + public static propnames: (keyof IFormComponentProps)[] = ['name', 'value', 'validations', 'validationError', 'validationErrors', 'required', '_validate']; + + public static defaultProps: IFormComponentProps = { + name: null, // this can be set to whatever, since it will be overwritten when child components are created + validationError: '', + validationErrors: {} + }; + + public static otherProps(props: any): any { + return omit(props, FormComponent.propnames); + } + + constructor(props: PROPS, context: any) { + super(props, context); + + // Default values for state + this.state = Mixin.getInitialState.call(this) as STATE; + } + + // Lifecycle methods + public componentWillMount(): void { + Mixin.componentWillMount.call(this); + } + + public componentWillReceiveProps(nextProps: PROPS): void { + Mixin.componentWillReceiveProps.call(this, nextProps); + } + + public componentDidUpdate(prevProps: PROPS): void { + Mixin.componentDidUpdate.call(this, prevProps); + } + + public componentWillUnmount(): void { + Mixin.componentWillUnmount.call(this); + } + + // Formsy methods + public setValue(value: VALUE): void { + Mixin.setValue.call(this, value); + } + + public resetvalue(): void { + Mixin.resetValue.call(this); + } + + public getValue(): VALUE { + return Mixin.getValue.call(this); + } + + public hasValue(): boolean { + return Mixin.hasValue.call(this); + } + + public getErrorMessage(): string { + return Mixin.getErrorMessage.call(this); + } + + public getErrorMessages(): string { + return Mixin.getErrorMessages.call(this); + } + + public isFormDisabled(): boolean { + return Mixin.isFormDisabled.call(this); + } + + public isValid(): boolean { + return Mixin.isValid.call(this); + } + + public isPristine(): boolean { + return Mixin.isPristine.call(this); + } + + public isFormSubmitted(): boolean { + return Mixin.isFormSubmitted.call(this); + } + + public isRequired(): boolean { + return Mixin.isRequired.call(this); + } + + public showRequired(): boolean { + return Mixin.showRequired.call(this); + } + + public showError(): boolean { + return Mixin.showError.call(this); + } + + public isValidValue(value: any): boolean { + return Mixin.isValidValue.call(this, value); + } + + public setValidations(validations: string, required: boolean) { + Mixin.setValidations.call(this, validations, required); + } +} diff --git a/app/scripts/modules/core/src/presentation/formsy/components/Input.tsx b/app/scripts/modules/core/src/presentation/formsy/components/Input.tsx new file mode 100644 index 00000000000..4c975395baa --- /dev/null +++ b/app/scripts/modules/core/src/presentation/formsy/components/Input.tsx @@ -0,0 +1,35 @@ +import * as React from 'react'; + +import { FormComponent, IFormComponentState, IFormComponentProps } from '../FormComponent'; + +export interface IProps extends IFormComponentProps { + className: string; + label: string; + type: string; +} + +/** A simple Formsy form component for validated tags (text or checkbox) */ +export class Input extends FormComponent { + public handleValueChanged(value: any) { + this.setValue(value); + } + + public render() { + const { name, label, type } = this.props; + const className = 'form-group' + (this.props.className || ' ') + (this.showRequired() ? 'required' : this.showError() ? 'error' : null); + const errorMessage = this.getErrorMessage(); + return ( +
+ + + {errorMessage} +
+ ); + } +} diff --git a/app/scripts/modules/core/src/presentation/formsy/components/TextArea.tsx b/app/scripts/modules/core/src/presentation/formsy/components/TextArea.tsx new file mode 100644 index 00000000000..e203c2edc5a --- /dev/null +++ b/app/scripts/modules/core/src/presentation/formsy/components/TextArea.tsx @@ -0,0 +1,75 @@ +import * as React from 'react'; +import { ChangeEvent, PropTypes, ValidationMap } from 'react'; +import autoBindMethods from 'class-autobind-decorator'; +import { IFormsyContext } from 'formsy-react'; + +import { FormComponent, IFormComponentProps, IFormComponentState } from '../FormComponent'; +import { IFormFieldLayoutProps } from 'core/presentation'; +import { noop } from 'core/utils'; + +export interface IProps extends IFormComponentProps, React.HTMLAttributes { + /** A react class that will layout the label, input, help, and validation error components */ + Layout: React.ComponentClass; + /** The label text for the textarea */ + label?: string; + /** (optional) The help or usage rollover markup */ + Help?: React.ReactElement; + /** The class string to place on the textarea */ + className?: string; + /** A callback for when the textarea value changes */ + onChange?(event: ChangeEvent): void; +} + +export interface IState extends IFormComponentState { } + +export interface ITextAreaContext { + formsy: IFormsyContext; +} + +/** + * A Formsy form component that accepts a LayoutComponent + */ +@autoBindMethods() +export class TextArea extends FormComponent { + public static contextTypes: ValidationMap = { + formsy: PropTypes.object, + }; + + public static defaultProps: Partial = { + name: null as any, + onChange: noop, + className: '', + }; + + public changeValue(event: ChangeEvent): void { + this.setValue(event.target.value); + this.props.onChange(event); + } + + public render() { + const { label, Help, Layout, name, className, rows } = this.props; + + const Label = label && ; + + const isInvalid = this.showError() || this.showRequired(); + const isDirty = !this.isPristine(); + const inputClass = `form-control ${className} ${isInvalid ? 'ng-invalid' : ''} ${isDirty ? 'ng-dirty' : ''}`; + const Input =