Skip to content

Commit

Permalink
feat(kubernetes/v2): Converts CopyToClipboard to React Component (#6451)
Browse files Browse the repository at this point in the history
* feat(kubernetes/v2): Converts CopyToClipboard to React Component

- Duplicates Angular component with a React component with similar behavior
- Replaces instance of Angular component in executions/Execution.tsx
- Replaces instance of Angular component in ManifestStatus.tsx
  • Loading branch information
DarrenN authored and christopherthielen committed Jan 30, 2019
1 parent 95b1f5c commit dba26d8
Show file tree
Hide file tree
Showing 8 changed files with 234 additions and 11 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,15 @@ import { Subscription } from 'rxjs';
import * as classNames from 'classnames';

import { Application } from 'core/application/application.model';
import { CopyToClipboard } from 'core/utils';
import { StageExecutionDetails } from 'core/pipeline/details/StageExecutionDetails';
import { ExecutionStatus } from 'core/pipeline/status/ExecutionStatus';
import { IExecution, IRestartDetails, IPipeline } from 'core/domain';
import { IExecutionViewState, IPipelineGraphNode } from 'core/pipeline/config/graph/pipelineGraph.service';
import { OrchestratedItemRunningTime } from './OrchestratedItemRunningTime';
import { SETTINGS } from 'core/config/settings';
import { AccountTag } from 'core/account';
import { NgReact, ReactInjector } from 'core/reactShims';
import { ReactInjector } from 'core/reactShims';
import { duration, timestamp } from 'core/utils/timeFormatters';
import { ISortFilter } from 'core/filterModel';
import { ExecutionState } from 'core/state';
Expand Down Expand Up @@ -250,7 +251,6 @@ export class Execution extends React.Component<IExecutionProps, IExecutionState>
const { application, execution, showAccountLabels, showDurations, standalone, title } = this.props;
const { pipelinesUrl, restartDetails, showingDetails, sortFilter, viewState } = this.state;

const { CopyToClipboard } = NgReact;
const accountLabels = this.props.execution.deploymentTargets.map(account => (
<AccountTag key={account} account={account} />
));
Expand Down
3 changes: 0 additions & 3 deletions app/scripts/modules/core/src/reactShims/ngReact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,12 @@ import IInjectorService = angular.auto.IInjectorService;
import { AddEntityTagLinksWrapperComponent } from 'core/entityTag/addEntityTagLinks.component';
import { AccountRegionClusterSelectorWrapperComponent } from 'core/widgets/accountRegionClusterSelectorWrapper.component';
import { ButtonBusyIndicatorComponent } from '../forms/buttonBusyIndicator/buttonBusyIndicator.component';
import { CopyToClipboardComponent } from '../utils/clipboard/copyToClipboard.component';
import { IDiffViewProps } from '../pipeline/config/actions/history/DiffView';
import { EntitySourceComponent } from 'core/entityTag/entitySource.component';
import { HelpFieldWrapperComponent } from '../help/helpField.component';
import { IAccountRegionClusterSelectorProps } from 'core/widgets/AccountRegionClusterSelector';
import { IAddEntityTagLinksProps } from 'core/entityTag/AddEntityTagLinks';
import { IButtonBusyIndicatorProps } from '../forms/buttonBusyIndicator/ButtonBusyIndicator';
import { ICopyToClipboardProps } from '../utils/clipboard/CopyToClipboard';
import { IEntitySourceProps } from 'core/entityTag/EntitySource';
import { IHelpFieldProps } from '../help/HelpField';
import { IInsightLayoutProps } from 'core/insight/InsightLayout';
Expand Down Expand Up @@ -47,7 +45,6 @@ export class NgReactInjector extends ReactInject {
public AccountRegionClusterSelector: React.ComponentClass<IAccountRegionClusterSelectorProps> = angular2react('accountRegionClusterSelectorWrapper', new AccountRegionClusterSelectorWrapperComponent(), this.$injectorProxy) as any;
public AddEntityTagLinks: React.ComponentClass<IAddEntityTagLinksProps> = angular2react('addEntityTagLinksWrapper', new AddEntityTagLinksWrapperComponent(), this.$injectorProxy) as any;
public ButtonBusyIndicator: React.ComponentClass<IButtonBusyIndicatorProps> = angular2react('buttonBusyIndicator', new ButtonBusyIndicatorComponent(), this.$injectorProxy) as any;
public CopyToClipboard: React.ComponentClass<ICopyToClipboardProps> = angular2react('copyToClipboard', new CopyToClipboardComponent(), this.$injectorProxy) as any;
public DiffView: React.ComponentClass<IDiffViewProps> = angular2react('diffView', diffViewComponent, this.$injectorProxy) as any;
public EntitySource: React.ComponentClass<IEntitySourceProps> = angular2react('entitySource', new EntitySourceComponent(), this.$injectorProxy) as any;
public HelpField: React.ComponentClass<IHelpFieldProps> = angular2react('helpFieldWrapper', new HelpFieldWrapperComponent(), this.$injectorProxy) as any;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ describe('Controller: Instance Archetype Selector', function() {

beforeEach(
window.module(
require('./instanceArchetypeSelector').name,
require('./instanceArchetypeSelector.js').name,
INSTANCE_TYPE_SERVICE,
SERVER_GROUP_CONFIGURATION_SERVICE,
),
Expand Down
15 changes: 15 additions & 0 deletions app/scripts/modules/core/src/utils/clipboard/CopyToClipboard.less
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
.clipboard-btn {
background-color: transparent !important;
border-width: 0 !important;
color: var(--color-dovegray) !important;
display: inline-block !important;
margin-left: 2px !important;
}
.clipboard-btn:hover {
background-color: transparent;
}
&.copy-to-clipboard-sm {
.clipboard-btn {
margin-bottom: 3px;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import * as React from 'react';
import * as ReactGA from 'react-ga';
import { mount } from 'enzyme';

import { CopyToClipboard } from './CopyToClipboard';

describe('<CopyToClipboard />', () => {
beforeEach(() => spyOn(ReactGA, 'event'));

it('renders an input with the text value', () => {
const wrapper = mount(<CopyToClipboard toolTip="Copy Rebel Girl" text="Rebel Girl" />);
const input = wrapper.find('input');
expect(input.get(0).props.value).toEqual('Rebel Girl');
});

it('Mouseover/click triggers overlay with toolTip', () => {
const wrapper = mount(<CopyToClipboard toolTip="Copy Rebel Girl" text="Rebel Girl" />);
const button = wrapper.find('button');
button.simulate('mouseOver');

// Grab the overlay from document by generated ID
const overlay = document.getElementById('clipboardValue-Rebel-Girl');
expect(overlay.innerText).toEqual('Copy Rebel Girl');

// Click replaces overlay text with padding + Copied!
button.simulate('click');
expect(overlay.innerText).toEqual('    Copied!    ');
});

it('fires a GA event on click', () => {
const wrapper = mount(<CopyToClipboard toolTip="Copy Rebel Girl" text="Rebel Girl" />);
const button = wrapper.find('button');
button.simulate('click');
expect(ReactGA.event).toHaveBeenCalled();
});
});
174 changes: 173 additions & 1 deletion app/scripts/modules/core/src/utils/clipboard/CopyToClipboard.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,177 @@
import * as React from 'react';
import * as ReactGA from 'react-ga';
import { OverlayTrigger, Tooltip } from 'react-bootstrap';
import { PositionProperty } from 'csstype';
import { padStart, padEnd } from 'lodash';

import './CopyToClipboard.less';

export interface ICopyToClipboardProps {
analyticsLabel?: string;
displayText: boolean;
text: string;
toolTip: string;
analyticsLabel?: string;
}

interface IInputStyle {
backgroundColor: string;
borderWidth: string;
display: string;
height?: string;
marginLeft?: string;
overflow?: string;
position?: PositionProperty;
}

interface ICopyToClipboardState {
tooltipCopy: boolean | string;
inputWidth: number | 'auto';
}

/**
* Places text in an invisible input field so we can auto-focus and select the text
* then copy it to the clipboard onClick. Used in labels found in components like
* ManifestStatus to make it easier to grab data from the UI.
*
* This component mimics utils/clipboard/copyToClipboard.component.ts but
* since the text is placed in an invisible input its very easy to select
* if the copy fails.
*/
export class CopyToClipboard extends React.Component<ICopyToClipboardProps, ICopyToClipboardState> {
public static defaultProps = {
displayText: false,
};

// Handles onto our DOM elements. We need to select data from the input
// and use the hiddenRef span to measure the width of our text
private inputRef: React.RefObject<HTMLInputElement> = React.createRef();
private hiddenRef: React.RefObject<HTMLSpanElement> = React.createRef();

private inputStyle: IInputStyle = {
backgroundColor: 'transparent',
borderWidth: '0px',
display: 'inline-block',
};

private hiddenStyle = {
height: 0,
overflow: 'hidden',
position: 'absolute' as 'absolute',
whiteSpace: 'pre' as 'pre',
};

constructor(props: ICopyToClipboardProps) {
super(props);
this.state = {
tooltipCopy: false,
inputWidth: 'auto',
};
}

/**
* We need to play some games to get the correct width of the container
* input element but grabbing the offsetWidth of a hidden span containing
* the same value text as the input.
*/
public componentDidMount() {
const { displayText } = this.props;
if (displayText) {
const hiddenNode = this.hiddenRef.current;
this.setState({ inputWidth: hiddenNode.offsetWidth + 3 });
}
}

/**
* Focuses on the input element and attempts to copy to the clipboard.
* Also updates state.tooltipCopy with a success/fail message, which is
* reset after 3s. The selection is immediately blur'd so you shouldn't
* see much of it during the copy.
*/
public handleClick = (e: React.SyntheticEvent): void => {
e.preventDefault();

const { analyticsLabel, toolTip, text } = this.props;
ReactGA.event({
category: 'Copy to Clipboard',
action: 'copy',
label: analyticsLabel || text,
});

const node: HTMLInputElement = this.inputRef.current;
node.focus();
node.select();

// A best attempt at trying to keep the Copied! text centered in the
// Tooltip, otherwise it jumps around.
let copiedText = 'Copied!';

const toolTipPadding = Math.round(Math.max(0, toolTip.length - copiedText.length) / 2);
copiedText = padStart(copiedText, copiedText.length + toolTipPadding, '\u2007');
copiedText = padEnd(copiedText, copiedText.length + toolTipPadding, '\u2007');

try {
document.execCommand('copy');
node.blur();
this.setState({ tooltipCopy: copiedText });
window.setTimeout(this.resetToolTip, 3000);
} catch (e) {
this.setState({ tooltipCopy: "Couldn't copy!" });
}
};

public resetToolTip = () => {
this.setState({ tooltipCopy: false });
};

public render() {
const { displayText, toolTip, text = '' } = this.props;
const { inputWidth, tooltipCopy } = this.state;

const persistOverlay = Boolean(tooltipCopy);
const copy = tooltipCopy || toolTip;
const id = `clipboardValue-${text.replace(' ', '-')}`;
const tooltipComponent = <Tooltip id={id}>{copy}</Tooltip>;

let updatedStyle = {
...this.inputStyle,
width: inputWidth,
};

if (!displayText) {
updatedStyle = {
...updatedStyle,
position: 'absolute' as 'absolute',
height: '0px',
marginLeft: '-9999px',
overflow: 'hidden',
};
}

return (
<React.Fragment>
{displayText && (
<span ref={this.hiddenRef} style={this.hiddenStyle}>
{text}
</span>
)}
<input
onChange={e => e} // no-op to prevent warnings
ref={this.inputRef}
value={text}
type="text"
style={updatedStyle}
/>
<OverlayTrigger defaultOverlayShown={persistOverlay} placement="top" overlay={tooltipComponent} delayHide={250}>
<button
onClick={this.handleClick}
className="btn btn-xs btn-default clipboard-btn"
uib-tooltip={toolTip}
aria-label="Copy to clipboard"
>
<span className="glyphicon glyphicon-copy" />
</button>
</OverlayTrigger>
</React.Fragment>
);
}
}
1 change: 1 addition & 0 deletions app/scripts/modules/core/src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
///<reference path="./classnames.d.ts" />

export * from './clipboard/CopyToClipboard';
export * from './debug';
export * from './json/JsonUtils';
export * from './noop';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as React from 'react';
import { IManifest, NgReact } from '@spinnaker/core';
import { CopyToClipboard, IManifest } from '@spinnaker/core';
import { DeployManifestStatusPills } from './DeployStatusPills';
import { ManifestDetailsLink } from './ManifestDetailsLink';
import { ManifestEvents } from './ManifestEvents';
Expand All @@ -14,15 +14,17 @@ export interface IManifestStatusProps {

export class ManifestStatus extends React.Component<IManifestStatusProps> {
public render() {
const { CopyToClipboard } = NgReact;
const { manifest, stage } = this.props;
const { account } = stage.context;
return [
<dl className="manifest-status" key="manifest-status">
<dt>{manifest.manifest.kind}</dt>
<dd>
{manifest.manifest.metadata.name}
<CopyToClipboard text={manifest.manifest.metadata.name} toolTip={`Copy ${manifest.manifest.metadata.name}`} />
<CopyToClipboard
displayText={true}
text={manifest.manifest.metadata.name}
toolTip={`Copy ${manifest.manifest.metadata.name}`}
/>
&nbsp;
<DeployManifestStatusPills manifest={manifest} />
</dd>
Expand Down

0 comments on commit dba26d8

Please sign in to comment.