Марафон. Фронтенд. Создание приложения "Викторина" на ReactJs.

Задание викторины.

Создать фронтенд приложение программы “Попутчик” используя библиотеку jQuery.

ссылка на бекенд

Бекенд отрадает все возможные запросы на сервер, форматы запросов и ответов.

Суть приложения.

Пользователю доступно 4 раздела.

  1. Форма заказа.

  2. Поиск заказов.

  3. Личный кабинет.

  4. Регистрация.

Регистрация.

При регистрации пользователь вводит свое имя и выбирает город.

Форма заказа.

Пользователь заполняет следующие поля.

  1. Начальная точка поездки.

  2. Конечная точка поездки.

  3. Дата и время поездки.

Поиск заказов.

В данном разделе пользователь может провести поиск интересующих заказов по выбранному раену города.

Далее пользователь может присоединиться к поездке.

Личный кабинет.

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

По каждому заказу он может отправить сообщение всем, кто к нему присоединен.

Технические требования.

Дизайн взять на основе bootstrap и сориентировать на мобильную версию клиента.

Работа с сокет соединением.

Фронтенд разработка на языке JavaScript. -> Создаем онлайн викторину на ReactJs.

Фронтенд. Приложение “Викторина” на ReactJs.

Клонируем шаблон.

git clone git@github.com:zdimon/marafon-js-quiz-template.git

Создаем приложение.

npx create-react-app my-app --template typescript

Эта команда создаст простейшее приложение и установит в него все зависимости.

Запуск веб сервера приложения.

npm run start

Копируем все из шаблона в папку public c заменой index.html.

start page

Ошибка появляется из за того, что теперь в index.html отсутствует элемент, к которому привязывается React.

Закоментарим запуск приложения по умолчанию.

// ReactDOM.render(
//   <React.StrictMode>
//     <App />
//   </React.StrictMode>,
//   document.getElementById('root')
// );

Запускаем сервер

npm run startу

В React все состоит из программных компонентов, которые привязываются к тегам страницы.

Создаем компонент Question в новой папке components.

Question.tsx

import React from 'react';


export function Question() {
  return (
    <div className="question">
      Test question
    </div>
  );
}

Привяжем компонент к диву в index.tsx.

import {Question} from './components/Question';

ReactDOM.render(
  <React.StrictMode>
    <Question />
  </React.StrictMode>,
  document.getElementById('#currentQuestionBlock')
);

Создадим компонент формы.

components/LoginForm.tsx

import React from 'react';


export function LoginForm() {
  return (
    <div className="chat-start">
      Как вас зовут?
      <input type="text" className="round" />
      <button id="chat-start" className="btn bg-white mt-3">Начать!</button>
    </div>
  );
}

Привяжем к элементу.

...
import {LoginForm} from './components/LoginForm';

...

ReactDOM.render(
    <React.StrictMode>
      <LoginForm />
    </React.StrictMode>,
    document.getElementById('#loginForm')
  );

Создадим условие, где проверим залогиненность.

if(sessionStorage.getItem('username')) { 
    ReactDOM.render(
        <React.StrictMode>
          <Question />
        </React.StrictMode>,
        document.getElementById('#currentQuestionBlock')
      );
} else {
    ReactDOM.render(
        <React.StrictMode>
          <LoginForm />
        </React.StrictMode>,
        document.getElementById('#loginForm')
      );
}

Создадим сервис, генерирующий запросы на сервер.

Установим библиотеку для http запросов.

npm install axios  --save

Создаем класс в Request.ts

import axios from "axios";

const serverUrl = 'http://localhost:7777/v1/quiz/';

export class Request {

    async get(url: string) {
        let response = await axios.get(`${serverUrl}${url}`)
        return response.data;
    }
}

axios в своей работе использует промисы.

Существует специальный синтаксис для работы с промисами, который называется «async/await».

У слова async один простой смысл: эта функция всегда возвращает промис. Значения других типов оборачиваются в завершившийся успешно промис автоматически.

await axios.get() - запустит запрос и не пойдет дальше по коду пока он не завершится.

Используем в компоненте формы LoginForm.

Сделаем AJAX-запрос в событии компонента componentDidMount.

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

Для того, чтобы эти данные определить и менять, есть функция useState.

Эта функция возвращает переменную состояния и функцию для его изменения (т.к. состояние никогда не меняется напрямую через переменную присвоением).

Т.е. когда получим данные с сервера мы должны вызвать setState, чтобы передать их компоненту.

При получении данных с сервера мы должны убедится что на этот момент компонент существует в DOM.

Для того, чтобы подвязатся к моменту монтирования компонента используем хук useEffect

