1
0
mirror of https://github.com/twirl/The-API-Book.git synced 2025-05-19 21:33:04 +02:00

Comments and mockups

This commit is contained in:
Sergey Konstantinov 2023-08-14 17:41:39 +03:00
parent abd2f42dd9
commit e0c4eee4f5
29 changed files with 812 additions and 155 deletions

1
.gitignore vendored
View File

@ -1,5 +1,6 @@
.vscode
.tmp
.DS_Store
node_modules
package-lock.json
*/desktop.ini

View File

@ -1,24 +1,26 @@
# Decomposing UI Components
This is the example illustrating complexities of decomposing a UI component into a series of subcomponents that would simultaneously allow to:
* Redefining the appearance of each of the subcomponent
* Introducing new business logic while keeping styling consistent
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 subcomponents.
* Introducing new business logic while keeping styling consistent.
* 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:
* Make all builder functions configurable through options
* Make `ISearchBoxComposer` a composition of two interfaces: one facade to interact with a `SearchBox`, and another facade to communicate with child components.
* Create a separate composer to close the gap between `OfferPanelComponent` and its buttons
* Add returning an operation status from the `SearchBox.search` method:
1. Make all builder functions configurable through the `SearchBox` options (instead of subclassing components and overriding builder functions)
2. Make a better abstraction of the `SearchBoxComposer` internal state. Make the `findOfferById` function asynchronous
3. Make rendering functions asynchronous
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>
```
Where
Where OperationResult is defined as:
```
type OperationResult =
@ -31,7 +33,11 @@ The following improvements to the code are left as an exercise for the reader:
status: OperationResultStatus.FAIL;
error: any;
};
```
With the enum:
```
export enum OperationResultStatus {
SUCCESS = 'success',
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)
* 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.
* Localize the component, making a locale and a dictionary a part of the `ISearchBox` options.
* 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.
* 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.
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.
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.
11. Parameterize all extra options, content fields, actions, and events.
12. Parametrize the markups of components either by:
* 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.

File diff suppressed because one or more lines are too long

View File

