Comments and mockups
13
.gitignore
vendored
@ -1,6 +1,7 @@
|
|||||||
.vscode
|
.vscode
|
||||||
.tmp
|
.tmp
|
||||||
node_modules
|
.DS_Store
|
||||||
package-lock.json
|
node_modules
|
||||||
*/desktop.ini
|
package-lock.json
|
||||||
*/~*.doc*
|
*/desktop.ini
|
||||||
|
*/~*.doc*
|
||||||
|
@ -1,24 +1,26 @@
|
|||||||
# Decomposing UI Components
|
# Decomposing UI Components
|
||||||
|
|
||||||
This is the example illustrating complexities of decomposing a UI component into a series of subcomponents that would simultaneously allow to:
|
This example illustrates the complexities of decomposing a UI component into a series of subcomponents that would simultaneously allow:
|
||||||
* Redefining the appearance of each of the subcomponent
|
* Redefining the appearance of each of the subcomponents.
|
||||||
* Introducing new business logic while keeping styling consistent
|
* Introducing new business logic while keeping styling consistent.
|
||||||
* Inheriting the UX of the component while changing both UI and business logic.
|
* Inheriting the UX of the component while changing both UI and business logic.
|
||||||
|
|
||||||
The `src` folder contains a TypeScript code for the component and corresponding interfaces, and the `index.js` file contains the compiled JavaScript (check `tsconfig.json` for compiler settings).
|
The `src` folder contains TypeScript code for the component and corresponding interfaces, while the `index.js` file contains the compiled JavaScript (please refer to `tsconfig.json` for compiler settings).
|
||||||
|
|
||||||
The `index.html` page includes a living example for each of the discussed scenarios, with links pointing to external playgrounds to work through the code if needed. [View it in your browser](https://twirl.github.io/examples/01.%20Decomposing%20UI%20Components/index.html).
|
The `index.html` page includes a live example for each of the discussed scenarios, with a live code playgrounds for further code exploration. Feel free to view it in your browser.
|
||||||
|
|
||||||
The following improvements to the code are left as an exercise for the reader:
|
The following improvements to the code are left as an exercise for the reader:
|
||||||
* Make all builder functions configurable through options
|
1. Make all builder functions configurable through the `SearchBox` options (instead of subclassing components and overriding builder functions)
|
||||||
* Make `ISearchBoxComposer` a composition of two interfaces: one facade to interact with a `SearchBox`, and another facade to communicate with child components.
|
2. Make a better abstraction of the `SearchBoxComposer` internal state. Make the `findOfferById` function asynchronous
|
||||||
* Create a separate composer to close the gap between `OfferPanelComponent` and its buttons
|
3. Make rendering functions asynchronous
|
||||||
* Add returning an operation status from the `SearchBox.search` method:
|
4. Refactor `ISearchBoxComposer` as a composition of two interfaces: one facade for interacting with a `SearchBox`, and another for communication with child components.
|
||||||
|
5. Create a separate composer to bridge the gap between `OfferPanelComponent` and its buttons.
|
||||||
|
6. Enhance the `SearchBox.search` method to return an operation status:
|
||||||
```
|
```
|
||||||
public search(query: string): Promise<OperationResult>
|
public search(query: string): Promise<OperationResult>
|
||||||
```
|
```
|
||||||
|
|
||||||
Where
|
Where OperationResult is defined as:
|
||||||
|
|
||||||
```
|
```
|
||||||
type OperationResult =
|
type OperationResult =
|
||||||
@ -31,7 +33,11 @@ The following improvements to the code are left as an exercise for the reader:
|
|||||||
status: OperationResultStatus.FAIL;
|
status: OperationResultStatus.FAIL;
|
||||||
error: any;
|
error: any;
|
||||||
};
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
With the enum:
|
||||||
|
|
||||||
|
```
|
||||||
export enum OperationResultStatus {
|
export enum OperationResultStatus {
|
||||||
SUCCESS = 'success',
|
SUCCESS = 'success',
|
||||||
FAIL = 'fail',
|
FAIL = 'fail',
|
||||||
@ -39,18 +45,11 @@ The following improvements to the code are left as an exercise for the reader:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
* Make an offer list paginated (implying adding pagination parameters to `ICoffeeApi.search` request and response, and dynamically loading new items while scrolling the offer list)
|
7. Implement pagination for the offer list (add pagination parameters to `ICoffeeApi.search` request and response, and load new items dynamically while scrolling the offer list).
|
||||||
|
8. Create a separate `ISeachBoxInput` component for the input string and the search button. Add the ability to cancel ongoing requests and include a "skeleton" animation to indicate that search results are loading.
|
||||||
* Make the input string and the search button a separate `ISeachBoxInput` component. Add an ability to cancel the ongoing request. Add a “skeleton” animation to indicate that search results are being loading.
|
9. Localize the component by making locale and a dictionary part of the `ISearchBox` options.
|
||||||
|
10. Make options mutable by exposing an `optionChange` event and implementing the `Composer`'s reaction to relevant option changes.
|
||||||
* Localize the component, making a locale and a dictionary a part of the `ISearchBox` options.
|
11. Parameterize all extra options, content fields, actions, and events.
|
||||||
|
12. Parametrize the markups of components either by:
|
||||||
* Parametrize `context` parameter for `OfferListComponent` and `OfferPanelComponent`. Make it comprise only events needed by the component, so that `ISearchBoxComposer` would be implementing `IOfferListComponentContext` and `IOfferPanelComponentContext` interfaces.
|
* Encapsulating them in Layout entities controlled through options. Create interfaces for each layout and a VisualComponent base class for entities with layouts. Inherit SearchBox, OfferListComponent, and OfferPanelComponent from this base class.
|
||||||
|
* Rewriting components as React / ReactNative / SwiftUI / Android View components or as UI components for other platforms of your choice.
|
||||||
* Make `options` mutable (expose an `optionChange` event and implement `Composers`'s reaction to relevant option changes).
|
|
||||||
|
|
||||||
* Parametrize all extra options, content fields, actions and events.
|
|
||||||
|
|
||||||
* Parametrize markups of components, either by:
|
|
||||||
* Incapsulating them in some `Layout` entities controlled through options. Create interfaces for each of the layouts. Create a `VisualComponent` base class for entities that have a layout and inherit `SearchBox`, `OfferListComponent` and `OfferPanelComponent` from it, or
|
|
||||||
* Rewriting components as React / ReactNative / SwiftUI / Android View component or as a UI component for any other platform of your choice.
|
|
@ -1,3 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview
|
||||||
|
* In this example, we replace the standard offer list
|
||||||
|
* with an alternative implementation that shows offers
|
||||||
|
* as markers on a map
|
||||||
|
*/
|
||||||
const {
|
const {
|
||||||
SearchBox,
|
SearchBox,
|
||||||
SearchBoxComposer,
|
SearchBoxComposer,
|
||||||
@ -6,6 +12,12 @@ const {
|
|||||||
util
|
util
|
||||||
} = ourCoffeeSdk;
|
} = ourCoffeeSdk;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A custom offer list component that
|
||||||
|
* renders data on the map instead of a static
|
||||||
|
* list. This class implements the `IOfferListComponent`
|
||||||
|
* interface from scratch.
|
||||||
|
*/
|
||||||
class CustomOfferList {
|
class CustomOfferList {
|
||||||
constructor(context, container, offerList) {
|
constructor(context, container, offerList) {
|
||||||
this.context = context;
|
this.context = context;
|
||||||
@ -14,11 +26,21 @@ class CustomOfferList {
|
|||||||
this.offerList = null;
|
this.offerList = null;
|
||||||
this.map = null;
|
this.map = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* We listen to the map events (marker selection)
|
||||||
|
* and translate it as an offer selection event.
|
||||||
|
* This is the requirement from the `IOfferListComponent`
|
||||||
|
* interface
|
||||||
|
*/
|
||||||
this.onMarkerClick = (markerId) => {
|
this.onMarkerClick = (markerId) => {
|
||||||
this.events.emit("offerSelect", {
|
this.events.emit("offerSelect", {
|
||||||
offerId: markerId
|
offerId: markerId
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* We are free to implement the business logic in
|
||||||
|
* any that suits our needs
|
||||||
|
*/
|
||||||
this.setOfferList = ({ offerList: newOfferList }) => {
|
this.setOfferList = ({ offerList: newOfferList }) => {
|
||||||
if (this.map) {
|
if (this.map) {
|
||||||
this.map.destroy();
|
this.map.destroy();
|
||||||
@ -26,6 +48,9 @@ class CustomOfferList {
|
|||||||
}
|
}
|
||||||
this.offerList = newOfferList;
|
this.offerList = newOfferList;
|
||||||
if (newOfferList) {
|
if (newOfferList) {
|
||||||
|
// We're displaying data on a map (a dummy one),
|
||||||
|
// using the additional data we pass through the
|
||||||
|
// customized composer (see below)
|
||||||
this.map = new DummyMapApi(this.container, [
|
this.map = new DummyMapApi(this.container, [
|
||||||
[16.355, 48.2],
|
[16.355, 48.2],
|
||||||
[16.375, 48.214]
|
[16.375, 48.214]
|
||||||
@ -46,6 +71,21 @@ class CustomOfferList {
|
|||||||
"offerPreviewListChange",
|
"offerPreviewListChange",
|
||||||
this.setOfferList
|
this.setOfferList
|
||||||
),
|
),
|
||||||
|
// We listen to the
|
||||||
|
// 'offerFullViewToggle' event on
|
||||||
|
// the parent composer context
|
||||||
|
// to select or deselect the corresponding
|
||||||
|
// marker.
|
||||||
|
//
|
||||||
|
// Note the important pattern:
|
||||||
|
// when the marker is clicked, we DO NOT
|
||||||
|
// mark it as selected, but only emit an
|
||||||
|
// event. This is because the offer list
|
||||||
|
// does not own the logic of selecting
|
||||||
|
// offers.
|
||||||
|
// It is the composer's responsibility
|
||||||
|
// to decide, whether this event should
|
||||||
|
// result in opening a panel or not
|
||||||
context.events.on(
|
context.events.on(
|
||||||
"offerFullViewToggle",
|
"offerFullViewToggle",
|
||||||
({ offer }) => {
|
({ offer }) => {
|
||||||
@ -57,6 +97,9 @@ class CustomOfferList {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* As required in the `IOfferListComponent` interface
|
||||||
|
*/
|
||||||
destroy() {
|
destroy() {
|
||||||
if (this.map) {
|
if (this.map) {
|
||||||
this.map.destroy();
|
this.map.destroy();
|
||||||
@ -67,6 +110,14 @@ class CustomOfferList {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* We need to subclass a standard `SearchBoxComposer`
|
||||||
|
* to achieve to important goals:
|
||||||
|
* * Use the custom offer list we created instead
|
||||||
|
* of the standard component
|
||||||
|
* * Enrich the preview data with the geographical
|
||||||
|
* coordinates of the coffee shop
|
||||||
|
*/
|
||||||
class CustomComposer extends SearchBoxComposer {
|
class CustomComposer extends SearchBoxComposer {
|
||||||
buildOfferListComponent(
|
buildOfferListComponent(
|
||||||
context,
|
context,
|
||||||
@ -93,6 +144,10 @@ class CustomComposer extends SearchBoxComposer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* We're subclassing `SearchBox` to use our
|
||||||
|
* enhanced composer
|
||||||
|
*/
|
||||||
class CustomSearchBox extends SearchBox {
|
class CustomSearchBox extends SearchBox {
|
||||||
buildComposer(context, container, options) {
|
buildComposer(context, container, options) {
|
||||||
return new CustomComposer(context, container, options);
|
return new CustomComposer(context, container, options);
|
||||||
|
@ -1,3 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview
|
||||||
|
* In this example, we change the composition logic:
|
||||||
|
* there is no “offer full view” (panel) component, only
|
||||||
|
* a offer list with additional actions
|
||||||
|
*/
|
||||||
const {
|
const {
|
||||||
SearchBox,
|
SearchBox,
|
||||||
SearchBoxComposer,
|
SearchBoxComposer,
|
||||||
@ -6,10 +12,22 @@ const {
|
|||||||
util
|
util
|
||||||
} = ourCoffeeSdk;
|
} = ourCoffeeSdk;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A customized version of the standard `OfferListComponent`.
|
||||||
|
* As we're okay with its logic, we reuse it with two modifications:
|
||||||
|
* * List items could be expanded (and then collapsed back)
|
||||||
|
* * List items contain the 'Place an order' button
|
||||||
|
*/
|
||||||
class CustomOfferList extends OfferListComponent {
|
class CustomOfferList extends OfferListComponent {
|
||||||
constructor(context, container, offerList, options) {
|
constructor(context, container, offerList, options) {
|
||||||
super(context, container, offerList, options);
|
super(context, container, offerList, options);
|
||||||
|
/**
|
||||||
|
* This is a custom DOM event listener to make
|
||||||
|
* other than selecting an offer actions on the item
|
||||||
|
* click event. This is the shortcut we took (see
|
||||||
|
* the explanations in the `OfferPanelComponent.ts`
|
||||||
|
* file).
|
||||||
|
*/
|
||||||
this.onClickListener = (e) => {
|
this.onClickListener = (e) => {
|
||||||
const { target, value: action } = util.findDataField(
|
const { target, value: action } = util.findDataField(
|
||||||
e.target,
|
e.target,
|
||||||
@ -31,7 +49,7 @@ class CustomOfferList extends OfferListComponent {
|
|||||||
this.collapse(container);
|
this.collapse(container);
|
||||||
break;
|
break;
|
||||||
case "createOrder":
|
case "createOrder":
|
||||||
this.context.createOrder(offerId);
|
this.events.emit("createOrder", { offerId });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -45,6 +63,10 @@ class CustomOfferList extends OfferListComponent {
|
|||||||
item.classList.remove("expanded");
|
item.classList.remove("expanded");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is a redefined function that returns
|
||||||
|
* the offer “preview” markup in the list
|
||||||
|
*/
|
||||||
generateOfferHtml(offer) {
|
generateOfferHtml(offer) {
|
||||||
return util.html`<li
|
return util.html`<li
|
||||||
class="custom-offer"
|
class="custom-offer"
|
||||||
@ -66,22 +88,96 @@ class CustomOfferList extends OfferListComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class CustomComposer extends SearchBoxComposer {
|
/**
|
||||||
buildOfferListComponent(
|
* This is a custom implementation of the
|
||||||
context,
|
* `ISearchBoxComposer` interface from scratch.
|
||||||
container,
|
* As there is no offer panel in this particular
|
||||||
offerList,
|
* UI, we don't need all the associated logic,
|
||||||
contextOptions
|
* so we replace the standard implementation
|
||||||
) {
|
* with this new one. However, we re-use the
|
||||||
return new CustomOfferList(
|
* implementation of the offer list subcomponent
|
||||||
context,
|
*/
|
||||||
|
class CustomComposer {
|
||||||
|
constructor(searchBox, container) {
|
||||||
|
this.events = new util.EventEmitter();
|
||||||
|
this.offerList = null;
|
||||||
|
this.container = container;
|
||||||
|
// This is our enhanced offer list
|
||||||
|
this.offerList = new CustomOfferList(
|
||||||
|
this,
|
||||||
container,
|
container,
|
||||||
this.generateOfferPreviews(offerList, contextOptions),
|
this.offerList
|
||||||
this.generateOfferListComponentOptions(contextOptions)
|
|
||||||
);
|
);
|
||||||
|
this.eventDisposers = [
|
||||||
|
searchBox.events.on(
|
||||||
|
"offerListChange",
|
||||||
|
({ offerList }) => this.onOfferListChange(offerList)
|
||||||
|
),
|
||||||
|
// What we need is to listen to an additional event
|
||||||
|
// the custom offer list emits, and convert it into
|
||||||
|
// the order creation request
|
||||||
|
this.offerList.events.on(
|
||||||
|
"createOrder",
|
||||||
|
({ offerId }) => {
|
||||||
|
const offer = this.findOfferById(offerId);
|
||||||
|
if (offer) {
|
||||||
|
this.events.emit("createOrder", {
|
||||||
|
offer
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* This is the `ISearchBoxComposer` interface
|
||||||
|
* method we must implement
|
||||||
|
*/
|
||||||
|
findOfferById(refOfferId) {
|
||||||
|
return this.offerList
|
||||||
|
? this.offerList.find(
|
||||||
|
({ offerId }) => offerId == refOfferId
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* This is the `ISearchBoxComposer` interface
|
||||||
|
* method we must implement
|
||||||
|
*/
|
||||||
|
destroy() {
|
||||||
|
for (const disposer of this.eventDisposers) {
|
||||||
|
disposer.off();
|
||||||
|
}
|
||||||
|
this.offerList.destroy();
|
||||||
|
}
|
||||||
|
onOfferListChange(offerList) {
|
||||||
|
this.offerList = offerList;
|
||||||
|
this.events.emit("offerPreviewListChange", {
|
||||||
|
// This is our custom offer preview generator
|
||||||
|
// function. As we don't plan to customize
|
||||||
|
// it further, we don't bother with exposing
|
||||||
|
// overridable methods, etc.
|
||||||
|
offerList:
|
||||||
|
offerList !== null
|
||||||
|
? offerList.map((offer) => ({
|
||||||
|
offerId: offer.offerId,
|
||||||
|
title: offer.place.title,
|
||||||
|
subtitle: offer.description,
|
||||||
|
bottomLine:
|
||||||
|
SearchBoxComposer.generateOfferBottomLine(
|
||||||
|
offer
|
||||||
|
),
|
||||||
|
price: offer.price
|
||||||
|
}))
|
||||||
|
: null
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* We're subclassing `SearchBox` to use our
|
||||||
|
* custom composer
|
||||||
|
*/
|
||||||
class CustomSearchBox extends SearchBox {
|
class CustomSearchBox extends SearchBox {
|
||||||
buildComposer(context, container, options) {
|
buildComposer(context, container, options) {
|
||||||
return new CustomComposer(context, container, options);
|
return new CustomComposer(context, container, options);
|
||||||
|
@ -1,3 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview
|
||||||
|
* In this example, we enhance the standard offer list with
|
||||||
|
* icons of the coffee shops and the offer view panel,
|
||||||
|
* with an additional business logic, exposing several additional
|
||||||
|
* controls and customizing the existing ones
|
||||||
|
*/
|
||||||
|
|
||||||
const {
|
const {
|
||||||
SearchBox,
|
SearchBox,
|
||||||
SearchBoxComposer,
|
SearchBoxComposer,
|
||||||
@ -9,7 +17,16 @@ const {
|
|||||||
|
|
||||||
const { buildCloseButton } = OfferPanelComponent;
|
const { buildCloseButton } = OfferPanelComponent;
|
||||||
|
|
||||||
const buildCustomOrderButton = function (offer, container) {
|
/**
|
||||||
|
* This is the factory method to create a customized
|
||||||
|
* “Place an order” button that augments the button
|
||||||
|
* look depending on the additional data fields
|
||||||
|
* in the assiciated offer
|
||||||
|
*/
|
||||||
|
const buildCustomCreateOrderButton = function (
|
||||||
|
offer,
|
||||||
|
container
|
||||||
|
) {
|
||||||
return OfferPanelComponent.buildCreateOrderButton(
|
return OfferPanelComponent.buildCreateOrderButton(
|
||||||
offer,
|
offer,
|
||||||
container,
|
container,
|
||||||
@ -24,6 +41,10 @@ const buildCustomOrderButton = function (offer, container) {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is the factory method to create a customized
|
||||||
|
* button that allows for making a phone call
|
||||||
|
*/
|
||||||
const buildCallButton = function (
|
const buildCallButton = function (
|
||||||
offer,
|
offer,
|
||||||
container,
|
container,
|
||||||
@ -38,6 +59,11 @@ const buildCallButton = function (
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is the factory method to create a customized
|
||||||
|
* button that allows for navigating back to the
|
||||||
|
* previous offer
|
||||||
|
*/
|
||||||
const buildPreviousOfferButton = function (
|
const buildPreviousOfferButton = function (
|
||||||
offer,
|
offer,
|
||||||
container
|
container
|
||||||
@ -49,6 +75,10 @@ const buildPreviousOfferButton = function (
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is the factory method to create a customized
|
||||||
|
* button that allows for navigating to the next offer
|
||||||
|
*/
|
||||||
const buildNextOfferButton = function (offer, container) {
|
const buildNextOfferButton = function (offer, container) {
|
||||||
return new NavigateButton(
|
return new NavigateButton(
|
||||||
"right",
|
"right",
|
||||||
@ -57,10 +87,17 @@ const buildNextOfferButton = function (offer, container) {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is a new implementation of the `IButton` interface
|
||||||
|
* from scratch. As “Back” and “Forward” buttons share little
|
||||||
|
* logic with the standard button (they do not have
|
||||||
|
* text or icon, feature a different design, etc.) it's
|
||||||
|
* more convenient to make a new class.
|
||||||
|
*/
|
||||||
class NavigateButton {
|
class NavigateButton {
|
||||||
constructor(direction, offerId, container) {
|
constructor(direction, targetOfferId, container) {
|
||||||
this.action = "navigate";
|
this.action = "navigate";
|
||||||
this.offerId = offerId;
|
this.targetOfferId = targetOfferId;
|
||||||
this.events = new util.EventEmitter();
|
this.events = new util.EventEmitter();
|
||||||
const button = (this.button =
|
const button = (this.button =
|
||||||
document.createElement("button"));
|
document.createElement("button"));
|
||||||
@ -83,6 +120,16 @@ class NavigateButton {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is the customization of the standard `OfferPanelComponent`
|
||||||
|
* class. In this custom implementation, the array of
|
||||||
|
* buttons is contructed dynamically depending on the data
|
||||||
|
* shown in the pannel.
|
||||||
|
*
|
||||||
|
* This is a bit of a shortcut (we should have a separate
|
||||||
|
* composer between a panel and its buttons). The full solution
|
||||||
|
* is left as an exercise for the reader.
|
||||||
|
*/
|
||||||
class CustomOfferPanel extends OfferPanelComponent {
|
class CustomOfferPanel extends OfferPanelComponent {
|
||||||
show() {
|
show() {
|
||||||
const buttons = [];
|
const buttons = [];
|
||||||
@ -90,7 +137,7 @@ class CustomOfferPanel extends OfferPanelComponent {
|
|||||||
if (offer.previousOfferId) {
|
if (offer.previousOfferId) {
|
||||||
buttons.push(buildPreviousOfferButton);
|
buttons.push(buildPreviousOfferButton);
|
||||||
}
|
}
|
||||||
buttons.push(buildCustomOrderButton);
|
buttons.push(buildCustomCreateOrderButton);
|
||||||
if (offer.phone) {
|
if (offer.phone) {
|
||||||
buttons.push(buildCallButton);
|
buttons.push(buildCallButton);
|
||||||
}
|
}
|
||||||
@ -103,6 +150,18 @@ class CustomOfferPanel extends OfferPanelComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* To work with the augmented panel we need
|
||||||
|
* an augmented composer:
|
||||||
|
* * Add the coffee chain icon to the
|
||||||
|
* “preview” data for the offer list
|
||||||
|
* * Use the enhanced offer panel instead
|
||||||
|
* of the standard one
|
||||||
|
* * Enrich the data for the panel needs
|
||||||
|
* with additional fields, such as
|
||||||
|
* the custom icon, phone, and the identifiers
|
||||||
|
* of the previous and next offers
|
||||||
|
*/
|
||||||
class CustomComposer extends SearchBoxComposer {
|
class CustomComposer extends SearchBoxComposer {
|
||||||
buildOfferPanelComponent(
|
buildOfferPanelComponent(
|
||||||
context,
|
context,
|
||||||
@ -167,7 +226,12 @@ class CustomComposer extends SearchBoxComposer {
|
|||||||
|
|
||||||
performAction(event) {
|
performAction(event) {
|
||||||
if (event.action === "navigate") {
|
if (event.action === "navigate") {
|
||||||
this.selectOffer(event.target.offerId);
|
// NB: `event` itself contains an `offerId`
|
||||||
|
// However, this is the identifier of a currently
|
||||||
|
// displayed offer. With `navigate` buttons
|
||||||
|
// we need a different offer, the one we
|
||||||
|
// need to navigate ro
|
||||||
|
this.selectOffer(event.target.targetOfferId);
|
||||||
} else {
|
} else {
|
||||||
super.performAction(event);
|
super.performAction(event);
|
||||||
}
|
}
|
||||||
@ -180,6 +244,10 @@ CustomComposer.DEFAULT_OPTIONS = {
|
|||||||
closeButtonText: "❌Not Now"
|
closeButtonText: "❌Not Now"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* We're subclassing `SearchBox` to use our
|
||||||
|
* enhanced composer
|
||||||
|
*/
|
||||||
class CustomSearchBox extends SearchBox {
|
class CustomSearchBox extends SearchBox {
|
||||||
buildComposer(context, container, options) {
|
buildComposer(context, container, options) {
|
||||||
return new CustomComposer(context, container, options);
|
return new CustomComposer(context, container, options);
|
||||||
|
@ -1,3 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview
|
||||||
|
* This file comprises a reference implementation
|
||||||
|
* of the `IOfferListComponent` interface called simply `OfferListComponent`
|
||||||
|
*/
|
||||||
|
|
||||||
import { attrValue, html, raw } from './util/html';
|
import { attrValue, html, raw } from './util/html';
|
||||||
import {
|
import {
|
||||||
IOfferListComponent,
|
IOfferListComponent,
|
||||||
@ -12,13 +18,29 @@ import {
|
|||||||
} from './interfaces/ISearchBoxComposer';
|
} from './interfaces/ISearchBoxComposer';
|
||||||
import { EventEmitter } from './util/EventEmitter';
|
import { EventEmitter } from './util/EventEmitter';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An `OfferListComponent` visualizes a list of short descriptions
|
||||||
|
* of offers (“previews”) and allows for interacting with it.
|
||||||
|
*
|
||||||
|
* The responsibility of this class is:
|
||||||
|
* * Rendering previews and react on the preview list change event
|
||||||
|
* * Allowing user to select a preview and emit the corresponding event
|
||||||
|
*/
|
||||||
export class OfferListComponent implements IOfferListComponent {
|
export class OfferListComponent implements IOfferListComponent {
|
||||||
|
/**
|
||||||
|
* An accessor to subscribe for events or emit them.
|
||||||
|
*/
|
||||||
public events: IEventEmitter<IOfferListComponentEvents> =
|
public events: IEventEmitter<IOfferListComponentEvents> =
|
||||||
new EventEmitter();
|
new EventEmitter();
|
||||||
|
/**
|
||||||
protected listenerDisposers: IDisposer[] = [];
|
* An inner state of the component, whether it's now
|
||||||
|
* rendered or not
|
||||||
|
*/
|
||||||
protected shown: boolean = false;
|
protected shown: boolean = false;
|
||||||
|
/**
|
||||||
|
* Event listeners
|
||||||
|
*/
|
||||||
|
private listenerDisposers: IDisposer[] = [];
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
protected readonly context: ISearchBoxComposer,
|
protected readonly context: ISearchBoxComposer,
|
||||||
@ -37,22 +59,37 @@ export class OfferListComponent implements IOfferListComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Provided for consistency for the developer
|
||||||
|
to have access to the full state
|
||||||
|
of the component */
|
||||||
public getOfferList() {
|
public getOfferList() {
|
||||||
return this.offerList;
|
return this.offerList;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destroys the component
|
||||||
|
*/
|
||||||
public destroy() {
|
public destroy() {
|
||||||
this.teardownListeners();
|
this.teardownListeners();
|
||||||
this.hide();
|
this.hide();
|
||||||
this.offerList = null;
|
this.offerList = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allows for programmatically selecting an
|
||||||
|
* offer in the list
|
||||||
|
*/
|
||||||
public selectOffer(offerId: string) {
|
public selectOffer(offerId: string) {
|
||||||
this.events.emit('offerSelect', {
|
this.events.emit('offerSelect', {
|
||||||
offerId
|
offerId
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An event handler for the context state change
|
||||||
|
* event. Exposed as a protected method to allow
|
||||||
|
* for altering the default reaction in subclasses
|
||||||
|
*/
|
||||||
protected onOfferListChange = ({
|
protected onOfferListChange = ({
|
||||||
offerList
|
offerList
|
||||||
}: IOfferPreviewListChangeEvent) => {
|
}: IOfferPreviewListChangeEvent) => {
|
||||||
@ -65,6 +102,47 @@ export class OfferListComponent implements IOfferListComponent {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A helper method to generate the DOM structure for
|
||||||
|
* displaying a preview. Exposed
|
||||||
|
*/
|
||||||
|
protected generateOfferHtml(offer: IOfferPreview): string {
|
||||||
|
return html`<li
|
||||||
|
class="our-coffee-sdk-offer-list-offer"
|
||||||
|
data-offer-id="${attrValue(offer.offerId)}"
|
||||||
|
>
|
||||||
|
<aside>${offer.price.formattedValue} ></aside>
|
||||||
|
${offer.imageUrl !== undefined
|
||||||
|
? html`<img src="${attrValue(offer.imageUrl)}" />`
|
||||||
|
: ''}
|
||||||
|
<h3>${offer.title}</h3>
|
||||||
|
<p>${offer.subtitle}</p>
|
||||||
|
<p>${offer.bottomLine}</p>
|
||||||
|
</li>`.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A listener to the DOM 'click' event. Exposed as a shortcut
|
||||||
|
* to allow for enriching the UX in subclasses.
|
||||||
|
* If we've taken long and 'proper' way, this should be
|
||||||
|
* a spearate composer to route events and data flow between
|
||||||
|
* the component and its representation.
|
||||||
|
*/
|
||||||
|
protected onClickListener = (e: MouseEvent) => {
|
||||||
|
let target = e.target;
|
||||||
|
while (target) {
|
||||||
|
const offerId = (<HTMLElement>target).dataset?.offerId;
|
||||||
|
if (offerId !== undefined) {
|
||||||
|
this.onOfferClick(offerId);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
target = (<HTMLElement>target).parentNode;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* A couple of helper methods to render the preview list
|
||||||
|
or to dispose the corresponding DOM structure. Exposed to allow
|
||||||
|
carrying out additional actions in subclasses if needed */
|
||||||
protected show() {
|
protected show() {
|
||||||
this.container.innerHTML = html`<ul class="our-coffee-sdk-offer-list">
|
this.container.innerHTML = html`<ul class="our-coffee-sdk-offer-list">
|
||||||
${this.offerList === null
|
${this.offerList === null
|
||||||
@ -83,49 +161,23 @@ export class OfferListComponent implements IOfferListComponent {
|
|||||||
this.shown = false;
|
this.shown = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected generateOfferHtml(offer: IOfferPreview): string {
|
/* Various methods to work with events */
|
||||||
return html`<li
|
private onOfferClick(offerId: string) {
|
||||||
class="our-coffee-sdk-offer-list-offer"
|
|
||||||
data-offer-id="${attrValue(offer.offerId)}"
|
|
||||||
>
|
|
||||||
<aside>${offer.price.formattedValue} ></aside>
|
|
||||||
${offer.imageUrl !== undefined
|
|
||||||
? html`<img src="${attrValue(offer.imageUrl)}" />`
|
|
||||||
: ''}
|
|
||||||
<h3>${offer.title}</h3>
|
|
||||||
<p>${offer.subtitle}</p>
|
|
||||||
<p>${offer.bottomLine}</p>
|
|
||||||
</li>`.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected onClickListener = (e: MouseEvent) => {
|
|
||||||
let target = e.target;
|
|
||||||
while (target) {
|
|
||||||
const offerId = (<HTMLElement>target).dataset?.offerId;
|
|
||||||
if (offerId !== undefined) {
|
|
||||||
this.onOfferClick(offerId);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
target = (<HTMLElement>target).parentNode;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
protected onOfferClick(offerId: string) {
|
|
||||||
this.selectOffer(offerId);
|
this.selectOffer(offerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected setupDomListeners() {
|
private setupDomListeners() {
|
||||||
this.container.addEventListener('click', this.onClickListener, false);
|
this.container.addEventListener('click', this.onClickListener, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected teardownListeners() {
|
private teardownListeners() {
|
||||||
for (const disposer of this.listenerDisposers) {
|
for (const disposer of this.listenerDisposers) {
|
||||||
disposer.off();
|
disposer.off();
|
||||||
}
|
}
|
||||||
this.listenerDisposers = [];
|
this.listenerDisposers = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
protected teardownDomListeners() {
|
private teardownDomListeners() {
|
||||||
this.container.removeEventListener(
|
this.container.removeEventListener(
|
||||||
'click',
|
'click',
|
||||||
this.onClickListener,
|
this.onClickListener,
|
||||||
|
@ -1,8 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview
|
||||||
|
* This file comprises a reference implementation
|
||||||
|
* of the `IButton` interface adapted for using with
|
||||||
|
* the `OfferPanelComponent` parent element
|
||||||
|
*/
|
||||||
|
|
||||||
import { IButton, IButtonEvents, IButtonOptions } from './interfaces/IButton';
|
import { IButton, IButtonEvents, IButtonOptions } from './interfaces/IButton';
|
||||||
import { IEventEmitter } from './interfaces/common';
|
import { IEventEmitter } from './interfaces/common';
|
||||||
import { EventEmitter } from './util/EventEmitter';
|
import { EventEmitter } from './util/EventEmitter';
|
||||||
import { $, attrValue, html, raw } from './util/html';
|
import { $, attrValue, html, raw } from './util/html';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays an UI element that represents a call to action
|
||||||
|
* for a user.
|
||||||
|
*
|
||||||
|
* The responsibility of this class is:
|
||||||
|
* * Rendering the corresponding UI
|
||||||
|
* * Emitting 'press' events
|
||||||
|
*/
|
||||||
export class OfferPanelButton implements IButton {
|
export class OfferPanelButton implements IButton {
|
||||||
public events: IEventEmitter<IButtonEvents> = new EventEmitter();
|
public events: IEventEmitter<IButtonEvents> = new EventEmitter();
|
||||||
|
|
||||||
|
@ -1,3 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview
|
||||||
|
* This file comprises a reference implementation
|
||||||
|
* of the `IOfferPanelComponent` interface called simply `OfferPanelComponent`
|
||||||
|
*/
|
||||||
|
|
||||||
import { omitUndefined } from '../test/util';
|
import { omitUndefined } from '../test/util';
|
||||||
import { CloseButton, CreateOrderButton } from './OfferPanelButton';
|
import { CloseButton, CreateOrderButton } from './OfferPanelButton';
|
||||||
import { IButton, IButtonPressEvent } from './interfaces/IButton';
|
import { IButton, IButtonPressEvent } from './interfaces/IButton';
|
||||||
@ -10,22 +16,51 @@ import {
|
|||||||
IOfferFullView,
|
IOfferFullView,
|
||||||
ISearchBoxComposer
|
ISearchBoxComposer
|
||||||
} from './interfaces/ISearchBoxComposer';
|
} from './interfaces/ISearchBoxComposer';
|
||||||
import { IDisposer, IEventEmitter, IExtraFields } from './interfaces/common';
|
import { IDisposer, IEventEmitter } from './interfaces/common';
|
||||||
import { EventEmitter } from './util/EventEmitter';
|
import { EventEmitter } from './util/EventEmitter';
|
||||||
import { $, html } from './util/html';
|
import { $, html } from './util/html';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A `OfferPanelComponent` represents a UI to display
|
||||||
|
* the detailed information regarding an offer (a “full view”)
|
||||||
|
* implying that user can act on the offer (for example,
|
||||||
|
* to create an order).
|
||||||
|
*
|
||||||
|
* The responsibility of the component is:
|
||||||
|
* * Displaying detailed information regarding an offer
|
||||||
|
* and update it if the corresponding context state
|
||||||
|
* change event happens
|
||||||
|
* * Rendering “buttons,” i.e. the control elements
|
||||||
|
* for user to express their intentions
|
||||||
|
* * Emitting “actions” when the user interacts with the buttons
|
||||||
|
* * Closing itself if needed
|
||||||
|
*/
|
||||||
export class OfferPanelComponent implements IOfferPanelComponent {
|
export class OfferPanelComponent implements IOfferPanelComponent {
|
||||||
|
/**
|
||||||
|
* An accessor to subscribe for events or emit them.
|
||||||
|
*/
|
||||||
public events: IEventEmitter<IOfferPanelComponentEvents> =
|
public events: IEventEmitter<IOfferPanelComponentEvents> =
|
||||||
new EventEmitter();
|
new EventEmitter();
|
||||||
|
/**
|
||||||
|
* A DOM element container for buttons
|
||||||
|
*/
|
||||||
protected buttonsContainer: HTMLElement | null = null;
|
protected buttonsContainer: HTMLElement | null = null;
|
||||||
|
/**
|
||||||
|
* An array of currently displayed buttons
|
||||||
|
*/
|
||||||
protected buttons: Array<{
|
protected buttons: Array<{
|
||||||
button: IButton;
|
button: IButton;
|
||||||
listenerDisposer: IDisposer;
|
listenerDisposer: IDisposer;
|
||||||
container: HTMLElement;
|
container: HTMLElement;
|
||||||
}> = [];
|
}> = [];
|
||||||
|
/**
|
||||||
|
* Event listeners
|
||||||
|
*/
|
||||||
protected listenerDisposers: IDisposer[] = [];
|
protected listenerDisposers: IDisposer[] = [];
|
||||||
|
/**
|
||||||
|
* An inner state of the component, whether it's open
|
||||||
|
* or closed
|
||||||
|
*/
|
||||||
protected shown: boolean = false;
|
protected shown: boolean = false;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@ -45,6 +80,10 @@ export class OfferPanelComponent implements IOfferPanelComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A static helper function to build a specific button
|
||||||
|
* for creating orders
|
||||||
|
*/
|
||||||
public static buildCreateOrderButton: ButtonBuilder = (
|
public static buildCreateOrderButton: ButtonBuilder = (
|
||||||
offer,
|
offer,
|
||||||
container,
|
container,
|
||||||
@ -58,6 +97,10 @@ export class OfferPanelComponent implements IOfferPanelComponent {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A static helper function to build a specific button
|
||||||
|
* for closing the panel
|
||||||
|
*/
|
||||||
public static buildCloseButton: ButtonBuilder = (
|
public static buildCloseButton: ButtonBuilder = (
|
||||||
offer,
|
offer,
|
||||||
container,
|
container,
|
||||||
@ -71,10 +114,14 @@ export class OfferPanelComponent implements IOfferPanelComponent {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/* Exposed for consistency */
|
||||||
public getOffer(): IOfferFullView | null {
|
public getOffer(): IOfferFullView | null {
|
||||||
return this.currentOffer;
|
return this.currentOffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destroys the panel and its buttons
|
||||||
|
*/
|
||||||
public destroy() {
|
public destroy() {
|
||||||
this.currentOffer = null;
|
this.currentOffer = null;
|
||||||
for (const disposer of this.listenerDisposers) {
|
for (const disposer of this.listenerDisposers) {
|
||||||
@ -85,6 +132,7 @@ export class OfferPanelComponent implements IOfferPanelComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A pair of helper methods to show and hide the panel */
|
||||||
protected show() {
|
protected show() {
|
||||||
this.container.innerHTML = html`<div class="our-coffee-sdk-offer-panel">
|
this.container.innerHTML = html`<div class="our-coffee-sdk-offer-panel">
|
||||||
<h1>${this.currentOffer.title}</h1>
|
<h1>${this.currentOffer.title}</h1>
|
||||||
@ -112,6 +160,11 @@ export class OfferPanelComponent implements IOfferPanelComponent {
|
|||||||
this.shown = false;
|
this.shown = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instantiates all buttons when a new offer is to be
|
||||||
|
* displayed. Exposed as a protected method to allow for
|
||||||
|
* an additional UX functionality in subclasses
|
||||||
|
*/
|
||||||
protected setupButtons() {
|
protected setupButtons() {
|
||||||
const buttonBuilders = this.options.buttonBuilders ?? [
|
const buttonBuilders = this.options.buttonBuilders ?? [
|
||||||
OfferPanelComponent.buildCreateOrderButton,
|
OfferPanelComponent.buildCreateOrderButton,
|
||||||
@ -133,6 +186,9 @@ export class OfferPanelComponent implements IOfferPanelComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destroys all buttons once the panel is hidden
|
||||||
|
*/
|
||||||
protected destroyButtons() {
|
protected destroyButtons() {
|
||||||
for (const { button, listenerDisposer, container } of this.buttons) {
|
for (const { button, listenerDisposer, container } of this.buttons) {
|
||||||
listenerDisposer.off();
|
listenerDisposer.off();
|
||||||
@ -142,6 +198,11 @@ export class OfferPanelComponent implements IOfferPanelComponent {
|
|||||||
this.buttons = [];
|
this.buttons = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A listener for the parent context's state change.
|
||||||
|
* Exposed as a protected method to allow for adding additional
|
||||||
|
* functionality
|
||||||
|
*/
|
||||||
protected onOfferFullViewToggle = ({ offer }) => {
|
protected onOfferFullViewToggle = ({ offer }) => {
|
||||||
if (this.shown) {
|
if (this.shown) {
|
||||||
this.hide();
|
this.hide();
|
||||||
@ -152,6 +213,11 @@ export class OfferPanelComponent implements IOfferPanelComponent {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A listener for button pressing events. Exposed
|
||||||
|
* as a protected method to allow for adding custom
|
||||||
|
* reactions
|
||||||
|
*/
|
||||||
protected onButtonPress = ({ target }: IButtonPressEvent) => {
|
protected onButtonPress = ({ target }: IButtonPressEvent) => {
|
||||||
if (this.currentOffer !== null) {
|
if (this.currentOffer !== null) {
|
||||||
this.events.emit('action', {
|
this.events.emit('action', {
|
||||||
@ -159,15 +225,25 @@ export class OfferPanelComponent implements IOfferPanelComponent {
|
|||||||
target,
|
target,
|
||||||
currentOfferId: this.currentOffer.offerId
|
currentOfferId: this.currentOffer.offerId
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
// TBD
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `OfferPanelComponent` options
|
||||||
|
*/
|
||||||
export interface OfferPanelComponentOptions
|
export interface OfferPanelComponentOptions
|
||||||
extends IOfferPanelComponentOptions {
|
extends IOfferPanelComponentOptions {
|
||||||
|
/**
|
||||||
|
* An array of factory methods to initialize
|
||||||
|
* buttons
|
||||||
|
*/
|
||||||
buttonBuilders?: ButtonBuilder[];
|
buttonBuilders?: ButtonBuilder[];
|
||||||
|
/**
|
||||||
|
* A UI options, whether an Offer Panel
|
||||||
|
* fully disables the interactivity of the
|
||||||
|
* underlying markup, or allows for interacting with it
|
||||||
|
*/
|
||||||
transparent?: boolean;
|
transparent?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,6 +1,10 @@
|
|||||||
import { SearchBoxComposer } from './SearchBoxComposer';
|
/**
|
||||||
|
* @fileoverview
|
||||||
|
* This file comprises a reference implementation
|
||||||
|
* of the `ISearchBox` interface called simply `SearchBox`
|
||||||
|
*/
|
||||||
|
|
||||||
import { $, html } from './util/html';
|
import { IDisposer, IEventEmitter } from './interfaces/common';
|
||||||
import { ICoffeeApi, INewOrder, ISearchResult } from './interfaces/ICoffeeApi';
|
import { ICoffeeApi, INewOrder, ISearchResult } from './interfaces/ICoffeeApi';
|
||||||
import {
|
import {
|
||||||
ISearchBox,
|
ISearchBox,
|
||||||
@ -8,23 +12,72 @@ import {
|
|||||||
ISearchBoxOptions
|
ISearchBoxOptions
|
||||||
} from './interfaces/ISearchBox';
|
} from './interfaces/ISearchBox';
|
||||||
import { ISearchBoxComposer } from './interfaces/ISearchBoxComposer';
|
import { ISearchBoxComposer } from './interfaces/ISearchBoxComposer';
|
||||||
import { IDisposer, IEventEmitter } from './interfaces/common';
|
|
||||||
import { EventEmitter } from './util/EventEmitter';
|
import { EventEmitter } from './util/EventEmitter';
|
||||||
|
import { SearchBoxComposer } from './SearchBoxComposer';
|
||||||
import { OfferPanelComponentOptions } from './OfferPanelComponent';
|
import { OfferPanelComponentOptions } from './OfferPanelComponent';
|
||||||
|
import { $, html } from './util/html';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A `SearchBox` represents a UI component
|
||||||
|
* that allows an end user to enter search queries,
|
||||||
|
* work with the received results and place orders.
|
||||||
|
* The user input which will be propagated
|
||||||
|
* to the underlying `ourCoffeeApi` functionality.
|
||||||
|
*
|
||||||
|
* The responsibility of this class is:
|
||||||
|
* * Handling user input consistently
|
||||||
|
* * Instantiating the `ISearchBoxComposer` subcomponent
|
||||||
|
* that takes care of the offer list UI & UX, and creating
|
||||||
|
* orders if a `composers` requests to.
|
||||||
|
* * Emitting events when current displayed search results
|
||||||
|
* (offers) are changed
|
||||||
|
* * Providing methods to programmatically initialize
|
||||||
|
* searching a given query and make orders
|
||||||
|
*/
|
||||||
export class SearchBox implements ISearchBox {
|
export class SearchBox implements ISearchBox {
|
||||||
|
/**
|
||||||
|
* An accessor to subscribe for events or emit them.
|
||||||
|
*/
|
||||||
public readonly events: IEventEmitter<ISearchBoxEvents> =
|
public readonly events: IEventEmitter<ISearchBoxEvents> =
|
||||||
new EventEmitter();
|
new EventEmitter();
|
||||||
|
/**
|
||||||
|
* The resolved options
|
||||||
|
*/
|
||||||
protected readonly options: SearchBoxOptions;
|
protected readonly options: SearchBoxOptions;
|
||||||
|
/**
|
||||||
|
* The instance of the search box composer
|
||||||
|
* that will handle presenting offers to the user
|
||||||
|
*/
|
||||||
protected readonly composer: ISearchBoxComposer;
|
protected readonly composer: ISearchBoxComposer;
|
||||||
|
/**
|
||||||
|
* The current list of search results (offers) to
|
||||||
|
* present to the user. Might be `null`.
|
||||||
|
*/
|
||||||
protected offerList: ISearchResult[] | null = null;
|
protected offerList: ISearchResult[] | null = null;
|
||||||
protected currentRequest: Promise<ISearchResult[]> | null = null;
|
/**
|
||||||
|
* The UI elements that are controlled by the `SearchBox` itself.
|
||||||
|
*/
|
||||||
protected searchButton: HTMLButtonElement;
|
protected searchButton: HTMLButtonElement;
|
||||||
protected input: HTMLInputElement;
|
protected input: HTMLInputElement;
|
||||||
protected layoutContainer: HTMLInputElement;
|
protected layoutContainer: HTMLInputElement;
|
||||||
protected listenerDisposers: IDisposer[] = [];
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A current asynchronous request to the search API (if any).
|
||||||
|
* Needed to manage a possible race if the user or
|
||||||
|
* the developer fires several search queries in a row.
|
||||||
|
*/
|
||||||
|
private currentRequest: Promise<ISearchResult[]> | null = null;
|
||||||
|
/**
|
||||||
|
* Event listeners to get disposed upon desructing the `SearchBox`
|
||||||
|
*/
|
||||||
|
private listenerDisposers: IDisposer[] = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A `SearchBox` synchoronously initializes itself
|
||||||
|
* in the given HTML element context and will use
|
||||||
|
* the given instance of the `ICoffeeApi` interface
|
||||||
|
* to run search queries and create orders.
|
||||||
|
*/
|
||||||
constructor(
|
constructor(
|
||||||
protected readonly container: HTMLElement,
|
protected readonly container: HTMLElement,
|
||||||
protected readonly coffeeApi: ICoffeeApi,
|
protected readonly coffeeApi: ICoffeeApi,
|
||||||
@ -46,6 +99,9 @@ export class SearchBox implements ISearchBox {
|
|||||||
this.setupListeners();
|
this.setupListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* These three methods are provided for consistency
|
||||||
|
for the developer to have access to the full state
|
||||||
|
of the `SearchBox` entity */
|
||||||
public getOptions() {
|
public getOptions() {
|
||||||
return this.options;
|
return this.options;
|
||||||
}
|
}
|
||||||
@ -54,6 +110,15 @@ export class SearchBox implements ISearchBox {
|
|||||||
return this.container;
|
return this.container;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getOfferList() {
|
||||||
|
return this.offerList;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performs searching of offers and reflects this
|
||||||
|
* operation in the UI
|
||||||
|
* @param {string} rawQuery Raw unsanitized input
|
||||||
|
*/
|
||||||
public async search(rawQuery: string): Promise<void> {
|
public async search(rawQuery: string): Promise<void> {
|
||||||
// Shall empty queries be allowed?
|
// Shall empty queries be allowed?
|
||||||
// As it's an API method, it might make sense
|
// As it's an API method, it might make sense
|
||||||
@ -71,14 +136,16 @@ export class SearchBox implements ISearchBox {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public getOfferList() {
|
/**
|
||||||
return this.offerList;
|
* Creates an order based on the offer.
|
||||||
}
|
*/
|
||||||
|
|
||||||
public createOrder(parameters: { offerId: string }): Promise<INewOrder> {
|
public createOrder(parameters: { offerId: string }): Promise<INewOrder> {
|
||||||
return this.coffeeApi.createOrder(parameters);
|
return this.coffeeApi.createOrder(parameters);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destroys the `SearchBox` and all its subcomponents
|
||||||
|
*/
|
||||||
public destroy() {
|
public destroy() {
|
||||||
this.teardownListeners();
|
this.teardownListeners();
|
||||||
this.composer.destroy();
|
this.composer.destroy();
|
||||||
@ -86,19 +153,59 @@ export class SearchBox implements ISearchBox {
|
|||||||
this.currentRequest = this.offerList = null;
|
this.currentRequest = this.offerList = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public buildComposer(
|
/**
|
||||||
context: SearchBox,
|
* Default options of the `SearchBox` component
|
||||||
container: HTMLElement,
|
*/
|
||||||
options: SearchBoxOptions
|
|
||||||
): ISearchBoxComposer {
|
|
||||||
return new SearchBoxComposer(context, container, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static DEFAULT_OPTIONS: SearchBoxOptions = {
|
public static DEFAULT_OPTIONS: SearchBoxOptions = {
|
||||||
searchButtonText: 'Search'
|
searchButtonText: 'Search'
|
||||||
};
|
};
|
||||||
|
|
||||||
protected render() {
|
/**
|
||||||
|
* Factory method to create a composer.
|
||||||
|
* Exposed as a protected method to allow
|
||||||
|
* instantiating custom composers.
|
||||||
|
* @param {ISearchBox} context Parent search box
|
||||||
|
* @param {HTMLElement} container An HTML Element
|
||||||
|
* container prepared for rendering the UI
|
||||||
|
* @param {ISearchBoxOptions} options Parent options
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
protected buildComposer(
|
||||||
|
context: ISearchBox,
|
||||||
|
container: HTMLElement,
|
||||||
|
options: ISearchBoxOptions
|
||||||
|
): ISearchBoxComposer {
|
||||||
|
return new SearchBoxComposer(context, container, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The internal implementation of setting a new
|
||||||
|
* offer list after a search is performed.
|
||||||
|
* Provided as a protected method to allow for custom
|
||||||
|
* search result list modifications in a subclass.
|
||||||
|
*/
|
||||||
|
protected setOfferList(offerList: ISearchResult[] | null) {
|
||||||
|
if (this.offerList !== offerList) {
|
||||||
|
this.offerList = offerList;
|
||||||
|
this.events.emit('offerListChange', { offerList });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Handling a 'Search' button press event. Provided as
|
||||||
|
* a protected method to allow custom validations
|
||||||
|
* or alternative inputs
|
||||||
|
*/
|
||||||
|
protected onSearchButtonClickListener = () => {
|
||||||
|
const query = this.input.value.trim();
|
||||||
|
if (query) {
|
||||||
|
this.search(query);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rendering HTML markup of the composer
|
||||||
|
*/
|
||||||
|
private render() {
|
||||||
this.container.innerHTML = html`<div class="our-coffee-sdk-search-box">
|
this.container.innerHTML = html`<div class="our-coffee-sdk-search-box">
|
||||||
<div class="our-coffee-sdk-search-box-head">
|
<div class="our-coffee-sdk-search-box-head">
|
||||||
<input type="text" class="our-coffee-sdk-search-box-input" />
|
<input type="text" class="our-coffee-sdk-search-box-input" />
|
||||||
@ -119,13 +226,10 @@ export class SearchBox implements ISearchBox {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected onSearchButtonClickListener = () => {
|
/**
|
||||||
const query = this.input.value.trim();
|
* Working with various events
|
||||||
if (query) {
|
*/
|
||||||
this.search(query);
|
private setupListeners() {
|
||||||
}
|
|
||||||
};
|
|
||||||
protected setupListeners() {
|
|
||||||
this.searchButton.addEventListener(
|
this.searchButton.addEventListener(
|
||||||
'click',
|
'click',
|
||||||
this.onSearchButtonClickListener,
|
this.onSearchButtonClickListener,
|
||||||
@ -137,7 +241,8 @@ export class SearchBox implements ISearchBox {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
protected teardownListeners() {
|
|
||||||
|
private teardownListeners() {
|
||||||
for (const disposer of this.listenerDisposers) {
|
for (const disposer of this.listenerDisposers) {
|
||||||
disposer.off();
|
disposer.off();
|
||||||
}
|
}
|
||||||
@ -148,20 +253,9 @@ export class SearchBox implements ISearchBox {
|
|||||||
false
|
false
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected setOfferList(offerList: ISearchResult[] | null) {
|
|
||||||
if (this.offerList !== offerList) {
|
|
||||||
this.offerList = offerList;
|
|
||||||
this.events.emit('offerListChange', { offerList });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SearchBoxOptions extends ISearchBoxOptions {
|
export interface SearchBoxOptions extends ISearchBoxOptions {
|
||||||
searchButtonText: string;
|
searchButtonText: string;
|
||||||
offerPanel?: Partial<OfferPanelComponentOptions>;
|
offerPanel?: Partial<OfferPanelComponentOptions>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SearchBoxComposerBuilder = (
|
|
||||||
context: SearchBox
|
|
||||||
) => ISearchBoxComposer;
|
|
||||||
|
@ -1,3 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview
|
||||||
|
* This file comprises a reference implementation
|
||||||
|
* of the `ISearchBoxComposer` interface called simply `SearchBoxComposer`
|
||||||
|
*/
|
||||||
|
|
||||||
import { ISearchResult } from './interfaces/ICoffeeApi';
|
import { ISearchResult } from './interfaces/ICoffeeApi';
|
||||||
import {
|
import {
|
||||||
ISearchBox,
|
ISearchBox,
|
||||||
@ -26,21 +32,64 @@ import { OfferListComponent } from './OfferListComponent';
|
|||||||
import { OfferPanelComponent } from './OfferPanelComponent';
|
import { OfferPanelComponent } from './OfferPanelComponent';
|
||||||
import { EventEmitter } from './util/EventEmitter';
|
import { EventEmitter } from './util/EventEmitter';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A `SearchBoxComposer` stands for an entity which
|
||||||
|
* controls the data flow between an abstract `ISearchBox`
|
||||||
|
* and a specific UI concept.
|
||||||
|
*
|
||||||
|
* This reference implementation assumes that each offer
|
||||||
|
* might be represented as a list item (a 'preview') and
|
||||||
|
* as a detailed representation (a `full view`).
|
||||||
|
*
|
||||||
|
* The responsibility of the composer is:
|
||||||
|
* * Instantiating and destroying nested components
|
||||||
|
* that handles previews (`IOfferListComponent`) and
|
||||||
|
* a full view (`IOfferPanelComponent`)
|
||||||
|
* * Handling an internal state (a list of offers and
|
||||||
|
* a currently selected offer) and emitting events when
|
||||||
|
* it changes
|
||||||
|
* * Generating previews, full views, and UI options when
|
||||||
|
* needed
|
||||||
|
* * Notifying parent `ISearchBox` about the user's intention
|
||||||
|
* to place an order
|
||||||
|
*/
|
||||||
export class SearchBoxComposer<ExtraOptions extends IExtraFields = {}>
|
export class SearchBoxComposer<ExtraOptions extends IExtraFields = {}>
|
||||||
implements ISearchBoxComposer
|
implements ISearchBoxComposer
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* An accessor to subscribe for events or emit them.
|
||||||
|
*/
|
||||||
public events: IEventEmitter<ISearchBoxComposerEvents> =
|
public events: IEventEmitter<ISearchBoxComposerEvents> =
|
||||||
new EventEmitter<ISearchBoxComposerEvents>();
|
new EventEmitter<ISearchBoxComposerEvents>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instances of subcomponents and HTML element containers
|
||||||
|
* to host them
|
||||||
|
*/
|
||||||
protected offerListContainer: HTMLElement | null = null;
|
protected offerListContainer: HTMLElement | null = null;
|
||||||
protected offerListComponent: IOfferListComponent | null = null;
|
protected offerListComponent: IOfferListComponent | null = null;
|
||||||
protected offerPanelContainer: HTMLElement | null = null;
|
protected offerPanelContainer: HTMLElement | null = null;
|
||||||
protected offerPanelComponent: IOfferPanelComponent | null = null;
|
protected offerPanelComponent: IOfferPanelComponent | null = null;
|
||||||
|
/**
|
||||||
|
* A current state of the composer itself
|
||||||
|
*/
|
||||||
protected offerList: ISearchResult[] | null = null;
|
protected offerList: ISearchResult[] | null = null;
|
||||||
protected currentOffer: ISearchResult | null = null;
|
protected currentOffer: ISearchResult | null = null;
|
||||||
|
|
||||||
protected listenerDisposers: IDisposer[];
|
/**
|
||||||
|
* Event listeners
|
||||||
|
*/
|
||||||
|
private onOfferPanelAction = (event: IOfferPanelActionEvent) => {
|
||||||
|
this.performAction(event);
|
||||||
|
};
|
||||||
|
private onOfferListOfferSelect = ({ offerId }: IOfferSelectedEvent) =>
|
||||||
|
this.selectOffer(offerId);
|
||||||
|
private listenerDisposers: IDisposer[];
|
||||||
|
/**
|
||||||
|
* A `SearchBoxComposer` synchoronously initializes itself
|
||||||
|
* in the context of the given `SearchBox` with provided
|
||||||
|
* options and HTML container element.
|
||||||
|
*/
|
||||||
constructor(
|
constructor(
|
||||||
protected readonly context: ISearchBox,
|
protected readonly context: ISearchBox,
|
||||||
protected readonly container: HTMLElement,
|
protected readonly container: HTMLElement,
|
||||||
@ -76,6 +125,9 @@ export class SearchBoxComposer<ExtraOptions extends IExtraFields = {}>
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allows for searching for a displayed offer
|
||||||
|
*/
|
||||||
public findOfferById(offerIdToFind: string): ISearchResult | null {
|
public findOfferById(offerIdToFind: string): ISearchResult | null {
|
||||||
// Theoretically, we could have built a `Map`
|
// Theoretically, we could have built a `Map`
|
||||||
// for quickly searching offers by their id
|
// for quickly searching offers by their id
|
||||||
@ -87,6 +139,10 @@ export class SearchBoxComposer<ExtraOptions extends IExtraFields = {}>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exposed publicly to allow developers to programmatically
|
||||||
|
* select an offer (which typically implies opening an offer panel)
|
||||||
|
*/
|
||||||
public selectOffer(offerId: string) {
|
public selectOffer(offerId: string) {
|
||||||
const offer = this.findOfferById(offerId);
|
const offer = this.findOfferById(offerId);
|
||||||
// Offer may be missing for a variety of reasons,
|
// Offer may be missing for a variety of reasons,
|
||||||
@ -105,6 +161,12 @@ export class SearchBoxComposer<ExtraOptions extends IExtraFields = {}>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exposed publicly to allow programmatically
|
||||||
|
* performing actions the composer is capable of,
|
||||||
|
* i.e., creating an order or closing the offer panel,
|
||||||
|
* or to add new actions in subclasses
|
||||||
|
*/
|
||||||
public performAction({
|
public performAction({
|
||||||
action,
|
action,
|
||||||
currentOfferId: offerId
|
currentOfferId: offerId
|
||||||
@ -122,6 +184,9 @@ export class SearchBoxComposer<ExtraOptions extends IExtraFields = {}>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exposed publicly as a helper function
|
||||||
|
*/
|
||||||
public createOrder(offerId: string) {
|
public createOrder(offerId: string) {
|
||||||
const offer = this.findOfferById(offerId);
|
const offer = this.findOfferById(offerId);
|
||||||
// Offer may be missing if `OfferPanelComponent`
|
// Offer may be missing if `OfferPanelComponent`
|
||||||
@ -131,6 +196,9 @@ export class SearchBoxComposer<ExtraOptions extends IExtraFields = {}>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destroys the composer and all its subcomponents
|
||||||
|
*/
|
||||||
public destroy() {
|
public destroy() {
|
||||||
for (const disposer of this.listenerDisposers) {
|
for (const disposer of this.listenerDisposers) {
|
||||||
disposer.off();
|
disposer.off();
|
||||||
@ -146,6 +214,13 @@ export class SearchBoxComposer<ExtraOptions extends IExtraFields = {}>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The event subscriber for the parent context `offerListChange`
|
||||||
|
* event. Transforms the high-level event into a couple of lover-level
|
||||||
|
* ones and maintaints the composer's internal state.
|
||||||
|
* Exposed as a protected method to allow custom reactions
|
||||||
|
* to parent context state change in subclasses.
|
||||||
|
*/
|
||||||
protected onContextOfferListChange = ({
|
protected onContextOfferListChange = ({
|
||||||
offerList
|
offerList
|
||||||
}: ISearchBoxOfferListChangeEvent) => {
|
}: ISearchBoxOfferListChangeEvent) => {
|
||||||
@ -164,13 +239,11 @@ export class SearchBoxComposer<ExtraOptions extends IExtraFields = {}>
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
protected onOfferPanelAction = (event: IOfferPanelActionEvent) => {
|
/**
|
||||||
this.performAction(event);
|
* A factory method to build an instance of an offer list
|
||||||
};
|
* sub-component. Exposed as a protected method to allow
|
||||||
|
* custom implementations of an offer list in subclasses
|
||||||
protected onOfferListOfferSelect = ({ offerId }: IOfferSelectedEvent) =>
|
*/
|
||||||
this.selectOffer(offerId);
|
|
||||||
|
|
||||||
protected buildOfferListComponent(
|
protected buildOfferListComponent(
|
||||||
context: ISearchBoxComposer,
|
context: ISearchBoxComposer,
|
||||||
container: HTMLElement,
|
container: HTMLElement,
|
||||||
@ -185,6 +258,11 @@ export class SearchBoxComposer<ExtraOptions extends IExtraFields = {}>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A helper to generate “preview” data for the offer list component.
|
||||||
|
* Exposed as a protected method to allow enriching preview data
|
||||||
|
* with custom fields in subclasses.
|
||||||
|
*/
|
||||||
protected generateOfferPreviews(
|
protected generateOfferPreviews(
|
||||||
offerList: ISearchResult[] | null,
|
offerList: ISearchResult[] | null,
|
||||||
contextOptions: ISearchBoxOptions & ExtraOptions
|
contextOptions: ISearchBoxOptions & ExtraOptions
|
||||||
@ -196,16 +274,27 @@ export class SearchBoxComposer<ExtraOptions extends IExtraFields = {}>
|
|||||||
title: offer.place.title,
|
title: offer.place.title,
|
||||||
subtitle: offer.recipe.shortDescription,
|
subtitle: offer.recipe.shortDescription,
|
||||||
price: offer.price,
|
price: offer.price,
|
||||||
bottomLine: this.generateOfferBottomLine(offer)
|
bottomLine: SearchBoxComposer.generateOfferBottomLine(offer)
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A helper to translate context options (i.e., the options of the
|
||||||
|
* parent `ISearchBox`) into the options of the offer list subcomponent.
|
||||||
|
* Exposed as a protected method to allow for an additional logic of
|
||||||
|
* generating options or passing extra options in subclasses
|
||||||
|
*/
|
||||||
protected generateOfferListComponentOptions(
|
protected generateOfferListComponentOptions(
|
||||||
options: ISearchBoxOptions
|
options: ISearchBoxOptions
|
||||||
): IOfferListComponentOptions {
|
): IOfferListComponentOptions {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A factory method to build an instance of an offer panel
|
||||||
|
* sub-component. Exposed as a protected method to allow
|
||||||
|
* custom implementations of an offer panel in subclasses
|
||||||
|
*/
|
||||||
protected buildOfferPanelComponent(
|
protected buildOfferPanelComponent(
|
||||||
context: ISearchBoxComposer,
|
context: ISearchBoxComposer,
|
||||||
container: HTMLElement,
|
container: HTMLElement,
|
||||||
@ -220,6 +309,11 @@ export class SearchBoxComposer<ExtraOptions extends IExtraFields = {}>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A helper to generate “full view” data for the offer panel component.
|
||||||
|
* Exposed as a protected method to allow enriching full view data
|
||||||
|
* with custom fields in subclasses.
|
||||||
|
*/
|
||||||
protected generateCurrentOfferFullView(
|
protected generateCurrentOfferFullView(
|
||||||
offer: ISearchResult | null,
|
offer: ISearchResult | null,
|
||||||
contextOptions: ISearchBoxOptions & ExtraOptions
|
contextOptions: ISearchBoxOptions & ExtraOptions
|
||||||
@ -231,19 +325,28 @@ export class SearchBoxComposer<ExtraOptions extends IExtraFields = {}>
|
|||||||
title: offer.place.title,
|
title: offer.place.title,
|
||||||
description: [
|
description: [
|
||||||
offer.recipe.mediumDescription,
|
offer.recipe.mediumDescription,
|
||||||
this.generateOfferBottomLine(offer)
|
SearchBoxComposer.generateOfferBottomLine(offer)
|
||||||
],
|
],
|
||||||
price: offer.price
|
price: offer.price
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A helper to translate context options (i.e., the options of the
|
||||||
|
* parent `ISearchBox`) into the options of the offer panel subcomponent.
|
||||||
|
* Exposed as a protected method to allow for an additional logic of
|
||||||
|
* generating options or passing extra options in subclasses
|
||||||
|
*/
|
||||||
protected generateOfferPanelComponentOptions(
|
protected generateOfferPanelComponentOptions(
|
||||||
options: ISearchBoxOptions & ExtraOptions
|
options: ISearchBoxOptions & ExtraOptions
|
||||||
): IOfferPanelComponentOptions {
|
): IOfferPanelComponentOptions {
|
||||||
return options.offerPanel ?? {};
|
return options.offerPanel ?? {};
|
||||||
}
|
}
|
||||||
|
|
||||||
protected generateOfferBottomLine(offer: ISearchResult): string {
|
/**
|
||||||
|
* A small helper method to generate “bottomlines” for offers
|
||||||
|
*/
|
||||||
|
public static generateOfferBottomLine(offer: ISearchResult): string {
|
||||||
return offer.place.walkingDistance.numericValueMeters >= 100
|
return offer.place.walkingDistance.numericValueMeters >= 100
|
||||||
? `${offer.place.walkTime.formattedValue} · ${offer.place.walkingDistance.formattedValue}`
|
? `${offer.place.walkTime.formattedValue} · ${offer.place.walkingDistance.formattedValue}`
|
||||||
: 'Just around the corner';
|
: 'Just around the corner';
|
||||||
|
@ -1,5 +1,9 @@
|
|||||||
import { IEventEmitter } from './common';
|
import { IEventEmitter } from './common';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An interface of a “button” — a UI control
|
||||||
|
* to represent a call to action.
|
||||||
|
*/
|
||||||
export interface IButton {
|
export interface IButton {
|
||||||
action: string;
|
action: string;
|
||||||
events: IEventEmitter<IButtonEvents>;
|
events: IEventEmitter<IButtonEvents>;
|
||||||
|
@ -5,11 +5,21 @@ import {
|
|||||||
ILocation
|
ILocation
|
||||||
} from './common';
|
} from './common';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An interface for a low-level API “wrapper”.
|
||||||
|
* Allows for:
|
||||||
|
* * Searching offers by a query
|
||||||
|
* * Creating an order by an offer
|
||||||
|
*/
|
||||||
export interface ICoffeeApi {
|
export interface ICoffeeApi {
|
||||||
search: (query: string) => Promise<ISearchResult[]>;
|
search: (query: string) => Promise<ISearchResult[]>;
|
||||||
createOrder: (parameters: { offerId: string }) => Promise<INewOrder>;
|
createOrder: (parameters: { offerId: string }) => Promise<INewOrder>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A specific search result represeting
|
||||||
|
* detailed information about an offer
|
||||||
|
*/
|
||||||
export interface ISearchResult {
|
export interface ISearchResult {
|
||||||
offerId: string;
|
offerId: string;
|
||||||
recipe: {
|
recipe: {
|
||||||
@ -28,6 +38,9 @@ export interface ISearchResult {
|
|||||||
price: IFormattedPrice;
|
price: IFormattedPrice;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A dummy interface for a newly created order
|
||||||
|
*/
|
||||||
export interface INewOrder {
|
export interface INewOrder {
|
||||||
orderId: string;
|
orderId: string;
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,10 @@
|
|||||||
import { IEventEmitter } from './common';
|
import { IEventEmitter } from './common';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An interface for an abstract component
|
||||||
|
* that displays a list of offers and allows
|
||||||
|
* for selecting an offer
|
||||||
|
*/
|
||||||
export interface IOfferListComponent {
|
export interface IOfferListComponent {
|
||||||
events: IEventEmitter<IOfferListComponentEvents>;
|
events: IEventEmitter<IOfferListComponentEvents>;
|
||||||
destroy: () => void;
|
destroy: () => void;
|
||||||
|
@ -1,5 +1,11 @@
|
|||||||
import { IEventEmitter } from './common';
|
import { IEventEmitter } from './common';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An interface for an abstract component
|
||||||
|
* that displays a detailed data about an
|
||||||
|
* offer and allows the user to interact
|
||||||
|
* with it by emitting action events
|
||||||
|
*/
|
||||||
export interface IOfferPanelComponent {
|
export interface IOfferPanelComponent {
|
||||||
events: IEventEmitter<IOfferPanelComponentEvents>;
|
events: IEventEmitter<IOfferPanelComponentEvents>;
|
||||||
destroy: () => void;
|
destroy: () => void;
|
||||||
|
@ -1,6 +1,12 @@
|
|||||||
import { INewOrder, ISearchResult } from './ICoffeeApi';
|
import { INewOrder, ISearchResult } from './ICoffeeApi';
|
||||||
import { IEventEmitter } from './common';
|
import { IEventEmitter } from './common';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An interface for an abstract component
|
||||||
|
* that allows the user or the developer to enter
|
||||||
|
* a search phrase and interact with the search results,
|
||||||
|
* including creating an order
|
||||||
|
*/
|
||||||
export interface ISearchBox {
|
export interface ISearchBox {
|
||||||
events: IEventEmitter<ISearchBoxEvents>;
|
events: IEventEmitter<ISearchBoxEvents>;
|
||||||
search: (query: string) => void;
|
search: (query: string) => void;
|
||||||
|
@ -1,8 +1,21 @@
|
|||||||
import { ISearchResult } from './ICoffeeApi';
|
import { ISearchResult } from './ICoffeeApi';
|
||||||
import { IEventEmitter, IFormattedPrice } from './common';
|
import { IEventEmitter, IFormattedPrice } from './common';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An interface for an abstract “composer” to serve
|
||||||
|
* as a bridge between a `SearchBox` and its customizable
|
||||||
|
* representation.
|
||||||
|
*
|
||||||
|
* A `composer` is stateful, implying that it somehow stores
|
||||||
|
* the offers being displayed.
|
||||||
|
*/
|
||||||
export interface ISearchBoxComposer {
|
export interface ISearchBoxComposer {
|
||||||
events: IEventEmitter<ISearchBoxComposerEvents>;
|
events: IEventEmitter<ISearchBoxComposerEvents>;
|
||||||
|
/**
|
||||||
|
* An accessor to the internal state that allows for
|
||||||
|
* querying it but not exposes the details regarding
|
||||||
|
* how the data is stored.
|
||||||
|
*/
|
||||||
findOfferById: (offerId: string) => ISearchResult | null;
|
findOfferById: (offerId: string) => ISearchResult | null;
|
||||||
destroy: () => void;
|
destroy: () => void;
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview
|
||||||
|
* Various interfaces for representing common data
|
||||||
|
*/
|
||||||
|
|
||||||
export interface IFormattedPrice {
|
export interface IFormattedPrice {
|
||||||
decimalValue: string;
|
decimalValue: string;
|
||||||
formattedValue: string;
|
formattedValue: string;
|
||||||
|
@ -1,5 +1,8 @@
|
|||||||
import { IDisposer, IEventEmitter } from '../interfaces/common';
|
import { IDisposer, IEventEmitter } from '../interfaces/common';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A helper class to subscribe for events and emit them
|
||||||
|
*/
|
||||||
export class EventEmitter<EventList extends Record<string, any>>
|
export class EventEmitter<EventList extends Record<string, any>>
|
||||||
implements IEventEmitter<EventList>
|
implements IEventEmitter<EventList>
|
||||||
{
|
{
|
||||||
@ -23,6 +26,10 @@ export class EventEmitter<EventList extends Record<string, any>>
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribes for an event
|
||||||
|
* @returns a `Disposer` which allows to unsubscribe
|
||||||
|
*/
|
||||||
public on<Type extends Extract<keyof EventList, string>>(
|
public on<Type extends Extract<keyof EventList, string>>(
|
||||||
type: Type,
|
type: Type,
|
||||||
callback: (event: EventList[Type]) => void
|
callback: (event: EventList[Type]) => void
|
||||||
@ -40,6 +47,10 @@ export class EventEmitter<EventList extends Record<string, any>>
|
|||||||
return disposer;
|
return disposer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emits an event, i.e., call all the subscribers for the
|
||||||
|
* specified event type
|
||||||
|
*/
|
||||||
public emit<Type extends Extract<keyof EventList, string>>(
|
public emit<Type extends Extract<keyof EventList, string>>(
|
||||||
type: Type,
|
type: Type,
|
||||||
event: EventList[Type]
|
event: EventList[Type]
|
||||||
|
@ -1,3 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* Helper function to find a single HTML element
|
||||||
|
* matching a selector or fail
|
||||||
|
*/
|
||||||
export function $<T extends Element>(
|
export function $<T extends Element>(
|
||||||
...args: [HTMLElement, string] | [string]
|
...args: [HTMLElement, string] | [string]
|
||||||
): T {
|
): T {
|
||||||
@ -78,7 +82,10 @@ export function hrefEscapeBuilder(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const httpHrefEscape = hrefEscapeBuilder();
|
export const httpHrefEscape = hrefEscapeBuilder();
|
||||||
|
/**
|
||||||
|
* Template function to safely render HTML templates
|
||||||
|
* and automatically escape substituted value
|
||||||
|
*/
|
||||||
export const html = makeTemplate(htmlEscape);
|
export const html = makeTemplate(htmlEscape);
|
||||||
export const raw = (str: string) => new HtmlSerializable(str);
|
export const raw = (str: string) => new HtmlSerializable(str);
|
||||||
export const attr = makeTemplate(attrEscape);
|
export const attr = makeTemplate(attrEscape);
|
||||||
|
@ -1,3 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview
|
||||||
|
* A dummy implementation of the coffee API. Always emits
|
||||||
|
* the predefined array of results
|
||||||
|
*/
|
||||||
|
|
||||||
import { ICoffeeApi, INewOrder } from '../../src/interfaces/ICoffeeApi';
|
import { ICoffeeApi, INewOrder } from '../../src/interfaces/ICoffeeApi';
|
||||||
import {
|
import {
|
||||||
IOfferFullView,
|
IOfferFullView,
|
||||||
@ -128,6 +134,14 @@ export const DUMMY_ORDER = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const dummyCoffeeApi: ICoffeeApi = {
|
export const dummyCoffeeApi: ICoffeeApi = {
|
||||||
search: async () => [...DUMMY_RESPONSE],
|
search: async () => timeouted([...DUMMY_RESPONSE], 300),
|
||||||
createOrder: async (): Promise<INewOrder> => DUMMY_ORDER
|
createOrder: async (): Promise<INewOrder> => timeouted(DUMMY_ORDER, 300)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function timeouted<T>(result: T, timeoutMs: number): Promise<T> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
resolve(result);
|
||||||
|
}, timeoutMs);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
@ -1,3 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview
|
||||||
|
* A dummy implementation of map component.
|
||||||
|
* Shows a statical picture
|
||||||
|
*/
|
||||||
import { ILocation } from '../../src/interfaces/common';
|
import { ILocation } from '../../src/interfaces/common';
|
||||||
|
|
||||||
export class DummyMapApi {
|
export class DummyMapApi {
|
||||||
|
@ -1,3 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview
|
||||||
|
* Various helpers for testing the components
|
||||||
|
*/
|
||||||
|
|
||||||
import { IEventEmitter } from '../src/interfaces/common';
|
import { IEventEmitter } from '../src/interfaces/common';
|
||||||
|
|
||||||
export async function waitForEvents<
|
export async function waitForEvents<
|
||||||
|
Before Width: | Height: | Size: 651 KiB After Width: | Height: | Size: 325 KiB |
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 62 KiB |
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 88 KiB |
BIN
src/img/mockups/08.png
Normal file
After Width: | Height: | Size: 49 KiB |
@ -14,7 +14,9 @@
|
|||||||
2. Комбинирование краткого и полного описания предложения в одном интерфейсе (предложение можно развернуть прямо в списке и сразу сделать заказ)
|
2. Комбинирование краткого и полного описания предложения в одном интерфейсе (предложение можно развернуть прямо в списке и сразу сделать заказ)
|
||||||
* иллюстрирует проблему полного удаления одного из субкомпонентов при сохранении бизнес-логики и UX:
|
* иллюстрирует проблему полного удаления одного из субкомпонентов при сохранении бизнес-логики и UX:
|
||||||
|
|
||||||
[]()
|
[]()
|
||||||
|
|
||||||
|
[]()
|
||||||
|
|
||||||
3. Манипуляция данными и доступными действиями для предложения через добавление новых кнопок (вперёд, назад, позвонить) и управление их содержимым. Отметим, что каждая из кнопок предоставляет свою проблему с точки зрения имплементации:
|
3. Манипуляция данными и доступными действиями для предложения через добавление новых кнопок (вперёд, назад, позвонить) и управление их содержимым. Отметим, что каждая из кнопок предоставляет свою проблему с точки зрения имплементации:
|
||||||
* кнопки навигации (вперёд/назад) требуют, чтобы информация о связности списка (есть предыдущий / следующий заказ) каким-то образом дошла до панели показа предложения;
|
* кнопки навигации (вперёд/назад) требуют, чтобы информация о связности списка (есть предыдущий / следующий заказ) каким-то образом дошла до панели показа предложения;
|
||||||
@ -22,7 +24,7 @@
|
|||||||
|
|
||||||
Пример иллюстрирует проблемы неоднозначности иерархий наследования и сильной связности компонентов;
|
Пример иллюстрирует проблемы неоднозначности иерархий наследования и сильной связности компонентов;
|
||||||
|
|
||||||
[]()
|
[]()
|
||||||
|
|
||||||
Решение, которое напрашивается в данной ситуации — это выделение двух дополнительных компонентов, отвечающих за представление списка предложений и за панель показа конкретного предложения, назовём их OfferList и OfferPanel. В случае отсутствия требований кастомизации, псевдокод, имплементирующий взаимодействие всех трёх компонентов, выглядел бы достаточно просто:
|
Решение, которое напрашивается в данной ситуации — это выделение двух дополнительных компонентов, отвечающих за представление списка предложений и за панель показа конкретного предложения, назовём их OfferList и OfferPanel. В случае отсутствия требований кастомизации, псевдокод, имплементирующий взаимодействие всех трёх компонентов, выглядел бы достаточно просто:
|
||||||
|
|
||||||
@ -126,7 +128,7 @@ class OfferPanel implements IOfferPanel {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Интерфейсы `ISearchBox` / `IOfferPanel` / `IOfferView` также очень просты (контрукторы и деструкторы опущены):
|
Интерфейсы `ISearchBox` / `IOfferPanel` / `IOfferView` также очень просты (конструкторы и деструкторы опущены):
|
||||||
```
|
```
|
||||||
interface ISearchBox {
|
interface ISearchBox {
|
||||||
search(query);
|
search(query);
|
||||||
@ -142,7 +144,7 @@ interface IOfferPanel {
|
|||||||
|
|
||||||
Если бы мы не разрабатывали SDK и у нас не было бы задачи разрешать кастомизацию этих компонентов, подобная реализация была бы стопроцентно уместной. Попробуем, однако, представить, как мы будем решать описанные выше задачи:
|
Если бы мы не разрабатывали SDK и у нас не было бы задачи разрешать кастомизацию этих компонентов, подобная реализация была бы стопроцентно уместной. Попробуем, однако, представить, как мы будем решать описанные выше задачи:
|
||||||
|
|
||||||
1. Показ списка предложений на карте. На первый взгляд, мы можем разработать альтернативный компонент показа списка предложений, скажем, `OfferMap`, который сможет использовать стандартную панель предложений. Но у нас есть одна проблема: если `OfferList` только отправляет команды для `OfferPanel`, то `OfferMap` должен ещё и получать обратную связь — событие закрытия панели, чтобы убрать выделение с метки. Наш интерфейс подобного не предусматривает. Имплементация этой функциональности не так и проста:
|
1. Показ списка предложений на карте: на первый взгляд, мы можем разработать альтернативный компонент показа списка предложений, скажем, `OfferMap`, который сможет использовать стандартную панель предложений. Но у нас есть одна проблема: если `OfferList` только отправляет команды для `OfferPanel`, то `OfferMap` должен ещё и получать обратную связь — событие закрытия панели, чтобы убрать выделение с метки. Наш интерфейс подобного не предусматривает. Имплементация этой функциональности не так и проста:
|
||||||
```
|
```
|
||||||
class CustomOfferPanel extends OfferPanel {
|
class CustomOfferPanel extends OfferPanel {
|
||||||
constructor(
|
constructor(
|
||||||
@ -173,7 +175,7 @@ interface IOfferPanel {
|
|||||||
|
|
||||||
Нам пришлось создать новый класс CustomOfferPanel, который, в отличие от своего родителя, теперь работает только со специфической имплементацией интерфейса IOfferList.
|
Нам пришлось создать новый класс CustomOfferPanel, который, в отличие от своего родителя, теперь работает только со специфической имплементацией интерфейса IOfferList.
|
||||||
|
|
||||||
2. Полные описания и заказ в самом списке заказов. В этом случае всё достаточно очевидно: мы можем добиться нужной функциональности только созданием собственного компонента. Даже если мы предоставим метод переопределения внешнего вида элемента списка для стандартного компонента `OfferList`, он всё равно продолжит создавать `OfferPanel` и открывать его по выбору предложения.
|
2. Полные описания и заказ в самом списке заказов — в этом случае всё достаточно очевидно: мы можем добиться нужной функциональности только созданием собственного компонента. Даже если мы предоставим метод переопределения внешнего вида элемента списка для стандартного компонента `OfferList`, он всё равно продолжит создавать `OfferPanel` и открывать его по выбору предложения.
|
||||||
|
|
||||||
3. Для реализации новых кнопок мы можем только лишь предложить программисту реализовать свой список предложений (чтобы предоставить методы выбора предыдущего / следующего предложения) и свою панель предложений, которая эти методы будет вызывать. Даже если мы задаимся какой нибудь простой кастомизацией, например, текста кнопки «Сделать заказ», то в данном коде она фактически является ответственностью класса `OfferList`:
|
3. Для реализации новых кнопок мы можем только лишь предложить программисту реализовать свой список предложений (чтобы предоставить методы выбора предыдущего / следующего предложения) и свою панель предложений, которая эти методы будет вызывать. Даже если мы задаимся какой нибудь простой кастомизацией, например, текста кнопки «Сделать заказ», то в данном коде она фактически является ответственностью класса `OfferList`:
|
||||||
```
|
```
|
||||||
@ -473,6 +475,8 @@ class SearchBoxComposer implements ISearchBoxComposer {
|
|||||||
2. Комбинирование функциональности списка и панели меняет концепцию, поэтому нам необходимо будет разработать собственный `ISearchBoxComposer`. Но мы при этом сможем использовать стандартный `OfferList`, поскольку `Composer` управляет и подготовкой данных для него, и реакцией на действия пользователей.
|
2. Комбинирование функциональности списка и панели меняет концепцию, поэтому нам необходимо будет разработать собственный `ISearchBoxComposer`. Но мы при этом сможем использовать стандартный `OfferList`, поскольку `Composer` управляет и подготовкой данных для него, и реакцией на действия пользователей.
|
||||||
3. Обогащение функциональности панели не меняет общую декомпозицию (значит, мы сможем продолжать использовать стандартный `SearchBoxComposer` и `OfferList`), но нам нужно переопределить подготовку данных и опций при открытии панели, и реализовать дополнительные события и действия, которые `SearchBoxComposer` транслирует с панели предложения.
|
3. Обогащение функциональности панели не меняет общую декомпозицию (значит, мы сможем продолжать использовать стандартный `SearchBoxComposer` и `OfferList`), но нам нужно переопределить подготовку данных и опций при открытии панели, и реализовать дополнительные события и действия, которые `SearchBoxComposer` транслирует с панели предложения.
|
||||||
|
|
||||||
|
Ценой этой гибкости является чрезвычайное усложнение взаимодейсвтия. Все события и потоки данных должны проходить через цепочку таких `Composer`-ов, удлиняющих иерархию сущностей. Любое преобразование (например, генерация опций для вложенного компонента или реакция на события контекста) должно быть параметризуемым. Мы можем подобрать какие-то разумные хелперы для того, чтобы пользоваться такими кастомизациями было проще, но никак не сможем убрать эту сложность из кода нашего SDK. Таков путь.
|
||||||
|
|
||||||
Пример реализации компонентов с описанными интерфейсами и имплементацией всех трёх кейсов вы можете найти в репозитории настоящей книги:
|
Пример реализации компонентов с описанными интерфейсами и имплементацией всех трёх кейсов вы можете найти в репозитории настоящей книги:
|
||||||
* исходный код доступен на [www.github.com/twirl/The-API-Book/docs/examples](https://github.com/twirl/The-API-Book/tree/gh-pages/docs/examples/01.%20Decomposing%20UI%20Components)
|
* исходный код доступен на [www.github.com/twirl/The-API-Book/docs/examples](https://github.com/twirl/The-API-Book/tree/gh-pages/docs/examples/01.%20Decomposing%20UI%20Components)
|
||||||
* там же предложены несколько задач для самостоятельного изучения;
|
* там же предложены несколько задач для самостоятельного изучения;
|
||||||
|