diff --git a/ui/packages/consul-ui/app/helpers/document-attrs.js b/ui/packages/consul-ui/app/helpers/document-attrs.js
new file mode 100644
index 0000000000..4f91fe14d7
--- /dev/null
+++ b/ui/packages/consul-ui/app/helpers/document-attrs.js
@@ -0,0 +1,96 @@
+import Helper from '@ember/component/helper';
+import { inject as service } from '@ember/service';
+import { runInDebug } from '@ember/debug';
+import MultiMap from 'mnemonist/multi-map';
+
+// keep a record or attrs
+const attrs = new Map();
+
+// keep a record of hashes privately
+const wm = new WeakMap();
+
+export default class DocumentAttrsHelper extends Helper {
+ @service('-document') document;
+
+ compute(params, hash) {
+ this.synchronize(this.document.documentElement, hash);
+ }
+
+ willDestroy() {
+ this.synchronize(this.document.documentElement);
+ wm.delete(this);
+ }
+
+ synchronize(root, hash) {
+ const prev = wm.get(this);
+ if (prev) {
+ // if this helper was already setting a property then remove them from
+ // our book keeping
+ Object.entries(prev).forEach(([key, value]) => {
+ let map = attrs.get(key);
+
+ if (typeof map !== 'undefined') {
+ [...new Set(value.split(' '))].map(val => map.remove(val, this));
+ }
+ });
+ }
+ if (hash) {
+ // if we are setting more properties add them to our book keeping
+ wm.set(this, hash);
+ [...Object.entries(hash)].forEach(([key, value]) => {
+ let values = attrs.get(key);
+ if (typeof values === 'undefined') {
+ values = new MultiMap(Set);
+ attrs.set(key, values);
+ }
+ [...new Set(value.split(' '))].map(val => {
+ if (values.count(val) === 0) {
+ values.set(val, null);
+ }
+ values.set(val, this);
+ });
+ });
+ }
+ [...attrs.entries()].forEach(([attr, values]) => {
+ let type = 'attr';
+ if (attr === 'class') {
+ type = attr;
+ } else if (attr.startsWith('data-')) {
+ type = 'data';
+ }
+ // go through our list of properties and synchronize the DOM
+ // properties with our properties
+ [...values.keys()].forEach(value => {
+ if (values.count(value) === 1) {
+ switch (type) {
+ case 'class':
+ root.classList.remove(value);
+ break;
+ case 'data':
+ default:
+ runInDebug(() => {
+ throw new Error(`${type} is not implemented yet`);
+ });
+ }
+ values.delete(value);
+ // remove the property if it has no values
+ if (values.size === 0) {
+ attrs.delete(attr);
+ }
+ } else {
+ switch (type) {
+ case 'class':
+ root.classList.add(value);
+ break;
+ case 'data':
+ default:
+ runInDebug(() => {
+ throw new Error(`${type} is not implemented yet`);
+ });
+ }
+ }
+ });
+ });
+ return attrs;
+ }
+}
diff --git a/ui/packages/consul-ui/app/instance-initializers/nspace.js b/ui/packages/consul-ui/app/instance-initializers/nspace.js
index f3039f8747..8bd6938413 100644
--- a/ui/packages/consul-ui/app/instance-initializers/nspace.js
+++ b/ui/packages/consul-ui/app/instance-initializers/nspace.js
@@ -101,22 +101,6 @@ export function initialize(container) {
}
register(container, route, item);
});
-
- // tell the view we have nspaces enabled
- container
- .lookup('service:dom')
- .root()
- .classList.add('has-nspaces');
- }
- // TODO: This needs to live in its own initializer, either:
- // 1. Make it be about adding classes to the root dom node
- // 2. Make it be about config and things to do on initialization re: config
- // If we go with 1 then we need to move both this and the above nspaces class
- if (env('CONSUL_ACLS_ENABLED')) {
- container
- .lookup('service:dom')
- .root()
- .classList.add('has-acls');
}
}
diff --git a/ui/packages/consul-ui/app/styles/components/app-view/layout.scss b/ui/packages/consul-ui/app/styles/components/app-view/layout.scss
index e7bf505025..358cd64e25 100644
--- a/ui/packages/consul-ui/app/styles/components/app-view/layout.scss
+++ b/ui/packages/consul-ui/app/styles/components/app-view/layout.scss
@@ -12,10 +12,6 @@
align-items: flex-start;
}
/* units */
-%app-view {
- margin-top: 50px;
-}
-
%app-view-title {
padding-bottom: 0.2em;
}
diff --git a/ui/packages/consul-ui/app/styles/layout.scss b/ui/packages/consul-ui/app/styles/layout.scss
index 46cf52cdb7..99f8c7d0f3 100644
--- a/ui/packages/consul-ui/app/styles/layout.scss
+++ b/ui/packages/consul-ui/app/styles/layout.scss
@@ -1,5 +1,14 @@
@import 'layouts/index';
+.app-view {
+ margin-top: 50px;
+}
+@media #{$--lt-spacious-page-header} {
+ html:not(.with-breadcrumbs) .app-view {
+ margin-top: 10px;
+ }
+}
+
/* all forms have a margin below the header */
html[data-route$='create'] .app-view > header + div > *:first-child,
html[data-route$='edit'] .app-view > header + div > *:first-child {
@@ -66,11 +75,6 @@ html[data-route$='edit'] main {
@extend %content-container-restricted;
}
-@media #{$--lt-spacious-page-header} {
- html[data-route$='.index']:not([data-route^='dc.kv']) .app-view {
- margin-top: 10px;
- }
-}
@media #{$--lt-spacious-page-header} {
.actions button.copy-btn {
margin-top: -56px;
diff --git a/ui/packages/consul-ui/app/templates/application.hbs b/ui/packages/consul-ui/app/templates/application.hbs
index c19a304e2e..6fb5c38809 100644
--- a/ui/packages/consul-ui/app/templates/application.hbs
+++ b/ui/packages/consul-ui/app/templates/application.hbs
@@ -1,5 +1,11 @@
{{page-title 'Consul' separator=' - '}}
+{{#if (env 'CONSUL_ACLS_ENABLED')}}
+ {{document-attrs class="has-acls"}}
+{{/if}}
+{{#if (env 'CONSUL_NSPACES_ENABLED')}}
+ {{document-attrs class="has-nspaces"}}
+{{/if}}
{{#if (not-eq router.currentRouteName 'application')}}
+ {{#if (not-eq parent.Key '/') }}
- {{#if (not-eq parent.Key '/') }}
- Key / Values
- {{/if}}
{{#each (slice 0 -2 (split parent.Key '/')) as |breadcrumb index|}}
- {{breadcrumb}}
{{/each}}
+ {{/if}}
{{#if (eq parent.Key '/')}}
diff --git a/ui/packages/consul-ui/tests/unit/helpers/document-attrs-test.js b/ui/packages/consul-ui/tests/unit/helpers/document-attrs-test.js
new file mode 100644
index 0000000000..7d2f2ac22e
--- /dev/null
+++ b/ui/packages/consul-ui/tests/unit/helpers/document-attrs-test.js
@@ -0,0 +1,41 @@
+import { module, test } from 'qunit';
+import Helper from 'consul-ui/helpers/document-attrs';
+
+const root = {
+ classList: {
+ add: () => {},
+ remove: () => {},
+ },
+};
+module('Unit | Helper | document-attrs', function() {
+ test('synchronize adds and removes values correctly', function(assert) {
+ let attrs, actual;
+ // add first helper
+ const a = new Helper();
+ attrs = a.synchronize(root, {
+ class: 'a b a a a a',
+ });
+ actual = [...attrs.get('class').keys()];
+ assert.deepEqual(actual, ['a', 'b'], 'keys are adding correctly');
+ const b = new Helper();
+ // add second helper
+ attrs = b.synchronize(root, {
+ class: 'z a a a a',
+ });
+ actual = [...attrs.get('class').keys()];
+ assert.deepEqual(actual, ['a', 'b', 'z'], 'more keys are added correctly');
+ // remove second helper
+ b.synchronize(root);
+ actual = [...attrs.get('class').keys()];
+ assert.deepEqual(actual, ['a', 'b'], 'keys are removed, leaving keys that need to remain');
+ // remove first helper
+ a.synchronize(root);
+ assert.ok(
+ typeof attrs.get('class') === 'undefined',
+ 'property is completely removed once its empty'
+ );
+ assert.throws(() => {
+ a.synchronize(root, { data: 'a' });
+ }, `throws an error if the attrs isn't class`);
+ });
+});