From 30c743dce1ffe312e55f26ea8cb97a233cb2c220 Mon Sep 17 00:00:00 2001 From: Phil Renaud Date: Wed, 25 Sep 2024 14:00:47 -0400 Subject: [PATCH] [ui] Version Tags on the job versions page (#24013) * Timeline styles and their buttons modernized, and tags added * styled but not yet functional version blocks * Rough pass at edit/unedit UX * Styles consolidated * better UX around version tag crud, plus adapter and serializers * Mirage and acceptance tests * Modify percy to not show time-based things --- nomad/job_endpoint.go | 6 + ui/app/adapters/version-tag.js | 30 ++++ ui/app/components/job-version.js | 130 ++++++++++++--- ui/app/models/job-version.js | 2 + ui/app/models/version-tag.js | 15 ++ ui/app/serializers/job-version.js | 7 + ui/app/serializers/version-tag.js | 18 ++ ui/app/styles/components/timeline.scss | 75 +++++++++ ui/app/templates/components/job-version.hbs | 155 ++++++++++++------ .../templates/components/two-step-button.hbs | 6 +- ui/mirage/config.js | 25 +++ ui/mirage/factories/job-version.js | 3 + ui/mirage/scenarios/default.js | 36 ++++ ui/tests/acceptance/job-versions-test.js | 155 +++++++++++++++++- 14 files changed, 590 insertions(+), 73 deletions(-) create mode 100644 ui/app/adapters/version-tag.js create mode 100644 ui/app/models/version-tag.js create mode 100644 ui/app/serializers/version-tag.js diff --git a/nomad/job_endpoint.go b/nomad/job_endpoint.go index 2845fefc2f7..31e9abd445d 100644 --- a/nomad/job_endpoint.go +++ b/nomad/job_endpoint.go @@ -2475,5 +2475,11 @@ func (j *Job) TagVersion(args *structs.JobApplyTagRequest, reply *structs.JobTag } reply.Index = index + if args.Tag != nil { + reply.Name = args.Tag.Name + reply.Description = args.Tag.Description + reply.TaggedTime = args.Tag.TaggedTime + } + return nil } diff --git a/ui/app/adapters/version-tag.js b/ui/app/adapters/version-tag.js new file mode 100644 index 00000000000..4ffc55f71cb --- /dev/null +++ b/ui/app/adapters/version-tag.js @@ -0,0 +1,30 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +// @ts-check + +import ApplicationAdapter from './application'; +import classic from 'ember-classic-decorator'; + +@classic +export default class VersionTagAdapter extends ApplicationAdapter { + urlForCreateRecord(_modelName, model) { + const tagName = model.attr('name'); + const jobName = model.attr('jobName'); + return `${this.buildURL()}/job/${jobName}/versions/${tagName}/tag`; + } + + async deleteTag(jobName, tagName) { + let deletion = this.ajax( + this.urlForDeleteRecord(jobName, tagName), + 'DELETE' + ); + return deletion; + } + + urlForDeleteRecord(jobName, tagName) { + return `${this.buildURL()}/job/${jobName}/versions/${tagName}/tag`; + } +} diff --git a/ui/app/components/job-version.js b/ui/app/components/job-version.js index e05c54b8f70..5422d04fbbb 100644 --- a/ui/app/components/job-version.js +++ b/ui/app/components/job-version.js @@ -3,30 +3,50 @@ * SPDX-License-Identifier: BUSL-1.1 */ -import Component from '@ember/component'; +import Component from '@glimmer/component'; import { action, computed } from '@ember/object'; -import { classNames } from '@ember-decorators/component'; +import { alias } from '@ember/object/computed'; import { inject as service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; import { task } from 'ember-concurrency'; import messageForError from 'nomad-ui/utils/message-from-adapter-error'; -import classic from 'ember-classic-decorator'; const changeTypes = ['Added', 'Deleted', 'Edited']; -@classic -@classNames('job-version', 'boxed-section') export default class JobVersion extends Component { - version = null; - isOpen = false; + @service store; + @service notifications; + @service router; + + @alias('args.version') version; + @tracked isOpen = false; + @tracked isEditing = false; + @tracked editableTag; // Passes through to the job-diff component verbose = true; - @service router; + constructor() { + super(...arguments); + this.initializeEditableTag(); + } + + initializeEditableTag() { + if (this.version.taggedVersion) { + this.editableTag = this.store.createRecord('versionTag', { + name: this.version.taggedVersion.name, + description: this.version.taggedVersion.description, + }); + } else { + this.editableTag = this.store.createRecord('versionTag'); + } + this.editableTag.versionNumber = this.version.number; + this.editableTag.jobName = this.version.get('job.plainId'); + } @computed('version.diff') get changeCount() { - const diff = this.get('version.diff'); + const diff = this.version.diff; const taskGroups = diff.TaskGroups || []; if (!diff) { @@ -44,36 +64,34 @@ export default class JobVersion extends Component { @computed('version.{number,job.version}') get isCurrent() { - return this.get('version.number') === this.get('version.job.version'); + return this.version.number === this.version.get('job.version'); } @action toggleDiff() { - this.toggleProperty('isOpen'); + this.isOpen = !this.isOpen; } @task(function* () { try { - const versionBeforeReversion = this.get('version.job.version'); - + const versionBeforeReversion = this.version.get('job.version'); yield this.version.revertTo(); - yield this.version.job.reload(); - - const versionAfterReversion = this.get('version.job.version'); + yield this.version.get('job').reload(); + const versionAfterReversion = this.version.get('job.version'); if (versionBeforeReversion === versionAfterReversion) { - this.handleError({ + this.args.handleError({ level: 'warn', title: 'Reversion Had No Effect', description: 'Reverting to an identical older version doesn’t produce a new version', }); } else { - const job = this.get('version.job'); + const job = this.version.get('job'); this.router.transitionTo('jobs.job.index', job.get('idWithNamespace')); } } catch (e) { - this.handleError({ + this.args.handleError({ level: 'danger', title: 'Could Not Revert', description: messageForError(e, 'revert'), @@ -81,6 +99,80 @@ export default class JobVersion extends Component { } }) revertTo; + + @action + handleKeydown(event) { + if (event.key === 'Escape') { + this.cancelEditTag(); + } + } + + @action + toggleEditTag() { + this.isEditing = !this.isEditing; + } + + @action + async saveTag(event) { + event.preventDefault(); + try { + if (!this.editableTag.name) { + this.notifications.add({ + title: 'Error Tagging Job Version', + message: 'Tag name is required', + color: 'critical', + }); + return; + } + const savedTag = await this.editableTag.save(); + this.version.taggedVersion = savedTag; + this.version.taggedVersion.setProperties({ + ...savedTag.toJSON(), + }); + this.initializeEditableTag(); + this.isEditing = false; + + this.notifications.add({ + title: 'Job Version Tagged', + color: 'success', + }); + } catch (error) { + console.log('error tagging job version', error); + this.notifications.add({ + title: 'Error Tagging Job Version', + message: messageForError(error), + color: 'critical', + }); + } + } + + @action + cancelEditTag() { + this.isEditing = false; + this.initializeEditableTag(); + } + + @action + async deleteTag() { + try { + await this.store + .adapterFor('version-tag') + .deleteTag(this.editableTag.jobName, this.editableTag.name); + this.notifications.add({ + title: 'Job Version Un-Tagged', + color: 'success', + }); + this.version.taggedVersion = null; + this.initializeEditableTag(); + this.isEditing = false; + } catch (error) { + this.notifications.add({ + title: 'Error Un-Tagging Job Version', + message: messageForError(error), + color: 'critical', + }); + } + } } const flatten = (accumulator, array) => accumulator.concat(array); diff --git a/ui/app/models/job-version.js b/ui/app/models/job-version.js index c4a0ef5f729..8fc1b3700b0 100644 --- a/ui/app/models/job-version.js +++ b/ui/app/models/job-version.js @@ -4,6 +4,7 @@ */ import Model from '@ember-data/model'; +import { fragment } from 'ember-data-model-fragments/attributes'; import { attr, belongsTo } from '@ember-data/model'; export default class JobVersion extends Model { @@ -12,6 +13,7 @@ export default class JobVersion extends Model { @attr('date') submitTime; @attr('number') number; @attr() diff; + @fragment('version-tag') taggedVersion; revertTo() { return this.store.adapterFor('job-version').revertTo(this); diff --git a/ui/app/models/version-tag.js b/ui/app/models/version-tag.js new file mode 100644 index 00000000000..6aec72a602c --- /dev/null +++ b/ui/app/models/version-tag.js @@ -0,0 +1,15 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Fragment from 'ember-data-model-fragments/fragment'; +import { attr } from '@ember-data/model'; + +export default class VersionTagModel extends Fragment { + @attr() name; + @attr() description; + @attr() taggedTime; + @attr('number') versionNumber; + @attr('string') jobName; +} diff --git a/ui/app/serializers/job-version.js b/ui/app/serializers/job-version.js index 4982501588e..814b3b4e655 100644 --- a/ui/app/serializers/job-version.js +++ b/ui/app/serializers/job-version.js @@ -13,6 +13,13 @@ export default class JobVersionSerializer extends ApplicationSerializer { number: 'Version', }; + normalize(typeHash, hash) { + if (hash.TaggedVersion) { + hash.TaggedVersion.VersionNumber = hash.Version; + } + return super.normalize(typeHash, hash); + } + normalizeFindHasManyResponse(store, modelClass, hash, id, requestType) { const zippedVersions = hash.Versions.map((version, index) => assign({}, version, { diff --git a/ui/app/serializers/version-tag.js b/ui/app/serializers/version-tag.js new file mode 100644 index 00000000000..d6b4ab3e9c6 --- /dev/null +++ b/ui/app/serializers/version-tag.js @@ -0,0 +1,18 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +// @ts-check +import ApplicationSerializer from './application'; +import { inject as service } from '@ember/service'; + +export default class VersionTagSerializer extends ApplicationSerializer { + @service store; + + serialize(snapshot, options) { + const hash = super.serialize(snapshot, options); + hash.Version = hash.VersionNumber; + return hash; + } +} diff --git a/ui/app/styles/components/timeline.scss b/ui/app/styles/components/timeline.scss index bb60059d306..89908d66693 100644 --- a/ui/app/styles/components/timeline.scss +++ b/ui/app/styles/components/timeline.scss @@ -61,5 +61,80 @@ > .boxed-section { margin-bottom: 0; } + + .job-version { + margin-bottom: 0; + & > .boxed-section { + box-shadow: var(--token-surface-high-box-shadow); + border-radius: 0.25rem; + header, + footer { + border: none; + padding: 0.75rem; + } + + footer { + background-color: var(--token-color-surface-faint); + border-top: 1px solid var(--token-color-border-faint); + display: grid; + grid-template-columns: auto auto; + align-items: center; + gap: 0.5rem; + & > .tag-options { + justify-self: start; + display: grid; + grid-template-areas: 'name description save cancel delete'; + grid-template-columns: auto 1fr auto auto auto; + gap: 0.5rem; + align-items: center; + + // Match the height of HDS:Button's @size="small" + input { + padding: 0.375rem 0.6875rem; + line-height: 100%; + } + + .tag-button-primary { + grid-area: name; + background-color: var(--token-color-surface-highlight); + color: var(--token-color-foreground-highlight-on-surface); + border-color: var(--token-color-foreground-highlight); + &:focus:before { + border-color: var(--token-color-foreground-highlight); + } + &:hover { + background-color: var(--token-color-border-highlight); + } + } + .tag-button-secondary { + grid-area: name; + } + .tag-description { + grid-area: description; + font-style: italic; + font-size: 0.875rem; + white-space: no-wrap; + overflow: hidden; + text-overflow: ellipsis; + text-wrap: nowrap; + max-width: 100%; + } + & > .tag-name { + grid-area: name; + } + } + & > .version-options { + justify-self: end; + } + + &.editing { + grid-template-columns: 1fr; + & > .tag-options { + width: 100%; + } + } + } + } + } } } diff --git a/ui/app/templates/components/job-version.hbs b/ui/app/templates/components/job-version.hbs index 19cceffe0ca..b901fdef8db 100644 --- a/ui/app/templates/components/job-version.hbs +++ b/ui/app/templates/components/job-version.hbs @@ -3,55 +3,112 @@ SPDX-License-Identifier: BUSL-1.1 ~}} -
- Version #{{this.version.number}} - - Stable - {{this.version.stable}} - - - Submitted - {{format-ts this.version.submitTime}} - -
- {{#unless this.isCurrent}} - {{#if (can "run job" namespace=this.version.job.namespace)}} - +
+
+
+ Version #{{this.version.number}} + + Stable + {{this.version.stable}} + + + Submitted + {{format-ts this.version.submitTime}} + +
+ {{#if this.version.diff}} + + {{else}} +
No Changes
+ {{/if}} +
+
+ {{#if this.isOpen}} +
+ +
+ {{/if}} +
+ {{#if this.isEditing}} +
+ {{! template-lint-disable no-down-event-binding }} + + + {{! template-lint-enable no-down-event-binding }} + + + {{#if this.version.taggedVersion}} + + {{/if}} + + {{else}} - +
+ {{#if this.version.taggedVersion}} + + {{else}} + + {{/if}} + + {{this.version.taggedVersion.description}} + +
+
+ {{#unless this.isCurrent}} + {{#if (can "run job" namespace=this.version.job.namespace)}} + + {{else}} + + {{/if}} + {{/unless}} +
{{/if}} - {{/unless}} - - {{#if this.version.diff}} - - {{else}} -
No Changes
- {{/if}} -
-
-{{#if this.isOpen}} -
- +
-{{/if}} + diff --git a/ui/app/templates/components/two-step-button.hbs b/ui/app/templates/components/two-step-button.hbs index 4931e3983b7..a25a2935376 100644 --- a/ui/app/templates/components/two-step-button.hbs +++ b/ui/app/templates/components/two-step-button.hbs @@ -6,7 +6,7 @@ {{#if this.isIdle}}