Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor(core/entityTag): Refactor Entity Tags to React #3717

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {IModalService, IModalSettings} from 'angular-ui-bootstrap';

export interface IConfirmationModalParams {
account?: string;
applicationName?: string;
askForReason?: boolean;
body?: string;
buttonText?: string;
Expand All @@ -12,6 +13,7 @@ export interface IConfirmationModalParams {
multiTaskTitle?: string;
platformHealthOnlyShowOverride?: boolean;
platformHealthType?: string;
provider?: string;
reason?: string;
size?: string;
submitJustWithReason?: boolean;
Expand Down
342 changes: 342 additions & 0 deletions app/scripts/modules/core/src/entityTag/EntityTagEditor.tsx
Original file line number Diff line number Diff line change
@@ -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;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do yall like or hate this syntax?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

works for me! doesn't look like there are arguments though?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The caller can access the trigger event from the click event handler or whatever. IDK if it's necessary.

I used onHide.apply to propagate any arguments

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure!

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<IEntityTagEditorProps, IEntityTagEditorState> {
public static defaultProps: Partial<IEntityTagEditorProps> = {
onHide: noop,
onUpdate: noop,
};

private taskMonitorBuilder: TaskMonitorBuilder = ReactInjector.taskMonitorBuilder;
private entityTagWriter: EntityTagWriter = ReactInjector.entityTagWriter;
private $uibModalInstanceEmulation: IModalServiceInstance & { deferred?: IDeferred<any> };
private form: Form;

/** Shows the Entity Tag Editor modal */
public static show(props: IEntityTagEditorProps): Promise<void> {
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 = (
<div className="modal-close close-button pull-right">
<a className="btn btn-link" onClick={this.onHide}>
<span className="glyphicon glyphicon-remove" />
</a>
</div>
);

const submitLabel = `${isNew ? ' Create' : ' Update'} ${tag.value.type}`;
const cancelButton = <button type="button" className="btn btn-default" onClick={this.onHide}>Cancel</button>;

const { TaskMonitorWrapper } = NgReact;

return (
<Modal show={this.state.show} onHide={this.onHide} dialogClassName="entity-tag-editor-modal">

<TaskMonitorWrapper monitor={this.state.taskMonitor} />
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm shocked that this works

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right?!


<Formsy.Form
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Formsy code. Declare a formsy form. It adds formsy to the React context, which can then be read from children components. The child components interact with the formsy object to notify of their current validity, etc.

ref={this.refCallback}
role="form"
name="form"
className="form-horizontal"
onSubmit={this.upsertTag}
onValid={this.onValid}
onInvalid={this.onInvalid}
>
<Modal.Header>
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

alternatively, <Modal.Header closeButton={true}>

<h3>{isNew ? 'Create' : 'Update'} {tag.value.type}</h3>
{closeButton}
</Modal.Header>

<Modal.Body>
<EntityTagMessage
message={message}
onMessageChanged={this.handleMessageChanged}
/>

{ ownerOptions && ownerOptions.length && (
<OwnerOptions
selectedOwner={this.state.owner}
ownerOptions={ownerOptions}
onOwnerOptionChanged={this.handleOwnerOptionChanged}
/>
) }
</Modal.Body>

<Modal.Footer>
{cancelButton}

<SubmitButton
onClick={this.submit}
label={submitLabel}
isDisabled={!isValid || isSubmitting}
submitting={this.state.isSubmitting}
/>

</Modal.Footer>
</Formsy.Form>

</Modal>
);
}
}



interface IEntityTagMessageProps {
message: string;
onMessageChanged(message: string): void;
}

@autoBindMethods
class EntityTagMessage extends React.Component<IEntityTagMessageProps, {}> {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I split these components out, but left them in the same file. Good? Bad? Separate files? The option component is teeny and I didn't think deserved a file.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fine by me

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small enough, only used here....

private handleTextareaChanged(event: React.FormEvent<HTMLTextAreaElement>): void {
this.props.onMessageChanged(event.currentTarget.value);
}

public render() {
const { message } = this.props;

return (
<div className="row">
<div className="col-md-10 col-md-offset-1">

<TextArea
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Each validation component type has to be written by us.

The label is passed as a string and wrapped in a <label> tag by the TextArea component. In contrast, _help is passed as a react element, and includes markup.

label="Message"
Help={<div>Markdown is okay <HelpField id="markdown.examples"/></div>}
Layout={BasicFieldLayout}
name="message"
required={true}
validationErrors={{ isDefaultRequiredValue: 'Please enter a message' }}
onChange={this.handleTextareaChanged}
value={message}
rows={5}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These props rows and className are propagated to the actual <textarea> element

className="form-control input-sm"
/>

{ message && (
<div className="form-group preview">
<div className="col-md-3 sm-label-right">
<strong>Preview</strong>
</div>
<div className="col-md-9">
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(marked(message)) }}/>
</div>
</div>
) }
</div>
</div>
)
}
}



interface IOwnerOptionsProps {
selectedOwner: any;
ownerOptions: IOwnerOption[];
onOwnerOptionChanged(owner: IOwnerOption): void;
}

@autoBindMethods
class OwnerOptions extends React.Component<IOwnerOptionsProps, void> {
public handleOwnerOptionChanged(option: IOwnerOption): void {
this.props.onOwnerOptionChanged(option);
}

public render() {
const { ownerOptions, selectedOwner } = this.props;

return (
<div className="row">
<div className="col-md-10 col-md-offset-1">
<div className="form-group">
<div className="col-md-3 sm-label-right">
<b>Applies to</b>
</div>
<div className="col-md-9">
{ ownerOptions.map((option: IOwnerOption) => (
<div key={option.label} className="radio">
<label>
<OwnerOption
option={option}
selectedOwner={selectedOwner}
onOwnerOptionChanged={this.handleOwnerOptionChanged}
/>
<span className="marked" dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(marked(option.label)) }}/>
</label>
</div>
)) }
</div>
</div>
</div>
</div>
);
}
}

interface IOwnerOptionProps {
option?: IOwnerOption;
selectedOwner?: any;
onOwnerOptionChanged?(option: IOwnerOption): void;
}

@autoBindMethods
class OwnerOption extends React.Component<IOwnerOptionProps, any> {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This component is here simply to avoid an arrow function

public handleOwnerChanged(): void {
this.props.onOwnerOptionChanged(this.props.option);
}

public render() {
const { option, selectedOwner } = this.props;
return (
<input
name="owner"
type="radio"
value={option.label}
onChange={this.handleOwnerChanged}
checked={option.owner === selectedOwner}
/>
);
}
}
Loading