diff --git a/changelog/29677.txt b/changelog/29677.txt
new file mode 100644
index 0000000000..61103ff49f
--- /dev/null
+++ b/changelog/29677.txt
@@ -0,0 +1,3 @@
+```release-note:improvement
+ui: adds key value pair string inputs as optional form for wrap tool
+```
diff --git a/ui/app/components/tools/wrap.hbs b/ui/app/components/tools/wrap.hbs
index e294aba9ac..8887cdbbd6 100644
--- a/ui/app/components/tools/wrap.hbs
+++ b/ui/app/components/tools/wrap.hbs
@@ -40,16 +40,30 @@
-
+
+
+
+ JSON
+
+
+
+ {{#if this.showJson}}
+
+ {{else}}
+
+ {{/if}}
+ {{#if this.hasLintingErrors}}
+
+ {{/if}}
diff --git a/ui/app/components/tools/wrap.js b/ui/app/components/tools/wrap.js
index 281db49c9c..cf8fbf3e40 100644
--- a/ui/app/components/tools/wrap.js
+++ b/ui/app/components/tools/wrap.js
@@ -7,6 +7,7 @@ import Component from '@glimmer/component';
import { service } from '@ember/service';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
+import { stringify } from 'core/helpers/stringify';
import errorMessage from 'vault/utils/error-message';
/**
@@ -21,18 +22,38 @@ export default class ToolsWrap extends Component {
@service store;
@service flashMessages;
- @tracked buttonDisabled = false;
+ @tracked hasLintingErrors = false;
@tracked token = '';
@tracked wrapTTL = null;
- @tracked wrapData = '{\n}';
+ @tracked wrapData = null;
@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
reset(clearData = true) {
this.token = '';
this.errorMessage = '';
this.wrapTTL = null;
- if (clearData) this.wrapData = '{\n}';
+ this.hasLintingErrors = false;
+ if (clearData) this.wrapData = null;
}
@action
@@ -44,15 +65,15 @@ export default class ToolsWrap extends Component {
@action
codemirrorUpdated(val, codemirror) {
codemirror.performLint();
- const hasErrors = codemirror?.state.lint.marked?.length > 0;
- this.buttonDisabled = hasErrors;
- if (!hasErrors) this.wrapData = val;
+ this.hasLintingErrors = codemirror?.state.lint.marked?.length > 0;
+ if (!this.hasLintingErrors) this.wrapData = JSON.parse(val);
}
@action
async handleSubmit(evt) {
evt.preventDefault();
- const data = JSON.parse(this.wrapData);
+
+ const data = this.wrapData;
const wrapTTL = this.wrapTTL || null;
try {
diff --git a/ui/tests/integration/components/tools/wrap-test.js b/ui/tests/integration/components/tools/wrap-test.js
index 2721bcdc93..c863a800ca 100644
--- a/ui/tests/integration/components/tools/wrap-test.js
+++ b/ui/tests/integration/components/tools/wrap-test.js
@@ -37,8 +37,13 @@ module('Integration | Component | tools/wrap', function (hooks) {
await this.renderComponent();
assert.dom('h1').hasText('Wrap Data', 'Title renders');
- assert.dom('label').hasText('Data to wrap (json-formatted)');
- assert.strictEqual(codemirror().getValue(' '), '{ }', 'json editor initializes with empty object');
+ assert.dom('[data-test-toggle-label="json"]').hasText('JSON');
+ 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(TS.submit).isEnabled();
assert.dom(TS.toolsInput('wrapping-token')).doesNotExist();
@@ -104,6 +109,67 @@ module('Integration | Component | tools/wrap', function (hooks) {
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) {
await this.renderComponent();
await codemirror().setValue(this.wrapData);
@@ -113,7 +179,11 @@ module('Integration | Component | tools/wrap', function (hooks) {
await waitUntil(() => find(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');
await click(TTL.toggleByLabel('Wrap TTL'));
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 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');
});
- 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 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);
- 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();
});
});