UI/kv codemirror diff (#13000)

* staying with jsondiff

* routing setup

* send compare against data to component after using new adapater method to return the version data.

* functionality

* fix issue on route transition not calling model hook

* formatting

* update version

* changelog

* glimmerize the json-editor component

* catch up

* passing tracked property from child to parent

* pull out of jsonEditor

* fix issue with message

* icon

* fix some issues with right selection

* changes and convert to component

* integration test

* tests

* fixes

* cleanup

* cleanup 2

* fixes

* fix test by spread attributes

* remove log

* remove
This commit is contained in:
Angel Garbarino 2021-12-01 11:41:49 -07:00 committed by GitHub
parent 82d49a08fb
commit 43148ed9f2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 495 additions and 60 deletions

3
changelog/13000.txt Normal file
View File

@ -0,0 +1,3 @@
```release-note:improvement
ui: Add version diff view for KV V2
```

View File

@ -54,6 +54,16 @@ export default ApplicationAdapter.extend({
});
},
querySecretDataByVersion(id) {
return this.ajax(this.urlForQueryRecord(id), 'GET')
.then(resp => {
return resp.data;
})
.catch(error => {
return error.data;
});
},
urlForCreateRecord(modelName, snapshot) {
let backend = snapshot.belongsTo('secret').belongsTo('engine').id;
let path = snapshot.attr('path');

View File

@ -0,0 +1,83 @@
/* eslint-disable no-undef */
import Component from '@glimmer/component';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
/**
* @module DiffVersionSelector
* DiffVersionSelector component includes a toolbar and diff view between KV 2 versions. It uses the library jsondiffpatch.
*
* @example
* ```js
* <DiffVersionSelector @model={model}/>
* ```
* @param {object} model - model that comes from secret-v2-version
*/
export default class DiffVersionSelector extends Component {
@tracked leftSideVersionDataSelected = null;
@tracked leftSideVersionSelected = null;
@tracked rightSideVersionDataSelected = null;
@tracked rightSideVersionSelected = null;
@tracked statesMatch = false;
@tracked visualDiff = null;
@service store;
adapter = this.store.adapterFor('secret-v2-version');
constructor() {
super(...arguments);
this.createVisualDiff();
}
get leftSideDataInit() {
let string = `["${this.args.model.engineId}", "${this.args.model.id}", "${this.args.model.currentVersion}"]`;
return this.adapter
.querySecretDataByVersion(string)
.then(response => response.data)
.catch(() => null);
}
get rightSideDataInit() {
let string = `["${this.args.model.engineId}", "${this.args.model.id}", "${this.rightSideVersionInit}"]`;
return this.adapter
.querySecretDataByVersion(string)
.then(response => response.data)
.catch(() => null);
}
get rightSideVersionInit() {
// initial value of right side version is one less than the current version
return this.args.model.currentVersion === 1 ? 0 : this.args.model.currentVersion - 1;
}
async createVisualDiff() {
let diffpatcher = jsondiffpatch.create({});
let leftSideVersionData = this.leftSideVersionDataSelected || (await this.leftSideDataInit);
let rightSideVersionData = this.rightSideVersionDataSelected || (await this.rightSideDataInit);
let delta = diffpatcher.diff(rightSideVersionData, leftSideVersionData);
if (delta === undefined) {
this.statesMatch = true;
this.visualDiff = JSON.stringify(leftSideVersionData, undefined, 2); // params: value, replacer (all properties included), space (white space and indentation, line break, etc.)
} else {
this.statesMatch = false;
this.visualDiff = jsondiffpatch.formatters.html.format(delta, rightSideVersionData);
}
}
@action
async selectVersion(selectedVersion, actions, side) {
let string = `["${this.args.model.engineId}", "${this.args.model.id}", "${selectedVersion}"]`;
let secretData = await this.adapter.querySecretDataByVersion(string);
if (side === 'left') {
this.leftSideVersionDataSelected = secretData.data;
this.leftSideVersionSelected = selectedVersion;
}
if (side === 'right') {
this.rightSideVersionDataSelected = secretData.data;
this.rightSideVersionSelected = selectedVersion;
}
await this.createVisualDiff();
// close dropdown menu.
actions.close();
}
}

View File

@ -1,4 +1,21 @@
import Component from '@ember/component';
import Component from '@glimmer/component';
import { action } from '@ember/object';
/**
* @module JsonEditor
*
* @example
* ```js
* <JsonEditor @title="Policy" @value={{codemirror.string}} @valueUpdated={{ action "codemirrorUpdate"}} />
* ```
*
* @param {string} [title] - Name above codemirror view
* @param {string} value - a specific string the comes from codemirror. It's the value inside the codemirror display
* @param {Function} [valueUpdated] - action to preform when you edit the codemirror value.
* @param {Function} [onFocusOut] - action to preform when you focus out of codemirror.
* @param {string} [helpText] - helper text.
* @param {object} [options] - option object that overrides codemirror default options such as the styling.
*/
const JSON_EDITOR_DEFAULTS = {
// IMPORTANT: `gutters` must come before `lint` since the presence of
@ -13,20 +30,16 @@ const JSON_EDITOR_DEFAULTS = {
showCursorWhenSelecting: true,
};
export default Component.extend({
showToolbar: true,
title: null,
subTitle: null,
helpText: null,
value: null,
options: null,
valueUpdated: null,
onFocusOut: null,
readOnly: false,
export default class JsonEditorComponent extends Component {
value = null;
valueUpdated = null;
onFocusOut = null;
readOnly = false;
options = null;
init() {
this._super(...arguments);
this.options = { ...JSON_EDITOR_DEFAULTS, ...this.options };
constructor() {
super(...arguments);
this.options = { ...JSON_EDITOR_DEFAULTS, ...this.args.options };
if (this.options.autoHeight) {
this.options.viewportMargin = Infinity;
delete this.options.autoHeight;
@ -36,18 +49,23 @@ export default Component.extend({
this.options.lineNumbers = false;
delete this.options.gutters;
}
},
}
actions: {
updateValue(...args) {
if (this.valueUpdated) {
this.valueUpdated(...args);
}
},
onFocus(...args) {
if (this.onFocusOut) {
this.onFocusOut(...args);
}
},
},
});
get getShowToolbar() {
return this.args.showToolbar === false ? false : true;
}
@action
updateValue(...args) {
if (this.args.valueUpdated) {
this.args.valueUpdated(...args);
}
}
@action
onFocus(...args) {
if (this.args.onFocusOut) {
this.args.onFocusOut(...args);
}
}
}

View File

@ -0,0 +1,4 @@
import Controller from '@ember/controller';
import BackendCrumbMixin from 'vault/mixins/backend-crumb';
export default class DiffController extends Controller.extend(BackendCrumbMixin) {}

View File

@ -103,6 +103,7 @@ Router.map(function() {
this.route('list', { path: '/list/*secret' });
this.route('show', { path: '/show/*secret' });
this.route('diff', { path: '/diff/*id' });
this.route('metadata', { path: '/metadata/*secret' });
this.route('edit-metadata', { path: '/edit-metadata/*secret' });
this.route('create', { path: '/create/*secret' });

View File

@ -0,0 +1,25 @@
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
export default class diff extends Route {
@service store;
beforeModel() {
let { backend } = this.paramsFor('vault.cluster.secrets.backend');
this.backend = backend;
}
model(params) {
let { id } = params;
return this.store.queryRecord('secret-v2', {
backend: this.backend,
id,
});
}
setupController(controller, model) {
controller.set('backend', this.backend); // for backendCrumb
controller.set('id', model.id); // for navigation on tabs
controller.set('model', model);
}
}

View File

@ -0,0 +1,24 @@
.visual-diff {
background-color: black;
pre {
color: $ui-gray-010;
}
}
.jsondiffpatch-deleted .jsondiffpatch-property-name,
.jsondiffpatch-deleted pre,
.jsondiffpatch-modified .jsondiffpatch-left-value pre,
.jsondiffpatch-textdiff-deleted {
background: $red-500;
}
.jsondiffpatch-added .jsondiffpatch-property-name,
.jsondiffpatch-added .jsondiffpatch-value pre,
.jsondiffpatch-modified .jsondiffpatch-right-value pre,
.jsondiffpatch-textdiff-added {
background: $green-500;
}
.jsondiffpatch-property-name {
color: $ui-gray-300;
}

View File

@ -118,3 +118,14 @@
margin: 0 $spacing-xs;
width: 0;
}
.version-diff-toolbar {
display: flex;
align-items: baseline;
gap: $spacing-s;
.diff-status {
display: flex;
direction: rtl;
}
}

View File

@ -52,6 +52,7 @@
@import './components/confirm';
@import './components/console-ui-panel';
@import './components/control-group';
@import './components/diff-version-selector';
@import './components/doc-link';
@import './components/empty-state';
@import './components/env-banner';

View File

@ -0,0 +1,94 @@
<Toolbar>
<div class="version-diff-toolbar" data-test-version-diff-toolbar>
{{!-- Left side version --}}
<BasicDropdown
@class="popup-menu"
@horizontalPosition="auto-right"
@verticalPosition="below"
as |D|
>
<D.trigger
data-test-popup-menu-trigger="left-version"
@class={{concat "toolbar-link" (if D.isOpen " is-active")}}
@tagName="button"
>
Version {{or this.leftSideVersionSelected this.args.model.currentVersion}}
<Chevron @direction="down" @isButton={{true}} />
</D.trigger>
<D.content @class="popup-menu-content">
<nav class="box menu">
<ul class="menu-list">
{{#each (reverse this.args.model.versions) as |leftSideSecretVersion|}}
<li class="action" data-test-leftSide-version={{leftSideSecretVersion.version}}>
<button
class="link"
{{on "click" (fn this.selectVersion leftSideSecretVersion.version D.actions "left")}}
>
Version {{leftSideSecretVersion.version}}
{{#if (and (eq leftSideSecretVersion.version (or this.leftSideVersionSelected this.args.model.currentVersion)) (not leftSideSecretVersion.destroyed) (not leftSideSecretVersion.deleted))}}
<Icon @glyph="check-circle-outline" class="has-text-success is-pulled-right" />
{{else if leftSideSecretVersion.destroyed}}
<Icon @glyph="cancel-square-fill" class="has-text-danger is-pulled-right" />
{{else if leftSideSecretVersion.deleted}}
<Icon @glyph="cancel-square-fill" class="has-text-grey is-pulled-right" />
{{/if}}
</button>
</li>
{{/each}}
</ul>
</nav>
</D.content>
</BasicDropdown>
{{!-- Right side version --}}
<BasicDropdown
@class="popup-menu"
@horizontalPosition="right"
@verticalPosition="below"
as |D|
>
<D.trigger
@class={{concat "toolbar-link" (if D.isOpen " is-active")}}
@tagName="button"
data-test-popup-menu-trigger="right-version"
>
Version {{or this.rightSideVersionSelected this.rightSideVersionInit}}
<Chevron @direction="down" @isButton={{true}} />
</D.trigger>
<D.content @class="popup-menu-content">
<nav class="box menu">
<ul class="menu-list">
{{#each (reverse this.args.model.versions) as |rightSideSecretVersion|}}
<li class="action">
<button
class="link"
{{on "click" (fn this.selectVersion rightSideSecretVersion.version D.actions "right")}}
data-test-rightSide-version={{rightSideSecretVersion.version}}
>
Version {{rightSideSecretVersion.version}}
{{#if (and (eq rightSideSecretVersion.version (or this.rightSideVersionSelected this.rightSideVersionInit)) (not rightSideSecretVersion.destroyed) (not rightSideSecretVersion.deleted))}}
<Icon @glyph="check-circle-outline" class="has-text-success is-pulled-right" />
{{else if rightSideSecretVersion.destroyed}}
<Icon @glyph="cancel-square-fill" class="has-text-danger is-pulled-right" />
{{else if rightSideSecretVersion.deleted}}
<Icon @glyph="cancel-square-fill" class="has-text-grey is-pulled-right" />
{{/if}}
</button>
</li>
{{/each}}
</ul>
</nav>
</D.content>
</BasicDropdown>
{{!-- Status --}}
{{#if this.statesMatch}}
<div class="diff-status">
<span>States match</span>
<Icon @glyph="check-circle-fill" class="has-text-success" />
</div>
{{/if}}
</div>
</Toolbar>
<div class="form-section visual-diff">
<pre>{{{this.visualDiff}}}</pre>
</div>

View File

@ -1,32 +1,36 @@
{{#if showToolbar }}
<div data-test-component="json-editor-toolbar">
<Toolbar>
<label class="is-label" data-test-component="json-editor-title">
{{title}}
{{#if subTitle }}
<span class="is-size-9 is-lowercase has-text-grey">({{ subTitle }})</span>
{{/if}}
</label>
<ToolbarActions>
{{yield}}
<div class="toolbar-separator"></div>
<CopyButton class="button is-transparent" @clipboardText={{value}}
@buttonType="button" @success={{action (set-flash-message 'Data copied!')}}>
<Icon @glyph="copy-action" aria-label="Copy" />
</CopyButton>
</ToolbarActions>
</Toolbar>
</div>
{{/if}}
{{ivy-codemirror
data-test-component="json-editor"
value=value
options=options
valueUpdated=(action "updateValue")
onFocusOut=(action "onFocus")
}}
{{#if helpText }}
<div class="box is-shadowless is-fullwidth has-short-padding">
<p class="sub-text">{{ helpText }}</p>
</div>
{{/if}}
<div ...attributes>
{{#if this.getShowToolbar }}
<div data-test-component="json-editor-toolbar">
<Toolbar>
<label class="is-label" data-test-component="json-editor-title">
{{@title}}
{{#if @subTitle }}
<span class="is-size-9 is-lowercase has-text-grey">({{ @subTitle }})</span>
{{/if}}
</label>
<ToolbarActions>
{{yield}}
<div class="toolbar-separator"></div>
<CopyButton class="button is-transparent" @clipboardText={{@value}}
@buttonType="button" @success={{action (set-flash-message 'Data copied!')}}>
<Icon @glyph="copy-action" aria-label="Copy" />
</CopyButton>
</ToolbarActions>
</Toolbar>
</div>
{{/if}}
{{ivy-codemirror
data-test-component="json-editor"
value=@value
options=this.options
valueUpdated=(action "updateValue")
onFocusOut=(action "onFocus")
}}
{{#if @helpText }}
<div class="box is-shadowless is-fullwidth has-short-padding">
<p class="sub-text">{{ @helpText }}</p>
</div>
{{/if}}
</div>

View File

@ -43,6 +43,20 @@
View version history
</SecretLink>
</li>
{{#if (gt @model.versions.length 1)}}
<li class="action">
<li>
<LinkTo
class="link"
@route="vault.cluster.secrets.backend.diff"
@model={{@model.id}}
data-test-view-diff
>
View diff
</LinkTo>
</li>
</li>
{{/if}}
</ul>
</nav>
</D.content>

View File

@ -0,0 +1,20 @@
<PageHeader as |p|>
<p.top>
<KeyValueHeader
@baseKey={{hash id=@model.id}}
@path="vault.cluster.secrets.backend.show"
@mode="show"
@showCurrent={{true}}
@root={{backendCrumb}}
/>
</p.top>
<p.levelLeft>
<h1 class="title is-3">
View diff
</h1>
</p.levelLeft>
</PageHeader>
<DiffVersionSelector
@model={{@model}}
/>

View File

@ -71,6 +71,8 @@ module.exports = function(defaults) {
app.import('node_modules/codemirror/addon/lint/lint.js');
app.import('node_modules/codemirror/addon/lint/json-lint.js');
app.import('node_modules/text-encoder-lite/text-encoder-lite.js');
app.import('node_modules/jsondiffpatch/dist/jsondiffpatch.umd.js');
app.import('node_modules/jsondiffpatch/dist/formatters-styles/html.css');
app.import('app/styles/bulma/bulma-radio-checkbox.css');

View File

@ -133,6 +133,7 @@
"filesize": "^4.2.1",
"flat": "^4.1.0",
"ivy-codemirror": "IvyApp/ivy-codemirror#fb09333c5144da47e14a9e6260f80577d5408374",
"jsondiffpatch": "^0.4.1",
"jsonlint": "^1.6.3",
"loader.js": "^4.7.0",
"node-forge": "^0.10.0",

View File

@ -0,0 +1,69 @@
import { click, settled, fillIn } from '@ember/test-helpers';
import { create } from 'ember-cli-page-object';
import { module, test } from 'qunit';
import { setupApplicationTest } from 'ember-qunit';
import editPage from 'vault/tests/pages/secrets/backend/kv/edit-secret';
import listPage from 'vault/tests/pages/secrets/backend/list';
import apiStub from 'vault/tests/helpers/noop-all-api-requests';
import authPage from 'vault/tests/pages/auth';
import consoleClass from 'vault/tests/pages/components/console/ui-panel';
const consoleComponent = create(consoleClass);
module('Acceptance | kv2 diff view', function(hooks) {
setupApplicationTest(hooks);
hooks.beforeEach(async function() {
this.server = apiStub({ usePassthrough: true });
return authPage.login();
});
hooks.afterEach(function() {
this.server.shutdown();
});
test('it shows correct diff status based on versions', async function(assert) {
const secretPath = `my-secret`;
await consoleComponent.runCommands([
`write sys/mounts/secret type=kv options=version=2`,
// delete any kv previously written here so that tests can be re-run
`delete secret/metadata/${secretPath}`,
'write -field=client_token auth/token/create policies=kv-v2-degrade',
]);
await listPage.visitRoot({ backend: 'secret' });
await settled();
await listPage.create();
await settled();
await editPage.createSecret(secretPath, 'version1', 'hello');
await settled();
await click('[data-test-popup-menu-trigger="version"]');
await settled();
assert.dom('[data-test-view-diff]').doesNotExist('does not show diff view with only one version');
// add another version
await click('[data-test-secret-edit="true"]');
await settled();
let secondKey = document.querySelectorAll('[data-test-secret-key]')[1];
let secondValue = document.querySelectorAll('.masked-value')[1];
await fillIn(secondKey, 'version2');
await fillIn(secondValue, 'world!');
await click('[data-test-secret-save]');
await settled();
await click('[data-test-popup-menu-trigger="version"]');
await settled();
assert.dom('[data-test-view-diff]').exists('does show diff view with two versions');
await click('[data-test-view-diff]');
await settled();
let diffBetweenVersion2and1 = document.querySelector('.jsondiffpatch-added').innerText;
assert.equal(diffBetweenVersion2and1, 'version2"world!"', 'shows the correct added part');
await click('[data-test-popup-menu-trigger="right-version"]');
await settled();
await click('[data-test-rightSide-version="2"]');
await settled();
assert.dom('.diff-status').exists('shows States Match');
});
});

View File

@ -0,0 +1,38 @@
import EmberObject from '@ember/object';
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, click, settled } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
const VERSIONS = [
{
version: 2,
},
{
version: 1,
},
];
module('Integration | Component | diff-version-selector', function(hooks) {
setupRenderingTest(hooks);
test('it renders', async function(assert) {
this.set(
'model',
EmberObject.create({
currentVersion: 2,
versions: VERSIONS,
})
);
await render(hbs`<DiffVersionSelector @model={{this.model}} />`);
let leftSideVersion = document
.querySelector('[data-test-popup-menu-trigger="left-version"]')
.innerText.trim();
assert.equal(leftSideVersion, 'Version 2', 'left side toolbar defaults to currentVersion');
await click('[data-test-popup-menu-trigger="left-version"]');
await settled();
assert.dom('[data-test-leftSide-version="1"]').exists('leftside shows both versions');
assert.dom('[data-test-leftSide-version="2"]').exists('leftside shows both versions');
});
});

View File

@ -8540,6 +8540,11 @@ detect-port@^1.3.0:
address "^1.0.1"
debug "^2.6.0"
diff-match-patch@^1.0.0:
version "1.0.5"
resolved "https://registry.yarnpkg.com/diff-match-patch/-/diff-match-patch-1.0.5.tgz#abb584d5f10cd1196dfc55aa03701592ae3f7b37"
integrity sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==
diff@^3.5.0:
version "3.5.0"
resolved "https://registry.yarnpkg.com/diff/-/diff-3.5.0.tgz#800c0dd1e0a8bfbc95835c202ad220fe317e5a12"
@ -13642,6 +13647,14 @@ json5@^2.1.2, json5@^2.1.3:
dependencies:
minimist "^1.2.5"
jsondiffpatch@^0.4.1:
version "0.4.1"
resolved "https://registry.yarnpkg.com/jsondiffpatch/-/jsondiffpatch-0.4.1.tgz#9fb085036767f03534ebd46dcd841df6070c5773"
integrity sha512-t0etAxTUk1w5MYdNOkZBZ8rvYYN5iL+2dHCCx/DpkFm/bW28M6y5nUS83D4XdZiHy35Fpaw6LBb+F88fHZnVCw==
dependencies:
chalk "^2.3.0"
diff-match-patch "^1.0.0"
jsonfile@^2.1.0:
version "2.4.0"
resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-2.4.0.tgz#3736a2b428b87bbda0cc83b53fa3d633a35c2ae8"