Марафон. Фронтенд. Створення програми "Вікторина" на ReactJs.

Фронтенд розробка JavaScript. -> Робота із веб-сокетами.

Робота із сокет з’єднанням.

Фронтенд розробка JavaScript. -> Завдання вікторини.

Завдання вікторини.

Створити фронтенд програму “Попутник” використовуючи бібліотеку jQuery.

[посилання на бекенд(http://taxiapi.webmonstr.com)

Бекенд відрадить всі можливі запити на сервер, формати запитів та відповідей.

Суть програми.

Користувачеві доступно 4 розділи.

  1. Форма замовлення.

  2. Пошук замовлень.

  3. Особистий кабінет.

  4. Реєстрація.

Реєстрація.

При реєстрації користувач вводить своє ім’я та обирає місто.

Форма замовлення.

Користувач заповнює такі поля.

  1. Початкова точка подорожі.

  2. Кінцева точка подорожі.

  3. Дата та час поїздки.

Пошук замовлень.

У даному розділі користувач може провести пошук замовлень, що цікавлять, за обраним районом міста.

Далі користувач може приєднатися до подорожі.

Особистий кабінет.

У цьому розділі користувач бачить свої замовлення або замовлення, до яких він приєднаний.

За кожним замовленням він може надіслати повідомлення всім, хто до нього приєднано.

Технічні вимоги.

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

Фронтенд розробка JavaScript. -> Формулювання задачі.

Формулювання задачі.

Взяти готовий шаблон передній додаток.

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. Зберегти ім’я користувача 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.

Фронтенд розробка 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.

У компоненті можуть бути стани - це дані, які змінюються в міру подій додатка.

Для того, щоб ці дані визначити і міняти, є функція використаннядержави.

Ця функція повертає змінну стану та функцію для його зміни (бо стан ніколи не змінюється безпосередньо через змінну присвоєнням).

Тобто. Коли отримаємо дані з сервера, ми повинні викликати 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.

[посилання на документацію] (https://www.learnrxjs.io/learn-rxjs/subjects/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

Проблема в тому, що функція реакт компонента запускається багато разів.

Щоб щось вистрілило один раз, потрібно загорнути це в хук, використовуючиефект.

Зробимо це та запитаємо нове питання із сервера.

...
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.