1
0
mirror of https://github.com/twirl/The-API-Book.git synced 2025-06-24 22:36:43 +02:00

Further dive into decomposing

This commit is contained in:
Sergey Konstantinov
2023-07-30 23:04:39 +03:00
parent c1358cb70b
commit c35d5ae61d
18 changed files with 621 additions and 207 deletions

View File

@ -10,9 +10,9 @@ The `src` folder contains a TypeScript code for the component and corresponding
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 following improvements to the code are left as an exercise for the reader:
* Make all builder functions options
* Returning operation status from the `SearchBox.search` method:
* Make all builder functions configurable through options
* Create a separate composer to close the gap between `OfferPanelComponent` and its buttons
* Add returning an operation status from the `SearchBox.search` method:
```
public search(query: string): Promise<OperationResult>
```

View File

@ -18,6 +18,16 @@
padding: 5px;
}
header * {
font-size: 16px;
margin: 0;
padding: 0;
}
header {
padding-bottom: 10px;
}
ul#example-list {
list-style-type: none;
margin: 0;
@ -51,6 +61,7 @@
#live-example {
min-width: 390px;
min-height: 500px;
float: left;
}
@ -66,10 +77,27 @@
<script src="sandbox.js"></script>
</head>
<body>
<h1>
Examples of Overriding Different Aspects of a Decomposed UI
Component
</h1>
<header>
<p>
Studying materials for
<strong
><a href="https://twirl.github.io/The-API-Book/"
>“The API” book</a
>
by Sergey Konstantinov.</strong
>
</p>
<p>
<a href="https://twirl.github.io/The-API-Book/#sdk-decomposing"
>Chapter 43: Problems of Introducing UI Components</a
>. Find the source code of the components and additional tasks
for self-study at the
<a
href="https://github.com/twirl/The-API-Book/tree/gh-pages/docs/examples/01.%20Decomposing%20UI%20Components"
>Github repository.</a
>
</p>
</header>
<div id="playground">
<div id="live-example">
<div>Live example <button id="refresh">Refresh</button></div>
@ -77,26 +105,32 @@
</div>
<div id="examples">
<ul id="example-list">
<li id="example-00">
<a href="javascript:showExample('00')"
>The reference: A regular <code>SearchBox</code></a
>
</li>
<li id="example-01">
<a href="javascript:showExample('01')"
>Example #1: A regular <code>SearchBox</code></a
>Example #1: A <code>SearchBox</code> with a custom
icon</a
>
</li>
<li id="example-02">
<a href="javascript:showExample('02')"
>Example #2: A <code>SearchBox</code> with a custom
icon</a
>Example #2: A <code>SearchBox</code> with a map</a
>
</li>
<li id="example-03">
<a href="javascript:showExample('03')"
>Example #3: A <code>SearchBox</code> with a map</a
>Example #3: A <code>SearchBox</code> with in-place
actions</a
>
</li>
<li id="example-04">
<a href="javascript:showExample('04')"
>Example #4: A <code>SearchBox</code> with in-place
actions</a
>Example #4: A <code>SearchBox</code> with a dynamic
set of buttons</a
>
</li>
</ul>

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,12 @@
class CustomSearchBox extends ourCoffeeSdk.SearchBox {
createOrder(offer) {
alert(`Isn't actually implemented (yet)`);
return super.createOrder(offer);
}
}
const searchBox = new CustomSearchBox(
document.getElementById("search-box"),
ourCoffeeSdk.dummyCoffeeApi
);
searchBox.search("Lungo");

View File

