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

@ -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

@ -6,13 +6,27 @@
</h1>
{{/block-slot}}
{{#block-slot 'content'}}
<div class="notice info">
<h3>Local Storage</h3>
<p>
These settings are specific to the Consul web UI. They are saved to local storage and persist through browser usage and visits.
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>Automatically get updated catalog information without refreshing the page. Any changes made to services and nodes would be reflected in real time.</p>
<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'}} />

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) {