diff --git a/ui/core/app/components/copyable/index.hbs b/ui/core/app/components/copyable/index.hbs
new file mode 100644
index 000000000..8d0a3df75
--- /dev/null
+++ b/ui/core/app/components/copyable/index.hbs
@@ -0,0 +1,22 @@
+{{#if this.isClipboardLibrarySupported}}
+
+
+ {{yield}}
+
+
+ {{@text}}
+
+{{else}}
+ {{yield}}
+{{/if}}
diff --git a/ui/core/app/components/copyable/index.js b/ui/core/app/components/copyable/index.js
new file mode 100644
index 000000000..01dcad838
--- /dev/null
+++ b/ui/core/app/components/copyable/index.js
@@ -0,0 +1,79 @@
+import Component from '@glimmer/component';
+import ClipboardJS from 'clipboard';
+import { action } from '@ember/object';
+import { tracked } from '@glimmer/tracking';
+import { generateComponentID } from 'rose/utilities/component-auto-id';
+import { task, timeout } from 'ember-concurrency';
+import { set } from '@ember/object';
+import { bind } from '@ember/runloop';
+
+export default class CopyableComponent extends Component {
+
+ // =attributes
+
+ id = generateComponentID();
+ copyableButtonId = `copyable-button-${this.id}`;
+ icon = 'copy-action';
+
+ @tracked text;
+ @tracked clipboard;
+
+ /**
+ * A Ember Concurrency-based task that updates copy icon to a success state
+ * and back after 1 second. This icon flash indicates success on
+ * copy to clipboard action.
+ *
+ * NOTE: tasks are sort of attributes and sort of methods, but they are not
+ * language-level constructs. Thus we annotate this task as if it
+ * is an attribute.
+ * @type {Task}
+ */
+ @task(function * () {
+ set(this, 'icon', 'copy-success');
+ yield timeout(1000);
+ set(this, 'icon', 'copy-action');
+ }).drop() successIconTimer;
+
+ // =methods
+
+ /**
+ * Checks for ClipboardJS support.
+ * @return {boolean}
+ */
+ get isClipboardLibrarySupported() {
+ return ClipboardJS.isSupported();
+ }
+
+ /**
+ * When copy to clipboard is successful, begin timer to show success icon.
+ */
+ onCopySuccess(clipboardEvent) {
+ clipboardEvent.clearSelection();
+ this.successIconTimer.perform();
+ }
+
+ // =actions
+
+ /**
+ * Initialize ClipboardJS library and register for it's events.
+ */
+ @action
+ register() {
+ if(!this.clipboard) {
+ this.clipboard = new ClipboardJS(`#${this.copyableButtonId}`, {
+ text: () => this.args.text,
+ });
+ this.clipboard.on('success', bind(this, this.onCopySuccess));
+ }
+ }
+
+ /**
+ * Destroy ClipboardJS library instance and cancel icon timer.
+ */
+ @action
+ destroy() {
+ this.clipboard?.destroy();
+ this.successIconTimer.cancelAll();
+ }
+
+}
diff --git a/ui/core/app/styles/app.scss b/ui/core/app/styles/app.scss
index b1a6af3c1..9e72b055f 100644
--- a/ui/core/app/styles/app.scss
+++ b/ui/core/app/styles/app.scss
@@ -80,3 +80,7 @@
}
}
}
+
+.copyable .copyable-button {
+ padding: 0rem;
+}
diff --git a/ui/core/ember-cli-build.js b/ui/core/ember-cli-build.js
index cc31a1b6c..25d88f3cc 100644
--- a/ui/core/ember-cli-build.js
+++ b/ui/core/ember-cli-build.js
@@ -20,6 +20,14 @@ module.exports = function (defaults) {
},
});
+ // Only import when in development or test mode
+ if (app.env.match(/(development)|(test)/i)) {
+ app.import('node_modules/clipboard/dist/clipboard.js');
+ }
+ else {
+ app.import('node_modules/clipboard/dist/clipboard.min.js');
+ }
+
// Use `app.import` to add additional libraries to the generated
// output files.
//
diff --git a/ui/core/package.json b/ui/core/package.json
index ee54d5c47..6b6547fbd 100644
--- a/ui/core/package.json
+++ b/ui/core/package.json
@@ -29,6 +29,7 @@
"devDependencies": {
"@babel/core": "^7.9.6",
"@ember/optional-features": "^1.3.0",
+ "@ember/render-modifiers": "^1.0.2",
"@glimmer/component": "^1.0.0",
"@glimmer/tracking": "^1.0.0",
"api": "*",
@@ -36,6 +37,7 @@
"babel-eslint": "^10.0.3",
"babel-loader": "^8.1.0",
"broccoli-asset-rev": "^3.0.0",
+ "clipboard": "^2.0.6",
"doctoc": "^1.4.0",
"ember-a11y-testing": "^3.0.0",
"ember-auto-import": "^1.5.3",
diff --git a/ui/core/tests/integration/components/copyable-test.js b/ui/core/tests/integration/components/copyable-test.js
new file mode 100644
index 000000000..07be2788f
--- /dev/null
+++ b/ui/core/tests/integration/components/copyable-test.js
@@ -0,0 +1,15 @@
+import { module, test } from 'qunit';
+import { setupRenderingTest } from 'ember-qunit';
+import { render, find } from '@ember/test-helpers';
+import { hbs } from 'ember-cli-htmlbars';
+
+module('Integration | Component | copyable', function(hooks) {
+ setupRenderingTest(hooks);
+
+ test('it renders', async function(assert) {
+ await render(hbs`text`);
+ assert.ok(find('.copyable'));
+ assert.equal(find('.copyable-content').textContent.trim(), 'text');
+ assert.equal(find('.copyable-button').textContent.trim(), 'Copy me');
+ });
+});
diff --git a/ui/core/translations/actions/en-us.yaml b/ui/core/translations/actions/en-us.yaml
index 40a38941d..7e7a9002a 100644
--- a/ui/core/translations/actions/en-us.yaml
+++ b/ui/core/translations/actions/en-us.yaml
@@ -27,4 +27,4 @@ add-members: Add Members
add-hosts: Add Hosts
set-password: Set Password
change-password: Change Password
-edit-form: Edit Form
+edit-form: Edit Form
\ No newline at end of file
diff --git a/yarn.lock b/yarn.lock
index a205584da..f13b0ae16 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2543,6 +2543,14 @@
ember-cli-babel "^6.16.0"
ember-compatibility-helpers "^1.1.1"
+"@ember/render-modifiers@^1.0.2":
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/@ember/render-modifiers/-/render-modifiers-1.0.2.tgz#2e87c48db49d922ce4850d707215caaac60d8444"
+ integrity sha512-6tEnHl5+62NTSAG2mwhGMFPhUrJQjoVqV+slsn+rlTknm2Zik+iwxBQEbwaiQOU1FUYxkS8RWcieovRNMR8inQ==
+ dependencies:
+ ember-cli-babel "^7.10.0"
+ ember-modifier-manager-polyfill "^1.1.0"
+
"@ember/test-helpers@^1.7.0", "@ember/test-helpers@^1.7.1":
version "1.7.1"
resolved "https://registry.yarnpkg.com/@ember/test-helpers/-/test-helpers-1.7.1.tgz#cc22a954b3b46856518f034bd492a74e0482389f"
@@ -7307,7 +7315,7 @@ cli-width@^2.0.0:
resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-2.2.1.tgz#b0433d0b4e9c847ef18868a4ef16fd5fc8271c48"
integrity sha512-GRMWDxpOB6Dgk2E5Uo+3eEBvtOOlimMmpbFiKuLFnQzYDavtLFY3K5ona41jgN/WdRZtG7utuVSVTL4HbZHGkw==
-clipboard@^2.0.0:
+clipboard@^2.0.0, clipboard@^2.0.6:
version "2.0.6"
resolved "https://registry.yarnpkg.com/clipboard/-/clipboard-2.0.6.tgz#52921296eec0fdf77ead1749421b21c968647376"
integrity sha512-g5zbiixBRk/wyKakSwCKd7vQXDjFnAMGHoEyBogG/bw9kTD9GvdAvaoRR1ALcEzt3pVKxZR0pViekPMIS0QyGg==
@@ -9601,7 +9609,7 @@ ember-maybe-import-regenerator@^0.1.5, ember-maybe-import-regenerator@^0.1.6:
ember-cli-babel "^6.0.0-beta.4"
regenerator-runtime "^0.9.5"
-ember-modifier-manager-polyfill@^1.2.0:
+ember-modifier-manager-polyfill@^1.1.0, ember-modifier-manager-polyfill@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/ember-modifier-manager-polyfill/-/ember-modifier-manager-polyfill-1.2.0.tgz#cf4444e11a42ac84f5c8badd85e635df57565dda"
integrity sha512-bnaKF1LLKMkBNeDoetvIJ4vhwRPKIIumWr6dbVuW6W6p4QV8ZiO+GdF8J7mxDNlog9CeL9Z/7wam4YS86G8BYA==