1
0
mirror of https://github.com/twirl/The-API-Book.git synced 2025-05-25 22:08:06 +02:00

examples refactoring

This commit is contained in:
Sergey Konstantinov 2023-08-01 01:25:30 +03:00
parent c35d5ae61d
commit 31e9a55e6c
19 changed files with 711 additions and 384 deletions

File diff suppressed because one or more lines are too long

View File

@ -88,7 +88,8 @@
> >
</p> </p>
<p> <p>
<a href="https://twirl.github.io/The-API-Book/#sdk-decomposing" <a
href="https://twirl.github.io/The-API-Book/API.en.html#chapter-44"
>Chapter 43: Problems of Introducing UI Components</a >Chapter 43: Problems of Introducing UI Components</a
>. Find the source code of the components and additional tasks >. Find the source code of the components and additional tasks
for self-study at the for self-study at the
@ -112,25 +113,19 @@
</li> </li>
<li id="example-01"> <li id="example-01">
<a href="javascript:showExample('01')" <a href="javascript:showExample('01')"
>Example #1: A <code>SearchBox</code> with a custom >Example #1: A <code>SearchBox</code> with a map</a
icon</a
> >
</li> </li>
<li id="example-02"> <li id="example-02">
<a href="javascript:showExample('02')" <a href="javascript:showExample('02')"
>Example #2: A <code>SearchBox</code> with a map</a >Example #2: A <code>SearchBox</code> with in-place
actions</a
> >
</li> </li>
<li id="example-03"> <li id="example-03">
<a href="javascript:showExample('03')" <a href="javascript:showExample('03')"
>Example #3: A <code>SearchBox</code> with in-place >Example #3: A <code>SearchBox</code> with custom
actions</a buttons</a
>
</li>
<li id="example-04">
<a href="javascript:showExample('04')"
>Example #4: A <code>SearchBox</code> with a dynamic
set of buttons</a
> >
</li> </li>
</ul> </ul>

File diff suppressed because one or more lines are too long

View File

@ -1,5 +1,5 @@
{ {
"printWidth": 50, "printWidth": 60,
"tabWidth": 2, "tabWidth": 2,
"trailingComma": "none" "trailingComma": "none"
} }

View File

