Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/components/button_component.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<%= button_tag(content, **tag_options, type: tag_type, class: css_class) %>
20 changes: 20 additions & 0 deletions app/components/button_component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
class ButtonComponent < BaseComponent
attr_reader :type, :outline, :tag_options

DEFAULT_BUTTON_TYPE = :button

def initialize(outline: false, **tag_options)
@outline = outline
@tag_options = tag_options
end

def css_class
classes = ['usa-button', *tag_options[:class]]
classes << 'usa-button--outline' if outline
classes
end

def tag_type
tag_options.fetch(:type, DEFAULT_BUTTON_TYPE)
end
end
5 changes: 5 additions & 0 deletions app/components/clipboard_button_component.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { loadPolyfills } from '@18f/identity-polyfill';

loadPolyfills(['custom-elements', 'clipboard'])
.then(() => import('@18f/identity-clipboard-button'))
.then(({ ClipboardButton }) => customElements.define('lg-clipboard-button', ClipboardButton));
14 changes: 14 additions & 0 deletions app/components/clipboard_button_component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
class ClipboardButtonComponent < ButtonComponent
attr_reader :clipboard_text, :tag_options

def initialize(clipboard_text:, **tag_options)
super(**tag_options)

@clipboard_text = clipboard_text
@tag_options = tag_options
end

def call
content_tag(:'lg-clipboard-button', super, data: { clipboard_text: clipboard_text })
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One possible future awkwardness we may run into is that because we're just wrapping the base markup, things like tag_options[:class] will apply to the child element, not the root. A small difference, but personally I'd expect tag_options to apply to the root element.

end
end
24 changes: 24 additions & 0 deletions app/javascript/packages/clipboard-button/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
export class ClipboardButton extends HTMLElement {
connectedCallback() {
/** @type {HTMLButtonElement?} */
this.button = this.querySelector('button');

this.button?.addEventListener('click', () => this.writeToClipboard());
}

/**
* Returns the text to be copied to the clipboard.
*
* @return {string}
*/
get clipboardText() {
return this.dataset.clipboardText || '';
}

/**
* Writes the element's clipboard text to the clipboard.
*/
writeToClipboard() {
navigator.clipboard.writeText(this.clipboardText);
}
}
66 changes: 66 additions & 0 deletions app/javascript/packages/clipboard-button/index.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import sinon from 'sinon';
import { getByRole } from '@testing-library/dom';
import userEvent from '@testing-library/user-event';
import { loadPolyfills } from '@18f/identity-polyfill';
import { ClipboardButton } from './index.js';

describe('ClipboardButton', () => {
before(async () => {
// Necessary until: https://github.com/jsdom/jsdom/issues/1568
await loadPolyfills(['clipboard']);

if (!customElements.get('lg-clipboard-button')) {
customElements.define('lg-clipboard-button', ClipboardButton);
}
});

beforeEach(() => {
sinon.spy(navigator.clipboard, 'writeText');
});

afterEach(() => {
navigator.clipboard.writeText.restore();
});

function createAndConnectElement({ clipboardText = '' } = {}) {
const element = document.createElement('lg-clipboard-button');
element.setAttribute('data-clipboard-text', clipboardText);
element.innerHTML = '<button type="button" class="usa-button">Copy</button>';
document.body.appendChild(element);
return element;
}

it('copies text to clipboard when clicking its button', () => {
const clipboardText = 'example';
const element = createAndConnectElement({ clipboardText });
const button = getByRole(element, 'button');

userEvent.click(button);

expect(navigator.clipboard.writeText).to.have.been.calledWith(clipboardText);
});

it('copies the latest clipboard attribute value after initialization', () => {
const clipboardText = 'example';
const element = createAndConnectElement({ clipboardText });
const changedClipbordText = 'example2';
element.setAttribute('data-clipboard-text', changedClipbordText);

const button = getByRole(element, 'button');

userEvent.click(button);

expect(navigator.clipboard.writeText).to.have.been.calledWith(changedClipbordText);
});

context('with nothing to copy', () => {
it('does writes an empty string to the clipboard', () => {
const element = createAndConnectElement();
const button = getByRole(element, 'button');

userEvent.click(button);

expect(navigator.clipboard.writeText).to.have.been.calledWith('');
});
});
});
5 changes: 5 additions & 0 deletions app/javascript/packages/clipboard-button/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "@18f/identity-clipboard-button",
"version": "1.0.0",
"private": true
}
7 changes: 6 additions & 1 deletion app/javascript/packages/polyfill/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import isSafe from './is-safe';
*/

