1
0
mirror of https://github.com/twirl/The-API-Book.git synced 2025-02-16 18:34:31 +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',

View File

@ -55,7 +55,7 @@ A question arises: If an offer of the coffee chain is shown in the panel, which
It is very easy to demonstrate how coupling several subject areas in one entity leads to highly sophisticated and unobvious logic. As the same arguments are applicable to the “Show location” button as well, it is kind of obvious that specialized options should take precedence over general ones. In our case, the type of a button should have more priority than some abstract “icon” data property.
But it is not the end of the story. If the developer still wants exactly this, i.e., to show a coffee shop chain icon (if any) on the order creation button, then what should they do? Following the same logic, we should provide an even more specialized possibility to do so. For example, we can adopt the following logic: if there is a `checkoutButtonIconUrl` property in the data, the icon will be taken from this field. Developers could customize the order creation button by overwriting this `checkoutButtonIconUrl` field for every search result:
But it is not the end of the story. If the developer still wants exactly this, i.e., to show a coffee shop chain icon (if any) on the order creation button, then what should they do? Following the same logic, we should provide an even more specialized possibility to do so. For example, we can adopt the following logic: if there is a `createOrderButtonIconUrl` property in the data, the icon will be taken from this field. Developers could customize the order creation button by overwriting this `createOrderButtonIconUrl` field for every search result:
```
const searchBox = new SearchBox({
@ -64,7 +64,7 @@ const searchBox = new SearchBox({
searchFunction: function (params) {
const res = await api.search(params);
res.forEach(function (item) {
item.checkoutButtonIconUrl =
item.createOrderButtonIconUrl =
<the URL of the icon>;
});
return res;

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 651 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

View File

@ -55,7 +55,7 @@
Можно легко продемонстрировать, как пересечение нескольких предметных областей в одном объекте быстро приводит к крайне запутанной и неочевидной логике. Поскольку те же соображения справедливы и для кнопки «Показать на карте», вроде бы очевидно, что по умолчанию более частные свойства должны побеждать более общие, т.е. тип кнопки должен быть приоритетнее какой-то абстрактной «иконки» в данных.
Но на этом история не заканчивается. Если разработчик всё-таки хочет именно этого, т.е. показывать иконку сети кофеен (если она есть) на кнопке создания заказа — как ему это сделать? Из той же логики, нам необходимо предоставить ещё более частную возможность такого переопределения. Например, представим себе следующую функциональность: если в данных предложения есть поле `checkoutButtonIconUrl`, то иконка будет взята из этого поля. Тогда разработчик сможет кастомизировать кнопку заказа, подменив в данных поле `checkoutButtonIconUrl` для каждого результата поиска:
Но на этом история не заканчивается. Если разработчик всё-таки хочет именно этого, т.е. показывать иконку сети кофеен (если она есть) на кнопке создания заказа — как ему это сделать? Из той же логики, нам необходимо предоставить ещё более частную возможность такого переопределения. Например, представим себе следующую функциональность: если в данных предложения есть поле `createOrderButtonIconUrl`, то иконка будет взята из этого поля. Тогда разработчик сможет кастомизировать кнопку заказа, подменив в данных поле `createOrderButtonIconUrl` для каждого результата поиска:
```
const searchBox = new SearchBox({
@ -64,7 +64,7 @@ const searchBox = new SearchBox({
searchFunction: function (params) {
const res = await api.search(params);
res.forEach(function (item) {
item.checkoutButtonIconUrl =
item.createOrderButtonIconUrl =
<URL нужной иконки>;
});
return res;

View File

@ -1 +1,246 @@
### Декомпозиция UI-компонентов
### [Декомпозиция UI-компонентов][sdk-decomposing]
Перейдём к более предметному разговору и попробуем объяснить, почему требование возможности замены одной из подсистем компонента (внешнего вида, бизнес-логики или поведения) приводит к кратному усложнению интерфейсов. Продолжим рассматривать пример компонента `SearchBox` из предыдущей главы. Напомним, что мы выделили следующие факторы, осложняющие декомпозицию визуальных компонентов:
* объединение в одном объекте разнородной функциональности, а именно — бизнес-логики, настройки внешнего вида и поведения компонента;
* появление разделяемых ресурсов, т.е. состояния объекта, которое могут одновременно читать и модифицировать разные акторы (включая конечного пользователя);
* неоднозначность иерархий наследования свойств компонентов.
Сделаем задачу более конкретной, и попробуем разработать наш `SearchBox` так, чтобы он допускал следующие модификации:
1. Брендирование предложения и кнопки заказа иконкой сети кофеен:
* иллюстрирует проблемы неоднозначности иерархий наследования и разделяемых ресурсов (иконки кнопки), как описано в предыдущей главе;
2. Замена списочного представления предложений, например, на представление в виде карты с метками:
* иллюстрирует проблему изменения дизайна одного субкомпонента (списка заказов) при сохранении поведения и дизайна остальных частей системы;
3. Добавление кнопки быстрого заказа в каждое предложение в списке:
* иллюстрирует проблему разделяемых ресурсов (см. более подробные пояснения ниже) и изменения UX системы в целом при сохранении существующего дизайна, поведения и бизнес-логики.
4. Добавление новой кнопки в панель создания заказа с дополнительной функцией (скажем, позвонить по телефону в кофейню):
* иллюстрирует проблему изменения бизнес-логики компонента при сохранении внешнего вида и UX компонента, а также неоднозначности иерархий наследования (поскольку новая кнопка должна располагать своими отдельными настройками).
[![APP](/img/mockups/05.png "Результаты поиска на карте")]()
[![APP](/img/mockups/06.png "Список результатов поиска с кнопками быстрых действий")]()
Решение, которое напрашивается в данной ситуации — это выделение двух дополнительных компонентов, отвечающих за представление списка предложений и за панель показа конкретного предложения, которые можно будет кастомизировать под конкретную задачу. Например, мы можем реализовать связь между ними следующим образом:
```
class SearchBox {
// Компонент списка предложений
public offerList: OfferList;
// Панель отображения
// выбранного предложения
public offerPanel: OfferPanel;
// Инициализация
setupComponents() {
// Подписываемся на клик по
// предложению
this.offerList.events.on(
'click',
(event) => {
this.selectedOffer =
event.target.offer;
this.offerPanel.show(
this.selectedOffer
);
});
this.offerPanel.on(
'click', () => {
this.createOrder();
}
)
}
// Ссылка на текущее выбранное предложение
private selectedOffer: Offer;
// Создаёт заказ
private createOrder() {
const order = await api
.createOrder(this.selectedOffer);
// Действия после создания заказа
}
}
```
В данном фрагменте кода налицо полный хаос с уровнями абстракции, и заодно сделано множество неявных предположений:
* единственный способ выбрать предложение — клик по элементу списка;
* единственный способ сделать заказ — клик внутри элемента «панель предложения»;
* заказ не может быть сделан, если предложение не было предварительно выбрано.
Из поставленных нами задач при такой декомпозиции мы можем условно решить только вторую (замена списка на карту), потому что к решению первой проблемы (замена иконки в кнопке заказа) мы не продвинулись вообще, а третью (кнопки быстрых действий в списке заказов) можно решить только следующим образом:
* сделать панель заказа невидимой / перенести её за границы экрана;
* после события `"click"` на кнопке создания заказа дождаться окончания отрисовки невидимой панели и сгенерировать на ней фиктивное событие `"click"`.
Думаем, излишне уточнять, что подобные решения ни в каком случае не могут считать разумным API для UI-компонента. Но как же нам всё-таки *сделать* этот интерфейс расширяемым?
Первый очевидный шаг заключается в том, чтобы `SearchBox` перестал реагировать на низкоуровневые события типа `click`, а стал только лишь контекстом для нижележащих сущностей и работал в терминах своего уровня абстракции. А для этого нам нужно в первую очередь установить, что же он из себя представляет *логически*, какова его область ответственности как компонента?
Предположим, что мы определим `SearchBox` концептуально как конечный автомат, находящийся в одном из трёх состояний:
1. Пуст (ожидает запроса пользователя и получения списка предложений).
2. Показан список предложений по запросу.
3. Показано конкретное предложение пользователю.
4. Создаётся заказ.
Соответствующие интерфейсы должны быть и предъявлены субкомпонентам: они должны нотифицировать об одном из переходов. Соответственно, `SearchBox` должен ждать не события `click`, а событий типа `selectOffer` и `createOrder`:
```
this.offerList.on(
'selectOffer',
(event) => {
this.selectedOffer =
event.offer;
this.offerPanel.show(
this.selectedOffer
);
});
this.offerPanel.on(
'createOrder', () => {
this.createOrder();
}
)
```
Возможности по кастомизации субкомпонентов расширились: теперь нет нужды эмулировать `'click'` для выбора предложения, есть семантический способ сделать это через событие `selectOffer`; аналогично, какие события обрабатывает панель предложения для бросания события `createOrder` — больше не забота самого `SearchBox`-а.
Однако описанный выше пример — с заказом свайпом по элементу списка — всё ещё реализуется «костыльно» через открытие невидимой панели, поскольку вызов `offerPanel.show` всё ещё жёстко вшит в сам `SearchBox`. Мы можем сделать ещё один шаг, и сделать связность ещё более слабой: пусть `SearchBox` не вызывает напрямую методы субкомпонентов, а только извещает об изменении собственного состояния:
```
this.offerList.on(
'selectOffer',
(event) => {
this.selectedOffer =
event.offer;
this.emit('stateChange', {
selectedOffer: this.
selectedOffer
});
});
this.offerPanel.on(
'createOrder', () => {
const order = await api.createOrder();
this.state = 'orderCreated';
this.emit('stateChange', {
order
});
}
)
```
Тем самым мы даём имплементациям компонентов бо́льшую свободу действий. `offerPanel` не обязана «открываться», если такого состояния в ней нет и может просто проигнорировать изменения состояния на `offerSelected`. Наконец, мы могли бы полностью абстрагироваться от нижележащего UI, если сделаем `SearchBox` транслятором несущественного для него события `"selectOffer"`:
```
// Имплементация SearchBox
class SearchBox {
public onMessage(message) {
switch (message.type) {
case 'selectOffer':
this.emit('stateChange', {
selectedOffer: message.offer
});
break;
}
}
};
// Имплементация OfferList
class OfferList {
public context: SearchBox;
onOfferClick(offer) {
// Компонент-список предложений
// инициирует выбор конкретного
// предложения через нотификацию
// родительского контекста
this.context.onMessage({
type: 'selectOffer',
offer
});
}
}
```
Это решение выглядит достаточно общим и в своём роде идеальным (`SearchBox` сведён к своей чистой функциональности — получению списка предложений по запросу пользователя), но при этом является, увы, очень ограниченно применимым:
* он содержит очень мало функциональности, которая реально помогала бы программисту в его работе;
* он включает функциональность трансляции событий, которые ничего не значат для самого `SearchBox` и являются очевидно излишними на этом уровне;
* он заставляет программиста досконально разобраться в механике работы каждого субкомпонента и имплементировать её полностью, если необходима альтернативная реализация.
Или, если сформулировать другими словами, наш `SearchBox` не «перекидывает мостик», не сближает два программных контекста (высокоуровневый `SearchBox` и низкоуровневую имплементацию, скажем, `offerPanel`-а). Пусть, например, разработчик хочет сделать не сложную замену UX, а очень простую вещь: сменить дизайн кнопки «Заказать» на какой-то другой. Проблема заключается в том, что альтернативная кнопка не бросает никаких событий `'createOrder'` — она генерирует самый обычный `'click'`. А значит, разработчику придётся написать эту логику самостоятельно.
```
class MyOfferPanel implements IOfferPanel {
protected parentSearchBox;
render() {
this.button = new CustomButton();
this.button.on('click', () => {
this.parentSearchBox.notify(
'createOrder'
)
});
}
}
```
В нашем примере это не выглядит чем-то сложным (но это только потому, что наш конечный автомат очень прост и содержит очень мало данных), но трудно не согласиться с тем, что необходимость писать подобный код совершенно неоправдана: почти любая альтернативная реализация кнопки генерирует именно событие `'click'`.
Другая очень большая проблема состоит в том, что с подобным «плоским» интерфейсом (любой актор может отправить события `selectOffer` / `createOrder`) мы фактически просто перевернули дырявую изоляцию абстракций с ног на голову: раньше `SearchBox` должен был знать о низкоуровневых объектах и их поведении — теперь низкоуровневые объекты должны знать о логике работы `SearchBox`. Такая перевёрнутая пирамида лучше прямой (нам хотя бы не приходится эмулировать клики на скрытых объектах), но далеко не идеальна с точки зрения архитектуры конкретного приложения. Написанный в этой парадигме код практически невозможно использовать повторно (приведённый выше пример `MyOfferPanel` нельзя использовать для каких-либо других целей, потому что действие по клику на кнопку всегда одно и то же — создание заказа), что приводит к необходимости копипастинга кода со всеми вытекающими проблемами.
Мы можем решить и эту проблему, если искусственным образом «перекинем мостик» — введём дополнительный уровень абстракции (назовём его, скажем, «арбитром»), который позволяет транслировать контексты:
```
class Arbiter implements IArbiter {
protected currentSelectedOffer;
constructor(
searchBox: ISearchBox,
offerPanel: IOfferPanel
) {
// Панель показа предложений
// должна быть каким-то образом
// привязана к арбитру
offerPanel.setArbiter(this);
searchBox.on('stateChange', (event) => {
// Арбитр переформулирует события
// `searchBox` в требования к
// панели предложений.
// Если выбрано новое предложение
if (this.currentSelectedOffer !=
event.offer) {
// Запоминаем предложение
this.currentSelectedOffer =
event.offer;
// Даём команду на открытие панели
this.emit('showPanel', {
content: this.generateOfferContent(
this.currentSelectedOffer
)
});
}
});
// Если же от кнопки создания заказа
// пришло событие 'click'
this.offerPanel.createOrderButton.on(
'click',
() => {
this.searchBox.notify('createOrder');
}
);
}
protected generateOfferContent(offer) {
// Формирует контент панели
}
}
```
Таким образом, мы убрали сильную связность компонентов: можно отдельно переопределить класс кнопки создания заказа (достаточно, чтобы он генерировал событие `'click'`) и даже саму панель целиком. Вся *специфическая* логика, относящаяся к работе панели показа приложений, теперь собрана в арбитре — саму панель можно переиспользовать в других частях приложения.
Более того, мы можем пойти дальше и сделать два уровня арбитров — между `SearchBox` и панелью предложений и между панелью предложений и кнопкой создания заказа. Тогда у нас пропадёт требование к `IOfferPanel` иметь поле `createOrderButton`, и мы сможем свободно комбинировать разные варианты: альтернативный способ подтверждения заказа (не по кнопке), альтернативная реализация панели с сохранением той же кнопки и т.д.
Единственной проблемой остаётся потрясающая сложность и неочевидность имплементации такого решения со всеми слоями промежуточных арбитров. Таков путь.