UI: glimmerize masked input (#20431)

* Glimmerize masked-input

* Update secret-create-or-update to change masked-input value

* Use maskedInput for ssh configure privateKey

* Add download button to masked input and v2 secrets. Resolves #6364

* Add changelog
This commit is contained in:
Chelsea Shaw 2023-05-01 11:43:05 -05:00 committed by GitHub
parent 3ca73ad07e
commit 59ff9c2eea
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 158 additions and 176 deletions

3
changelog/20431.txt Normal file
View File

@ -0,0 +1,3 @@
```release-note:improvement
ui: Add download button for each secret value in KV v2
```

View File

@ -253,22 +253,16 @@ export default class SecretCreateOrUpdate extends Component {
this.codemirrorString = this.args.secretData.toJSONString(true);
}
@action
handleMaskedInputChange(secret, index, value) {
const row = { ...secret, value };
set(this.args.secretData, index, row);
this.handleChange();
}
@action
handleChange() {
this.codemirrorString = this.args.secretData.toJSONString(true);
set(this.args.modelForData, 'secretData', this.args.secretData.toJSON());
}
//submit on shift + enter
@action
handleKeyDown(e) {
e.stopPropagation();
if (!(e.keyCode === keys.ENTER && e.metaKey)) {
return;
}
const $form = this.element.querySelector('form');
if ($form.length) {
$form.submit();
}
}
@action
updateValidationErrorCount(errorCount) {
this.validationErrorCount = errorCount;

View File

@ -49,14 +49,16 @@
}
.button.masked-input-toggle,
.button.copy-button {
.button.copy-button,
.button.download-button {
min-width: $spacing-xl;
border-left: 0;
color: $grey;
box-shadow: 0 3px 1px 0px rgba(10, 10, 10, 0.12);
}
.button.copy-button {
.button.copy-button,
.button.download-button {
border-radius: 0;
}
@ -66,7 +68,8 @@
.display-only {
.button.masked-input-toggle,
.button.copy-button {
.button.copy-button,
.button.download-button {
background: transparent;
height: auto;
line-height: 1rem;

View File

@ -46,7 +46,13 @@
Private key
</label>
<div class="control">
<Textarea name="privateKey" id="privateKey" class="input" @value={{@model.privateKey}} />
<MaskedInput
@name="privateKey"
id="privateKey"
class="input"
@value={{@model.privateKey}}
@onChange={{mut @model.privateKey}}
/>
</div>
</div>
<div class="field">

View File

@ -75,8 +75,7 @@
<div class="column info-table-row-edit">
<MaskedInput
@name={{secret.name}}
@onKeyDown={{action "handleKeyDown"}}
@onChange={{action "handleChange"}}
@onChange={{fn this.handleMaskedInputChange secret index}}
@value={{secret.value}}
data-test-secret-value="true"
/>
@ -212,8 +211,7 @@
<div class="column">
<MaskedInput
@name={{secret.name}}
@onKeyDown={{action "handleKeyDown"}}
@onChange={{action "handleChange"}}
@onChange={{fn this.handleMaskedInputChange secret index}}
@value={{secret.value}}
data-test-secret-value="true"
/>

View File

@ -71,8 +71,6 @@
@displayOnly={{true}}
@allowCopy={{true}}
@value={{this.wrappedData}}
@success={{action "handleCopySuccess"}}
@error={{action "handleCopyError"}}
/>
{{/if}}
</li>

View File

@ -66,7 +66,13 @@
{{#each @modelForData.secretKeyAndValue as |secret|}}
<InfoTableRow @label={{secret.key}} @value={{secret.value}} @alwaysRender={{true}}>
{{#if secret.value}}
<MaskedInput @value={{secret.value}} @displayOnly={{true}} @allowCopy={{true}} />
<MaskedInput
@name={{secret.key}}
@value={{secret.value}}
@displayOnly={{true}}
@allowCopy={{true}}
@allowDownload={{@isV2}}
/>
{{else}}
<Icon @name="minus" />
{{/if}}

View File

@ -216,12 +216,11 @@
@subText="{{@attr.options.subText}} Add one item per row."
/>
{{else if (eq @attr.options.sensitive true)}}
{{! Masked Input }}
<MaskedInput
@name={{@attr.name}}
@value={{or (get @model this.valuePath) @attr.options.defaultValue}}
@allowCopy="true"
@onChange={{this.setAndBroadcast}}
@name={{@attr.name}}
@onKeyUp={{@onKeyUp}}
/>
{{#if this.validationError}}

View File

@ -0,0 +1,58 @@
<div
class="masked-input {{if @displayOnly 'display-only'}} {{if @allowCopy 'allow-copy'}}"
data-test-masked-input
data-test-field
...attributes
>
{{#if @displayOnly}}
{{#if this.showValue}}
<pre class="masked-value display-only is-word-break">{{@value}}</pre>
{{else}}
<pre class="masked-value display-only masked-font">***********</pre>
{{/if}}
{{else}}
<Textarea
id={{this.textareaId}}
name={{@name}}
@value={{@value}}
class="input masked-value {{unless this.showValue 'masked-font'}}"
rows={{1}}
wrap="off"
spellcheck="false"
{{on "change" this.onChange}}
{{on "keyup" (fn this.handleKeyUp @name @value)}}
data-test-textarea
/>
{{/if}}
{{#if @allowCopy}}
<CopyButton
@clipboardText={{@value}}
@success={{action (set-flash-message "Data copied!")}}
class="copy-button button {{if @displayOnly 'is-compact'}}"
data-test-copy-button
>
<Icon @name="clipboard-copy" aria-hidden="Copy value" />
</CopyButton>
{{/if}}
{{#if @allowDownload}}
<DownloadButton
class="button download-button"
@filename={{or @name "secret-value"}}
@data={{@value}}
@stringify={{true}}
aria-label="Download secret value"
>
<Icon @name="download" />
</DownloadButton>
{{/if}}
<button
onclick={{this.toggleMask}}
type="button"
aria-label={{if this.showValue "mask value" "show value"}}
title={{if this.showValue "mask value" "show value"}}
class="{{if (eq @value '') 'has-text-grey'}} masked-input-toggle button"
data-test-button="toggle-masked"
>
<Icon @name={{if this.showValue "eye" "eye-off"}} />
</button>
</div>

View File

@ -3,9 +3,12 @@
* SPDX-License-Identifier: MPL-2.0
*/
import Component from '@ember/component';
import { debug } from '@ember/debug';
import { action } from '@ember/object';
import { guidFor } from '@ember/object/internals';
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import autosize from 'autosize';
import layout from '../templates/components/masked-input';
/**
* @module MaskedInput
@ -13,53 +16,50 @@ import layout from '../templates/components/masked-input';
*
* @example
* <MaskedInput
* @value={{attr.options.defaultValue}}
* @value={{get @model attr.name}}
* @allowCopy={{true}}
* @onChange={{action "someAction"}}
* @onKeyUp={{action "onKeyUp"}}
* @allowDownload={{true}}
* @onChange={{this.handleChange}}
* @onKeyUp={{this.handleKeyUp}}
* />
*
* @param [value] {String} - The value to display in the input.
* @param [allowCopy=null] {bool} - Whether or not the input should render with a copy button.
* @param value {String} - The value to display in the input.
* @param name {String} - The key correlated to the value. Used for the download file name.
* @param [onChange=Callback] {Function|action} - Callback triggered on change, sends new value. Must set the value of @value
* @param [allowCopy=false] {bool} - Whether or not the input should render with a copy button.
* @param [displayOnly=false] {bool} - Whether or not to display the value as a display only `pre` element or as an input.
* @param [onChange=Function.prototype] {Function|action} - A function to call when the value of the input changes.
* @param [onKeyUp=Function.prototype] {Function|action} - A function to call whenever on the dom event onkeyup. Generally passed down from higher level parent.
* @param [isCertificate=false] {bool} - If certificate display the label and icons differently.
*
*/
export default Component.extend({
layout,
value: null,
showValue: false,
didInsertElement() {
this._super(...arguments);
autosize(this.element.querySelector('textarea'));
},
didUpdate() {
this._super(...arguments);
autosize.update(this.element.querySelector('textarea'));
},
willDestroyElement() {
this._super(...arguments);
autosize.destroy(this.element.querySelector('textarea'));
},
displayOnly: false,
onKeyDown() {},
onKeyUp() {},
onChange() {},
actions: {
toggleMask() {
this.toggleProperty('showValue');
},
updateValue(e) {
const value = e.target.value;
this.set('value', value);
this.onChange(value);
},
handleKeyUp(name, value) {
if (this.onKeyUp) {
this.onKeyUp(name, value);
}
},
},
});
export default class MaskedInputComponent extends Component {
textareaId = 'textarea-' + guidFor(this);
@tracked showValue = false;
constructor() {
super(...arguments);
if (!this.args.onChange && !this.args.displayOnly) {
debug('onChange is required for editable Masked Input!');
}
this.updateSize();
}
updateSize() {
autosize(document.getElementById(this.textareaId));
}
@action onChange(evt) {
const value = evt.target.value;
if (this.args.onChange) {
this.args.onChange(value);
}
}
@action handleKeyUp(name, value) {
this.updateSize();
if (this.onKeyUp) {
this.onKeyUp(name, value);
}
}
@action toggleMask() {
this.showValue = !this.showValue;
}
}

View File

@ -1,56 +0,0 @@
<div
class="masked-input {{if this.displayOnly 'display-only'}} {{if this.allowCopy 'allow-copy'}}"
data-test-masked-input
data-test-field
>
{{#if this.displayOnly}}
{{#if this.showValue}}
<pre class="masked-value display-only is-word-break">{{this.value}}</pre>
{{else}}
<pre class="masked-value display-only masked-font">***********</pre>
{{/if}}
{{else if this.inputField}}
<input
autocomplete="off"
spellcheck="false"
value={{this.value}}
class="input {{unless this.showValue 'masked-font'}}"
onchange={{action "updateValue"}}
data-test-input
/>
{{else}}
{{! template-lint-disable no-down-event-binding }}
<textarea
class="input masked-value {{unless this.showValue 'masked-font'}}"
rows={{1}}
wrap="off"
onkeydown={{action this.onKeyDown}}
onchange={{action "updateValue"}}
onkeyup={{action (action "handleKeyUp" this.name) value="target.value"}}
value={{this.value}}
spellcheck="false"
data-test-textarea
></textarea>
{{! template-lint-enable no-down-event-binding }}
{{/if}}
{{#if this.allowCopy}}
<CopyButton
@clipboardText={{this.value}}
@success={{action (set-flash-message "Data copied!")}}
class="copy-button button {{if this.displayOnly 'is-compact'}}"
data-test-copy-button
>
<Icon @name="clipboard-copy" aria-hidden="Copy value" />
</CopyButton>
{{/if}}
<button
onclick={{action "toggleMask"}}
type="button"
aria-label={{if this.showValue "mask value" "show value"}}
title={{if this.showValue "mask value" "show value"}}
class="{{if (eq this.value '') 'has-text-grey'}} masked-input-toggle button"
data-test-button="toggle-masked"
>
<Icon @name={{if this.showValue "eye" "eye-off"}} />
</button>
</div>

View File

@ -16,68 +16,40 @@ module('Integration | Component | masked input', function (hooks) {
setupRenderingTest(hooks);
test('it renders', async function (assert) {
await render(hbs`{{masked-input}}`);
assert.dom('[data-test-masked-input]').exists('shows expiration beacon');
});
test('it renders a textarea', async function (assert) {
await render(hbs`{{masked-input}}`);
await render(hbs`<MaskedInput />`);
assert.dom('[data-test-masked-input]').exists('shows masked input');
assert.ok(component.textareaIsPresent);
assert.dom('[data-test-textarea]').hasClass('masked-font', 'it renders an input with obscure font');
assert.notOk(component.copyButtonIsPresent, 'does not render copy button by default');
assert.notOk(component.downloadButtonIsPresent, 'does not render download button by default');
await component.toggleMasked();
assert.dom('.masked-value').doesNotHaveClass('masked-font', 'it unmasks when show button is clicked');
await component.toggleMasked();
assert.dom('.masked-value').hasClass('masked-font', 'it remasks text when button is clicked');
});
test('it renders an input with obscure font', async function (assert) {
await render(hbs`{{masked-input}}`);
assert.dom('[data-test-textarea]').hasClass('masked-font', 'loading class with correct font');
});
test('it renders obscure font when displayOnly', async function (assert) {
test('it renders correctly when displayOnly', async function (assert) {
this.set('value', 'value');
await render(hbs`{{masked-input displayOnly=true value=this.value}}`);
await render(hbs`<MaskedInput @displayOnly={{true}} @value={{this.value}} />`);
assert.dom('.masked-value').hasClass('masked-font', 'loading class with correct font');
});
test('it does not render a textarea when displayOnly is true', async function (assert) {
await render(hbs`{{masked-input displayOnly=true}}`);
assert.notOk(component.textareaIsPresent);
assert.dom('.masked-value').hasClass('masked-font', 'value has obscured font');
assert.notOk(component.textareaIsPresent, 'it does not render a textarea when displayOnly is true');
});
test('it renders a copy button when allowCopy is true', async function (assert) {
await render(hbs`{{masked-input allowCopy=true}}`);
await render(hbs`<MaskedInput @allowCopy={{true}} />`);
assert.ok(component.copyButtonIsPresent);
});
test('it does not render a copy button when allowCopy is false', async function (assert) {
await render(hbs`{{masked-input allowCopy=false}}`);
assert.notOk(component.copyButtonIsPresent);
});
test('it unmasks text when button is clicked', async function (assert) {
this.set('value', 'value');
await render(hbs`{{masked-input value=this.value}}`);
await component.toggleMasked();
assert.dom('.masked-value').doesNotHaveClass('masked-font');
});
test('it remasks text when button is clicked', async function (assert) {
this.set('value', 'value');
await render(hbs`{{masked-input value=this.value}}`);
await component.toggleMasked();
await component.toggleMasked();
assert.dom('.masked-value').hasClass('masked-font');
test('it renders a download button when allowDownload is true', async function (assert) {
await render(hbs`<MaskedInput @allowDownload={{true}} />`);
assert.ok(component.downloadButtonIsPresent);
});
test('it shortens all outputs when displayOnly and masked', async function (assert) {
this.set('value', '123456789-123456789-123456789');
await render(hbs`{{masked-input value=this.value displayOnly=true}}`);
await render(hbs`<MaskedInput @value={{this.value}} @displayOnly={{true}} />`);
const maskedValue = document.querySelector('.masked-value').innerText;
assert.strictEqual(maskedValue.length, 11);
@ -88,7 +60,7 @@ module('Integration | Component | masked input', function (hooks) {
test('it does not unmask text on focus', async function (assert) {
this.set('value', '123456789-123456789-123456789');
await render(hbs`{{masked-input value=this.value}}`);
await render(hbs`<MaskedInput @value={{this.value}} />`);
assert.dom('.masked-value').hasClass('masked-font');
await focus('.masked-value');
assert.dom('.masked-value').hasClass('masked-font');
@ -96,7 +68,7 @@ module('Integration | Component | masked input', function (hooks) {
test('it does not remove value on tab', async function (assert) {
this.set('value', 'hello');
await render(hbs`{{masked-input value=this.value}}`);
await render(hbs`<MaskedInput @value={{this.value}} />`);
await triggerKeyEvent('[data-test-textarea]', 'keydown', 9);
await component.toggleMasked();
const unMaskedValue = document.querySelector('.masked-value').value;

View File

@ -8,5 +8,6 @@ import { clickable, isPresent } from 'ember-cli-page-object';
export default {
textareaIsPresent: isPresent('[data-test-textarea]'),
copyButtonIsPresent: isPresent('[data-test-copy-button]'),
downloadButtonIsPresent: isPresent('[data-test-download-button]'),
toggleMasked: clickable('[data-test-button="toggle-masked"]'),
};