@ -1,54 +1,101 @@
const buildCustomOrderButton = function ( const {
offer, SearchBox,
container SearchBoxComposer,
) { DummyMapApi,
return ourCoffeeSdk.OfferPanelComponent.buildCreateOrderButton( dummyCoffeeApi,
offer, util
container, } = ourCoffeeSdk;
{
createOrderButtonUrl: class CustomOfferList {
offer && offer.createOrderButtonIcon, constructor(context, container, offerList) {
createOrderButtonText: this.context = context;
(offer && this.container = container;
`Buy now for just ${offer.price.formattedValue}`) || this.events = new util.EventEmitter();
"Place an Order" this.offerList = null;
this.map = null;
this.onMarkerClick = (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 DummyMapApi(this.container, [
[16.355, 48.2],
[16.375, 48.214]
]);
for (const offer of newOfferList) {
this.map.addMarker(
offer.offerId,
offer.location,
this.onMarkerClick
); );
}
}
}; };
class CustomComposer extends ourCoffeeSdk.SearchBoxComposer { this.setOfferList({ offerList });
generateOfferPreviews(offerList) { this.contextListeners = [
const result = super.generateOfferPreviews( context.events.on(
offerList "offerPreviewListChange",
this.setOfferList
),
context.events.on(
"offerFullViewToggle",
({ offer }) => {
this.map.selectSingleMarker(
offer && offer.offerId
); );
}
)
];
}
destroy() {
if (this.map) {
this.map.destroy();
}
for (const listener of this.contextListeners) {
listener.off();
}
}
}
class CustomComposer extends 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);
return result === null return result === null
? result ? result
: result.map((preview, index) => ({ : result.map((preview, index) => ({
...preview, ...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 { class CustomSearchBox extends SearchBox {
buildComposer(context, container, options) { buildComposer(context, container, options) {
return new CustomComposer( return new CustomComposer(context, container, options);
context,
container,
options
);
} }
createOrder(offer) { createOrder(offer) {
@ -59,15 +106,10 @@ class CustomSearchBox extends ourCoffeeSdk.SearchBox {
const searchBox = new CustomSearchBox( const searchBox = new CustomSearchBox(
document.getElementById("search-box"), document.getElementById("search-box"),
ourCoffeeSdk.dummyCoffeeApi, dummyCoffeeApi,
{ {
offerPanel: { offerPanel: {
buttonBuilders: [ transparent: true
buildCustomOrderButton,
ourCoffeeSdk.OfferPanelComponent
.buildCloseButton
],
closeButtonText: "❌Not Now"
} }
} }
); );

View File

@ -1,56 +1,76 @@
class CustomOfferList { class CustomOfferList extends ourCoffeeSdk.OfferListComponent {
constructor(context, container, offerList) { constructor(
this.context = context; context,
this.container = container; container,
this.events = offerList,
new ourCoffeeSdk.util.EventEmitter(); options
this.offerList = null; ) {
this.map = null; super(context, container, offerList, options);
this.onOfferButtonClickListener = (e) => {
this.onMarkerSelect = (markerId) => { const action = e.target.dataset.action;
this.events.emit("offerSelect", { const offerId = e.target.dataset.offerId;
offerId: markerId if (action === "offerSelect") {
}); this.events.emit(action, { offerId });
}; } else if (action === "createOffer") {
this.setOfferList = ({ const offer =
offerList: newOfferList this.context.findOfferById(offerId);
}) => { if (offer) {
if (this.map) { this.context.events.emit(
this.map.destroy(); "createOrder",
this.map = null; {
offer
} }
this.offerList = newOfferList; );
if (newOfferList) { }
this.map = new ourCoffeeSdk.DummyMapApi( }
};
}
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, this.container,
[ "button"
[16.355, 48.206],
[16.375, 48.214]
]
); );
for (const offer of newOfferList) { for (const button of buttons) {
this.map.addMarker( button.removeEventListener(
offer.offerId, "click",
offer.location, this.onOfferButtonClickListener
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 { class CustomComposer extends ourCoffeeSdk.SearchBoxComposer {
@ -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 { class CustomSearchBox extends ourCoffeeSdk.SearchBox {

View File

@ -1,106 +1,189 @@
class CustomOfferList extends ourCoffeeSdk.OfferListComponent { const {
constructor( SearchBox,
context, SearchBoxComposer,
OfferPanelComponent,
OfferPanelButton,
util,
dummyCoffeeApi
} = ourCoffeeSdk;
const { buildCreateOrderButton, buildCloseButton } =
OfferPanelComponent;
const buildCustomOrderButton = function (offer, container) {
return OfferPanelComponent.buildCreateOrderButton(
offer,
container,
{
createOrderButtonUrl:
offer && offer.createOrderButtonIcon,
createOrderButtonText:
(offer &&
`Buy now for just ${offer.price.formattedValue}`) ||
"Place an Order"
}
);
};
const buildCallButton = function (
offer,
container, container,
offerList,
options options
) { ) {
super(context, container, offerList, options); return new OfferPanelButton("call", container, {
this.onOfferButtonClickListener = (e) => { text: util.html`<a href="tel:${util.attrValue(
const action = e.target.dataset.action; offer.phone
const offerId = e.target.dataset.offerId; )}" style="color: inherit; text-decoration: none;">${
if (action === "offerSelect") { options.callButtonText
this.events.emit(action, { offerId }); }</a>`
} else if (action === "createOffer") { });
const offer =
this.context.findOfferById(offerId);
if (offer) {
this.context.events.emit(
"createOrder",
{
offer
}
);
}
}
}; };
}
generateOfferHtml(offer) { const buildPreviousOfferButton = function (
return ourCoffeeSdk.util.html`<li offer,
class="custom-offer" container
> ) {
<aside><button return new NavigateButton(
data-offer-id="${ourCoffeeSdk.util.attrValue( "left",
offer.offerId offer.previousOfferId,
)}" container
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
); );
};
const buildNextOfferButton = function (offer, container) {
return new NavigateButton(
"right",
offer.nextOfferId,
container
);
};
class NavigateButton {
constructor(direction, offerId, container) {
this.action = "navigate";
this.offerId = offerId;
this.events = new util.EventEmitter();
const button = (this.button =
document.createElement("button"));
button.innerHTML = direction === "left" ? "⟨" : "⟩";
button.className = direction;
container.classList.add("custom-control");
this.container = container;
this.listener = () =>
this.events.emit("press", {
target: this
});
button.addEventListener("click", this.listener);
container.appendChild(button);
}
destroy() {
this.button.removeEventListener("click", this.listener);
this.button.parentElement.removeChild(this.button);
this.container.classList.remove("custom-control");
} }
} }
teardownDomListeners() { class CustomOfferPanel extends OfferPanelComponent {
const buttons = document.querySelectorAll( show() {
this.container, const buttons = [];
"button" const offer = this.currentOffer;
); if (offer.previousOfferId) {
for (const button of buttons) { buttons.push(buildPreviousOfferButton);
button.removeEventListener(
"click",
this.onOfferButtonClickListener
);
} }
buttons.push(buildCreateOrderButton);
if (offer.phone) {
buttons.push(buildCallButton);
}
buttons.push(buildCloseButton);
if (offer.nextOfferId) {
buttons.push(buildNextOfferButton);
}
this.options.buttonBuilders = buttons;
super.show();
} }
} }
class CustomComposer extends ourCoffeeSdk.SearchBoxComposer { class CustomComposer extends SearchBoxComposer {
buildOfferListComponent( buildOfferPanelComponent(
context, context,
container, container,
offerList, currentOffer,
contextOptions contextOptions
) { ) {
return new CustomOfferList( return new CustomOfferPanel(
context, context,
container, container,
this.generateOfferPreviews( this.generateCurrentOfferFullView(
offerList, currentOffer,
contextOptions contextOptions
), ),
this.generateOfferListComponentOptions( {
...CustomComposer.DEFAULT_OPTIONS,
...this.generateOfferPanelComponentOptions(
contextOptions contextOptions
) )
}
); );
} }
generateOfferPreviews(offerList) {
const result = super.generateOfferPreviews(offerList);
return result === null
? result
: result.map((preview, index) => ({
...preview,
imageUrl: offerList[index].place.icon
}));
}
generateCurrentOfferFullView(offer, options) {
const offerFullView =
super.generateCurrentOfferFullView(offer, options);
if (offer) {
if (offer.place.phone) {
offerFullView.phone = offer.place.phone;
}
if (offer.place.icon) {
offerFullView.createOrderButtonIcon =
offer.place.icon;
}
if (this.offerList) {
const offers = this.offerList;
const index = offers.findIndex(
({ offerId }) => offerId === offer.offerId
);
if (index > 0) {
offerFullView.previousOfferId =
offers[index - 1].offerId;
}
if (index < offers.length - 1 && index >= 0) {
offerFullView.nextOfferId =
offers[index + 1].offerId;
}
}
}
return offerFullView;
}
performAction(event) {
if (event.action === "navigate") {
this.selectOffer(event.target.offerId);
} else {
super.performAction(event);
}
}
} }
class CustomSearchBox extends ourCoffeeSdk.SearchBox { CustomComposer.DEFAULT_OPTIONS = {
createOrderButtonText: "🛒Place an Order",
callButtonText: "☎️ Make a Call",
closeButtonText: "❌Not Now"
};
class CustomSearchBox extends SearchBox {
buildComposer(context, container, options) { buildComposer(context, container, options) {
return new CustomComposer( return new CustomComposer(context, container, options);
context,
container,
options
);
} }
createOrder(offer) { createOrder(offer) {
@ -111,6 +194,6 @@ class CustomSearchBox extends ourCoffeeSdk.SearchBox {
const searchBox = new CustomSearchBox( const searchBox = new CustomSearchBox(
document.getElementById("search-box"), document.getElementById("search-box"),
ourCoffeeSdk.dummyCoffeeApi dummyCoffeeApi
); );
searchBox.search("Lungo"); searchBox.search("Lungo");

View File

@ -1,105 +0,0 @@
const {
SearchBox,
SearchBoxComposer,
OfferPanelComponent,
OfferPanelButton,
util,
dummyCoffeeApi
} = ourCoffeeSdk;
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>`
});
};
const buildCreateOrderButton =
OfferPanelComponent.buildCreateOrderButton;
const buildCloseButton =
OfferPanelComponent.buildCloseButton;
class CustomOfferPanel extends OfferPanelComponent {
show() {
this.options.buttonBuilders = this
.currentOffer.phone
? [
buildCreateOrderButton,
buildCallButton,
buildCloseButton
]
: [
buildCreateOrderButton,
buildCloseButton
];
return super.show();
}
}
class CustomComposer extends SearchBoxComposer {
buildOfferPanelComponent(
context,
container,
currentOffer,
contextOptions
) {
return new CustomOfferPanel(
context,
container,
this.generateCurrentOfferFullView(
currentOffer,
contextOptions
),
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 SearchBox {
buildComposer(context, container, options) {
return new CustomComposer(
context,
container,
options
);
}
createOrder(offer) {
alert(`Isn't actually implemented (yet)`);
return super.createOrder(offer);
}
}
const searchBox = new CustomSearchBox(
document.getElementById("search-box"),
dummyCoffeeApi,
{
offerPanel: {
createOrderButtonText: "🛒Place an Order",
callButtonText: "☎️ Make a Call",
closeButtonText: "❌Not Now"
}
}
);
searchBox.search("Lungo");

View File

@ -18,7 +18,7 @@ export class OfferPanelButton implements IButton {
protected readonly options: IButtonOptions protected readonly options: IButtonOptions
) { ) {
this.container.innerHTML = html`<button this.container.innerHTML = html`<button
class="our-coffee-sdk-sdk-offer-panel-button" class="our-coffee-sdk-offer-panel-button"
> >
${this.options.iconUrl ${this.options.iconUrl
? html`<img src="${attrValue(this.options.iconUrl)}" />` ? html`<img src="${attrValue(this.options.iconUrl)}" />`
@ -27,7 +27,7 @@ export class OfferPanelButton implements IButton {
</button>`.toString(); </button>`.toString();
this.button = $<HTMLButtonElement>( this.button = $<HTMLButtonElement>(
this.container, this.container,
'.our-coffee-sdk-sdk-offer-panel-button' '.our-coffee-sdk-offer-panel-button'
); );
this.button.addEventListener('click', this.onClickListener, false); this.button.addEventListener('click', this.onClickListener, false);

View File

@ -97,7 +97,9 @@ export class OfferPanelComponent implements IOfferPanelComponent {
this.container, this.container,
'.our-coffee-sdk-offer-panel-buttons' '.our-coffee-sdk-offer-panel-buttons'
); );
if (!this.options.transparent) {
this.container.classList.add('our-coffee-sdk-cover-blur'); this.container.classList.add('our-coffee-sdk-cover-blur');
}
this.setupButtons(); this.setupButtons();
this.shown = true; this.shown = true;
} }
@ -150,11 +152,12 @@ export class OfferPanelComponent implements IOfferPanelComponent {
} }
}; };
protected onButtonPress = ({ target: { action } }: IButtonPressEvent) => { protected onButtonPress = ({ target }: IButtonPressEvent) => {
if (this.currentOffer !== null) { if (this.currentOffer !== null) {
this.events.emit('action', { this.events.emit('action', {
action, action: target.action,
offerId: this.currentOffer.offerId target,
currentOfferId: this.currentOffer.offerId
}); });
} else { } else {
// TBD // TBD
@ -165,6 +168,7 @@ export class OfferPanelComponent implements IOfferPanelComponent {
export interface OfferPanelComponentOptions export interface OfferPanelComponentOptions
extends IOfferPanelComponentOptions { extends IOfferPanelComponentOptions {
buttonBuilders?: ButtonBuilder[]; buttonBuilders?: ButtonBuilder[];
transparent?: boolean;
} }
export type ButtonBuilder = ( export type ButtonBuilder = (

View File

@ -87,6 +87,46 @@ export class SearchBoxComposer<ExtraOptions extends IExtraFields = {}>
); );
} }
public selectOffer(offerId: string) {
const offer = this.findOfferById(offerId);
// Offer may be missing for a variety of reasons,
// most notably of `OfferListComponent` renders
// offers asynchronously
if (offer !== null) {
this.currentOffer = offer;
this.events.emit('offerFullViewToggle', {
offer: this.generateCurrentOfferFullView(
this.currentOffer,
this.contextOptions
)
});
} else {
// TDB
}
}
public performAction({
action,
currentOfferId: offerId
}: IOfferPanelActionEvent) {
switch (action) {
case 'createOrder':
const offer = this.findOfferById(offerId);
// Offer may be missing if `OfferPanelComponent`
// renders offers asynchronously
if (offer !== null) {
this.events.emit('createOrder', { offer });
}
break;
case 'close':
if (this.currentOffer !== null) {
this.currentOffer = null;
this.events.emit('offerFullViewToggle', { offer: null });
}
break;
}
}
public destroy() { public destroy() {
for (const disposer of this.listenerDisposers) { for (const disposer of this.listenerDisposers) {
disposer.off(); disposer.off();
@ -120,45 +160,12 @@ export class SearchBoxComposer<ExtraOptions extends IExtraFields = {}>
}); });
}; };
protected onOfferPanelAction = ({ protected onOfferPanelAction = (event: IOfferPanelActionEvent) => {
action, this.performAction(event);
offerId
}: IOfferPanelActionEvent) => {
switch (action) {
case 'createOrder':
const offer = this.findOfferById(offerId);
// Offer may be missing if `OfferPanelComponent`
// renders offers asynchronously
if (offer !== null) {
this.events.emit('createOrder', { offer });
}
break;
case 'close':
if (this.currentOffer !== null) {
this.currentOffer = null;
this.events.emit('offerFullViewToggle', { offer: null });
}
break;
}
}; };
protected onOfferListOfferSelect = ({ offerId }: IOfferSelectedEvent) => { protected onOfferListOfferSelect = ({ offerId }: IOfferSelectedEvent) =>
const offer = this.findOfferById(offerId); this.selectOffer(offerId);
// Offer may be missing for a variety of reasons,
// most notably of `OfferListComponent` renders
// offers asynchronously
if (offer !== null) {
this.currentOffer = offer;
this.events.emit('offerFullViewToggle', {
offer: this.generateCurrentOfferFullView(
this.currentOffer,
this.contextOptions
)
});
} else {
// TDB
}
};
protected buildOfferListComponent( protected buildOfferListComponent(
context: ISearchBoxComposer, context: ISearchBoxComposer,

View File

@ -18,5 +18,6 @@ export interface IOfferPanelComponentEvents {
export interface IOfferPanelActionEvent { export interface IOfferPanelActionEvent {
action: string; action: string;
offerId: string; currentOfferId: string;
target?: any;
} }

View File

@ -118,7 +118,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
padding: 40px 32px 30px; padding: 0 32px;
margin: 2px 12px; margin: 2px 12px;
border: 1px solid #d8d8d8; border: 1px solid #d8d8d8;
border-radius: 40px; border-radius: 40px;
@ -149,9 +149,10 @@
list-style-type: none; list-style-type: none;
margin: 12px 0; margin: 12px 0;
padding: 0; padding: 0;
position: relative;
} }
.our-coffee-sdk-offer-panel-buttons button { .our-coffee-sdk-offer-panel-button {
height: 50px; height: 50px;
width: 300px; width: 300px;
background: #3a3a3c; background: #3a3a3c;
@ -167,14 +168,13 @@
mix-blend-mode: luminosity; mix-blend-mode: luminosity;
} }
.our-coffee-sdk-offer-panel-buttons button img { .our-coffee-sdk-offer-panel-button img {
max-height: 22px; max-height: 22px;
max-width: 22px; max-width: 22px;
vertical-align: bottom; vertical-align: bottom;
} }
.our-coffee-sdk-offer-panel-buttons .our-coffee-sdk-offer-panel-close-button {
button.our-coffee-sdk-offer-panel-close-button {
font-size: 17px; font-size: 17px;
line-height: 22px; line-height: 22px;
height: 22px; height: 22px;
@ -189,7 +189,7 @@
font-size: 15px; font-size: 15px;
line-height: 34px; line-height: 34px;
letter-spacing: -0.24px; letter-spacing: -0.24px;
margin: 5px 0; margin: 5px 23px 0 0;
} }
.custom-offer > aside { .custom-offer > aside {
@ -213,3 +213,31 @@
margin: 1px 0; margin: 1px 0;
mix-blend-mode: luminosity; mix-blend-mode: luminosity;
} }
.custom-control {
position: absolute;
top: 0;
height: 0;
width: 100%;
}
.custom-control > button {
color: rgba(60, 60, 67, 0.6);
font-size: 60px;
line-height: 30px;
margin-top: -15px;
height: 60px;
background: none;
cursor: pointer;
border: none;
}
.custom-control .right {
float: right;
margin-right: -30px;
}
.custom-control .left {
float: left;
margin-left: -30px;
}

View File

@ -137,8 +137,9 @@ describe('OfferPanelComponent', () => {
}); });
expect(events).toEqual([ expect(events).toEqual([
{ {
offerId: CAFEE_CHAMOMILE_LUNGO_OFFER_FULL_VIEW.offerId, currentOfferId: CAFEE_CHAMOMILE_LUNGO_OFFER_FULL_VIEW.offerId,
action: MOCK_ACTION action: MOCK_ACTION,
target: mockButton
} }
]); ]);
offerPanel.destroy(); offerPanel.destroy();

View File

@ -161,7 +161,7 @@ describe('SearchBoxComposer', () => {
'createOrder', 'createOrder',
() => { () => {
mockOfferPanel.events.emit('action', { mockOfferPanel.events.emit('action', {
offerId: CAFEE_CHAMOMILE_LUNGO_OFFER.offerId, currentOfferId: CAFEE_CHAMOMILE_LUNGO_OFFER.offerId,
action: 'createOrder' action: 'createOrder'
}); });
} }
@ -180,7 +180,7 @@ describe('SearchBoxComposer', () => {
event = e; event = e;
}); });
mockOfferPanel.events.emit('action', { mockOfferPanel.events.emit('action', {
offerId: 'fakeOfferId', currentOfferId: 'fakeOfferId',
action: 'createOrder' action: 'createOrder'
}); });
expect(event).toEqual(null); expect(event).toEqual(null);
@ -200,7 +200,7 @@ describe('SearchBoxComposer', () => {
'offerFullViewToggle', 'offerFullViewToggle',
() => { () => {
mockOfferPanel.events.emit('action', { mockOfferPanel.events.emit('action', {
offerId: CAFEE_CHAMOMILE_LUNGO_OFFER.offerId, currentOfferId: CAFEE_CHAMOMILE_LUNGO_OFFER.offerId,
action: 'close' action: 'close'
}); });
} }

View File

@ -14,7 +14,8 @@ export class DummyMapApi {
'background-size: cover', 'background-size: cover',
'position: relative', 'position: relative',
'width: 100%', 'width: 100%',
'height: 100%' 'height: 100%',
'overflow: hidden'
].join(';'); ].join(';');
} }
@ -35,6 +36,7 @@ export class DummyMapApi {
(this.bounds[1][1] - this.bounds[0][1]) (this.bounds[1][1] - this.bounds[0][1])
) - 30; ) - 30;
const marker = document.createElement('div'); const marker = document.createElement('div');
marker.dataset.markerId = markerId;
marker.style.cssText = [ marker.style.cssText = [
'position: absolute', 'position: absolute',
'width: 30px', 'width: 30px',
@ -44,7 +46,8 @@ export class DummyMapApi {
'align: center', 'align: center',
'line-height: 30px', 'line-height: 30px',
'font-size: 30px', 'font-size: 30px',
'cursor: pointer' 'cursor: pointer',
'mix-blend-mode: luminosity'
].join(';'); ].join(';');
marker.innerHTML = marker.innerHTML =
'<a href="javascript:void(0)" style="text-decoration: none;">📍</a>'; '<a href="javascript:void(0)" style="text-decoration: none;">📍</a>';
@ -58,6 +61,17 @@ export class DummyMapApi {
this.container.appendChild(marker); this.container.appendChild(marker);
} }
public selectSingleMarker(markerId: string) {
for (const marker of this.container.childNodes) {
const element = <HTMLElement>marker;
if (element.dataset?.markerId === markerId) {
element.style.mixBlendMode = 'unset';
} else {
element.style.mixBlendMode = 'luminosity';
}
}
}
public destroy() { public destroy() {
for (const dispose of this.listenerDisposers) { for (const dispose of this.listenerDisposers) {
dispose(); dispose();

View File

@ -2,7 +2,10 @@ const express = require('express');
const port = process.argv[2]; const port = process.argv[2];
express() express()
.use('/', express.static('docs')) .get('/', (req, res) => {
res.redirect('/The-API-Book');
})
.use('/The-API-Book', express.static('docs'))
.listen(port, () => { .listen(port, () => {
console.log(`Docs Server is listening port ${port}`); console.log(`Docs Server is listening port ${port}`);
}); });

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

View File

@ -10,15 +10,19 @@
* иллюстрирует проблемы неоднозначности иерархий наследования и разделяемых ресурсов (иконки кнопки), как описано в предыдущей главе; * иллюстрирует проблемы неоднозначности иерархий наследования и разделяемых ресурсов (иконки кнопки), как описано в предыдущей главе;
2. Замена списочного представления предложений, например, на представление в виде карты с метками: 2. Замена списочного представления предложений, например, на представление в виде карты с метками:
* иллюстрирует проблему изменения дизайна одного субкомпонента (списка заказов) при сохранении поведения и дизайна остальных частей системы; * иллюстрирует проблему изменения дизайна одного субкомпонента (списка заказов) при сохранении поведения и дизайна остальных частей системы;
3. Добавление кнопки быстрого заказа в каждое предложение в списке:
* иллюстрирует проблему разделяемых ресурсов (см. более подробные пояснения ниже) и изменения UX системы в целом при сохранении существующего дизайна, поведения и бизнес-логики.
4. Добавление новой кнопки в панель создания заказа с дополнительной функцией (скажем, позвонить по телефону в кофейню):
* иллюстрирует проблему изменения бизнес-логики компонента при сохранении внешнего вида и UX компонента, а также неоднозначности иерархий наследования (поскольку новая кнопка должна располагать своими отдельными настройками).
[![APP](/img/mockups/05.png "Результаты поиска на карте")]() [![APP](/img/mockups/05.png "Результаты поиска на карте")]()
3. Добавление кнопки быстрого заказа в каждое предложение в списке:
* иллюстрирует проблему разделяемых ресурсов (см. более подробные пояснения ниже) и изменения UX системы в целом при сохранении существующего дизайна, поведения и бизнес-логики.
[![APP](/img/mockups/06.png "Список результатов поиска с кнопками быстрых действий")]() [![APP](/img/mockups/06.png "Список результатов поиска с кнопками быстрых действий")]()
4. Динамическое добавление новой кнопки в панель создания заказа с дополнительной функцией (скажем, позвонить по телефону в кофейню):
* иллюстрирует проблему динамического изменения бизнес-логики компонента при сохранении внешнего вида и UX компонента, а также неоднозначности иерархий наследования (поскольку новая кнопка должна располагать своими отдельными настройками).
[![APP](/img/mockups/07.png "Дополнительная кнопка «Позвонить»")]()
Решение, которое напрашивается в данной ситуации — это выделение двух дополнительных компонентов, отвечающих за представление списка предложений и за панель показа конкретного предложения, которые можно будет кастомизировать под конкретную задачу. Например, мы можем реализовать связь между ними следующим образом: Решение, которое напрашивается в данной ситуации — это выделение двух дополнительных компонентов, отвечающих за представление списка предложений и за панель показа конкретного предложения, которые можно будет кастомизировать под конкретную задачу. Например, мы можем реализовать связь между ними следующим образом:
``` ```
@ -67,13 +71,13 @@ class SearchBox {
* единственный способ сделать заказ — клик внутри элемента «панель предложения»; * единственный способ сделать заказ — клик внутри элемента «панель предложения»;
* заказ не может быть сделан, если предложение не было предварительно выбрано. * заказ не может быть сделан, если предложение не было предварительно выбрано.
Из поставленных нами задач при такой декомпозиции мы можем условно решить только вторую (замена списка на карту), потому что к решению первой проблемы (замена иконки в кнопке заказа) мы не продвинулись вообще, а третью (кнопки быстрых действий в списке заказов) можно решить только следующим образом: Из поставленных нами задач при такой декомпозиции мы можем условно решить только вторую (замена списка на карту), потому что к решению первой и четвёртой проблемы (настройка кнопок в панели) мы не продвинулись вообще, а третью (кнопки быстрых действий в списке заказов) можно решить только следующим образом:
* сделать панель заказа невидимой / перенести её за границы экрана; * сделать панель заказа невидимой / перенести её за границы экрана;
* после события `"click"` на кнопке создания заказа дождаться окончания отрисовки невидимой панели и сгенерировать на ней фиктивное событие `"click"`. * после события `"click"` на кнопке создания заказа дождаться окончания отрисовки невидимой панели и сгенерировать на ней фиктивное событие `"click"`.
Думаем, излишне уточнять, что подобные решения ни в каком случае не могут считать разумным API для UI-компонента. Но как же нам всё-таки *сделать* этот интерфейс расширяемым? Думаем, излишне уточнять, что подобные решения ни в каком случае не могут считать разумным API для UI-компонента. Но как же нам всё-таки *сделать* этот интерфейс расширяемым?
Первый очевидный шаг заключается в том, чтобы `SearchBox` перестал реагировать на низкоуровневые события типа `click`, а стал только лишь контекстом для нижележащих сущностей и работал в терминах своего уровня абстракции. А для этого нам нужно в первую очередь установить, что же он из себя представляет *логически*, какова его область ответственности как компонента? Первая очевидная проблема заключается в том, что `SearchBox` должен реагировать на низкоуровневые события типа `click`. Согласно рекомендациям, данным нами в главе «[Слабая связность](#back-compat-weak-coupling)», мы должны сделать его контекстом для нижележащих сущностей, а для этого нам нужно в первую очередь установить, что же он из себя представляет *логически*, какова его область ответственности как компонента?
Предположим, что мы определим `SearchBox` концептуально как конечный автомат, находящийся в одном из трёх состояний: Предположим, что мы определим `SearchBox` концептуально как конечный автомат, находящийся в одном из трёх состояний:
1. Пуст (ожидает запроса пользователя и получения списка предложений). 1. Пуст (ожидает запроса пользователя и получения списка предложений).