Написать пост

Создание Typing Test приложения на React + TypeScript + Redux Toolkit

Рассказал о создании приложения для проверки скорости и точности печати (Typing Test App) на React + TypeScript и Redux Toolkit.

Создание Typing Test приложения на React + TypeScript + Redux Toolkit

Всем доброго времени суток! В данной статье я хочу рассказать о создании приложения для проверки скорости и точности печати (Typing Test App). Приложение будем создавать на React + TypeScript, для работы с состоянием приложения используем Redux Toolkit.

В статье я не буду описывать создание стилей, а с полным кодом приложения вы можете ознакомиться в репозитории проекта.

Задачи

  1. У пользователей должна быть возможность выбирать количество предложений
  2. Текст необходимо получать из внешнего API;
  3. Применение соответствующих стилей для правильного и неправильного символа;
  4. Подсветка текущего символа;
  5. Приложение должно вычислять и отображать скорость и точность печати текста пользователем;
  6. Пользователи должны иметь возможность перезапустить текущий тест.

Настройка проекта

Создадим React проект с TypeScript шаблоном, выполнив в терминале следующую команду: npx create-react-app typing-test-app --template typescript.

После завершения установки, перейдем в директорию проекта, установим axios (npm install axios) и Redux Toolkit (npm install @reduxjs/toolkit react-redux).

Удалим все файлы, которые нам не пригодятся (оставим index.tsx, App.tsx, index.css, react-app-env.d.ts). Создадим папки api, assets, components, helpers, redux, types и style. Файл index.css переместим в папку style.

Отредактируем оставшиеся файлы.

Файл index.tsx:

			import React from 'react';
import ReactDOM from 'react-dom/client';

import './style/index.css';

import App from './App';

const root = ReactDOM.createRoot(
  document.getElementById('root') as HTMLElement
);

root.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);
		

Файл App.tsx:

			import { FunctionComponent } from 'react';

const App:FunctionComponent = () => {
  return (
    <>
    </>
  );
};

export default App;
		

Приложение мы пишем на TypeScript, поэтому для функциональных компонентов желательно указывать тип FunctionComponent, который мы импортируем из React.

Header и Footer

В папке components создаем папку ui, в которой создадим два компонента Header и Footer. В Header, импортируем логотип и стили. Возвращать будем

с логотипом и названием приложения.

			import { FunctionComponent } from 'react';

import logo from '../../assets/images/logo.svg';

import '../../style/ui/header.css';

const Header:FunctionComponent = () => {
  return (
    <header className='container header'>
      <div className='header-container'>
        <img src={logo} alt='site logo' />
        <h1 className='large-header'>Typing Test</h1>
      </div>
    </header>
  );
};

export default Header;
		

В Footer импортируем стили и возвращаем

с одним абзацем.

			import { FunctionComponent } from 'react';

import '../../style/ui/footer.css';

const Footer:FunctionComponent = () => {
  return (
    <footer className='container footer'>
      <p>Made by Viktor P. © 2023</p>
    </footer>
  );
};

export default Footer;
		

Импортируем созданные компоненты в App.tsx.

			import { FunctionComponent } from 'react';

import './style/typography.css';

import Header from './components/ui/Header';
import Footer from './components/ui/Footer';

const App:FunctionComponent = () => {
  return (
    <>
      <Header />
      <main className='container main'>
      </main>
      <Footer />
    </>
  );
};

export default App;
		

Создание базовых компонентов

В папке components создадим компонент Test, пока что этот компонент будет отображать только слово ‘Test’.

			import { FunctionComponent } from 'react';

const Test:FunctionComponent = () => {
  return (
    <section>
      <h2 className='big-header'>Test</h2>
    </section>
  );
};

export default Test;
		

В папке ui создадим компонент Button. Импортируем встроенный тип ComponentPropsWithoutRef, с помощью него мы сможем получить все атрибуты, которые есть у элемента

Там же создаем компонент ModalWindow. В этот компонент мы будем передавать текст для заголовка окна и необходимые элементы в качестве children.

			import { FunctionComponent } from 'react';

import '../../style/ui/modal.css';

type ModalWindowProps = {
  children: JSX.Element | JSX.Element[];
  title: string;
};

