Разработка клиентской части блога на DVA.Часть 2

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

Для начала давайте немного изменим функцию получения данных с сервера. На данный момент она использует библиотеку isomorphic-fetch. А мы ее заменим на axios. Также подключим библиотеку для работы с куками, так как нам это пригодится при реализации авторизации в приложении.

Подключение библиотеки для работы с куками:

npm i js-cookie --save

Код для функции request получился такой:

import axios from 'axios';
import lodash from 'lodash';
import Cookies from 'js-cookie';

export default function axioRequest(options) {

  const token = Cookies.get('authorized');

  if (token) {
    options = lodash.extend(options, {
      headers: {
        'authorized': token
      },
      params: lodash.extend({}, options.params || {}, {
        access_token: token
      })
    });
  }

  return axios.request(options).then((response) => {
    const {statusText, status} = response;
    let data = response.data;
    if (data instanceof Array) {
      data = {
        list: data,  
      }
    }

    return Promise.resolve({
      success : true,
      message : statusText,
      statusCode: status,
      ...data
    });
  }).catch((error) => {
    const {response} = error;
    let msg;
    let statusCode;
    if (response && response instanceof Object) {
      const {data, statusText} = response;
      statusCode = response.status;
      msg = data.message || statusText
    } else {
      statusCode = 600;
      msg = error.message || 'Network Error';
    }
    return Promise.reject({success: false, statusCode, message: msg})
  })
}

В нем есть две небольшие особенности. Первая - это добавление параметра для авторизации по JWT в заголовок и query параметр. Вторая - если нам придет ответ в виде массива, то он будет автоматически преобразован в объект со свойством list

Далее необходимо немного поправить методы моделей blogs и articles, приведя функции получения данных с сервера в соответствии с нашим методом request.

blogs.js

import request from '../utils/request';

export async function query(filter) {
  const fOptions = filter || {};
  return request({
    url : '/api/blogs',
    method: 'get',
    params: {
      filter: JSON.stringify(fOptions)
    }
  });
}

export async function findById(id) {
  return request({
    url : `/api/blogs/${id}`,
    method: 'get'
  });
}

export async function blogArticles(id, filter) {
  const fOptions = filter || {};
  return request({
    url : `/api/blogs/${id}/articles`,
    method: 'get',
    params: {
      filter: JSON.stringify(fOptions)
    }
  });
}

articles.js

import request from '../utils/request';

export async function findOne(id) {
  return request({
    url : `/api/articles/${id}`,
    method: 'get'
  });
}

Главная страница

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

В список блогов на главной странице мы будет выводить только 5 последних блогов, а в список статей мы будем выводить только 5 лучших статей выделенного блога.

Для реализации данной страницы нам необходимо:

  • Создать компоненты для вывода блогов статей на главной странице в виде списка;
  • Создать модель состояния для подключения к главной странице;
  • Добавить параметры к сервисам запроса блогов и статей, чтобы они учитывали порядок сортировки и лимит вывода;
  • Установить компоненты на главную страницу и подключить их через модель.

Начнем с создания компонентов для вывода списка блогов и статей на главной странице приложения. Назовем их соответственно MainBlogsList и MainArticlesList. Для отображения обоих списков воспользуемся компонентом ant design - List.

Замечание. В принципе, данные компоненты похожи на компонент списка статей. Можно было бы выделить какой-то общий подход для отображения таких списков и вынести часть кода в общий класс (функцию) и с помощью библиотеки compose сделать компоненты высшего порядка с передачей необходимых параметров в тех или иных случаях, но не будем сейчас заострять на этом внимание.

Хотя MainBlogsList и MainArticlesList - похожие по функционалу компоненты, но первый будет немного сложнее. В компонент MainBlogsList мы отправим три свойства:

  • этот список блогов;
  • идентификатор текущего блога;
  • функцию, которая будет вызываться при клике на запись блога.

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

import React, {Component} from 'react';
import PropTypes from 'prop-types';
import {List, Icon} from 'antd';
import {Link} from 'react-router-dom';

class MainBlogsList extends Component {

  render() {
    let me = this;

    const {items} = me.props;

    const view = items.length
      ? <List className="main-list"
        itemLayout="horizontal"
        dataSource={items}
        renderItem={item => (
        <div onClick={() => me.onSelectItem(item)}>
          <List.Item extra={me.getExtraContent(item)}>
            <List.Item.Meta
              title={<Link to={`/blog/${item.id}`}>{item.title}
              </Link>}
              description={item.description}
            />
          </List.Item>
        </div>
      )}
    />
    : <h4>На данный момент не заведено ни одного блога!</h4>;

    return (
      <div>
        {view}
      </div>
    );
  }

  onSelectItem = (item) => {
    let me = this;
    const {selectBlog} = me.props;
    selectBlog(item);
  };

  getExtraContent = (item) => {
    let me = this;
    const {blogId} = me.props;
    return item.id === blogId ? <Icon type="check" />: '';
  };
}

MainBlogsList.propTypes = {
  items: PropTypes.array,
  blogId: PropTypes.number,
  selectBlog: PropTypes.func
};

export default MainBlogsList;

Код компонента MainArticlesList достаточно простой:

