Skip to content

Commit

Permalink
feat(cc-pricing-header)!: adapt to support priceSystem currency
Browse files Browse the repository at this point in the history
BREAKING CHANGE: the component no longer supports hard coded change rate
Prices displayed in pricing components are no longer computed based on
hard coded change rate.
Prices related to the selected currency come from the API priceSytem
endpoint.

This means that some types for the component properties have changed:
- `currency` is now a `string` (ISO 4217 currency code),
- `selectedCurrency` is now a `string` (ISO 4217 currency code).

Fixes #1167
  • Loading branch information
florian-sanders-cc committed Dec 4, 2024
1 parent 4ca85cd commit 125e9e7
Show file tree
Hide file tree
Showing 5 changed files with 125 additions and 49 deletions.
48 changes: 31 additions & 17 deletions src/components/cc-pricing-header/cc-pricing-header.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,26 +14,26 @@ import '../cc-icon/cc-icon.js';
import '../cc-notice/cc-notice.js';
import { CcZone } from '../cc-zone/cc-zone.js';

/** @type {Currency} */
// FIXME: this code is duplicated across all pricing components (see issue #732 for more details)
const DEFAULT_CURRENCY = { code: 'EUR', changeRate: 1 };
const DEFAULT_CURRENCY = 'EUR';

/** @type {Temporality} */
// FIXME: this code is duplicated across all pricing components (see issue #732 for more details)
const DEFAULT_TEMPORALITY = { type: '30-days', digits: 2 };

/**
* @typedef {import('../common.types.js').Currency} Currency
* @typedef {import('../common.types.js').Temporality} Temporality
* @typedef {import('./cc-pricing-header.types.js').PricingHeaderState} PricingHeaderState
* @typedef {import('@shoelace-style/shoelace').SlSelect} SlSelect
* @typedef {import('../../lib/events.types.js').EventWithTarget<SlSelect>} SlSelectEvent
*/

/**
* A component that allows the selection of a temporality, a currency and a zone.
*
* @cssdisplay block
*
* @fires {CustomEvent<Currency>} cc-pricing-header:change-currency - Fires the `currency` whenever the currency selection changes.
* @fires {CustomEvent<string>} cc-pricing-header:change-currency - Fires the `currency` whenever the currency selection changes.
* @fires {CustomEvent<Temporality>} cc-pricing-header:change-temporality - Fires the `temporality` whenever the temporality selection changes.
* @fires {CustomEvent<string>} cc-pricing-header:change-zone - Fires the `zoneId` (zone name) whenever the zone selection changes.
*
Expand All @@ -43,7 +43,7 @@ export class CcPricingHeader extends LitElement {
static get properties() {
return {
currencies: { type: Array },
selectedCurrency: { type: Object, attribute: 'selected-currency' },
selectedCurrency: { type: String, attribute: 'selected-currency' },
selectedTemporality: { type: Object, attribute: 'selected-temporality' },
selectedZoneId: { type: String, attribute: 'selected-zone-id' },
state: { type: Object },
Expand All @@ -54,10 +54,10 @@ export class CcPricingHeader extends LitElement {
constructor() {
super();

/** @type {Currency[]} Sets the list of currencies available for selection. */
/** @type {string[]} Sets the list of currencies available for selection. */
this.currencies = [DEFAULT_CURRENCY];

/** @type {Currency} Sets the current selected currency. */
/** @type {string} Sets the current selected currency. */
this.selectedCurrency = DEFAULT_CURRENCY;