const ModalWindow:FunctionComponent<ModalWindowProps> = ( {children, title} ) => {
  return (
    <div className='modal-window-blackout'>
      <div className='modal-window'>
        <h2 className='big-header modal-window-text'>
          {title}
        </h2>
        {children}
      </div>
    </div>
  );
};

export default ModalWindow;
		

НастройкаRedux Toolkit

Перед тем как использовать созданные компоненты в проекте, мы создадим состояние для теста. Для этого воспользуемся Redux Toolkit.

В папке redux, создадим папку store, в которой создадим файл testSlice. Опишем тип для состояния теста. Для isTestStarted и isTestFinished, мы зададим тип boolean, а sentences будет string. Начальным состоянием isTestStarted и isTestFinished установим false, а sentences будет строкой с цифрой 4.

Далее, с помощью метода createSlice создадим слайс для нашего приложения. У нас будет четыре редюсера, по одному для работы с каждым отдельным состоянием и один общий, для сброса состояния теста к исходным значениям. Для типизации action.payload воспользуемся встроенным типом PayloadAction, которому в качестве дженерика будем передавать необходимый тип.

			import { createSlice, PayloadAction } from '@reduxjs/toolkit';

type TestState = {
  isTestStarted: boolean;
  isTestFinished: boolean;
  sentences: string;
}

const initialState: TestState = {
  isTestStarted: false,
  isTestFinished: false,
  sentences: '4',
};

const testSlice = createSlice({
  name: 'testSlice',
  initialState,
  reducers: {
    setIsTestStarted(state, action: PayloadAction<boolean>) {
      state.isTestStarted = action.payload;
    },
    setIsTestFinished(state, action: PayloadAction<boolean>) {
      state.isTestFinished = action.payload;
    },
    setSentences(state, action: PayloadAction<string>) {
      state.sentences = action.payload;
    },
    resetTestState(state) {
      state.isTestStarted = false;
      state.isTestFinished = false;
      state.sentences = '4';
    }
  }
});

export const { 
  setIsTestStarted, 
  setIsTestFinished, 
  setSentences, 
  resetTestState 
} = testSlice.actions;

export default testSlice.reducer;
		

Далее, в папке store создаем одноименный файл и в нем опишем store нашего приложения. Редюсер у нас пока один, импортируем его из файла testSlice.

Экспортируем созданный store, и так как мы используем TypeScript, необходимо создать и экспортировать дополнительные типы. Они понадобятся для работы с Redux хуками. Для RootState воспользуемся встроенной TypeScript утилитой – ReturnType, которая будет принимать определение типа метода getState, а возвращать тип возвращаемого getState значения. Для определения типа AppDispatch воспользуемся оператором typeof.

			import { configureStore } from '@reduxjs/toolkit';

import testReducer from './testSlice';

const store = configureStore({
  reducer: {
    testSlice: testReducer,
  },
});

export default store;

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
		

В папке redux создадим файл hooks, импортируем в него созданные типы. И создаем два кастомных хука: useAppDispatch и useAppSelector. Создание этих хуков подробно описано в документации Redux Toolkit, это просто типизированные версии встроенных Redux хуков useDispatch и useSelector.

			import { useDispatch, useSelector } from 'react-redux';
import type { TypedUseSelectorHook } from 'react-redux';
import type { RootState, AppDispatch } from './store/store';

export const useAppDispatch: () => AppDispatch = useDispatch;
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
		

В файле index.tsx. Обернем компонент App в Provider. В Provider передадим созданный store.

			import ReactDOM from 'react-dom/client';
import { Provider } from 'react-redux';

import store from './redux/store/store';

import './style/index.css';

import App from './App';

const root = ReactDOM.createRoot(
  document.getElementById('root') as HTMLElement
);

root.render(
  <Provider store={store}>
    <App />
  </Provider>
);
		

Откроем файл App, импортируем в него созданные хуки, функции setIsTestStarted и setSentences, и компоненты Button и ModalWindow.

Создадим функцию testStateToggler, внутри которой будем изменять состояние isTestStarted на true, тем самым запуская тест.

В блок