Хук useEffect представляет собой совокупность методов componentDidMount, componentDidUpdate, и componentWillUnmount.

Создание компонента одного стикера.

Так как мы планируем выводить стикеры в цикле удобней под них создать отдельный компонент и передать в него стикер свойством (props).

import React from 'react';
import { useState } from 'react';

export function Sticker(props: any) {
    const [current, setCurrent] = useState(0);
    return (
        <img 
        width="50" 
        alt=""
        src={props.item.get_url}
        />
    )

}

** Sticker(props: any)** - тут мы говорим о том что в компонент зайдут данные.

Отработаем клик на стикере и установим текущий стикер в виде переменной-состояния current.

import React from 'react';
import { useState } from 'react';

export function Sticker(props: any) {
    const [current, setCurrent] = useState(0);
    const select = (id: number) => {
        setCurrent(id);
    }
    return (
        <img 
        width="50" 
        className={` ${props.item.id===current ? 'sticker-active': ''} `}
        alt=""
        onClick={() => select(props.item.id)}
        src={props.item.get_url}
        />
    )

}

LoginForm запрос стикеров с сервера.

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

import { Request } from '../Request';
import {Sticker} from './Sticker';


export function LoginForm() {
  const [stickers, setStickers] = useState([]);
  useEffect(() => {
    let req = new Request();
    req.get('sticker/list').then((data) => {
       setStickers(data);
    })
   },[]);
  return (
    <div className="login-form">
      <p>Как вас зовут?</p>
      <p><input type="text" className="round" /></p>
      <div className="stickers" >
          { stickers.map((el,index) =>  <Sticker key={index} item={el} />) }
      </div>
      <button id="chat-start" className="btn bg-white mt-3">Начать!</button>
    </div>
  );
}

При использовании useEffect нужно передать пустой массив вторым параметром для избежания рекурсии т.к. иначе это будет срабатывать при каждых изменениях стейта.

useEffect(() => {},[]);

Добавим запрос на сервер для получения текущего вопроса в компоненте Question.tsx

import React from 'react';
import { Request } from '../Request';
import { useState, useEffect } from 'react';

export function Question() {
  const req = new Request();
  const [question, setQuestion] = useState({
    question: '',
    answers: ''
  });
  useEffect(() => {
    req.get('get_current_question').then((data) => {
      setQuestion(data);
    })
  },[]);
  return (
    <div className="question">
      {question.question} ({question.answers})
    </div>
  );
}

TODO: Формат сообщения тут неплхо оформить в виде интерфейса.

useState({
        question: '',
        answers: ''
      });

start page

Далее нам нужно отработать клик по стикеру и передать его из компонента Sticker в компонент LoginForm.

Для этого определим обработчик клика в Sticker.ts

import React from 'react';


export function Sticker(props:any) {

  var select = (id: number) => {
    props.onSelectSticker(id); 
  }
  return (
    <img width="50" 
    onClick={() => select(props.item.id)} 
    src={props.item.get_url} />
  );
}

И прокинем обработчик через props из родительского компонента в Sticker.

