diff --git a/.gitignore b/.gitignore index 86e4817..f3cf77d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ -.vscode -.tmp -node_modules -package-lock.json -*/desktop.ini -*/~*.doc* \ No newline at end of file +.vscode +.tmp +.DS_Store +node_modules +package-lock.json +*/desktop.ini +*/~*.doc* diff --git a/docs/examples/01. Decomposing UI Components/README.md b/docs/examples/01. Decomposing UI Components/README.md index 0170ba4..52eb63e 100644 --- a/docs/examples/01. Decomposing UI Components/README.md +++ b/docs/examples/01. Decomposing UI Components/README.md @@ -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 ``` - 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. \ No newline at end of file diff --git a/docs/examples/01. Decomposing UI Components/index.js b/docs/examples/01. Decomposing UI Components/index.js index 3e9a4b1..c1193b1 100644 --- a/docs/examples/01. Decomposing UI Components/index.js +++ b/docs/examples/01. Decomposing UI Components/index.js @@ -16,7 +16,7 @@ \*******************************************************************************/ /***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { -eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ OfferListComponent: () => (/* binding */ OfferListComponent)\n/* harmony export */ });\n/* harmony import */ var _util_html__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./util/html */ \"./docs/examples/01. Decomposing UI Components/src/util/html.ts\");\n/* harmony import */ var _util_EventEmitter__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./util/EventEmitter */ \"./docs/examples/01. Decomposing UI Components/src/util/EventEmitter.ts\");\n\n\nclass OfferListComponent {\n constructor(context, container, offerList, options) {\n this.context = context;\n this.container = container;\n this.offerList = offerList;\n this.options = options;\n this.events = new _util_EventEmitter__WEBPACK_IMPORTED_MODULE_1__.EventEmitter();\n this.listenerDisposers = [];\n this.shown = false;\n this.onOfferListChange = ({ offerList }) => {\n if (this.shown) {\n this.hide();\n }\n this.offerList = offerList;\n if (offerList !== null) {\n this.show();\n }\n };\n this.onClickListener = (e) => {\n var _a;\n let target = e.target;\n while (target) {\n const offerId = (_a = target.dataset) === null || _a === void 0 ? void 0 : _a.offerId;\n if (offerId !== undefined) {\n this.onOfferClick(offerId);\n break;\n }\n target = target.parentNode;\n }\n };\n this.listenerDisposers.push(this.context.events.on('offerPreviewListChange', this.onOfferListChange));\n if (offerList !== null) {\n this.show();\n }\n }\n getOfferList() {\n return this.offerList;\n }\n destroy() {\n this.teardownListeners();\n this.hide();\n this.offerList = null;\n }\n selectOffer(offerId) {\n this.events.emit('offerSelect', {\n offerId\n });\n }\n show() {\n this.container.innerHTML = (0,_util_html__WEBPACK_IMPORTED_MODULE_0__.html) ``.toString();\n this.setupDomListeners();\n this.shown = true;\n }\n hide() {\n this.teardownDomListeners();\n this.container.innerHTML = '';\n this.shown = false;\n }\n generateOfferHtml(offer) {\n return (0,_util_html__WEBPACK_IMPORTED_MODULE_0__.html) `\n \n ${offer.imageUrl !== undefined\n ? (0,_util_html__WEBPACK_IMPORTED_MODULE_0__.html) ``\n : ''}\n

${offer.title}

\n

${offer.subtitle}

\n

${offer.bottomLine}

\n `.toString();\n }\n onOfferClick(offerId) {\n this.selectOffer(offerId);\n }\n setupDomListeners() {\n this.container.addEventListener('click', this.onClickListener, false);\n }\n teardownListeners() {\n for (const disposer of this.listenerDisposers) {\n disposer.off();\n }\n this.listenerDisposers = [];\n }\n teardownDomListeners() {\n this.container.removeEventListener('click', this.onClickListener, false);\n }\n}\n\n\n//# sourceURL=webpack://ourCoffeeSdk/./docs/examples/01._Decomposing_UI_Components/src/OfferListComponent.ts?"); +eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ OfferListComponent: () => (/* binding */ OfferListComponent)\n/* harmony export */ });\n/* harmony import */ var _util_html__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./util/html */ \"./docs/examples/01. Decomposing UI Components/src/util/html.ts\");\n/* harmony import */ var _util_EventEmitter__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./util/EventEmitter */ \"./docs/examples/01. Decomposing UI Components/src/util/EventEmitter.ts\");\n/**\n * @fileoverview\n * This file comprises a reference implementation\n * of the `IOfferListComponent` interface called simply `OfferListComponent`\n */\n\n\n/**\n * An `OfferListComponent` visualizes a list of short descriptions\n * of offers (“previews”) and allows for interacting with it.\n *\n * The responsibility of this class is:\n * * Rendering previews and react on the preview list change event\n * * Allowing user to select a preview and emit the corresponding event\n */\nclass OfferListComponent {\n constructor(context, container, offerList, options) {\n this.context = context;\n this.container = container;\n this.offerList = offerList;\n this.options = options;\n /**\n * An accessor to subscribe for events or emit them.\n */\n this.events = new _util_EventEmitter__WEBPACK_IMPORTED_MODULE_1__.EventEmitter();\n /**\n * An inner state of the component, whether it's now\n * rendered or not\n */\n this.shown = false;\n /**\n * Event listeners\n */\n this.listenerDisposers = [];\n /**\n * An event handler for the context state change\n * event. Exposed as a protected method to allow\n * for altering the default reaction in subclasses\n */\n this.onOfferListChange = ({ offerList }) => {\n if (this.shown) {\n this.hide();\n }\n this.offerList = offerList;\n if (offerList !== null) {\n this.show();\n }\n };\n /**\n * A listener to the DOM 'click' event. Exposed as a shortcut\n * to allow for enriching the UX in subclasses.\n * If we've taken long and 'proper' way, this should be\n * a spearate composer to route events and data flow between\n * the component and its representation.\n */\n this.onClickListener = (e) => {\n var _a;\n let target = e.target;\n while (target) {\n const offerId = (_a = target.dataset) === null || _a === void 0 ? void 0 : _a.offerId;\n if (offerId !== undefined) {\n this.onOfferClick(offerId);\n break;\n }\n target = target.parentNode;\n }\n };\n this.listenerDisposers.push(this.context.events.on('offerPreviewListChange', this.onOfferListChange));\n if (offerList !== null) {\n this.show();\n }\n }\n /* Provided for consistency for the developer\n to have access to the full state\n of the component */\n getOfferList() {\n return this.offerList;\n }\n /**\n * Destroys the component\n */\n destroy() {\n this.teardownListeners();\n this.hide();\n this.offerList = null;\n }\n /**\n * Allows for programmatically selecting an\n * offer in the list\n */\n selectOffer(offerId) {\n this.events.emit('offerSelect', {\n offerId\n });\n }\n /**\n * A helper method to generate the DOM structure for\n * displaying a preview. Exposed\n */\n generateOfferHtml(offer) {\n return (0,_util_html__WEBPACK_IMPORTED_MODULE_0__.html) `\n \n ${offer.imageUrl !== undefined\n ? (0,_util_html__WEBPACK_IMPORTED_MODULE_0__.html) ``\n : ''}\n

${offer.title}

\n

${offer.subtitle}

\n

${offer.bottomLine}

\n `.toString();\n }\n /* A couple of helper methods to render the preview list\n or to dispose the corresponding DOM structure. Exposed to allow\n carrying out additional actions in subclasses if needed */\n show() {\n this.container.innerHTML = (0,_util_html__WEBPACK_IMPORTED_MODULE_0__.html) `
    \n ${this.offerList === null\n ? ''\n : this.offerList.map((offer) => (0,_util_html__WEBPACK_IMPORTED_MODULE_0__.raw)(this.generateOfferHtml(offer)))}\n