добавим рендер по условию, если isTestStarted – true, то отрисовываем Test, если false, то отрисовываем ModalWindow. В ModalWindow передаем Button с функцией testStateToggler для события onClick.

			import { FunctionComponent } from 'react';

import './style/typography.css';

import { useAppSelector, useAppDispatch } from './redux/hooks';
import { setIsTestStarted, setSentences } from './redux/store/testSlice';

import Header from './components/ui/Header';
import Footer from './components/ui/Footer';
import Test from './components/Test';
import ModalWindow from './components/ui/ModalWindow';
import Button from './components/ui/Button';

const App:FunctionComponent = () => {
  const dispatch = useAppDispatch();
  const isTestStarted = useAppSelector(state => state.testSlice.isTestStarted);

  const testStateToggler = () => dispatch(setIsTestStarted(true));

  return (
    <>
      <Header />
      <main className='container main'>
        {
          isTestStarted 
            ? <Test /> 
            : <ModalWindow title='Take a typing test'>
                <Button btnText='start' onClick={testStateToggler} />
              </ModalWindow>
        }
      </main>
      <Footer />
    </>
  );
};

export default App;
		

Теперь изначально будет отображаться компонент ModalWindow, а при нажатии на кнопку Start, появится компонент Test.

Работа с текстом

Для получения текста используем https://baconipsum.com/json-api/. В папке api создадим файл getText, который будет содержать асинхронную функцию. Внутри этой функции мы с помощью axios отправляем GET запрос на сервер и возвращаем ответ, в нашем случае в ответ на запрос мы ожидаем получить строку с текстом. В качестве аргумента функция getText будет принимать количество запрашиваемых предложений. Остальные параметры запроса берем из документации baconipsum

			import axios from 'axios';

async function getText(sentences: string) {
  const response = await axios.get<string>('https://baconipsum.com/api/', {
    params: {
      type: 'all-meat',
      sentences,
      format: 'text'
    }
  });

  return response;
}

export default getText;
		

Теперь опишем тип для массива символов. В папке types создадим одноименный файл. Массив будет состоять из объектов с двумя свойствами: сам символ и css – класс.

			export type TextType = {
  char: string;
  class: string;
};
		

Для работы с текстом создадим отдельный слайс, перейдем в папку redux/store и создадим там файл textSlice. Импортируем в него TextType и функцию getText. Для выполнения асинхронного запроса нам понадобится метод createAsyncThunk, импортируем его из redux toolkit.

Начнем с описания типа, здесь нам понадобится сам массив символов – text, состояние загрузки текста – isLoading, возможные ошибки загрузки – error, также нам нужно следить за индексом текущего символа – currentCharIndex, считать количество ошибок – mistakes и общее количество нажатий – pressingCount.

Далее, используя метод createAsyncThunk, создаем функцию fetchText. Для типизации createAsyncThunk используем дженерик, в который мы передадим три параметра. Первым параметром будет string, т.к. мы ожидаем, что функция вернет нам строку, вторым параметром также будет string, т.к. именно строку мы будем передавать функции колбэку и третьим параметром укажем rejectValue, он нам понадобится для обработки ошибок.

В сам метод createAsyncThunk мы передаем имя для action и асинхронную функцию для получения текста. Внутри этой асинхронной функции воспользуемся ранее созданной функцией getText, в которую передадим количество запрашиваемых предложений. В случае успеха будем возвращать response.data, в случае ошибки вернем встроенную функцию rejectWithValue с сообщением об ошибке.

В объекте reducers опишем редюсеры для работы с состоянием текста. setText для изменения массива с текстом, setCurrentCharIndex и setMistakes для изменения индекса текущего символа и количества ошибок соответственно, increasePressingCount для подсчета количества нажатий и resetTextState для сброса состояния к начальным значениям.

В extraReducers будем обрабатывать action для функции fetchText. Во время загрузки изменяем состояние isLoading на true и обнулять error. При успешном получении данных будем разбивать полученную строку на символы и формировать массив объектов, который сохраним в text. Для символа с индексом 0 сразу будем устанавливать класс ‘current-char’. В случае ошибки будем присваивать в error сообщение об ошибке.

			import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';