/**
* @typedef {"fetch"|"classlist"|"crypto"|"custom-elements"|"custom-event"|"url"} SupportedPolyfills
* @typedef {"fetch"|"classlist"|"clipboard"|"crypto"|"custom-elements"|"custom-event"|"url"} SupportedPolyfills
*/

/**
Expand All @@ -23,6 +23,11 @@ const POLYFILLS = {
test: () => 'classList' in Element.prototype,
load: () => import(/* webpackChunkName: "classlist-polyfill" */ 'classlist-polyfill'),
},
clipboard: {
test: () => 'clipboard' in navigator,
load: () =>
import(/* webpackChunkName: "clipboard-polyfill" */ 'clipboard-polyfill/overwrite-globals'),
},
crypto: {
test: () => 'crypto' in window,
load: () => import(/* webpackChunkName: "webcrypto-shim" */ 'webcrypto-shim'),
Expand Down
1 change: 1 addition & 0 deletions app/javascript/packages/polyfill/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"dependencies": {
"@webcomponents/custom-elements": "^1.5.0",
"classlist-polyfill": "^1.2.0",
"clipboard-polyfill": "^3.0.3",
"custom-event-polyfill": "^1.0.7",
"js-polyfills": "^0.1.43",
"webcrypto-shim": "^0.1.6",
Expand Down
38 changes: 22 additions & 16 deletions app/views/shared/_personal_key.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,28 @@
<div class="full-width-box">
<%= render 'partials/personal_key/key', code: code %>
</div>
<br>
<div class="sm-col sm-col-4 center">
<%= link_to t('forms.backup_code.download'), idv_download_personal_key_path,
class: 'usa-button usa-button--outline' %>
</div>
<div class="sm-col sm-col-4 center">
<%= link_to t('users.personal_key.print'), '#', data: {print: true},
class: 'usa-button usa-button--outline' %>
</div>
<div class="sm-col sm-col-4 center">
<%= link_to t('links.copy'), '#', data: {"clipboard-text": code},
class: 'usa-button usa-button--outline clipboard' %>
<div class="grid-row text-center margin-y-3">
<div class="grid-col-12 tablet:grid-col-4 margin-y-1">
<%= link_to(
t('forms.backup_code.download'),
idv_download_personal_key_path,
class: 'usa-button usa-button--outline',
) %>
</div>
<div class="grid-col-12 tablet:grid-col-4 margin-y-1">
<%= button_tag(
t('users.personal_key.print'),
type: :button,
data: { print: true },
class: 'usa-button usa-button--outline',
) %>
</div>
<div class="grid-col-12 tablet:grid-col-4 margin-y-1">
<%= render ClipboardButtonComponent.new(clipboard_text: code, outline: true) do %>
<%= t('links.copy') %>
<% end %>
</div>
</div>
<br>
<br>
<br>
Comment on lines -24 to -26
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

😍

<div class="sm-col sm-col-2 padding-right-2">
<%= image_tag(asset_url('alert/icon-lock-alert-important.svg'), alt: '', size: '80') %>
</div>
Expand All @@ -43,4 +49,4 @@
'data-toggle': 'modal' %>
</p>
<%= render 'shared/personal_key_confirmation_modal', code: code, update_path: update_path %>
<%== javascript_packs_tag_once 'personal-key-page-controller', 'clipboard' %>
<%== javascript_packs_tag_once 'personal-key-page-controller' %>
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"@types/sinon": "^9.0.11",
"chai": "^4.2.0",
"chai-as-promised": "^7.1.1",
"clipboard-polyfill": "^3.0.3",
"dirty-chai": "^2.0.1",
"eslint": "^8.3.0",
"jsdom": "^16.4.0",
Expand Down
43 changes: 43 additions & 0 deletions spec/components/button_component_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
require 'rails_helper'

RSpec.describe ButtonComponent, type: :component do
let(:type) { nil }
let(:outline) { false }
let(:content) { 'Button' }
let(:options) do
{
outline: outline,
type: type,
}.compact
end

subject(:rendered) { render_inline ButtonComponent.new(options) { content } }

it 'renders button content' do
expect(rendered).to have_content(content)
end

it 'renders as type=button' do
expect(rendered).to have_css('button[type=button]')
end

it 'renders with design system classes' do
expect(rendered).to have_css('button.usa-button')
end

context 'with outline' do
let(:outline) { true }

it 'renders with design system classes' do
expect(rendered).to have_css('button.usa-button.usa-button--outline')
end
end

context 'with type' do
let(:type) { :submit }

it 'renders as type' do
expect(rendered).to have_css('button[type=submit]')
end
end
end
29 changes: 29 additions & 0 deletions spec/components/clipboard_button_component_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
require 'rails_helper'

RSpec.describe ClipboardButtonComponent, type: :component do
let(:clipboard_text) { 'Copy Text' }
let(:content) { 'Button' }
let(:tag_options) { {} }

subject(:rendered) do
render_inline ClipboardButtonComponent.new(clipboard_text: clipboard_text, **tag_options) do
content
end
end

it 'renders button content' do
expect(rendered).to have_content(content)
end

it 'renders with clipboard text as data-attribute' do
expect(rendered).to have_css("lg-clipboard-button[data-clipboard-text='#{clipboard_text}']")
end

context 'with tag options' do
let(:tag_options) { { outline: true } }

it 'renders button given the tag options' do
expect(rendered).to have_css('button.usa-button.usa-button--outline')
end
end
end
22 changes: 22 additions & 0 deletions spec/support/shared_examples_for_personal_keys.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,26 @@
end
end
end

context 'with javascript enabled', js: true do
before do
page.driver.browser.execute_cdp(
'Browser.grantPermissions',
origin: page.server_url,
permissions: ['clipboardReadWrite', 'clipboardSanitizedWrite'],
)
end

after do
page.driver.browser.execute_cdp('Browser.resetPermissions')
end

it 'allows a user to copy the code into the confirmation modal' do
click_on t('links.copy')
copied_text = page.evaluate_async_script('navigator.clipboard.readText().then(arguments[0])')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what's arguments here?

Copy link
Contributor Author

@aduth aduth Dec 16, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what's arguments here?

From what I understand, evaluate_async_script wraps the given script in a function, called with a callback function argument (last argument?).

So it ends up being something like...

(function(/* callback */) {
  navigator.clipboard.readText().then(arguments[0])
})(callback);

arguments being the JavaScript keyword to reference the given function arguments.

Copy link
Contributor

@zachmargolis zachmargolis Dec 16, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

neat, I wish *they'd call it it something clearer like resolve lol 😂

*they = the webdriver/capybara devs

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Initially I'd hoped it would infer the promise value and treat it as something more like...

copied_text = page.evaluate_async_script('navigator.clipboard.readText()')
(() => navigator.clipboard.readText())().then(callback);


code = page.all('[data-personal-key]').map(&:text).join('-')
expect(copied_text).to eq(code)
end
end
end
5 changes: 5 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2678,6 +2678,11 @@ cleave.js@^1.6.0:
resolved "https://registry.yarnpkg.com/cleave.js/-/cleave.js-1.6.0.tgz#0e4e011943bdd70c67c9dcf4ff800ce710529171"
integrity sha512-ivqesy3j5hQVG3gywPfwKPbi/7ZSftY/UNp5uphnqjr25yI2CP8FS2ODQPzuLXXnNLi29e2+PgPkkiKUXLs/Nw==

clipboard-polyfill@^3.0.3:
version "3.0.3"
resolved "https://registry.yarnpkg.com/clipboard-polyfill/-/clipboard-polyfill-3.0.3.tgz#159ea0768e20edc7ffda404bd13c54c73de4ff40"
integrity sha512-hts0o01ZkwjA1qHA5gFePzAj/780W7v+eyN3GdaCRyDnapzcPsKRV5aodv77gcr40NDIcyNjNmc+HvfKV+jD0g==

clipboard@^2.0.6:
version "2.0.8"
resolved "https://registry.yarnpkg.com/clipboard/-/clipboard-2.0.8.tgz#ffc6c103dd2967a83005f3f61976aa4655a4cdba"
Expand Down