Skip to content

Commit

Permalink
fix(sdk,secondary-button): rework the component the way we did with t…
Browse files Browse the repository at this point in the history
…he main button
  • Loading branch information
heyqbnk committed Oct 1, 2024
1 parent b010e07 commit 389d022
Show file tree
Hide file tree
Showing 6 changed files with 113 additions and 82 deletions.
22 changes: 10 additions & 12 deletions packages/sdk/src/scopes/components/secondary-button/exports.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,20 @@
export {
isSupported as isSecondaryButtonSupported,
mount as mountSecondaryButton,
onClick as onSecondaryButtonClick,
offClick as offSecondaryButtonClick,
setParams as setSecondaryButtonParams,
unmount as unmountSecondaryButton,
} from './methods.js';
export {
backgroundColor as secondaryButtonBackgroundColor,
hasShineEffect as secondaryButtonHasShineEffect,
isSupported as isSecondaryButtonSupported,
isMounted as isSecondaryButtonMounted,
isVisible as isSecondaryButtonVisible,
isLoaderVisible as isSecondaryButtonLoaderVisible,
isEnabled as isSecondaryButtonEnabled,
position as secondaryButtonPosition,
state as secondaryButtonState,
isEnabled as isSecondaryButtonEnabled,
mount as mountSecondaryButton,
onClick as onSecondaryButtonClick,
offClick as offSecondaryButtonClick,
text as secondaryButtonText,
textColor as secondaryButtonTextColor,
} from './signals.js';
setParams as setSecondaryButtonParams,
unmount as unmountSecondaryButton,
position as secondaryButtonPosition,
} from './exports.variable.js';
export * as secondaryButton from './exports.variable.js';
export type { State as SecondaryButtonState } from './types.js';
export * as secondaryButton from './exports.variable.js';
Original file line number Diff line number Diff line change
@@ -1,2 +1,13 @@
export * from './methods.js';
export * from './signals.js';
export {
backgroundColor,
hasShineEffect,
isMounted,
isVisible,
isLoaderVisible,
isEnabled,
position,
state,
text,
textColor,
} from './signals.js';
44 changes: 31 additions & 13 deletions packages/sdk/src/scopes/components/secondary-button/methods.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@ import {
isEnabled,
isLoaderVisible,
isVisible,
state,
internalState,
backgroundColor,
hasShineEffect,
state,
position,
} from './signals.js';
import { onClick, offClick, setParams, mount, unmount, isSupported } from './methods.js';

