Comments and mockups
							
								
								
									
										13
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -1,6 +1,7 @@ | ||||
| .vscode | ||||
| .tmp | ||||
| node_modules | ||||
| package-lock.json | ||||
| */desktop.ini | ||||
| */~*.doc* | ||||
| .vscode | ||||
| .tmp | ||||
| .DS_Store | ||||
| node_modules | ||||
| package-lock.json | ||||
| */desktop.ini | ||||
| */~*.doc* | ||||
|   | ||||
| @@ -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. | ||||
| @@ -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); | ||||
|   | ||||
| @@ -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, | ||||
|     container, | ||||
|     offerList, | ||||
|     contextOptions | ||||
|   ) { | ||||
|     return new CustomOfferList( | ||||
|       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, | ||||
|       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); | ||||
|   | ||||
| @@ -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); | ||||
|   | ||||
| @@ -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} ></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} ></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, | ||||
|   | ||||
| @@ -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(); | ||||
|  | ||||
|   | ||||
| @@ -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; | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -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; | ||||
|   | ||||
| @@ -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'; | ||||
|   | ||||
| @@ -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>; | ||||
|   | ||||
| @@ -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; | ||||
| } | ||||
|   | ||||
| @@ -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; | ||||
|   | ||||
| @@ -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; | ||||
|   | ||||
| @@ -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; | ||||
|   | ||||
| @@ -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; | ||||
| } | ||||
|   | ||||
| @@ -1,3 +1,8 @@ | ||||
| /** | ||||
|  * @fileoverview | ||||
|  * Various interfaces for representing common data | ||||
|  */ | ||||
|  | ||||
| export interface IFormattedPrice { | ||||
|     decimalValue: string; | ||||
|     formattedValue: string; | ||||
|   | ||||
| @@ -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] | ||||
|   | ||||
| @@ -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); | ||||
|   | ||||
| @@ -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); | ||||
|     }); | ||||
| } | ||||
|   | ||||
| @@ -1,3 +1,8 @@ | ||||
| /** | ||||
|  * @fileoverview | ||||
|  * A dummy implementation of map component. | ||||
|  * Shows a statical picture | ||||
|  */ | ||||
| import { ILocation } from '../../src/interfaces/common'; | ||||
|  | ||||
| export class DummyMapApi { | ||||
|   | ||||
| @@ -1,3 +1,8 @@ | ||||
| /** | ||||
|  * @fileoverview | ||||
|  * Various helpers for testing the components | ||||
|  */ | ||||
|  | ||||
| import { IEventEmitter } from '../src/interfaces/common'; | ||||
|  | ||||
| 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. Комбинирование краткого и полного описания предложения в одном интерфейсе (предложение можно развернуть прямо в списке и сразу сделать заказ) | ||||
|       * иллюстрирует проблему полного удаления одного из субкомпонентов при сохранении бизнес-логики и UX: | ||||
|  | ||||
|       []() | ||||
|       []() | ||||
|  | ||||
|       []() | ||||
|  | ||||
|   3. Манипуляция данными и доступными действиями для предложения через добавление новых кнопок (вперёд, назад, позвонить) и управление их содержимым. Отметим, что каждая из кнопок предоставляет свою проблему с точки зрения имплементации: | ||||
|       * кнопки навигации (вперёд/назад) требуют, чтобы информация о связности списка (есть предыдущий / следующий заказ) каким-то образом дошла до панели показа предложения; | ||||
| @@ -22,7 +24,7 @@ | ||||
|  | ||||
|       Пример иллюстрирует проблемы неоднозначности иерархий наследования и сильной связности компонентов; | ||||
|  | ||||
|       []() | ||||
|       []() | ||||
|  | ||||
| Решение, которое напрашивается в данной ситуации — это выделение двух дополнительных компонентов, отвечающих за представление списка предложений и за панель показа конкретного предложения, назовём их 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) | ||||
|       * там же предложены несколько задач для самостоятельного изучения; | ||||
|   | ||||