/** @type {Temporality} Sets the current selected temporality. */
Expand All @@ -77,7 +77,7 @@ export class CcPricingHeader extends LitElement {
* Returns the localized string corresponding to a given temporality type.
*
* @param {Temporality['type']} type - the temporality type
* @return {string} the localized string corresponding to the given temporality type
* @return {string|Node} the localized string corresponding to the given temporality type
*/
_getPriceLabel(type) {
switch (type) {
Expand All @@ -100,18 +100,18 @@ export class CcPricingHeader extends LitElement {
* Retrieves the currency corresponding to the selected currency code.
* Dispatches a `cc-pricing-header:change-currency` event with the currency as its payload.
*
* @param {Event & { target: { value: string }}} e - the event that called this method
* @param {SlSelectEvent} e - the event that called this method
*/
_onCurrencyChange(e) {
const currency = this.currencies.find((c) => c.code === e.target.value);
dispatchCustomEvent(this, 'change-currency', currency);
this.selectedCurrency = /** @type {string} */ (e.target.value);
dispatchCustomEvent(this, 'change-currency', this.selectedCurrency);
}

/**
* Retrieves the temporality corresponding to the selected temporality type.
* Dispatches a `cc-pricing-header:change-temporality` event with the temporality as its payload.
*
* @param {Event & { target: { value: string }}} e - the event that called this method
* @param {SlSelectEvent} e - the event that called this method
*/
_onTemporalityChange(e) {
const temporality = this.temporalities.find((t) => t.type === e.target.value);
Expand All @@ -122,7 +122,7 @@ export class CcPricingHeader extends LitElement {
* Retrieves the zone id from the event payload.
* Dispatches a `cc-pricing-header:change-zone` event with the zone id as its payload.
*
* @param {Event & { target: { value: string }}} e - the event that called this method
* @param {SlSelectEvent} e - the event that called this method
*/
_onZoneChange(e) {
const zoneId = e.target.value;
Expand All @@ -146,19 +146,29 @@ export class CcPricingHeader extends LitElement {
@sl-change=${this._onTemporalityChange}
>
${this.temporalities.map(
(t) => html` <sl-option value=${t.type}>${this._getPriceLabel(t.type)}</sl-option> `,
(temporality) => html`
<sl-option
?disabled=${skeleton && this.selectedTemporality.type !== temporality.type}
value=${temporality.type}
>${this._getPriceLabel(temporality.type)}</sl-option
>
`,
)}
<cc-icon slot="expand-icon" .icon=${iconArrowDown}></cc-icon>
</sl-select>
<sl-select
label="${i18n('cc-pricing-header.label.currency')}"
class="currency-select"
value=${this.selectedCurrency?.code}
value=${this.selectedCurrency}
@sl-change=${this._onCurrencyChange}
>
${this.currencies.map(
(c) => html` <sl-option value=${c.code}>${getCurrencySymbol(c.code)} ${c.code}</sl-option> `,
(currency) => html`
<sl-option ?disabled=${skeleton && this.selectedCurrency !== currency} value=${currency}
>${getCurrencySymbol(currency)} ${currency}</sl-option
>
`,
)}
<cc-icon slot="expand-icon" .icon=${iconArrowDown}></cc-icon>
</sl-select>
Expand All @@ -173,7 +183,11 @@ export class CcPricingHeader extends LitElement {
>
${zones.map(
(zone) => html`
<sl-option class="zone-item" value=${zone.name}>
<sl-option
?disabled=${skeleton && this.selectedZoneId !== zone.name}
class="zone-item"
value=${zone.name}
>
${CcZone.getText(zone)}
<cc-zone slot="prefix" .state=${{ type: 'loaded', ...zone }}></cc-zone>
</sl-option>
Expand Down
39 changes: 28 additions & 11 deletions src/components/cc-pricing-header/cc-pricing-header.smart.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,28 +17,28 @@ import './cc-pricing-header.js';
defineSmartComponent({
selector: 'cc-pricing-header',
params: {
zoneId: { type: String },
apiConfig: { type: Object },
zoneId: { type: String, optional: true },
},
/**
* @param {Object} settings
* @param {CcPricingHeader} settings.component
* @param {{apiConfig: ApiConfig, zoneId: string }} settings.context
* @param {{apiConfig: ApiConfig, zoneId?: string }} settings.context
* @param {(type: string, listener: (detail: any) => void) => void} settings.onEvent
* @param {function} settings.updateComponent
* @param {AbortSignal} settings.signal
*/
// @ts-expect-error FIXME: remove once `onContextUpdate` is type with generics
onContextUpdate({ container, component, context, onEvent, updateComponent, signal }) {
const { zoneId } = context;
const { apiConfig, zoneId = 'par' } = context;

/**
* This `cc-smart-container` is placed around the whole `cc-pricing-page` component.
* Within the `cc-pricing-page`, every `cc-pricing-product` component is placed inside a distinct `cc-smart-container`.
*
* This smart component targets `cc-pricing-header` but when `zoneId` changes, we want to trigger
* a new fetch from all pricing product smart.
* To do so, this smart component modifies its own context.
* Since all pricing product smart share this context and watch for `zoneId` changes, it triggers new fetches.
* To do so, this smart component modifies the global `<cc-smart-container>` context wrapping all pricing components.
* Since all pricing product smart share this context and watch for `currency` changes, it triggers new fetches.
*
* For more info, refer to the `Smart` docs about the `cc-pricing-header` component in the `Notes` section.
*/
onEvent(
'cc-pricing-header:change-zone',
Expand All @@ -48,6 +48,22 @@ defineSmartComponent({
},
);

/**
* This smart component targets `cc-pricing-header` but when `currency` changes, we want to trigger
* a new fetch from all pricing product smart.
* To do so, this smart component modifies the global `<cc-smart-container>` context wrapping all pricing components.
* Since all pricing product smart share this context and watch for `currency` changes, it triggers new fetches.
*
* For more info, refer to the `Smart` docs about the `cc-pricing-header` component in the `Notes` section.
*/
onEvent(
'cc-pricing-header:change-currency',
/** @param {string} currency */
(currency) => {
container.context = { ...container.context, currency };
},
);

/**
* Zones data is not dynamic and not context dependant.
* We only need to fetch these once and we don't want to fetch
Expand All @@ -57,7 +73,7 @@ defineSmartComponent({
* we update `cc-pricing-header` accordingly.
*/
if (component.state.type === 'loading') {
fetchAllZones({ signal })
fetchAllZones({ apiConfig, signal })
.then((zones) => {
updateComponent('state', { type: 'loaded', zones });
updateComponent('selectedZoneId', zoneId);
Expand All @@ -74,12 +90,13 @@ defineSmartComponent({

/**
* @param {Object} parameters
* @param {ApiConfig} [parameters.apiConfig]
* @param {AbortSignal} parameters.signal
* @returns {Promise<Zone[]>}
*/
function fetchAllZones({ signal }) {
function fetchAllZones({ apiConfig, signal }) {
return getAllZones()
.then(sendToApi({ signal, cacheDelay: ONE_DAY }))
.then(sendToApi({ apiConfig, signal, cacheDelay: ONE_DAY }))
.then(
/**
* @param {Zone[]} zones
Expand Down
50 changes: 47 additions & 3 deletions src/components/cc-pricing-header/cc-pricing-header.smart.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@ title: '💡 Smart'

## ⚙️ Params

| Name | Type | Details | Default |
|-------------|-------------|----------------------------------------------------------------------------------|---------|
| `zoneId` | `string` | Name from [`/v4/products/zones`](https://api.clever-cloud.com/v4/products/zones) | `par` |
| Name | Type | Required | Details | Default |
|-------------|:-----------:|:--------:|----------------------------------------------------------------------------------|------------------------------------------------|
| `apiConfig` | `ApiConfig` | Yes | Object with API configuration (target host) | `{ API_HOST: "https://api.clever-cloud.com" }` |
| `zoneId` | `string` | No | Name from [`/v4/products/zones`](https://api.clever-cloud.com/v4/products/zones) | `par` |

## 🌐 API endpoints

Expand All @@ -35,3 +36,46 @@ title: '💡 Smart'
</cc-smart-container>
```

## 📄 Notes

Pricing components rely on nested `cc-smart-container` components.

1. There is a global `<cc-smart-container>` wrapping all pricing components to provide shared context,
2. There are individual `<cc-smart-container>` wrapping each `<cc-pricing-product>` & `<cc-pricing-product-consumption>` component slotted within the `<cc-pricing-page>` to provide context specific to each product.

The typical HTML for a basic `cc-pricing-page` implementation looks like this:
```html
<cc-smart-container context='{
"apiConfig": {
API_HOST: ""
},
"zoneId": "mtl",
"currency": "USD"
}'>
<cc-pricing-page>
<cc-pricing-header currencies="..." temporalities="..."></cc-pricing-header>
<cc-smart-container context="{ productId: 'php'}">
<cc-pricing-product mode="runtime" action="add"></cc-pricing-product>
</cc-smart-container>

<cc-smart-container context="{ productId: 'redis-addon'}">
<cc-pricing-product mode="addon" action="add"></cc-pricing-product>
</cc-smart-container>
<cc-pricing-estimation currencies="..." temporalities="..."></cc-pricing-estimation>
</cc-pricing-page>
</cc-smart-container>
```

`zoneId` and `currency` need to be shared between all pricing components because they drive the displayed prices.
When `zoneId` or `currency` changes, we need all pricing components that show prices to fetch the `priceSystem` (list of prices by product) corresponding to the selected zone and selected currency.
Components showing prices are:

- `cc-pricing-product`,
- `cc-pricing-product-consumption`,
- `cc-pricing-estimation`.

This is why when `zoneId` or `currency` is changed by selecting a different value in either `cc-pricing-header` or `cc-pricing-estimation` dropdowns, the global `cc-smart-container` context is updated.

Since all `cc-smart-container` inherit the context of their parent, every `cc-smart-container` wrapping a `cc-pricing-product` or `cc-pricing-product-consumption` retrieve the `zoneId` / `currency` and fetch the corresponding `priceSystem`.

Of course, the `priceSystem` API call is cached so that we don't actually trigger redundant calls.
30 changes: 18 additions & 12 deletions src/components/cc-pricing-header/cc-pricing-header.stories.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,12 @@ const conf = {
* @typedef {import('./cc-pricing-header.types.js').PricingHeaderStateLoaded} PricingHeaderStateLoaded
* @typedef {import('./cc-pricing-header.types.js').PricingHeaderStateLoading} PricingHeaderStateLoading
* @typedef {import('./cc-pricing-header.types.js').PricingHeaderStateError} PricingHeaderStateError
* @typedef {import('../common.types.js').Currency} Currency
* @typedef {import('../common.types.js').Temporality} Temporality
*/

/** @type {{ currencies: Currency[], temporalities: Temporality[], state: PricingHeaderStateLoaded, selectedZoneId: 'par' }} */
/** @type {Partial<CcPricingHeader>} */
const defaultItem = {
currencies: [
{ code: 'EUR', changeRate: 1 },
{ code: 'GBP', changeRate: 0.88603 },
{ code: 'USD', changeRate: 1.1717 },
],
currencies: ['EUR', 'GBP', 'USD'],
temporalities: [
{
type: 'second',
Expand Down Expand Up @@ -58,40 +53,43 @@ const defaultItem = {
};

export const defaultStory = makeStory(conf, {
/** @type {Partial<CcPricingHeader>[]} */
items: [defaultItem],
});

export const loading = makeStory(conf, {
/** @type {Partial<CcPricingHeader>[]} */
items: [
{
/** @type {PricingHeaderStateLoading} */
...defaultItem,
state: { type: 'loading' },
},
],
});

export const error = makeStory(conf, {
/** @type {Partial<CcPricingHeader>[]} */
items: [
{
/** @type {PricingHeaderStateError} */
...defaultItem,
state: { type: 'error' },
},
],
});

export const dataLoadedWithDollars = makeStory(conf, {
/** @type {{ state: PricingHeaderStateLoaded, selectedCurrency: Currency, selectedZoneId: 'mtl' }[]} */
/** @type {Partial<CcPricingHeader>[]} */
items: [
{
...defaultItem,
selectedCurrency: { code: 'USD', changeRate: 1.1717 },
selectedCurrency: 'USD',
selectedZoneId: 'mtl',
},
],
});

export const dataLoadedWithMinute = makeStory(conf, {
/** @type {{ state: PricingHeaderStateLoaded, selectedTemporality: Temporality, selectedZoneId: 'war' }[]} */
/** @type {Partial<CcPricingHeader>[]} */
items: [
{
...defaultItem,
Expand All @@ -102,6 +100,7 @@ export const dataLoadedWithMinute = makeStory(conf, {
});

export const simulations = makeStory(conf, {
/** @type {Partial<CcPricingHeader>[]} */
items: [
{
currencies: defaultItem.currencies,
Expand Down Expand Up @@ -134,6 +133,13 @@ export const simulations = makeStory(conf, {
component.selectedCurrency = defaultItem.currencies[1];
},
),
storyWait(
2000,
/** @param {CcPricingHeader[]} components */
([component]) => {
component.selectedZoneId = 'mtl';
},
),
],
});

Expand Down
7 changes: 1 addition & 6 deletions src/components/common.types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,11 +116,6 @@ interface Marker {
// Additional specific properties for the marker custom element.
}

interface Currency {
code: string;
changeRate: number;
}

interface Plan {
productName: string;
name: string;
Expand Down Expand Up @@ -165,7 +160,7 @@ type ActionType = 'add' | 'none';

interface Temporality {
type: 'second' | 'minute' | 'hour' | 'day' | '30-days' | '1000-minutes';
digits: number; // how many fraction digits to display the price
digits?: number; // how many fraction digits to display the price
}

interface RedirectionNamespace {
Expand Down

0 comments on commit 125e9e7

Please sign in to comment.