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 }