import React from 'react';
import PropTypes from 'prop-types';
import {List} from 'antd';
import {Link} from 'react-router-dom';

function MainArticlesList({items, blogId}) {
  const message = (blogId === 0)
  ? 'Выберите блог для отображения статей!'
  : 'На данный момент в текущем блоге нет ни одной статьи!';

  const view = items.length
  ? <List
    itemLayout="horizontal"
    dataSource={items}
    renderItem={item => (
      <List.Item>
        <List.Item.Meta
          title={<Link to={`/article/${item.id}`}>{item.title}</Link>}
          description={item.except}
        />
      </List.Item>
    )}
  />
  : <h4>{message}</h4>;

  return (
    <div>
     {view}
    </div>
  );
}

MainArticlesList.propTypes = {
  items: PropTypes.array,
  blogId: PropTypes.number
};

export default MainArticlesList;

Теперь перейдем к созданию модели состояния главной страницы. Назовем ее dashboard. Конечно, нам понадобится список блогов (blogs) и список статей (articles), а также идентификатор текущего выбранного блога. В редьюсерах у нас будут следующие функции - updateState для обновления списка блогов или статей после загрузки, selectBlog для выбора текущего активного блога. Для асинхронных вызовов мы определим функции-генераторы fetch и fetchArticles

import pathToRegexp from 'path-to-regexp'
import {query, blogArticles} from '../services/blogs';

export default {

namespace: 'dashboard',

  state: {
    blogId: 0,
    blogs:[],
    articles: []
  },

  subscriptions: {
    setup ({dispatch, history}) {
      history.listen(({pathname}) => {
        const match = pathToRegexp('/').exec(pathname);
        if (match) {
          dispatch({type: 'fetch'})
        }
      })
    },
  },

  effects: {
    *fetch({payload}, {call, put}) {
      const filter = {
        order: 'id DESC',
        limit: 5
      };
      const {list} = yield call(query, filter);
      yield put({type: 'updateState', payload: {blogs: list}});
    },

    *fetchArticles({payload}, {call, put}) {
      const filter = {
        order: 'votes DESC',
        limit: 5
      };
      const {list} = yield call(blogArticles, payload.id, filter);
      yield put({type: 'updateState', payload: {articles: list}});
    },

  },

  reducers: {
    updateState(state, action) {
      return {...state, ...action.payload};
    },

    selectBlog(state, action) {
      const {id: blogId} = action.payload;
      return {...state, blogId};
    },
  },
};

После реализации этих компонентов нам необходимо соединить все это вместе с помощью функции connect в компоненте IndexPage для главной страницы:

import React from 'react';
import {connect} from 'dva';
import {Link} from 'react-router-dom';
import {Row, Col} from 'antd';
import PropTypes from 'prop-types';

import MainBlogsList from '../components/MainBlogsList';
import MainArticlesList from '../components/MainArticlesList';

function IndexPage({dashboard, dispatch}) {

  const {blogs, articles, blogId} = dashboard;

  const onSelectBlog = function (item) {
    dispatch({type: 'dashboard/selectBlog', payload: item});
    dispatch({type: 'dashboard/fetchArticles', payload: {id: item.id}});
  };

  return (<div>
    <h1>Главная страница</h1>
    <Row gutter={8}>
      <Col span="12">
        <h2>Последние блоги</h2>
        <MainBlogsList items={blogs} blogId={blogId} selectBlog={onSelectBlog}/>
      </Col>

      <Col span="12">
        <h2>Лучшие статьи</h2>
        <MainArticlesList items={articles} blogId={blogId} />
      </Col>
    </Row>
  </div>
  );
}

IndexPage.propTypes = {
  blogs : PropTypes.array,
  articles: PropTypes.array,
};

export default connect(({dashboard}) => ({dashboard}))(IndexPage);

Для реализации фильтрации данных на сервере нам необходимо знать в каком виде необходимо отправить фильтр, чтобы получить необходимый список блогов или статей. В документации по Loopback говорится, что фильтр передается get параметром и представляет собой сериализованный json объект с различными параметрами для фильтрации, сортировки и постраничного вывода. Т.е. нам необходимы вот такие параметры для нашего случая:

# Для блогов
const filter = {
     order: "id DESC",
     limit: 5
}


# Для статей блога
const filter = {
     order: "votes DESC",
     limit: 5
}

На данном этапе все готово, чтобы поправить логику применения фильтров к mock данным. А затем приступим к реализации вызова api с Loopback.

В итоге у нас получилась вот такая заготовка страницы:

Итог

На данном этапе наше приложение:

  • Умеет отображать на главной странице список 5 последних блогов и 5 лучших статей из выбранного блога;
  • Имеет страницу со списком блогов, где, в виде карточек, представлены все блоки;
  • Имеет страницу отдельного  блога, где выводит все статьи данного блога;
  • Имеет страницу отдельной статьи.

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

Скачать исходный код можно по ссылке:

git clone https://github.com/dionic-i/lb-blog-client.git

После загрузки переходим в папку проекта:

npm i
git checkout part2-1
npm run dev

В следующий раз мы подключим наш блог к API, разработанному на Loopback и разработаем авторизацию на JWT. Следите за обновлениями. Всем добра!

  dva

  Смотреть все посты