diff --git a/awx/ui_next/src/components/ClipboardCopyButton/ClipboardCopyButton.jsx b/awx/ui_next/src/components/ClipboardCopyButton/ClipboardCopyButton.jsx
new file mode 100644
index 0000000000..9bd91fbac3
--- /dev/null
+++ b/awx/ui_next/src/components/ClipboardCopyButton/ClipboardCopyButton.jsx
@@ -0,0 +1,92 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Button, Tooltip } from '@patternfly/react-core';
+import { CopyIcon } from '@patternfly/react-icons';
+
+import styled from 'styled-components';
+
+const CopyButton = styled(Button)`
+ padding: 2px 4px;
+ margin-left: 8px;
+ border: none;
+ &:hover {
+ background-color: #0066cc;
+ color: white;
+ }
+`;
+
+export const clipboardCopyFunc = (event, text) => {
+ const clipboard = event.currentTarget.parentElement;
+ const el = document.createElement('input');
+ el.value = text;
+ clipboard.appendChild(el);
+ el.select();
+ document.execCommand('copy');
+ clipboard.removeChild(el);
+};
+
+class ClipboardCopyButton extends React.Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ copied: false,
+ };
+
+ this.handleCopyClick = this.handleCopyClick.bind(this);
+ }
+
+ handleCopyClick = event => {
+ const { stringToCopy, switchDelay } = this.props;
+ if (this.timer) {
+ window.clearTimeout(this.timer);
+ this.setState({ copied: false });
+ }
+ clipboardCopyFunc(event, stringToCopy);
+ this.setState({ copied: true }, () => {
+ this.timer = window.setTimeout(() => {
+ this.setState({ copied: false });
+ this.timer = null;
+ }, switchDelay);
+ });
+ };
+
+ render() {
+ const { clickTip, entryDelay, exitDelay, hoverTip } = this.props;
+ const { copied } = this.state;
+
+ return (
+
+
+
+
+
+ );
+ }
+}
+
+ClipboardCopyButton.propTypes = {
+ clickTip: PropTypes.string.isRequired,
+ entryDelay: PropTypes.number,
+ exitDelay: PropTypes.number,
+ hoverTip: PropTypes.string.isRequired,
+ stringToCopy: PropTypes.string.isRequired,
+ switchDelay: PropTypes.number,
+};
+
+ClipboardCopyButton.defaultProps = {
+ entryDelay: 100,
+ exitDelay: 1600,
+ switchDelay: 2000,
+};
+
+export default ClipboardCopyButton;
diff --git a/awx/ui_next/src/components/ClipboardCopyButton/ClipboardCopyButton.test.jsx b/awx/ui_next/src/components/ClipboardCopyButton/ClipboardCopyButton.test.jsx
new file mode 100644
index 0000000000..0cdbab8302
--- /dev/null
+++ b/awx/ui_next/src/components/ClipboardCopyButton/ClipboardCopyButton.test.jsx
@@ -0,0 +1,36 @@
+import React from 'react';
+import { mountWithContexts } from '@testUtils/enzymeHelpers';
+import ClipboardCopyButton from './ClipboardCopyButton';
+
+document.execCommand = jest.fn();
+
+jest.useFakeTimers();
+
+describe('ClipboardCopyButton', () => {
+ test('renders the expected content', () => {
+ const wrapper = mountWithContexts(
+
+ );
+ expect(wrapper).toHaveLength(1);
+ });
+ test('clicking button calls execCommand to copy to clipboard', () => {
+ const wrapper = mountWithContexts(
+
+ ).find('ClipboardCopyButton');
+ expect(wrapper.state('copied')).toBe(false);
+ wrapper.find('Button').simulate('click');
+ expect(document.execCommand).toBeCalledWith('copy');
+ expect(wrapper.state('copied')).toBe(true);
+ jest.runAllTimers();
+ wrapper.update();
+ expect(wrapper.state('copied')).toBe(false);
+ });
+});
diff --git a/awx/ui_next/src/components/ClipboardCopyButton/index.js b/awx/ui_next/src/components/ClipboardCopyButton/index.js
new file mode 100644
index 0000000000..45adfe436e
--- /dev/null
+++ b/awx/ui_next/src/components/ClipboardCopyButton/index.js
@@ -0,0 +1 @@
+export { default } from './ClipboardCopyButton';
diff --git a/awx/ui_next/src/screens/Project/ProjectList/ProjectList.test.jsx b/awx/ui_next/src/screens/Project/ProjectList/ProjectList.test.jsx
index e44970edf0..f9a0f55327 100644
--- a/awx/ui_next/src/screens/Project/ProjectList/ProjectList.test.jsx
+++ b/awx/ui_next/src/screens/Project/ProjectList/ProjectList.test.jsx
@@ -13,6 +13,7 @@ const mockProjects = [
url: '/api/v2/projects/1',
type: 'project',
scm_type: 'git',
+ scm_revision: 'hfadsh89sa9gsaisdf0jogos0fgd9sgdf89adsf98',
summary_fields: {
last_job: {
id: 9000,
@@ -30,6 +31,7 @@ const mockProjects = [
url: '/api/v2/projects/2',
type: 'project',
scm_type: 'svn',
+ scm_revision: '7788f7erga0jijodfgsjisiodf98sdga9hg9a98gaf',
summary_fields: {
last_job: {
id: 9002,
@@ -47,6 +49,7 @@ const mockProjects = [
url: '/api/v2/projects/3',
type: 'project',
scm_type: 'insights',
+ scm_revision: '4893adfi749493afjksjoaiosdgjoaisdjadfisjaso',
summary_fields: {
last_job: {
id: 9003,
diff --git a/awx/ui_next/src/screens/Project/ProjectList/ProjectListItem.jsx b/awx/ui_next/src/screens/Project/ProjectList/ProjectListItem.jsx
index ed81638f45..5eff31dd21 100644
--- a/awx/ui_next/src/screens/Project/ProjectList/ProjectListItem.jsx
+++ b/awx/ui_next/src/screens/Project/ProjectList/ProjectListItem.jsx
@@ -12,6 +12,7 @@ import { Link as _Link } from 'react-router-dom';
import { SyncIcon } from '@patternfly/react-icons';
import styled from 'styled-components';
+import ClipboardCopyButton from '@components/ClipboardCopyButton';
import DataListCell from '@components/DataListCell';
import DataListCheck from '@components/DataListCheck';
import ListActionButton from '@components/ListActionButton';
@@ -102,6 +103,14 @@ class ProjectListItem extends React.Component {
{project.scm_type.toUpperCase()}
,
+
+ {project.scm_revision.substring(0, 7)}
+
+ ,
{project.summary_fields.user_capabilities.start && (
diff --git a/awx/ui_next/src/screens/Project/ProjectList/ProjectListItem.test.jsx b/awx/ui_next/src/screens/Project/ProjectList/ProjectListItem.test.jsx
index 1573efac80..bb5a7bcc4b 100644
--- a/awx/ui_next/src/screens/Project/ProjectList/ProjectListItem.test.jsx
+++ b/awx/ui_next/src/screens/Project/ProjectList/ProjectListItem.test.jsx
@@ -17,6 +17,7 @@ describe('', () => {
url: '/api/v2/projects/1',
type: 'project',
scm_type: 'git',
+ scm_revision: '7788f7erga0jijodfgsjisiodf98sdga9hg9a98gaf',
summary_fields: {
last_job: {
id: 9000,
@@ -43,6 +44,7 @@ describe('', () => {
url: '/api/v2/projects/1',
type: 'project',
scm_type: 'git',
+ scm_revision: '7788f7erga0jijodfgsjisiodf98sdga9hg9a98gaf',
summary_fields: {
last_job: {
id: 9000,