import getText from '../../api/getText';

import { TextType } from '../../types/types';

type TextState = {
  text: TextType[];
  isLoading: boolean;
  error: string | null | undefined;
  currentCharIndex: number;
  mistakes: number;
  pressingCount: number;
};

export const fetchText = createAsyncThunk<string, string, {rejectValue: string}>(
  'textSlice/fetchText',
  async function(sentences: string, {rejectWithValue}) {
    try {
      const response = await getText(sentences);
      return response.data;
    }
    catch (e) {
      return rejectWithValue( (e as Error).message );
    }
  }
);

const initialState: TextState = {
  text: [],
  isLoading: false,
  error: null,
  currentCharIndex: 0,
  mistakes: 0,
  pressingCount: 0
};

const textSlice = createSlice({
  name: 'textSlice',
  initialState,
  reducers: {
    setText(state, action: PayloadAction<TextType[]>) {
      state.text = action.payload;
    },
    setCurrentCharIndex(state, action: PayloadAction<number>) {
      state.currentCharIndex = action.payload;
    },
    setMistakes(state, action: PayloadAction<number>) {
      state.mistakes = action.payload;
    },
    increasePressingCount(state) {
      state.pressingCount = state.pressingCount + 1;
    },
    resetTextState(state) {
      state.currentCharIndex = 0;
      state.mistakes = 0;
      state.pressingCount = 0;
    },
  },
  extraReducers: (builder) => {
    builder
      .addCase(fetchText.pending, (state) => {
        state.isLoading = true;
        state.error = null;
      })
      .addCase(fetchText.fulfilled, (state, action) => {
        state.text = action.payload.split('').map((item, index) => {
          return index === 0 
            ? {char: item, class: 'current-char'} 
            : {char: item, class: ''} 
        });
        state.isLoading = false;
      })
      .addCase(fetchText.rejected, (state, action) => {
        state.isLoading = false;
        state.error = action.payload;
      });
  }
});

export const { 
  setText, 
  setCurrentCharIndex,  
  setMistakes, 
  increasePressingCount,
  resetTextState
} = textSlice.actions;

export default textSlice.reducer;
		

Импортируем textSlice в наш store.

			import { configureStore } from '@reduxjs/toolkit';

import testReducer from './testSlice';
import textReducer from './textSlice';

const store = configureStore({
  reducer: {
    testSlice: testReducer,
    textSlice: textReducer,
  },
});

export default store;

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
		

Для стилизации символов нам необходимо создать две функции. Первая будет стилизовать текущий символ, а вторая будет сверять нажатую клавишу с текущим символом и применять необходимый стиль, правильного или неправильного символа.

В папке helpers создадим файл charTransform. Начнем с функции getCurrentChar, на вход она будет принимать массив типа TextType и индекс текущего элемента, а возвращать новый массив типа TextType. Внутри самой функции будем перебирать входной массив и сравнивать индекс элемента массива с индексом текущего элемента, если индексы равны, то возвращаем элемент с классом current-char.

Теперь создадим функцию compareChars, на вход она будет принимать массив типа TextType, индекс текущего элемента, количество ошибок и нажатую клавишу, возвращать будет новый массив типа TextType, новый индекс текущего элемента и новое количество ошибок. Внутри функции делаем примерно тоже самое, проходим по массиву, сравниваем индексы и проверяем, правильно ли нажата клавиша.

			import { TextType } from '../types/types';

type GetCurrentCharType = (
  charsArray: TextType[], 
    currentIndex: number
) => TextType[];

type CompareCharsType = (
  charsArray: TextType[], 
    currentIndex: number,
    pressedKey: string,
    mistakes: number,
) => [
  resultArr: TextType[],
  currentIndex: number,
  mistakes: number
];

export const getCurrentChar: GetCurrentCharType = (charsArray, currentIndex) => {
  return charsArray.map((item, index) => {
    if (index === currentIndex) {
      return {
        ...item,
        class: 'current-char'
      };
    }

    return item;
  });
};

