diff --git a/changelog/23200.txt b/changelog/23200.txt
new file mode 100644
index 000000000000..245cc694afbb
--- /dev/null
+++ b/changelog/23200.txt
@@ -0,0 +1,3 @@
+```release-note:improvement
+ui: Move access to KV V2 version diff view to toolbar in Version History
+```
\ No newline at end of file
diff --git a/ui/lib/kv/addon/components/kv-version-dropdown.hbs b/ui/lib/kv/addon/components/kv-version-dropdown.hbs
index 8f006da972a5..00494bc43b20 100644
--- a/ui/lib/kv/addon/components/kv-version-dropdown.hbs
+++ b/ui/lib/kv/addon/components/kv-version-dropdown.hbs
@@ -9,17 +9,36 @@
diff --git a/ui/lib/kv/addon/components/page/secret/metadata/version-diff.hbs b/ui/lib/kv/addon/components/page/secret/metadata/version-diff.hbs
new file mode 100644
index 000000000000..d90924f2f0a4
--- /dev/null
+++ b/ui/lib/kv/addon/components/page/secret/metadata/version-diff.hbs
@@ -0,0 +1,33 @@
+
diff --git a/ui/lib/kv/addon/routes.js b/ui/lib/kv/addon/routes.js
index efd759b17fc2..529ecbe8d5b4 100644
--- a/ui/lib/kv/addon/routes.js
+++ b/ui/lib/kv/addon/routes.js
@@ -19,6 +19,7 @@ export default buildRoutes(function () {
this.route('metadata', function () {
this.route('edit');
this.route('versions');
+ this.route('diff');
});
});
this.route('configuration');
diff --git a/ui/lib/kv/addon/routes/secret/metadata/diff.js b/ui/lib/kv/addon/routes/secret/metadata/diff.js
new file mode 100644
index 000000000000..6f0e52080e91
--- /dev/null
+++ b/ui/lib/kv/addon/routes/secret/metadata/diff.js
@@ -0,0 +1,23 @@
+/**
+ * Copyright (c) HashiCorp, Inc.
+ * SPDX-License-Identifier: MPL-2.0
+ */
+
+import Route from '@ember/routing/route';
+import { breadcrumbsForSecret } from 'kv/utils/kv-breadcrumbs';
+
+export default class KvSecretMetadataDiffRoute extends Route {
+ // model passed from parent secret route, if we need to access or intercept
+ // it can retrieved via `this.modelFor('secret'), which includes the metadata model.
+ setupController(controller, resolvedModel) {
+ super.setupController(controller, resolvedModel);
+ const breadcrumbsArray = [
+ { label: 'secrets', route: 'secrets', linkExternal: true },
+ { label: resolvedModel.backend, route: 'list' },
+ ...breadcrumbsForSecret(resolvedModel.path),
+ { label: 'version history', route: 'secret.metadata.versions' },
+ { label: 'diff' },
+ ];
+ controller.set('breadcrumbs', breadcrumbsArray);
+ }
+}
diff --git a/ui/lib/kv/addon/templates/secret/metadata/diff.hbs b/ui/lib/kv/addon/templates/secret/metadata/diff.hbs
new file mode 100644
index 000000000000..97b37f1261c0
--- /dev/null
+++ b/ui/lib/kv/addon/templates/secret/metadata/diff.hbs
@@ -0,0 +1,6 @@
+
\ No newline at end of file
diff --git a/ui/tests/helpers/kv/kv-selectors.js b/ui/tests/helpers/kv/kv-selectors.js
index 3c9e5efb69f7..8728af7bf4c0 100644
--- a/ui/tests/helpers/kv/kv-selectors.js
+++ b/ui/tests/helpers/kv/kv-selectors.js
@@ -53,9 +53,6 @@ export const PAGE = {
edit: {
toggleDiff: '[data-test-toggle-input="Show diff"',
toggleDiffDescription: '[data-test-diff-description]',
- visualDiff: '[data-test-visual-diff]',
- added: `.jsondiffpatch-added`,
- deleted: `.jsondiffpatch-deleted`,
},
list: {
createSecret: '[data-test-toolbar-create-secret]',
@@ -73,10 +70,14 @@ export const PAGE = {
icon: (version) => `[data-test-icon-holder="${version}"]`,
linkedBlock: (version) =>
version ? `[data-test-version-linked-block="${version}"]` : '[data-test-version-linked-block]',
- button: (version) => `[data-test-version-button="${version}"]`,
versionMenu: (version) => `[data-test-version-linked-block="${version}"] [data-test-popup-menu-trigger]`,
createFromVersion: (version) => `[data-test-create-new-version-from="${version}"]`,
},
+ diff: {
+ visualDiff: '[data-test-visual-diff]',
+ added: `.jsondiffpatch-added`,
+ deleted: `.jsondiffpatch-deleted`,
+ },
create: {
metadataSection: '[data-test-metadata-section]',
},
diff --git a/ui/tests/integration/components/kv/page/kv-page-secret-edit-test.js b/ui/tests/integration/components/kv/page/kv-page-secret-edit-test.js
index 050cce23f5e3..3c06689c813c 100644
--- a/ui/tests/integration/components/kv/page/kv-page-secret-edit-test.js
+++ b/ui/tests/integration/components/kv/page/kv-page-secret-edit-test.js
@@ -108,24 +108,24 @@ module('Integration | Component | kv-v2 | Page::Secret::Edit', function (hooks)
assert.dom(PAGE.edit.toggleDiff).isDisabled('Diff toggle is disabled');
assert.dom(PAGE.edit.toggleDiffDescription).hasText('No changes to show. Update secret to view diff');
- assert.dom(PAGE.edit.visualDiff).doesNotExist('Does not show visual diff');
+ assert.dom(PAGE.diff.visualDiff).doesNotExist('Does not show visual diff');
await fillIn(FORM.keyInput(1), 'foo2');
await fillIn(FORM.maskedValueInput(1), 'bar2');
assert.dom(PAGE.edit.toggleDiff).isNotDisabled('Diff toggle is not disabled');
assert.dom(PAGE.edit.toggleDiffDescription).hasText('Showing the diff will reveal secret values');
- assert.dom(PAGE.edit.visualDiff).doesNotExist('Does not show visual diff');
+ assert.dom(PAGE.diff.visualDiff).doesNotExist('Does not show visual diff');
await click(PAGE.edit.toggleDiff);
- assert.dom(PAGE.edit.visualDiff).exists('Shows visual diff');
- assert.dom(PAGE.edit.added).hasText(`foo2"bar2"`);
+ assert.dom(PAGE.diff.visualDiff).exists('Shows visual diff');
+ assert.dom(PAGE.diff.added).hasText(`foo2"bar2"`);
await click(FORM.toggleJson);
codemirror().setValue('{ "foo3": "bar3" }');
- assert.dom(PAGE.edit.visualDiff).exists('Visual diff updates');
- assert.dom(PAGE.edit.deleted).hasText(`foo"bar"`);
- assert.dom(PAGE.edit.added).hasText(`foo3"bar3"`);
+ assert.dom(PAGE.diff.visualDiff).exists('Visual diff updates');
+ assert.dom(PAGE.diff.deleted).hasText(`foo"bar"`);
+ assert.dom(PAGE.diff.added).hasText(`foo3"bar3"`);
});
test('it saves nested secrets', async function (assert) {
diff --git a/ui/tests/integration/components/kv/page/kv-page-version-diff-test.js b/ui/tests/integration/components/kv/page/kv-page-version-diff-test.js
new file mode 100644
index 000000000000..322d166e3769
--- /dev/null
+++ b/ui/tests/integration/components/kv/page/kv-page-version-diff-test.js
@@ -0,0 +1,146 @@
+/**
+ * Copyright (c) HashiCorp, Inc.
+ * SPDX-License-Identifier: MPL-2.0
+ */
+
+import { module, test } from 'qunit';
+import { setupRenderingTest } from 'ember-qunit';
+import { setupEngine } from 'ember-engines/test-support';
+import { setupMirage } from 'ember-cli-mirage/test-support';
+import { click, findAll, render } from '@ember/test-helpers';
+import { hbs } from 'ember-cli-htmlbars';
+import { kvMetadataPath, kvDataPath } from 'vault/utils/kv-path';
+import { PAGE } from 'vault/tests/helpers/kv/kv-selectors';
+import { allowAllCapabilitiesStub } from 'vault/tests/helpers/stubs';
+
+module('Integration | Component | kv | Page::Secret::Metadata::VersionDiff', function (hooks) {
+ setupRenderingTest(hooks);
+ setupEngine(hooks, 'kv');
+ setupMirage(hooks);
+
+ hooks.beforeEach(async function () {
+ this.backend = 'kv-engine';
+ this.path = 'my-secret';
+ this.breadcrumbs = [{ label: 'version history', route: 'secret.metadata.versions' }, { label: 'diff' }];
+
+ this.store = this.owner.lookup('service:store');
+ this.server.post('/sys/capabilities-self', allowAllCapabilitiesStub());
+
+ const metadata = this.server.create('kv-metadatum');
+ metadata.id = kvMetadataPath(this.backend, this.path);
+ this.store.pushPayload('kv/metadata', { modelName: 'kv/metadata', ...metadata });
+ this.metadata = this.store.peekRecord('kv/metadata', metadata.id);
+ // push current secret version record into the store to assert only one request is made
+ const dataId = kvDataPath(this.backend, this.path, 4);
+ this.store.pushPayload('kv/data', {
+ modelName: 'kv/data',
+ id: dataId,
+ secret_data: { foo: 'bar' },
+ version: this.metadata.currentVersion,
+ });
+ });
+
+ test('it renders empty states when current version is deleted or destroyed', async function (assert) {
+ assert.expect(4);
+ this.server.get(`/${this.backend}/data/${this.path}`, () => {});
+ const { currentVersion } = this.metadata;
+
+ // destroyed
+ this.metadata.versions[currentVersion].destroyed = true;
+ await render(
+ hbs`
+
+ `,
+ { owner: this.engine }
+ );
+ assert.dom(PAGE.emptyStateTitle).hasText(`Version ${currentVersion} has been destroyed`);
+ assert
+ .dom(PAGE.emptyStateMessage)
+ .hasText('The current version of this secret has been destroyed. Select another version to compare.');
+
+ // deleted
+ this.metadata.versions[currentVersion].destroyed = false;
+ this.metadata.versions[currentVersion].deletion_time = '2023-07-25T00:36:19.950545Z';
+ await render(
+ hbs`
+
+ `,
+ { owner: this.engine }
+ );
+
+ assert.dom(PAGE.emptyStateTitle).hasText(`Version ${currentVersion} has been deleted`);
+ assert
+ .dom(PAGE.emptyStateMessage)
+ .hasText('The current version of this secret has been deleted. Select another version to compare.');
+ });
+
+ test('it renders compared data of the two versions and shows icons for deleted, destroyed and current', async function (assert) {
+ assert.expect(14);
+ this.server.get(`/${this.backend}/data/${this.path}`, (schema, req) => {
+ assert.ok('request made to the fetch version 1 data.');
+ // request should not be made for version 4 (current version) because that record already exists in the store
+ assert.strictEqual(req.queryParams.version, '1', 'request includes version param');
+ return {
+ request_id: 'foobar',
+ data: {
+ data: { hello: 'world' },
+ metadata: {
+ created_time: '2023-06-20T21:26:47.592306Z',
+ custom_metadata: null,
+ deletion_time: '',
+ destroyed: false,
+ version: 1,
+ },
+ },
+ };
+ });
+
+ await render(
+ hbs`
+
+ `,
+ { owner: this.engine }
+ );
+
+ const [left, right] = findAll(PAGE.detail.versionDropdown);
+ assert.dom(PAGE.diff.visualDiff).hasText(
+ `foo\"bar\"hello\"world\"`, // eslint-disable-line no-useless-escape
+ 'correctly pull in the data from version 4 and compared to version 1.'
+ );
+ assert.dom(PAGE.diff.deleted).hasText(`hello"world"`);
+ assert.dom(PAGE.diff.added).hasText(`foo"bar"`);
+ assert.dom(right).hasText('Version 4', 'shows the current version for the left side default version.');
+ assert.dom(left).hasText('Version 1', 'shows the latest active version on init.');
+
+ await click(left);
+
+ for (const num in this.metadata.versions) {
+ const data = this.metadata.versions[num];
+ assert.dom(PAGE.detail.version(num)).exists('renders the button for each version.');
+
+ if (data.destroyed || data.deletion_time) {
+ assert
+ .dom(`${PAGE.detail.version(num)} [data-test-icon="x-square-fill"]`)
+ .hasClass(`${data.destroyed ? 'has-text-danger' : 'has-text-grey'}`);
+ }
+ }
+ assert
+ .dom(`${PAGE.detail.version('1')} button`)
+ .hasClass('is-active', 'correctly shows the selected version 1 as active.');
+ });
+});