Skip to content

Commit

Permalink
Add a copy icon to entries in KeyValuesTable (#204) (#292)
Browse files Browse the repository at this point in the history
* Add a copy icon to entries in KeyValuesTable (#204)

Signed-off-by: Everett Ross <reverett@uber.com>

* Add a tooltip to copy icon in KeyValuesTable

Signed-off-by: Everett Ross <reverett@uber.com>

* Fix copied test name, add test for KeyValuesTable state change on tooltip hide

Signed-off-by: Everett Ross <reverett@uber.com>

* Add eslint rule to prevent unnecessary braces in jsx

Signed-off-by: Everett Ross <reverett@uber.com>

* Add classname to tr to remove element selector, fix yarn.lock

Signed-off-by: Everett Ross <reverett@uber.com>
  • Loading branch information
everett980 authored and tiffon committed Jan 4, 2019
1 parent 0a0cf66 commit d1d258a
Show file tree
Hide file tree
Showing 10 changed files with 286 additions and 70 deletions.
1 change: 1 addition & 0 deletions .eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"jsx-a11y/interactive-supports-focus": 0,

/* react */
"react/jsx-curly-brace-presence": [2, 'never'],
"react/jsx-filename-extension": 0,
"react/forbid-prop-types": 1,
"react/require-default-props": 1,
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"eslint-plugin-flowtype": "^2.35.0",
"eslint-plugin-import": "^2.7.0",
"eslint-plugin-jsx-a11y": "^6.0.2",
"eslint-plugin-react": "^7.2.1",
"eslint-plugin-react": "^7.12.2",
"flow-bin": "^0.71.0",
"glow": "^1.2.2",
"husky": "^0.14.3",
Expand Down
1 change: 1 addition & 0 deletions packages/jaeger-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
"query-string": "^5.0.0",
"raven-js": "^3.22.1",
"react": "^16.3.2",
"react-copy-to-clipboard": "^5.0.1",
"react-dimensions": "^1.3.0",
"react-dom": "^16.3.2",
"react-ga": "^2.4.1",
Expand Down
2 changes: 1 addition & 1 deletion packages/jaeger-ui/src/components/App/NotFound.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export default function NotFound({ error }: NotFoundProps) {
<section className="ub-m3">
<h1>Error</h1>
{error && <ErrorMessage error={error} />}
<Link to={prefixUrl('/')}>{'Back home'}</Link>
<Link to={prefixUrl('/')}>Back home</Link>
</section>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ export default function TraceHeader(props: Props) {
const AttrsComponent = state === fetchedState.DONE ? Attrs : EmptyAttrs;
return (
<div className="TraecDiffHeader--traceHeader">
<h1 className={`TraecDiffHeader--traceTitle`}>
<h1 className="TraecDiffHeader--traceTitle">
<span>
{traceID ? (
<React.Fragment>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,11 @@ limitations under the License.
vertical-align: baseline;
}

.KeyValueTable--body > tr > td {
.KeyValueTable--row > td {
padding: 0.25rem 0.5rem;
}

.KeyValueTable--body > tr:nth-child(2n) > td {
.KeyValueTable--row:nth-child(2n) > td {
background: #f5f5f5;
}

Expand All @@ -40,7 +40,19 @@ limitations under the License.
width: 125px;
}

.KeyValueTable--body > tr > td {
.KeyValueTable--copyColumn {
text-align: right;
}

.KeyValueTable--copyIcon {
visibility: hidden;
}

.KeyValueTable--row:hover > .KeyValueTable--copyColumn > .KeyValueTable--copyIcon {
visibility: unset;
}

.KeyValueTable--row > td {
padding: 0.25rem 0.5rem;
vertical-align: top;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@

import * as React from 'react';
import jsonMarkup from 'json-markup';
import { Dropdown, Icon, Menu } from 'antd';
import { Dropdown, Icon, Menu, Tooltip } from 'antd';
import { CopyToClipboard } from 'react-copy-to-clipboard';
import type { KeyValuePair, Link } from '../../../../types/trace';

import './KeyValuesTable.css';
Expand All @@ -32,7 +33,7 @@ function parseIfJson(value) {
return value;
}

const LinkValue = (props: { href: string, title?: string, children: React.Node }) => (
export const LinkValue = (props: { href: string, title?: string, children: React.Node }) => (
<a href={props.href} title={props.title} target="_blank" rel="noopener noreferrer">
{props.children} <Icon className="KeyValueTable--linkIcon" type="export" />
</a>
Expand All @@ -55,54 +56,104 @@ type KeyValuesTableProps = {
linksGetter: ?(KeyValuePair[], number) => Link[],
};

export default function KeyValuesTable(props: KeyValuesTableProps) {
const { data, linksGetter } = props;
return (
<div className="KeyValueTable u-simple-scrollbars">
<table className="u-width-100">
<tbody className="KeyValueTable--body">
{data.map((row, i) => {
const markup = {
__html: jsonMarkup(parseIfJson(row.value)),
};
// eslint-disable-next-line react/no-danger
const jsonTable = <div className="ub-inline-block" dangerouslySetInnerHTML={markup} />;
const links = linksGetter ? linksGetter(data, i) : null;
let valueMarkup;
if (links && links.length === 1) {
valueMarkup = (
<div>
<LinkValue href={links[0].url} title={links[0].text}>
{jsonTable}
</LinkValue>
</div>
);
} else if (links && links.length > 1) {
valueMarkup = (
<div>
<Dropdown overlay={linkValueList(links)} placement="bottomRight" trigger={['click']}>
<a>
{jsonTable} <Icon className="KeyValueTable--linkIcon" type="profile" />
</a>
</Dropdown>
</div>
type KeyValuesTableState = {
copiedRows: Set<KeyValuePair>,
};

export default class KeyValuesTable extends React.PureComponent<KeyValuesTableProps, KeyValuesTableState> {
props: KeyValuesTableProps;

constructor(props: KeyValuesTableProps) {
super(props);

this.state = {
copiedRows: new Set(),
};
}

handleCopyIconClick = (row: KeyValuePair) => {
const newCopiedRows = new Set(this.state.copiedRows);
newCopiedRows.add(row);
this.setState({
copiedRows: newCopiedRows,
});
};

handleTooltipVisibilityChange = (row: KeyValuePair, visible: boolean) => {
if (!visible && this.state.copiedRows.has(row)) {
const newCopiedRows = new Set(this.state.copiedRows);
newCopiedRows.delete(row);
this.setState({
copiedRows: newCopiedRows,
});
}
};

render() {
const { data, linksGetter } = this.props;
return (
<div className="KeyValueTable u-simple-scrollbars">
<table className="u-width-100">
<tbody className="KeyValueTable--body">
{data.map((row, i) => {
const tooltipTitle = this.state.copiedRows.has(row) ? 'Copied' : 'Copy JSON';
const markup = {
__html: jsonMarkup(parseIfJson(row.value)),
};
// eslint-disable-next-line react/no-danger
const jsonTable = <div className="ub-inline-block" dangerouslySetInnerHTML={markup} />;
const links = linksGetter ? linksGetter(data, i) : null;
let valueMarkup;
if (links && links.length === 1) {
valueMarkup = (
<div>
<LinkValue href={links[0].url} title={links[0].text}>
{jsonTable}
</LinkValue>
</div>
);
} else if (links && links.length > 1) {
valueMarkup = (
<div>
<Dropdown overlay={linkValueList(links)} placement="bottomRight" trigger={['click']}>
<a>
{jsonTable} <Icon className="KeyValueTable--linkIcon" type="profile" />
</a>
</Dropdown>
</div>
);
} else {
valueMarkup = jsonTable;
}
return (
// `i` is necessary in the key because row.key can repeat
// eslint-disable-next-line react/no-array-index-key
<tr className="KeyValueTable--row" key={`${row.key}-${i}`}>
<td className="KeyValueTable--keyColumn">{row.key}</td>
<td>{valueMarkup}</td>
<td className="KeyValueTable--copyColumn">
<Tooltip
arrowPointAtCenter
mouseLeaveDelay={0.5}
onVisibleChange={visible => this.handleTooltipVisibilityChange(row, visible)}
placement="left"
title={tooltipTitle}
>
<CopyToClipboard text={JSON.stringify(row, null, 2)}>
<Icon
className="KeyValueTable--copyIcon"
onClick={() => this.handleCopyIconClick(row)}
type="copy"
/>
</CopyToClipboard>
</Tooltip>
</td>
</tr>
);
} else {
valueMarkup = jsonTable;
}
return (
// `i` is necessary in the key because row.key can repeat
// eslint-disable-next-line react/no-array-index-key
<tr key={`${row.key}-${i}`}>
<td className="KeyValueTable--keyColumn">{row.key}</td>
<td>{valueMarkup}</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
})}
</tbody>
</table>
</div>
);
}
}

KeyValuesTable.LinkValue = LinkValue;
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@

import React from 'react';
import { shallow } from 'enzyme';
import { Dropdown } from 'antd';
import { Dropdown, Icon, Tooltip } from 'antd';
import { CopyToClipboard } from 'react-copy-to-clipboard';

import KeyValuesTable from './KeyValuesTable';
import KeyValuesTable, { LinkValue } from './KeyValuesTable';

describe('<KeyValuesTable>', () => {
let wrapper;
Expand Down Expand Up @@ -53,7 +54,7 @@ describe('<KeyValuesTable>', () => {
: [],
});

const anchor = wrapper.find(KeyValuesTable.LinkValue);
const anchor = wrapper.find(LinkValue);
expect(anchor).toHaveLength(1);
expect(anchor.prop('href')).toBe('http://example.com/?kind=client');
expect(anchor.prop('title')).toBe('More info about client');
Expand All @@ -78,7 +79,7 @@ describe('<KeyValuesTable>', () => {
});
const dropdown = wrapper.find(Dropdown);
const menu = shallow(dropdown.prop('overlay'));
const anchors = menu.find(KeyValuesTable.LinkValue);
const anchors = menu.find(LinkValue);
expect(anchors).toHaveLength(2);
const firstAnchor = anchors.first();
expect(firstAnchor.prop('href')).toBe('http://example.com/1?kind=client');
Expand All @@ -94,4 +95,50 @@ describe('<KeyValuesTable>', () => {
.text()
).toBe('span.kind');
});

describe('CopyIcon', () => {
const indexToCopy = 1;

it('should render a Copy icon with <CopyToClipboard /> and <Tooltip /> for each data element', () => {
const trs = wrapper.find('tr');
expect(trs.length).toBe(data.length);
trs.forEach((tr, i) => {
const copyColumn = tr.find('.KeyValueTable--copyColumn');
expect(copyColumn.find(CopyToClipboard).prop('text')).toBe(JSON.stringify(data[i], null, 2));
expect(copyColumn.find(Tooltip).length).toBe(1);
expect(copyColumn.find({ type: 'copy' }).length).toBe(1);
});
});

it('should add correct data entry to state when icon is clicked', () => {
expect(wrapper.state().copiedRows.size).toBe(0);
wrapper
.find('tr')
.at(indexToCopy)
.find(Icon)
.simulate('click');
expect(wrapper.state().copiedRows.size).toBe(1);
expect(wrapper.state().copiedRows.has(data[indexToCopy])).toBe(true);
});

it('should remove correct data entry to state when tooltip hides', () => {
wrapper.setState({ copiedRows: new Set(data) });
wrapper
.find('tr')
.at(indexToCopy)
.find(Tooltip)
.prop('onVisibleChange')(false);
expect(wrapper.state().copiedRows.size).toBe(data.length - 1);
expect(wrapper.state().copiedRows.has(data[indexToCopy])).toBe(false);
});

it('should render correct tooltip title for each row', () => {
wrapper.setState({ copiedRows: new Set([data[indexToCopy]]) });
const tooltips = wrapper.find(Tooltip);
tooltips.forEach((tooltip, i) =>
expect(tooltip.prop('title')).toBe(i === indexToCopy ? 'Copied' : 'Copy JSON')
);
expect.assertions(data.length);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export default class SpanDetailRow extends React.PureComponent<SpanDetailRowProp
traceStartTime,
} = this.props;
return (
<TimelineRow className={`detail-row`}>
<TimelineRow className="detail-row">
<TimelineRow.Cell width={columnDivision}>
<SpanTreeOffset level={span.depth + 1} />
<span>
Expand Down
Loading

0 comments on commit d1d258a

Please sign in to comment.