export const compareChars: CompareCharsType = (charsArray, currentIndex, pressedKey, mistakes) => {
  let newCurrentIndex = currentIndex;
  let newMistakes = mistakes;

  const resultArr = charsArray.map((item, index) => {
    if (index === currentIndex && item.char === pressedKey) {
      newCurrentIndex += 1;
      return {
        ...item,
        class: 'right-char'
      };
    } else if (index === currentIndex && item.char !== pressedKey) {
      newMistakes += 1;
      return {
        ...item,
        class: 'wrong-char'
      };
    }

    return item;
  });

  return [resultArr, newCurrentIndex, newMistakes];
};
		

Далее, в папке components создадим компонент Text. Импортируем в него функции getCurrentChar и compareChars. Из textSlice импортируем функции fetchText, setText, setCurrentCharIndex, increasePressingCount и setMistakes.

Для получения текста, внутри хука useEffect будем вызывать функцию fetchText. В которую будем передавать переменную sentences.

Добавим еще один useEffect, в котором при изменении индекса текущего символа, будем вызывать функцию getCurrentChar и изменять текст.

Для обработки нажатия клавиш также используем useEffect. Внутри будем сравнивать currentCharIndex с длиной текста и если currentCharIndex меньше, то используя конструкцию Function Expression, создаем функцию обработчик. В этой функции будем вызывать функцию compareChars, и обновлять текст, индекс текущего символа, количество ошибок и количество нажатий.

В этом компоненте мы используем условный рендеринг. Если есть ошибка, будем выводить на экран значение переменной error, если идет загрузка, будем отображать параграф с текстом «Loading text…», а когда текст загружен, будем выводить его на экран.

			import { FunctionComponent, useEffect } from 'react';

import { useAppDispatch, useAppSelector } from '../redux/hooks';
import { fetchText, setText, setCurrentCharIndex, increasePressingCount, setMistakes } from '../redux/store/textSlice';

import { getCurrentChar, compareChars } from '../helpers/charTransform';

const Text:FunctionComponent = () => {
  const dispatch = useAppDispatch();
  const text = useAppSelector(state => state.textSlice.text);
  const isLoading = useAppSelector(state => state.textSlice.isLoading);
  const error = useAppSelector(state => state.textSlice.error);
  const currentCharIndex = useAppSelector(state => state.textSlice.currentCharIndex);
  const mistakes = useAppSelector(state => state.textSlice.mistakes);
  const pressingCount = useAppSelector(state => state.textSlice.pressingCount);
  const sentences = useAppSelector(state => state.testSlice.sentences);

  useEffect(() => {
    dispatch(fetchText(sentences));
  }, [dispatch]);

  useEffect(() => {
    const newText = getCurrentChar(text, currentCharIndex);
    dispatch(setText(newText));
  }, [dispatch, currentCharIndex]);

  useEffect(() => {
    if (currentCharIndex < text.length) {
      const keyPressHandler = (event: KeyboardEvent) => {
        const [newText, newCurrentIndex, newMistakes] = compareChars(text, currentCharIndex, event.key, mistakes);
        
        dispatch(setCurrentCharIndex(newCurrentIndex));
        dispatch(setText(newText));
        dispatch(setMistakes(newMistakes));
        dispatch(increasePressingCount());
      }

      document.addEventListener('keypress', keyPressHandler);

      return () => {
        document.removeEventListener('keypress', keyPressHandler);
      };
    }
  }, [dispatch, text]);

  return (
    <div className='test-text-wrapper'>
      {
        error && 
          <p className='error-text'>{error}</p>
      }
      {
        isLoading 
          ? <p className='test-loading-text'>Loading text...</p>
          : <div>
              {
                text.map((item, index) => {
                  return (
                    <span className={item.class} key={index}>
                      {item.char}
                    </span>
                  )
                })
              }
            </div> 
      }
    </div>
  );
};

export default Text;
		

Импортируем созданный компонент Text в компонент Test.

			import { FunctionComponent } from 'react';

import '../style/test.css';

import Text from './Text';

const Test:FunctionComponent = () => {
  return (
    <section className='test-container'>
      <Text />
    </section>
  );
};

export default Test;
		

Подсчет скорости и точности печати

