ui: Add Route component / routlet service (#9813)

* Add Routlet service and Route Component

* Add ember-assign-helper (already an indirect dependency)

* Use EventListeners for is-href instead of observing

* Don't include :active in '-intent' styles
This commit is contained in:
John Cowen 2021-03-08 12:15:54 +00:00 committed by GitHub
parent 9ff49034e7
commit 7c36d749f5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 338 additions and 205 deletions

View File

@ -9,7 +9,6 @@
@extend %main-nav-horizontal-action;
}
%main-nav-horizontal .popover-menu [type='checkbox']:checked + label > *,
%main-nav-horizontal > ul > li > a:active,
%main-nav-horizontal > ul > li.is-active > a,
%main-nav-horizontal > ul > li.is-active > label > * {
@extend %main-nav-horizontal-action-active;

View File

@ -5,7 +5,6 @@
%main-nav-vertical > ul > li > span {
@extend %main-nav-vertical-action;
}
%main-nav-vertical > ul > li > a:active,
%main-nav-vertical > ul > li.is-active > a {
@extend %main-nav-vertical-action-active;
}

View File

@ -1,11 +1,13 @@
{{did-insert this.connect}}
{{will-destroy this.disconnect}}
{{did-insert this.connect}}
{{will-destroy this.disconnect}}
<section
{{did-insert (fn this.attributeChanged 'element')}}
class="outlet"
data-outlet={{@name}}
data-route={{this.route}}
data-route={{this.routeName}}
data-state={{this.state.name}}
data-transition={{concat this.previousState.name ' ' this.state.name}}
{{on 'transitionend' this.transitionEnd}}
>
{{yield (hash
state=this.state

View File

@ -12,56 +12,18 @@ class State {
}
}
class Outlets {
constructor() {
this.map = new Map();
}
sort() {
this.sorted = [...this.map.keys()];
this.sorted.sort((a, b) => {
const al = a.split('.').length;
const bl = b.split('.').length;
switch (true) {
case al > bl:
return -1;
case al < bl:
return 1;
default:
return 0;
}
});
}
set(name, value) {
this.map.set(name, value);
this.sort();
}
get(name) {
return this.map.get(name);
}
delete(name) {
this.map.delete(name);
this.sort();
}
keys() {
return this.sorted;
}
}
const outlets = new Outlets();
export default class Outlet extends Component {
@service('routlet') routlet;
@service('router') router;
@service('dom') dom;
@tracked route;
@tracked element;
@tracked routeName;
@tracked state;
@tracked previousState;
@tracked endTransition;
constructor() {
super(...arguments);
if (this.args.name === 'application') {
this.setAppState('loading');
this.setAppRoute(this.router.currentRouteName);
}
get model() {
return this.args.model || {};
}
setAppRoute(name) {
@ -70,7 +32,7 @@ export default class Outlet extends Component {
name = name.substr(nspace.length);
}
if (name !== 'loading') {
const doc = this.dom.root();
const doc = this.element.ownerDocument.documentElement;
if (doc.classList.contains('ember-loading')) {
doc.classList.remove('ember-loading');
}
@ -80,31 +42,43 @@ export default class Outlet extends Component {
}
setAppState(state) {
this.dom.root().dataset.state = state;
const doc = this.element.ownerDocument.documentElement;
doc.dataset.state = state;
}
setOutletRoutes(route) {
const keys = [...outlets.keys()];
const pos = keys.indexOf(this.name);
const key = pos + 1;
const parent = outlets.get(keys[key]);
parent.route = this.args.name;
@action
attributeChanged(prop, value) {
switch (prop) {
case 'element':
this.element = value;
if (this.args.name === 'application') {
this.setAppState('loading');
this.setAppRoute(this.router.currentRouteName);
}
break;
}
}
this.route = route;
@action transitionEnd($el) {
if (typeof this.endTransition === 'function') {
this.endTransition();
}
}
@action
startLoad(transition) {
const keys = [...outlets.keys()];
const outlet =
keys.find(item => {
return transition.to.name.indexOf(item) !== -1;
}) || 'application';
const outlet = this.routlet.findOutlet(transition.to.name) || 'application';
if (this.args.name === outlet) {
this.previousState = this.state;
this.state = new State('loading');
this.endTransition = this.routlet.transition();
// if we have no transition-duration set immediately end the transition
const duration = window
.getComputedStyle(this.element)
.getPropertyValue('transition-duration');
if (parseFloat(duration) === 0) {
this.endTransition();
}
}
if (this.args.name === 'application') {
this.setAppState('loading');
@ -114,8 +88,6 @@ export default class Outlet extends Component {
@action
endLoad(transition) {
if (this.state.matches('loading')) {
this.setOutletRoutes(transition.to.name);
this.previousState = this.state;
this.state = new State('idle');
}
@ -126,7 +98,7 @@ export default class Outlet extends Component {
@action
connect() {
outlets.set(this.args.name, this);
this.routlet.addOutlet(this.args.name, this);
this.previousState = this.state = new State('idle');
this.router.on('routeWillChange', this.startLoad);
this.router.on('routeDidChange', this.endLoad);
@ -134,7 +106,7 @@ export default class Outlet extends Component {
@action
disconnect() {
outlets.delete(this.args.name);
this.routlet.removeOutlet(this.args.name);
this.router.off('routeWillChange', this.startLoad);
this.router.off('routeDidChange', this.endLoad);
}

View File

@ -0,0 +1,5 @@
{{did-insert this.connect}}
{{will-destroy this.disconnect}}
{{yield (hash
model=model
)}}

View File

@ -0,0 +1,19 @@
import Component from '@glimmer/component';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
export default class RouteComponent extends Component {
@service('routlet') routlet;
@tracked model;
@action
connect() {
this.routlet.addRoute(this.args.name, this);
}
@action
disconnect() {
this.routlet.removeRoute(this.args.name, this);
}
}

View File

@ -1,21 +1,31 @@
/*eslint ember/no-observers: "warn"*/
// TODO: Remove ^
import Helper from '@ember/component/helper';
import { inject as service } from '@ember/service';
import { observes } from '@ember-decorators/object';
import { action } from '@ember/object';
export default class IsHrefHelper extends Helper {
@service('router') router;
init() {
super.init(...arguments);
this.router.on('routeWillChange', this.routeWillChange);
}
compute([targetRouteName, ...rest]) {
if (this.router.currentRouteName.startsWith('nspace.') && targetRouteName.startsWith('dc.')) {
targetRouteName = `nspace.${targetRouteName}`;
}
if (typeof this.next !== 'undefined' && this.next !== 'loading') {
return this.next.startsWith(targetRouteName);
}
return this.router.isActive(...[targetRouteName, ...rest]);
}
@observes('router.currentURL')
onURLChange() {
@action
routeWillChange(transition) {
this.next = transition.to.name.replace('.index', '');
this.recompute();
}
willDestroy() {
this.router.off('routeWillChange', this.routeWillChange);
}
}

View File

@ -47,6 +47,18 @@ export default class DcRoute extends Route {
dc: params.dc,
nspace: get(nspace || {}, 'Name'),
});
// the model here is actually required for the entire application
// but we need to wait until we are in this route so we know what the dc
// and or nspace is if the below changes please revisit the comments
// in routes/application:model
// We do this here instead of in setupController to prevent timing issues
// in lower routes
this.controllerFor('application').setProperties({
dc,
nspace,
token,
permissions,
});
return {
dc,
nspace,
@ -55,15 +67,6 @@ export default class DcRoute extends Route {
};
}
setupController(controller, model) {
super.setupController(...arguments);
// the model here is actually required for the entire application
// but we need to wait until we are in this route so we know what the dc
// and or nspace is if the below changes please revists the comments
// in routes/application:model
this.controllerFor('application').setProperties(model);
}
// TODO: This will eventually be deprecated please see
// https://deprecations.emberjs.com/v3.x/#toc_deprecate-router-events
@action

View File

@ -22,11 +22,11 @@ export default class IndexRoute extends Route {
async model(params, transition) {
const nspace = this.modelFor('nspace').nspace.substr(1);
const dc = this.modelFor('dc').dc.Name;
const items = await this.data.source(uri => uri`/${nspace}/${dc}/services`);
const items = this.data.source(uri => uri`/${nspace}/${dc}/services`);
return {
dc,
nspace,
items,
items: await items,
searchProperties: this.queryParams.searchproperty.empty[0],
};
}

View File

@ -11,11 +11,12 @@ export default class RoutingRoute extends Route {
.slice(0, -1)
.join('.');
const model = this.modelFor(parent);
const chain = this.data.source(
uri => uri`/${model.nspace}/${model.dc.Name}/discovery-chain/${model.slug}`
);
return {
...model,
chain: await this.data.source(
uri => uri`/${model.nspace}/${model.dc.Name}/discovery-chain/${model.slug}`
),
chain: await chain,
};
}

View File

@ -1,7 +1,7 @@
import Route from 'consul-ui/routing/route';
export default class TagsRoute extends Route {
model() {
async model() {
const parent = this.routeName
.split('.')
.slice(0, -1)

View File

@ -0,0 +1,101 @@
import Service from '@ember/service';
import { schedule } from '@ember/runloop';
class Outlets {
constructor() {
this.map = new Map();
this.sorted = [];
}
sort() {
this.sorted = [...this.map.keys()];
this.sorted.sort((a, b) => {
if (a === 'application') {
return 1;
}
if (b === 'application') {
return -1;
}
const al = a.split('.').length;
const bl = b.split('.').length;
switch (true) {
case al > bl:
return -1;
case al < bl:
return 1;
default:
return 0;
}
});
}
set(name, value) {
this.map.set(name, value);
// TODO: find, splice to insert at the correct index instead of sorting
// all the time
this.sort();
}
get(name) {
return this.map.get(name);
}
delete(name) {
// TODO: find, splice to delete at the correct index instead of sorting
// all the time
this.map.delete(name);
this.sort();
}
keys() {
return this.sorted;
}
}
const outlets = new Outlets();
export default class RoutletService extends Service {
ready() {
return this._transition;
}
transition() {
let endTransition;
this._transition = new Promise(resolve => {
endTransition = resolve;
});
return endTransition;
}
findOutlet(name) {
const keys = [...outlets.keys()];
const key = keys.find(item => name.indexOf(item) !== -1);
return key;
}
addOutlet(name, outlet) {
outlets.set(name, outlet);
}
removeOutlet(name) {
outlets.delete(name);
}
// modelFor gets the model for Outlet specified by `name`, not the Route
modelFor(name) {
const outlet = outlets.get(name);
if (typeof outlet !== 'undefined') {
return outlet.model || {};
}
return {};
}
addRoute(name, route) {
const keys = [...outlets.keys()];
const pos = keys.indexOf(name);
const key = pos + 1;
const outlet = outlets.get(keys[key]);
if (typeof outlet !== 'undefined') {
route.model = outlet.model;
// TODO: Try to avoid the double computation bug
schedule('afterRender', () => {
outlet.routeName = route.args.name;
});
}
}
removeRoute(name, route) {}
}

View File

@ -1,3 +1,6 @@
<Route
@name={{routeName}}
>
<HeadLayout />
{{page-title 'Consul' separator=' - '}}
@ -35,3 +38,4 @@ as |source|>
<Consul::Loader class="view-loader" />
</HashicorpConsul>
{{/if}}
</Route>

View File

@ -1,5 +1,10 @@
<Outlet
<Route
@name={{routeName}}
as |o|>
{{outlet}}
</Outlet>
as |route|>
<Outlet
@name={{routeName}}
@model={{route.model}}
as |o|>
{{outlet}}
</Outlet>
</Route>

View File

@ -1,3 +1,6 @@
<Route
@name={{routeName}}
>
{{page-title 'Services'}}
<EventSource @src={{items}} />
@ -108,3 +111,4 @@ as |sort filters items|}}
</AppView>
{{/let}}
</Route>

View File

@ -1,114 +1,118 @@
{{#let items.firstObject as |item|}}
{{page-title item.Service.Service}}
<DataLoader as |loader|>
<Route
@name={{routeName}}
as |route|>
{{#let items.firstObject as |item|}}
{{page-title item.Service.Service}}
<DataLoader as |loader|>
<BlockSlot @name="data">
<EventSource @src={{items}} @onerror={{action loader.dispatchError}} />
{{#if (not loader.error)}}
<EventSource @src={{proxies}} />
<EventSource @src={{chain}} />
{{/if}}
</BlockSlot>
<BlockSlot @name="error">
<AppError @error={{loader.error}} />
</BlockSlot>
<BlockSlot @name="disconnected" as |Notification|>
{{#if (eq loader.error.status "404")}}
<Notification @sticky={{true}}>
<p data-notification role="alert" class="warning notification-update">
<strong>Warning!</strong>
This service has been deregistered and no longer exists in the catalog.
</p>
</Notification>
{{else if (eq loader.error.status "403")}}
<Notification @sticky={{true}}>
<p data-notification role="alert" class="error notification-update">
<strong>Error!</strong>
You no longer have access to this service
</p>
</Notification>
{{else}}
<Notification @sticky={{true}}>
<p data-notification role="alert" class="warning notification-update">
<strong>Warning!</strong>
An error was returned whilst loading this data, refresh to try again.
</p>
</Notification>
{{/if}}
</BlockSlot>
<BlockSlot @name="loaded">
<AppView>
<BlockSlot @name="notification" as |status type item error|>
<TopologyMetrics::Notifications
@type={{type}}
@status={{status}}
@error={{error}}
/>
</BlockSlot>
<BlockSlot @name="breadcrumbs">
<ol>
<li><a data-test-back href={{href-to 'dc.services'}}>All Services</a></li>
</ol>
</BlockSlot>
<BlockSlot @name="header">
<h1>
{{item.Service.Service}}
</h1>
<Consul::ExternalSource @item={{item.Service}} />
<Consul::Kind @item={{item.Service}} @withInfo={{true}} />
</BlockSlot>
<BlockSlot @name="nav">
{{#if (not-eq item.Service.Kind 'mesh-gateway')}}
<TabNav @items={{
compact
(array
(if (and dc.MeshEnabled item.IsMeshOrigin (or (gt proxies.length 0) (eq item.Service.Kind 'ingress-gateway')))
(hash label="Topology" href=(href-to "dc.services.show.topology") selected=(is-href "dc.services.show.topology"))
'')
(if (eq item.Service.Kind 'terminating-gateway')
(hash label="Linked Services" href=(href-to "dc.services.show.services") selected=(is-href "dc.services.show.services"))
'')
(if (eq item.Service.Kind 'ingress-gateway')
(hash label="Upstreams" href=(href-to "dc.services.show.upstreams") selected=(is-href "dc.services.show.upstreams"))
'')
(hash label="Instances" href=(href-to "dc.services.show.instances") selected=(is-href "dc.services.show.instances"))
(if (not-eq item.Service.Kind 'terminating-gateway')
(hash label="Intentions" href=(href-to "dc.services.show.intentions") selected=(is-href "dc.services.show.intentions"))
'')
(if (and dc.MeshEnabled item.IsOrigin)
(hash label="Routing" href=(href-to "dc.services.show.routing") selected=(is-href "dc.services.show.routing"))
'')
(if (not item.Service.Kind)
(hash label="Tags" href=(href-to "dc.services.show.tags") selected=(is-href "dc.services.show.tags"))
'')
)
}}/>
<BlockSlot @name="data">
<EventSource @src={{items}} @onerror={{action loader.dispatchError}} />
{{#if (not loader.error)}}
<EventSource @src={{proxies}} />
<EventSource @src={{chain}} />
{{/if}}
</BlockSlot>
<BlockSlot @name="actions">
{{#if urls.service}}
<a href={{render-template urls.service (hash
Datacenter=dc.Name
Service=(hash Name=item.Service.Service)
)}}
target="_blank"
rel="noopener noreferrer"
data-test-dashboard-anchor>
Open Dashboard
</a>
{{/if}}
</BlockSlot>
<BlockSlot @name="content">
<Outlet
@name={{routeName}}
as |o|>
{{outlet}}
</Outlet>
</BlockSlot>
</AppView>
</BlockSlot>
</DataLoader>
{{/let}}
</BlockSlot>
<BlockSlot @name="error">
<AppError @error={{loader.error}} />
</BlockSlot>
<BlockSlot @name="disconnected" as |Notification|>
{{#if (eq loader.error.status "404")}}
<Notification @sticky={{true}}>
<p data-notification role="alert" class="warning notification-update">
<strong>Warning!</strong>
This service has been deregistered and no longer exists in the catalog.
</p>
</Notification>
{{else if (eq loader.error.status "403")}}
<Notification @sticky={{true}}>
<p data-notification role="alert" class="error notification-update">
<strong>Error!</strong>
You no longer have access to this service
</p>
</Notification>
{{else}}
<Notification @sticky={{true}}>
<p data-notification role="alert" class="warning notification-update">
<strong>Warning!</strong>
An error was returned whilst loading this data, refresh to try again.
</p>
</Notification>
{{/if}}
</BlockSlot>
<BlockSlot @name="loaded">
<AppView>
<BlockSlot @name="notification" as |status type item error|>
<TopologyMetrics::Notifications
@type={{type}}
@status={{status}}
@error={{error}}
/>
</BlockSlot>
<BlockSlot @name="breadcrumbs">
<ol>
<li><a data-test-back href={{href-to 'dc.services'}}>All Services</a></li>
</ol>
</BlockSlot>
<BlockSlot @name="header">
<h1>
{{item.Service.Service}}
</h1>
<Consul::ExternalSource @item={{item.Service}} />
<Consul::Kind @item={{item.Service}} @withInfo={{true}} />
</BlockSlot>
<BlockSlot @name="nav">
{{#if (not-eq item.Service.Kind 'mesh-gateway')}}
<TabNav @items={{
compact
(array
(if (and dc.MeshEnabled item.IsMeshOrigin (or (gt proxies.length 0) (eq item.Service.Kind 'ingress-gateway')))
(hash label="Topology" href=(href-to "dc.services.show.topology") selected=(is-href "dc.services.show.topology"))
'')
(if (eq item.Service.Kind 'terminating-gateway')
(hash label="Linked Services" href=(href-to "dc.services.show.services") selected=(is-href "dc.services.show.services"))
'')
(if (eq item.Service.Kind 'ingress-gateway')
(hash label="Upstreams" href=(href-to "dc.services.show.upstreams") selected=(is-href "dc.services.show.upstreams"))
'')
(hash label="Instances" href=(href-to "dc.services.show.instances") selected=(is-href "dc.services.show.instances"))
(if (not-eq item.Service.Kind 'terminating-gateway')
(hash label="Intentions" href=(href-to "dc.services.show.intentions") selected=(is-href "dc.services.show.intentions"))
'')
(if (and dc.MeshEnabled item.IsOrigin)
(hash label="Routing" href=(href-to "dc.services.show.routing") selected=(is-href "dc.services.show.routing"))
'')
(if (not item.Service.Kind)
(hash label="Tags" href=(href-to "dc.services.show.tags") selected=(is-href "dc.services.show.tags"))
'')
)
}}/>
{{/if}}
</BlockSlot>
<BlockSlot @name="actions">
{{#if urls.service}}
<a href={{render-template urls.service (hash
Datacenter=dc.Name
Service=(hash Name=item.Service.Service)
)}}
target="_blank"
rel="noopener noreferrer"
data-test-dashboard-anchor>
Open Dashboard
</a>
{{/if}}
</BlockSlot>
<BlockSlot @name="content">
<Outlet
@name={{routeName}}
as |o|>
{{outlet}}
</Outlet>
</BlockSlot>
</AppView>
</BlockSlot>
</DataLoader>
{{/let}}
</Route>

View File

@ -1,3 +1,6 @@
<Route
@name={{routeName}}
>
<div class="tab-section">
{{#let
@ -66,3 +69,4 @@ as |sort filters items|}}
</DataCollection>
{{/let}}
</div>
</Route>

View File

@ -87,6 +87,7 @@
"d3-selection": "^2.0.0",
"d3-shape": "^2.0.0",
"dayjs": "^1.9.3",
"ember-assign-helper": "^0.3.0",
"ember-auto-import": "^1.5.3",
"ember-can": "^3.0.0",
"ember-changeset-conditional-validations": "^0.6.0",