...
export function LoginForm() {
  ...
  const [sticker, setSticker] = useState(0);
  ...
   var handleSelectSticker = (id: number) => {
      setSticker(id);
   }
  return (
    ...
      <div className="stickers">        
      { 
       stickers.map((el, key) => 
      <Sticker 
         onSelectSticker={handleSelectSticker} 
         item={el} 
         key={key}
      />)
     }
     ...

Добавим state для имени пользователя и привяжем его к изменению input-а при помощи обработчика handleChangeUsername.

export function LoginForm() {
  ...
  const [username, setUsername] = useState('');

  var handleChangeUsername = (evt:any) => {
    setUsername(evt.target.value);
  }

   ...

  <input onChange={handleChangeUsername} type="text" className="round" />

Отправляем сообщение на сервер.

Создадим компонент формы отправки MessageForm.ts.

import React, {useState} from 'react';
import { Request } from '../Request';

export function MessageForm() {
  const req = new Request();
  const [message, setMessage] = useState('');
  var handleChangeMessage = (evt:any) => {
    setMessage(evt.target.value);
  }

  var submit = () => {
      let data = {
          message: message,
          playername: localStorage.getItem('username')
      }
      req.post('save_message', data).then((data) => {
        setMessage('');
     })
  }
  return (
    <>
    <input 
        type="text" 
        className="form-control mr-3" 
        placeholder="Введите ответ на вопрос"
        defaultValue={message}
        onChange={handleChangeMessage}
    />
    <button onClick={submit} type="submit" className="btn btn-primary d-flex align-items-center p-2"><i className="fa fa-paper-plane-o" aria-hidden="true"></i><span className="d-none d-lg-block ml-1">Send</span></button>
    
  );
}

Сокет соединение.

Нам теперь необходимо установить сокет-соединение.

Для этого создадим класс соединения SocketConnection.ts и определим обработчики основных событий в его конструкторе.

export class SocketConnection {
   timer:any = null;
   websocket:any = null;
   constructor() {

    this.wsConnect();
   }

   wsConnect() {
    clearInterval(this.timer);
    this.websocket = new WebSocket('ws://quizapi.webmonstr.com:7777/quiz/');

    this.websocket.onerror = (evt: any) => {
        this.timer = setTimeout(() => this.wsConnect(),2000);
    }

    this.websocket.onmessage = (message: any) => {
        var message = JSON.parse(message.data)
        console.log(message);
    }

    this.websocket.onclose =  (event: any) => {
        console.log('Close connection');
        this.timer = setTimeout(() => this.wsConnect(),2000);
    };
   }
}

События.

Нам необходимо отслеживать приход сообщений по сокетам в разных компонентах.

Для этого удобней всего генерировать события и подписываться на них в компонентах.

В этом класе мы будем использовать библиотеку RxJS и ее изструмен Subject.

ссылка на документацию

Установим

npm install rxjs @types/rx --save

Применим и запустим событие при приходе сообщения.

...
import { Subject } from 'rxjs';
...
export class SocketConnection {
   ...
   newMessage$ = new Subject();
   ...

    this.websocket.onmessage = (message: any) => {
        var message = JSON.parse(message.data)
        this.newMessage$.next(message);
    }

Однкако теперь нужно решить проблебу множественных экземпляров этого класса.

Применим паттерн singletone.

export class SocketConnection {
   private static instance: SocketConnection;
   ...
   public static getInstance(): SocketConnection {
        if (!SocketConnection.instance) {
            SocketConnection.instance = new SocketConnection();
        }
        return SocketConnection.instance;
    }

Теперь будем создавать этот объект через метод getInstance и он будет всегда в единственном экземпляре.

import { SocketConnection } from '../SocketConnection';

const socket = SocketConnection.getInstance();

Подпишемся на это событие в компоненте Question.

import { SocketConnection } from '../SocketConnection';


export function Question() {
  const req = new Request();
  const socket = SocketConnection.getInstance();
  socket.newMessage$.subscribe((data: any) => {
    console.log(data);
  });

Однако при этом мы наблюдаем 4 реакции на одно событие.

start page

Проблема в том, что функция реакт компонента запускается по многу раз.

Чтобы что то выстрелило один раз, нужно завернуть это в хук useEffect.

Сделаем это и запросим новый вопрос с сервера.

...
export function Question(props: any) {
  useEffect(() => {
    socket.sendMessage$.subscribe((data: any) => {
      if(data.is_right) {
          let req = new Request();
          req.get('get_current_question').then((data) => {
            setQuestion(data);
            console.log(data);
            });
          }
      });
  },[])
  ...

Создадим компонент сообщения Message.tsx.

import React from 'react';

export function Message(props: any) {

  return (

    <div className={`chat ${props.message.is_right ? "chat-left" : ""}`}>
    <div className="chat-user">
       <a className="avatar m-0">
          <img src="images/user/1.jpg" alt="avatar" className="avatar-35 " />
       </a>
  <span className="chat-time mt-1">{props.message.playername}</span>
    </div>
    <div className="chat-detail">
       <div className="chat-message">
          <p>{ props.message.text } {props.message.is_right}</p>
       </div>
    </div>
  </div>
  );
}

Создадим компонент списка сообщений MessageBox.tsx.

Отработаем сообщение прихода нового ответа.

import React from 'react';
import { Request } from '../Request';
import { useState, useEffect } from 'react';
import { SocketConnection } from '../SocketConnection';
import { Message } from './Message';


const socket = SocketConnection.getInstance();

export function MessageBox(props: any) {
  useEffect(() => {
    socket.newMessage$.subscribe((payload: any) => {
        // const msg = [...messages] as any;
        // msg.push(payload);
        // setMessages(msg);
        const req = new Request();
        req.get('message/list').then((data) => {
            setMessages(data);
        })
      });
  },[])

  const [messages, setMessages] = useState([]);
  useEffect(() => {
    const req = new Request();
    req.get('message/list').then((data) => {
        setMessages(data);
        console.log(data);
    })
  },[]);
  return (
    <div className="messages">
      { messages.map((el, key) => <Message key={key} message={el} />) }
    </div>
  );
}

Компонент текущего пользователя CurrentPlayer.tsx.

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


    export function CurrentPlayer(props: any) {
      const [image, setImage] = useState('');
      useEffect(() => {
        setImage(localStorage.getItem('image') as string);
      },[]);
      return (
        <>
        <div className="avatar chat-user-profile m-0 mr-3">
            <img 
            src={image}
            alt="avatar" 
            className="avatar-50 " />
        </div>
      <h5 className="mb-0">{localStorage.getItem('username')}</h5>
      &nbsp;<h5 className="player-account"> Правильных ответов: {localStorage.getItem('account')}</h5>
        
      );
}

Компонент списка участников PlayerList.tsx.

Формулировка задачи.

Взять готовый шаблон фронтенд приложения.

start page

ссылка на репозиторий с шаблоном

Комманда для клонирования.

git clone git@github.com:zdimon/marafon-js-quiz-template.git

Используя REST API бекенд, расположенный по адресу http://quizapi.webmonstr.com/

Админка http://quiz.webmonstr.com/admin

Логин - admin

Пароль - admin

Создать приложение, используя библиотку jQuery в виде плагина.

Подключение плагина на странице.

  <script src="app.js"></script>
  <script>
      var app = $('#rootBlock').quizPlugin();
      app.start();
  </script>

Суть работы приложения:

В начале проверяется переменная username в localStorage браузера.

Если этой переменной нет, то пользователю выводится форма авторизации со списком стикеров и полем, где он указываем свое имя.

Стикеры запрашиваются по адресу http://quiz.webmonstr.com/v1/quiz/sticker/list

После ввода имени и выбора стикера послать на адрес http://quiz.webmonstr.com/v1/quiz/player/join POST запрос с данными json в формате:

{
  "name": "string",
  "sticker_id": 0
}

Проанализировать ответ от сервера.

В случае успешного ответа (отсутсвие в нем поля error):

  1. Cохранить имя пользователя в localStorage.

  2. Убрать форму авторизации.

  3. Вывести чат-комнату.

В случае ошибки вывести ее на странице над кнопкой формы красным цветом.

Вывод чат-комнаты.

  1. Запросить текущих игроков и вывести в блоке #playerListBlock согласно дизайну.

Выводим стикер и имя пользователя.

Вместо слова Admin выводит текущий счет пользователя.

Запросить список игроков можно по адресу:

http://quiz.webmonstr.com/v1/quiz/player/list
  1. Запросить текущий вопрос викторины и вывести в блоке #questionBlock

Адрес запроса:

http://quiz.webmonstr.com/v1/quiz/get_current_question

Формат ответа.

{
  "lang": "ru",
  "level": 3,
  "tp": "questionend",
  "mode": "fullmatch",
  "theme": 4,
  "question": "Какое слово образовано от начальных слов песни «Прелестная Катарина»?",
  "answers": "ШАРМАНКА",
  "is_published": true,
  "order": 1
}
  1. Запросить список ответов и вывести в блоке chatMessageBlock согласно дизайну.

Адрес запроса.

Формат ответа.

Ответы, помеченные как неправильные, выводить на розовом фоне, правильные - на салатовом.

  1. При вводе ответа и нажатии на кнопку Send или клавиши Enter отправлять POST сообщение на сервер.

Ответ отправлять только в случае не пустого сообщения.

Адрес.

http://localhost:7777/v1/quiz/save_message

Формат отправляемых данных:

{
  "message": "string",
  "playername": "string"
}

Ответ игнорировать.

  1. Создать веб-сокет соединение с сервером.

Адрес веб-сокет соединения.

ws://localhost:7777/quiz/

Обеспечить реакцию приложения на следующие сообщения.

Сообщение о новом сообщении.

Формат сообщения:

{ 
    'type': 'quiz_message', 
    'message': {...}
}

При приходе сообщения:

  1. Вывести его в списке сообщений согласно дизайну и логике описанной выше.

  2. В случае если сообщение помечено как правильное,

    2.1 Обновить список игроков (либо использовать сообщение об обновлении аккаунта см. ниже) и проиграть произвольный звук.

    2.2 Запросить следующий вопрос и обновить блок #questionBlock

Сообщение о удалении сообщения.

Формат.

{ 
    'type': 'quiz_delete_message', 
    'message': {...}
}

При поступлении сообщения удалять его из списка по ID в сообщении.

Сообщение об обновлении аккаунта.

{ 
    'type': 'quiz_update_account', 
    'message': {...}
}

При поступлении сообщения обновить аккаунт пользователя по его ID.