@ -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 {
SearchBox,
SearchBoxComposer,
@ -6,6 +12,12 @@ const {
util
} = 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 {
constructor(context, container, offerList) {
this.context = context;
@ -14,11 +26,21 @@ class CustomOfferList {
this.offerList = 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.events.emit("offerSelect", {
offerId: markerId
});
};
/**
* We are free to implement the business logic in
* any that suits our needs
*/
this.setOfferList = ({ offerList: newOfferList }) => {
if (this.map) {
this.map.destroy();
@ -26,6 +48,9 @@ class CustomOfferList {
}
this.offerList = 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, [
[16.355, 48.2],
[16.375, 48.214]
@ -46,6 +71,21 @@ class CustomOfferList {
"offerPreviewListChange",
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(
"offerFullViewToggle",
({ offer }) => {
@ -57,6 +97,9 @@ class CustomOfferList {
];
}
/**
* As required in the `IOfferListComponent` interface
*/
destroy() {
if (this.map) {
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 {
buildOfferListComponent(
context,
@ -93,6 +144,10 @@ class CustomComposer extends SearchBoxComposer {
}
}
/**
* We're subclassing `SearchBox` to use our
* enhanced composer
*/
class CustomSearchBox extends SearchBox {
buildComposer(context, container, options) {
return new CustomComposer(context, container, options);

View File

@ -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 {
SearchBox,
SearchBoxComposer,
@ -6,10 +12,22 @@ const {
util
} = 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 {
constructor(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) => {
const { target, value: action } = util.findDataField(
e.target,
@ -31,7 +49,7 @@ class CustomOfferList extends OfferListComponent {
this.collapse(container);
break;
case "createOrder":
this.context.createOrder(offerId);
this.events.emit("createOrder", { offerId });
break;
}
};
@ -45,6 +63,10 @@ class CustomOfferList extends OfferListComponent {
item.classList.remove("expanded");
}
/**
* This is a redefined function that returns
* the offer preview markup in the list
*/
generateOfferHtml(offer) {
return util.html`<li
class="custom-offer"
@ -66,22 +88,96 @@ class CustomOfferList extends OfferListComponent {
}
}
class CustomComposer extends SearchBoxComposer {
buildOfferListComponent(
context,
/**
* This is a custom implementation of the
* `ISearchBoxComposer` interface from scratch.
* As there is no offer panel in this particular
* UI, we don't need all the associated logic,
* so we replace the standard implementation
* with this new one. However, we re-use the
* implementation of the offer list subcomponent
*/
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,
offerList,
contextOptions
) {
return new CustomOfferList(
context,
container,
this.generateOfferPreviews(offerList, contextOptions),
this.generateOfferListComponentOptions(contextOptions)
this.offerList
);
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 {
buildComposer(context, container, options) {
return new CustomComposer(context, container, options);

View File

@ -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 {
SearchBox,
SearchBoxComposer,
@ -9,7 +17,16 @@ const {
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(
offer,
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 (
offer,
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 (
offer,
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) {
return new NavigateButton(
"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 {
constructor(direction, offerId, container) {
constructor(direction, targetOfferId, container) {
this.action = "navigate";
this.offerId = offerId;
this.targetOfferId = targetOfferId;
this.events = new util.EventEmitter();
const button = (this.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 {
show() {
const buttons = [];
@ -90,7 +137,7 @@ class CustomOfferPanel extends OfferPanelComponent {
if (offer.previousOfferId) {
buttons.push(buildPreviousOfferButton);
}
buttons.push(buildCustomOrderButton);
buttons.push(buildCustomCreateOrderButton);
if (offer.phone) {
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 {
buildOfferPanelComponent(
context,
@ -167,7 +226,12 @@ class CustomComposer extends SearchBoxComposer {
performAction(event) {
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 {
super.performAction(event);
}
@ -180,6 +244,10 @@ CustomComposer.DEFAULT_OPTIONS = {
closeButtonText: "❌Not Now"
};
/**
* We're subclassing `SearchBox` to use our
* enhanced composer
*/
class CustomSearchBox extends SearchBox {
buildComposer(context, container, options) {
return new CustomComposer(context, container, options);

View File

@ -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 {
IOfferListComponent,
@ -12,13 +18,29 @@ import {
} from './interfaces/ISearchBoxComposer';
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 {
/**
* An accessor to subscribe for events or emit them.
*/
public events: IEventEmitter<IOfferListComponentEvents> =
new EventEmitter();
protected listenerDisposers: IDisposer[] = [];
/**
* An inner state of the component, whether it's now
* rendered or not
*/
protected shown: boolean = false;
/**
* Event listeners
*/
private listenerDisposers: IDisposer[] = [];
constructor(
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() {
return this.offerList;
}
/**
* Destroys the component
*/
public destroy() {
this.teardownListeners();
this.hide();
this.offerList = null;
}
/**
* Allows for programmatically selecting an
* offer in the list
*/
public selectOffer(offerId: string) {
this.events.emit('offerSelect', {
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 = ({
offerList
}: 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} &gt;</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() {
this.container.innerHTML = html`<ul class="our-coffee-sdk-offer-list">
${this.offerList === null
@ -83,49 +161,23 @@ export class OfferListComponent implements IOfferListComponent {
this.shown = false;
}
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} &gt;</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) {
/* Various methods to work with events */
private onOfferClick(offerId: string) {
this.selectOffer(offerId);
}
protected setupDomListeners() {
private setupDomListeners() {
this.container.addEventListener('click', this.onClickListener, false);
}
protected teardownListeners() {
private teardownListeners() {
for (const disposer of this.listenerDisposers) {
disposer.off();
}
this.listenerDisposers = [];
}
protected teardownDomListeners() {
private teardownDomListeners() {
this.container.removeEventListener(
'click',
this.onClickListener,

View File

@ -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 { IEventEmitter } from './interfaces/common';
import { EventEmitter } from './util/EventEmitter';
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 {
public events: IEventEmitter<IButtonEvents> = new EventEmitter();

View File

@ -1,3 +1,9 @@
/**
* @fileoverview
* This file comprises a reference implementation
* of the `IOfferPanelComponent` interface called simply `OfferPanelComponent`
*/
import { omitUndefined } from '../test/util';
import { CloseButton, CreateOrderButton } from './OfferPanelButton';
import { IButton, IButtonPressEvent } from './interfaces/IButton';
@ -10,22 +16,51 @@ import {
IOfferFullView,
ISearchBoxComposer
} from './interfaces/ISearchBoxComposer';
import { IDisposer, IEventEmitter, IExtraFields } from './interfaces/common';
import { IDisposer, IEventEmitter } from './interfaces/common';
import { EventEmitter } from './util/EventEmitter';
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 {
/**
* An accessor to subscribe for events or emit them.
*/
public events: IEventEmitter<IOfferPanelComponentEvents> =
new EventEmitter();
/**
* A DOM element container for buttons
*/
protected buttonsContainer: HTMLElement | null = null;
/**
* An array of currently displayed buttons
*/
protected buttons: Array<{
button: IButton;
listenerDisposer: IDisposer;
container: HTMLElement;
}> = [];
/**
* Event listeners
*/
protected listenerDisposers: IDisposer[] = [];
/**
* An inner state of the component, whether it's open
* or closed
*/
protected shown: boolean = false;
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 = (
offer,
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 = (
offer,
container,
@ -71,10 +114,14 @@ export class OfferPanelComponent implements IOfferPanelComponent {
})
);
/* Exposed for consistency */
public getOffer(): IOfferFullView | null {
return this.currentOffer;
}
/**
* Destroys the panel and its buttons
*/
public destroy() {
this.currentOffer = null;
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() {
this.container.innerHTML = html`<div class="our-coffee-sdk-offer-panel">
<h1>${this.currentOffer.title}</h1>
@ -112,6 +160,11 @@ export class OfferPanelComponent implements IOfferPanelComponent {
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() {
const buttonBuilders = this.options.buttonBuilders ?? [
OfferPanelComponent.buildCreateOrderButton,
@ -133,6 +186,9 @@ export class OfferPanelComponent implements IOfferPanelComponent {
}
}
/**
* Destroys all buttons once the panel is hidden
*/
protected destroyButtons() {
for (const { button, listenerDisposer, container } of this.buttons) {
listenerDisposer.off();
@ -142,6 +198,11 @@ export class OfferPanelComponent implements IOfferPanelComponent {
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 }) => {
if (this.shown) {
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) => {
if (this.currentOffer !== null) {
this.events.emit('action', {
@ -159,15 +225,25 @@ export class OfferPanelComponent implements IOfferPanelComponent {
target,
currentOfferId: this.currentOffer.offerId
});
} else {
// TBD
}
};
}
/**
* `OfferPanelComponent` options
*/
export interface OfferPanelComponentOptions
extends IOfferPanelComponentOptions {
/**
* An array of factory methods to initialize
* buttons
*/
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;
}

View File

@ -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 {
ISearchBox,
@ -8,23 +12,72 @@ import {
ISearchBoxOptions
} from './interfaces/ISearchBox';
import { ISearchBoxComposer } from './interfaces/ISearchBoxComposer';
import { IDisposer, IEventEmitter } from './interfaces/common';
import { EventEmitter } from './util/EventEmitter';
import { SearchBoxComposer } from './SearchBoxComposer';
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 {
/**
* An accessor to subscribe for events or emit them.
*/
public readonly events: IEventEmitter<ISearchBoxEvents> =
new EventEmitter();
/**
* The resolved options
*/
protected readonly options: SearchBoxOptions;
/**
* The instance of the search box composer
* that will handle presenting offers to the user
*/
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 currentRequest: Promise<ISearchResult[]> | null = null;
/**
* The UI elements that are controlled by the `SearchBox` itself.
*/
protected searchButton: HTMLButtonElement;
protected input: 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(
protected readonly container: HTMLElement,
protected readonly coffeeApi: ICoffeeApi,
@ -46,6 +99,9 @@ export class SearchBox implements ISearchBox {
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() {
return this.options;
}
@ -54,6 +110,15 @@ export class SearchBox implements ISearchBox {
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> {
// Shall empty queries be allowed?
// 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> {
return this.coffeeApi.createOrder(parameters);
}
/**
* Destroys the `SearchBox` and all its subcomponents
*/
public destroy() {
this.teardownListeners();
this.composer.destroy();
@ -86,19 +153,59 @@ export class SearchBox implements ISearchBox {
this.currentRequest = this.offerList = null;
}
public buildComposer(
context: SearchBox,
container: HTMLElement,
options: SearchBoxOptions
): ISearchBoxComposer {
return new SearchBoxComposer(context, container, options);
}
/**
* Default options of the `SearchBox` component
*/
public static DEFAULT_OPTIONS: SearchBoxOptions = {
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">
<div class="our-coffee-sdk-search-box-head">
<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();
if (query) {
this.search(query);
}
};
protected setupListeners() {
/**
* Working with various events
*/
private setupListeners() {
this.searchButton.addEventListener(
'click',
this.onSearchButtonClickListener,
@ -137,7 +241,8 @@ export class SearchBox implements ISearchBox {
)
);
}
protected teardownListeners() {
private teardownListeners() {
for (const disposer of this.listenerDisposers) {
disposer.off();
}
@ -148,20 +253,9 @@ export class SearchBox implements ISearchBox {
false
);
}
protected setOfferList(offerList: ISearchResult[] | null) {
if (this.offerList !== offerList) {
this.offerList = offerList;
this.events.emit('offerListChange', { offerList });
}
}
}
export interface SearchBoxOptions extends ISearchBoxOptions {
searchButtonText: string;
offerPanel?: Partial<OfferPanelComponentOptions>;
}
export type SearchBoxComposerBuilder = (
context: SearchBox
) => ISearchBoxComposer;

View File

@ -1,3 +1,9 @@
/**
* @fileoverview
* This file comprises a reference implementation
* of the `ISearchBoxComposer` interface called simply `SearchBoxComposer`
*/
import { ISearchResult } from './interfaces/ICoffeeApi';
import {
ISearchBox,
@ -26,21 +32,64 @@ import { OfferListComponent } from './OfferListComponent';
import { OfferPanelComponent } from './OfferPanelComponent';
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 = {}>
implements ISearchBoxComposer
{
/**
* An accessor to subscribe for events or emit them.
*/
public events: IEventEmitter<ISearchBoxComposerEvents> =
new EventEmitter<ISearchBoxComposerEvents>();
/**
* Instances of subcomponents and HTML element containers
* to host them
*/
protected offerListContainer: HTMLElement | null = null;
protected offerListComponent: IOfferListComponent | null = null;
protected offerPanelContainer: HTMLElement | null = null;
protected offerPanelComponent: IOfferPanelComponent | null = null;
/**
* A current state of the composer itself
*/
protected offerList: 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(
protected readonly context: ISearchBox,
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 {
// Theoretically, we could have built a `Map`
// 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) {
const offer = this.findOfferById(offerId);
// 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({
action,
currentOfferId: offerId
@ -122,6 +184,9 @@ export class SearchBoxComposer<ExtraOptions extends IExtraFields = {}>
}
}
/**
* Exposed publicly as a helper function
*/
public createOrder(offerId: string) {
const offer = this.findOfferById(offerId);
// 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() {
for (const disposer of this.listenerDisposers) {
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 = ({
offerList
}: ISearchBoxOfferListChangeEvent) => {
@ -164,13 +239,11 @@ export class SearchBoxComposer<ExtraOptions extends IExtraFields = {}>
});
};
protected onOfferPanelAction = (event: IOfferPanelActionEvent) => {
this.performAction(event);
};
protected onOfferListOfferSelect = ({ offerId }: IOfferSelectedEvent) =>
this.selectOffer(offerId);
/**
* 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 buildOfferListComponent(
context: ISearchBoxComposer,
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(
offerList: ISearchResult[] | null,
contextOptions: ISearchBoxOptions & ExtraOptions
@ -196,16 +274,27 @@ export class SearchBoxComposer<ExtraOptions extends IExtraFields = {}>
title: offer.place.title,
subtitle: offer.recipe.shortDescription,
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(
options: ISearchBoxOptions
): IOfferListComponentOptions {
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(
context: ISearchBoxComposer,
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(
offer: ISearchResult | null,
contextOptions: ISearchBoxOptions & ExtraOptions
@ -231,19 +325,28 @@ export class SearchBoxComposer<ExtraOptions extends IExtraFields = {}>
title: offer.place.title,
description: [
offer.recipe.mediumDescription,
this.generateOfferBottomLine(offer)
SearchBoxComposer.generateOfferBottomLine(offer)
],
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(
options: ISearchBoxOptions & ExtraOptions
): IOfferPanelComponentOptions {
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
? `${offer.place.walkTime.formattedValue} · ${offer.place.walkingDistance.formattedValue}`
: 'Just around the corner';

View File

@ -1,5 +1,9 @@
import { IEventEmitter } from './common';
/**
* An interface of a button a UI control
* to represent a call to action.
*/
export interface IButton {
action: string;
events: IEventEmitter<IButtonEvents>;

View File

@ -5,11 +5,21 @@ import {
ILocation
} 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 {
search: (query: string) => Promise<ISearchResult[]>;
createOrder: (parameters: { offerId: string }) => Promise<INewOrder>;
}
/**
* A specific search result represeting
* detailed information about an offer
*/
export interface ISearchResult {
offerId: string;
recipe: {
@ -28,6 +38,9 @@ export interface ISearchResult {
price: IFormattedPrice;
}
/**
* A dummy interface for a newly created order
*/
export interface INewOrder {
orderId: string;
}

View File

@ -1,5 +1,10 @@
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 {
events: IEventEmitter<IOfferListComponentEvents>;
destroy: () => void;

View File

@ -1,5 +1,11 @@
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 {
events: IEventEmitter<IOfferPanelComponentEvents>;
destroy: () => void;

View File

@ -1,6 +1,12 @@
import { INewOrder, ISearchResult } from './ICoffeeApi';
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 {
events: IEventEmitter<ISearchBoxEvents>;
search: (query: string) => void;

View File

@ -1,8 +1,21 @@
import { ISearchResult } from './ICoffeeApi';
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 {
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;
destroy: () => void;
}

View File

@ -1,3 +1,8 @@
/**
* @fileoverview
* Various interfaces for representing common data
*/
export interface IFormattedPrice {
decimalValue: string;
formattedValue: string;

View File

@ -1,5 +1,8 @@
import { IDisposer, IEventEmitter } from '../interfaces/common';
/**
* A helper class to subscribe for events and emit them
*/
export class EventEmitter<EventList extends Record<string, any>>
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>>(
type: Type,
callback: (event: EventList[Type]) => void
@ -40,6 +47,10 @@ export class EventEmitter<EventList extends Record<string, any>>
return disposer;
}
/**
* Emits an event, i.e., call all the subscribers for the
* specified event type
*/
public emit<Type extends Extract<keyof EventList, string>>(
type: Type,
event: EventList[Type]

View File

@ -1,3 +1,7 @@
/**
* Helper function to find a single HTML element
* matching a selector or fail
*/
export function $<T extends Element>(
...args: [HTMLElement, string] | [string]
): T {
@ -78,7 +82,10 @@ export function hrefEscapeBuilder(
}
export const httpHrefEscape = hrefEscapeBuilder();
/**
* Template function to safely render HTML templates
* and automatically escape substituted value
*/
export const html = makeTemplate(htmlEscape);
export const raw = (str: string) => new HtmlSerializable(str);
export const attr = makeTemplate(attrEscape);

View File

@ -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 {
IOfferFullView,
@ -128,6 +134,14 @@ export const DUMMY_ORDER = {
};
export const dummyCoffeeApi: ICoffeeApi = {
search: async () => [...DUMMY_RESPONSE],
createOrder: async (): Promise<INewOrder> => DUMMY_ORDER
search: async () => timeouted([...DUMMY_RESPONSE], 300),
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);
});
}

View File

@ -1,3 +1,8 @@
/**
* @fileoverview
* A dummy implementation of map component.
* Shows a statical picture
*/
import { ILocation } from '../../src/interfaces/common';
export class DummyMapApi {

View File

@ -1,3 +1,8 @@
/**
* @fileoverview
* Various helpers for testing the components
*/
import { IEventEmitter } from '../src/interfaces/common';
export async function waitForEvents<

Binary file not shown.

Before

Width:  |  Height:  |  Size: 651 KiB

After

Width:  |  Height:  |  Size: 325 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 88 KiB

BIN
src/img/mockups/08.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

View File

@ -14,7 +14,9 @@
2. Комбинирование краткого и полного описания предложения в одном интерфейсе (предложение можно развернуть прямо в списке и сразу сделать заказ)
* иллюстрирует проблему полного удаления одного из субкомпонентов при сохранении бизнес-логики и UX:
[![APP](/img/mockups/06.png "Список результатов поиска с кнопками быстрых действий")]()
[![APP](/img/mockups/06.png "Список результатов поиска с короткими описаниями предложений")]()
[![APP](/img/mockups/07.png "Список результатов поиска, в котором некоторые предложения развёрнуты")]()
3. Манипуляция данными и доступными действиями для предложения через добавление новых кнопок (вперёд, назад, позвонить) и управление их содержимым. Отметим, что каждая из кнопок предоставляет свою проблему с точки зрения имплементации:
* кнопки навигации (вперёд/назад) требуют, чтобы информация о связности списка (есть предыдущий / следующий заказ) каким-то образом дошла до панели показа предложения;
@ -22,7 +24,7 @@
Пример иллюстрирует проблемы неоднозначности иерархий наследования и сильной связности компонентов;
[![APP](/img/mockups/07.png "Дополнительная кнопка «Позвонить»")]()
[![APP](/img/mockups/08.png "Панель предложения с дополнительными кнопками и иконками")]()
Решение, которое напрашивается в данной ситуации — это выделение двух дополнительных компонентов, отвечающих за представление списка предложений и за панель показа конкретного предложения, назовём их OfferList и OfferPanel. В случае отсутствия требований кастомизации, псевдокод, имплементирующий взаимодействие всех трёх компонентов, выглядел бы достаточно просто:
@ -126,7 +128,7 @@ class OfferPanel implements IOfferPanel {
}
```
Интерфейсы `ISearchBox` / `IOfferPanel` / `IOfferView` также очень просты (контрукторы и деструкторы опущены):
Интерфейсы `ISearchBox` / `IOfferPanel` / `IOfferView` также очень просты (конструкторы и деструкторы опущены):
```
interface ISearchBox {
search(query);
@ -142,7 +144,7 @@ interface IOfferPanel {
Если бы мы не разрабатывали SDK и у нас не было бы задачи разрешать кастомизацию этих компонентов, подобная реализация была бы стопроцентно уместной. Попробуем, однако, представить, как мы будем решать описанные выше задачи:
1. Показ списка предложений на карте. На первый взгляд, мы можем разработать альтернативный компонент показа списка предложений, скажем, `OfferMap`, который сможет использовать стандартную панель предложений. Но у нас есть одна проблема: если `OfferList` только отправляет команды для `OfferPanel`, то `OfferMap` должен ещё и получать обратную связь — событие закрытия панели, чтобы убрать выделение с метки. Наш интерфейс подобного не предусматривает. Имплементация этой функциональности не так и проста:
1. Показ списка предложений на карте: на первый взгляд, мы можем разработать альтернативный компонент показа списка предложений, скажем, `OfferMap`, который сможет использовать стандартную панель предложений. Но у нас есть одна проблема: если `OfferList` только отправляет команды для `OfferPanel`, то `OfferMap` должен ещё и получать обратную связь — событие закрытия панели, чтобы убрать выделение с метки. Наш интерфейс подобного не предусматривает. Имплементация этой функциональности не так и проста:
```
class CustomOfferPanel extends OfferPanel {
constructor(
@ -173,7 +175,7 @@ interface IOfferPanel {
Нам пришлось создать новый класс CustomOfferPanel, который, в отличие от своего родителя, теперь работает только со специфической имплементацией интерфейса IOfferList.
2. Полные описания и заказ в самом списке заказов. В этом случае всё достаточно очевидно: мы можем добиться нужной функциональности только созданием собственного компонента. Даже если мы предоставим метод переопределения внешнего вида элемента списка для стандартного компонента `OfferList`, он всё равно продолжит создавать `OfferPanel` и открывать его по выбору предложения.
2. Полные описания и заказ в самом списке заказов — в этом случае всё достаточно очевидно: мы можем добиться нужной функциональности только созданием собственного компонента. Даже если мы предоставим метод переопределения внешнего вида элемента списка для стандартного компонента `OfferList`, он всё равно продолжит создавать `OfferPanel` и открывать его по выбору предложения.
3. Для реализации новых кнопок мы можем только лишь предложить программисту реализовать свой список предложений (чтобы предоставить методы выбора предыдущего / следующего предложения) и свою панель предложений, которая эти методы будет вызывать. Даже если мы задаимся какой нибудь простой кастомизацией, например, текста кнопки «Сделать заказ», то в данном коде она фактически является ответственностью класса `OfferList`:
```
@ -473,6 +475,8 @@ class SearchBoxComposer implements ISearchBoxComposer {
2. Комбинирование функциональности списка и панели меняет концепцию, поэтому нам необходимо будет разработать собственный `ISearchBoxComposer`. Но мы при этом сможем использовать стандартный `OfferList`, поскольку `Composer` управляет и подготовкой данных для него, и реакцией на действия пользователей.
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)
* там же предложены несколько задач для самостоятельного изучения;