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 }