github.com/hernad/nomad@v1.6.112/ui/app/components/variable-form.js (about)

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