Для подсчета скорости печати нам потребуется таймер. В папке redux/store создадим файл timerSlice. Начальным состоянием будет выключенный таймер и количество секунд равное 0. Также будет три редюсера, для изменения состояния таймера, увеличения секунд на 1 и обнуления секунд.

			import { createSlice, PayloadAction } from '@reduxjs/toolkit';

type TimerState = {
  isTimerOn: boolean;
  seconds: number;
}

const initialState: TimerState = {
  isTimerOn: false,
  seconds: 0,
};

const timerSlice = createSlice({
  name: 'timerSlice',
  initialState,
  reducers: {
    setIsTimerOn(state, action: PayloadAction<boolean>) {
      state.isTimerOn = action.payload;
    },
    increaseSeconds(state) {
      state.seconds = state.seconds + 1;
    },
    resetSeconds(state) {
      state.seconds = 0;
    },
  }
});

export const { setIsTimerOn, increaseSeconds, resetSeconds } = timerSlice.actions;
export default timerSlice.reducer;
		

Импортируем timerSlice в наш store.

			import { configureStore } from '@reduxjs/toolkit';

import testReducer from './testSlice';
import textReducer from './textSlice';
import timerReducer from './timerSlice';

const store = configureStore({
  reducer: {
    testSlice: testReducer,
    textSlice: textReducer,
    timerSlice: timerReducer
  },
});

export default store;

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
		

Теперь нам необходимо добавить включение и выключение таймера в компонент Text. Перед созданием обработчика нажатия клавиш, будем проверять количество нажатий и при первом нажатии запускать таймер. Внутри функции keyPressHandler будем проверять новое значение индекса текущего символа, и если оно равно длине текста, то будем выключать таймер и устанавливать состояние isTestFinished в значение true.

			useEffect(() => {
  if (pressingCount === 0 && text.length > 0) {
    dispatch(setIsTimerOn(true));
  }
  
  if (currentCharIndex < text.length) {
    const keyPressHandler = (event: KeyboardEvent) => {
      const [newText, newCurrentIndex, newMistakes] = compareChars(text, currentCharIndex, event.key, mistakes);
            
      dispatch(setCurrentCharIndex(newCurrentIndex));
      dispatch(setText(newText));
      dispatch(setMistakes(newMistakes));
      dispatch(increasePressingCount());
    
      if (newCurrentIndex === text.length) {
        dispatch(setIsTimerOn(false));
        dispatch(setIsTestFinished(true));
      }
    }
  
    document.addEventListener('keypress', keyPressHandler);
    
    return () => {
      document.removeEventListener('keypress', keyPressHandler);
    };
  }
}, [dispatch, text]);
		

Далее создадим функции подсчета скорости и точности печати. Для этого в папке helpers создадим файл statsCounting, и в нем создадим две эти функции.

Функция accuracyCounting будет принимать количество ошибок и общее количество нажатий, а возвращать процент правильно нажатых клавиш. Так как общее количество нажатий может быть равно нулю, необходимо это проверить.

Функция speedCounting принимает количество правильных символов и количество секунд. Возвращает скорость печати (количество слов в минуту). Для вычисления скорости необходимо перевести секунды в минуты, а количество правильных символов в количество слов (обычно подобные приложения берут среднюю длину слов равную пяти символам). Секунды могут быть равны нулю, поэтому их также необходимо проверить.

			export function accuracyCounting(mistakes: number, pressingCount: number) {
  if (pressingCount) {
    return (100 - ((mistakes / pressingCount) * 100)).toFixed(2);
  }
  
  return '0.00';
}

export function speedCounting(correctLetters: number, seconds: number) {
  if (seconds) {
    const words = correctLetters / 5;
    const minutes = seconds / 60;
    
    return (words / minutes).toFixed(2);
  }
  
  return '0.00';
}
		

Теперь в папке components создадим компонент Stats, который будем использовать для отображения статистики. Импортируем в него функции speedCounting, accuracyCounting и increaseSeconds. В качестве props этот компонент будет принимать только необязательных children.

Создадим состояние для скорости и точности, а ошибки, нажатия, секунды и состояние таймера будем брать из глобального состояния. Воспользуемся хуком useEffect и при изменении количества ошибок, нажатий или секунд, будем вызывать функции speedCounting и accuracyCounting и устанавливать результаты вызова этих функции в соответствующее состояние.