`.toString();\n this.setupDomListeners();\n this.shown = true;\n }\n hide() {\n this.teardownDomListeners();\n this.container.innerHTML = '';\n this.shown = false;\n }\n /* Various methods to work with events */\n onOfferClick(offerId) {\n this.selectOffer(offerId);\n }\n setupDomListeners() {\n this.container.addEventListener('click', this.onClickListener, false);\n }\n teardownListeners() {\n for (const disposer of this.listenerDisposers) {\n disposer.off();\n }\n this.listenerDisposers = [];\n }\n teardownDomListeners() {\n this.container.removeEventListener('click', this.onClickListener, false);\n }\n}\n\n\n//# sourceURL=webpack://ourCoffeeSdk/./docs/examples/01._Decomposing_UI_Components/src/OfferListComponent.ts?"); /***/ }), @@ -26,7 +26,7 @@ eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpac \*****************************************************************************/ /***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { -eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ CloseButton: () => (/* binding */ CloseButton),\n/* harmony export */ CreateOrderButton: () => (/* binding */ CreateOrderButton),\n/* harmony export */ OfferPanelButton: () => (/* binding */ OfferPanelButton)\n/* harmony export */ });\n/* harmony import */ var _util_EventEmitter__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./util/EventEmitter */ \"./docs/examples/01. Decomposing UI Components/src/util/EventEmitter.ts\");\n/* harmony import */ var _util_html__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./util/html */ \"./docs/examples/01. Decomposing UI Components/src/util/html.ts\");\n\n\nclass OfferPanelButton {\n constructor(action, container, options) {\n this.action = action;\n this.container = container;\n this.options = options;\n this.events = new _util_EventEmitter__WEBPACK_IMPORTED_MODULE_0__.EventEmitter();\n this.onClickListener = () => {\n this.events.emit('press', { target: this });\n };\n this.container.innerHTML = (0,_util_html__WEBPACK_IMPORTED_MODULE_1__.html) `\n ${this.options.iconUrl\n ? (0,_util_html__WEBPACK_IMPORTED_MODULE_1__.html) ``\n : ''}\n ${(0,_util_html__WEBPACK_IMPORTED_MODULE_1__.raw)(this.options.text)}\n `.toString();\n this.button = (0,_util_html__WEBPACK_IMPORTED_MODULE_1__.$)(this.container, '.our-coffee-sdk-offer-panel-button');\n this.button.addEventListener('click', this.onClickListener, false);\n }\n destroy() {\n this.button.removeEventListener('click', this.onClickListener, false);\n this.container.innerHTML = '';\n }\n}\nclass CreateOrderButton extends OfferPanelButton {\n constructor(container, options) {\n super('createOrder', container, Object.assign({ text: 'Place an Order' }, options));\n this.button.classList.add('our-coffee-sdk-offer-panel-create-order-button');\n }\n}\nclass CloseButton extends OfferPanelButton {\n constructor(container, options) {\n super('close', container, Object.assign({ text: 'Not Now' }, options));\n this.button.classList.add('our-coffee-sdk-offer-panel-close-button');\n }\n}\n\n\n//# sourceURL=webpack://ourCoffeeSdk/./docs/examples/01._Decomposing_UI_Components/src/OfferPanelButton.ts?"); +eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ CloseButton: () => (/* binding */ CloseButton),\n/* harmony export */ CreateOrderButton: () => (/* binding */ CreateOrderButton),\n/* harmony export */ OfferPanelButton: () => (/* binding */ OfferPanelButton)\n/* harmony export */ });\n/* harmony import */ var _util_EventEmitter__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./util/EventEmitter */ \"./docs/examples/01. Decomposing UI Components/src/util/EventEmitter.ts\");\n/* harmony import */ var _util_html__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./util/html */ \"./docs/examples/01. Decomposing UI Components/src/util/html.ts\");\n/**\n * @fileoverview\n * This file comprises a reference implementation\n * of the `IButton` interface adapted for using with\n * the `OfferPanelComponent` parent element\n */\n\n\n/**\n * Displays an UI element that represents a call to action\n * for a user.\n *\n * The responsibility of this class is:\n * * Rendering the corresponding UI\n * * Emitting 'press' events\n */\nclass OfferPanelButton {\n constructor(action, container, options) {\n this.action = action;\n this.container = container;\n this.options = options;\n this.events = new _util_EventEmitter__WEBPACK_IMPORTED_MODULE_0__.EventEmitter();\n this.onClickListener = () => {\n this.events.emit('press', { target: this });\n };\n this.container.innerHTML = (0,_util_html__WEBPACK_IMPORTED_MODULE_1__.html) `\n ${this.options.iconUrl\n ? (0,_util_html__WEBPACK_IMPORTED_MODULE_1__.html) ``\n : ''}\n ${(0,_util_html__WEBPACK_IMPORTED_MODULE_1__.raw)(this.options.text)}\n `.toString();\n this.button = (0,_util_html__WEBPACK_IMPORTED_MODULE_1__.$)(this.container, '.our-coffee-sdk-offer-panel-button');\n this.button.addEventListener('click', this.onClickListener, false);\n }\n destroy() {\n this.button.removeEventListener('click', this.onClickListener, false);\n this.container.innerHTML = '';\n }\n}\nclass CreateOrderButton extends OfferPanelButton {\n constructor(container, options) {\n super('createOrder', container, Object.assign({ text: 'Place an Order' }, options));\n this.button.classList.add('our-coffee-sdk-offer-panel-create-order-button');\n }\n}\nclass CloseButton extends OfferPanelButton {\n constructor(container, options) {\n super('close', container, Object.assign({ text: 'Not Now' }, options));\n this.button.classList.add('our-coffee-sdk-offer-panel-close-button');\n }\n}\n\n\n//# sourceURL=webpack://ourCoffeeSdk/./docs/examples/01._Decomposing_UI_Components/src/OfferPanelButton.ts?"); /***/ }), @@ -36,7 +36,7 @@ eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpac \********************************************************************************/ /***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { -eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ OfferPanelComponent: () => (/* binding */ OfferPanelComponent)\n/* harmony export */ });\n/* harmony import */ var _test_util__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ../test/util */ \"./docs/examples/01. Decomposing UI Components/test/util.ts\");\n/* harmony import */ var _OfferPanelButton__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./OfferPanelButton */ \"./docs/examples/01. Decomposing UI Components/src/OfferPanelButton.ts\");\n/* harmony import */ var _util_EventEmitter__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ./util/EventEmitter */ \"./docs/examples/01. Decomposing UI Components/src/util/EventEmitter.ts\");\n/* harmony import */ var _util_html__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! ./util/html */ \"./docs/examples/01. Decomposing UI Components/src/util/html.ts\");\n\n\n\n\nclass OfferPanelComponent {\n constructor(context, container, currentOffer, options) {\n this.context = context;\n this.container = container;\n this.currentOffer = currentOffer;\n this.options = options;\n this.events = new _util_EventEmitter__WEBPACK_IMPORTED_MODULE_2__.EventEmitter();\n this.buttonsContainer = null;\n this.buttons = [];\n this.listenerDisposers = [];\n this.shown = false;\n this.onOfferFullViewToggle = ({ offer }) => {\n if (this.shown) {\n this.hide();\n }\n this.currentOffer = offer;\n if (offer) {\n this.show();\n }\n };\n this.onButtonPress = ({ target }) => {\n if (this.currentOffer !== null) {\n this.events.emit('action', {\n action: target.action,\n target,\n currentOfferId: this.currentOffer.offerId\n });\n }\n else {\n // TBD\n }\n };\n this.listenerDisposers.push(this.context.events.on('offerFullViewToggle', this.onOfferFullViewToggle));\n if (currentOffer !== null) {\n this.show();\n }\n }\n getOffer() {\n return this.currentOffer;\n }\n destroy() {\n this.currentOffer = null;\n for (const disposer of this.listenerDisposers) {\n disposer.off();\n }\n if (this.shown) {\n this.hide();\n }\n }\n show() {\n this.container.innerHTML = (0,_util_html__WEBPACK_IMPORTED_MODULE_3__.html) `
\n

${this.currentOffer.title}

