3db9f11c37
* Extend variables under the nomad path prefix to allow for job-templates (#15570) * Extend variables under the nomad path prefix to allow for job-templates * Add job-templates to error message hinting * RadioCard component for Job Templates (#15582) * chore: add * test: component API * ui: component template * refact: remove bc naming collission * styles: remove SASS var causing conflicts * Disallow specific variable at nomad/job-templates (#15681) * Disallows variables at exactly nomad/job-templates * idiomatic refactor * Expanding nomad job init to accept a template flag (#15571) * Adding a string flag for templates on job init * data-down actions-up version of a custom template editor within variable * Dont force grid on job template editor * list-templates flag started * Correctly slice from end of path name * Pre-review cleanup * Variable form acceptance test for job template editing * Some review cleanup * List Job templates test * Example from template test * Using must.assertions instead of require etc * ui: add choose template button (#15596) * ui: add new routes * chore: update file directory * ui: add choose template button * test: button and page navigation * refact: update var name * ui: use `Button` component from `HDS` (#15607) * ui: integrate buttons * refact: remove helper * ui: remove icons on non-tertiary buttons * refact: update normalize method for key/value pairs (#15612) * `revert`: `onCancel` for `JobDefinition` The `onCancel` method isn't included in the component API for `JobEditor` and the primary cancel behavior exists outside of the component. With the exception of the `JobDefinition` page where we include this button in the top right of the component instead of next to the `Plan` button. * style: increase button size * style: keep lime green * ui: select template (#15613) * ui: deprecate unused component * ui: deprecate tests * ui: jobs.run.templates.index * ui: update logic to handle templates * refact: revert key/value changes * style: padding for cards + buttons * temp: fixtures for mirage testing * Revert "refact: revert key/value changes" This reverts commit 124e95d12140be38fc921f7e15243034092c4063. * ui: guard template for unsaved job * ui: handle reading template variable * Revert "refact: update normalize method for key/value pairs (#15612)" This reverts commit 6f5ffc9b610702aee7c47fbff742cc81f819ab74. * revert: remove test fixtures * revert: prettier problems * refact: test doesnt need filter expression * styling: button sizes and responsive cards * refact: remove route guarding * ui: update variable adapter * refact: remove model editing behavior * refact: model should query variables to populate editor * ui: clear qp on exit * refact: cleanup deprecated API * refact: query all namespaces * refact: deprecate action * ui: rely on collection * refact: patch deprecate transition API * refact: patch test to expect namespace qp * styling: padding, conditionals * ui: flashMessage on 404 * test: update for o(n+1) query * ui: create new job template (#15744) * refact: remove unused code * refact: add type safety * test: select template flow * test: add data-test attrs * chore: remove dead code * test: create new job flow * ui: add create button * ui: create job template * refact: no need for wildcard * refact: record instead of delete * styling: spacing * ui: add error handling and form validation to job create template (#15767) * ui: handle server side errors * ui: show error to prevent duplicate * refact: conditional namespace * ui: save as template flow (#15787) * bug: patches failing tests associated with `pretender` (#15812) * refact: update assertion * refact: test set-up * ui: job templates manager view (#15815) * ui: manager list view * test: edit flow * refact: deprecate column-helper * ui: template edit and delete flow (#15823) * ui: manager list view * refact: update title * refact: update permissions * ui: template edit page * bug: typo * refact: update toast messages * bug: clear selections on exit (#15827) * bug: clear controllers on exit * test: mirage config changes (#15828) * refact: deprecate column-helper * style: update z-index for HDS * Revert "style: update z-index for HDS" This reverts commit d3d87ceab6d083f7164941587448607838944fc1. * refact: update delete button * refact: edit redirect * refact: patch reactivity issues * styling: fixed width * refact: override defaults * styling: edit text causing overflow * styling: add inline text Co-authored-by: Phil Renaud <phil.renaud@hashicorp.com> * bug: edit `text` to `template` Co-authored-by: Phil Renaud <phil.renaud@hashicorp.com> Co-authored-by: Phil Renaud <phil.renaud@hashicorp.com> * test: delete flow job templates (#15896) * refact: edit names * bug: set correct ref to store * chore: trim whitespace: * test: delete flow * bug: reactively update view (#15904) * Initialized default jobs (#15856) * Initialized default jobs * More jobs scaffolded * Better commenting on a couple example job specs * Adapter doing the work * fall back to epic config * Label format helper and custom serialization logic * Test updates to account for a never-empty state * Test suite uses settled and maintain RecordArray in adapter return * Updates to hello-world and variables example jobspecs * Parameterized job gets optional payload output * Formatting changes for param and service discovery job templates * Multi-group service discovery job * Basic test for default templates (#15965) * Basic test for default templates * Percy snapshot for manage page * Some late-breaking design changes * Some copy edits to the header paragraphs for job templates (#15967) * Added some init options for job templates (#15994) * Async method for populating default job templates from the variable adapter --------- Co-authored-by: Jai <41024828+ChaiWithJai@users.noreply.github.com>
423 lines
11 KiB
JavaScript
423 lines
11 KiB
JavaScript
// @ts-check
|
|
|
|
import Component from '@glimmer/component';
|
|
import { action, computed } 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';
|
|
import notifyConflict from 'nomad-ui/utils/notify-conflict';
|
|
import isEqual from 'lodash.isequal';
|
|
|
|
const EMPTY_KV = {
|
|
key: '',
|
|
value: '',
|
|
warnings: EmberObject.create(),
|
|
};
|
|
|
|
export default class VariableFormComponent extends Component {
|
|
@service flashMessages;
|
|
@service router;
|
|
@service store;
|
|
|
|
@tracked variableNamespace = null;
|
|
@tracked namespaceOptions = null;
|
|
@tracked hasConflict = false;
|
|
|
|
/**
|
|
* @typedef {Object} conflictingVariable
|
|
* @property {string} ModifyTime
|
|
* @property {Object} Items
|
|
*/
|
|
|
|
/**
|
|
* @type {conflictingVariable}
|
|
*/
|
|
@tracked conflictingVariable = null;
|
|
|
|
@tracked path = '';
|
|
constructor() {
|
|
super(...arguments);
|
|
set(this, 'path', this.args.model.path);
|
|
this.addExitHandler();
|
|
}
|
|
|
|
@action
|
|
setNamespace(namespace) {
|
|
this.variableNamespace = namespace;
|
|
}
|
|
|
|
@action
|
|
setNamespaceOptions(options) {
|
|
this.namespaceOptions = options;
|
|
|
|
// Set first namespace option
|
|
if (options.length) {
|
|
this.variableNamespace = this.args.model.namespace;
|
|
}
|
|
}
|
|
|
|
get shouldDisableSave() {
|
|
const disallowedPath =
|
|
this.path?.startsWith('nomad/') &&
|
|
!(
|
|
this.path?.startsWith('nomad/jobs') ||
|
|
(this.path?.startsWith('nomad/job-templates') &&
|
|
trimPath([this.path]) !== 'nomad/job-templates')
|
|
);
|
|
return !!this.JSONError || !this.path || disallowedPath;
|
|
}
|
|
|
|
get isJobTemplateVariable() {
|
|
return this.path?.startsWith('nomad/job-templates/');
|
|
}
|
|
|
|
get jobTemplateName() {
|
|
return this.path.split('nomad/job-templates/').slice(-1);
|
|
}
|
|
|
|
/**
|
|
* @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));
|
|
}
|
|
set(this, 'keyValues', keyValues);
|
|
|
|
this.JSONItems = stringifyObject([
|
|
this.keyValues.reduce((acc, { key, value }) => {
|
|
acc[key] = value;
|
|
return acc;
|
|
}, {}),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @typedef {Object} DuplicatePathWarning
|
|
* @property {string} path
|
|
*/
|
|
|
|
/**
|
|
* @type {DuplicatePathWarning}
|
|
*/
|
|
get duplicatePathWarning() {
|
|
const existingVariables = this.args.existingVariables || [];
|
|
const pathValue = trimPath([this.path]);
|
|
let existingVariable = existingVariables
|
|
.without(this.args.model)
|
|
.find(
|
|
(v) => v.path === pathValue && v.namespace === this.variableNamespace
|
|
);
|
|
if (existingVariable) {
|
|
return {
|
|
path: existingVariable.path,
|
|
};
|
|
} else {
|
|
return 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 refresh() {
|
|
window.location.reload();
|
|
}
|
|
|
|
@action saveWithOverwrite(e) {
|
|
set(this, 'conflictingVariable', null);
|
|
this.save(e, true);
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {KeyboardEvent} e
|
|
*/
|
|
@action setModelPath(e) {
|
|
set(this.args.model, 'path', e.target.value);
|
|
}
|
|
|
|
@action updateKeyValue(key, value) {
|
|
if (this.keyValues.find((kv) => kv.key === key)) {
|
|
this.keyValues.find((kv) => kv.key === key).value = value;
|
|
} else {
|
|
this.keyValues.pushObject({ key, value, warnings: EmberObject.create() });
|
|
}
|
|
}
|
|
|
|
@action
|
|
async save(e, overwrite = false) {
|
|
if (e.type === 'submit') {
|
|
e.preventDefault();
|
|
}
|
|
|
|
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 {
|
|
set(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.set('path', this.path);
|
|
this.args.model.setAndTrimPath();
|
|
await this.args.model.save({ adapterOptions: { overwrite } });
|
|
|
|
this.flashMessages.add({
|
|
title: 'Variable saved',
|
|
message: `${this.path} successfully saved`,
|
|
type: 'success',
|
|
destroyOnClick: false,
|
|
timeout: 5000,
|
|
});
|
|
this.removeExitHandler();
|
|
this.router.transitionTo('variables.variable', this.args.model.id);
|
|
} catch (error) {
|
|
notifyConflict(this)(error);
|
|
if (!this.hasConflict) {
|
|
this.flashMessages.add({
|
|
title: `Error saving ${this.path}`,
|
|
message: error,
|
|
type: 'error',
|
|
destroyOnClick: false,
|
|
sticky: true,
|
|
});
|
|
} else {
|
|
if (error.errors[0]?.detail) {
|
|
set(this, 'conflictingVariable', error.errors[0].detail);
|
|
}
|
|
window.scrollTo(0, 0); // because the k/v list may be long, ensure the user is snapped to top to read error
|
|
}
|
|
}
|
|
}
|
|
|
|
//#region JSON Editing
|
|
|
|
view = this.args.view;
|
|
|
|
get isJSONView() {
|
|
return this.args.view === 'json';
|
|
}
|
|
|
|
// 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 Variable.
|
|
// Ditto for an array of objects. We expect a single object to be a Variable.
|
|
const hasFormatErrors =
|
|
JSON.parse(value) instanceof Array ||
|
|
typeof JSON.parse(value) !== 'object';
|
|
if (hasFormatErrors) {
|
|
throw new Error('A 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
|
|
);
|
|
}
|
|
|
|
//#region Unsaved Changes Confirmation
|
|
|
|
hasRemovedExitHandler = false;
|
|
|
|
@computed(
|
|
'args.model.{keyValues,path}',
|
|
'keyValues.@each.{key,value}',
|
|
'path'
|
|
)
|
|
get hasUserModifiedAttributes() {
|
|
const compactedBasicKVs = this.keyValues
|
|
.map((kv) => ({ key: kv.key, value: kv.value }))
|
|
.filter((kv) => kv.key || kv.value);
|
|
const compactedPassedKVs = this.args.model.keyValues.filter(
|
|
(kv) => kv.key || kv.value
|
|
);
|
|
const unequal =
|
|
!isEqual(compactedBasicKVs, compactedPassedKVs) ||
|
|
!isEqual(this.path, this.args.model.path);
|
|
return unequal;
|
|
}
|
|
|
|
addExitHandler() {
|
|
this.router.on('routeWillChange', this, this.confirmExit);
|
|
}
|
|
|
|
removeExitHandler() {
|
|
if (!this.hasRemovedExitHandler) {
|
|
this.router.off('routeWillChange', this, this.confirmExit);
|
|
this.hasRemovedExitHandler = true;
|
|
}
|
|
}
|
|
|
|
confirmExit(transition) {
|
|
if (transition.isAborted || transition.queryParamsOnly) return;
|
|
|
|
if (this.hasUserModifiedAttributes) {
|
|
if (
|
|
!confirm(
|
|
'Your variable has unsaved changes. Are you sure you want to leave?'
|
|
)
|
|
) {
|
|
transition.abort();
|
|
} else {
|
|
this.removeExitHandler();
|
|
}
|
|
}
|
|
}
|
|
|
|
willDestroy() {
|
|
super.willDestroy(...arguments);
|
|
this.removeExitHandler();
|
|
}
|
|
|
|
//#endregion Unsaved Changes Confirmation
|
|
}
|