Добавим еще один useEffect, в котором будем проверять, включен ли таймер, и если включен, то будем увеличивать количество секунд.

			import { FunctionComponent, useState, useEffect } from 'react';

import '../style/stats.css';

import { useAppDispatch, useAppSelector } from '../redux/hooks';
import { increaseSeconds } from '../redux/store/timerSlice';

import { speedCounting, accuracyCounting } from '../helpers/statsCounting';

type StatsProps = {
  children?: JSX.Element | JSX.Element[];
};

const Stats:FunctionComponent<StatsProps> = ( {children} ) => {
  const dispatch = useAppDispatch();
  const mistakes = useAppSelector(state => state.textSlice.mistakes);
  const pressingCount = useAppSelector(state => state.textSlice.pressingCount);
  const seconds = useAppSelector(state => state.timerSlice.seconds);
  const isTimerOn = useAppSelector(state => state.timerSlice.isTimerOn);
  const [speed, setSpeed] = useState('0.00');
  const [accuracy, setAccuracy] = useState('0.00');

  useEffect(() => {
    const correctLetters = pressingCount - mistakes;
    
    setAccuracy(accuracyCounting(mistakes, pressingCount));
    setSpeed(speedCounting(correctLetters, seconds));
  }, [mistakes, pressingCount, seconds]);

  useEffect(() => {
    if (isTimerOn) {
      const timer = setTimeout(() => {
        dispatch(increaseSeconds());
      }, 1000);
      return () => clearTimeout(timer);
    }
  }, [isTimerOn, seconds, dispatch]);
  
  return (
    <div className='stats-container'>
      <div>
        <p className='mid-header uppercase-text stat-title'>speed</p>
        <p className='uppercase-text paragraph'>{speed} WPM</p>
      </div>
      <div>
        <p className='mid-header uppercase-text stat-title'>accuracy</p>
        <p className='uppercase-text paragraph'>{accuracy} %</p>
      </div>
      {children}
    </div>
  );
};

export default Stats;
		

Импортируем созданный компонент в компонент Test.

			import { FunctionComponent } from 'react';

import '../style/test.css';

import Text from './Text';
import Stats from './Stats';

const Test:FunctionComponent = () => {
return (
    <section className='test-container'>
      <Text />
      <Stats />
    </section>
  );
};

export default Test;
		

Завершение теста

В файле charTransform создадим еще одну функцию, она будет приводить массив с текстом в изначальное состояние. Принимать и возвращать она будет массив типа TextType. Внутри функции будем перебирать массив и устанавливать пустую строку в значение поля class вложенных объектов. Для объекта под индексом 0 class установим ‘current-char’.

			......

type restoreTextType = (
  charsArray: TextType[], 
) => TextType[];

......

export const restoreText: restoreTextType = (charsArray) => {
  return charsArray.map((item, index) => {
    if (index === 0) {
      return {
        ...item,
        class: 'current-char'
      };
    }

    return {
      ...item,
      class: ''
    };
  });
};
		

В компоненте Test создадим две функции, для перезапуска и для начала нового теста. Добавим условный рендеринг, если isTestFinished – true, то будем отображать компонент ModalWindow со статистикой и двумя кнопками, Restart и New Test. Также в первый компонент Stats добавим кнопку перезапуска теста. Так как после нажатия кнопки, фокус остается на ней, нужно его принудительно снять, если этого не сделать, то при нажатии пробела во время печати кнопка будет срабатывать.

			import { FunctionComponent } from 'react';

import '../style/test.css';

import { useAppDispatch, useAppSelector } from '../redux/hooks';
import { resetSeconds } from '../redux/store/timerSlice';
import { setIsTestFinished, resetTestState } from '../redux/store/testSlice';
import { resetTextState, setText } from '../redux/store/textSlice';

import { restoreText } from '../helpers/charTransform';

import Text from './Text';
import Stats from './Stats';
import ModalWindow from './ui/ModalWindow';
import Button from './ui/Button';