\n ${this.currentOffer.description.map((description) => (0,_util_html__WEBPACK_IMPORTED_MODULE_3__.html) `

${description}

`)}\n
    \n
    `.toString();\n this.buttonsContainer = (0,_util_html__WEBPACK_IMPORTED_MODULE_3__.$)(this.container, '.our-coffee-sdk-offer-panel-buttons');\n if (!this.options.transparent) {\n this.container.classList.add('our-coffee-sdk-cover-blur');\n }\n this.setupButtons();\n this.shown = true;\n }\n hide() {\n this.destroyButtons();\n this.buttonsContainer = null;\n this.container.innerHTML = '';\n this.container.classList.remove('our-coffee-sdk-cover-blur');\n this.shown = false;\n }\n setupButtons() {\n var _a;\n const buttonBuilders = (_a = this.options.buttonBuilders) !== null && _a !== void 0 ? _a : [\n OfferPanelComponent.buildCreateOrderButton,\n OfferPanelComponent.buildCloseButton\n ];\n for (const buttonBuilder of buttonBuilders) {\n const container = document.createElement('li');\n this.buttonsContainer.appendChild(container);\n const button = buttonBuilder(this.currentOffer, container, this.options);\n const listenerDisposer = button.events.on('press', this.onButtonPress);\n this.buttons.push({ button, listenerDisposer, container });\n }\n }\n destroyButtons() {\n for (const { button, listenerDisposer, container } of this.buttons) {\n listenerDisposer.off();\n button.destroy();\n container.parentNode.removeChild(container);\n }\n this.buttons = [];\n }\n}\nOfferPanelComponent.buildCreateOrderButton = (offer, container, options) => new _OfferPanelButton__WEBPACK_IMPORTED_MODULE_1__.CreateOrderButton(container, (0,_test_util__WEBPACK_IMPORTED_MODULE_0__.omitUndefined)({\n iconUrl: options.createOrderButtonUrl,\n text: options.createOrderButtonText\n}));\nOfferPanelComponent.buildCloseButton = (offer, container, options) => new _OfferPanelButton__WEBPACK_IMPORTED_MODULE_1__.CloseButton(container, (0,_test_util__WEBPACK_IMPORTED_MODULE_0__.omitUndefined)({\n iconUrl: options.closeButtonUrl,\n text: options.closeButtonText\n}));\n\n\n//# sourceURL=webpack://ourCoffeeSdk/./docs/examples/01._Decomposing_UI_Components/src/OfferPanelComponent.ts?"); +eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ OfferPanelComponent: () => (/* binding */ OfferPanelComponent)\n/* harmony export */ });\n/* harmony import */ var _test_util__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ../test/util */ \"./docs/examples/01. Decomposing UI Components/test/util.ts\");\n/* harmony import */ var _OfferPanelButton__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./OfferPanelButton */ \"./docs/examples/01. Decomposing UI Components/src/OfferPanelButton.ts\");\n/* harmony import */ var _util_EventEmitter__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ./util/EventEmitter */ \"./docs/examples/01. Decomposing UI Components/src/util/EventEmitter.ts\");\n/* harmony import */ var _util_html__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! ./util/html */ \"./docs/examples/01. Decomposing UI Components/src/util/html.ts\");\n/**\n * @fileoverview\n * This file comprises a reference implementation\n * of the `IOfferPanelComponent` interface called simply `OfferPanelComponent`\n */\n\n\n\n\n/**\n * A `OfferPanelComponent` represents a UI to display\n * the detailed information regarding an offer (a “full view”)\n * implying that user can act on the offer (for example,\n * to create an order).\n *\n * The responsibility of the component is:\n * * Displaying detailed information regarding an offer\n * and update it if the corresponding context state\n * change event happens\n * * Rendering “buttons,” i.e. the control elements\n * for user to express their intentions\n * * Emitting “actions” when the user interacts with the buttons\n * * Closing itself if needed\n */\nclass OfferPanelComponent {\n constructor(context, container, currentOffer, options) {\n this.context = context;\n this.container = container;\n this.currentOffer = currentOffer;\n this.options = options;\n /**\n * An accessor to subscribe for events or emit them.\n */\n this.events = new _util_EventEmitter__WEBPACK_IMPORTED_MODULE_2__.EventEmitter();\n /**\n * A DOM element container for buttons\n */\n this.buttonsContainer = null;\n /**\n * An array of currently displayed buttons\n */\n this.buttons = [];\n /**\n * Event listeners\n */\n this.listenerDisposers = [];\n /**\n * An inner state of the component, whether it's open\n * or closed\n */\n this.shown = false;\n /**\n * A listener for the parent context's state change.\n * Exposed as a protected method to allow for adding additional\n * functionality\n */\n this.onOfferFullViewToggle = ({ offer }) => {\n if (this.shown) {\n this.hide();\n }\n this.currentOffer = offer;\n if (offer) {\n this.show();\n }\n };\n /**\n * A listener for button pressing events. Exposed\n * as a protected method to allow for adding custom\n * reactions\n */\n this.onButtonPress = ({ target }) => {\n if (this.currentOffer !== null) {\n this.events.emit('action', {\n action: target.action,\n target,\n currentOfferId: this.currentOffer.offerId\n });\n }\n };\n this.listenerDisposers.push(this.context.events.on('offerFullViewToggle', this.onOfferFullViewToggle));\n if (currentOffer !== null) {\n this.show();\n }\n }\n /* Exposed for consistency */\n getOffer() {\n return this.currentOffer;\n }\n /**\n * Destroys the panel and its buttons\n */\n destroy() {\n this.currentOffer = null;\n for (const disposer of this.listenerDisposers) {\n disposer.off();\n }\n if (this.shown) {\n this.hide();\n }\n }\n /* A pair of helper methods to show and hide the panel */\n show() {\n this.container.innerHTML = (0,_util_html__WEBPACK_IMPORTED_MODULE_3__.html) `
    \n

    ${this.currentOffer.title}

    \n ${this.currentOffer.description.map((description) => (0,_util_html__WEBPACK_IMPORTED_MODULE_3__.html) `

    ${description}

    `)}\n
      \n
      `.toString();\n this.buttonsContainer = (0,_util_html__WEBPACK_IMPORTED_MODULE_3__.$)(this.container, '.our-coffee-sdk-offer-panel-buttons');\n if (!this.options.transparent) {\n this.container.classList.add('our-coffee-sdk-cover-blur');\n }\n this.setupButtons();\n this.shown = true;\n }\n hide() {\n this.destroyButtons();\n this.buttonsContainer = null;\n this.container.innerHTML = '';\n this.container.classList.remove('our-coffee-sdk-cover-blur');\n this.shown = false;\n }\n /**\n * Instantiates all buttons when a new offer is to be\n * displayed. Exposed as a protected method to allow for\n * an additional UX functionality in subclasses\n */\n setupButtons() {\n var _a;\n const buttonBuilders = (_a = this.options.buttonBuilders) !== null && _a !== void 0 ? _a : [\n OfferPanelComponent.buildCreateOrderButton,\n OfferPanelComponent.buildCloseButton\n ];\n for (const buttonBuilder of buttonBuilders) {\n const container = document.createElement('li');\n this.buttonsContainer.appendChild(container);\n const button = buttonBuilder(this.currentOffer, container, this.options);\n const listenerDisposer = button.events.on('press', this.onButtonPress);\n this.buttons.push({ button, listenerDisposer, container });\n }\n }\n /**\n * Destroys all buttons once the panel is hidden\n */\n destroyButtons() {\n for (const { button, listenerDisposer, container } of this.buttons) {\n listenerDisposer.off();\n button.destroy();\n container.parentNode.removeChild(container);\n }\n this.buttons = [];\n }\n}\n/**\n * A static helper function to build a specific button\n * for creating orders\n */\nOfferPanelComponent.buildCreateOrderButton = (offer, container, options) => new _OfferPanelButton__WEBPACK_IMPORTED_MODULE_1__.CreateOrderButton(container, (0,_test_util__WEBPACK_IMPORTED_MODULE_0__.omitUndefined)({\n iconUrl: options.createOrderButtonUrl,\n text: options.createOrderButtonText\n}));\n/**\n * A static helper function to build a specific button\n * for closing the panel\n */\nOfferPanelComponent.buildCloseButton = (offer, container, options) => new _OfferPanelButton__WEBPACK_IMPORTED_MODULE_1__.CloseButton(container, (0,_test_util__WEBPACK_IMPORTED_MODULE_0__.omitUndefined)({\n iconUrl: options.closeButtonUrl,\n text: options.closeButtonText\n}));\n\n\n//# sourceURL=webpack://ourCoffeeSdk/./docs/examples/01._Decomposing_UI_Components/src/OfferPanelComponent.ts?"); /***/ }), @@ -46,7 +46,7 @@ eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpac \**********************************************************************/ /***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { -eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ SearchBox: () => (/* binding */ SearchBox)\n/* harmony export */ });\n/* harmony import */ var _SearchBoxComposer__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./SearchBoxComposer */ \"./docs/examples/01. Decomposing UI Components/src/SearchBoxComposer.ts\");\n/* harmony import */ var _util_html__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./util/html */ \"./docs/examples/01. Decomposing UI Components/src/util/html.ts\");\n/* harmony import */ var _util_EventEmitter__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ./util/EventEmitter */ \"./docs/examples/01. Decomposing UI Components/src/util/EventEmitter.ts\");\nvar __awaiter = (undefined && undefined.__awaiter) || function (thisArg, _arguments, P, generator) {\n function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }\n return new (P || (P = Promise))(function (resolve, reject) {\n function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }\n function rejected(value) { try { step(generator[\"throw\"](value)); } catch (e) { reject(e); } }\n function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }\n step((generator = generator.apply(thisArg, _arguments || [])).next());\n });\n};\n\n\n\nclass SearchBox {\n constructor(container, coffeeApi, options) {\n this.container = container;\n this.coffeeApi = coffeeApi;\n this.events = new _util_EventEmitter__WEBPACK_IMPORTED_MODULE_2__.EventEmitter();\n this.offerList = null;\n this.currentRequest = null;\n this.listenerDisposers = [];\n this.onSearchButtonClickListener = () => {\n const query = this.input.value.trim();\n if (query) {\n this.search(query);\n }\n };\n this.options =\n options !== undefined\n ? Object.assign(Object.assign({}, SearchBox.DEFAULT_OPTIONS), options) : Object.assign({}, SearchBox.DEFAULT_OPTIONS);\n this.render();\n this.composer = this.buildComposer(this, this.layoutContainer, this.options);\n this.setupListeners();\n }\n getOptions() {\n return this.options;\n }\n getContainer() {\n return this.container;\n }\n search(rawQuery) {\n return __awaiter(this, void 0, void 0, function* () {\n // Shall empty queries be allowed?\n // As it's an API method, it might make sense\n // for a developer to send empty strings to\n // the API method, so we keep this possibility\n const query = rawQuery.trim();\n this.input.value = query;\n const request = (this.currentRequest = this.coffeeApi.search(query));\n this.setOfferList(null);\n yield request.then((result) => {\n if (request === this.currentRequest) {\n this.setOfferList(result);\n this.currentRequest = null;\n }\n });\n });\n }\n getOfferList() {\n return this.offerList;\n }\n createOrder(parameters) {\n return this.coffeeApi.createOrder(parameters);\n }\n destroy() {\n this.teardownListeners();\n this.composer.destroy();\n this.container.innerHTML = '';\n this.currentRequest = this.offerList = null;\n }\n buildComposer(context, container, options) {\n return new _SearchBoxComposer__WEBPACK_IMPORTED_MODULE_0__.SearchBoxComposer(context, container, options);\n }\n render() {\n this.container.innerHTML = (0,_util_html__WEBPACK_IMPORTED_MODULE_1__.html) `
      \n
      \n \n \n
      \n
      \n
      `.toString();\n this.input = (0,_util_html__WEBPACK_IMPORTED_MODULE_1__.$)(this.container, '.our-coffee-sdk-search-box-input');\n this.searchButton = (0,_util_html__WEBPACK_IMPORTED_MODULE_1__.$)(this.container, '.our-coffee-sdk-search-box-search-button');\n this.layoutContainer = (0,_util_html__WEBPACK_IMPORTED_MODULE_1__.$)(this.container, '.our-coffee-sdk-layout-container');\n }\n setupListeners() {\n this.searchButton.addEventListener('click', this.onSearchButtonClickListener, false);\n this.listenerDisposers.push(this.composer.events.on('createOrder', ({ offer: { offerId } }) => this.createOrder({ offerId })));\n }\n teardownListeners() {\n for (const disposer of this.listenerDisposers) {\n disposer.off();\n }\n this.listenerDisposers = [];\n this.searchButton.removeEventListener('click', this.onSearchButtonClickListener, false);\n }\n setOfferList(offerList) {\n if (this.offerList !== offerList) {\n this.offerList = offerList;\n this.events.emit('offerListChange', { offerList });\n }\n }\n}\nSearchBox.DEFAULT_OPTIONS = {\n searchButtonText: 'Search'\n};\n\n\n//# sourceURL=webpack://ourCoffeeSdk/./docs/examples/01._Decomposing_UI_Components/src/SearchBox.ts?"); +eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ SearchBox: () => (/* binding */ SearchBox)\n/* harmony export */ });\n/* harmony import */ var _util_EventEmitter__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./util/EventEmitter */ \"./docs/examples/01. Decomposing UI Components/src/util/EventEmitter.ts\");\n/* harmony import */ var _SearchBoxComposer__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./SearchBoxComposer */ \"./docs/examples/01. Decomposing UI Components/src/SearchBoxComposer.ts\");\n/* harmony import */ var _util_html__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ./util/html */ \"./docs/examples/01. Decomposing UI Components/src/util/html.ts\");\n/**\n * @fileoverview\n * This file comprises a reference implementation\n * of the `ISearchBox` interface called simply `SearchBox`\n */\nvar __awaiter = (undefined && undefined.__awaiter) || function (thisArg, _arguments, P, generator) {\n function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }\n return new (P || (P = Promise))(function (resolve, reject) {\n function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }\n function rejected(value) { try { step(generator[\"throw\"](value)); } catch (e) { reject(e); } }\n function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }\n step((generator = generator.apply(thisArg, _arguments || [])).next());\n });\n};\n\n\n\n/**\n * A `SearchBox` represents a UI component\n * that allows an end user to enter search queries,\n * work with the received results and place orders.\n * The user input which will be propagated\n * to the underlying `ourCoffeeApi` functionality.\n *\n * The responsibility of this class is:\n * * Handling user input consistently\n * * Instantiating the `ISearchBoxComposer` subcomponent\n * that takes care of the offer list UI & UX, and creating\n * orders if a `composers` requests to.\n * * Emitting events when current displayed search results\n * (offers) are changed\n * * Providing methods to programmatically initialize\n * searching a given query and make orders\n */\nclass SearchBox {\n /**\n * A `SearchBox` synchoronously initializes itself\n * in the given HTML element context and will use\n * the given instance of the `ICoffeeApi` interface\n * to run search queries and create orders.\n */\n constructor(container, coffeeApi, options) {\n this.container = container;\n this.coffeeApi = coffeeApi;\n /**\n * An accessor to subscribe for events or emit them.\n */\n this.events = new _util_EventEmitter__WEBPACK_IMPORTED_MODULE_0__.EventEmitter();\n /**\n * The current list of search results (offers) to\n * present to the user. Might be `null`.\n */\n this.offerList = null;\n /**\n * A current asynchronous request to the search API (if any).\n * Needed to manage a possible race if the user or\n * the developer fires several search queries in a row.\n */\n this.currentRequest = null;\n /**\n * Event listeners to get disposed upon desructing the `SearchBox`\n */\n this.listenerDisposers = [];\n /**\n * Handling a 'Search' button press event. Provided as\n * a protected method to allow custom validations\n * or alternative inputs\n */\n this.onSearchButtonClickListener = () => {\n const query = this.input.value.trim();\n if (query) {\n this.search(query);\n }\n };\n this.options =\n options !== undefined\n ? Object.assign(Object.assign({}, SearchBox.DEFAULT_OPTIONS), options) : Object.assign({}, SearchBox.DEFAULT_OPTIONS);\n this.render();\n this.composer = this.buildComposer(this, this.layoutContainer, this.options);\n this.setupListeners();\n }\n /* These three methods are provided for consistency\n for the developer to have access to the full state\n of the `SearchBox` entity */\n getOptions() {\n return this.options;\n }\n getContainer() {\n return this.container;\n }\n getOfferList() {\n return this.offerList;\n }\n /**\n * Performs searching of offers and reflects this\n * operation in the UI\n * @param {string} rawQuery Raw unsanitized input\n */\n search(rawQuery) {\n return __awaiter(this, void 0, void 0, function* () {\n // Shall empty queries be allowed?\n // As it's an API method, it might make sense\n // for a developer to send empty strings to\n // the API method, so we keep this possibility\n const query = rawQuery.trim();\n this.input.value = query;\n const request = (this.currentRequest = this.coffeeApi.search(query));\n this.setOfferList(null);\n yield request.then((result) => {\n if (request === this.currentRequest) {\n this.setOfferList(result);\n this.currentRequest = null;\n }\n });\n });\n }\n /**\n * Creates an order based on the offer.\n */\n createOrder(parameters) {\n return this.coffeeApi.createOrder(parameters);\n }\n /**\n * Destroys the `SearchBox` and all its subcomponents\n */\n destroy() {\n this.teardownListeners();\n this.composer.destroy();\n this.container.innerHTML = '';\n this.currentRequest = this.offerList = null;\n }\n /**\n * Factory method to create a composer.\n * Exposed as a protected method to allow\n * instantiating custom composers.\n * @param {ISearchBox} context Parent search box\n * @param {HTMLElement} container An HTML Element\n * container prepared for rendering the UI\n * @param {ISearchBoxOptions} options Parent options\n * @returns\n */\n buildComposer(context, container, options) {\n return new _SearchBoxComposer__WEBPACK_IMPORTED_MODULE_1__.SearchBoxComposer(context, container, options);\n }\n /**\n * The internal implementation of setting a new\n * offer list after a search is performed.\n * Provided as a protected method to allow for custom\n * search result list modifications in a subclass.\n */\n setOfferList(offerList) {\n if (this.offerList !== offerList) {\n this.offerList = offerList;\n this.events.emit('offerListChange', { offerList });\n }\n }\n /**\n * Rendering HTML markup of the composer\n */\n render() {\n this.container.innerHTML = (0,_util_html__WEBPACK_IMPORTED_MODULE_2__.html) `
      \n
      \n \n \n
      \n
      \n
      `.toString();\n this.input = (0,_util_html__WEBPACK_IMPORTED_MODULE_2__.$)(this.container, '.our-coffee-sdk-search-box-input');\n this.searchButton = (0,_util_html__WEBPACK_IMPORTED_MODULE_2__.$)(this.container, '.our-coffee-sdk-search-box-search-button');\n this.layoutContainer = (0,_util_html__WEBPACK_IMPORTED_MODULE_2__.$)(this.container, '.our-coffee-sdk-layout-container');\n }\n /**\n * Working with various events\n */\n setupListeners() {\n this.searchButton.addEventListener('click', this.onSearchButtonClickListener, false);\n this.listenerDisposers.push(this.composer.events.on('createOrder', ({ offer: { offerId } }) => this.createOrder({ offerId })));\n }\n teardownListeners() {\n for (const disposer of this.listenerDisposers) {\n disposer.off();\n }\n this.listenerDisposers = [];\n this.searchButton.removeEventListener('click', this.onSearchButtonClickListener, false);\n }\n}\n/**\n * Default options of the `SearchBox` component\n */\nSearchBox.DEFAULT_OPTIONS = {\n searchButtonText: 'Search'\n};\n\n\n//# sourceURL=webpack://ourCoffeeSdk/./docs/examples/01._Decomposing_UI_Components/src/SearchBox.ts?"); /***/ }), @@ -56,7 +56,7 @@ eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpac \******************************************************************************/ /***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { -eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ SearchBoxComposer: () => (/* binding */ SearchBoxComposer)\n/* harmony export */ });\n/* harmony import */ var _OfferListComponent__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./OfferListComponent */ \"./docs/examples/01. Decomposing UI Components/src/OfferListComponent.ts\");\n/* harmony import */ var _OfferPanelComponent__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./OfferPanelComponent */ \"./docs/examples/01. Decomposing UI Components/src/OfferPanelComponent.ts\");\n/* harmony import */ var _util_EventEmitter__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ./util/EventEmitter */ \"./docs/examples/01. Decomposing UI Components/src/util/EventEmitter.ts\");\n\n\n\nclass SearchBoxComposer {\n constructor(context, container, contextOptions) {\n this.context = context;\n this.container = container;\n this.contextOptions = contextOptions;\n this.events = new _util_EventEmitter__WEBPACK_IMPORTED_MODULE_2__.EventEmitter();\n this.offerListContainer = null;\n this.offerListComponent = null;\n this.offerPanelContainer = null;\n this.offerPanelComponent = null;\n this.offerList = null;\n this.currentOffer = null;\n this.onContextOfferListChange = ({ offerList }) => {\n if (this.currentOffer !== null) {\n this.currentOffer = null;\n this.events.emit('offerFullViewToggle', { offer: null });\n }\n this.offerList = offerList;\n this.events.emit('offerPreviewListChange', {\n offerList: this.generateOfferPreviews(this.offerList, this.contextOptions)\n });\n };\n this.onOfferPanelAction = (event) => {\n this.performAction(event);\n };\n this.onOfferListOfferSelect = ({ offerId }) => this.selectOffer(offerId);\n this.offerListContainer = document.createElement('div');\n container.appendChild(this.offerListContainer);\n this.offerListComponent = this.buildOfferListComponent(this, this.offerListContainer, this.offerList, this.contextOptions);\n this.offerPanelContainer = document.createElement('div');\n container.appendChild(this.offerPanelContainer);\n this.offerPanelComponent = this.buildOfferPanelComponent(this, this.offerPanelContainer, this.currentOffer, this.contextOptions);\n this.listenerDisposers = [\n context.events.on('offerListChange', this.onContextOfferListChange),\n this.offerListComponent.events.on('offerSelect', this.onOfferListOfferSelect),\n this.offerPanelComponent.events.on('action', this.onOfferPanelAction)\n ];\n }\n findOfferById(offerIdToFind) {\n var _a, _b;\n // Theoretically, we could have built a `Map`\n // for quickly searching offers by their id\n // Practically, as all responses in an API must be\n // paginated, it makes little sense to optimize this prematurely.\n return ((_b = (_a = this.offerList) === null || _a === void 0 ? void 0 : _a.find(({ offerId }) => offerId === offerIdToFind)) !== null && _b !== void 0 ? _b : null);\n }\n selectOffer(offerId) {\n const offer = this.findOfferById(offerId);\n // Offer may be missing for a variety of reasons,\n // most notably of `OfferListComponent` renders\n // offers asynchronously\n if (offer !== null) {\n this.currentOffer = offer;\n this.events.emit('offerFullViewToggle', {\n offer: this.generateCurrentOfferFullView(this.currentOffer, this.contextOptions)\n });\n }\n else {\n // TDB\n }\n }\n performAction({ action, currentOfferId: offerId }) {\n switch (action) {\n case 'createOrder':\n this.createOrder(offerId);\n break;\n case 'close':\n if (this.currentOffer !== null) {\n this.currentOffer = null;\n this.events.emit('offerFullViewToggle', { offer: null });\n }\n break;\n }\n }\n createOrder(offerId) {\n const offer = this.findOfferById(offerId);\n // Offer may be missing if `OfferPanelComponent`\n // renders offers asynchronously\n if (offer !== null) {\n this.events.emit('createOrder', { offer });\n }\n }\n destroy() {\n for (const disposer of this.listenerDisposers) {\n disposer.off();\n }\n this.listenerDisposers = [];\n this.offerListComponent.destroy();\n this.offerPanelComponent.destroy();\n this.offerListContainer.parentNode.removeChild(this.offerListContainer);\n this.offerPanelContainer.parentNode.removeChild(this.offerPanelContainer);\n }\n buildOfferListComponent(context, container, offerList, contextOptions) {\n return new _OfferListComponent__WEBPACK_IMPORTED_MODULE_0__.OfferListComponent(context, container, this.generateOfferPreviews(offerList, contextOptions), this.generateOfferListComponentOptions(contextOptions));\n }\n generateOfferPreviews(offerList, contextOptions) {\n return offerList === null\n ? null\n : offerList.map((offer) => ({\n offerId: offer.offerId,\n title: offer.place.title,\n subtitle: offer.recipe.shortDescription,\n price: offer.price,\n bottomLine: this.generateOfferBottomLine(offer)\n }));\n }\n generateOfferListComponentOptions(options) {\n return {};\n }\n buildOfferPanelComponent(context, container, currentOffer, contextOptions) {\n return new _OfferPanelComponent__WEBPACK_IMPORTED_MODULE_1__.OfferPanelComponent(context, container, this.generateCurrentOfferFullView(currentOffer, contextOptions), this.generateOfferPanelComponentOptions(contextOptions));\n }\n generateCurrentOfferFullView(offer, contextOptions) {\n return offer === null\n ? null\n : {\n offerId: offer.offerId,\n title: offer.place.title,\n description: [\n offer.recipe.mediumDescription,\n this.generateOfferBottomLine(offer)\n ],\n price: offer.price\n };\n }\n generateOfferPanelComponentOptions(options) {\n var _a;\n return (_a = options.offerPanel) !== null && _a !== void 0 ? _a : {};\n }\n generateOfferBottomLine(offer) {\n return offer.place.walkingDistance.numericValueMeters >= 100\n ? `${offer.place.walkTime.formattedValue} · ${offer.place.walkingDistance.formattedValue}`\n : 'Just around the corner';\n }\n}\n\n\n//# sourceURL=webpack://ourCoffeeSdk/./docs/examples/01._Decomposing_UI_Components/src/SearchBoxComposer.ts?"); +eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ SearchBoxComposer: () => (/* binding */ SearchBoxComposer)\n/* harmony export */ });\n/* harmony import */ var _OfferListComponent__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./OfferListComponent */ \"./docs/examples/01. Decomposing UI Components/src/OfferListComponent.ts\");\n/* harmony import */ var _OfferPanelComponent__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./OfferPanelComponent */ \"./docs/examples/01. Decomposing UI Components/src/OfferPanelComponent.ts\");\n/* harmony import */ var _util_EventEmitter__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ./util/EventEmitter */ \"./docs/examples/01. Decomposing UI Components/src/util/EventEmitter.ts\");\n/**\n * @fileoverview\n * This file comprises a reference implementation\n * of the `ISearchBoxComposer` interface called simply `SearchBoxComposer`\n */\n\n\n\n/**\n * A `SearchBoxComposer` stands for an entity which\n * controls the data flow between an abstract `ISearchBox`\n * and a specific UI concept.\n *\n * This reference implementation assumes that each offer\n * might be represented as a list item (a 'preview') and\n * as a detailed representation (a `full view`).\n *\n * The responsibility of the composer is:\n * * Instantiating and destroying nested components\n * that handles previews (`IOfferListComponent`) and\n * a full view (`IOfferPanelComponent`)\n * * Handling an internal state (a list of offers and\n * a currently selected offer) and emitting events when\n * it changes\n * * Generating previews, full views, and UI options when\n * needed\n * * Notifying parent `ISearchBox` about the user's intention\n * to place an order\n */\nclass SearchBoxComposer {\n /**\n * A `SearchBoxComposer` synchoronously initializes itself\n * in the context of the given `SearchBox` with provided\n * options and HTML container element.\n */\n constructor(context, container, contextOptions) {\n this.context = context;\n this.container = container;\n this.contextOptions = contextOptions;\n /**\n * An accessor to subscribe for events or emit them.\n */\n this.events = new _util_EventEmitter__WEBPACK_IMPORTED_MODULE_2__.EventEmitter();\n /**\n * Instances of subcomponents and HTML element containers\n * to host them\n */\n this.offerListContainer = null;\n this.offerListComponent = null;\n this.offerPanelContainer = null;\n this.offerPanelComponent = null;\n /**\n * A current state of the composer itself\n */\n this.offerList = null;\n this.currentOffer = null;\n /**\n * Event listeners\n */\n this.onOfferPanelAction = (event) => {\n this.performAction(event);\n };\n this.onOfferListOfferSelect = ({ offerId }) => this.selectOffer(offerId);\n /**\n * The event subscriber for the parent context `offerListChange`\n * event. Transforms the high-level event into a couple of lover-level\n * ones and maintaints the composer's internal state.\n * Exposed as a protected method to allow custom reactions\n * to parent context state change in subclasses.\n */\n this.onContextOfferListChange = ({ offerList }) => {\n if (this.currentOffer !== null) {\n this.currentOffer = null;\n this.events.emit('offerFullViewToggle', { offer: null });\n }\n this.offerList = offerList;\n this.events.emit('offerPreviewListChange', {\n offerList: this.generateOfferPreviews(this.offerList, this.contextOptions)\n });\n };\n this.offerListContainer = document.createElement('div');\n container.appendChild(this.offerListContainer);\n this.offerListComponent = this.buildOfferListComponent(this, this.offerListContainer, this.offerList, this.contextOptions);\n this.offerPanelContainer = document.createElement('div');\n container.appendChild(this.offerPanelContainer);\n this.offerPanelComponent = this.buildOfferPanelComponent(this, this.offerPanelContainer, this.currentOffer, this.contextOptions);\n this.listenerDisposers = [\n context.events.on('offerListChange', this.onContextOfferListChange),\n this.offerListComponent.events.on('offerSelect', this.onOfferListOfferSelect),\n this.offerPanelComponent.events.on('action', this.onOfferPanelAction)\n ];\n }\n /**\n * Allows for searching for a displayed offer\n */\n findOfferById(offerIdToFind) {\n var _a, _b;\n // Theoretically, we could have built a `Map`\n // for quickly searching offers by their id\n // Practically, as all responses in an API must be\n // paginated, it makes little sense to optimize this prematurely.\n return ((_b = (_a = this.offerList) === null || _a === void 0 ? void 0 : _a.find(({ offerId }) => offerId === offerIdToFind)) !== null && _b !== void 0 ? _b : null);\n }\n /**\n * Exposed publicly to allow developers to programmatically\n * select an offer (which typically implies opening an offer panel)\n */\n selectOffer(offerId) {\n const offer = this.findOfferById(offerId);\n // Offer may be missing for a variety of reasons,\n // most notably of `OfferListComponent` renders\n // offers asynchronously\n if (offer !== null) {\n this.currentOffer = offer;\n this.events.emit('offerFullViewToggle', {\n offer: this.generateCurrentOfferFullView(this.currentOffer, this.contextOptions)\n });\n }\n else {\n // TDB\n }\n }\n /**\n * Exposed publicly to allow programmatically\n * performing actions the composer is capable of,\n * i.e., creating an order or closing the offer panel,\n * or to add new actions in subclasses\n */\n performAction({ action, currentOfferId: offerId }) {\n switch (action) {\n case 'createOrder':\n this.createOrder(offerId);\n break;\n case 'close':\n if (this.currentOffer !== null) {\n this.currentOffer = null;\n this.events.emit('offerFullViewToggle', { offer: null });\n }\n break;\n }\n }\n /**\n * Exposed publicly as a helper function\n */\n createOrder(offerId) {\n const offer = this.findOfferById(offerId);\n // Offer may be missing if `OfferPanelComponent`\n // renders offers asynchronously\n if (offer !== null) {\n this.events.emit('createOrder', { offer });\n }\n }\n /**\n * Destroys the composer and all its subcomponents\n */\n destroy() {\n for (const disposer of this.listenerDisposers) {\n disposer.off();\n }\n this.listenerDisposers = [];\n this.offerListComponent.destroy();\n this.offerPanelComponent.destroy();\n this.offerListContainer.parentNode.removeChild(this.offerListContainer);\n this.offerPanelContainer.parentNode.removeChild(this.offerPanelContainer);\n }\n /**\n * A factory method to build an instance of an offer list\n * sub-component. Exposed as a protected method to allow\n * custom implementations of an offer list in subclasses\n */\n buildOfferListComponent(context, container, offerList, contextOptions) {\n return new _OfferListComponent__WEBPACK_IMPORTED_MODULE_0__.OfferListComponent(context, container, this.generateOfferPreviews(offerList, contextOptions), this.generateOfferListComponentOptions(contextOptions));\n }\n /**\n * A helper to generate “preview” data for the offer list component.\n * Exposed as a protected method to allow enriching preview data\n * with custom fields in subclasses.\n */\n generateOfferPreviews(offerList, contextOptions) {\n return offerList === null\n ? null\n : offerList.map((offer) => ({\n offerId: offer.offerId,\n title: offer.place.title,\n subtitle: offer.recipe.shortDescription,\n price: offer.price,\n bottomLine: SearchBoxComposer.generateOfferBottomLine(offer)\n }));\n }\n /**\n * A helper to translate context options (i.e., the options of the\n * parent `ISearchBox`) into the options of the offer list subcomponent.\n * Exposed as a protected method to allow for an additional logic of\n * generating options or passing extra options in subclasses\n */\n generateOfferListComponentOptions(options) {\n return {};\n }\n /**\n * A factory method to build an instance of an offer panel\n * sub-component. Exposed as a protected method to allow\n * custom implementations of an offer panel in subclasses\n */\n buildOfferPanelComponent(context, container, currentOffer, contextOptions) {\n return new _OfferPanelComponent__WEBPACK_IMPORTED_MODULE_1__.OfferPanelComponent(context, container, this.generateCurrentOfferFullView(currentOffer, contextOptions), this.generateOfferPanelComponentOptions(contextOptions));\n }\n /**\n * A helper to generate “full view” data for the offer panel component.\n * Exposed as a protected method to allow enriching full view data\n * with custom fields in subclasses.\n */\n generateCurrentOfferFullView(offer, contextOptions) {\n return offer === null\n ? null\n : {\n offerId: offer.offerId,\n title: offer.place.title,\n description: [\n offer.recipe.mediumDescription,\n SearchBoxComposer.generateOfferBottomLine(offer)\n ],\n price: offer.price\n };\n }\n /**\n * A helper to translate context options (i.e., the options of the\n * parent `ISearchBox`) into the options of the offer panel subcomponent.\n * Exposed as a protected method to allow for an additional logic of\n * generating options or passing extra options in subclasses\n */\n generateOfferPanelComponentOptions(options) {\n var _a;\n return (_a = options.offerPanel) !== null && _a !== void 0 ? _a : {};\n }\n /**\n * A small helper method to generate “bottomlines” for offers\n */\n static generateOfferBottomLine(offer) {\n return offer.place.walkingDistance.numericValueMeters >= 100\n ? `${offer.place.walkTime.formattedValue} · ${offer.place.walkingDistance.formattedValue}`\n : 'Just around the corner';\n }\n}\n\n\n//# sourceURL=webpack://ourCoffeeSdk/./docs/examples/01._Decomposing_UI_Components/src/SearchBoxComposer.ts?"); /***/ }), @@ -76,7 +76,7 @@ eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpac \******************************************************************************/ /***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { -eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ Disposer: () => (/* binding */ Disposer),\n/* harmony export */ EventEmitter: () => (/* binding */ EventEmitter)\n/* harmony export */ });\nclass EventEmitter {\n constructor() {\n this.listenersByType = new Map();\n this.disposeCallback = (type, disposer) => {\n const listeners = this.listenersByType.get(type);\n if (listeners !== undefined) {\n const callback = listeners.get(disposer);\n if (callback !== undefined) {\n listeners.delete(disposer);\n if (listeners.size === 0) {\n this.listenersByType.delete(type);\n }\n }\n }\n };\n }\n on(type, callback) {\n const disposer = new Disposer(type, this.disposeCallback);\n const listeners = this.listenersByType.get(type);\n if (listeners === undefined) {\n this.listenersByType.set(type, new Map([[disposer, callback]]));\n }\n else {\n listeners.set(disposer, callback);\n }\n return disposer;\n }\n emit(type, event) {\n const listeners = this.listenersByType.get(type);\n if (listeners !== undefined) {\n for (const callback of listeners.values()) {\n callback(event);\n }\n }\n }\n}\nclass Disposer {\n constructor(type, callback) {\n this.type = type;\n this.callback = callback;\n this.active = true;\n }\n off() {\n if (this.active) {\n this.callback(this.type, this);\n this.active = false;\n }\n }\n}\n\n\n//# sourceURL=webpack://ourCoffeeSdk/./docs/examples/01._Decomposing_UI_Components/src/util/EventEmitter.ts?"); +eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ Disposer: () => (/* binding */ Disposer),\n/* harmony export */ EventEmitter: () => (/* binding */ EventEmitter)\n/* harmony export */ });\n/**\n * A helper class to subscribe for events and emit them\n */\nclass EventEmitter {\n constructor() {\n this.listenersByType = new Map();\n this.disposeCallback = (type, disposer) => {\n const listeners = this.listenersByType.get(type);\n if (listeners !== undefined) {\n const callback = listeners.get(disposer);\n if (callback !== undefined) {\n listeners.delete(disposer);\n if (listeners.size === 0) {\n this.listenersByType.delete(type);\n }\n }\n }\n };\n }\n /**\n * Subscribes for an event\n * @returns a `Disposer` which allows to unsubscribe\n */\n on(type, callback) {\n const disposer = new Disposer(type, this.disposeCallback);\n const listeners = this.listenersByType.get(type);\n if (listeners === undefined) {\n this.listenersByType.set(type, new Map([[disposer, callback]]));\n }\n else {\n listeners.set(disposer, callback);\n }\n return disposer;\n }\n /**\n * Emits an event, i.e., call all the subscribers for the\n * specified event type\n */\n emit(type, event) {\n const listeners = this.listenersByType.get(type);\n if (listeners !== undefined) {\n for (const callback of listeners.values()) {\n callback(event);\n }\n }\n }\n}\nclass Disposer {\n constructor(type, callback) {\n this.type = type;\n this.callback = callback;\n this.active = true;\n }\n off() {\n if (this.active) {\n this.callback(this.type, this);\n this.active = false;\n }\n }\n}\n\n\n//# sourceURL=webpack://ourCoffeeSdk/./docs/examples/01._Decomposing_UI_Components/src/util/EventEmitter.ts?"); /***/ }), @@ -86,7 +86,7 @@ eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpac \**********************************************************************/ /***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { -eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ $: () => (/* binding */ $),\n/* harmony export */ HtmlSerializable: () => (/* binding */ HtmlSerializable),\n/* harmony export */ attr: () => (/* binding */ attr),\n/* harmony export */ attrEscape: () => (/* binding */ attrEscape),\n/* harmony export */ attrValue: () => (/* binding */ attrValue),\n/* harmony export */ findDataField: () => (/* binding */ findDataField),\n/* harmony export */ getSubstituteValue: () => (/* binding */ getSubstituteValue),\n/* harmony export */ href: () => (/* binding */ href),\n/* harmony export */ hrefEscapeBuilder: () => (/* binding */ hrefEscapeBuilder),\n/* harmony export */ hrefValue: () => (/* binding */ hrefValue),\n/* harmony export */ html: () => (/* binding */ html),\n/* harmony export */ htmlEscape: () => (/* binding */ htmlEscape),\n/* harmony export */ httpHrefEscape: () => (/* binding */ httpHrefEscape),\n/* harmony export */ makeTemplate: () => (/* binding */ makeTemplate),\n/* harmony export */ raw: () => (/* binding */ raw)\n/* harmony export */ });\nfunction $(...args) {\n const [node, selector] = args.length === 2 ? args : [document, args[0]];\n const result = node.querySelector(selector);\n if (!result) {\n throw new Error(`Cannot select a node with \"${selector}\" selector`);\n }\n return result;\n}\nfunction makeTemplate(e) {\n return (texts, ...substitutes) => {\n const parts = [];\n for (let i = 0; i < texts.length; i++) {\n parts.push(texts[i]);\n if (substitutes[i]) {\n parts.push(getSubstituteValue(substitutes[i], e));\n }\n }\n return new HtmlSerializable(parts.join(''));\n };\n}\nclass HtmlSerializable {\n constructor(__html) {\n this.__html = __html;\n this.__isHtmlSerializable = true;\n }\n toString() {\n return this.__html;\n }\n}\nfunction getSubstituteValue(value, escapeFunction) {\n if (typeof value == 'string') {\n return escapeFunction(value);\n }\n else if (value.__isHtmlSerializable) {\n return value.toString();\n }\n else {\n return value\n .map((v) => getSubstituteValue(v, escapeFunction))\n .join('');\n }\n}\nfunction htmlEscape(str) {\n return str\n .replace(/\\&/g, '&')\n .replace(/\\/g, '>');\n}\nfunction attrEscape(str) {\n return htmlEscape(str).replace(/\\'/g, ''').replace(/\\\"/g, '"');\n}\nfunction hrefEscapeBuilder(allowedProtocols = ['http:', 'https:', null]) {\n return (raw) => {\n var _a;\n const str = raw.trim();\n const protocol = (_a = str.match(/$[a-z0-9\\+\\-\\.]+\\:/i)[0]) !== null && _a !== void 0 ? _a : null;\n if (allowedProtocols.includes(protocol)) {\n return attrEscape(str);\n }\n else {\n return '';\n }\n };\n}\nconst httpHrefEscape = hrefEscapeBuilder();\nconst html = makeTemplate(htmlEscape);\nconst raw = (str) => new HtmlSerializable(str);\nconst attr = makeTemplate(attrEscape);\nconst attrValue = (str) => new HtmlSerializable(attrEscape(str));\nconst href = makeTemplate(httpHrefEscape);\nconst hrefValue = (str, allowedProtocols) => new HtmlSerializable(allowedProtocols === undefined\n ? httpHrefEscape(str)\n : hrefEscapeBuilder(allowedProtocols)(str));\nconst findDataField = (target, fieldName) => {\n let node = target;\n while (node) {\n if (node.dataset && node.dataset[fieldName] !== undefined) {\n return {\n target: node,\n value: node.dataset[fieldName]\n };\n }\n node = node.parentElement;\n }\n return {\n target: null,\n value: null\n };\n};\n\n\n//# sourceURL=webpack://ourCoffeeSdk/./docs/examples/01._Decomposing_UI_Components/src/util/html.ts?"); +eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ $: () => (/* binding */ $),\n/* harmony export */ HtmlSerializable: () => (/* binding */ HtmlSerializable),\n/* harmony export */ attr: () => (/* binding */ attr),\n/* harmony export */ attrEscape: () => (/* binding */ attrEscape),\n/* harmony export */ attrValue: () => (/* binding */ attrValue),\n/* harmony export */ findDataField: () => (/* binding */ findDataField),\n/* harmony export */ getSubstituteValue: () => (/* binding */ getSubstituteValue),\n/* harmony export */ href: () => (/* binding */ href),\n/* harmony export */ hrefEscapeBuilder: () => (/* binding */ hrefEscapeBuilder),\n/* harmony export */ hrefValue: () => (/* binding */ hrefValue),\n/* harmony export */ html: () => (/* binding */ html),\n/* harmony export */ htmlEscape: () => (/* binding */ htmlEscape),\n/* harmony export */ httpHrefEscape: () => (/* binding */ httpHrefEscape),\n/* harmony export */ makeTemplate: () => (/* binding */ makeTemplate),\n/* harmony export */ raw: () => (/* binding */ raw)\n/* harmony export */ });\n/**\n * Helper function to find a single HTML element\n * matching a selector or fail\n */\nfunction $(...args) {\n const [node, selector] = args.length === 2 ? args : [document, args[0]];\n const result = node.querySelector(selector);\n if (!result) {\n throw new Error(`Cannot select a node with \"${selector}\" selector`);\n }\n return result;\n}\nfunction makeTemplate(e) {\n return (texts, ...substitutes) => {\n const parts = [];\n for (let i = 0; i < texts.length; i++) {\n parts.push(texts[i]);\n if (substitutes[i]) {\n parts.push(getSubstituteValue(substitutes[i], e));\n }\n }\n return new HtmlSerializable(parts.join(''));\n };\n}\nclass HtmlSerializable {\n constructor(__html) {\n this.__html = __html;\n this.__isHtmlSerializable = true;\n }\n toString() {\n return this.__html;\n }\n}\nfunction getSubstituteValue(value, escapeFunction) {\n if (typeof value == 'string') {\n return escapeFunction(value);\n }\n else if (value.__isHtmlSerializable) {\n return value.toString();\n }\n else {\n return value\n .map((v) => getSubstituteValue(v, escapeFunction))\n .join('');\n }\n}\nfunction htmlEscape(str) {\n return str\n .replace(/\\&/g, '&')\n .replace(/\\/g, '>');\n}\nfunction attrEscape(str) {\n return htmlEscape(str).replace(/\\'/g, ''').replace(/\\\"/g, '"');\n}\nfunction hrefEscapeBuilder(allowedProtocols = ['http:', 'https:', null]) {\n return (raw) => {\n var _a;\n const str = raw.trim();\n const protocol = (_a = str.match(/$[a-z0-9\\+\\-\\.]+\\:/i)[0]) !== null && _a !== void 0 ? _a : null;\n if (allowedProtocols.includes(protocol)) {\n return attrEscape(str);\n }\n else {\n return '';\n }\n };\n}\nconst httpHrefEscape = hrefEscapeBuilder();\n/**\n * Template function to safely render HTML templates\n * and automatically escape substituted value\n */\nconst html = makeTemplate(htmlEscape);\nconst raw = (str) => new HtmlSerializable(str);\nconst attr = makeTemplate(attrEscape);\nconst attrValue = (str) => new HtmlSerializable(attrEscape(str));\nconst href = makeTemplate(httpHrefEscape);\nconst hrefValue = (str, allowedProtocols) => new HtmlSerializable(allowedProtocols === undefined\n ? httpHrefEscape(str)\n : hrefEscapeBuilder(allowedProtocols)(str));\nconst findDataField = (target, fieldName) => {\n let node = target;\n while (node) {\n if (node.dataset && node.dataset[fieldName] !== undefined) {\n return {\n target: node,\n value: node.dataset[fieldName]\n };\n }\n node = node.parentElement;\n }\n return {\n target: null,\n value: null\n };\n};\n\n\n//# sourceURL=webpack://ourCoffeeSdk/./docs/examples/01._Decomposing_UI_Components/src/util/html.ts?"); /***/ }), @@ -106,7 +106,7 @@ eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpac \*************************************************************************************/ /***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { -eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ CAFEE_CHAMOMILE_LUNGO_OFFER: () => (/* binding */ CAFEE_CHAMOMILE_LUNGO_OFFER),\n/* harmony export */ CAFEE_CHAMOMILE_LUNGO_OFFER_FULL_VIEW: () => (/* binding */ CAFEE_CHAMOMILE_LUNGO_OFFER_FULL_VIEW),\n/* harmony export */ CAFEE_CHAMOMILE_LUNGO_OFFER_PREVIEW: () => (/* binding */ CAFEE_CHAMOMILE_LUNGO_OFFER_PREVIEW),\n/* harmony export */ DUMMY_ORDER: () => (/* binding */ DUMMY_ORDER),\n/* harmony export */ DUMMY_PREVIEWS: () => (/* binding */ DUMMY_PREVIEWS),\n/* harmony export */ DUMMY_RESPONSE: () => (/* binding */ DUMMY_RESPONSE),\n/* harmony export */ dummyCoffeeApi: () => (/* binding */ dummyCoffeeApi)\n/* harmony export */ });\nvar __awaiter = (undefined && undefined.__awaiter) || function (thisArg, _arguments, P, generator) {\n function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }\n return new (P || (P = Promise))(function (resolve, reject) {\n function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }\n function rejected(value) { try { step(generator[\"throw\"](value)); } catch (e) { reject(e); } }\n function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }\n step((generator = generator.apply(thisArg, _arguments || [])).next());\n });\n};\nconst CAFEE_CHAMOMILE_LUNGO_OFFER = {\n offerId: '1',\n recipe: {\n title: 'Lungo',\n shortDescription: 'Best Lungo in the town!',\n mediumDescription: `It's our best lungo. Smart SDK developers always choose Cafe “Chamomile” Lungo!`\n },\n place: {\n title: 'Cafe “Chamomile”',\n walkingDistance: {\n numericValueMeters: 200,\n formattedValue: '200m'\n },\n location: {\n longitude: 16.361645,\n latitude: 48.211515\n },\n walkTime: {\n intervalValueSeconds: 120,\n formattedValue: '2 min'\n },\n icon: '../../assets/coffee.png',\n phone: '12484345508'\n },\n price: {\n decimalValue: '37.00',\n formattedValue: '$37',\n currencyCode: 'USD'\n }\n};\nconst CAFEE_CHAMOMILE_LUNGO_OFFER_PREVIEW = {\n offerId: '1',\n title: 'Cafe “Chamomile”',\n subtitle: 'Best Lungo in the town!',\n bottomLine: '200m · 2 min',\n imageUrl: '/assets/coffee.png',\n price: {\n decimalValue: '37.00',\n formattedValue: '$37',\n currencyCode: 'USD'\n }\n};\nconst CAFEE_CHAMOMILE_LUNGO_OFFER_FULL_VIEW = {\n offerId: '1',\n title: 'Cafe “Chamomile”',\n description: [\n 'This is our best lungo. Smart SDK developers always choose Cafe “Chamomile” Lungo!',\n '200m · 2 min'\n ],\n price: {\n decimalValue: '37.00',\n formattedValue: '$37',\n currencyCode: 'USD'\n }\n};\nconst DUMMY_RESPONSE = [\n CAFEE_CHAMOMILE_LUNGO_OFFER,\n {\n offerId: '2',\n recipe: {\n title: 'Lungo',\n shortDescription: 'Best Lungo in the world!',\n mediumDescription: `It's our best lungo. Smart SDK developers always choose Peripheral Perk© Lungo!`\n },\n place: {\n title: 'Peripheral Perk',\n walkingDistance: {\n numericValueMeters: 400,\n formattedValue: '400m'\n },\n location: {\n longitude: 16.365423,\n latitude: 48.210377\n },\n walkTime: {\n intervalValueSeconds: 600,\n formattedValue: '5 min'\n }\n },\n price: {\n decimalValue: '39.00',\n formattedValue: '$39',\n currencyCode: 'USD'\n }\n },\n {\n offerId: '3',\n recipe: {\n title: 'Lungo',\n shortDescription: 'Best Lungo in this app!',\n mediumDescription: `It's our best lungo. Smart SDK developers always choose Top Top Cafe Lungo!`\n },\n place: {\n title: 'Top Top Cafe',\n walkingDistance: {\n numericValueMeters: 50,\n formattedValue: '50m'\n },\n location: {\n longitude: 16.367205,\n latitude: 48.208574\n },\n walkTime: {\n intervalValueSeconds: 50,\n formattedValue: 'less than a minute'\n }\n },\n price: {\n decimalValue: '41.00',\n formattedValue: '$41',\n currencyCode: 'USD'\n }\n }\n];\nconst DUMMY_PREVIEWS = [CAFEE_CHAMOMILE_LUNGO_OFFER_PREVIEW];\nconst DUMMY_ORDER = {\n orderId: '321'\n};\nconst dummyCoffeeApi = {\n search: () => __awaiter(void 0, void 0, void 0, function* () { return [...DUMMY_RESPONSE]; }),\n createOrder: () => __awaiter(void 0, void 0, void 0, function* () { return DUMMY_ORDER; })\n};\n\n\n//# sourceURL=webpack://ourCoffeeSdk/./docs/examples/01._Decomposing_UI_Components/test/fixtures/dummyCoffeeApi.ts?"); +eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ CAFEE_CHAMOMILE_LUNGO_OFFER: () => (/* binding */ CAFEE_CHAMOMILE_LUNGO_OFFER),\n/* harmony export */ CAFEE_CHAMOMILE_LUNGO_OFFER_FULL_VIEW: () => (/* binding */ CAFEE_CHAMOMILE_LUNGO_OFFER_FULL_VIEW),\n/* harmony export */ CAFEE_CHAMOMILE_LUNGO_OFFER_PREVIEW: () => (/* binding */ CAFEE_CHAMOMILE_LUNGO_OFFER_PREVIEW),\n/* harmony export */ DUMMY_ORDER: () => (/* binding */ DUMMY_ORDER),\n/* harmony export */ DUMMY_PREVIEWS: () => (/* binding */ DUMMY_PREVIEWS),\n/* harmony export */ DUMMY_RESPONSE: () => (/* binding */ DUMMY_RESPONSE),\n/* harmony export */ dummyCoffeeApi: () => (/* binding */ dummyCoffeeApi)\n/* harmony export */ });\n/**\n * @fileoverview\n * A dummy implementation of the coffee API. Always emits\n * the predefined array of results\n */\nvar __awaiter = (undefined && undefined.__awaiter) || function (thisArg, _arguments, P, generator) {\n function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }\n return new (P || (P = Promise))(function (resolve, reject) {\n function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }\n function rejected(value) { try { step(generator[\"throw\"](value)); } catch (e) { reject(e); } }\n function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }\n step((generator = generator.apply(thisArg, _arguments || [])).next());\n });\n};\nconst CAFEE_CHAMOMILE_LUNGO_OFFER = {\n offerId: '1',\n recipe: {\n title: 'Lungo',\n shortDescription: 'Best Lungo in the town!',\n mediumDescription: `It's our best lungo. Smart SDK developers always choose Cafe “Chamomile” Lungo!`\n },\n place: {\n title: 'Cafe “Chamomile”',\n walkingDistance: {\n numericValueMeters: 200,\n formattedValue: '200m'\n },\n location: {\n longitude: 16.361645,\n latitude: 48.211515\n },\n walkTime: {\n intervalValueSeconds: 120,\n formattedValue: '2 min'\n },\n icon: '../../assets/coffee.png',\n phone: '12484345508'\n },\n price: {\n decimalValue: '37.00',\n formattedValue: '$37',\n currencyCode: 'USD'\n }\n};\nconst CAFEE_CHAMOMILE_LUNGO_OFFER_PREVIEW = {\n offerId: '1',\n title: 'Cafe “Chamomile”',\n subtitle: 'Best Lungo in the town!',\n bottomLine: '200m · 2 min',\n imageUrl: '/assets/coffee.png',\n price: {\n decimalValue: '37.00',\n formattedValue: '$37',\n currencyCode: 'USD'\n }\n};\nconst CAFEE_CHAMOMILE_LUNGO_OFFER_FULL_VIEW = {\n offerId: '1',\n title: 'Cafe “Chamomile”',\n description: [\n 'This is our best lungo. Smart SDK developers always choose Cafe “Chamomile” Lungo!',\n '200m · 2 min'\n ],\n price: {\n decimalValue: '37.00',\n formattedValue: '$37',\n currencyCode: 'USD'\n }\n};\nconst DUMMY_RESPONSE = [\n CAFEE_CHAMOMILE_LUNGO_OFFER,\n {\n offerId: '2',\n recipe: {\n title: 'Lungo',\n shortDescription: 'Best Lungo in the world!',\n mediumDescription: `It's our best lungo. Smart SDK developers always choose Peripheral Perk© Lungo!`\n },\n place: {\n title: 'Peripheral Perk',\n walkingDistance: {\n numericValueMeters: 400,\n formattedValue: '400m'\n },\n location: {\n longitude: 16.365423,\n latitude: 48.210377\n },\n walkTime: {\n intervalValueSeconds: 600,\n formattedValue: '5 min'\n }\n },\n price: {\n decimalValue: '39.00',\n formattedValue: '$39',\n currencyCode: 'USD'\n }\n },\n {\n offerId: '3',\n recipe: {\n title: 'Lungo',\n shortDescription: 'Best Lungo in this app!',\n mediumDescription: `It's our best lungo. Smart SDK developers always choose Top Top Cafe Lungo!`\n },\n place: {\n title: 'Top Top Cafe',\n walkingDistance: {\n numericValueMeters: 50,\n formattedValue: '50m'\n },\n location: {\n longitude: 16.367205,\n latitude: 48.208574\n },\n walkTime: {\n intervalValueSeconds: 50,\n formattedValue: 'less than a minute'\n }\n },\n price: {\n decimalValue: '41.00',\n formattedValue: '$41',\n currencyCode: 'USD'\n }\n }\n];\nconst DUMMY_PREVIEWS = [CAFEE_CHAMOMILE_LUNGO_OFFER_PREVIEW];\nconst DUMMY_ORDER = {\n orderId: '321'\n};\nconst dummyCoffeeApi = {\n search: () => __awaiter(void 0, void 0, void 0, function* () { return timeouted([...DUMMY_RESPONSE], 300); }),\n createOrder: () => __awaiter(void 0, void 0, void 0, function* () { return timeouted(DUMMY_ORDER, 300); })\n};\nfunction timeouted(result, timeoutMs) {\n return new Promise((resolve) => {\n setTimeout(() => {\n resolve(result);\n }, timeoutMs);\n });\n}\n\n\n//# sourceURL=webpack://ourCoffeeSdk/./docs/examples/01._Decomposing_UI_Components/test/fixtures/dummyCoffeeApi.ts?"); /***/ }), @@ -126,7 +126,7 @@ eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpac \******************************************************************/ /***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { -eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ defer: () => (/* binding */ defer),\n/* harmony export */ omitUndefined: () => (/* binding */ omitUndefined),\n/* harmony export */ waitForEvents: () => (/* binding */ waitForEvents)\n/* harmony export */ });\nvar __awaiter = (undefined && undefined.__awaiter) || function (thisArg, _arguments, P, generator) {\n function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }\n return new (P || (P = Promise))(function (resolve, reject) {\n function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }\n function rejected(value) { try { step(generator[\"throw\"](value)); } catch (e) { reject(e); } }\n function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }\n step((generator = generator.apply(thisArg, _arguments || [])).next());\n });\n};\nfunction waitForEvents(emitter, type, callback, n = 1) {\n return __awaiter(this, void 0, void 0, function* () {\n let counter = 0;\n const result = [];\n const disposers = [];\n return new Promise((resolve) => {\n const disposer = emitter.on(type, (event) => {\n result.push(event);\n if (++counter >= n) {\n setTimeout(() => {\n for (const disposer of disposers) {\n disposer.off();\n }\n resolve(result);\n }, 0);\n }\n });\n disposers.push(disposer);\n callback();\n });\n });\n}\nfunction defer() {\n let resolve;\n let reject;\n const promise = new Promise((nestedResolve, nestedReject) => {\n resolve = nestedResolve;\n });\n return {\n resolve,\n reject,\n promise\n };\n}\nfunction omitUndefined(obj) {\n const result = {};\n for (const [k, v] of Object.entries(obj)) {\n if (v !== undefined) {\n result[k] = v;\n }\n }\n return result;\n}\n\n\n//# sourceURL=webpack://ourCoffeeSdk/./docs/examples/01._Decomposing_UI_Components/test/util.ts?"); +eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ defer: () => (/* binding */ defer),\n/* harmony export */ omitUndefined: () => (/* binding */ omitUndefined),\n/* harmony export */ waitForEvents: () => (/* binding */ waitForEvents)\n/* harmony export */ });\n/**\n * @fileoverview\n * Various helpers for testing the components\n */\nvar __awaiter = (undefined && undefined.__awaiter) || function (thisArg, _arguments, P, generator) {\n function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }\n return new (P || (P = Promise))(function (resolve, reject) {\n function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }\n function rejected(value) { try { step(generator[\"throw\"](value)); } catch (e) { reject(e); } }\n function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }\n step((generator = generator.apply(thisArg, _arguments || [])).next());\n });\n};\nfunction waitForEvents(emitter, type, callback, n = 1) {\n return __awaiter(this, void 0, void 0, function* () {\n let counter = 0;\n const result = [];\n const disposers = [];\n return new Promise((resolve) => {\n const disposer = emitter.on(type, (event) => {\n result.push(event);\n if (++counter >= n) {\n setTimeout(() => {\n for (const disposer of disposers) {\n disposer.off();\n }\n resolve(result);\n }, 0);\n }\n });\n disposers.push(disposer);\n callback();\n });\n });\n}\nfunction defer() {\n let resolve;\n let reject;\n const promise = new Promise((nestedResolve, nestedReject) => {\n resolve = nestedResolve;\n });\n return {\n resolve,\n reject,\n promise\n };\n}\nfunction omitUndefined(obj) {\n const result = {};\n for (const [k, v] of Object.entries(obj)) {\n if (v !== undefined) {\n result[k] = v;\n }\n }\n return result;\n}\n\n\n//# sourceURL=webpack://ourCoffeeSdk/./docs/examples/01._Decomposing_UI_Components/test/util.ts?"); /***/ }) diff --git a/docs/examples/01. Decomposing UI Components/samples/01.js b/docs/examples/01. Decomposing UI Components/samples/01.js index 0840c9f..bdde3a1 100644 --- a/docs/examples/01. Decomposing UI Components/samples/01.js +++ b/docs/examples/01. Decomposing UI Components/samples/01.js @@ -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); diff --git a/docs/examples/01. Decomposing UI Components/samples/02.js b/docs/examples/01. Decomposing UI Components/samples/02.js index fbff40c..aa329bd 100644 --- a/docs/examples/01. Decomposing UI Components/samples/02.js +++ b/docs/examples/01. Decomposing UI Components/samples/02.js @@ -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`
    • 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); diff --git a/docs/examples/01. Decomposing UI Components/samples/03.js b/docs/examples/01. Decomposing UI Components/samples/03.js index a7982b7..d7aa995 100644 --- a/docs/examples/01. Decomposing UI Components/samples/03.js +++ b/docs/examples/01. Decomposing UI Components/samples/03.js @@ -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); diff --git a/docs/examples/01. Decomposing UI Components/src/OfferListComponent.ts b/docs/examples/01. Decomposing UI Components/src/OfferListComponent.ts index 53aa80b..69c6d75 100644 --- a/docs/examples/01. Decomposing UI Components/src/OfferListComponent.ts +++ b/docs/examples/01. Decomposing UI Components/src/OfferListComponent.ts @@ -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 = 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`
    • + + ${offer.imageUrl !== undefined + ? html`` + : ''} +

      ${offer.title}

      +

      ${offer.subtitle}

      +

      ${offer.bottomLine}

      +
    • `.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 = (target).dataset?.offerId; + if (offerId !== undefined) { + this.onOfferClick(offerId); + break; + } + target = (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`
        ${this.offerList === null @@ -83,49 +161,23 @@ export class OfferListComponent implements IOfferListComponent { this.shown = false; } - protected generateOfferHtml(offer: IOfferPreview): string { - return html`
      • - - ${offer.imageUrl !== undefined - ? html`` - : ''} -

        ${offer.title}

        -

        ${offer.subtitle}

        -

        ${offer.bottomLine}

        -
      • `.toString(); - } - - protected onClickListener = (e: MouseEvent) => { - let target = e.target; - while (target) { - const offerId = (target).dataset?.offerId; - if (offerId !== undefined) { - this.onOfferClick(offerId); - break; - } - target = (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, diff --git a/docs/examples/01. Decomposing UI Components/src/OfferPanelButton.ts b/docs/examples/01. Decomposing UI Components/src/OfferPanelButton.ts index f418004..f9838e2 100644 --- a/docs/examples/01. Decomposing UI Components/src/OfferPanelButton.ts +++ b/docs/examples/01. Decomposing UI Components/src/OfferPanelButton.ts @@ -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 = new EventEmitter(); diff --git a/docs/examples/01. Decomposing UI Components/src/OfferPanelComponent.ts b/docs/examples/01. Decomposing UI Components/src/OfferPanelComponent.ts index 07367ce..15ebd77 100644 --- a/docs/examples/01. Decomposing UI Components/src/OfferPanelComponent.ts +++ b/docs/examples/01. Decomposing UI Components/src/OfferPanelComponent.ts @@ -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 = 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`

        ${this.currentOffer.title}

        @@ -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; } diff --git a/docs/examples/01. Decomposing UI Components/src/SearchBox.ts b/docs/examples/01. Decomposing UI Components/src/SearchBox.ts index aee82a9..5c4e39b 100644 --- a/docs/examples/01. Decomposing UI Components/src/SearchBox.ts +++ b/docs/examples/01. Decomposing UI Components/src/SearchBox.ts @@ -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 = 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 | 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 | 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 { // 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 { 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`