ui: Remove WithEventSource mixin, use a component instead (#7953)

The WithEventSource mixin was responsible for catching EventSource
errors and cleaning up events sources then the user left a Controller.

As we are trying to avoid mixin usage, we moved this all to an
`EventSource` component, which can clean up when the component is
removed from the page, and also fires an onerror event.

Moving to a component firing an onerror event means we can also remove
all of our custom computed property work that we were using previously
to catch errors (thrown when a service etc. is removed)
This commit is contained in:
John Cowen 2020-06-17 14:19:50 +01:00 committed by hashicorp-ci
parent 918b6525d5
commit 2b0b1e61d2
24 changed files with 175 additions and 195 deletions

View File

@ -0,0 +1,25 @@
## EventSource
```handlebars
<EventSource
@src={{eventSourceObject}}
@onerror={{action 'error'}}
/>
```
### Arguments
| Argument | Type | Default | Description |
| --- | --- | --- | --- |
| `src` | `EventSourceProxy` | | An EventSource object |
| `onerror` | `Function` | | The action to fire when an error occurs. Emits ErrorEvent object with an `error` property containing the Error. |
| `closeOnDestroy` | `Boolean` | true | Whether to close and destroy the event source when the component is destroyed |
This component is used to configure event source error from within a template, but also ensures that EventSources are cleaned/up destroyed when the user leaves the page (when the component is removed from the page)
### See
- [Component Source Code](./index.js)
- [Template Source Code](./index.hbs)
---

View File

@ -0,0 +1,38 @@
import Component from '@ember/component';
import { inject as service } from '@ember/service';
export default Component.extend({
tagName: '',
dom: service('dom'),
logger: service('logger'),
closeOnDestroy: true,
onerror: function(e) {
this.logger.execute(e.error);
},
init: function() {
this._super(...arguments);
this._listeners = this.dom.listeners();
},
willDestroyElement: function() {
if (this.closeOnDestroy && typeof (this.src || {}).close === 'function') {
this.src.close();
this.src.willDestroy();
}
this._listeners.remove();
this._super(...arguments);
},
didReceiveAttrs: function() {
this._listeners.remove();
if (typeof (this.src || {}).addEventListener === 'function') {
this._listeners.add(this.src, {
error: e => {
try {
this.onerror(e);
} catch (err) {
this.logger.execute(e.error);
}
},
});
}
},
});

View File

@ -7,6 +7,7 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{{#let (concat 'tabular-details-' name '-toggle-' guid '_') as |inputId|}}
{{#each items as |item index|}} {{#each items as |item index|}}
<tr data-test-tabular-row onclick={{action 'click'}}> <tr data-test-tabular-row onclick={{action 'click'}}>
<YieldSlot @name="row">{{yield item index}}</YieldSlot> <YieldSlot @name="row">{{yield item index}}</YieldSlot>
@ -28,5 +29,6 @@
</td> </td>
</tr> </tr>
{{/each}} {{/each}}
{{/let}}
</tbody> </tbody>
</table> </table>

View File

@ -1,20 +1,14 @@
import Component from '@ember/component'; import Component from '@ember/component';
import SlotsMixin from 'block-slots';
import { inject as service } from '@ember/service'; import { inject as service } from '@ember/service';
import { set } from '@ember/object'; import Slotted from 'block-slots';
import { subscribe } from 'consul-ui/utils/computed/purify';
let uid = 0; export default Component.extend(Slotted, {
export default Component.extend(SlotsMixin, {
dom: service('dom'), dom: service('dom'),
onchange: function() {}, onchange: function() {},
init: function() { init: function() {
this._super(...arguments); this._super(...arguments);
set(this, 'uid', uid++); this.guid = this.dom.guid(this);
}, },
inputId: subscribe('name', 'uid', function(name = 'name') {
return `tabular-details-${name}-toggle-${uid}_`;
}),
actions: { actions: {
click: function(e) { click: function(e) {
this.dom.clickFirstAnchor(e); this.dom.clickFirstAnchor(e);

View File

@ -1,11 +0,0 @@
import { computed as computedPropertyFactory } from '@ember/object';
export const computed = function() {
const prop = computedPropertyFactory(...arguments);
prop.catch = function(cb) {
return this.meta({
catch: cb,
});
};
return prop;
};

View File

@ -1,6 +1,5 @@
import Controller from '@ember/controller'; import Controller from '@ember/controller';
import WithEventSource from 'consul-ui/mixins/with-event-source'; export default Controller.extend({
export default Controller.extend(WithEventSource, {
queryParams: { queryParams: {
filterBy: { filterBy: {
as: 'action', as: 'action',

View File

@ -1,7 +1,6 @@
import Controller from '@ember/controller'; import Controller from '@ember/controller';
import WithEventSource from 'consul-ui/mixins/with-event-source';
export default Controller.extend(WithEventSource, { export default Controller.extend({
queryParams: { queryParams: {
filterBy: { filterBy: {
as: 'status', as: 'status',

View File

@ -2,25 +2,29 @@ import Controller from '@ember/controller';
import { inject as service } from '@ember/service'; import { inject as service } from '@ember/service';
import { get } from '@ember/object'; import { get } from '@ember/object';
import { alias } from '@ember/object/computed'; import { alias } from '@ember/object/computed';
import WithEventSource, { listen } from 'consul-ui/mixins/with-event-source';
export default Controller.extend(WithEventSource, { export default Controller.extend({
dom: service('dom'), dom: service('dom'),
notify: service('flashMessages'), notify: service('flashMessages'),
items: alias('item.Services'), items: alias('item.Services'),
item: listen('item').catch(function(e) { actions: {
if (e.target.readyState === 1) { error: function(e) {
// OPEN if (e.target.readyState === 1) {
if (get(e, 'error.errors.firstObject.status') === '404') { // OPEN
this.notify.add({ if (get(e, 'error.errors.firstObject.status') === '404') {
destroyOnClick: false, this.notify.add({
sticky: true, destroyOnClick: false,
type: 'warning', sticky: true,
action: 'update', type: 'warning',
}); action: 'update',
this.tomography.close(); });
this.sessions.close(); [e.target, this.tomography, this.sessions].forEach(function(item) {
if (item && typeof item.close === 'function') {
item.close();
}
});
}
} }
} },
}), },
}); });

View File

@ -1,7 +1,6 @@
import Controller from '@ember/controller'; import Controller from '@ember/controller';
import WithEventSource from 'consul-ui/mixins/with-event-source'; export default Controller.extend({
export default Controller.extend(WithEventSource, {
queryParams: { queryParams: {
search: { search: {
as: 'filter', as: 'filter',

View File

@ -1,7 +1,7 @@
import Controller from '@ember/controller'; import Controller from '@ember/controller';
import { computed } from '@ember/object'; import { computed } from '@ember/object';
import WithEventSource from 'consul-ui/mixins/with-event-source';
export default Controller.extend(WithEventSource, { export default Controller.extend({
queryParams: { queryParams: {
sortBy: 'sort', sortBy: 'sort',
search: { search: {

View File

@ -1,25 +1,27 @@
import Controller from '@ember/controller'; import Controller from '@ember/controller';
import { get } from '@ember/object'; import { get } from '@ember/object';
import { inject as service } from '@ember/service'; import { inject as service } from '@ember/service';
import WithEventSource, { listen } from 'consul-ui/mixins/with-event-source';
export default Controller.extend(WithEventSource, { export default Controller.extend({
notify: service('flashMessages'), notify: service('flashMessages'),
item: listen('item').catch(function(e) { actions: {
if (e.target.readyState === 1) { error: function(e) {
// OPEN if (e.target.readyState === 1) {
if (get(e, 'error.errors.firstObject.status') === '404') { // OPEN
this.notify.add({ if (get(e, 'error.errors.firstObject.status') === '404') {
destroyOnClick: false, this.notify.add({
sticky: true, destroyOnClick: false,
type: 'warning', sticky: true,
action: 'update', type: 'warning',
}); action: 'update',
const proxy = this.proxy; });
if (proxy) { [e.target, this.proxy].forEach(function(item) {
proxy.close(); if (item && typeof item.close === 'function') {
item.close();
}
});
} }
} }
} },
}), },
}); });

View File

@ -1,21 +1,29 @@
import Controller from '@ember/controller'; import Controller from '@ember/controller';
import { get } from '@ember/object'; import { get } from '@ember/object';
import { inject as service } from '@ember/service'; import { inject as service } from '@ember/service';
import WithEventSource, { listen } from 'consul-ui/mixins/with-event-source'; export default Controller.extend({
export default Controller.extend(WithEventSource, {
dom: service('dom'), dom: service('dom'),
notify: service('flashMessages'), notify: service('flashMessages'),
item: listen('item').catch(function(e) { actions: {
if (e.target.readyState === 1) { error: function(e) {
// OPEN if (e.target.readyState === 1) {
if (get(e, 'error.errors.firstObject.status') === '404') { // OPEN
this.notify.add({ if (get(e, 'error.errors.firstObject.status') === '404') {
destroyOnClick: false, this.notify.add({
sticky: true, destroyOnClick: false,
type: 'warning', sticky: true,
action: 'update', type: 'warning',
}); action: 'update',
});
}
[e.target, this.intentions, this.chain, this.proxies, this.gatewayServices].forEach(
function(item) {
if (item && typeof item.close === 'function') {
item.close();
}
}
);
} }
} },
}), },
}); });

View File

@ -19,7 +19,7 @@ export function initialize(container) {
}; };
}) })
.concat( .concat(
['dc', 'policy', 'role'].map(function(item) { ['policy', 'role'].map(function(item) {
// create repositories that return a promise resolving to an EventSource // create repositories that return a promise resolving to an EventSource
return { return {
service: `repository/${item}/component`, service: `repository/${item}/component`,

View File

@ -1,61 +0,0 @@
import Mixin from '@ember/object/mixin';
import { set } from '@ember/object';
import { computed as catchable } from 'consul-ui/computed/catchable';
import purify from 'consul-ui/utils/computed/purify';
import WithListeners from 'consul-ui/mixins/with-listeners';
const PREFIX = '_';
export default Mixin.create(WithListeners, {
setProperties: function(model) {
const _model = {};
Object.keys(model).forEach(prop => {
// here (see comment below on deleting)
if (model[prop] && typeof model[prop].addEventListener === 'function') {
let meta;
// TODO: metaForProperty throws an error if the property is not
// computed-like, this is far from ideal but happy with this
// until we can find a better way in an ember post 2.18 world
// of finding out if a property is computed or not
// (or until we switch all this out for <DataSource /> compoments
try {
meta = this.constructor.metaForProperty(prop);
} catch (e) {
meta = {};
}
if (typeof meta.catch === 'function') {
_model[`${PREFIX}${prop}`] = model[prop];
this.listen(_model[`_${prop}`], 'error', meta.catch.bind(this));
} else {
_model[prop] = model[prop];
}
} else {
_model[prop] = model[prop];
}
});
return this._super(_model);
},
reset: function(exiting) {
Object.keys(this).forEach(prop => {
if (this[prop] && typeof this[prop].close === 'function') {
this[prop].willDestroy();
// ember doesn't delete on 'resetController' by default
// right now we only call reset when we are exiting, therefore a full
// setProperties will be called the next time we enter the Route so this
// is ok for what we need and means that the above conditional works
// as expected (see 'here' comment above)
// delete this[prop];
// TODO: Check that nulling this out instead of deleting is fine
// pretty sure it is as above is just a falsey check
set(this, prop, null);
}
});
return this._super(...arguments);
},
willDestroy: function() {
this.reset(true);
this._super(...arguments);
},
});
export const listen = purify(catchable, function(props) {
return props.map(item => `${PREFIX}${item}`);
});

View File

@ -1,4 +1,5 @@
{{title 'Intentions'}} {{title 'Intentions'}}
<EventSource @src={{items}} />
{{#let (filter-by "Action" "deny" items) as |denied|}} {{#let (filter-by "Action" "deny" items) as |denied|}}
{{#let (selectable-key-values {{#let (selectable-key-values
(array "" (concat "All (" items.length ")")) (array "" (concat "All (" items.length ")"))

View File

@ -1,4 +1,5 @@
{{title 'Nodes'}} {{title 'Nodes'}}
<EventSource @src={{items}} />
{{#let (selectable-key-values {{#let (selectable-key-values
(array "" "All (Any Status)") (array "" "All (Any Status)")
(array "critical" "Critical Checks") (array "critical" "Critical Checks")

View File

@ -1,4 +1,5 @@
{{title item.Node}} {{title item.Node}}
<EventSource @src={{item}} @onerror={{action "error"}} />
<AppView @class="node show"> <AppView @class="node show">
<BlockSlot @name="notification" as |status type|> <BlockSlot @name="notification" as |status type|>
{{!TODO: Move sessions to its own folder within nodes }} {{!TODO: Move sessions to its own folder within nodes }}

View File

@ -1,4 +1,5 @@
{{title 'Namespaces'}} {{title 'Namespaces'}}
<EventSource @src={{items}} />
<AppView @class="nspace list" @loading={{isLoading}}> <AppView @class="nspace list" @loading={{isLoading}}>
<BlockSlot @name="notification" as |status type subject|> <BlockSlot @name="notification" as |status type subject|>
{{partial 'dc/nspaces/notifications'}} {{partial 'dc/nspaces/notifications'}}

View File

@ -1,4 +1,5 @@
{{title 'Services'}} {{title 'Services'}}
<EventSource @src={{items}} />
{{#let (selectable-key-values {{#let (selectable-key-values
(array "Name:asc" "A to Z") (array "Name:asc" "A to Z")
(array "Name:desc" "Z to A") (array "Name:desc" "Z to A")

View File

@ -1,4 +1,6 @@
{{title item.ID}} {{title item.ID}}
<EventSource @src={{item}} @onerror={{action "error"}} />
<EventSource @src={{proxy}} />
<AppView @class="instance show"> <AppView @class="instance show">
<BlockSlot @name="notification" as |status type|> <BlockSlot @name="notification" as |status type|>
{{partial 'dc/services/notifications'}} {{partial 'dc/services/notifications'}}

View File

@ -1,4 +1,7 @@
{{title item.Service.Service}} {{title item.Service.Service}}
<EventSource @src={{item}} @onerror={{action "error"}} />
<EventSource @src={{chain}} />
<EventSource @src={{intentions}} />
<AppView @class="service show"> <AppView @class="service show">
<BlockSlot @name="notification" as |status type|> <BlockSlot @name="notification" as |status type|>
{{partial 'dc/services/notifications'}} {{partial 'dc/services/notifications'}}

View File

@ -1,42 +0,0 @@
import { get, computed } from '@ember/object';
/**
* Converts a conventional non-pure Ember `computed` function into a pure one
* (see https://github.com/emberjs/rfcs/blob/be351b059f08ac0fe709bc7697860d5064717a7f/text/0000-tracked-properties.md#avoiding-dependency-hell)
*
* @param {function} computed - a computed function to 'purify' (convert to a pure function)
* @param {function} filter - Optional string filter function to pre-process the names of computed properties
* @returns {function} - A pure `computed` function
*/
const _success = function(value) {
return value;
};
const purify = function(computed, filter = args => args) {
return function() {
let args = [...arguments];
let success = _success;
// pop the user function off the end
if (typeof args[args.length - 1] === 'function') {
success = args.pop();
}
args = filter(args);
// this is the 'conventional' `computed`
const cb = function(name) {
return success.apply(
this,
args.map(item => {
// Right now this just takes the first part of the path so:
// `items.[]` or `items.@each.prop` etc
// gives you `items` which is 'probably' what you expect
// it won't work with something like `item.objects.[]`
// it could potentially be made to do so, but we don't need that right now at least
return get(this, item.split('.')[0]);
})
);
};
// concat/push the user function back on
return computed(...args.concat([cb]));
};
};
export const subscribe = purify(computed);
export default purify;

View File

@ -4,36 +4,24 @@ const proxies = {};
export default function(ObjProxy, ArrProxy, createListeners) { export default function(ObjProxy, ArrProxy, createListeners) {
return function(source, data = []) { return function(source, data = []) {
let Proxy = ObjProxy; let Proxy = ObjProxy;
// TODO: Why are these two separate? // TODO: When is data ever a string?
// And when is data ever a string?
if (typeof data !== 'string' && typeof get(data, 'length') !== 'undefined') {
data = data.filter(function(item) {
return !get(item, 'isDestroyed') && !get(item, 'isDeleted') && get(item, 'isLoaded');
});
}
let type = 'object'; let type = 'object';
if (typeof data !== 'string' && typeof get(data, 'length') !== 'undefined') { if (typeof data !== 'string' && typeof get(data, 'length') !== 'undefined') {
Proxy = ArrProxy; Proxy = ArrProxy;
type = 'array'; type = 'array';
data = data.filter(function(item) {
return !get(item, 'isDestroyed') && !get(item, 'isDeleted') && get(item, 'isLoaded');
});
} }
if (typeof proxies[type] === 'undefined') { if (typeof proxies[type] === 'undefined') {
proxies[type] = Proxy.extend({ proxies[type] = Proxy.extend({
closed: false,
error: null,
init: function() { init: function() {
this.listeners = createListeners(); this.listeners = createListeners();
this.listeners.add(this._source, 'message', e => set(this, 'content', e.data)); this.listeners.add(this._source, 'message', e => set(this, 'content', e.data));
this.listeners.add(this._source, 'open', () => set(this, 'closed', false));
this.listeners.add(this._source, 'close', () => set(this, 'closed', true));
this.listeners.add(this._source, 'error', e => set(this, 'error', e.error));
this._super(...arguments); this._super(...arguments);
}, },
addEventListener: function(type, handler) { addEventListener: function(type, handler) {
// Force use of computed for messages
// Temporarily disable this restriction
// if (type !== 'message') {
this.listeners.add(this._source, type, handler); this.listeners.add(this._source, type, handler);
// }
}, },
getCurrentEvent: function() { getCurrentEvent: function() {
return this._source.getCurrentEvent(...arguments); return this._source.getCurrentEvent(...arguments);

View File

@ -0,0 +1,26 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
module('Integration | Component | event-source', function(hooks) {
setupRenderingTest(hooks);
test('it renders', async function(assert) {
// Set any properties with this.set('myProperty', 'value');
// Handle any actions with this.set('myAction', function(val) { ... });
await render(hbs`<EventSource />`);
assert.equal(this.element.textContent.trim(), '');
// Template block usage:
await render(hbs`
<EventSource>
template block text
</EventSource>
`);
assert.equal(this.element.textContent.trim(), 'template block text');
});
});