diff --git a/app/scripts/modules/core/src/loadBalancer/CreateLoadBalancerButton.tsx b/app/scripts/modules/core/src/loadBalancer/CreateLoadBalancerButton.tsx new file mode 100644 index 00000000000..cd45868d0e9 --- /dev/null +++ b/app/scripts/modules/core/src/loadBalancer/CreateLoadBalancerButton.tsx @@ -0,0 +1,82 @@ +import * as React from 'react'; +import { BindAll } from 'lodash-decorators'; + +import { Application, ILoadBalancer, ReactInjector, Tooltip } from '@spinnaker/core'; + +export interface ICreateLoadBalancerButtonProps { + app: Application; +} + +export interface ICreateLoadBalancerButtonState { + provider: any; + showModal: boolean; +} + +@BindAll() +export class CreateLoadBalancerButton extends React.Component { + + constructor(props: ICreateLoadBalancerButtonProps) { + super(props); + this.state = { + provider: null, + showModal: false, + }; + } + + public componentDidMount(): void { + const { providerSelectionService, cloudProviderRegistry, versionSelectionService } = ReactInjector; + const { app } = this.props; + providerSelectionService.selectProvider(app, 'loadBalancer').then((selectedProvider) => { + versionSelectionService.selectVersion(selectedProvider).then((selectedVersion) => { + this.setState({ provider: cloudProviderRegistry.getValue(selectedProvider, 'loadBalancer', selectedVersion) }); + }); + }); + } + + private createLoadBalancer(): void { + const { provider } = this.state; + if (!provider) { return; } + + if (provider.CreateLoadBalancerModal) { + // react + this.setState({ showModal: true }); + } else { + // angular + ReactInjector.modalService.open({ + templateUrl: provider.createLoadBalancerTemplateUrl, + controller: `${provider.createLoadBalancerController} as ctrl`, + size: 'lg', + resolve: { + application: () => this.props.app, + loadBalancer: (): ILoadBalancer => null, + isNew: () => true, + forPipelineConfig: () => false + } + }).result.catch(() => {}); + } + } + + private showModal(show: boolean): void { + this.setState({ showModal: show }); + } + + public render() { + const { app } = this.props; + const { provider, showModal } = this.state; + + const CreateLoadBalancerModal = provider ? provider.CreateLoadBalancerModal : null; + + return ( +
+ + {CreateLoadBalancerModal && } +
+ ); + } +} diff --git a/app/scripts/modules/core/src/loadBalancer/LoadBalancers.tsx b/app/scripts/modules/core/src/loadBalancer/LoadBalancers.tsx index ba85bc2170d..94cd54ae741 100644 --- a/app/scripts/modules/core/src/loadBalancer/LoadBalancers.tsx +++ b/app/scripts/modules/core/src/loadBalancer/LoadBalancers.tsx @@ -4,12 +4,12 @@ import { Subscription } from 'rxjs'; import { Application } from 'core/application/application.model'; import { FilterTags, IFilterTag } from 'core/filterModel/FilterTags'; -import { ILoadBalancer, ILoadBalancerGroup } from 'core/domain'; +import { ILoadBalancerGroup } from 'core/domain'; import { LoadBalancerPod } from './LoadBalancerPod'; -import { Tooltip } from 'core/presentation/Tooltip'; import { Spinner } from 'core/widgets/spinners/Spinner'; import { NgReact, ReactInjector } from 'core/reactShims'; +import { CreateLoadBalancerButton } from 'core/loadBalancer/CreateLoadBalancerButton'; export interface ILoadBalancersProps { app: Application; @@ -81,27 +81,6 @@ export class LoadBalancers extends React.Component { - versionSelectionService.selectVersion(selectedProvider).then((selectedVersion) => { - const provider = cloudProviderRegistry.getValue(selectedProvider, 'loadBalancer', selectedVersion); - ReactInjector.modalService.open({ - templateUrl: provider.createLoadBalancerTemplateUrl, - controller: `${provider.createLoadBalancerController} as ctrl`, - size: 'lg', - resolve: { - application: () => app, - loadBalancer: (): ILoadBalancer => null, - isNew: () => true, - forPipelineConfig: () => false - } - }).result.catch(() => {}); - }); - }); - }; - private updateUIState(state: ILoadBalancersState): void { const params: any = { hideServerGroups: undefined, @@ -181,13 +160,7 @@ export class LoadBalancers extends React.Component
- +
diff --git a/app/scripts/modules/core/src/loadBalancer/index.ts b/app/scripts/modules/core/src/loadBalancer/index.ts index 14dc619feb3..6cb52827b4e 100644 --- a/app/scripts/modules/core/src/loadBalancer/index.ts +++ b/app/scripts/modules/core/src/loadBalancer/index.ts @@ -2,6 +2,7 @@ export * from './loadBalancerDataUtils'; export * from './loadBalancer.read.service'; export * from './loadBalancer.write.service'; +export * from './CreateLoadBalancerButton'; export * from './LoadBalancerClusterContainer'; export * from './LoadBalancerInstances'; export * from './LoadBalancerServerGroup'; diff --git a/app/scripts/modules/core/src/modal/buttons/ModalClose.tsx b/app/scripts/modules/core/src/modal/buttons/ModalClose.tsx new file mode 100644 index 00000000000..25c735fa3c9 --- /dev/null +++ b/app/scripts/modules/core/src/modal/buttons/ModalClose.tsx @@ -0,0 +1,17 @@ +import * as React from 'react'; + +export interface IModalCloseProps { + dismiss: () => void; +} + +export class ModalClose extends React.Component { + public render() { + return ( +
+ +
+ ); + } +} diff --git a/app/scripts/modules/core/src/modal/buttons/SubmitButton.tsx b/app/scripts/modules/core/src/modal/buttons/SubmitButton.tsx index 27c86aacb21..8b51681027c 100644 --- a/app/scripts/modules/core/src/modal/buttons/SubmitButton.tsx +++ b/app/scripts/modules/core/src/modal/buttons/SubmitButton.tsx @@ -2,10 +2,12 @@ import * as React from 'react'; import { Button } from 'react-bootstrap'; import { NgReact } from 'core/reactShims'; +import { noop } from 'core/utils'; export interface ISubmitButtonProps { - onClick: () => void; + onClick?: () => void; isDisabled?: boolean; + isFormSubmit?: boolean; isNew?: boolean; submitting: boolean; label: string; @@ -13,19 +15,21 @@ export interface ISubmitButtonProps { export class SubmitButton extends React.Component { public render() { + const { isDisabled, isFormSubmit, isNew, label, onClick, submitting } = this.props; const { ButtonBusyIndicator } = NgReact; return ( ); } diff --git a/app/scripts/modules/core/src/modal/index.ts b/app/scripts/modules/core/src/modal/index.ts index b0b8be94542..c805fe339ce 100644 --- a/app/scripts/modules/core/src/modal/index.ts +++ b/app/scripts/modules/core/src/modal/index.ts @@ -1,3 +1,6 @@ +export * from './buttons/ModalClose'; export * from './buttons/SubmitButton'; +export * from './wizard/WizardModal'; +export * from './wizard/WizardPage'; export { V2_MODAL_WIZARD_COMPONENT, V2ModalWizard } from './wizard/v2modalWizard.component'; export { WizardPage, V2_MODAL_WIZARD_SERVICE, V2ModalWizardService, WizardPageState } from './wizard/v2modalWizard.service'; diff --git a/app/scripts/modules/core/src/modal/wizard/WizardModal.tsx b/app/scripts/modules/core/src/modal/wizard/WizardModal.tsx new file mode 100644 index 00000000000..693128cc085 --- /dev/null +++ b/app/scripts/modules/core/src/modal/wizard/WizardModal.tsx @@ -0,0 +1,241 @@ +import * as React from 'react'; +import * as classNames from 'classnames'; +import { BindAll } from 'lodash-decorators'; +import { Formik, Form, FormikProps, FormikValues } from 'formik'; +import { Modal } from 'react-bootstrap'; + +import { TaskMonitor } from 'core'; +import { NgReact } from 'core/reactShims'; + +import { ModalClose } from '../buttons/ModalClose'; +import { SubmitButton } from '../buttons/SubmitButton'; + +import { IWizardPageProps, IWizardPageValidate } from './WizardPage'; + +export interface IWizardPageData { + element: HTMLElement; + label: string; + props: IWizardPageProps; + validate: IWizardPageValidate; +} + +export interface IWizardModalProps { + dismiss: () => void; + heading: string; + hideSections?: Set; + initialValues: FormikValues; + show: boolean; + submit: (values: FormikValues) => void; + submitButtonLabel: string; + taskMonitor: TaskMonitor; + validate: IWizardPageValidate; +} + +export interface IWizardModalState { + currentPage: IWizardPageData; + dirtyPages: Set; + errorPages: Set; + formInvalid: boolean; + pages: string[]; +} + +@BindAll() +export class WizardModal extends React.Component { + private pages: { [label: string]: IWizardPageData } = {}; + private stepsElement: HTMLDivElement; + + constructor(props: IWizardModalProps) { + super(props); + + this.state = { + currentPage: null, + dirtyPages: new Set(), + errorPages: new Set(), + formInvalid: false, + pages: [], + }; + + } + + private setCurrentPage(pageState: IWizardPageData): void { + if (this.stepsElement) { + this.stepsElement.scrollTop = pageState.element.offsetTop; + } + this.setState({ currentPage: pageState }); + } + + private onHide(): void { + // noop + } + + private onMount(element: any): void { + if (element) { + const label = element.state.label; + this.pages[label] = { + element: element.element, + label, + validate: element.validate, + props: element.props, + }; + } + } + + private dirtyCallback(name: string, dirty: boolean): void { + const dirtyPages = new Set(this.state.dirtyPages); + if (dirty) { + dirtyPages.add(name); + } else { + dirtyPages.delete(name); + } + this.setState({ dirtyPages }); + } + + public componentDidMount(): void { + const pages = this.getVisiblePageNames(); + this.setState({ pages: this.getVisiblePageNames(), currentPage: this.pages[pages[0]] }); + } + + public componentWillReceiveProps(): void { + this.setState({ pages: this.getVisiblePageNames() }); + } + + public componentWillUnmount(): void { + this.pages = {}; + } + + private handleStepsScroll(event: React.UIEvent): void { + // Cannot precalculate because sections can shrink/grow. + // Could optimize by having a callback every time the size changes... but premature + const pageTops = this.state.pages.map((pageName) => this.pages[pageName].element.offsetTop); + const scrollTop = event.currentTarget.scrollTop; + + let reversedCurrentPage = pageTops.reverse().findIndex((pageTop) => scrollTop >= pageTop); + if (reversedCurrentPage === undefined) { reversedCurrentPage = pageTops.length - 1; } + const currentPageIndex = pageTops.length - (reversedCurrentPage + 1); + const currentPage = this.pages[this.state.pages[currentPageIndex]]; + + this.setState({ currentPage }); + } + + private getFilteredChildren(): React.ReactChild[] { + return React.Children.toArray(this.props.children).filter( + (child: any): boolean => { + if (!child || !child.type || !child.type.label) { return false; } + return !this.props.hideSections.has(child.type.label); + } + ); + } + + private getVisiblePageNames(): string[] { + return this.getFilteredChildren().map((child: any) => child.type.label); + } + + private validate(values: {}): any { + const errors: { [key: string]: string }[] = []; + const newErrorPages: Set = new Set(); + + this.state.pages.forEach((pageName) => { + const pageErrors = this.pages[pageName].validate ? this.pages[pageName].validate(values) : {}; + if (Object.keys(pageErrors).length > 0) { + newErrorPages.add(pageName); + } else { + newErrorPages.delete(pageName); + } + errors.push(pageErrors); + }); + errors.push(this.props.validate(values)); + const flattenedErrors = Object.assign({}, ...errors); + this.setState({ errorPages: newErrorPages, formInvalid: Object.keys(flattenedErrors).length > 0 }); + return flattenedErrors; + } + + private revalidate(values: {}, setErrors: (errors: {}) => void) { + setErrors(this.validate(values)); + } + + public render(): React.ReactElement> { + const { dismiss, heading, hideSections, initialValues, show, submitButtonLabel, taskMonitor } = this.props; + const { currentPage, dirtyPages, errorPages, formInvalid, pages } = this.state; + const { TaskMonitorWrapper } = NgReact; + + const pagesToShow = pages.filter((page) => !hideSections.has(page)); + + const submitting = taskMonitor && taskMonitor.submitting; + + return ( + + {taskMonitor && } + ) => ( +
+ + + {heading &&

{heading}

} +
+ +
+
+
    + {pagesToShow.map((pageName) => ( + + ))} +
+
+
+
this.stepsElement = ele} onScroll={this.handleStepsScroll}> + {this.getFilteredChildren().map((child: React.ReactElement) => { + return React.cloneElement(child, { ...props, dirtyCallback: this.dirtyCallback, onMount: this.onMount, revalidate: () => this.revalidate(props.values, props.setErrors) }); + })} +
+
+
+
+ + + + + + )} + /> +
+ ); + } +} + +const WizardStepLabel = (props: { current: boolean, dirty: boolean, pageState: IWizardPageData, onClick: (pageState: IWizardPageData) => void }): JSX.Element => { + const { current, dirty, onClick, pageState } = props; + const className = classNames({ + default: !pageState.props.done, + dirty, + current, + done: pageState.props.done, + }); + const handleClick = () => { onClick(pageState); }; + + return ( +
  • + {pageState.label} +
  • + ) +}; diff --git a/app/scripts/modules/core/src/modal/wizard/WizardPage.tsx b/app/scripts/modules/core/src/modal/wizard/WizardPage.tsx new file mode 100644 index 00000000000..58be86e949d --- /dev/null +++ b/app/scripts/modules/core/src/modal/wizard/WizardPage.tsx @@ -0,0 +1,77 @@ +import * as React from 'react'; +import * as classNames from 'classnames'; +import { BindAll } from 'lodash-decorators'; + +import { noop } from 'core/utils'; + +export interface IWizardPageProps { + mandatory?: boolean; + dirty?: boolean; + dontMarkCompleteOnView?: boolean; + done?: boolean; + onMount?: (self: IWrappedWizardPage) => void; + dirtyCallback?: (name: string, dirty: boolean) => void; + ref?: () => void; + revalidate?: () => void; +} + +export type IWizardPageValidate = (values: { [key: string]: any } ) => { [key: string]: string }; +export type IWrappedWizardPage = (React.ComponentClass | React.SFC) & { LABEL: string }; + +export function wizardPage

    (WrappedComponent: IWrappedWizardPage) { + @BindAll() + class WizardPage extends React.Component

    { + public static defaultProps: Partial = { + dirtyCallback: noop, + }; + public static label = WrappedComponent.LABEL; + + public element: any; + public validate: IWizardPageValidate; + + constructor(props: P & IWizardPageProps) { + super(props); + this.state = { + label: WrappedComponent.LABEL, + }; + } + + public componentDidMount(): void { + this.props.onMount(this as any); + } + + public componentWillUnmount(): void { + this.props.onMount(undefined); + } + + private handleRef(element: any) { + if (element) { this.element = element; } + } + + private handleWrappedRef(wrappedComponent: any) { + if (wrappedComponent) { this.validate = wrappedComponent.validate; } + } + + public render() { + const { dirtyCallback, dirty, done, mandatory } = this.props; + const showDone = done || !mandatory; + const className = classNames({ + default: !showDone, + dirty, + done: showDone, + }); + + return ( +

    +
    +

    {WrappedComponent.LABEL}

    +
    +
    + +
    +
    + ); + } + } + return WizardPage; +}; diff --git a/app/scripts/modules/core/src/modal/wizard/modalWizard.less b/app/scripts/modules/core/src/modal/wizard/modalWizard.less index 0f87aa57130..769cc0288d4 100644 --- a/app/scripts/modules/core/src/modal/wizard/modalWizard.less +++ b/app/scripts/modules/core/src/modal/wizard/modalWizard.less @@ -3,7 +3,7 @@ @indicator-size: 14px; @indicator-radius: @indicator-size/2; -v2-modal-wizard { +v2-modal-wizard, .wizard-modal { .modal-header { /* Set to keep sticky section headings beneath modal header*/ position: relative; @@ -78,7 +78,6 @@ v2-modal-wizard { color: var(--color-danger); background-color: transparent; content: "\e088"; - top: 11px; } } } @@ -118,7 +117,6 @@ v2-modal-wizard { color: var(--color-danger); background-color: transparent; content: "\e088"; - top: 11px; } } diff --git a/app/scripts/modules/core/src/presentation/main.less b/app/scripts/modules/core/src/presentation/main.less index 1a8b2366042..3329647d369 100644 --- a/app/scripts/modules/core/src/presentation/main.less +++ b/app/scripts/modules/core/src/presentation/main.less @@ -727,6 +727,21 @@ tfoot .add-new { margin-bottom: 0; } +.form-control.invalid { + border-color: var(--color-danger); + .box-shadow(inset 0 1px 1px rgba(0, 0, 0, .075)); // Redeclare so transitions work + &:focus { + border-color: var(--color-danger); + @shadow: inset 0 1px 1px rgba(0, 0, 0, .075), 0 0 6px var(--color-danger-light); + .box-shadow(@shadow); + } +} + +input:invalid { + border-color: var(--color-danger); + -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px var(--color-danger-light); + box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px var(--color-danger-light); +} .form-control { &.ng-invalid { @@ -1165,6 +1180,12 @@ select.input-sm { padding-left: 14px; } +.Select.is-open { z-index: 10000; } + +.sortable-helper { + z-index: 10000; +} + html { overflow: hidden; } diff --git a/app/scripts/modules/core/src/styleguide/public/styleguide.html b/app/scripts/modules/core/src/styleguide/public/styleguide.html index db7ab426b11..e79d24e2223 100644 --- a/app/scripts/modules/core/src/styleguide/public/styleguide.html +++ b/app/scripts/modules/core/src/styleguide/public/styleguide.html @@ -1,5 +1,5 @@
    - +