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:
parent
81f209d71e
commit
7021433185
|
@ -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 '';
|
||||
},
|
||||
}),
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
position: relative;
|
||||
padding: 1em;
|
||||
padding-left: 45px;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
%notice::before {
|
||||
position: absolute;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
{{yield}}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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&orgid=1&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}}
|
|
@ -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
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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) {
|
||||
|
|
Loading…
Reference in New Issue