Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Edit Secure Variables as JSON #13461

Merged
merged 12 commits into from
Jul 4, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 1 addition & 9 deletions ui/app/components/json-viewer.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,8 @@
import Component from '@ember/component';
import { computed } from '@ember/object';
import { classNames, classNameBindings } from '@ember-decorators/component';
import classic from 'ember-classic-decorator';

@classic
@classNames('json-viewer')
@classNameBindings('fluidHeight:has-fluid-height')
export default class JsonViewer extends Component {
json = null;

@computed('json')
get jsonStr() {
return JSON.stringify(this.json, null, 2);
}
}
export default class JsonViewer extends Component {}
101 changes: 59 additions & 42 deletions ui/app/components/secure-variable-form.hbs
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
<form class="new-secure-variables" autocomplete="off" {{on "submit" this.save}}
{{did-insert this.appendItemIfEditing}}
>
{{!-- TODO: {{if this.parseError 'is-danger'}} on inputs --}}
{{did-update this.onViewChange @view}}
{{did-insert this.establishKeyValues}}
philrenaud marked this conversation as resolved.
Show resolved Hide resolved
<form class="new-secure-variables" autocomplete="off" {{on "submit" this.save}}>
<div>
<label>
<span>
Expand Down Expand Up @@ -34,46 +33,64 @@
</p>
{{/if}}
</div>
{{#each this.keyValues as |entry iter|}}
<div class="key-value">
<label>
<span>
Key
</span>
<Input
@type="text"
@value={{entry.key}}
class="input"
{{autofocus ignore=(eq iter 0)}}
{{on "input" (fn this.validateKey entry)}}
/>
</label>
<SecureVariableForm::InputGroup @entry={{entry}} />
{{#if (eq entry this.keyValues.lastObject)}}
<button
class="add-more button is-info is-inverted"
type="button"
disabled={{not (and entry.key entry.value)}}
{{on "click" this.appendRow}}
>
Add More
</button>
{{else}}
<button
class="delete-row button is-danger is-inverted"
type="button"
{{on "click" (action this.deleteRow entry)}}
>
Delete
</button>
{{#if (eq this.view "json")}}
<div class="editor-wrapper boxed-section-body is-full-bleed {{if this.JSONError "error"}}">
<div
data-test-json-editor
{{code-mirror
content=this.JSONItems
onUpdate=this.updateCode
extraKeys=(hash Cmd-Enter=(action "save"))
}}
/>
{{#if this.JSONError}}
<p class="help is-danger">
{{this.JSONError}}
</p>
{{/if}}
{{#each-in entry.warnings as |k v|}}
<span class="key-value-error help is-danger">
{{v}}
</span>
{{/each-in}}
</div>
{{/each}}
{{else}}
{{#each this.keyValues as |entry iter|}}
<div class="key-value">
<label>
<span>
Key
</span>
<Input
@type="text"
@value={{entry.key}}
class="input"
{{autofocus ignore=(eq iter 0)}}
{{on "input" (fn this.validateKey entry)}}
/>
</label>
<SecureVariableForm::InputGroup @entry={{entry}} />
{{#if (eq entry this.keyValues.lastObject)}}
<button
class="add-more button is-info is-inverted"
type="button"
disabled={{not (and entry.key entry.value)}}
{{on "click" this.appendRow}}
>
Add More
</button>
{{else}}
<button
class="delete-row button is-danger is-inverted"
type="button"
{{on "click" (action this.deleteRow entry)}}
>
Delete
</button>
{{/if}}
{{#each-in entry.warnings as |k v|}}
<span class="key-value-error help is-danger">
{{v}}
</span>
{{/each-in}}
</div>
{{/each}}
{{/if}}
<footer>
<button
disabled={{this.shouldDisableSave}}
Expand Down
176 changes: 153 additions & 23 deletions ui/app/components/secure-variable-form.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,17 @@ import { tracked } from '@glimmer/tracking';
import { inject as service } from '@ember/service';
import { trimPath } from '../helpers/trim-path';
import { copy } from 'ember-copy';
import EmberObject from '@ember/object';
import EmberObject, { set } from '@ember/object';
// eslint-disable-next-line no-unused-vars
import MutableArray from '@ember/array/mutable';
import { A } from '@ember/array';
import { stringifyObject } from 'nomad-ui/helpers/stringify-object';

const EMPTY_KV = {
key: '',
value: '',
warnings: EmberObject.create(),
};

export default class SecureVariableFormComponent extends Component {
@service router;
Expand All @@ -23,16 +33,45 @@ export default class SecureVariableFormComponent extends Component {
@tracked duplicatePathWarning = null;

get shouldDisableSave() {
return !this.args.model?.path;
return !!this.JSONError || !this.args.model?.path;
}

@tracked keyValues = copy(this.args.model?.keyValues || [])?.map((kv) => {
return {
key: kv.key,
value: kv.value,
warnings: EmberObject.create(),
};
});
/**
* @type {MutableArray<{key: string, value: string, warnings: EmberObject}>}
*/
keyValues = A([]);

/**
* @type {string}
*/
JSONItems = '{}';

@action
establishKeyValues() {
const keyValues = copy(this.args.model?.keyValues || [])?.map((kv) => {
return {
key: kv.key,
value: kv.value,
warnings: EmberObject.create(),
};
});

/**
* Appends a row to the end of the Items list if you're editing an existing variable.
* This will allow it to auto-focus and make all other rows deletable
*/
if (!this.args.model?.isNew) {
keyValues.pushObject(copy(EMPTY_KV));
}
this.keyValues = keyValues;

this.JSONItems = stringifyObject([
this.keyValues.reduce((acc, { key, value }) => {
acc[key] = value;
return acc;
}, {}),
]);
}

@action
validatePath(e) {
Expand All @@ -53,20 +92,26 @@ export default class SecureVariableFormComponent extends Component {
@action
validateKey(entry, e) {
const value = e.target.value;
// No dots in key names
if (value.includes('.')) {
entry.warnings.set('dottedKeyError', 'Key should not contain a period.');
} else {
delete entry.warnings.dottedKeyError;
entry.warnings.notifyPropertyChange('dottedKeyError');
}

// no duplicate keys
const existingKeys = this.keyValues.map((kv) => kv.key);
if (existingKeys.includes(value)) {
entry.warnings.set('duplicateKeyError', 'Key already exists.');
} else {
delete entry.warnings.duplicateKeyError;
entry.warnings.notifyPropertyChange('duplicateKeyError');
}
}

@action appendRow() {
this.keyValues.pushObject({
key: '',
value: '',
warnings: EmberObject.create(),
});
this.keyValues.pushObject(copy(EMPTY_KV));
}

@action deleteRow(row) {
Expand All @@ -75,10 +120,16 @@ export default class SecureVariableFormComponent extends Component {

@action
async save(e) {
e.preventDefault();
if (e.type === 'submit') {
philrenaud marked this conversation as resolved.
Show resolved Hide resolved
e.preventDefault();
}
// TODO: temp, hacky way to force translation to tabular keyValues
if (this.view === 'json') {
this.translateAndValidateItems('table');
}
try {
const nonEmptyItems = this.keyValues.filter(
(item) => item.key.trim() && item.value
const nonEmptyItems = A(
this.keyValues.filter((item) => item.key.trim() && item.value)
philrenaud marked this conversation as resolved.
Show resolved Hide resolved
);
if (!nonEmptyItems.length) {
throw new Error('Please provide at least one key/value pair.');
Expand All @@ -96,7 +147,6 @@ export default class SecureVariableFormComponent extends Component {
type: 'success',
destroyOnClick: false,
timeout: 5000,
showProgress: true,
});
this.router.transitionTo('variables.variable', this.args.model.path);
} catch (error) {
Expand All @@ -110,13 +160,93 @@ export default class SecureVariableFormComponent extends Component {
}
}

//#region JSON Editing

view = this.args.view;
// Prevent duplicate onUpdate events when @view is set to its already-existing value,
// which happens because parent's queryParams and toggle button both resolve independently.
@action onViewChange([view]) {
if (view !== this.view) {
set(this, 'view', view);
philrenaud marked this conversation as resolved.
Show resolved Hide resolved
this.translateAndValidateItems(view);
}
}

@action
translateAndValidateItems(view) {
// TODO: move the translation functions in serializers/variable.js to generic importable functions.
if (view === 'json') {
// Translate table to JSON
set(
this,
'JSONItems',
stringifyObject([
this.keyValues
.filter((item) => item.key.trim() && item.value) // remove empty items when translating to JSON
.reduce((acc, { key, value }) => {
acc[key] = value;
return acc;
}, {}),
])
);

// Give the user a foothold if they're transitioning an empty K/V form into JSON
if (!Object.keys(this.JSONItems).length) {
set(this, 'JSONItems', stringifyObject([{ '': '' }]));
}
} else if (view === 'table') {
// Translate JSON to table
set(
this,
'keyValues',
A(
Object.entries(JSON.parse(this.JSONItems)).map(([key, value]) => {
return {
key,
value: typeof value === 'string' ? value : JSON.stringify(value),
warnings: EmberObject.create(),
};
})
)
);
}

// Reset any error state, since the errorring json will not persist
set(this, 'JSONError', null);
}

/**
* Appends a row to the end of the Items list if you're editing an existing variable.
* This will allow it to auto-focus and make all other rows deletable
* @type {string}
*/
@action appendItemIfEditing() {
if (!this.args.model?.isNew) {
this.appendRow();
@tracked JSONError = null;
/**
*
* @param {string} value
*/
@action updateCode(value, codemirror) {
codemirror.performLint();
try {
const hasLintErrors = codemirror?.state.lint.marked?.length > 0;
if (hasLintErrors || !JSON.parse(value)) {
throw new Error('Invalid JSON');
}

// "myString" is valid JSON, but it's not a valid Secure Variable.
// Ditto for an array of objects. We expect a single object to be a Secure Variable.
const hasFormatErrors =
JSON.parse(value) instanceof Array ||
typeof JSON.parse(value) !== 'object';
if (hasFormatErrors) {
throw new Error(
'A Secure Variable must be formatted as a single JSON object'
);
}

set(this, 'JSONError', null);
set(this, 'JSONItems', value);
} catch (error) {
set(this, 'JSONError', error);
}
}
//#endregion JSON Editing
}
22 changes: 21 additions & 1 deletion ui/app/controllers/variables/new.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,29 @@
// @ts-check

import Controller from '@ember/controller';
import { inject as service } from '@ember/service';
import { tracked } from '@glimmer/tracking';

export default class VariablesNewController extends Controller {
@service store;
queryParams = ['path'];
queryParams = ['path', 'view'];
get existingVariables() {
return this.store.peekAll('variable');
}

//#region Code View
/**
* @type {"table" | "json"}
*/
@tracked
view = 'table';

toggleView() {
if (this.view === 'table') {
this.view = 'json';
} else {
this.view = 'table';
}
}
//#endregion Code View
}
Loading