open-nomad/ui/app/components/variable-form.js
Phil Renaud 3db9f11c37
[feat] Nomad Job Templates (#15746)
* 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>
2023-02-02 10:37:40 -05:00

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
}