Skip to content

Commit

Permalink
Propose event standard (#87)
Browse files Browse the repository at this point in the history
* Update events, add tests

* Fix docs modal
  • Loading branch information
DangoDev authored Apr 22, 2019
1 parent 0145d53 commit 936c5d0
Show file tree
Hide file tree
Showing 14 changed files with 383 additions and 178 deletions.
1 change: 1 addition & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
__mocks__
dist/
www/

Expand Down
85 changes: 85 additions & 0 deletions __mocks__/@stencil/state-tunnel/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { FunctionalComponent, HTMLStencilElement } from '@stencil/core';
import { SubscribeCallback, ConsumerRenderer, PropList } from '../declarations';

export const createProviderConsumer = <T extends { [key: string]: any }>(
defaultState: T,
consumerRender: ConsumerRenderer<T>
) => {
let listeners: Map<HTMLStencilElement, PropList<T>> = new Map();
let currentState: T = defaultState;

const updateListener = (fields: PropList<T>, listener: HTMLStencilElement) => {
if (Array.isArray(fields)) {
[...fields].forEach(fieldName => {
(listener as any)[fieldName] = currentState[fieldName];
});
} else {
(listener as any)[fields] = {
...(currentState as object),
} as T;
}
listener.forceUpdate();
};

const subscribe: SubscribeCallback<T> = (el: HTMLStencilElement, propList: PropList<T>) => {
if (listeners.has(el)) {
return () => {};
}
listeners.set(el, propList);
updateListener(propList, el);

return () => {
listeners.delete(el);
};
};

const Provider: FunctionalComponent<{ state: T }> = ({ state }, children) => {
currentState = state;
listeners.forEach(updateListener);
return children;
};

const Consumer: FunctionalComponent<{}> = (props, children) => {
// The casting on subscribe is to allow for crossover through the stencil compiler
// In the future we should allow for generics in components.
return consumerRender(subscribe, children[0] as any);
};

const injectProps = (childComponent: any, fieldList: PropList<T>) => {
let unsubscribe: any = null;

const elementRefName = Object.keys(childComponent.properties).find(propName => {
return childComponent.properties[propName].elementRef == true;
});
if (elementRefName == undefined) {
throw new Error(
`Please ensure that your Component ${
childComponent.is
} has an attribute with an "@Element" decorator. ` +
`This is required to be able to inject properties.`
);
}

const prevComponentWillLoad = childComponent.prototype.componentWillLoad;
childComponent.prototype.componentWillLoad = function() {
unsubscribe = subscribe(this[elementRefName], fieldList);
if (prevComponentWillLoad) {
return prevComponentWillLoad.bind(this)();
}
};

const prevComponentDidUnload = childComponent.prototype.componentDidUnload;
childComponent.prototype.componentDidUnload = function() {
unsubscribe();
if (prevComponentDidUnload) {
return prevComponentDidUnload.bind(this)();
}
};
};

return {
Provider,
Consumer,
injectProps,
};
};
9 changes: 5 additions & 4 deletions src/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,8 +323,9 @@ export namespace Components {
'hideCta'?: boolean;
'isExistingResource'?: boolean;
'linkFormat'?: string;
'onManifold-planCTA-click'?: (event: CustomEvent) => void;
'onManifold-planUpdated'?: (event: CustomEvent) => void;
'onManifold-planSelector-change'?: (event: CustomEvent) => void;
'onManifold-planSelector-click'?: (event: CustomEvent) => void;
'onManifold-planSelector-load'?: (event: CustomEvent) => void;
'plan'?: Catalog.ExpandedPlan;
'product'?: Catalog.Product;
}
Expand Down Expand Up @@ -475,7 +476,7 @@ export namespace Components {
'linkFormat'?: string;
'logo'?: string;
'name'?: string;
'onManifold-serviceCard-click'?: (event: CustomEvent) => void;
'onManifold-marketplace-click'?: (event: CustomEvent) => void;
'productId'?: string;
}

Expand All @@ -486,7 +487,7 @@ export namespace Components {
interface ManifoldTemplateCardAttributes extends StencilHTMLAttributes {
'category'?: string;
'linkFormat'?: string;
'onManifold-templateCard-click'?: (event: CustomEvent) => void;
'onManifold-template-click'?: (event: CustomEvent) => void;
}

interface ManifoldToast {
Expand Down
49 changes: 20 additions & 29 deletions src/components/manifold-marketplace/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,47 +60,38 @@ comma-separated list:
<manifold-marketplace featured="piio,zerosix" />
```

## Navigation
## Events

When users click on a product card, you expect something to happen, right? By
default, service cards will emit a `manifold-serviceCard-click` custom event
whenever a user clicks anywhere on a card. You can listen for it like so,
and use this value to navigate client-side or perform some other action of
your choice:
This component emits [custom
events](https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent/CustomEvent)
when it updates. To listen to those events, add an event listener either on
the component itself, or `document`.

```js
document.addEventListener('manifold-serviceCard-click', { detail: { label } } => {
alert(`You clicked the card for ${label}`);
document.addEventListener('manifold-marketplace-click', { detail: { productLabel } } => {
alert(`You clicked the card for ${productLabel}`);
});
```

Alternately, if you’d like the service cards to be plain, ol’ `<a>` tags, you
can specify a `link-format` attribute, where `:product` will be substituted
with each product’s URL-friendly slug:
The following events are emitted:

| Event Name | Description | Data |
| :--------------------------- | :------------------------------------------------------ | :-------------------------- |
| `manifold-marketplace-click` | Fires whenever a user has clicked on a product. | `productId`, `productLabel` |
| `manifold-template-click` | Fires whenever a user has clicked on a custom template. | `category` |

## Navigation

By default, service cards will only emit the `manifold-marketplace-click`
event (above). But it can also be turned into an `<a>` tag by specifying
`link-format`:

```html
<manifold-marketplace link-format="/product/:product" />
<!-- <a href="/product/jawsdb-mysql"> -->
```

Note that template cards also emit an event as well:
`manifold-templateCard-click`.

#### Handling Events in React

Attaching listeners to custom components in React [requires the use of refs](https://custom-elements-everywhere.com/). Example:

```js
marketplaceLoaded(node) {
node.addEventListener("manifold-serviceCard-click", ({ detail: { label } }) => {
alert(`You clicked the card for ${label}`);
});
}

render() {
return <manifold-marketplace ref={this.marketplaceLoaded} />;
}
```
`:product` will be replaced with the url-friendly slug for the product.

<!-- Auto Generated Below -->

Expand Down
50 changes: 45 additions & 5 deletions src/components/manifold-plan-details/manifold-plan-details.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,20 +24,60 @@ describe(`<manifold-plan-details>`, () => {
});
});

it('dispatches update event when loaded', () => {
it('dispatches load event', () => {
const planDetails = new PlanDetails();
planDetails.plan = ExpandedPlanCustom;
planDetails.product = Product;

const mock = { emit: jest.fn() };
planDetails.planUpdated = mock;
planDetails.planLoad = mock;

planDetails.componentWillLoad();
expect(mock.emit).toHaveBeenCalledWith({
features: { instance_class: 'db.t2.micro', redundancy: false, storage: 5 },
id: '235exy25wvzpxj52p87bh87gbnj4y',
label: 'custom',
product: 'jawsdb-mysql',
planId: '235exy25wvzpxj52p87bh87gbnj4y',
planLabel: 'custom',
productLabel: 'jawsdb-mysql',
});
});

it('dispatches update event', () => {
const planDetails = new PlanDetails();
planDetails.plan = ExpandedPlanCustom;
planDetails.product = Product;
planDetails.planLoad = { emit: jest.fn() };
planDetails.componentWillLoad(); // Set initial features

const mock = { emit: jest.fn() };
planDetails.planUpdate = mock;

// Set redundancy: true
const e = new CustomEvent('', { detail: { name: 'redundancy', value: true } });
planDetails.handleChangeValue(e);
expect(mock.emit).toHaveBeenCalledWith({
features: { redundancy: true, instance_class: 'db.t2.micro', storage: 5 },
planId: '235exy25wvzpxj52p87bh87gbnj4y',
planLabel: 'custom',
productLabel: 'jawsdb-mysql',
});
});

it('dispatches click event', () => {
const planDetails = new PlanDetails();
planDetails.plan = ExpandedPlanCustom;
planDetails.product = Product;
planDetails.planLoad = { emit: jest.fn() };
planDetails.componentWillLoad(); // Set initial features

const mock = { emit: jest.fn() };
planDetails.planClick = mock;

planDetails.onClick(new Event('click'));
expect(mock.emit).toHaveBeenCalledWith({
features: { instance_class: 'db.t2.micro', redundancy: false, storage: 5 },
planId: '235exy25wvzpxj52p87bh87gbnj4y',
planLabel: 'custom',
productLabel: 'jawsdb-mysql',
});
});
});
82 changes: 48 additions & 34 deletions src/components/manifold-plan-details/manifold-plan-details.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,65 +4,75 @@ import { initialFeatures } from '../../utils/plan';
import { FeatureValue } from './components/FeatureValue';
import { FeatureLabel } from './components/FeatureLabel';

interface EventDetail {
planId: string;
planLabel: string;
productLabel: string | undefined;
features: UserFeatures;
}

@Component({
tag: 'manifold-plan-details',
styleUrl: 'plan-details.css',
shadow: true,
})
export class ManifoldPlanDetails {
@Prop() isExistingResource?: boolean;
@Prop() plan?: Catalog.ExpandedPlan;
@Prop() hideCta?: boolean = false;
@Prop() linkFormat?: string;
@Prop() plan?: Catalog.ExpandedPlan;
@Prop() product?: Catalog.Product;
@Prop() hideCta?: boolean = false;
@State() features: UserFeatures = {};
@Event({
eventName: 'manifold-planUpdated',
bubbles: true,
})
planUpdated: EventEmitter;
@Event({ eventName: 'manifold-planSelector-change', bubbles: true }) planUpdate: EventEmitter;
@Event({ eventName: 'manifold-planSelector-click', bubbles: true }) planClick: EventEmitter;
@Event({ eventName: 'manifold-planSelector-load', bubbles: true }) planLoad: EventEmitter;
@Watch('plan') onUpdate(newPlan: Catalog.ExpandedPlan) {
const features = this.initialFeatures(newPlan);
this.features = features; // If plan changed, we want to reset all user-selected values
this.updatedPlanHandler({ features }); // Dispatch change event when plan changed
const detail: EventDetail = {
planId: newPlan.id,
planLabel: newPlan.body.label,
productLabel: this.product && this.product.body.label,
features,
};
this.planUpdate.emit(detail);
}
@Event({
eventName: 'manifold-planCTA-click',
bubbles: true,
})
ctaClicked: EventEmitter;

componentWillLoad() {
const features = this.initialFeatures();
this.features = features; // Set default features the first time
this.updatedPlanHandler({ features }); // Dispatch change event when loaded
if (this.plan && this.product) {
// This conditional should always fire on component load
const detail: EventDetail = {
planId: this.plan.id,
planLabel: this.plan.body.label,
productLabel: this.product.body.label,
features,
};
this.planLoad.emit(detail);
}
}

handleChangeValue({ detail: { name, value } }: CustomEvent) {
const features = { ...this.features, [name]: value };
this.features = features;
this.updatedPlanHandler({ features }); // Dispatch change event when user changed feature
this.features = features; // User-selected features
if (this.plan && this.product) {
// Same as above: this should always fire; just needed for TS
const detail: EventDetail = {
planId: this.plan.id,
planLabel: this.plan.body.label,
productLabel: this.product.body.label,
features,
};
this.planUpdate.emit(detail);
}
}

initialFeatures(plan: Catalog.ExpandedPlan | undefined = this.plan): UserFeatures {
if (!plan || !plan.body.expanded_features) return {};
return { ...initialFeatures(plan.body.expanded_features) };
}

updatedPlanHandler({
id = this.plan && this.plan.id,
label = this.plan && this.plan.body.label,
product = this.product && this.product.body.label,
features = this.features,
}) {
this.planUpdated.emit({
id,
label,
product,
features,
});
}

get ctaLink() {
if (!this.product || !this.plan) return undefined;
if (typeof this.linkFormat !== 'string') return undefined;
Expand Down Expand Up @@ -144,11 +154,15 @@ export class ManifoldPlanDetails {
}

onClick = (e: Event): void => {
if (!this.linkFormat) {
if (!this.linkFormat && this.plan && this.product) {
e.preventDefault();
const product = this.product && this.product.body.label;
const plan = this.plan && this.plan.body.label;
this.ctaClicked.emit({ product, plan, features: this.features });
const detail: EventDetail = {
productLabel: this.product.body.label,
planId: this.plan.id,
planLabel: this.plan.body.label,
features: this.features,
};
this.planClick.emit(detail);
}
};

Expand Down
9 changes: 5 additions & 4 deletions src/components/manifold-plan-details/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,11 @@

## Events

| Event | Description | Type |
| ------------------------ | ----------- | ------------------- |
| `manifold-planCTA-click` | | `CustomEvent<void>` |
| `manifold-planUpdated` | | `CustomEvent<void>` |
| Event | Description | Type |
| ------------------------------ | ----------- | ------------------- |
| `manifold-planSelector-change` | | `CustomEvent<void>` |
| `manifold-planSelector-click` | | `CustomEvent<void>` |
| `manifold-planSelector-load` | | `CustomEvent<void>` |


----------------------------------------------
Expand Down
Loading

0 comments on commit 936c5d0

Please sign in to comment.