UI: Add KV view for wrap tool (#29677)

* add kv view for wrap tool

* add changelog entry

* update toggle  and tests

* update changelog, style updates, fix linting error bug

* update tests

* update test to include multiline input

* clean up

* test improvements and clean up

* shift away from disabling button on error

* update test for json lint warning

* add check after back

* move assertions to a better test for them
This commit is contained in:
lane-wetmore
2025-02-24 20:21:21 -06:00
committed by GitHub
parent 5d1a971c44
commit 13d302d509
4 changed files with 176 additions and 26 deletions

3
changelog/29677.txt Normal file
View File

@@ -0,0 +1,3 @@
```release-note:improvement
ui: adds key value pair string inputs as optional form for wrap tool
```

View File

@@ -40,16 +40,30 @@
<div class="box is-sideless is-fullwidth is-marginless"> <div class="box is-sideless is-fullwidth is-marginless">
<NamespaceReminder @mode="perform" @noun="wrap" /> <NamespaceReminder @mode="perform" @noun="wrap" />
<MessageError @errorMessage={{this.errorMessage}} /> <MessageError @errorMessage={{this.errorMessage}} />
<div class="field"> <Toolbar>
<div class="control"> <ToolbarFilters>
<Toggle @name="json" @checked={{this.showJson}} @onChange={{this.handleToggle}}>
<span class="has-text-grey">JSON</span>
</Toggle>
</ToolbarFilters>
</Toolbar>
{{#if this.showJson}}
<JsonEditor <JsonEditor
class="has-top-margin-s"
@title="Data to wrap" @title="Data to wrap"
@subTitle="json-formatted" @subTitle="json-formatted"
@value={{this.wrapData}} @value={{this.stringifiedWrapData}}
@valueUpdated={{this.codemirrorUpdated}} @valueUpdated={{this.codemirrorUpdated}}
/> />
</div> {{else}}
</div> <KvObjectEditor
class="has-top-margin-l"
@label="Data to wrap"
@value={{this.wrapData}}
@onChange={{fn (mut this.wrapData)}}
@warnNonStringValues={{true}}
/>
{{/if}}
<TtlPicker <TtlPicker
@label="Wrap TTL" @label="Wrap TTL"
@initialValue="30m" @initialValue="30m"
@@ -58,10 +72,17 @@
@helperTextEnabled="Wrap will expire after" @helperTextEnabled="Wrap will expire after"
@changeOnInit={{true}} @changeOnInit={{true}}
/> />
{{#if this.hasLintingErrors}}
<AlertInline
@color="warning"
class="has-top-padding-s"
@message="JSON is unparsable. Fix linting errors to avoid data discrepancies."
/>
{{/if}}
</div> </div>
<div class="field is-grouped box is-fullwidth is-bottomless"> <div class="field is-grouped box is-fullwidth is-bottomless">
<div class="control"> <div class="control">
<Hds::Button @text="Wrap data" type="submit" disabled={{this.buttonDisabled}} data-test-tools-submit /> <Hds::Button @text="Wrap data" type="submit" data-test-tools-submit />
</div> </div>
</div> </div>
</form> </form>

View File

@@ -7,6 +7,7 @@ import Component from '@glimmer/component';
import { service } from '@ember/service'; import { service } from '@ember/service';
import { action } from '@ember/object'; import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking'; import { tracked } from '@glimmer/tracking';
import { stringify } from 'core/helpers/stringify';
import errorMessage from 'vault/utils/error-message'; import errorMessage from 'vault/utils/error-message';
/** /**
@@ -21,18 +22,38 @@ export default class ToolsWrap extends Component {
@service store; @service store;
@service flashMessages; @service flashMessages;
@tracked buttonDisabled = false; @tracked hasLintingErrors = false;
@tracked token = ''; @tracked token = '';
@tracked wrapTTL = null; @tracked wrapTTL = null;
@tracked wrapData = '{\n}'; @tracked wrapData = null;
@tracked errorMessage = ''; @tracked errorMessage = '';
@tracked showJson = true;
get startingValue() {
// must pass the third param called "space" in JSON.stringify to structure object with whitespace
// otherwise the following codemirror modifier check will pass `this._editor.getValue() !== namedArgs.content` and _setValue will be called.
// the method _setValue moves the cursor to the beginning of the text field.
// the effect is that the cursor jumps after the first key input.
return JSON.stringify({ '': '' }, null, 2);
}
get stringifiedWrapData() {
return this?.wrapData ? stringify([this.wrapData], {}) : this.startingValue;
}
@action
handleToggle() {
this.showJson = !this.showJson;
this.hasLintingErrors = false;
}
@action @action
reset(clearData = true) { reset(clearData = true) {
this.token = ''; this.token = '';
this.errorMessage = ''; this.errorMessage = '';
this.wrapTTL = null; this.wrapTTL = null;
if (clearData) this.wrapData = '{\n}'; this.hasLintingErrors = false;
if (clearData) this.wrapData = null;
} }
@action @action
@@ -44,15 +65,15 @@ export default class ToolsWrap extends Component {
@action @action
codemirrorUpdated(val, codemirror) { codemirrorUpdated(val, codemirror) {
codemirror.performLint(); codemirror.performLint();
const hasErrors = codemirror?.state.lint.marked?.length > 0; this.hasLintingErrors = codemirror?.state.lint.marked?.length > 0;
this.buttonDisabled = hasErrors; if (!this.hasLintingErrors) this.wrapData = JSON.parse(val);
if (!hasErrors) this.wrapData = val;
} }
@action @action
async handleSubmit(evt) { async handleSubmit(evt) {
evt.preventDefault(); evt.preventDefault();
const data = JSON.parse(this.wrapData);
const data = this.wrapData;
const wrapTTL = this.wrapTTL || null; const wrapTTL = this.wrapTTL || null;
try { try {

View File

@@ -37,8 +37,13 @@ module('Integration | Component | tools/wrap', function (hooks) {
await this.renderComponent(); await this.renderComponent();
assert.dom('h1').hasText('Wrap Data', 'Title renders'); assert.dom('h1').hasText('Wrap Data', 'Title renders');
assert.dom('label').hasText('Data to wrap (json-formatted)'); assert.dom('[data-test-toggle-label="json"]').hasText('JSON');
assert.strictEqual(codemirror().getValue(' '), '{ }', 'json editor initializes with empty object'); assert.dom('[data-test-component="json-editor-title"]').hasText('Data to wrap (json-formatted)');
assert.strictEqual(
codemirror().getValue(' '),
`{ \"\": \"\" }`, // eslint-disable-line no-useless-escape
'json editor initializes with empty object that includes whitespace'
);
assert.dom(TTL.toggleByLabel('Wrap TTL')).isNotChecked('Wrap TTL defaults to unchecked'); assert.dom(TTL.toggleByLabel('Wrap TTL')).isNotChecked('Wrap TTL defaults to unchecked');
assert.dom(TS.submit).isEnabled(); assert.dom(TS.submit).isEnabled();
assert.dom(TS.toolsInput('wrapping-token')).doesNotExist(); assert.dom(TS.toolsInput('wrapping-token')).doesNotExist();
@@ -104,6 +109,67 @@ module('Integration | Component | tools/wrap', function (hooks) {
await click(TS.submit); await click(TS.submit);
}); });
test('it toggles between views and preserves input data', async function (assert) {
assert.expect(6);
await this.renderComponent();
await codemirror().setValue(this.wrapData);
assert.dom('[data-test-component="json-editor-title"]').hasText('Data to wrap (json-formatted)');
await click('[data-test-toggle-input="json"]');
assert.dom('[data-test-component="json-editor-title"]').doesNotExist();
assert.dom('[data-test-kv-key="0"]').hasValue('foo');
assert.dom('[data-test-kv-value="0"]').hasValue('bar');
await click('[data-test-toggle-input="json"]');
assert.dom('[data-test-component="json-editor-title"]').exists();
assert.strictEqual(
codemirror().getValue(' '),
`{ \"foo": \"bar" }`, // eslint-disable-line no-useless-escape
'json editor has original data'
);
});
test('it submits from kv view', async function (assert) {
assert.expect(6);
const multilineData = `this is a multi-line secret
that contains
some seriously important config`;
const flashSpy = sinon.spy(this.owner.lookup('service:flash-messages'), 'success');
const updatedWrapData = JSON.stringify({
...JSON.parse(this.wrapData),
foo: 'bar',
foo2: multilineData,
});
this.server.post('sys/wrapping/wrap', (schema, { requestBody, requestHeaders }) => {
const payload = JSON.parse(requestBody);
assert.propEqual(payload, JSON.parse(updatedWrapData), `payload contains data: ${requestBody}`);
assert.strictEqual(requestHeaders['X-Vault-Wrap-TTL'], '30m', 'request header has default wrap ttl');
return {
wrap_info: {
token: this.token,
accessor: '5yjKx6Om9NmBx1mjiN1aIrnm',
ttl: 1800,
creation_time: '2024-06-07T12:02:22.096254-07:00',
creation_path: 'sys/wrapping/wrap',
},
};
});
await this.renderComponent();
await click('[data-test-toggle-input="json"]');
await fillIn('[data-test-kv-key="0"]', 'foo');
await fillIn('[data-test-kv-value="0"]', 'bar');
await click('[data-test-kv-add-row="0"]');
await fillIn('[data-test-kv-key="1"]', 'foo2');
await fillIn('[data-test-kv-value="1"]', multilineData);
await click(TS.submit);
await waitUntil(() => find(TS.toolsInput('wrapping-token')));
assert.true(flashSpy.calledWith('Wrap was successful.'), 'it renders success flash');
assert.dom(TS.toolsInput('wrapping-token')).hasText(this.token);
assert.dom('label').hasText('Wrapped token');
assert.dom('.CodeMirror').doesNotExist();
});
test('it resets on done', async function (assert) { test('it resets on done', async function (assert) {
await this.renderComponent(); await this.renderComponent();
await codemirror().setValue(this.wrapData); await codemirror().setValue(this.wrapData);
@@ -113,7 +179,11 @@ module('Integration | Component | tools/wrap', function (hooks) {
await waitUntil(() => find(TS.button('Done'))); await waitUntil(() => find(TS.button('Done')));
await click(TS.button('Done')); await click(TS.button('Done'));
assert.strictEqual(codemirror().getValue(' '), '{ }', 'json editor resets to empty object'); assert.strictEqual(
codemirror().getValue(' '),
`{ \"\": \"\" }`, // eslint-disable-line no-useless-escape
'json editor initializes with empty object that includes whitespace'
);
assert.dom(TTL.toggleByLabel('Wrap TTL')).isNotChecked('Wrap TTL resets to unchecked'); assert.dom(TTL.toggleByLabel('Wrap TTL')).isNotChecked('Wrap TTL resets to unchecked');
await click(TTL.toggleByLabel('Wrap TTL')); await click(TTL.toggleByLabel('Wrap TTL'));
assert.dom(TTL.valueInputByLabel('Wrap TTL')).hasValue('30', 'ttl resets to default when toggled'); assert.dom(TTL.valueInputByLabel('Wrap TTL')).hasValue('30', 'ttl resets to default when toggled');
@@ -126,16 +196,51 @@ module('Integration | Component | tools/wrap', function (hooks) {
await waitUntil(() => find(TS.button('Back'))); await waitUntil(() => find(TS.button('Back')));
await click(TS.button('Back')); await click(TS.button('Back'));
assert.strictEqual(codemirror().getValue(' '), `{"foo": "bar"}`, 'json editor has original data'); assert.strictEqual(
codemirror().getValue(' '),
`{ \"foo": \"bar" }`, // eslint-disable-line no-useless-escape
'json editor has original data'
);
assert.dom(TTL.toggleByLabel('Wrap TTL')).isNotChecked('Wrap TTL defaults to unchecked'); assert.dom(TTL.toggleByLabel('Wrap TTL')).isNotChecked('Wrap TTL defaults to unchecked');
}); });
test('it disables/enables submit based on json linting', async function (assert) { test('it renders/hides warning based on json linting', async function (assert) {
await this.renderComponent(); await this.renderComponent();
await codemirror().setValue(`{bad json}`); await codemirror().setValue(`{bad json}`);
assert.dom(TS.submit).isDisabled('submit disables if json editor has linting errors'); assert
.dom('[data-test-inline-alert]')
.hasText(
'JSON is unparsable. Fix linting errors to avoid data discrepancies.',
'Linting error message is shown for json view'
);
await codemirror().setValue(this.wrapData); await codemirror().setValue(this.wrapData);
assert.dom(TS.submit).isEnabled('submit reenables if json editor has no linting errors'); assert.dom('[data-test-inline-alert]').doesNotExist();
});
test('it hides json warning on back and on done', async function (assert) {
await this.renderComponent();
await codemirror().setValue(`{bad json}`);
assert
.dom('[data-test-inline-alert]')
.hasText(
'JSON is unparsable. Fix linting errors to avoid data discrepancies.',
'Linting error message is shown for json view'
);
await click(TS.submit);
await waitUntil(() => find(TS.button('Done')));
await click(TS.button('Done'));
assert.dom('[data-test-inline-alert]').doesNotExist();
await codemirror().setValue(`{bad json}`);
assert
.dom('[data-test-inline-alert]')
.hasText(
'JSON is unparsable. Fix linting errors to avoid data discrepancies.',
'Linting error message is shown for json view'
);
await click(TS.submit);
await waitUntil(() => find(TS.button('Back')));
await click(TS.button('Back'));
assert.dom('[data-test-inline-alert]').doesNotExist();
}); });
}); });