const Test:FunctionComponent = () => {
  const dispatch = useAppDispatch();
  const isTestFinished = useAppSelector(state => state.testSlice.isTestFinished);
  const text = useAppSelector(state => state.textSlice.text);

  function restart() {
    dispatch(resetSeconds());
    dispatch(resetTextState());
    dispatch(setText(restoreText(text)));

    if (isTestFinished) {
      dispatch(setIsTestFinished(false));
    }
  }

  function newTest() {
    dispatch(resetTestState()); 
    dispatch(resetTextState());
    dispatch(resetSeconds());
  }

  return (
    <section className='test-container'>
      <Text />
      <Stats>
        <Button 
          btnText='restart' 
          onClick={restart} 
          onFocus={(event) => event.target.blur()} 
        />
      </Stats>
      {
        isTestFinished && 
          <ModalWindow title='Test completed!'>
            <Stats />
            <Button btnText='restart' onClick={restart}/>
            <Button btnText='new test' onClick={newTest}/>
          </ModalWindow>
      }
    </section>
  );
};

export default Test;
		

Выбор количества предложений

Последнее, что осталось сделать – это добавить возможность выбирать количество предложений. Для этого в папке components/ui создадим компонент Select.

Тут нам также, как и в компоненте Button потребуется тип ComponentPropsWithoutRef, чтобы мы могли получить все атрибуты элемента select. В качестве props компонент будет принимать значение по умолчанию и массив значений. Возвращать будет один элемент select.

			import { ComponentPropsWithoutRef } from 'react';

import '../../style/ui/select.css';

interface SelectProps extends ComponentPropsWithoutRef<'select'> {
  defaultValue: string;
  options: {
    value: string,
    name: string
  }[];
}

const Select:React.FC<SelectProps> = ( {defaultValue, options, ...props} ) => {
  return (
    <select 
      className='uppercase-text paragraph select'
      defaultValue={defaultValue}
      {...props}
    >
      {
        options.map(option => {
          return (
            <option 
              key={option.value} 
              value={option.value} 
            >
              {option.name}
            </option>
          );
        })
      }
    </select>
  );
};

export default Select;
		

Теперь перейдем в App и добавим созданный компонент Select в отрисовку ModalWindow. В качестве defaultValue будем передавать состояние sentences, для options создадим небольшой массив объектов с двумя полями value и name, а при событии onChange будем изменять значение sentences.

			import { FunctionComponent } from 'react';

import './style/typography.css';

import { useAppSelector, useAppDispatch } from './redux/hooks';
import { setIsTestStarted, setSentences } from './redux/store/testSlice';

import Header from './components/ui/Header';
import Footer from './components/ui/Footer';
import Test from './components/Test';
import ModalWindow from './components/ui/ModalWindow';
import Button from './components/ui/Button';
import Select from './components/ui/Select';

const App:FunctionComponent = () => {
  const dispatch = useAppDispatch();
  const isTestStarted = useAppSelector(state => state.testSlice.isTestStarted);
  const sentences = useAppSelector(state => state.testSlice.sentences);
  const sentencesOptions = [
    {value: '1', name: '1'},
    {value: '2', name: '2'},
    {value: '3', name: '3'},
    {value: '4', name: '4'},
    {value: '5', name: '5'},
  ];

  const testStateToggler = () => dispatch(setIsTestStarted(true));
  const changeSentences = (value: string) => dispatch(setSentences(value));

  return (
    <>
      <Header />
      <main className='container main'>
        {
          isTestStarted 
            ? <Test /> 
            : <ModalWindow title='Take a typing test'>
                <label className='paragraph' htmlFor='select-senteces'>
                  Choose number of sentences
                </label>
                <Select 
                  id='select-senteces'
                  defaultValue={sentences} 
                  options={sentencesOptions} 
                  onChange={(event) => changeSentences(event.target.value)}
                />
                <Button btnText='start' onClick={testStateToggler} />
              </ModalWindow>
        }
      </main>
      <Footer />
    </>
  );
};

export default App;
		

Приложение работает и полностью готово.

Заключение

Репозиторий проекта и Live. Буду рад вашим комментариям и советам. С радостью отвечу на любые вопросы!

Следите за новыми постами
Следите за новыми постами по любимым темам
1К открытий3К показов