From 5a0f37c0b673a10f88d6eb63a1e8d48aec2293d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Von=C3=A1=C5=A1ek?= Date: Fri, 27 Sep 2024 15:44:48 +0200 Subject: [PATCH] Selectable DCA frequency unit via dropdown --- packages/apps/src/app/dca/Form.css | 8 ++ packages/apps/src/app/dca/Form.ts | 118 +++++++++++++++--- packages/apps/src/app/dca/translation.en.json | 4 + packages/apps/src/app/dca/types.ts | 1 + packages/ui/src/component/Dropdown.css | 41 ++++++ packages/ui/src/component/Dropdown.ts | 47 +++++++ packages/ui/src/component/Popper.css | 4 + packages/ui/src/component/Popper.ts | 44 ++++--- packages/ui/src/component/Slider.css | 4 + packages/ui/src/component/Slider.ts | 8 +- packages/ui/src/index.ts | 1 + 11 files changed, 244 insertions(+), 36 deletions(-) create mode 100644 packages/ui/src/component/Dropdown.css create mode 100644 packages/ui/src/component/Dropdown.ts diff --git a/packages/apps/src/app/dca/Form.css b/packages/apps/src/app/dca/Form.css index be4dccad1d..2b088f8a16 100644 --- a/packages/apps/src/app/dca/Form.css +++ b/packages/apps/src/app/dca/Form.css @@ -83,6 +83,14 @@ width: 100%; } +.frequency-trigger { + display: inline-flex; + margin-right: -8px; + height: 18px; + font-size: 14px; + cursor: pointer; +} + .adornment { white-space: nowrap; font-weight: 500; diff --git a/packages/apps/src/app/dca/Form.ts b/packages/apps/src/app/dca/Form.ts index 4ad4fd4dee..5989d85c43 100644 --- a/packages/apps/src/app/dca/Form.ts +++ b/packages/apps/src/app/dca/Form.ts @@ -18,12 +18,15 @@ import { baseStyles, formStyles } from 'styles'; import { formatAmount, humanizeAmount } from 'utils/amount'; import { MINUTE_MS } from 'utils/time'; -import { DcaOrder, INTERVAL_DCA, IntervalDca } from './types'; +import { DcaOrder, FrequencyUnit, INTERVAL_DCA, IntervalDca } from './types'; import { Amount, Asset } from '@galacticcouncil/sdk'; import styles from './Form.css'; +const HOUR_MIN = 60; +const DAY_MIN = 24 * HOUR_MIN; + @customElement('gc-dca-form') export class DcaForm extends BaseElement { private account = new DatabaseController(this, AccountCursor); @@ -36,6 +39,7 @@ export class DcaForm extends BaseElement { @property({ type: Object }) assetIn: Asset = null; @property({ type: Object }) assetOut: Asset = null; @property({ type: String }) interval: IntervalDca = 'hour'; + @property({ type: Number }) intervalMultiplier: number = 1; @property({ type: Number }) frequency: number = null; @property({ type: String }) amountIn = null; @@ -44,6 +48,7 @@ export class DcaForm extends BaseElement { @property({ attribute: false }) error = {}; @state() advanced: boolean = false; + @state() frequencyUnit: FrequencyUnit = 'min'; static styles = [baseStyles, formStyles, styles]; @@ -83,6 +88,18 @@ export class DcaForm extends BaseElement { this.advanced = !this.advanced; } + get minFrequency() { + return this.order + ? Math.min(this.order.frequencyMin, this.order.frequencyOpt) + : 0; + } + + get maxFrequency() { + return Number.isFinite(this.order?.frequencyOpt) + ? Math.max(this.minFrequency, this.order.frequencyOpt) + : 0; + } + onScheduleClick(e: any) { const options = { bubbles: true, @@ -92,11 +109,20 @@ export class DcaForm extends BaseElement { } onIntervalChange(e: any) { + const interval = e.detail.value as IntervalDca; const options = { bubbles: true, composed: true, - detail: { value: e.detail.value }, + detail: { value: interval }, + }; + + const freqUnitByInterval: Record = { + hour: 'min', + day: 'min', + week: 'hour', }; + + this.setFrequencyUnit(this.maxFrequency, freqUnitByInterval[interval]); this.dispatchEvent(new CustomEvent('interval-change', options)); } @@ -109,15 +135,28 @@ export class DcaForm extends BaseElement { this.dispatchEvent(new CustomEvent('interval-mul-change', options)); } - onFrequencyChange(e: any) { + convertFrequencyValue(value: number, unit: FrequencyUnit = 'min') { + return unit === 'min' + ? value + : unit === 'hour' + ? value * HOUR_MIN + : value * DAY_MIN; + } + + onFrequencyChange(value: number, unit: FrequencyUnit = 'min') { const options = { bubbles: true, composed: true, - detail: { value: e.detail.value }, + detail: { value: this.convertFrequencyValue(value, unit) }, }; this.dispatchEvent(new CustomEvent('frequency-change', options)); } + setFrequencyUnit(value: number, unit: FrequencyUnit) { + this.frequencyUnit = unit; + this.onFrequencyChange(value, unit); + } + infoSummaryTemplate() { if (this.inProgress) { return html` @@ -327,14 +366,8 @@ export class DcaForm extends BaseElement { } formFrequencyTemplate() { - const min = this.order - ? Math.min(this.order.frequencyMin, this.order.frequencyOpt) - : 0; - - const max = Number.isFinite(this.order?.frequencyOpt) - ? Math.max(min, this.order.frequencyOpt) - : 0; - + const min = this.minFrequency; + const max = this.maxFrequency; const value = this.frequency ?? max; const valueMsec = value * 60 * 1000; @@ -348,18 +381,69 @@ export class DcaForm extends BaseElement { }) : undefined; + const range = max - min; + const rangeInHours = Math.floor(range / HOUR_MIN); + const rangeInDays = Math.floor(range / DAY_MIN); + + const minValues: Record = { + min: min, + hour: Math.ceil(min / HOUR_MIN), + day: Math.ceil(min / DAY_MIN), + }; + + const maxValues: Record = { + min: max, + hour: Math.floor(max / HOUR_MIN), + day: Math.floor(max / DAY_MIN), + }; + + const values: Record = { + min: value, + hour: Math.floor(value / HOUR_MIN), + day: Math.floor(value / DAY_MIN), + }; + + const units = [ + 'min', + rangeInHours > 0 && 'hour', + rangeInDays > 0 && 'day', + ].filter((u): u is FrequencyUnit => !!u); + return html`
this.onFrequencyChange(e)}> + @input-change=${(e: CustomEvent) => + this.onFrequencyChange( + parseFloat(e.detail.value), + this.frequencyUnit, + )}> > +
+ ${units.length > 1 + ? html` + ({ + active: this.frequencyUnit === u, + text: i18n.t(`form.frequency.${u}`), + onClick: () => this.setFrequencyUnit(values[u], u), + }))}> +
+ ${i18n.t(`form.frequency.${this.frequencyUnit}`)} + +
+
+ ` + : i18n.t(`form.frequency.${this.frequencyUnit}`)} +
`; diff --git a/packages/apps/src/app/dca/translation.en.json b/packages/apps/src/app/dca/translation.en.json index 21552cdf82..42799bec41 100644 --- a/packages/apps/src/app/dca/translation.en.json +++ b/packages/apps/src/app/dca/translation.en.json @@ -22,6 +22,10 @@ "form.info.estSchedule": "Schedule end (est.)", "form.info.slippage": "Slippage protection", + "form.frequency.min": "minutes", + "form.frequency.hour": "hours", + "form.frequency.day": "days", + "error.insufficientBalance": "Your trade is bigger than your balance.", "error.minBudgetTooLow": "The minimum budget is {{amount}} {{asset}}.", "error.frequencyOutOfRange": "The valid frequency is between {{min}} and {{max}} minutes.", diff --git a/packages/apps/src/app/dca/types.ts b/packages/apps/src/app/dca/types.ts index 722bd92c24..a8ec3d0fa5 100644 --- a/packages/apps/src/app/dca/types.ts +++ b/packages/apps/src/app/dca/types.ts @@ -54,6 +54,7 @@ export const INTERVAL_DCA_MS: Record = { }; export type IntervalDca = (typeof INTERVAL_DCA)[number]; +export type FrequencyUnit = 'min' | 'hour' | 'day'; export interface DcaOrder extends Humanizer { amountIn: BigNumber; diff --git a/packages/ui/src/component/Dropdown.css b/packages/ui/src/component/Dropdown.css new file mode 100644 index 0000000000..80e230b2fb --- /dev/null +++ b/packages/ui/src/component/Dropdown.css @@ -0,0 +1,41 @@ +.tooltip { + display: none; + width: max-content; + min-width: 120px; + text-align: left; + position: fixed; + top: 0; + left: 0; + background: var(--hex-dark-blue-700); + border: 1px solid var(--hex-dark-blue-400); + color: white; + padding: 10px; + border-radius: 8px; + z-index: 1000; + box-shadow: 0px 40px 40px 0px rgba(0, 0, 0, 0.8); +} + +.tooltip.show { + display: block; +} + +.tooltip > button { + display: block; + width: 100%; + text-align: left; + border: none; + padding: 4px 8px; + background: transparent; + border-radius: 4px; + cursor: pointer; + color: var(--hex-basic-500); +} + +.tooltip > button.active { + background: rgba(255, 255, 255, 0.06); + color: white; +} + +.tooltip > button:hover { + color: white; +} diff --git a/packages/ui/src/component/Dropdown.ts b/packages/ui/src/component/Dropdown.ts new file mode 100644 index 0000000000..ac047a29a7 --- /dev/null +++ b/packages/ui/src/component/Dropdown.ts @@ -0,0 +1,47 @@ +import { html } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { UIGCElement } from 'component/base/UIGCElement'; + +import { Popper } from 'component/Popper'; + +import styles from './Dropdown.css'; + +type ItemProps = { + text: string; + active?: boolean; + onClick: () => void; +}; + +@customElement('uigc-dropdown') +export class Dropdown extends Popper { + static styles = [UIGCElement.styles, styles]; + + get buttonElement() { + return this.shadowRoot.querySelector('.tooltip > button'); + } + + @property({ type: String }) text = null; + @property({ type: Array }) items: ItemProps[] = []; + + handleItemClick(item: ItemProps) { + this.tooltipElement.classList.remove('show'); + item.onClick(); + } + + render() { + return html` + +
+ ${this.items.map( + (item) => html` + + `, + )} +
+ `; + } +} diff --git a/packages/ui/src/component/Popper.css b/packages/ui/src/component/Popper.css index 3d015d3014..33a6915b14 100644 --- a/packages/ui/src/component/Popper.css +++ b/packages/ui/src/component/Popper.css @@ -13,3 +13,7 @@ font-size: 90%; z-index: 1000; } + +.tooltip.show { + display: block; +} diff --git a/packages/ui/src/component/Popper.ts b/packages/ui/src/component/Popper.ts index 1fd1fda11a..4bc1b5e13a 100644 --- a/packages/ui/src/component/Popper.ts +++ b/packages/ui/src/component/Popper.ts @@ -3,13 +3,17 @@ import { customElement, property } from 'lit/decorators.js'; import { UIGCElement } from './base/UIGCElement'; -import { computePosition, flip } from '@floating-ui/dom'; +import { computePosition, flip, Placement } from '@floating-ui/dom'; import styles from './Popper.css'; +type TriggerMethod = 'click' | 'hover'; + @customElement('uigc-popper') export class Popper extends UIGCElement { @property({ type: String }) text = null; + @property({ type: String }) placement: Placement = 'right-start'; + @property({ type: String }) triggerMethod: TriggerMethod = 'hover'; static styles = [UIGCElement.styles, styles]; @@ -24,7 +28,7 @@ export class Popper extends UIGCElement { private updatePosition = () => { computePosition(this.triggerElement, this.tooltipElement, { - placement: 'right-start', + placement: this.placement, strategy: 'fixed', middleware: [flip()], }).then(({ x, y }) => { @@ -35,30 +39,36 @@ export class Popper extends UIGCElement { }); }; - private mouseOverListener = () => { - Object.assign(this.tooltipElement.style, { - display: 'block', - }); + private onShow = () => { + this.tooltipElement.classList.add('show'); this.updatePosition(); }; - private mouseOutListener = () => { - Object.assign(this.tooltipElement.style, { - display: 'none', - }); + private onHide = () => { + this.tooltipElement.classList.remove('show'); + }; + + private onToggle = () => { + this.tooltipElement.classList.toggle('show'); + this.updatePosition(); }; override async firstUpdated() { - this.triggerElement.addEventListener('mouseover', this.mouseOverListener); - this.triggerElement.addEventListener('mouseout', this.mouseOutListener); + if (this.triggerMethod === 'click') { + this.triggerElement.addEventListener('mousedown', this.onToggle); + } else { + this.triggerElement.addEventListener('mouseover', this.onShow); + this.triggerElement.addEventListener('mouseout', this.onHide); + } } override disconnectedCallback() { - this.triggerElement.removeEventListener( - 'mouseover', - this.mouseOverListener, - ); - this.triggerElement.removeEventListener('mouseout', this.mouseOutListener); + if (this.triggerMethod === 'click') { + this.triggerElement.removeEventListener('mousedown', this.onToggle); + } else { + this.triggerElement.removeEventListener('mouseover', this.onShow); + this.triggerElement.removeEventListener('mouseout', this.onHide); + } super.disconnectedCallback(); } diff --git a/packages/ui/src/component/Slider.css b/packages/ui/src/component/Slider.css index 2dff5e0f86..19b2ba5d98 100644 --- a/packages/ui/src/component/Slider.css +++ b/packages/ui/src/component/Slider.css @@ -110,6 +110,10 @@ display: flex; align-items: center; gap: 4px; + margin-left: auto; +} +.slider-root .hint { + margin-left: 4px; } .slider-root .bottom { diff --git a/packages/ui/src/component/Slider.ts b/packages/ui/src/component/Slider.ts index 15a28b029d..73f09cd371 100644 --- a/packages/ui/src/component/Slider.ts +++ b/packages/ui/src/component/Slider.ts @@ -92,7 +92,7 @@ export class Slider extends UIGCElement { hintTemplate() { if (!this.hint) return ''; return html` - + `; @@ -103,7 +103,11 @@ export class Slider extends UIGCElement {

${this.label}

-

${this.value} ${this.unit} ${this.hintTemplate()}

+

+ ${this.value} + ${this.unit} + ${this.hintTemplate()} +