diff --git a/app/scripts/modules/core/src/pipeline/executions/execution/Execution.tsx b/app/scripts/modules/core/src/pipeline/executions/execution/Execution.tsx index 2d76a4e01f0..c162a35df44 100644 --- a/app/scripts/modules/core/src/pipeline/executions/execution/Execution.tsx +++ b/app/scripts/modules/core/src/pipeline/executions/execution/Execution.tsx @@ -6,6 +6,7 @@ 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'; @@ -13,7 +14,7 @@ import { IExecutionViewState, IPipelineGraphNode } from 'core/pipeline/config/gr 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'; @@ -250,7 +251,6 @@ export class Execution extends React.Component 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 => ( )); diff --git a/app/scripts/modules/core/src/reactShims/ngReact.ts b/app/scripts/modules/core/src/reactShims/ngReact.ts index a85cbeb3b9a..3f69dc70217 100644 --- a/app/scripts/modules/core/src/reactShims/ngReact.ts +++ b/app/scripts/modules/core/src/reactShims/ngReact.ts @@ -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'; @@ -47,7 +45,6 @@ export class NgReactInjector extends ReactInject { public AccountRegionClusterSelector: React.ComponentClass = angular2react('accountRegionClusterSelectorWrapper', new AccountRegionClusterSelectorWrapperComponent(), this.$injectorProxy) as any; public AddEntityTagLinks: React.ComponentClass = angular2react('addEntityTagLinksWrapper', new AddEntityTagLinksWrapperComponent(), this.$injectorProxy) as any; public ButtonBusyIndicator: React.ComponentClass = angular2react('buttonBusyIndicator', new ButtonBusyIndicatorComponent(), this.$injectorProxy) as any; - public CopyToClipboard: React.ComponentClass = angular2react('copyToClipboard', new CopyToClipboardComponent(), this.$injectorProxy) as any; public DiffView: React.ComponentClass = angular2react('diffView', diffViewComponent, this.$injectorProxy) as any; public EntitySource: React.ComponentClass = angular2react('entitySource', new EntitySourceComponent(), this.$injectorProxy) as any; public HelpField: React.ComponentClass = angular2react('helpFieldWrapper', new HelpFieldWrapperComponent(), this.$injectorProxy) as any; diff --git a/app/scripts/modules/core/src/serverGroup/configure/common/instanceArchtypeSelector.spec.js b/app/scripts/modules/core/src/serverGroup/configure/common/instanceArchtypeSelector.spec.js index 9a07f72f7a5..7495a2cd1a1 100644 --- a/app/scripts/modules/core/src/serverGroup/configure/common/instanceArchtypeSelector.spec.js +++ b/app/scripts/modules/core/src/serverGroup/configure/common/instanceArchtypeSelector.spec.js @@ -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, ), diff --git a/app/scripts/modules/core/src/utils/clipboard/CopyToClipboard.less b/app/scripts/modules/core/src/utils/clipboard/CopyToClipboard.less new file mode 100644 index 00000000000..ec5d012856a --- /dev/null +++ b/app/scripts/modules/core/src/utils/clipboard/CopyToClipboard.less @@ -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; + } +} diff --git a/app/scripts/modules/core/src/utils/clipboard/CopyToClipboard.spec.tsx b/app/scripts/modules/core/src/utils/clipboard/CopyToClipboard.spec.tsx new file mode 100644 index 00000000000..8472d12d9f2 --- /dev/null +++ b/app/scripts/modules/core/src/utils/clipboard/CopyToClipboard.spec.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; +import * as ReactGA from 'react-ga'; +import { mount } from 'enzyme'; + +import { CopyToClipboard } from './CopyToClipboard'; + +describe('', () => { + beforeEach(() => spyOn(ReactGA, 'event')); + + it('renders an input with the text value', () => { + const wrapper = mount(); + const input = wrapper.find('input'); + expect(input.get(0).props.value).toEqual('Rebel Girl'); + }); + + it('Mouseover/click triggers overlay with toolTip', () => { + const wrapper = mount(); + 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(); + const button = wrapper.find('button'); + button.simulate('click'); + expect(ReactGA.event).toHaveBeenCalled(); + }); +}); diff --git a/app/scripts/modules/core/src/utils/clipboard/CopyToClipboard.tsx b/app/scripts/modules/core/src/utils/clipboard/CopyToClipboard.tsx index 6edf31ed0de..37c421cd327 100644 --- a/app/scripts/modules/core/src/utils/clipboard/CopyToClipboard.tsx +++ b/app/scripts/modules/core/src/utils/clipboard/CopyToClipboard.tsx @@ -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 { + 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 = React.createRef(); + private hiddenRef: React.RefObject = 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 = {copy}; + + let updatedStyle = { + ...this.inputStyle, + width: inputWidth, + }; + + if (!displayText) { + updatedStyle = { + ...updatedStyle, + position: 'absolute' as 'absolute', + height: '0px', + marginLeft: '-9999px', + overflow: 'hidden', + }; + } + + return ( + + {displayText && ( + + {text} + + )} + e} // no-op to prevent warnings + ref={this.inputRef} + value={text} + type="text" + style={updatedStyle} + /> + + + + + ); + } } diff --git a/app/scripts/modules/core/src/utils/index.ts b/app/scripts/modules/core/src/utils/index.ts index 115720c1ca9..a179f0b800b 100644 --- a/app/scripts/modules/core/src/utils/index.ts +++ b/app/scripts/modules/core/src/utils/index.ts @@ -1,5 +1,6 @@ /// +export * from './clipboard/CopyToClipboard'; export * from './debug'; export * from './json/JsonUtils'; export * from './noop'; diff --git a/app/scripts/modules/kubernetes/src/v2/pipelines/stages/deployManifest/react/ManifestStatus.tsx b/app/scripts/modules/kubernetes/src/v2/pipelines/stages/deployManifest/react/ManifestStatus.tsx index d75a01ff2a1..8d7489a28ef 100644 --- a/app/scripts/modules/kubernetes/src/v2/pipelines/stages/deployManifest/react/ManifestStatus.tsx +++ b/app/scripts/modules/kubernetes/src/v2/pipelines/stages/deployManifest/react/ManifestStatus.tsx @@ -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'; @@ -14,15 +14,17 @@ export interface IManifestStatusProps { export class ManifestStatus extends React.Component { public render() { - const { CopyToClipboard } = NgReact; const { manifest, stage } = this.props; const { account } = stage.context; return [
{manifest.manifest.kind}
- {manifest.manifest.metadata.name} - +