open-nomad/ui/app/components/secure-variable-form.js
Jai 7d90d22956
ui: add namespace filter to variables.index (#13618)
* ui: add namespace filter feature

ui:  add namespace filtering to variables.index

test:  namespace filter

refact:  fix action in template

ui:  move data fetching and query param logic to

ui:  controller query parameter logic

ui:  prevent  from forwarding query param

ui:  create variables controller

refact:  use dependency injection for controlling parent qp

chore: clean-up reset in  route

chore:  clean-up reset in  route

* ui:  add namespace filter to secure var form (#13629)

ui:  update variable factory to accept namespaces

refact:  update  api to accept disabled

ui:  add namespace setting logic to form

refact:  remove debugger

refact:  get correct selectors for

ui:  move data loading to namespace-filter component

chore:  prettify template

ui: update factory to handle namespace setting

refact:  remove inline styling for grid class

* ui:  fix placement of filter in `SecureVariablesForm` (#13762)

* refact:  conditionally render css class

* chore:  remove unused CSS property

* refact:  edit path-input class to prevent textarea override

* refact:  inject missing store service (#13763)

* chore:  patch fixes for when no default namespace is available (#13782)

* test:  add tests for namespace filtering conditions (#13816)

* test:  add tests for namespace filtering and namespaces appearing in form

* patch namespace related issue to saving and querying (#13825)

* refact:  use namespace id, not entity

* refact:  update adapter to edit request to include qp

* ui: early exit if no snapshot

* refact:  test passes wrong interface to method

* chore:  add missing url update URL builder

* refact:  model in  doesn't have absolutePath

* Align error message

* chore:  update tests (#13905)

* chore:  patch brittle tests with better selectors

* chore:  update assertion count

Co-authored-by: Phil Renaud <phil@riotindustries.com>
2022-07-22 16:05:35 -04:00

296 lines
8 KiB
JavaScript

// @ts-check
import Component from '@glimmer/component';
import { action } from '@ember/object';
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, { 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 flashMessages;
@service router;
@service store;
/**
* @typedef {Object} DuplicatePathWarning
* @property {string} path
*/
/**
* @type {DuplicatePathWarning}
*/
@tracked duplicatePathWarning = null;
@tracked variableNamespace = null;
@tracked namespaceOptions = null;
@action
setNamespace(namespace) {
this.variableNamespace = namespace;
}
@action
setNamespaceOptions(options) {
this.namespaceOptions = options;
// Set first namespace option
if (options.length) {
this.variableNamespace = options[0].key;
}
}
get shouldDisableSave() {
const disallowedPath =
this.args.model?.path?.startsWith('nomad/') &&
!this.args.model?.path?.startsWith('nomad/jobs');
return !!this.JSONError || !this.args.model?.path || disallowedPath;
}
/**
* @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) {
const value = trimPath([e.target.value]);
const existingVariables = this.args.existingVariables || [];
let existingVariable = existingVariables
.without(this.args.model)
.find((v) => v.path === value);
if (existingVariable) {
this.duplicatePathWarning = {
path: existingVariable.path,
};
} else {
this.duplicatePathWarning = null;
}
}
@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(copy(EMPTY_KV));
}
@action deleteRow(row) {
this.keyValues.removeObject(row);
}
@action
async save(e) {
if (e.type === 'submit') {
e.preventDefault();
}
// TODO: temp, hacky way to force translation to tabular keyValues
if (this.view === 'json') {
this.translateAndValidateItems('table');
}
try {
const nonEmptyItems = A(
this.keyValues.filter((item) => item.key.trim() && item.value)
);
if (!nonEmptyItems.length) {
throw new Error('Please provide at least one key/value pair.');
} else {
this.keyValues = nonEmptyItems;
}
if (this.args.model?.isNew) {
if (this.namespaceOptions) {
this.args.model.set('namespace', this.variableNamespace);
} else {
const [namespace] = this.store.peekAll('namespace').toArray();
this.args.model.set('namespace', namespace.id);
}
}
this.args.model.set('keyValues', this.keyValues);
this.args.model.setAndTrimPath();
await this.args.model.save();
this.flashMessages.add({
title: 'Secure Variable saved',
message: `${this.args.model.path} successfully saved`,
type: 'success',
destroyOnClick: false,
timeout: 5000,
});
this.router.transitionTo('variables.variable', this.args.model.path);
} catch (error) {
this.flashMessages.add({
title: `Error saving ${this.args.model.path}`,
message: error,
type: 'error',
destroyOnClick: false,
sticky: true,
});
}
}
//#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);
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(JSON.parse(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(),
};
})
)
);
// If the JSON object is empty at switch time, add an empty KV in to give the user a foothold
if (!Object.keys(JSON.parse(this.JSONItems)).length) {
this.appendRow();
}
}
// Reset any error state, since the errorring json will not persist
set(this, 'JSONError', null);
}
/**
* @type {string}
*/
@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
get shouldShowLinkedEntities() {
return (
this.args.model.pathLinkedEntities?.job ||
this.args.model.pathLinkedEntities?.group ||
this.args.model.pathLinkedEntities?.task
);
}
}