github.com/anth0d/nomad@v0.0.0-20221214183521-ae3a0a2cad06/ui/app/components/variable-form.js (about)

     1  // @ts-check
     2  
     3  import Component from '@glimmer/component';
     4  import { action, computed } from '@ember/object';
     5  import { tracked } from '@glimmer/tracking';
     6  import { inject as service } from '@ember/service';
     7  import { trimPath } from '../helpers/trim-path';
     8  import { copy } from 'ember-copy';
     9  import EmberObject, { set } from '@ember/object';
    10  // eslint-disable-next-line no-unused-vars
    11  import MutableArray from '@ember/array/mutable';
    12  import { A } from '@ember/array';
    13  import { stringifyObject } from 'nomad-ui/helpers/stringify-object';
    14  import notifyConflict from 'nomad-ui/utils/notify-conflict';
    15  import isEqual from 'lodash.isequal';
    16  
    17  const EMPTY_KV = {
    18    key: '',
    19    value: '',
    20    warnings: EmberObject.create(),
    21  };
    22  
    23  export default class VariableFormComponent extends Component {
    24    @service flashMessages;
    25    @service router;
    26    @service store;
    27  
    28    @tracked variableNamespace = null;
    29    @tracked namespaceOptions = null;
    30    @tracked hasConflict = false;
    31  
    32    /**
    33     * @typedef {Object} conflictingVariable
    34     * @property {string} ModifyTime
    35     * @property {Object} Items
    36     */
    37  
    38    /**
    39     * @type {conflictingVariable}
    40     */
    41    @tracked conflictingVariable = null;
    42  
    43    @tracked path = '';
    44    constructor() {
    45      super(...arguments);
    46      set(this, 'path', this.args.model.path);
    47      this.addExitHandler();
    48    }
    49  
    50    @action
    51    setNamespace(namespace) {
    52      this.variableNamespace = namespace;
    53    }
    54  
    55    @action
    56    setNamespaceOptions(options) {
    57      this.namespaceOptions = options;
    58  
    59      // Set first namespace option
    60      if (options.length) {
    61        this.variableNamespace = this.args.model.namespace;
    62      }
    63    }
    64  
    65    get shouldDisableSave() {
    66      const disallowedPath =
    67        this.path?.startsWith('nomad/') && !this.path?.startsWith('nomad/jobs');
    68      return !!this.JSONError || !this.path || disallowedPath;
    69    }
    70  
    71    /**
    72     * @type {MutableArray<{key: string, value: string, warnings: EmberObject}>}
    73     */
    74    keyValues = A([]);
    75  
    76    /**
    77     * @type {string}
    78     */
    79    JSONItems = '{}';
    80  
    81    @action
    82    establishKeyValues() {
    83      const keyValues = copy(this.args.model?.keyValues || [])?.map((kv) => {
    84        return {
    85          key: kv.key,
    86          value: kv.value,
    87          warnings: EmberObject.create(),
    88        };
    89      });
    90  
    91      /**
    92       * Appends a row to the end of the Items list if you're editing an existing variable.
    93       * This will allow it to auto-focus and make all other rows deletable
    94       */
    95      if (!this.args.model?.isNew) {
    96        keyValues.pushObject(copy(EMPTY_KV));
    97      }
    98      set(this, 'keyValues', keyValues);
    99  
   100      this.JSONItems = stringifyObject([
   101        this.keyValues.reduce((acc, { key, value }) => {
   102          acc[key] = value;
   103          return acc;
   104        }, {}),
   105      ]);
   106    }
   107  
   108    /**
   109     * @typedef {Object} DuplicatePathWarning
   110     * @property {string} path
   111     */
   112  
   113    /**
   114     * @type {DuplicatePathWarning}
   115     */
   116    get duplicatePathWarning() {
   117      const existingVariables = this.args.existingVariables || [];
   118      const pathValue = trimPath([this.path]);
   119      let existingVariable = existingVariables
   120        .without(this.args.model)
   121        .find(
   122          (v) => v.path === pathValue && v.namespace === this.variableNamespace
   123        );
   124      if (existingVariable) {
   125        return {
   126          path: existingVariable.path,
   127        };
   128      } else {
   129        return null;
   130      }
   131    }
   132  
   133    @action
   134    validateKey(entry, e) {
   135      const value = e.target.value;
   136      // No dots in key names
   137      if (value.includes('.')) {
   138        entry.warnings.set('dottedKeyError', 'Key should not contain a period.');
   139      } else {
   140        delete entry.warnings.dottedKeyError;
   141        entry.warnings.notifyPropertyChange('dottedKeyError');
   142      }
   143  
   144      // no duplicate keys
   145      const existingKeys = this.keyValues.map((kv) => kv.key);
   146      if (existingKeys.includes(value)) {
   147        entry.warnings.set('duplicateKeyError', 'Key already exists.');
   148      } else {
   149        delete entry.warnings.duplicateKeyError;
   150        entry.warnings.notifyPropertyChange('duplicateKeyError');
   151      }
   152    }
   153  
   154    @action appendRow() {
   155      this.keyValues.pushObject(copy(EMPTY_KV));
   156    }
   157  
   158    @action deleteRow(row) {
   159      this.keyValues.removeObject(row);
   160    }
   161  
   162    @action refresh() {
   163      window.location.reload();
   164    }
   165  
   166    @action saveWithOverwrite(e) {
   167      set(this, 'conflictingVariable', null);
   168      this.save(e, true);
   169    }
   170  
   171    /**
   172     *
   173     * @param {KeyboardEvent} e
   174     */
   175    @action setModelPath(e) {
   176      set(this.args.model, 'path', e.target.value);
   177    }
   178  
   179    @action
   180    async save(e, overwrite = false) {
   181      if (e.type === 'submit') {
   182        e.preventDefault();
   183      }
   184  
   185      if (this.view === 'json') {
   186        this.translateAndValidateItems('table');
   187      }
   188      try {
   189        const nonEmptyItems = A(
   190          this.keyValues.filter((item) => item.key.trim() && item.value)
   191        );
   192        if (!nonEmptyItems.length) {
   193          throw new Error('Please provide at least one key/value pair.');
   194        } else {
   195          set(this, 'keyValues', nonEmptyItems);
   196        }
   197  
   198        if (this.args.model?.isNew) {
   199          if (this.namespaceOptions) {
   200            this.args.model.set('namespace', this.variableNamespace);
   201          } else {
   202            const [namespace] = this.store.peekAll('namespace').toArray();
   203            this.args.model.set('namespace', namespace.id);
   204          }
   205        }
   206  
   207        this.args.model.set('keyValues', this.keyValues);
   208        this.args.model.set('path', this.path);
   209        this.args.model.setAndTrimPath();
   210        await this.args.model.save({ adapterOptions: { overwrite } });
   211  
   212        this.flashMessages.add({
   213          title: 'Variable saved',
   214          message: `${this.path} successfully saved`,
   215          type: 'success',
   216          destroyOnClick: false,
   217          timeout: 5000,
   218        });
   219        this.removeExitHandler();
   220        this.router.transitionTo('variables.variable', this.args.model.id);
   221      } catch (error) {
   222        notifyConflict(this)(error);
   223        if (!this.hasConflict) {
   224          this.flashMessages.add({
   225            title: `Error saving ${this.path}`,
   226            message: error,
   227            type: 'error',
   228            destroyOnClick: false,
   229            sticky: true,
   230          });
   231        } else {
   232          if (error.errors[0]?.detail) {
   233            set(this, 'conflictingVariable', error.errors[0].detail);
   234          }
   235          window.scrollTo(0, 0); // because the k/v list may be long, ensure the user is snapped to top to read error
   236        }
   237      }
   238    }
   239  
   240    //#region JSON Editing
   241  
   242    view = this.args.view;
   243  
   244    get isJSONView() {
   245      return this.args.view === 'json';
   246    }
   247  
   248    // Prevent duplicate onUpdate events when @view is set to its already-existing value,
   249    // which happens because parent's queryParams and toggle button both resolve independently.
   250    @action onViewChange([view]) {
   251      if (view !== this.view) {
   252        set(this, 'view', view);
   253        this.translateAndValidateItems(view);
   254      }
   255    }
   256  
   257    @action
   258    translateAndValidateItems(view) {
   259      // TODO: move the translation functions in serializers/variable.js to generic importable functions.
   260      if (view === 'json') {
   261        // Translate table to JSON
   262        set(
   263          this,
   264          'JSONItems',
   265          stringifyObject([
   266            this.keyValues
   267              .filter((item) => item.key.trim() && item.value) // remove empty items when translating to JSON
   268              .reduce((acc, { key, value }) => {
   269                acc[key] = value;
   270                return acc;
   271              }, {}),
   272          ])
   273        );
   274  
   275        // Give the user a foothold if they're transitioning an empty K/V form into JSON
   276        if (!Object.keys(JSON.parse(this.JSONItems)).length) {
   277          set(this, 'JSONItems', stringifyObject([{ '': '' }]));
   278        }
   279      } else if (view === 'table') {
   280        // Translate JSON to table
   281        set(
   282          this,
   283          'keyValues',
   284          A(
   285            Object.entries(JSON.parse(this.JSONItems)).map(([key, value]) => {
   286              return {
   287                key,
   288                value: typeof value === 'string' ? value : JSON.stringify(value),
   289                warnings: EmberObject.create(),
   290              };
   291            })
   292          )
   293        );
   294  
   295        // If the JSON object is empty at switch time, add an empty KV in to give the user a foothold
   296        if (!Object.keys(JSON.parse(this.JSONItems)).length) {
   297          this.appendRow();
   298        }
   299      }
   300  
   301      // Reset any error state, since the errorring json will not persist
   302      set(this, 'JSONError', null);
   303    }
   304  
   305    /**
   306     * @type {string}
   307     */
   308    @tracked JSONError = null;
   309    /**
   310     *
   311     * @param {string} value
   312     */
   313    @action updateCode(value, codemirror) {
   314      codemirror.performLint();
   315      try {
   316        const hasLintErrors = codemirror?.state.lint.marked?.length > 0;
   317        if (hasLintErrors || !JSON.parse(value)) {
   318          throw new Error('Invalid JSON');
   319        }
   320  
   321        // "myString" is valid JSON, but it's not a valid Variable.
   322        // Ditto for an array of objects. We expect a single object to be a Variable.
   323        const hasFormatErrors =
   324          JSON.parse(value) instanceof Array ||
   325          typeof JSON.parse(value) !== 'object';
   326        if (hasFormatErrors) {
   327          throw new Error('A Variable must be formatted as a single JSON object');
   328        }
   329  
   330        set(this, 'JSONError', null);
   331        set(this, 'JSONItems', value);
   332      } catch (error) {
   333        set(this, 'JSONError', error);
   334      }
   335    }
   336    //#endregion JSON Editing
   337  
   338    get shouldShowLinkedEntities() {
   339      return (
   340        this.args.model.pathLinkedEntities?.job ||
   341        this.args.model.pathLinkedEntities?.group ||
   342        this.args.model.pathLinkedEntities?.task
   343      );
   344    }
   345  
   346    //#region Unsaved Changes Confirmation
   347  
   348    hasRemovedExitHandler = false;
   349  
   350    @computed(
   351      'args.model.{keyValues,path}',
   352      'keyValues.@each.{key,value}',
   353      'path'
   354    )
   355    get hasUserModifiedAttributes() {
   356      const compactedBasicKVs = this.keyValues
   357        .map((kv) => ({ key: kv.key, value: kv.value }))
   358        .filter((kv) => kv.key || kv.value);
   359      const compactedPassedKVs = this.args.model.keyValues.filter(
   360        (kv) => kv.key || kv.value
   361      );
   362      const unequal =
   363        !isEqual(compactedBasicKVs, compactedPassedKVs) ||
   364        !isEqual(this.path, this.args.model.path);
   365      return unequal;
   366    }
   367  
   368    addExitHandler() {
   369      this.router.on('routeWillChange', this, this.confirmExit);
   370    }
   371  
   372    removeExitHandler() {
   373      if (!this.hasRemovedExitHandler) {
   374        this.router.off('routeWillChange', this, this.confirmExit);
   375        this.hasRemovedExitHandler = true;
   376      }
   377    }
   378  
   379    confirmExit(transition) {
   380      if (transition.isAborted || transition.queryParamsOnly) return;
   381  
   382      if (this.hasUserModifiedAttributes) {
   383        if (
   384          !confirm(
   385            'Your variable has unsaved changes. Are you sure you want to leave?'
   386          )
   387        ) {
   388          transition.abort();
   389        } else {
   390          this.removeExitHandler();
   391        }
   392      }
   393    }
   394  
   395    willDestroy() {
   396      super.willDestroy(...arguments);
   397      this.removeExitHandler();
   398    }
   399  
   400    //#endregion Unsaved Changes Confirmation
   401  }