From 32652a58939f3d75517292d55d5961751870db63 Mon Sep 17 00:00:00 2001 From: Alina Date: Sun, 17 Mar 2024 23:01:21 +0400 Subject: [PATCH 01/13] chore: add new type of operation result, update chrome browser, add exhaustive errors --- public/_locales/en/messages.json | 15 +++++++++++++ src/shared/browser/chrome.ts | 28 ++++++++++++++++++++---- src/shared/browser/types.ts | 20 ++++++----------- src/shared/libs/operationResult/index.ts | 12 +++++++--- 4 files changed, 55 insertions(+), 20 deletions(-) diff --git a/public/_locales/en/messages.json b/public/_locales/en/messages.json index 8bc09fb..794b32c 100644 --- a/public/_locales/en/messages.json +++ b/public/_locales/en/messages.json @@ -9,5 +9,20 @@ }, "close": { "message": "Close" + }, + "ERROR_CAN_NOT_GET_DATA_FROM_STORAGE": { + "message": "Can't get data from storage" + }, + "ERROR_CAN_NOT_UPDATE_DATA_IN_STORAGE": { + "message": "Can't update data in storage" + }, + "ERROR_CAN_NOT_GET_OLD_DATA_FROM_STORAGE": { + "message": "Data in storage changed, but can't get old data" + }, + "ERROR_CAN_NOT_GET_NEW_DATA_FROM_STORAGE": { + "message": "Data in storage changed, can't get new data" + }, + "SELECTED_LANGUAGES_SAVED": { + "message": "Selected languages succesfully saved" } } \ No newline at end of file diff --git a/src/shared/browser/chrome.ts b/src/shared/browser/chrome.ts index 9b7e6c6..0729856 100644 --- a/src/shared/browser/chrome.ts +++ b/src/shared/browser/chrome.ts @@ -13,7 +13,12 @@ export const chromeBrowser: IBrowser = { const json = JSON.parse(storage[key]) resolve(Result.Success(json)) } catch (error) { - resolve(Result.Error(`ERROR_CAN_NOT_GET_DATA_FROM_STORAGE`)) + resolve( + Result.Error({ + type: 'ERROR_CAN_NOT_GET_DATA_FROM_STORAGE', + error: error instanceof Error ? error : null, + }), + ) } }) }) @@ -30,7 +35,12 @@ export const chromeBrowser: IBrowser = { }) .then(() => resolve(Result.Success(true))) } catch (error) { - resolve(Result.Error(`ERROR_CAN_NOT_UPDATE_DATA_IN_STORAGE`)) + resolve( + Result.Error({ + type: `ERROR_CAN_NOT_UPDATE_DATA_IN_STORAGE`, + error: error instanceof Error ? error : null, + }), + ) } }) }, @@ -42,7 +52,12 @@ export const chromeBrowser: IBrowser = { try { oldValue = JSON.parse(changes[key].oldValue) } catch (error) { - callback(Result.Error(`ERROR_CAN_NOT_GET_OLD_DATA_FROM_STORAGE`)) + callback( + Result.Error({ + type: `ERROR_CAN_NOT_GET_OLD_DATA_FROM_STORAGE`, + error: error instanceof Error ? error : null, + }), + ) } try { @@ -54,7 +69,12 @@ export const chromeBrowser: IBrowser = { }), ) } catch (error) { - callback(Result.Error(`ERROR_CAN_NOT_GET_NEW_DATA_FROM_STORAGE`)) + callback( + Result.Error({ + type: `ERROR_CAN_NOT_GET_NEW_DATA_FROM_STORAGE`, + error: error instanceof Error ? error : null, + }), + ) } } }) diff --git a/src/shared/browser/types.ts b/src/shared/browser/types.ts index 5e0adff..077a44a 100644 --- a/src/shared/browser/types.ts +++ b/src/shared/browser/types.ts @@ -1,25 +1,19 @@ -import type { IResult } from '@shared/libs/operationResult' +import type { TResult } from '@shared/libs/operationResult' export interface IBrowser { storage: { local: { - get: ( - key: string, - defaultValue: Data, - ) => Promise> + get: (key: string, defaultValue: Data) => Promise> - set: (key: string, value: Data) => Promise> + set: (key: string, value: Data) => Promise> onChanged: ( key: string, callback: ( - changes: IResult< - { - newValue: Data - oldValue: Data - }, - string - >, + changes: TResult<{ + newValue: Data + oldValue: Data + }>, ) => void, defaultValue: Data, ) => void diff --git a/src/shared/libs/operationResult/index.ts b/src/shared/libs/operationResult/index.ts index 642443e..cb4f7ad 100644 --- a/src/shared/libs/operationResult/index.ts +++ b/src/shared/libs/operationResult/index.ts @@ -1,6 +1,12 @@ -export type IResult = { data: T; error: null } | { data: null; error: E } +export type TBaseError = { type: string; error: Error | null } +export type TResult = + | { data: T; error: null } + | { data: null; error: E } export const Result = Object.freeze({ - Success: (data: T): IResult => ({ data, error: null }), - Error: (error: E): IResult => ({ data: null, error: error }), + Success: (data: T): TResult => ({ data, error: null }), + Error: (error: E): TResult => ({ + data: null, + error: error, + }), }) From 43b98c05c813dea7f2c58de51b6b4660aa1bc1b6 Mon Sep 17 00:00:00 2001 From: Alina Date: Sun, 17 Mar 2024 23:01:52 +0400 Subject: [PATCH 02/13] chore: add abstract storage class --- src/entities/storage/index.ts | 1 + src/entities/storage/model/AbstractStorage.ts | 35 +++++++++++++++++++ src/entities/storage/model/index.ts | 1 + 3 files changed, 37 insertions(+) create mode 100644 src/entities/storage/index.ts create mode 100644 src/entities/storage/model/AbstractStorage.ts create mode 100644 src/entities/storage/model/index.ts diff --git a/src/entities/storage/index.ts b/src/entities/storage/index.ts new file mode 100644 index 0000000..116e668 --- /dev/null +++ b/src/entities/storage/index.ts @@ -0,0 +1 @@ +export * from './model' diff --git a/src/entities/storage/model/AbstractStorage.ts b/src/entities/storage/model/AbstractStorage.ts new file mode 100644 index 0000000..138822a --- /dev/null +++ b/src/entities/storage/model/AbstractStorage.ts @@ -0,0 +1,35 @@ +import { browser } from '@shared/browser' +import { TResult } from '@shared/libs/operationResult' + +export class AbstractStorage { + key: string + defaultValue: StorageValue + + constructor(key: string, defaultValue: StorageValue) { + this.key = key + this.defaultValue = defaultValue + } + + async get() { + return browser.storage.local.get(this.key, this.defaultValue) + } + + async set(value: StorageValue) { + return browser.storage.local.set(this.key, value) + } + + onChanged( + callback: ( + changes: TResult<{ + newValue: StorageValue + oldValue: StorageValue + }>, + ) => void, + ) { + browser.storage.local.onChanged( + this.key, + callback, + this.defaultValue, + ) + } +} diff --git a/src/entities/storage/model/index.ts b/src/entities/storage/model/index.ts new file mode 100644 index 0000000..3fc41c9 --- /dev/null +++ b/src/entities/storage/model/index.ts @@ -0,0 +1 @@ +export { AbstractStorage } from './AbstractStorage' From 806467cbcf450c7cfa23198b9b63904caaf08a8b Mon Sep 17 00:00:00 2001 From: Alina Date: Sun, 17 Mar 2024 23:03:14 +0400 Subject: [PATCH 03/13] chore: add selected languages storage --- src/entities/language/index.ts | 1 + .../language/model/SelectedLanguagesStorage.ts | 8 ++++++++ src/entities/language/model/index.ts | 3 +++ src/entities/language/model/languages.ts | 10 ++++++++++ src/entities/language/model/types.ts | 7 +++++++ 5 files changed, 29 insertions(+) create mode 100644 src/entities/language/index.ts create mode 100644 src/entities/language/model/SelectedLanguagesStorage.ts create mode 100644 src/entities/language/model/index.ts create mode 100644 src/entities/language/model/languages.ts create mode 100644 src/entities/language/model/types.ts diff --git a/src/entities/language/index.ts b/src/entities/language/index.ts new file mode 100644 index 0000000..116e668 --- /dev/null +++ b/src/entities/language/index.ts @@ -0,0 +1 @@ +export * from './model' diff --git a/src/entities/language/model/SelectedLanguagesStorage.ts b/src/entities/language/model/SelectedLanguagesStorage.ts new file mode 100644 index 0000000..4afa91c --- /dev/null +++ b/src/entities/language/model/SelectedLanguagesStorage.ts @@ -0,0 +1,8 @@ +import { AbstractStorage } from '@entities/storage' + +import { TLanguageCode } from '.' + +export const SelectedLanguagesStorage = new AbstractStorage( + 'selected_languages', + [], +) diff --git a/src/entities/language/model/index.ts b/src/entities/language/model/index.ts new file mode 100644 index 0000000..41292f8 --- /dev/null +++ b/src/entities/language/model/index.ts @@ -0,0 +1,3 @@ +export type { TLanguageCode, ILanguage } from './types' +export { LANGUAGES } from './languages' +export { SelectedLanguagesStorage } from './SelectedLanguagesStorage' diff --git a/src/entities/language/model/languages.ts b/src/entities/language/model/languages.ts new file mode 100644 index 0000000..c51a9ee --- /dev/null +++ b/src/entities/language/model/languages.ts @@ -0,0 +1,10 @@ +import { ILanguage } from '.' + +export const languageCodes = ['en', 'jp', 'pt', 'ko'] as const + +export const LANGUAGES: ILanguage[] = [ + { label: 'English', value: 'en' }, + { label: 'Japanese', value: 'jp' }, + { label: 'Portuguese', value: 'pt' }, + { label: 'Korean', value: 'ko' }, +] diff --git a/src/entities/language/model/types.ts b/src/entities/language/model/types.ts new file mode 100644 index 0000000..267478d --- /dev/null +++ b/src/entities/language/model/types.ts @@ -0,0 +1,7 @@ +import { languageCodes } from './languages' + +export type TLanguageCode = (typeof languageCodes)[number] +export interface ILanguage { + label: string + value: TLanguageCode +} From ea3583e4d25239c6be7762d5daf09355f3b74910 Mon Sep 17 00:00:00 2001 From: Alina Date: Sun, 17 Mar 2024 23:04:02 +0400 Subject: [PATCH 04/13] chore: add copy button --- src/shared/ui/CopyButton/index.tsx | 42 ++++++++++++++++++++++++++++++ src/shared/ui/icons/CopyIcon.tsx | 8 ++++++ src/shared/ui/icons/DoneIcon.tsx | 8 ++++++ 3 files changed, 58 insertions(+) create mode 100644 src/shared/ui/CopyButton/index.tsx create mode 100644 src/shared/ui/icons/CopyIcon.tsx create mode 100644 src/shared/ui/icons/DoneIcon.tsx diff --git a/src/shared/ui/CopyButton/index.tsx b/src/shared/ui/CopyButton/index.tsx new file mode 100644 index 0000000..f5bbec3 --- /dev/null +++ b/src/shared/ui/CopyButton/index.tsx @@ -0,0 +1,42 @@ +import { useRef, useState } from 'preact/hooks' + +import { browser } from '@shared/browser' + +import { Button, Props as ButtonProps } from '../Button' +import { CopyIcon } from '../icons/CopyIcon' +import { DoneIcon } from '../icons/DoneIcon' + +interface Props extends ButtonProps { + text: string +} + +export const CopyButton = ({ text, ...rest }: Props) => { + const displayCopySuccessTimeout = useRef() + const [isCopied, setIsCopied] = useState(false) + + const resetIsCopiedAfterDelay = () => { + clearTimeout(displayCopySuccessTimeout.current) + displayCopySuccessTimeout.current = setTimeout(() => { + setIsCopied(false) + }, 1000) + } + + const onCopy = (text: string) => { + navigator.clipboard.writeText(text) + setIsCopied(true) + resetIsCopiedAfterDelay() + } + + return ( + + + + + ) +} diff --git a/src/features/language/SelectLanguages/ui/index.ts b/src/features/language/SelectLanguages/ui/index.ts new file mode 100644 index 0000000..a58f0a2 --- /dev/null +++ b/src/features/language/SelectLanguages/ui/index.ts @@ -0,0 +1 @@ +export { SelectLanguages } from './SelectLanguages' diff --git a/src/shared/ui/Toast/Toast.tsx b/src/shared/ui/Toast/Toast.tsx index 54107b6..e8adf14 100644 --- a/src/shared/ui/Toast/Toast.tsx +++ b/src/shared/ui/Toast/Toast.tsx @@ -56,7 +56,7 @@ export const Toast = ({ toast, removeToast, openDetails }: Props) => { color={mapToastTypeToButtonColor[type]} endIcon={} onClick={removeToast} - aria-label={browser.i18n.getMessage('closeToast')} + aria-label={browser.i18n.getMessage('CLOSE')} /> ) From e9a0a56a780f1689fbb8f11e9c4585cf82561e88 Mon Sep 17 00:00:00 2001 From: Alina Date: Sun, 17 Mar 2024 23:39:53 +0400 Subject: [PATCH 08/13] test: update chrome test accroding to new error format --- src/shared/browser/__test__/chrome.test.ts | 31 +++++++++++++++------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/src/shared/browser/__test__/chrome.test.ts b/src/shared/browser/__test__/chrome.test.ts index 371e09b..ad51c43 100644 --- a/src/shared/browser/__test__/chrome.test.ts +++ b/src/shared/browser/__test__/chrome.test.ts @@ -36,9 +36,10 @@ describe('browser', () => { defaultValue, ) - expect(getResult).toEqual( - Result.Error(`ERROR_CAN_NOT_GET_DATA_FROM_STORAGE`), - ) + expect(getResult.data).toBeNull() + expect(getResult.error).toMatchObject({ + type: 'ERROR_CAN_NOT_GET_DATA_FROM_STORAGE', + }) }) test('should get default value if storage is empty', async () => { @@ -79,9 +80,10 @@ describe('browser', () => { circularValue, ) - expect(setResult).toEqual( - Result.Error('ERROR_CAN_NOT_UPDATE_DATA_IN_STORAGE'), - ) + expect(setResult.data).toBeNull() + expect(setResult.error).toMatchObject({ + type: 'ERROR_CAN_NOT_UPDATE_DATA_IN_STORAGE', + }) const getResult = await chromeBrowser.storage.local.get( KEY, @@ -137,7 +139,14 @@ describe('browser', () => { expect(callback).toHaveBeenCalledTimes(2) - expect(callback).toHaveBeenCalledWith( + const call1 = callback.mock.calls[0] + expect(call1[0].data).toBeNull() + expect(call1[0].error).toMatchObject({ + type: 'ERROR_CAN_NOT_GET_OLD_DATA_FROM_STORAGE', + }) + + const call2 = callback.mock.calls[1] + expect(call2[0]).toEqual( Result.Success({ newValue, oldValue: defaultValue, @@ -162,9 +171,11 @@ describe('browser', () => { chromeBrowser.storage.local.onChanged(KEY, callback, defaultValue) - expect(callback).toHaveBeenCalledWith( - Result.Error(`ERROR_CAN_NOT_GET_NEW_DATA_FROM_STORAGE`), - ) + const call = callback.mock.calls[0] + expect(call[0].data).toBeNull() + expect(call[0].error).toMatchObject({ + type: 'ERROR_CAN_NOT_GET_NEW_DATA_FROM_STORAGE', + }) }) }) }) From 30c3beb5e3ab00c96b4dac0c4e98f10b7ea3f170 Mon Sep 17 00:00:00 2001 From: Alina Date: Wed, 20 Mar 2024 01:42:36 +0400 Subject: [PATCH 09/13] test: add tests for use select languages hook --- package.json | 4 +- pnpm-lock.yaml | 15 ++ src/__tests__/setupTests.ts | 9 + src/__tests__/testUtils.tsx | 20 +- src/entities/storage/model/AbstractStorage.ts | 4 +- .../__tests__/useSelectLanguages.test.ts | 197 ++++++++++++++++++ .../ui/Toast/__mocks__/useAddErrorToast.tsx | 11 + src/shared/ui/Toast/__mocks__/useToast.tsx | 11 + 8 files changed, 264 insertions(+), 7 deletions(-) create mode 100644 src/features/language/SelectLanguages/hooks/__tests__/useSelectLanguages.test.ts create mode 100644 src/shared/ui/Toast/__mocks__/useAddErrorToast.tsx create mode 100644 src/shared/ui/Toast/__mocks__/useToast.tsx diff --git a/package.json b/package.json index 4053fc7..21f64f9 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ "license": "ISC", "dependencies": { "classnames": "2.5.1", - "preact": "10.19.6" + "preact": "10.19.6", + "uuid": "9.0.1" }, "devDependencies": { "@commitlint/cli": "19.0.3", @@ -28,6 +29,7 @@ "@trivago/prettier-plugin-sort-imports": "4.3.0", "@types/chrome": "0.0.262", "@types/node": "20.11.25", + "@types/uuid": "9.0.8", "@typescript-eslint/eslint-plugin": "7.1.1", "@typescript-eslint/parser": "7.1.1", "@vitest/coverage-v8": "1.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8ff726d..dac26d9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,9 @@ dependencies: preact: specifier: 10.19.6 version: 10.19.6 + uuid: + specifier: 9.0.1 + version: 9.0.1 devDependencies: '@commitlint/cli': @@ -33,6 +36,9 @@ devDependencies: '@types/node': specifier: 20.11.25 version: 20.11.25 + '@types/uuid': + specifier: 9.0.8 + version: 9.0.8 '@typescript-eslint/eslint-plugin': specifier: 7.1.1 version: 7.1.1(@typescript-eslint/parser@7.1.1)(eslint@8.57.0)(typescript@5.4.2) @@ -1194,6 +1200,10 @@ packages: resolution: {integrity: sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==} dev: true + /@types/uuid@9.0.8: + resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==} + dev: true + /@typescript-eslint/eslint-plugin@7.1.1(@typescript-eslint/parser@7.1.1)(eslint@8.57.0)(typescript@5.4.2): resolution: {integrity: sha512-zioDz623d0RHNhvx0eesUmGfIjzrk18nSBC8xewepKXbBvN/7c1qImV7Hg8TI1URTxKax7/zxfxj3Uph8Chcuw==} engines: {node: ^16.0.0 || >=18.0.0} @@ -4350,6 +4360,11 @@ packages: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} dev: true + /uuid@9.0.1: + resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + hasBin: true + dev: false + /v8-to-istanbul@9.2.0: resolution: {integrity: sha512-/EH/sDgxU2eGxajKdwLCDmQ4FWq+kpi3uCmBGpw1xJtnAxEjlD8j8PEiGWpCIMIs3ciNAgH0d3TTJiUkYzyZjA==} engines: {node: '>=10.12.0'} diff --git a/src/__tests__/setupTests.ts b/src/__tests__/setupTests.ts index 597cb88..b2f0112 100644 --- a/src/__tests__/setupTests.ts +++ b/src/__tests__/setupTests.ts @@ -1,3 +1,12 @@ +import { vi } from 'vitest' + import { chromeMock } from '@shared/browser/__mocks__/chrome' +import * as toastModule from '@shared/ui/Toast' +import { useAddErrorToastMock } from '@shared/ui/Toast/__mocks__/useAddErrorToast' +import { useToastMock } from '@shared/ui/Toast/__mocks__/useToast' global.chrome = chromeMock +vi.spyOn(toastModule, 'useToast').mockImplementation(useToastMock) +vi.spyOn(toastModule, 'useAddErrorToast').mockImplementation( + useAddErrorToastMock, +) diff --git a/src/__tests__/testUtils.tsx b/src/__tests__/testUtils.tsx index e2d5dd6..f297c8a 100644 --- a/src/__tests__/testUtils.tsx +++ b/src/__tests__/testUtils.tsx @@ -1,8 +1,14 @@ -import { RenderOptions, render } from '@testing-library/preact' +import { + RenderOptions, + renderHook as baseRenderHook, + render, +} from '@testing-library/preact' import { ComponentChildren } from 'preact' +import { ToastProvider } from '@shared/ui/Toast' + const AllTheProviders = ({ children }: { children: ComponentChildren }) => { - return children + return {children} } const customRender = ( @@ -10,8 +16,14 @@ const customRender = ( options?: Omit, ) => render(ui, { wrapper: AllTheProviders, ...options }) -// re-export everything export * from '@testing-library/preact' -// override render method export { customRender as render } + +export const renderHook = ( + hook: (initialProps: Props) => Result, +) => baseRenderHook(hook, { wrapper: AllTheProviders }) + +export const INVALID_JSON_STRING = `{"name": "Joe", "age": null]` +export const CIRCULAR_VALUE = { prop: 'value', circularRef: {} } +CIRCULAR_VALUE.circularRef = CIRCULAR_VALUE diff --git a/src/entities/storage/model/AbstractStorage.ts b/src/entities/storage/model/AbstractStorage.ts index 138822a..5dad7c5 100644 --- a/src/entities/storage/model/AbstractStorage.ts +++ b/src/entities/storage/model/AbstractStorage.ts @@ -10,11 +10,11 @@ export class AbstractStorage { this.defaultValue = defaultValue } - async get() { + get() { return browser.storage.local.get(this.key, this.defaultValue) } - async set(value: StorageValue) { + set(value: StorageValue) { return browser.storage.local.set(this.key, value) } diff --git a/src/features/language/SelectLanguages/hooks/__tests__/useSelectLanguages.test.ts b/src/features/language/SelectLanguages/hooks/__tests__/useSelectLanguages.test.ts new file mode 100644 index 0000000..9d92b64 --- /dev/null +++ b/src/features/language/SelectLanguages/hooks/__tests__/useSelectLanguages.test.ts @@ -0,0 +1,197 @@ +import { afterEach, describe, expect, test, vi } from 'vitest' + +import { SelectedLanguagesStorage } from '@entities/language' + +import { Result } from '@shared/libs/operationResult' +import { useAddErrorToastMockReturnValues } from '@shared/ui/Toast/__mocks__/useAddErrorToast' +import { useToastMockReturnValues } from '@shared/ui/Toast/__mocks__/useToast' + +import { renderHook, waitFor } from '@tests/testUtils' + +import { useSelectLanguages } from '../useSelectLanguages' + +describe('useSelectLangugages', () => { + const error = { + type: `ERROR`, + error: null, + } + + afterEach(async () => { + await chrome.storage.local.clear() + vi.clearAllMocks() + }) + + describe('initialization', () => { + test('should set empty array when SelectedLanguagesStorage returns no selected variants', async () => { + SelectedLanguagesStorage.set([]) + + const { result, unmount, rerender } = renderHook(() => + useSelectLanguages(), + ) + + rerender() + + await waitFor(() => { + expect(result.current.selectedLanguages.length).toBe(0) + expect(useAddErrorToastMockReturnValues.addErrorToast).toBeCalledTimes( + 0, + ) + }) + + unmount() + }) + + test('should set empty array and show toast with error when DictionariesService returns error', async () => { + vi.spyOn(SelectedLanguagesStorage, 'get').mockResolvedValue( + Result.Error(error), + ) + SelectedLanguagesStorage.set([]) + + const { result, unmount, rerender } = renderHook(() => + useSelectLanguages(), + ) + + rerender() + + await waitFor(() => { + expect(result.current.selectedLanguages.length).toBe(0) + expect(useAddErrorToastMockReturnValues.addErrorToast).toBeCalledWith( + error, + ) + }) + + unmount() + }) + + test('should change selected languages when selected language store changed', async () => { + vi.spyOn(SelectedLanguagesStorage, 'onChanged').mockImplementation( + (callback) => { + callback( + Result.Success({ + newValue: ['jp'], + oldValue: [], + }), + ) + }, + ) + + const { result, unmount, rerender } = renderHook(() => + useSelectLanguages(), + ) + + rerender() + + await waitFor(() => { + const isSelected = result.current.checkIsSelectedLanguage('jp') + expect(isSelected).toBeTruthy() + expect(useAddErrorToastMockReturnValues.addErrorToast).toBeCalledTimes( + 0, + ) + }) + + unmount() + }) + + test('should not change selected and show toast with error languages when selected language store changed with error', async () => { + vi.spyOn(SelectedLanguagesStorage, 'onChanged').mockImplementation( + (callback) => { + callback(Result.Error(error)) + }, + ) + + const { result, unmount, rerender } = renderHook(() => + useSelectLanguages(), + ) + + rerender() + + await waitFor(() => { + expect(result.current.selectedLanguages.length).toBe(0) + expect(useAddErrorToastMockReturnValues.addErrorToast).toBeCalledWith( + error, + ) + }) + + unmount() + }) + }) + + describe('toggleSelectedLanguage', () => { + test('should select language if not selected', async () => { + SelectedLanguagesStorage.set([]) + + const { result, unmount, rerender } = renderHook(() => + useSelectLanguages(), + ) + + rerender() + + await waitFor(() => { + result.current.toggleSelectedLanguage('en')() + const isSelected = result.current.checkIsSelectedLanguage('en') + expect(isSelected).toBeTruthy() + }) + + unmount() + }) + + test('should unselect language if selected', async () => { + SelectedLanguagesStorage.set(['en']) + + const { result, unmount, rerender } = renderHook(() => + useSelectLanguages(), + ) + + rerender() + + await waitFor(() => { + result.current.toggleSelectedLanguage('en')() + const isSelected = result.current.checkIsSelectedLanguage('en') + expect(isSelected).toBeFalsy() + }) + + unmount() + }) + }) + + describe('updateSelectedLanguages', () => { + test('should set to selected languages store s lected values and show opration success information', async () => { + SelectedLanguagesStorage.set(['en']) + + const { result, unmount, rerender } = renderHook(() => + useSelectLanguages(), + ) + + rerender() + + await waitFor(() => { + result.current.updateSelectedLanguages() + const call = useToastMockReturnValues.addToast.mock.calls[0] + expect(call[0].type).toBe('success') + expect(call[0].title).toBeDefined() + }) + + unmount() + }) + test('should show error when can not update languages', async () => { + vi.spyOn(SelectedLanguagesStorage, 'set').mockResolvedValue( + Result.Error(error), + ) + + const { result, unmount, rerender } = renderHook(() => + useSelectLanguages(), + ) + + rerender() + + await waitFor(() => { + result.current.updateSelectedLanguages() + expect(useAddErrorToastMockReturnValues.addErrorToast).toBeCalledWith( + error, + ) + }) + + unmount() + }) + }) +}) diff --git a/src/shared/ui/Toast/__mocks__/useAddErrorToast.tsx b/src/shared/ui/Toast/__mocks__/useAddErrorToast.tsx new file mode 100644 index 0000000..b19859f --- /dev/null +++ b/src/shared/ui/Toast/__mocks__/useAddErrorToast.tsx @@ -0,0 +1,11 @@ +import { MockedFunction, vi } from 'vitest' + +import { useAddErrorToast } from '../useAddErrorToast' + +export const useAddErrorToastMockReturnValues = { + addErrorToast: vi.fn(), +} + +export const useAddErrorToastMock: MockedFunction = vi + .fn() + .mockReturnValue(useAddErrorToastMockReturnValues) diff --git a/src/shared/ui/Toast/__mocks__/useToast.tsx b/src/shared/ui/Toast/__mocks__/useToast.tsx new file mode 100644 index 0000000..05a36b3 --- /dev/null +++ b/src/shared/ui/Toast/__mocks__/useToast.tsx @@ -0,0 +1,11 @@ +import { MockedFunction, vi } from 'vitest' + +import { useToast } from '../ToastContext' + +export const useToastMockReturnValues = { + addToast: vi.fn(), +} + +export const useToastMock: MockedFunction = vi + .fn() + .mockReturnValue(useToastMockReturnValues) From dae1ec200e3d48921a27e7dea6cb6cece3df5af7 Mon Sep 17 00:00:00 2001 From: Alina Date: Wed, 20 Mar 2024 03:19:18 +0400 Subject: [PATCH 10/13] test: add tests for select languages component --- .../hooks/__mock__/useSelectLanguages.ts | 14 +++ .../ui/__tests__/SelectLanguages.test.tsx | 114 ++++++++++++++++++ 2 files changed, 128 insertions(+) create mode 100644 src/features/language/SelectLanguages/hooks/__mock__/useSelectLanguages.ts create mode 100644 src/features/language/SelectLanguages/ui/__tests__/SelectLanguages.test.tsx diff --git a/src/features/language/SelectLanguages/hooks/__mock__/useSelectLanguages.ts b/src/features/language/SelectLanguages/hooks/__mock__/useSelectLanguages.ts new file mode 100644 index 0000000..fffeafa --- /dev/null +++ b/src/features/language/SelectLanguages/hooks/__mock__/useSelectLanguages.ts @@ -0,0 +1,14 @@ +import { MockedFunction, vi } from 'vitest' + +import { useSelectLanguages } from '../useSelectLanguages' + +export const useSelectLanguagesMockReturnValues = { + toggleSelectedLanguage: vi.fn(), + checkIsSelectedLanguage: vi.fn(), + updateSelectedLanguages: vi.fn(), + selectedLanguages: [], + reset: vi.fn(), +} + +export const useSelectLanguagesMock: MockedFunction = + vi.fn().mockReturnValue(useSelectLanguagesMockReturnValues) diff --git a/src/features/language/SelectLanguages/ui/__tests__/SelectLanguages.test.tsx b/src/features/language/SelectLanguages/ui/__tests__/SelectLanguages.test.tsx new file mode 100644 index 0000000..bad8156 --- /dev/null +++ b/src/features/language/SelectLanguages/ui/__tests__/SelectLanguages.test.tsx @@ -0,0 +1,114 @@ +import { afterEach, describe, expect, test, vi } from 'vitest' + +import { LANGUAGES } from '@entities/language' + +import { cleanup, fireEvent, render, screen } from '@tests/testUtils' + +import { + useSelectLanguagesMock, + useSelectLanguagesMockReturnValues, +} from '../../hooks/__mock__/useSelectLanguages' +import * as selectLanguagesModule from '../../hooks/useSelectLanguages' +import { SelectLanguages } from '../SelectLanguages' + +describe('SelectLanguages component', () => { + vi.spyOn(selectLanguagesModule, 'useSelectLanguages').mockImplementation( + useSelectLanguagesMock, + ) + + afterEach(() => { + cleanup() + vi.clearAllMocks() + }) + + test('renders SelectLanguages component', () => { + render() + + expect(screen.getByText(/SELECT_LANGUAGES_FORM_TITLE/i)).toBeDefined() + }) + + test('calls updateSelectedLanguages when form is submitted', () => { + render() + + const form = screen.getByRole('form', { + name: /SELECT_LANGUAGES_FORM_TITLE/i, + }) + + fireEvent.submit(form) + + expect( + useSelectLanguagesMockReturnValues.updateSelectedLanguages, + ).toHaveBeenCalled() + }) + + test('calls updateSelectedLanguages when form submit button is clicked', () => { + render() + + const saveButton = screen.getByRole('button', { + name: /SAVE/i, + }) + + fireEvent.submit(saveButton) + + expect( + useSelectLanguagesMockReturnValues.updateSelectedLanguages, + ).toHaveBeenCalled() + }) + + test('calls reset when form cancel button is clicked', () => { + render() + + const cancelButton = screen.getByRole('button', { + name: /CANCEL/i, + }) + + fireEvent.click(cancelButton) + + expect(useSelectLanguagesMockReturnValues.reset).toHaveBeenCalled() + }) + + test('calls toggleSelectedLanguage with "en" value when language English is clicked', () => { + render() + + const englishLanguageCheckbox = screen.getByRole('checkbox', { + name: /english/i, + }) + + fireEvent.click(englishLanguageCheckbox) + + expect( + useSelectLanguagesMockReturnValues.toggleSelectedLanguage, + ).toHaveBeenCalledWith('en') + }) + + test('checkbox is checked, when checkIsSelectedLanguage returns true', () => { + useSelectLanguagesMockReturnValues.checkIsSelectedLanguage.mockReturnValue( + true, + ) + render() + const englishLanguageCheckbox = screen.getByRole( + 'checkbox', + { + name: /english/i, + }, + ) + + expect(englishLanguageCheckbox.checked).toBeTruthy() + }) + + test('checkbox is not checked, when checkIsSelectedLanguage returns false', () => { + useSelectLanguagesMockReturnValues.checkIsSelectedLanguage.mockReturnValue( + false, + ) + + render() + const englishLanguageCheckbox = screen.getByRole( + 'checkbox', + { + name: /english/i, + }, + ) + + expect(englishLanguageCheckbox.checked).toBeFalsy() + }) +}) From 625c0a37cb9cbb74c9be3038d1b84908242cc2b6 Mon Sep 17 00:00:00 2001 From: Alina Date: Wed, 20 Mar 2024 03:36:06 +0400 Subject: [PATCH 11/13] chore: rename abstract storage to storage --- src/entities/language/model/SelectedLanguagesStorage.ts | 4 ++-- src/entities/storage/model/{AbstractStorage.ts => Storage.ts} | 2 +- src/entities/storage/model/index.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) rename src/entities/storage/model/{AbstractStorage.ts => Storage.ts} (94%) diff --git a/src/entities/language/model/SelectedLanguagesStorage.ts b/src/entities/language/model/SelectedLanguagesStorage.ts index 4afa91c..b7291aa 100644 --- a/src/entities/language/model/SelectedLanguagesStorage.ts +++ b/src/entities/language/model/SelectedLanguagesStorage.ts @@ -1,8 +1,8 @@ -import { AbstractStorage } from '@entities/storage' +import { Storage } from '@entities/storage' import { TLanguageCode } from '.' -export const SelectedLanguagesStorage = new AbstractStorage( +export const SelectedLanguagesStorage = new Storage( 'selected_languages', [], ) diff --git a/src/entities/storage/model/AbstractStorage.ts b/src/entities/storage/model/Storage.ts similarity index 94% rename from src/entities/storage/model/AbstractStorage.ts rename to src/entities/storage/model/Storage.ts index 5dad7c5..441ff7b 100644 --- a/src/entities/storage/model/AbstractStorage.ts +++ b/src/entities/storage/model/Storage.ts @@ -1,7 +1,7 @@ import { browser } from '@shared/browser' import { TResult } from '@shared/libs/operationResult' -export class AbstractStorage { +export class Storage { key: string defaultValue: StorageValue diff --git a/src/entities/storage/model/index.ts b/src/entities/storage/model/index.ts index 3fc41c9..f658a8e 100644 --- a/src/entities/storage/model/index.ts +++ b/src/entities/storage/model/index.ts @@ -1 +1 @@ -export { AbstractStorage } from './AbstractStorage' +export { Storage } from './Storage' From 61d4f4725dac55432a8246951b608c4d782651f6 Mon Sep 17 00:00:00 2001 From: Alina Date: Wed, 20 Mar 2024 03:49:39 +0400 Subject: [PATCH 12/13] chore: rename some types --- src/shared/browser/__mocks__/chrome.ts | 10 +++++----- src/shared/browser/__test__/chrome.test.ts | 19 ++++++++++--------- src/shared/ui/Toast/toastStore.ts | 4 ++-- 3 files changed, 17 insertions(+), 16 deletions(-) diff --git a/src/shared/browser/__mocks__/chrome.ts b/src/shared/browser/__mocks__/chrome.ts index 26d9f91..73fc220 100644 --- a/src/shared/browser/__mocks__/chrome.ts +++ b/src/shared/browser/__mocks__/chrome.ts @@ -1,12 +1,12 @@ import { MockedObject, vi } from 'vitest' -type IChrome = typeof chrome -type StoreValue = Record +type TChrome = typeof chrome +type TStorageValue = Record -let store: StoreValue = {} +let store: TStorageValue = {} function getStorageValueByKey(key: string) { - const result: StoreValue = {} + const result: TStorageValue = {} result[key] = store[key] return result } @@ -40,4 +40,4 @@ export const chromeMock = { i18n: { getMessage: vi.fn((key) => `Translated<${key}>`), }, -} as unknown as MockedObject +} as unknown as MockedObject diff --git a/src/shared/browser/__test__/chrome.test.ts b/src/shared/browser/__test__/chrome.test.ts index ad51c43..24ac3c5 100644 --- a/src/shared/browser/__test__/chrome.test.ts +++ b/src/shared/browser/__test__/chrome.test.ts @@ -6,11 +6,11 @@ import { chromeBrowser } from '../chrome' describe('browser', () => { describe('storage', () => { - type StorageValue = object + type TStorageValue = object const KEY = 'testKey' - const storedValue: StorageValue = { someData: 'value' } - const defaultValue: StorageValue = { defaultData: 'defaultValue' } + const storedValue: TStorageValue = { someData: 'value' } + const defaultValue: TStorageValue = { defaultData: 'defaultValue' } const circularValue = { prop: 'value', circularRef: {} } circularValue.circularRef = circularValue const invalidJSONString = `{"name": "Joe", "age": null]` @@ -52,10 +52,11 @@ describe('browser', () => { }) test('should get previously setted data', async () => { - const setResult = await chromeBrowser.storage.local.set( - KEY, - storedValue, - ) + const setResult = + await chromeBrowser.storage.local.set( + KEY, + storedValue, + ) expect(setResult).toEqual(Result.Success(true)) @@ -68,14 +69,14 @@ describe('browser', () => { }) test('should not set data to the storage if data is not valid', async () => { - let setResult = await chromeBrowser.storage.local.set( + let setResult = await chromeBrowser.storage.local.set( KEY, storedValue, ) expect(setResult).toEqual(Result.Success(true)) - setResult = await chromeBrowser.storage.local.set( + setResult = await chromeBrowser.storage.local.set( KEY, circularValue, ) diff --git a/src/shared/ui/Toast/toastStore.ts b/src/shared/ui/Toast/toastStore.ts index aee6680..0774521 100644 --- a/src/shared/ui/Toast/toastStore.ts +++ b/src/shared/ui/Toast/toastStore.ts @@ -2,13 +2,13 @@ import { useCallback, useRef, useState } from 'preact/hooks' import { IToast } from './Toast' -export type IAddToastProps = Omit +export type TAddToastProps = Omit export const useToastsStore = () => { const timersRef = useRef>({}) const [toasts, setToasts] = useState([]) - const addToast = useCallback((toast: IAddToastProps) => { + const addToast = useCallback((toast: TAddToastProps) => { const toastId = new Date().toISOString() setToasts((prevToasts) => [ { From c0df961e023af6ace69b99722060c28e9c728a6e Mon Sep 17 00:00:00 2001 From: Alina Date: Thu, 21 Mar 2024 18:46:56 +0400 Subject: [PATCH 13/13] chore: change select languages hook to nanostores --- package.json | 1 - pnpm-lock.yaml | 15 -- src/__tests__/testUtils.tsx | 4 +- .../model/SelectedLanguagesStorage.ts | 8 - src/entities/language/model/index.ts | 1 - .../hooks/__mock__/useSelectLanguages.ts | 14 -- .../__tests__/useSelectLanguages.test.ts | 197 ------------------ .../hooks/useSelectLanguages.tsx | 73 ------- .../SelectLanguages/model/__mock__/store.ts | 12 ++ .../model/__tests__/store.test.ts | 121 +++++++++++ .../language/SelectLanguages/model/store.ts | 56 +++++ .../SelectLanguages/ui/SelectLanguages.tsx | 15 +- .../ui/__tests__/SelectLanguages.test.tsx | 39 ++-- .../ui/Toast/__mocks__/useAddErrorToast.tsx | 11 - src/shared/ui/Toast/__mocks__/useToast.tsx | 11 - src/shared/ui/Toast/useAddErrorToast.tsx | 24 --- 16 files changed, 210 insertions(+), 392 deletions(-) delete mode 100644 src/entities/language/model/SelectedLanguagesStorage.ts delete mode 100644 src/features/language/SelectLanguages/hooks/__mock__/useSelectLanguages.ts delete mode 100644 src/features/language/SelectLanguages/hooks/__tests__/useSelectLanguages.test.ts delete mode 100644 src/features/language/SelectLanguages/hooks/useSelectLanguages.tsx create mode 100644 src/features/language/SelectLanguages/model/__mock__/store.ts create mode 100644 src/features/language/SelectLanguages/model/__tests__/store.test.ts create mode 100644 src/features/language/SelectLanguages/model/store.ts delete mode 100644 src/shared/ui/Toast/__mocks__/useAddErrorToast.tsx delete mode 100644 src/shared/ui/Toast/__mocks__/useToast.tsx delete mode 100644 src/shared/ui/Toast/useAddErrorToast.tsx diff --git a/package.json b/package.json index ece38c0..6befb63 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,6 @@ "@trivago/prettier-plugin-sort-imports": "4.3.0", "@types/chrome": "0.0.262", "@types/node": "20.11.25", - "@types/uuid": "9.0.8", "@typescript-eslint/eslint-plugin": "7.1.1", "@typescript-eslint/parser": "7.1.1", "@vitest/coverage-v8": "1.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 356068f..50afb5b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,9 +16,6 @@ dependencies: preact: specifier: 10.19.6 version: 10.19.6 - uuid: - specifier: 9.0.1 - version: 9.0.1 devDependencies: '@commitlint/cli': @@ -45,9 +42,6 @@ devDependencies: '@types/node': specifier: 20.11.25 version: 20.11.25 - '@types/uuid': - specifier: 9.0.8 - version: 9.0.8 '@typescript-eslint/eslint-plugin': specifier: 7.1.1 version: 7.1.1(@typescript-eslint/parser@7.1.1)(eslint@8.57.0)(typescript@5.4.2) @@ -1220,10 +1214,6 @@ packages: resolution: {integrity: sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==} dev: true - /@types/uuid@9.0.8: - resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==} - dev: true - /@typescript-eslint/eslint-plugin@7.1.1(@typescript-eslint/parser@7.1.1)(eslint@8.57.0)(typescript@5.4.2): resolution: {integrity: sha512-zioDz623d0RHNhvx0eesUmGfIjzrk18nSBC8xewepKXbBvN/7c1qImV7Hg8TI1URTxKax7/zxfxj3Uph8Chcuw==} engines: {node: ^16.0.0 || >=18.0.0} @@ -4391,11 +4381,6 @@ packages: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} dev: true - /uuid@9.0.1: - resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} - hasBin: true - dev: false - /v8-to-istanbul@9.2.0: resolution: {integrity: sha512-/EH/sDgxU2eGxajKdwLCDmQ4FWq+kpi3uCmBGpw1xJtnAxEjlD8j8PEiGWpCIMIs3ciNAgH0d3TTJiUkYzyZjA==} engines: {node: '>=10.12.0'} diff --git a/src/__tests__/testUtils.tsx b/src/__tests__/testUtils.tsx index f297c8a..2f094f2 100644 --- a/src/__tests__/testUtils.tsx +++ b/src/__tests__/testUtils.tsx @@ -5,10 +5,8 @@ import { } from '@testing-library/preact' import { ComponentChildren } from 'preact' -import { ToastProvider } from '@shared/ui/Toast' - const AllTheProviders = ({ children }: { children: ComponentChildren }) => { - return {children} + return <>{children} } const customRender = ( diff --git a/src/entities/language/model/SelectedLanguagesStorage.ts b/src/entities/language/model/SelectedLanguagesStorage.ts deleted file mode 100644 index b7291aa..0000000 --- a/src/entities/language/model/SelectedLanguagesStorage.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Storage } from '@entities/storage' - -import { TLanguageCode } from '.' - -export const SelectedLanguagesStorage = new Storage( - 'selected_languages', - [], -) diff --git a/src/entities/language/model/index.ts b/src/entities/language/model/index.ts index 41292f8..013f6d4 100644 --- a/src/entities/language/model/index.ts +++ b/src/entities/language/model/index.ts @@ -1,3 +1,2 @@ export type { TLanguageCode, ILanguage } from './types' export { LANGUAGES } from './languages' -export { SelectedLanguagesStorage } from './SelectedLanguagesStorage' diff --git a/src/features/language/SelectLanguages/hooks/__mock__/useSelectLanguages.ts b/src/features/language/SelectLanguages/hooks/__mock__/useSelectLanguages.ts deleted file mode 100644 index fffeafa..0000000 --- a/src/features/language/SelectLanguages/hooks/__mock__/useSelectLanguages.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { MockedFunction, vi } from 'vitest' - -import { useSelectLanguages } from '../useSelectLanguages' - -export const useSelectLanguagesMockReturnValues = { - toggleSelectedLanguage: vi.fn(), - checkIsSelectedLanguage: vi.fn(), - updateSelectedLanguages: vi.fn(), - selectedLanguages: [], - reset: vi.fn(), -} - -export const useSelectLanguagesMock: MockedFunction = - vi.fn().mockReturnValue(useSelectLanguagesMockReturnValues) diff --git a/src/features/language/SelectLanguages/hooks/__tests__/useSelectLanguages.test.ts b/src/features/language/SelectLanguages/hooks/__tests__/useSelectLanguages.test.ts deleted file mode 100644 index 9d92b64..0000000 --- a/src/features/language/SelectLanguages/hooks/__tests__/useSelectLanguages.test.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { afterEach, describe, expect, test, vi } from 'vitest' - -import { SelectedLanguagesStorage } from '@entities/language' - -import { Result } from '@shared/libs/operationResult' -import { useAddErrorToastMockReturnValues } from '@shared/ui/Toast/__mocks__/useAddErrorToast' -import { useToastMockReturnValues } from '@shared/ui/Toast/__mocks__/useToast' - -import { renderHook, waitFor } from '@tests/testUtils' - -import { useSelectLanguages } from '../useSelectLanguages' - -describe('useSelectLangugages', () => { - const error = { - type: `ERROR`, - error: null, - } - - afterEach(async () => { - await chrome.storage.local.clear() - vi.clearAllMocks() - }) - - describe('initialization', () => { - test('should set empty array when SelectedLanguagesStorage returns no selected variants', async () => { - SelectedLanguagesStorage.set([]) - - const { result, unmount, rerender } = renderHook(() => - useSelectLanguages(), - ) - - rerender() - - await waitFor(() => { - expect(result.current.selectedLanguages.length).toBe(0) - expect(useAddErrorToastMockReturnValues.addErrorToast).toBeCalledTimes( - 0, - ) - }) - - unmount() - }) - - test('should set empty array and show toast with error when DictionariesService returns error', async () => { - vi.spyOn(SelectedLanguagesStorage, 'get').mockResolvedValue( - Result.Error(error), - ) - SelectedLanguagesStorage.set([]) - - const { result, unmount, rerender } = renderHook(() => - useSelectLanguages(), - ) - - rerender() - - await waitFor(() => { - expect(result.current.selectedLanguages.length).toBe(0) - expect(useAddErrorToastMockReturnValues.addErrorToast).toBeCalledWith( - error, - ) - }) - - unmount() - }) - - test('should change selected languages when selected language store changed', async () => { - vi.spyOn(SelectedLanguagesStorage, 'onChanged').mockImplementation( - (callback) => { - callback( - Result.Success({ - newValue: ['jp'], - oldValue: [], - }), - ) - }, - ) - - const { result, unmount, rerender } = renderHook(() => - useSelectLanguages(), - ) - - rerender() - - await waitFor(() => { - const isSelected = result.current.checkIsSelectedLanguage('jp') - expect(isSelected).toBeTruthy() - expect(useAddErrorToastMockReturnValues.addErrorToast).toBeCalledTimes( - 0, - ) - }) - - unmount() - }) - - test('should not change selected and show toast with error languages when selected language store changed with error', async () => { - vi.spyOn(SelectedLanguagesStorage, 'onChanged').mockImplementation( - (callback) => { - callback(Result.Error(error)) - }, - ) - - const { result, unmount, rerender } = renderHook(() => - useSelectLanguages(), - ) - - rerender() - - await waitFor(() => { - expect(result.current.selectedLanguages.length).toBe(0) - expect(useAddErrorToastMockReturnValues.addErrorToast).toBeCalledWith( - error, - ) - }) - - unmount() - }) - }) - - describe('toggleSelectedLanguage', () => { - test('should select language if not selected', async () => { - SelectedLanguagesStorage.set([]) - - const { result, unmount, rerender } = renderHook(() => - useSelectLanguages(), - ) - - rerender() - - await waitFor(() => { - result.current.toggleSelectedLanguage('en')() - const isSelected = result.current.checkIsSelectedLanguage('en') - expect(isSelected).toBeTruthy() - }) - - unmount() - }) - - test('should unselect language if selected', async () => { - SelectedLanguagesStorage.set(['en']) - - const { result, unmount, rerender } = renderHook(() => - useSelectLanguages(), - ) - - rerender() - - await waitFor(() => { - result.current.toggleSelectedLanguage('en')() - const isSelected = result.current.checkIsSelectedLanguage('en') - expect(isSelected).toBeFalsy() - }) - - unmount() - }) - }) - - describe('updateSelectedLanguages', () => { - test('should set to selected languages store s lected values and show opration success information', async () => { - SelectedLanguagesStorage.set(['en']) - - const { result, unmount, rerender } = renderHook(() => - useSelectLanguages(), - ) - - rerender() - - await waitFor(() => { - result.current.updateSelectedLanguages() - const call = useToastMockReturnValues.addToast.mock.calls[0] - expect(call[0].type).toBe('success') - expect(call[0].title).toBeDefined() - }) - - unmount() - }) - test('should show error when can not update languages', async () => { - vi.spyOn(SelectedLanguagesStorage, 'set').mockResolvedValue( - Result.Error(error), - ) - - const { result, unmount, rerender } = renderHook(() => - useSelectLanguages(), - ) - - rerender() - - await waitFor(() => { - result.current.updateSelectedLanguages() - expect(useAddErrorToastMockReturnValues.addErrorToast).toBeCalledWith( - error, - ) - }) - - unmount() - }) - }) -}) diff --git a/src/features/language/SelectLanguages/hooks/useSelectLanguages.tsx b/src/features/language/SelectLanguages/hooks/useSelectLanguages.tsx deleted file mode 100644 index d15591f..0000000 --- a/src/features/language/SelectLanguages/hooks/useSelectLanguages.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { useEffect, useRef, useState } from 'preact/hooks' - -import { TLanguageCode } from '@entities/language' -import { SelectedLanguagesStorage } from '@entities/language' - -import { browser } from '@shared/browser' -import { useAddErrorToast, useToast } from '@shared/ui/Toast' - -export const useSelectLanguages = () => { - const { addToast } = useToast() - const { addErrorToast } = useAddErrorToast() - const initialSelectedLanguages = useRef([]) - - const [selectedLanguages, setSelectedLanguages] = useState( - [], - ) - - const restoreSelectedLanguages = async () => { - const selectedDictionaries = await SelectedLanguagesStorage.get() - if (selectedDictionaries.data) { - setSelectedLanguages(selectedDictionaries.data) - initialSelectedLanguages.current = selectedDictionaries.data - } else { - addErrorToast(selectedDictionaries.error) - } - } - - useEffect(() => { - restoreSelectedLanguages() - SelectedLanguagesStorage.onChanged((changes) => { - changes.data - ? setSelectedLanguages(changes.data.newValue) - : addErrorToast(changes.error) - }) - }, []) - - const toggleSelectedLanguage = (language: TLanguageCode) => () => { - setSelectedLanguages((prevState) => { - const isSelected = prevState.includes(language) - return isSelected - ? prevState.filter((lang) => lang !== language) - : [...prevState, language] - }) - } - - const checkIsSelectedLanguage = (languageCode: TLanguageCode) => { - return selectedLanguages.includes(languageCode) - } - - const updateSelectedLanguages = async () => { - const setResult = await SelectedLanguagesStorage.set( - selectedLanguages as TLanguageCode[], - ) - setResult.data - ? addToast({ - type: 'success', - title: browser.i18n.getMessage('SELECTED_LANGUAGES_SAVED'), - }) - : addErrorToast(setResult.error) - } - - const reset = () => { - setSelectedLanguages(initialSelectedLanguages.current) - } - - return { - toggleSelectedLanguage, - selectedLanguages, - checkIsSelectedLanguage, - updateSelectedLanguages, - reset, - } -} diff --git a/src/features/language/SelectLanguages/model/__mock__/store.ts b/src/features/language/SelectLanguages/model/__mock__/store.ts new file mode 100644 index 0000000..b8e97da --- /dev/null +++ b/src/features/language/SelectLanguages/model/__mock__/store.ts @@ -0,0 +1,12 @@ +import { MockedFunction, vi } from 'vitest' + +import { checkIsSelected, commit, reset, toggle } from '../store' + +export const commitMock: MockedFunction = vi.fn() + +export const resetMock: MockedFunction = vi.fn() + +export const toggleMock: MockedFunction = vi.fn() + +export const checkIsSelectedMock: MockedFunction = + vi.fn() diff --git a/src/features/language/SelectLanguages/model/__tests__/store.test.ts b/src/features/language/SelectLanguages/model/__tests__/store.test.ts new file mode 100644 index 0000000..14f722f --- /dev/null +++ b/src/features/language/SelectLanguages/model/__tests__/store.test.ts @@ -0,0 +1,121 @@ +import { allTasks, cleanStores, keepMount } from 'nanostores' +import { afterEach, describe, expect, test, vi } from 'vitest' + +import { Result } from '@shared/libs/operationResult' +import { getErrorToastMock } from '@shared/ui/Toast/helpers/__mock__/getErrorToast' +import { addToastMock } from '@shared/ui/Toast/model/__mock__/store' + +import { waitFor } from '@tests/testUtils' + +import { + $selectedLanguages, + SelectedLanguagesStorage, + checkIsSelected, + commit, + reset, + toggle, +} from '../store' + +describe('selectedLanguages store', () => { + const error = { + type: `ERROR`, + error: null, + } + + afterEach(async () => { + await chrome.storage.local.clear() + cleanStores($selectedLanguages) + $selectedLanguages.set([]) + }) + + test('should initialize with empty array', () => { + keepMount($selectedLanguages) + + expect($selectedLanguages.get()).toEqual([]) + expect(getErrorToastMock).toBeCalledTimes(0) + }) + + describe('commmon', () => { + test('should set value from SelectedLanguagesStorage', async () => { + await SelectedLanguagesStorage.set(['en']) + + keepMount($selectedLanguages) + await allTasks() + + expect($selectedLanguages.get()).toEqual(['en']) + expect(getErrorToastMock).toBeCalledTimes(0) + }) + + test('should set empty array and show toast with error when SelectedLanguagesStorage returns error', async () => { + vi.spyOn(SelectedLanguagesStorage, 'get').mockResolvedValueOnce( + Result.Error(error), + ) + + keepMount($selectedLanguages) + await allTasks() + + expect($selectedLanguages.get()).toEqual([]) + expect(getErrorToastMock).toBeCalledWith(error) + }) + }) + + describe('toggle', () => { + test('should select language if not selected and reset', async () => { + await SelectedLanguagesStorage.set([]) + + keepMount($selectedLanguages) + await allTasks() + + toggle('en') + expect(checkIsSelected('en')).toBeTruthy() + + reset() + expect(checkIsSelected('en')).toBeFalsy() + }) + + test('should unselect language if selected and reset', async () => { + await SelectedLanguagesStorage.set(['en']) + + keepMount($selectedLanguages) + await allTasks() + + toggle('en') + expect(checkIsSelected('en')).toBeFalsy() + + reset() + expect(checkIsSelected('en')).toBeTruthy() + }) + }) + + describe('commit', () => { + test('should set to selected languages store selected values and show opration success information', async () => { + $selectedLanguages.set(['en']) + + keepMount($selectedLanguages) + await allTasks() + + waitFor(async () => { + await commit() + + const call = addToastMock.mock.calls[0] + expect(call[0].type).toBe('success') + expect(call[0].title).toBeDefined() + }) + }) + + test('should show error when can not update languages', async () => { + vi.spyOn(SelectedLanguagesStorage, 'set').mockResolvedValue( + Result.Error(error), + ) + + keepMount($selectedLanguages) + await allTasks() + + waitFor(async () => { + await commit() + + expect(getErrorToastMock).toBeCalledWith(error) + }) + }) + }) +}) diff --git a/src/features/language/SelectLanguages/model/store.ts b/src/features/language/SelectLanguages/model/store.ts new file mode 100644 index 0000000..1ac3008 --- /dev/null +++ b/src/features/language/SelectLanguages/model/store.ts @@ -0,0 +1,56 @@ +import { atom, onMount, task } from 'nanostores' + +import { TLanguageCode } from '@entities/language' +import { Storage } from '@entities/storage' + +import { browser } from '@shared/browser' +import { addToast, getErrorToast } from '@shared/ui/Toast' + +export const SelectedLanguagesStorage = new Storage( + 'selected_languages', + [], +) + +let defaultValue: TLanguageCode[] = [] +export const $selectedLanguages = atom([]) + +onMount($selectedLanguages, () => { + task(async () => { + const getResult = await SelectedLanguagesStorage.get() + if (getResult.data) { + defaultValue = getResult.data + $selectedLanguages.set(getResult.data) + } else { + addToast(getErrorToast(getResult.error)) + } + }) +}) + +export const toggle = async (languageCode: TLanguageCode) => { + const isSelected = $selectedLanguages.get().includes(languageCode) + + const newSelectedLanguages = isSelected + ? $selectedLanguages.get().filter((lang) => lang !== languageCode) + : [...$selectedLanguages.get(), languageCode] + + $selectedLanguages.set(newSelectedLanguages) +} + +export const checkIsSelected = (languageCode: TLanguageCode) => { + return $selectedLanguages.get().includes(languageCode) +} + +export const reset = () => { + $selectedLanguages.set(defaultValue) +} + +export const commit = async () => { + const setResult = await SelectedLanguagesStorage.set($selectedLanguages.get()) + + setResult.data + ? addToast({ + type: 'success', + title: browser.i18n.getMessage('SELECTED_LANGUAGES_SAVED'), + }) + : addToast(getErrorToast(setResult.error)) +} diff --git a/src/features/language/SelectLanguages/ui/SelectLanguages.tsx b/src/features/language/SelectLanguages/ui/SelectLanguages.tsx index 9707c03..58b7292 100644 --- a/src/features/language/SelectLanguages/ui/SelectLanguages.tsx +++ b/src/features/language/SelectLanguages/ui/SelectLanguages.tsx @@ -5,23 +5,16 @@ import { ILanguage } from '@entities/language/model' import { browser } from '@shared/browser' import { Button } from '@shared/ui/Button' -import { useSelectLanguages } from '../hooks/useSelectLanguages' +import { checkIsSelected, commit, reset, toggle } from '../model/store' interface Props { languages: ILanguage[] } export const SelectLanguages = ({ languages }: Props) => { - const { - toggleSelectedLanguage, - checkIsSelectedLanguage, - updateSelectedLanguages, - reset, - } = useSelectLanguages() - const onSubmit: JSXInternal.SubmitEventHandler = (e) => { e.preventDefault() - updateSelectedLanguages() + commit() } return ( @@ -43,8 +36,8 @@ export const SelectLanguages = ({ languages }: Props) => { type="checkbox" multiple value={language.value} - checked={checkIsSelectedLanguage(language.value)} - onChange={toggleSelectedLanguage(language.value)} + checked={checkIsSelected(language.value)} + onChange={() => toggle(language.value)} /> {language.label} diff --git a/src/features/language/SelectLanguages/ui/__tests__/SelectLanguages.test.tsx b/src/features/language/SelectLanguages/ui/__tests__/SelectLanguages.test.tsx index bad8156..a5584e6 100644 --- a/src/features/language/SelectLanguages/ui/__tests__/SelectLanguages.test.tsx +++ b/src/features/language/SelectLanguages/ui/__tests__/SelectLanguages.test.tsx @@ -5,16 +5,19 @@ import { LANGUAGES } from '@entities/language' import { cleanup, fireEvent, render, screen } from '@tests/testUtils' import { - useSelectLanguagesMock, - useSelectLanguagesMockReturnValues, -} from '../../hooks/__mock__/useSelectLanguages' -import * as selectLanguagesModule from '../../hooks/useSelectLanguages' + checkIsSelectedMock, + commitMock, + resetMock, + toggleMock, +} from '../../model/__mock__/store' +import * as store from '../../model/store' import { SelectLanguages } from '../SelectLanguages' describe('SelectLanguages component', () => { - vi.spyOn(selectLanguagesModule, 'useSelectLanguages').mockImplementation( - useSelectLanguagesMock, - ) + vi.spyOn(store, 'checkIsSelected').mockImplementation(checkIsSelectedMock) + vi.spyOn(store, 'commit').mockImplementation(commitMock) + vi.spyOn(store, 'reset').mockImplementation(resetMock) + vi.spyOn(store, 'toggle').mockImplementation(toggleMock) afterEach(() => { cleanup() @@ -36,9 +39,7 @@ describe('SelectLanguages component', () => { fireEvent.submit(form) - expect( - useSelectLanguagesMockReturnValues.updateSelectedLanguages, - ).toHaveBeenCalled() + expect(commitMock).toHaveBeenCalled() }) test('calls updateSelectedLanguages when form submit button is clicked', () => { @@ -50,9 +51,7 @@ describe('SelectLanguages component', () => { fireEvent.submit(saveButton) - expect( - useSelectLanguagesMockReturnValues.updateSelectedLanguages, - ).toHaveBeenCalled() + expect(commitMock).toHaveBeenCalled() }) test('calls reset when form cancel button is clicked', () => { @@ -64,7 +63,7 @@ describe('SelectLanguages component', () => { fireEvent.click(cancelButton) - expect(useSelectLanguagesMockReturnValues.reset).toHaveBeenCalled() + expect(resetMock).toHaveBeenCalled() }) test('calls toggleSelectedLanguage with "en" value when language English is clicked', () => { @@ -76,15 +75,11 @@ describe('SelectLanguages component', () => { fireEvent.click(englishLanguageCheckbox) - expect( - useSelectLanguagesMockReturnValues.toggleSelectedLanguage, - ).toHaveBeenCalledWith('en') + expect(toggleMock).toHaveBeenCalledWith('en') }) test('checkbox is checked, when checkIsSelectedLanguage returns true', () => { - useSelectLanguagesMockReturnValues.checkIsSelectedLanguage.mockReturnValue( - true, - ) + checkIsSelectedMock.mockReturnValue(true) render() const englishLanguageCheckbox = screen.getByRole( 'checkbox', @@ -97,9 +92,7 @@ describe('SelectLanguages component', () => { }) test('checkbox is not checked, when checkIsSelectedLanguage returns false', () => { - useSelectLanguagesMockReturnValues.checkIsSelectedLanguage.mockReturnValue( - false, - ) + checkIsSelectedMock.mockReturnValue(false) render() const englishLanguageCheckbox = screen.getByRole( diff --git a/src/shared/ui/Toast/__mocks__/useAddErrorToast.tsx b/src/shared/ui/Toast/__mocks__/useAddErrorToast.tsx deleted file mode 100644 index b19859f..0000000 --- a/src/shared/ui/Toast/__mocks__/useAddErrorToast.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { MockedFunction, vi } from 'vitest' - -import { useAddErrorToast } from '../useAddErrorToast' - -export const useAddErrorToastMockReturnValues = { - addErrorToast: vi.fn(), -} - -export const useAddErrorToastMock: MockedFunction = vi - .fn() - .mockReturnValue(useAddErrorToastMockReturnValues) diff --git a/src/shared/ui/Toast/__mocks__/useToast.tsx b/src/shared/ui/Toast/__mocks__/useToast.tsx deleted file mode 100644 index 05a36b3..0000000 --- a/src/shared/ui/Toast/__mocks__/useToast.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { MockedFunction, vi } from 'vitest' - -import { useToast } from '../ToastContext' - -export const useToastMockReturnValues = { - addToast: vi.fn(), -} - -export const useToastMock: MockedFunction = vi - .fn() - .mockReturnValue(useToastMockReturnValues) diff --git a/src/shared/ui/Toast/useAddErrorToast.tsx b/src/shared/ui/Toast/useAddErrorToast.tsx deleted file mode 100644 index 5dc4a11..0000000 --- a/src/shared/ui/Toast/useAddErrorToast.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { browser } from '@shared/browser' -import { TBaseError } from '@shared/libs/operationResult' - -import { useToast } from '.' -import { ErrorDetails } from '../ErrorDetails' - -export const useAddErrorToast = () => { - const { addToast } = useToast() - - const addErrorToast = (error: TBaseError) => { - addToast({ - type: 'error', - title: browser.i18n.getMessage(error.type), - details: ( - - ), - }) - } - - return { addErrorToast } -}