ui: dashboard links (#5704)

This PR adds a new {{template-anchor}} component. This component lets you specify a 'href template' in a handlebars like format instead of a normal string href. This template will be interpolated with the contents of a vars="" attribute.

Also contains code to add an extra UI Setting to be able to store a template to be used for this anchor in localStorage
This commit is contained in:
John Cowen 2019-05-01 19:19:43 +01:00 committed by John Cowen
parent 81f209d71e
commit 7021433185
17 changed files with 290 additions and 57 deletions

View File

@ -0,0 +1,73 @@
import Component from '@ember/component';
import { get, set, computed } from '@ember/object';
const createWeak = function(wm = new WeakMap()) {
return {
get: function(ref, prop) {
let map = wm.get(ref);
if (map) {
return map[prop];
}
},
set: function(ref, prop, value) {
let map = wm.get(ref);
if (typeof map === 'undefined') {
map = {};
wm.set(ref, map);
}
map[prop] = value;
return map[prop];
},
};
};
const weak = createWeak();
// Covers alpha-capitalized dot separated API keys such as
// `{{Name}}`, `{{Service.Name}}` etc. but not `{{}}`
const templateRe = /{{([A-Za-z.0-9_-]+)}}/g;
export default Component.extend({
tagName: 'a',
attributeBindings: ['href', 'rel', 'target'],
rel: computed({
get: function(prop) {
return weak.get(this, prop);
},
set: function(prop, value) {
switch (value) {
case 'external':
value = `${value} noopener noreferrer`;
set(this, 'target', '_blank');
break;
}
return weak.set(this, prop, value);
},
}),
vars: computed({
get: function(prop) {
return weak.get(this, prop);
},
set: function(prop, value) {
weak.set(this, prop, value);
set(this, 'href', weak.get(this, 'template'));
},
}),
href: computed({
get: function(prop) {
return weak.get(this, prop);
},
set: function(prop, value) {
weak.set(this, 'template', value);
const vars = weak.get(this, 'vars');
if (typeof vars !== 'undefined' && typeof value !== 'undefined') {
value = value.replace(templateRe, function(match, group) {
try {
return get(vars, group) || '';
} catch (e) {
return '';
}
});
return weak.set(this, prop, value);
}
return '';
},
}),
});

View File

@ -6,6 +6,13 @@ export default Controller.extend({
repo: service('settings'),
dom: service('dom'),
actions: {
key: function(e) {
switch (true) {
case e.keyCode === 13:
// disable ENTER
e.preventDefault();
}
},
change: function(e, value, item) {
const event = get(this, 'dom').normalizeEvent(e, value);
// TODO: Switch to using forms like the rest of the app
@ -23,6 +30,13 @@ export default Controller.extend({
set(this, 'item.client.blocking', !blocking);
this.send('update', get(this, 'item'));
break;
case 'urls[service]':
if (typeof get(this, 'item.urls') === 'undefined') {
set(this, 'item.urls', {});
}
set(this, 'item.urls.service', target.value);
this.send('update', get(this, 'item'));
break;
}
},
},

View File

@ -5,6 +5,7 @@ import { get } from '@ember/object';
export default Route.extend({
repo: service('repository/service'),
settings: service('settings'),
queryParams: {
s: {
as: 'filter',
@ -13,8 +14,10 @@ export default Route.extend({
},
model: function(params) {
const repo = get(this, 'repo');
const settings = get(this, 'settings');
return hash({
item: repo.findBySlug(params.name, this.modelFor('dc').dc.Name),
urls: settings.findBySlug('urls'),
});
},
setupController: function(controller, model) {

View File

@ -2,6 +2,9 @@
%main-content a {
color: $gray-900;
}
a[rel*='external'] {
@extend %with-exit;
}
%main-content label a[rel*='help'] {
color: $gray-400;
}

View File

@ -13,7 +13,7 @@ main {
}
@media #{$--lt-spacious-page-header} {
%app-view header .actions {
margin-top: 5px;
margin-top: 9px;
}
}
// TODO: This should be its own component

View File

@ -9,6 +9,7 @@
float: right;
display: flex;
align-items: flex-start;
margin-top: 9px;
}
%app-view header dl {
float: left;
@ -28,12 +29,7 @@
}
%app-view h2 {
padding-bottom: 0.2em;
margin-bottom: 1.1em;
}
%app-view fieldset h2,
%app-view fieldset p {
padding-bottom: 0;
margin-bottom: 0;
margin-bottom: 0.2em;
}
%app-view header .actions > *:not(:last-child) {
margin-right: 12px;
@ -64,3 +60,8 @@
min-height: 1em;
margin-bottom: 0.4em;
}
// TODO: Think about an %app-form or similar
%app-content fieldset {
padding-bottom: 0.3em;
margin-bottom: 2em;
}

View File

@ -1,4 +1,5 @@
%app-view h2 {
%app-view h2,
%app-view fieldset {
border-bottom: $decor-border-200;
}
%app-view fieldset h2 {
@ -16,7 +17,8 @@
}
%app-view header > div > div:last-child,
%app-view header h1,
%app-view h2 {
%app-view h2,
%app-view fieldset {
border-color: $gray-200;
}
// We know that any sibling navs might have a top border

View File

@ -149,6 +149,15 @@
height: 0.05em;
transform: rotate(45deg);
}
%with-exit::after {
@extend %pseudo-icon-bg-img;
top: 3px;
right: -8px;
background-image: $exit-svg;
background-color: $color-transparent;
width: 16px;
height: 16px;
}
/*TODO: All chevrons need merging */
%with-chevron-down::before {
@extend %pseudo-icon-bg-img;

View File

@ -2,6 +2,7 @@
position: relative;
padding: 1em;
padding-left: 45px;
margin-bottom: 1em;
}
%notice::before {
position: absolute;

View File

@ -1,5 +1,8 @@
main p,
%modal-window p {
%button {
font-family: $typo-family-sans;
}
main p:not(:last-child),
%modal-window p:not(:last-child) {
margin-bottom: 1em;
}
%button,
@ -8,6 +11,7 @@ main p,
%form-element [type='password'] {
line-height: 1.5;
}
h3,
%radio-group label {
line-height: 1.25;
}
@ -32,6 +36,7 @@ h1,
font-weight: $typo-weight-bold;
}
h2,
h3,
fieldset > header,
caption,
%header-nav,
@ -77,18 +82,22 @@ td strong,
%footer > * {
font-size: inherit;
}
h1 {
font-size: $typo-header-100;
}
h2,
h2 {
font-size: $typo-header-200;
}
h3 {
font-size: $typo-header-300;
}
%healthcheck-info dt,
%header-drop-nav .is-active,
%app-view h1 em {
font-size: $typo-size-500;
}
body,
fieldset h2,
fieldset > header,
pre code,
input,
textarea,

View File

@ -10,7 +10,7 @@ td a.is-management::after {
.template-policy.template-list main header .actions,
.template-token.template-list main header .actions {
position: relative;
top: 50px;
top: 45px;
}
}

View File

@ -0,0 +1 @@
{{yield}}

View File

@ -26,23 +26,23 @@
}}
{{/block-slot}}
{{#block-slot 'actions'}}
{{#feedback-dialog type='inline'}}
{{#block-slot 'action' as |success error|}}
{{#copy-button success=(action success) error=(action error) clipboardText=item.Address title='copy IP address to clipboard'}}
{{item.Address}}
{{/copy-button}}
{{/block-slot}}
{{#block-slot 'success' as |transition|}}
<p class={{transition}}>
Copied IP Address!
</p>
{{/block-slot}}
{{#block-slot 'error' as |transition|}}
<p class={{transition}}>
Sorry, something went wrong!
</p>
{{/block-slot}}
{{/feedback-dialog}}
{{#feedback-dialog type='inline'}}
{{#block-slot 'action' as |success error|}}
{{#copy-button success=(action success) error=(action error) clipboardText=item.Address title='copy IP address to clipboard'}}
{{item.Address}}
{{/copy-button}}
{{/block-slot}}
{{#block-slot 'success' as |transition|}}
<p class={{transition}}>
Copied IP Address!
</p>
{{/block-slot}}
{{#block-slot 'error' as |transition|}}
<p class={{transition}}>
Sorry, something went wrong!
</p>
{{/block-slot}}
{{/feedback-dialog}}
{{/block-slot}}
{{#block-slot 'content'}}
{{#each

View File

@ -32,6 +32,11 @@
selected=selectedTab
}}
{{/block-slot}}
{{#block-slot 'actions'}}
{{#if urls.service}}
{{#templated-anchor href=urls.service vars=(hash Service=(hash Name=item.Service.Service)) rel="external"}}Open Dashboard{{/templated-anchor}}
{{/if}}
{{/block-slot}}
{{#block-slot 'content'}}
{{#each
(compact

View File

@ -1,26 +1,40 @@
{{#hashicorp-consul id="wrapper" dcs=dcs dc=dc}}
{{#app-view class="settings show"}}
{{#block-slot 'header'}}
<h1>
Settings
</h1>
{{/block-slot}}
{{#block-slot 'content'}}
<p>
These settings are specific to the Consul web UI. They are saved to local storage and persist through browser usage and visits.
</p>
<form>
<fieldset>
<h2>Blocking Queries</h2>
<p>Automatically get updated catalog information without refreshing the page. Any changes made to services and nodes would be reflected in real time.</p>
<div class="type-toggle">
<label>
<input type="checkbox" name="client[blocking]" checked={{if item.client.blocking 'checked' }} onchange={{action 'change'}} />
<span>{{if item.client.blocking 'On' 'Off' }}</span>
</label>
</div>
</fieldset>
</form>
{{/block-slot}}
{{/app-view}}
{{#app-view class="settings show"}}
{{#block-slot 'header'}}
<h1>
Settings
</h1>
{{/block-slot}}
{{#block-slot 'content'}}
<div class="notice info">
<h3>Local Storage</h3>
<p>
These settings are immediately saved to local storage and persisted through browser usage.
</p>
</div>
<form>
<fieldset>
<h2>Dashboard Links</h2>
<p>
Add a link to the service detail page in the UI to get quick access to a service-wide metrics dashboard. Enter the dashboard URL into the field below. You can use the placeholder {{Service.Name}} which will be replaced with the name of the service currently being viewed.
</p>
<label class="type-text">
<span>Link template for services</span>
<input type="text" name="urls[service]" value={{item.urls.service}} onchange={{action 'change'}} onkeypress={{action 'key'}} onkeydown={{action 'key'}} />
<em>e.g. https://grafana.example.com/d/1/consul-service-mesh&amp;orgid=1&amp;service-name={{ '{{Service.Name}}' }}</em>
</label>
</fieldset>
<fieldset>
<h2>Blocking Queries</h2>
<p>Keep catalog info up-to-date without refreshing the page. Any changes made to services and nodes would be reflected in real time.</p>
<div class="type-toggle">
<label>
<input type="checkbox" name="client[blocking]" checked={{if item.client.blocking 'checked' }} onchange={{action 'change'}} />
<span>{{if item.client.blocking 'On' 'Off' }}</span>
</label>
</div>
</fieldset>
</form>
{{/block-slot}}
{{/app-view}}
{{/hashicorp-consul}}

View File

@ -0,0 +1,98 @@
import { moduleForComponent, test } from 'ember-qunit';
import hbs from 'htmlbars-inline-precompile';
moduleForComponent('templated-anchor', 'Integration | Component | templated anchor', {
integration: true,
});
test('it renders', function(assert) {
[
{
href: 'http://localhost/?={{Name}}/{{ID}}',
vars: {
Name: 'name',
ID: 'id',
},
result: 'http://localhost/?=name/id',
},
{
href: 'http://localhost/?={{Name}}/{{ID}}',
vars: {
Name: '{{Name}}',
ID: '{{ID}}',
},
result: 'http://localhost/?={{Name}}/{{ID}}',
},
{
href: 'http://localhost/?={{deep.Name}}/{{deep.ID}}',
vars: {
deep: {
Name: '{{Name}}',
ID: '{{ID}}',
},
},
result: 'http://localhost/?={{Name}}/{{ID}}',
},
{
href: 'http://localhost/?={{}}/{{}}',
vars: {
Name: 'name',
ID: 'id',
},
result: 'http://localhost/?={{}}/{{}}',
},
{
href: 'http://localhost/?={{Service_Name}}/{{Meta-Key}}',
vars: {
Service_Name: 'name',
['Meta-Key']: 'id',
},
result: 'http://localhost/?=name/id',
},
{
href: 'http://localhost/?={{Service_Name}}/{{Meta-Key}}',
vars: {
WrongPropertyName: 'name',
['Meta-Key']: 'id',
},
result: 'http://localhost/?=/id',
},
{
href: 'http://localhost/?={{.Name}}',
vars: {
['.Name']: 'name',
},
result: 'http://localhost/?=',
},
{
href: 'http://localhost/?={{.}}',
vars: {
['.']: 'name',
},
result: 'http://localhost/?=',
},
{
href: 'http://localhost/?={{deep..Name}}',
vars: {
deep: {
Name: 'Name',
ID: 'ID',
},
},
result: 'http://localhost/?=',
},
].forEach(item => {
this.set('item', item);
this.render(hbs`
{{#templated-anchor href=item.href vars=item.vars}}
Dashboard link
{{/templated-anchor}}
`);
assert.equal(
this.$()
.find('a')
.attr('href'),
item.result
);
});
});

View File

@ -2,7 +2,7 @@ import { moduleFor, test } from 'ember-qunit';
moduleFor('route:dc/services/show', 'Unit | Route | dc/services/show', {
// Specify the other units that are required for this test.
needs: ['service:repository/service'],
needs: ['service:repository/service', 'service:settings'],
});
test('it exists', function(assert) {