Expand All @@ -41,8 +43,17 @@ beforeEach(() => {
themeParams.isCssVarsBound,
themeParams.isMounted,
themeParams.state,
state,
text,
textColor,
isMounted,
isEnabled,
isLoaderVisible,
isVisible,
internalState,
backgroundColor,
hasShineEffect,
state,
position,
].forEach(resetSignal);
mockPostEvent();
});
Expand All @@ -53,23 +64,25 @@ describe.each([
{ signal: isEnabled, name: 'isEnabled' },
{ signal: isLoaderVisible, name: 'isLoaderVisible' },
{ signal: isVisible, name: 'isVisible' },
{ signal: position, name: 'position' },
{ signal: text, name: 'text' },
{ signal: textColor, name: 'textColor' },
] as const)('$name property', ({ signal, name }) => {
beforeEach(() => {
state.set({
internalState.set({
backgroundColor: '#123456',
hasShineEffect: true,
isEnabled: true,
isLoaderVisible: true,
isVisible: true,
position: 'right',
text: 'TEXT',
textColor: '#789abc',
});
});

it(`should use "${name}" property from state`, () => {
expect(signal()).toBe(state()[name]);
expect(signal()).toBe(internalState()[name]);
});
});

Expand All @@ -92,12 +105,13 @@ describe('mounted', () => {

describe('setParams', () => {
it('should save the state in storage key tapps/secondaryButton', () => {
state.set({
internalState.set({
backgroundColor: '#123456',
hasShineEffect: true,
isEnabled: true,
isLoaderVisible: true,
isVisible: true,
position: 'top',
text: 'TEXT',
textColor: '#789abc',
});
Expand All @@ -108,17 +122,18 @@ describe('mounted', () => {
});

expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledWith('tapps/secondaryButton', '{"backgroundColor":"#111111","hasShineEffect":true,"isEnabled":true,"isLoaderVisible":true,"isVisible":true,"text":"TEXT","textColor":"#789abc"}');
expect(spy).toHaveBeenCalledWith('tapps/secondaryButton', '{"backgroundColor":"#111111","hasShineEffect":true,"isEnabled":true,"isLoaderVisible":true,"isVisible":true,"position":"top","text":"TEXT","textColor":"#789abc"}');
});

it('should call "web_app_setup_secondary_button" only if text is not empty', () => {
const spy = mockPostEvent();
state.set({
internalState.set({
backgroundColor: '#123456',
hasShineEffect: false,
isEnabled: true,
isLoaderVisible: true,
isVisible: true,
position: 'bottom',
text: '',
textColor: '#789abc',
});
Expand All @@ -128,12 +143,13 @@ describe('mounted', () => {
setParams({ text: 'abc' });
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledWith('web_app_setup_secondary_button', {
color: '#123456',
has_shine_effect: false,
is_visible: true,
is_active: true,
is_progress_visible: true,
is_visible: true,
position: 'bottom',
text: 'abc',
color: '#123456',
text_color: '#789abc',
});
});
Expand Down Expand Up @@ -246,12 +262,13 @@ describe('offClick', () => {

describe('setParams', () => {
it('should merge passed object with the state', () => {
state.set({
internalState.set({
backgroundColor: '#123456',
hasShineEffect: true,
isEnabled: true,
isLoaderVisible: true,
isVisible: true,
position: 'right',
text: 'TEXT',
textColor: '#789abc',
});
Expand All @@ -264,12 +281,13 @@ describe('setParams', () => {
textColor: '#000000',
});

expect(state()).toStrictEqual({
expect(internalState()).toStrictEqual({
backgroundColor: '#111111',
hasShineEffect: true,
isEnabled: false,
isLoaderVisible: false,
isVisible: true,
position: 'right',
text: 'TEXT UPDATED',
textColor: '#000000',
});
Expand All @@ -282,15 +300,15 @@ describe('unmount', () => {
it('should stop calling postEvent function and session storage updates when something changes', () => {
const postEventSpy = mockPostEvent();
const storageSpy = mockSessionStorageSetItem();
state.set({ ...state(), text: 'Hello!' });
internalState.set({ ...internalState(), text: 'Hello!' });
expect(postEventSpy).toHaveBeenCalledTimes(1);
expect(storageSpy).toHaveBeenCalledTimes(1);

postEventSpy.mockClear();
storageSpy.mockClear();

unmount();
state.set({ ...state() });
internalState.set({ ...internalState() });

expect(postEventSpy).toHaveBeenCalledTimes(0);
expect(storageSpy).toHaveBeenCalledTimes(0);
Expand Down
78 changes: 34 additions & 44 deletions packages/sdk/src/scopes/components/secondary-button/methods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,8 @@ import {
import { isPageReload } from '@telegram-apps/navigation';

import { $version, postEvent } from '@/scopes/globals.js';
import {
mount as tpMount,
buttonColor as tpButtonColor,
buttonTextColor as tpButtonTextColor,
} from '@/scopes/components/theme-params/instance.js';

import { state, isMounted } from './signals.js';
import { internalState, isMounted, state } from './signals.js';
import type { State } from './types.js';

type StorageValue = State;
Expand All @@ -30,6 +25,23 @@ export function isSupported(): boolean {
return supports(MINI_APPS_METHOD, $version());
}

/**
* Mounts the component.
*
* This function restores the component state and is automatically saving it in the local storage
* if it changed.
*/
export function mount(): void {
if (!isMounted()) {
const prev = isPageReload() && getStorageValue<StorageValue>(STORAGE_KEY);
prev && internalState.set(prev);

internalState.sub(onInternalStateChanged);
state.sub(onStateChanged);
isMounted.set(true);
}
}

/**
* Adds a new main button click listener.
* @param fn - event listener.
Expand All @@ -47,55 +59,32 @@ export function offClick(fn: EventListener<'secondary_button_pressed'>): void {
off(CLICK_EVENT, fn);
}

/**
* Mounts the component.
*
* This function restores the component state and is automatically saving it in the local storage
* if it changed.
*/
export function mount(): void {
if (!isMounted()) {
const prev = isPageReload() && getStorageValue<StorageValue>(STORAGE_KEY);
if (prev) {
state.set(prev);
} else {
tpMount();
setParams({
backgroundColor: tpButtonColor(),
textColor: tpButtonTextColor(),
});
}

state.sub(onStateChanged);
isMounted.set(true);
}
function onInternalStateChanged(s: State): void {
setStorageValue<StorageValue>(STORAGE_KEY, s);
}

function onStateChanged(s: State): void {
function onStateChanged(s: Required<State>): void {
// We should not commit changes until the payload is correct. Some version of Telegram will
// crash due to the empty value of the text.
if (s.text) {
postEvent(MINI_APPS_METHOD, {
color: s.backgroundColor,
has_shine_effect: s.hasShineEffect,
is_active: s.isEnabled,
is_progress_visible: s.isLoaderVisible,
is_visible: s.isVisible,
position: s.position,
text: s.text,
text_color: s.textColor,
});
}
setStorageValue<StorageValue>(STORAGE_KEY, s);
s.text && postEvent(MINI_APPS_METHOD, {
color: s.backgroundColor,
has_shine_effect: s.hasShineEffect,
is_active: s.isEnabled,
is_progress_visible: s.isLoaderVisible,
is_visible: s.isVisible,
position: s.position,
text: s.text,
text_color: s.textColor,
});
}

/**
* Updates the main button state.
* @param updates - state changes to perform.
*/
export function setParams(updates: Partial<State>): void {
state.set({
...state(),
internalState.set({
...internalState(),
...Object.fromEntries(
Object.entries(updates).filter(([, v]) => v !== undefined),
),
Expand All @@ -109,6 +98,7 @@ export function setParams(updates: Partial<State>): void {
* @see onClick
*/
export function unmount(): void {
internalState.unsub(onInternalStateChanged);
state.unsub(onStateChanged);
isMounted.set(false);
}
30 changes: 20 additions & 10 deletions packages/sdk/src/scopes/components/secondary-button/signals.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,40 @@
import { computed, type Computed, signal } from '@telegram-apps/signals';

import { buttonColor } from '@/scopes/components/theme-params/signals.js';
import { bottomBarColorRGB } from '@/scopes/components/mini-app/signals.js';

import type { State } from './types.js';

/**
* Complete component state.
*/
export const state = signal<State>({
backgroundColor: '#000000',
function fromState<K extends keyof Required<State>>(key: K): Computed<Required<State>[K]> {
return computed(() => state()[key]);
}

export const internalState = signal<State>({
hasShineEffect: false,
isEnabled: true,
isLoaderVisible: false,
isVisible: false,
position: 'left',
text: 'Cancel',
textColor: '#2481cc',
});

/**
* Complete component state.
*/
export const state = computed<Required<State>>(() => {
const s = internalState();
return {
...s,
backgroundColor: s.backgroundColor || bottomBarColorRGB() || '#000000',
textColor: s.textColor || buttonColor() || '#2481cc',
};
});

/**
* True if the component is currently mounted.
*/
export const isMounted = signal(false);

function fromState<K extends keyof State>(key: K): Computed<State[K]> {
return computed(() => state()[key]);
}

/**
* @see State.backgroundColor
*/
Expand Down
Loading

0 comments on commit 389d022

Please sign in to comment.