@ -1,4 +1,56 @@
const buildCustomOrderButton = function (
offer,
container
) {
return ourCoffeeSdk.OfferPanelComponent.buildCreateOrderButton(
offer,
container,
{
createOrderButtonUrl:
offer && offer.createOrderButtonIcon,
createOrderButtonText:
(offer &&
`Buy now for just ${offer.price.formattedValue}`) ||
"Place an Order"
}
);
};
class CustomComposer extends ourCoffeeSdk.SearchBoxComposer {
generateOfferPreviews(offerList) {
const result = super.generateOfferPreviews(
offerList
);
return result === null
? result
: result.map((preview, index) => ({
...preview,
imageUrl: offerList[index].place.icon
}));
}
generateCurrentOfferFullView(offer, options) {
return offer === null
? offer
: {
...super.generateCurrentOfferFullView(
offer,
options
),
createOrderButtonIcon: offer.place.icon
};
}
}
class CustomSearchBox extends ourCoffeeSdk.SearchBox {
buildComposer(context, container, options) {
return new CustomComposer(
context,
container,
options
);
}
createOrder(offer) {
alert(`Isn't actually implemented (yet)`);
return super.createOrder(offer);
@ -7,6 +59,16 @@ class CustomSearchBox extends ourCoffeeSdk.SearchBox {
const searchBox = new CustomSearchBox(
document.getElementById("search-box"),
ourCoffeeSdk.dummyCoffeeApi
ourCoffeeSdk.dummyCoffeeApi,
{
offerPanel: {
buttonBuilders: [
buildCustomOrderButton,
ourCoffeeSdk.OfferPanelComponent
.buildCloseButton
],
closeButtonText: "❌Not Now"
}
}
);
searchBox.search("Lungo");

View File

@ -1,22 +1,78 @@
const buildCustomOrderButton = function (
offer,
container
) {
return ourCoffeeSdk.OfferPanelComponent.buildCreateOrderButton(
offer,
container,
{
createOrderButtonUrl:
offer && offer.createOrderButtonIcon,
createOrderButtonText:
(offer &&
`Buy now for just ${offer.price.formattedValue}`) ||
"Place an Order"
class CustomOfferList {
constructor(context, container, offerList) {
this.context = context;
this.container = container;
this.events =
new ourCoffeeSdk.util.EventEmitter();
this.offerList = null;
this.map = null;
this.onMarkerSelect = (markerId) => {
this.events.emit("offerSelect", {
offerId: markerId
});
};
this.setOfferList = ({
offerList: newOfferList
}) => {
if (this.map) {
this.map.destroy();
this.map = null;
}
this.offerList = newOfferList;
if (newOfferList) {
this.map = new ourCoffeeSdk.DummyMapApi(
this.container,
[
[16.355, 48.206],
[16.375, 48.214]
]
);
for (const offer of newOfferList) {
this.map.addMarker(
offer.offerId,
offer.location,
this.onMarkerSelect
);
}
}
};
this.setOfferList({ offerList });
this.contextListener = context.events.on(
"offerPreviewListChange",
this.setOfferList
);
}
destroy() {
if (this.map) {
this.map.destroy();
}
);
};
this.contextListener.off();
}
}
class CustomComposer extends ourCoffeeSdk.SearchBoxComposer {
buildOfferListComponent(
context,
container,
offerList,
contextOptions
) {
return new CustomOfferList(
context,
container,
this.generateOfferPreviews(
offerList,
contextOptions
),
this.generateOfferListComponentOptions(
contextOptions
)
);
}
generateOfferPreviews(offerList) {
const result = super.generateOfferPreviews(
offerList
@ -25,21 +81,10 @@ class CustomComposer extends ourCoffeeSdk.SearchBoxComposer {
? result
: result.map((preview, index) => ({
...preview,
imageUrl: offerList[index].place.icon
location:
offerList[index].place.location
}));
}
generateCurrentOfferFullView(offer, options) {
return offer === null
? offer
: {
...super.generateCurrentOfferFullView(
offer,
options
),
createOrderButtonIcon: offer.place.icon
};
}
}
class CustomSearchBox extends ourCoffeeSdk.SearchBox {
@ -59,16 +104,6 @@ class CustomSearchBox extends ourCoffeeSdk.SearchBox {
const searchBox = new CustomSearchBox(
document.getElementById("search-box"),
ourCoffeeSdk.dummyCoffeeApi,
{
offerPanel: {
buttonBuilders: [
buildCustomOrderButton,
ourCoffeeSdk.OfferPanelComponent
.buildCloseButton
],
closeButtonText: "❌Not Now"
}
}
ourCoffeeSdk.dummyCoffeeApi
);
searchBox.search("Lungo");

View File

@ -1,55 +1,75 @@
class CustomOfferList {
constructor(context, container, offerList) {
this.context = context;
this.container = container;
this.events =
new ourCoffeeSdk.util.EventEmitter();
this.offerList = null;
this.map = null;
this.onMarkerSelect = (markerId) => {
this.events.emit("offerSelect", {
offerId: markerId
});
};
this.setOfferList = ({
offerList: newOfferList
}) => {
if (this.map) {
this.map.destroy();
this.map = null;
}
this.offerList = newOfferList;
if (newOfferList) {
this.map = new ourCoffeeSdk.DummyMapApi(
this.container,
[
[16.355, 48.206],
[16.375, 48.214]
]
);
for (const offer of newOfferList) {
this.map.addMarker(
offer.offerId,
offer.location,
this.onMarkerSelect
class CustomOfferList extends ourCoffeeSdk.OfferListComponent {
constructor(
context,
container,
offerList,
options
) {
super(context, container, offerList, options);
this.onOfferButtonClickListener = (e) => {
const action = e.target.dataset.action;
const offerId = e.target.dataset.offerId;
if (action === "offerSelect") {
this.events.emit(action, { offerId });
} else if (action === "createOffer") {
const offer =
this.context.findOfferById(offerId);
if (offer) {
this.context.events.emit(
"createOrder",
{
offer
}
);
}
}
};
this.setOfferList({ offerList });
this.contextListener = context.events.on(
"offerPreviewListChange",
this.setOfferList
);
}
destroy() {
if (this.map) {
this.map.destroy();
generateOfferHtml(offer) {
return ourCoffeeSdk.util.html`<li
class="custom-offer"
>
<aside><button
data-offer-id="${ourCoffeeSdk.util.attrValue(
offer.offerId
)}"
data-action="createOffer">Buy now for ${
offer.price.formattedValue
}</button><button
data-offer-id="${ourCoffeeSdk.util.attrValue(
offer.offerId
)}"
data-action="offerSelect">View details
</button></aside>
<div><strong>${offer.title}</strong></div>
<div>${offer.subtitle}</div>
<div>${offer.bottomLine}</div>
</li>`.toString();
}
setupDomListeners() {
const buttons =
this.container.querySelectorAll("button");
for (const button of buttons) {
button.addEventListener(
"click",
this.onOfferButtonClickListener
);
}
}
teardownDomListeners() {
const buttons = document.querySelectorAll(
this.container,
"button"
);
for (const button of buttons) {
button.removeEventListener(
"click",
this.onOfferButtonClickListener
);
}
this.contextListener.off();
}
}
@ -72,19 +92,6 @@ class CustomComposer extends ourCoffeeSdk.SearchBoxComposer {
)
);
}
generateOfferPreviews(offerList) {
const result = super.generateOfferPreviews(
offerList
);
return result === null
? result
: result.map((preview, index) => ({
...preview,
location:
offerList[index].place.location
}));
}
}
class CustomSearchBox extends ourCoffeeSdk.SearchBox {

View File

@ -1,102 +1,82 @@
class CustomOfferList extends ourCoffeeSdk.OfferListComponent {
constructor(
context,
container,
offerList,
options
) {
super(context, container, offerList, options);
this.onOfferButtonClickListener = (e) => {
const action = e.target.dataset.action;
const offerId = e.target.dataset.offerId;
if (action === "offerSelect") {
this.events.emit(action, { offerId });
} else if (action === "createOffer") {
const offer =
this.context.findOfferById(offerId);
if (offer) {
this.context.events.emit(
"createOrder",
{
offer
}
);
}
}
};
}
const {
SearchBox,
SearchBoxComposer,
OfferPanelComponent,
OfferPanelButton,
util,
dummyCoffeeApi
} = ourCoffeeSdk;
generateOfferHtml(offer) {
return ourCoffeeSdk.util.html`<li
class="custom-offer"
>
<div><strong>${offer.title}</strong></div>
<div>${offer.subtitle}</div>
<div>${offer.bottomLine} <button
data-offer-id="${ourCoffeeSdk.util.attrValue(
offer.offerId
)}"
data-action="createOffer"
>Buy now for ${
offer.price.formattedValue
}</button><button
data-offer-id="${ourCoffeeSdk.util.attrValue(
offer.offerId
)}"
data-action="offerSelect"
>View details</button>
</div>
<hr/>
</li>`.toString();
}
const buildCallButton = function (
offer,
container,
options
) {
return new OfferPanelButton("call", container, {
text: util.html`<a href="tel:${util.attrValue(
offer.phone
)}" style="color: inherit; text-decoration: none;">${
options.callButtonText
}</a>`
});
};
setupDomListeners() {
const buttons =
this.container.querySelectorAll("button");
for (const button of buttons) {
button.addEventListener(
"click",
this.onOfferButtonClickListener
);
}
}
const buildCreateOrderButton =
OfferPanelComponent.buildCreateOrderButton;
const buildCloseButton =
OfferPanelComponent.buildCloseButton;
teardownDomListeners() {
const buttons = document.querySelectorAll(
this.container,
"button"
);
for (const button of buttons) {
button.removeEventListener(
"click",
this.onOfferButtonClickListener
);
}
class CustomOfferPanel extends OfferPanelComponent {
show() {
this.options.buttonBuilders = this
.currentOffer.phone
? [
buildCreateOrderButton,
buildCallButton,
buildCloseButton
]
: [
buildCreateOrderButton,
buildCloseButton
];
return super.show();
}
}
class CustomComposer extends ourCoffeeSdk.SearchBoxComposer {
buildOfferListComponent(
class CustomComposer extends SearchBoxComposer {
buildOfferPanelComponent(
context,
container,
offerList,
currentOffer,
contextOptions
) {
return new CustomOfferList(
return new CustomOfferPanel(
context,
container,
this.generateOfferPreviews(
offerList,
this.generateCurrentOfferFullView(
currentOffer,
contextOptions
),
this.generateOfferListComponentOptions(
this.generateOfferPanelComponentOptions(
contextOptions
)
);
}
generateCurrentOfferFullView(offer, options) {
const offerFullView =
super.generateCurrentOfferFullView(
offer,
options
);
if (offer && offer.place.phone) {
offerFullView.phone = offer.place.phone;
}
return offerFullView;
}
}
class CustomSearchBox extends ourCoffeeSdk.SearchBox {
class CustomSearchBox extends SearchBox {
buildComposer(context, container, options) {
return new CustomComposer(
context,
@ -113,6 +93,13 @@ class CustomSearchBox extends ourCoffeeSdk.SearchBox {
const searchBox = new CustomSearchBox(
document.getElementById("search-box"),
ourCoffeeSdk.dummyCoffeeApi
dummyCoffeeApi,
{
offerPanel: {
createOrderButtonText: "🛒Place an Order",
callButtonText: "☎️ Make a Call",
closeButtonText: "❌Not Now"
}
}
);
searchBox.search("Lungo");

View File

@ -62,6 +62,6 @@ window.onload = function () {
codeLens: false,
fontFamily: 'local-monospace'
});
showExample('01');
showExample('00');
});
};

View File

@ -25,7 +25,6 @@ export class OfferPanelComponent implements IOfferPanelComponent {
container: HTMLElement;
}> = [];
protected listenerDisposers: IDisposer[] = [];
protected buttonBuilders: ButtonBuilder[];
protected shown: boolean = false;
@ -35,10 +34,6 @@ export class OfferPanelComponent implements IOfferPanelComponent {
protected currentOffer: IOfferFullView | null,
protected readonly options: OfferPanelComponentOptions
) {
this.buttonBuilders = options.buttonBuilders ?? [
OfferPanelComponent.buildCreateOrderButton,
OfferPanelComponent.buildCloseButton
];
this.listenerDisposers.push(
this.context.events.on(
'offerFullViewToggle',
@ -116,7 +111,11 @@ export class OfferPanelComponent implements IOfferPanelComponent {
}
protected setupButtons() {
for (const buttonBuilder of this.buttonBuilders) {
const buttonBuilders = this.options.buttonBuilders ?? [
OfferPanelComponent.buildCreateOrderButton,
OfferPanelComponent.buildCloseButton
];
for (const buttonBuilder of buttonBuilders) {
const container = document.createElement('li');
this.buttonsContainer.appendChild(container);
const button = buttonBuilder(

View File

@ -23,6 +23,7 @@ export interface ISearchResult {
walkTime: IFormattedDuration;
location: ILocation;
icon?: string;
phone?: string;
};
price: IFormattedPrice;
}

View File

@ -182,3 +182,34 @@
color: #3a3a3c;
background: transparent;
}
.custom-offer {
clear: right;
border-bottom: 0.5px solid rgba(60, 60, 67, 0.36);
font-size: 15px;
line-height: 34px;
letter-spacing: -0.24px;
margin: 5px 0;
}
.custom-offer > aside {
float: right;
width: 120px;
text-align: right;
}
.custom-offer > aside > button {
height: 30px;
width: 120px;
background: #3a3a3c;
border-radius: 7px;
font-size: 12px;
line-height: 22px;
text-align: center;
letter-spacing: -0.408px;
color: #ffffff;
border: transparent;
cursor: pointer;
margin: 1px 0;
mix-blend-mode: luminosity;
}

View File

@ -25,7 +25,8 @@ export const CAFEE_CHAMOMILE_LUNGO_OFFER = {
intervalValueSeconds: 120,
formattedValue: '2 min'
},
icon: '../../assets/coffee.png'
icon: '../../assets/coffee.png',
phone: '12484345508'
},
price: {
decimalValue: '37.00',