[ui] Text wrap long lines of code and logs (#17754)

* Text and code wrapping as a localStorage var

* task-log uses wrapping and kb shortcut

* Word wrap keyboard labels

* Wrapper as a toggle not a button

* Changelog and fixed an extra space trailing log lines

* Moves toggle to inside

* Acceptance tests for ww and toggle click
This commit is contained in:
Phil Renaud 2023-06-30 17:07:57 -04:00 committed by GitHub
parent e7cc7f2123
commit d559072e48
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 157 additions and 6 deletions

3
.changelog/17754.txt Normal file
View File

@ -0,0 +1,3 @@
```release-note:improvement
ui: adds a toggle and localStorage property to Word Wrap logs and job definitions
```

View File

@ -86,6 +86,7 @@ export default class JobEditor extends Component {
} }
@localStorageProperty('nomadMessageJobPlan', true) shouldShowPlanMessage; @localStorageProperty('nomadMessageJobPlan', true) shouldShowPlanMessage;
@localStorageProperty('nomadShouldWrapCode', false) shouldWrapCode;
@action @action
dismissPlanMessage() { dismissPlanMessage() {
@ -184,6 +185,14 @@ export default class JobEditor extends Component {
} }
} }
/**
* Toggle the wrapping of the job's definition or definition variables.
*/
@action
toggleWrap() {
this.shouldWrapCode = !this.shouldWrapCode;
}
/** /**
* Read the content of an uploaded job specification file and update the job's definition. * Read the content of an uploaded job specification file and update the job's definition.
* *
@ -279,6 +288,7 @@ export default class JobEditor extends Component {
planOutput: this.planOutput, planOutput: this.planOutput,
shouldShowPlanMessage: this.shouldShowPlanMessage, shouldShowPlanMessage: this.shouldShowPlanMessage,
view: this.args.view, view: this.args.view,
shouldWrap: this.shouldWrapCode,
}; };
} }
@ -295,6 +305,7 @@ export default class JobEditor extends Component {
onSelect: this.args.onSelect, onSelect: this.args.onSelect,
onUpdate: this.updateCode, onUpdate: this.updateCode,
onUpload: this.uploadJobSpec, onUpload: this.uploadJobSpec,
onToggleWrap: this.toggleWrap,
}; };
} }
} }

View File

@ -12,6 +12,7 @@ import { logger } from 'nomad-ui/utils/classes/log';
import timeout from 'nomad-ui/utils/timeout'; import timeout from 'nomad-ui/utils/timeout';
import { classNames } from '@ember-decorators/component'; import { classNames } from '@ember-decorators/component';
import classic from 'ember-classic-decorator'; import classic from 'ember-classic-decorator';
import localStorageProperty from 'nomad-ui/utils/properties/local-storage';
class MockAbortController { class MockAbortController {
abort() { abort() {
@ -42,6 +43,8 @@ export default class TaskLog extends Component {
shouldFillHeight = true; shouldFillHeight = true;
@localStorageProperty('nomadShouldWrapCode', false) wrapped;
@alias('userSettings.logMode') mode; @alias('userSettings.logMode') mode;
@computed('allocation.{id,node.httpAddr}', 'useServer') @computed('allocation.{id,node.httpAddr}', 'useServer')
@ -123,4 +126,9 @@ export default class TaskLog extends Component {
failoverToServer() { failoverToServer() {
this.set('useServer', true); this.set('useServer', true);
} }
@action toggleWrap() {
this.toggleProperty('wrapped');
return false;
}
} }

View File

@ -30,6 +30,7 @@ export default class CodeMirrorModifier extends Modifier {
} }
didUpdateArguments() { didUpdateArguments() {
this._editor.setOption('lineWrapping', this.args.named.lineWrapping);
this._editor.setOption('readOnly', this.args.named.readOnly); this._editor.setOption('readOnly', this.args.named.readOnly);
if (!this.args.named.content) { if (!this.args.named.content) {
return; return;
@ -66,6 +67,7 @@ export default class CodeMirrorModifier extends Modifier {
value: this.args.named.content || '', value: this.args.named.content || '',
viewportMargin: this.args.named.viewportMargin || '', viewportMargin: this.args.named.viewportMargin || '',
screenReaderLabel: this.args.named.screenReaderLabel || '', screenReaderLabel: this.args.named.screenReaderLabel || '',
lineWrapping: this.args.named.lineWrapping || false,
}); });
if (this.autofocus) { if (this.autofocus) {

View File

@ -70,9 +70,22 @@
border-top-right-radius: 0; border-top-right-radius: 0;
} }
.button { .button,
.is-inline-block {
display: inline-block; display: inline-block;
} }
.header-toggle {
display: inline-block;
position: relative;
top: 0.2em;
margin-left: $control-padding-horizontal;
margin-right: $control-padding-horizontal;
}
&.task-log-head .header-toggle {
top: 0.5em;
}
} }
.boxed-section-body { .boxed-section-body {

View File

@ -12,6 +12,10 @@
code { code {
height: 100%; height: 100%;
&.wrapped {
white-space: pre-wrap;
}
} }
.is-light { .is-light {

View File

@ -8,6 +8,15 @@
Job Definition Job Definition
{{#if @data.cancelable}} {{#if @data.cancelable}}
<div class="pull-right" style="display: flex"> <div class="pull-right" style="display: flex">
<span class="header-toggle">
<Hds::Form::Toggle::Field
{{keyboard-shortcut label="Toggle word wrap" action=(action @fns.onToggleWrap) pattern=(array "w" "w") menuLevel=true }}
checked={{@data.shouldWrap}}
{{on "change" @fns.onToggleWrap}}
as |F|>
<F.Label>Word Wrap</F.Label>
</Hds::Form::Toggle::Field>
</span>
<Tooltip <Tooltip
@condition={{unless @data.hasSpecification true false}} @condition={{unless @data.hasSpecification true false}}
@isFullText={{true}} @isFullText={{true}}
@ -57,6 +66,7 @@
theme="hashi" theme="hashi"
onUpdate=@fns.onUpdate onUpdate=@fns.onUpdate
mode=(if (eq @data.format "json") "javascript" "ruby") mode=(if (eq @data.format "json") "javascript" "ruby")
lineWrapping=@data.shouldWrap
}} }}
></div> ></div>
</div> </div>
@ -77,6 +87,7 @@
onUpdate=@fns.onUpdate onUpdate=@fns.onUpdate
type="hclVariables" type="hclVariables"
mode="ruby" mode="ruby"
lineWrapping=@data.shouldWrap
}} }}
></div> ></div>
</div> </div>

View File

@ -7,6 +7,16 @@
<div class="boxed-section-head"> <div class="boxed-section-head">
Job Definition Job Definition
<div class="pull-right" style="display: flex"> <div class="pull-right" style="display: flex">
<span class="header-toggle">
<Hds::Form::Toggle::Field
{{keyboard-shortcut label="Toggle word wrap" action=(action @fns.onToggleWrap) pattern=(array "w" "w") menuLevel=true }}
checked={{@data.shouldWrap}}
{{on "change" @fns.onToggleWrap}}
as |F|>
<F.Label>Word Wrap</F.Label>
</Hds::Form::Toggle::Field>
</span>
<Tooltip @condition={{unless @data.hasSpecification true false}} @isFullText={{true}} @text="A jobspec file was not submitted when this job was run. You can still view and edit the expanded JSON format."> <Tooltip @condition={{unless @data.hasSpecification true false}} @isFullText={{true}} @text="A jobspec file was not submitted when this job was run. You can still view and edit the expanded JSON format.">
<div class="job-definition-select {{unless @data.hasSpecification " disabled"}}" data-test-select={{@data.view}}> <div class="job-definition-select {{unless @data.hasSpecification " disabled"}}" data-test-select={{@data.view}}>
<button <button
@ -26,6 +36,7 @@
</button> </button>
</div> </div>
</Tooltip> </Tooltip>
<button <button
class="button is-light is-compact" class="button is-light is-compact"
type="button" type="button"
@ -34,6 +45,7 @@
> >
Edit Edit
</button> </button>
</div> </div>
</div> </div>
<div class="boxed-section-body is-full-bleed"> <div class="boxed-section-body is-full-bleed">
@ -46,6 +58,7 @@
readOnly=true readOnly=true
screenReaderLabel="Job specification" screenReaderLabel="Job specification"
theme="hashi-read-only" theme="hashi-read-only"
lineWrapping=@data.shouldWrap
}} }}
/> />
{{else}} {{else}}
@ -56,6 +69,7 @@
theme="hashi-read-only" theme="hashi-read-only"
readOnly=true readOnly=true
screenReaderLabel="JSON Viewer" screenReaderLabel="JSON Viewer"
lineWrapping=@data.shouldWrap
}} }}
/> />
{{/if}} {{/if}}
@ -75,6 +89,7 @@
mode="ruby" mode="ruby"
theme="hashi-read-only" theme="hashi-read-only"
readOnly=true readOnly=true
lineWrapping=@data.shouldWrap
}} }}
/> />
</div> </div>

View File

@ -3,4 +3,4 @@
SPDX-License-Identifier: MPL-2.0 SPDX-License-Identifier: MPL-2.0
~}} ~}}
<code data-test-output>{{this.logger.output}}</code> <code data-test-output class={{if @wrapped "wrapped"}}>{{this.logger.output}}</code>

View File

@ -16,12 +16,22 @@
</div> </div>
</div> </div>
{{/if}} {{/if}}
<div class="boxed-section-head"> <div class="boxed-section-head task-log-head">
<span> <span>
<button data-test-log-action="stdout" class="button {{if (eq this.mode "stdout") "is-info"}}" {{action "setMode" "stdout"}} type="button">stdout</button> <button data-test-log-action="stdout" class="button {{if (eq this.mode "stdout") "is-info"}}" {{action "setMode" "stdout"}} type="button">stdout</button>
<button data-test-log-action="stderr" class="button {{if (eq this.mode "stderr") "is-danger"}}" {{action "setMode" "stderr"}} type="button">stderr</button> <button data-test-log-action="stderr" class="button {{if (eq this.mode "stderr") "is-danger"}}" {{action "setMode" "stderr"}} type="button">stderr</button>
</span> </span>
<span class="pull-right"> <span class="pull-right">
<span class="header-toggle">
<Hds::Form::Toggle::Field
{{keyboard-shortcut label="Toggle word wrap" action=(action "toggleWrap") pattern=(array "w" "w") menuLevel=true }}
checked={{this.wrapped}}
{{on "change" this.toggleWrap}}
data-test-word-wrap-toggle
as |F|>
<F.Label>Word Wrap</F.Label>
</Hds::Form::Toggle::Field>
</span>
<button data-test-log-action="head" class="button is-white" onclick={{action "gotoHead"}} type="button">Head</button> <button data-test-log-action="head" class="button is-white" onclick={{action "gotoHead"}} type="button">Head</button>
<button data-test-log-action="tail" class="button is-white" onclick={{action "gotoTail"}} type="button">Tail</button> <button data-test-log-action="tail" class="button is-white" onclick={{action "gotoTail"}} type="button">Tail</button>
<button data-test-log-action="toggle-stream" class="button is-white" onclick={{action "toggleStream"}} type="button" title="{{if this.logger.isStreaming "Stop" "Start"}} log streaming"> <button data-test-log-action="toggle-stream" class="button is-white" onclick={{action "toggleStream"}} type="button" title="{{if this.logger.isStreaming "Stop" "Start"}} log streaming">
@ -30,5 +40,5 @@
</span> </span>
</div> </div>
<div data-test-log-box class="boxed-section-body is-dark is-full-bleed"> <div data-test-log-box class="boxed-section-body is-dark is-full-bleed">
<StreamingFile @logger={{this.logger}} @mode={{this.streamMode}} @isStreaming={{this.isStreaming}} @shouldFillHeight={{this.shouldFillHeight}} /> <StreamingFile @logger={{this.logger}} @mode={{this.streamMode}} @isStreaming={{this.isStreaming}} @shouldFillHeight={{this.shouldFillHeight}} @wrapped={{this.wrapped}} />
</div> </div>

View File

@ -4,7 +4,12 @@
*/ */
/* eslint-disable qunit/require-expect */ /* eslint-disable qunit/require-expect */
import { click, currentURL, findAll } from '@ember/test-helpers'; import {
click,
currentURL,
findAll,
triggerKeyEvent,
} from '@ember/test-helpers';
import { run } from '@ember/runloop'; import { run } from '@ember/runloop';
import { module, test } from 'qunit'; import { module, test } from 'qunit';
import { setupApplicationTest } from 'ember-qunit'; import { setupApplicationTest } from 'ember-qunit';
@ -69,6 +74,75 @@ module('Acceptance | task logs', function (hooks) {
); );
}); });
test('logs can be word-wrapped', async function (assert) {
await TaskLogs.visit({ id: allocation.id, name: task.name });
assert.dom('[data-test-word-wrap-toggle]').isNotChecked();
assert.dom('[data-test-output]').doesNotHaveClass('wrapped');
run.later(() => {
run.cancelTimers();
}, 100);
await click('[data-test-word-wrap-toggle]');
assert.dom('[data-test-word-wrap-toggle]').isChecked();
assert.dom('[data-test-output]').hasClass('wrapped');
run.later(() => {
run.cancelTimers();
}, 100);
await click('[data-test-word-wrap-toggle]');
assert.dom('[data-test-word-wrap-toggle]').isNotChecked();
assert.dom('[data-test-output]').doesNotHaveClass('wrapped');
window.localStorage.clear();
});
test('logs in sidebar can be word-wrapped', async function (assert) {
await TaskLogs.visitParentJob({
id: job.id,
allocationId: allocation.id,
name: task.name,
});
run.later(() => {
run.cancelTimers();
}, 500);
const taskRow = [
...findAll('.task-sub-row').filter((row) => {
return row.textContent.includes(task.name);
}),
][0];
await click(taskRow.querySelector('button.logs-sidebar-trigger'));
assert.dom('[data-test-word-wrap-toggle]').isNotChecked();
assert.dom('[data-test-output]').doesNotHaveClass('wrapped');
run.later(() => {
run.cancelTimers();
}, 500);
// type "ww" to trigger word wrap
const W_KEY = 87;
triggerKeyEvent('.sidebar', 'keydown', W_KEY);
await triggerKeyEvent('.sidebar', 'keydown', W_KEY);
assert.dom('[data-test-word-wrap-toggle]').isChecked();
assert.dom('[data-test-output]').hasClass('wrapped');
run.later(() => {
run.cancelTimers();
}, 100);
triggerKeyEvent('.sidebar', 'keydown', W_KEY);
await triggerKeyEvent('.sidebar', 'keydown', W_KEY);
assert.dom('[data-test-word-wrap-toggle]').isNotChecked();
assert.dom('[data-test-output]').doesNotHaveClass('wrapped');
window.localStorage.clear();
});
test('logs are accessible in a sidebar context', async function (assert) { test('logs are accessible in a sidebar context', async function (assert) {
await TaskLogs.visitParentJob({ await TaskLogs.visitParentJob({
id: job.id, id: job.id,