5.5 Список приёмов


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



5.5.1 Знакомство с react-bootstrap-table-next


Прежде чем мы приступим к созданию списка приёмов, нам необходим какой-то базовый компонент, отображающий этот самый список. Его можно написать самому, а можно использовать какой-нибудь популярный плагин.


Совет!

Когда вы ищете какой-нибудь плагин React для реализации вашей задачи, всегда заходите в Git и проверяйте две важные вещи: частота обновлений и размер сообщества. Два этих фактора гарантируют, что ваш плагин будет поддерживаться, развиваться и избавляться от багов. Хорошо когда обновления происходят раз в неделю или месяц, а не год. Иначе, если вы наткнётесь на какой-то баг, исправят его не скоро, а вам придется отказаться от использования такого плагина после значительных потерь времени. Не стоит добавлять в проект непопулярные, плохо поддерживаемые плагины - лучше написать самому!

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

Сейчас перед нами стоит задача отобразить список приёмов у всех врачей с возможностью фильтрации:



Для решения этой задачи предлагаю вашему вниманию плагин react-bootstrap-table-next. Это очень мощная библиотека, которая предоставляет таблицу с богатым ассортиментом возможностей. Чтобы познакомиться с ней достаточно посмотреть раздел демо. Здесь собраны все типовые случаи использования: пагинация, сортировка, стили строк и столбцов, события и многое другое.


Совет!

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

Вы могли заметить, что в названии плагина использовано слово bootstrap. Я не случайно порекомендовал именно этот плагин. Он является переделкой своего предшественника react-bootstrap-table, который в свою очередь является умной React таблицей для bootstrap. В нашей ситуации это очень кстати. Тем не менее вы можете использовать и любой другой плагин для отображения таблицы.



5.5.2 Базовый компонент таблицы


Чтобы отобразить список приёмов нам понадобится базовый компонент таблицы. Он, конечно, не является обязательным. Однако лучше всего иметь такой компонент-обёртку с заданным API.


Совет!

Все подключаемые плагины лучше оборачивать в собственные компоненты-обёртки со своим API. Такая стратегия даёт массу преимуществ. Во-первых, при необходимости плагин можно поменять на другой. Во-вторых функционал плагина можно расширить. В-третьих такие компоненты можно использовать повторно в других проектах. Компоненты-обёртки, а также компоненты, написанные с нуля и составляют вашу собственную библиотеку. Все они находятся в папке /components.

Давайте добавим модуль react-bootstrap-table-next в проект:



Затем создадим наш первый базовый компонент-обёртку вокруг плагина:


Код
    
  import React, { Component } from 'react'

  import cn from 'classname'
  import PropTypes from 'prop-types'
  import BootstrapTable from 'react-bootstrap-table-next'
  
  import './Table.scss'
  
  const NO_DATA_TEXT = 'Данных нет'
  
  export default class Table extends Component {
  
   static propTypes = {
     columns: PropTypes.arrayOf(PropTypes.object), // дескрипторы столбцов таблицы
     data: PropTypes.arrayOf(PropTypes.object), // данные таблицы
     keyField: PropTypes.string, // имя уникального столбца
     noDataText: PropTypes.string,

     hasHover: PropTypes.bool,
     hasOptions: PropTypes.bool,
     hasBorders: PropTypes.bool,

     isStriped: PropTypes.bool,

     expandRow: PropTypes.object,

     className: PropTypes.string,
     containerClass: PropTypes.string,

     getRowStyle: PropTypes.func
   }

   static defaultProps = {
     data: [],
     columns: [],
     keyField: 'id',
     noDataText: NO_DATA_TEXT,

     isRemote: true,
     isStriped: true,
     isLoading: false,

     hasHover: false,
     hasHeader: false,
     hasBorders: false,

     getRowStyle: function() { return null }
   }

   getRowStyle = (row, rowIndex) => {
     return this.props.getRowStyle(row, rowIndex)
   }

   render() {
     const {
       data,
       columns,
       keyField,
       expandRow,
       className,
       containerClass,
       isStriped,
       hasBorders,
       hasHover,
       noDataText,
     } = this.props

     return (
       <div className={cn('TableContainer', containerClass)}>
         <BootstrapTable
                 expandRow={expandRow}
                 data={data}
                 columns={columns}
                 keyField={keyField}
                 classes={cn('Table', className)}
                 headerClasses={'Table-Header'}
                 striped={isStriped}
                 hover={hasHover}
                 bordered={hasBorders}
                 rowStyle={this.getRowStyle}
                 noDataIndication={noDataText}
         />
       </div>
     )
   }
  }
  

В компоненте сначала идут статические члены: propTypes и defaultProps. В них мы определяем типы свойств и их значения по умолчанию соответственно. Далее отрисовываем сам библиотечный компонент <BootstrapTable>, передавая ему необходимые свойства. Их может быть больше - тут представлен лишь небольшой перечень.



5.5.3 Фиктивные данные


Такие компоненты, как список всегда показывают какие-либо данные. Очень часто при разработке приложения серверная часть по каким-то причинам может быть не готова. Не смотря на это разработка должна продолжаться, и заказчик должен видеть результат. Необходима демо-версия приложения с фиктивными (фэйковыми, стабовыми) данными, которые в процессе разработки сервера будут заменяться реальными. Более того, можно реализовать возможность переключаться между фиктивными и реальными данными.

Давайте создадим файл с фиктивными данными и назовём его MockData.js в папке /lib. В нём мы создадим данные для нашего списка приёмов:


Код
    
  export const appointments = [
     {
       date: 1560422694514,
       clientName: 'Должанский Николай Сергеевич',
       status: 'Завершён',
       holderName: 'Иванов Иван Иванович',
       compliences: 'Боль в правом ухе',
       diagnosis: 'Застужено правое ухо'
     },
     {
       date: 1560422694514,
       clientName: 'Пертов Пётр Генадьевич',
       status: 'Завершён',
       holderName: 'Иванов Иван Иванович',
       compliences: 'Боль в горле',
       diagnosis: 'Ангина'
     }
  ]
  

Даты в данных лучше всегда указывать числом. Дело в том, что дату в виде числа можно отформатировать любым требуемым образом. Даты в виде строк далеко не такие гибкие.



5.5.4 Компонент списка приёмов


Поскольку базовый компонент таблицы и фиктивные данные готовы, самое время создать компонент списка приёмов:


Код
    
  import React, { Component } from 'react'
  
  import { map } from 'underscore'
  import Moment from 'react-moment'
  
  import './Appointments.scss'
  
  import Table from '../Table/Table'
  import Header from '../Header/Header'
  
  import { ReactComponent as Appointment } from '../../images/appointment.svg'
  
  import { appointments as data } from '../../lib/MockData'
  
  const TITLE = 'Приёмы'
  
  export default class Appointments extends Component {
  
   render() {
     return (
       <div className='Appointments'>
         <Header
                 title={TITLE}
                 userName='Иванов Иван Иванович'
                 className='Appointments-Header'
                 renderIcon={() => (
             <Appointment className='Header-Icon' />
           )}
         />
         <div className='Appointments-Body'>
           <Table
                   data={data}
                   columns={[
                   {
                   dataField: 'date',
                 text: 'Дата',
                 headerStyle: {
                   width: '200px'
                 },
                 formatter: (v, row) => {
                   return (
                     <Moment date={v} format='DD.MM.YYYY HH.mm'/>
                   )
                 }
               },
               {
                 dataField: 'clientName',
                 text: 'Клиент'
               },
               {
                 dataField: 'status',
                 text: 'Статус'
               },
               {
                 dataField: 'holderName',
                 text: 'Принимающий'
               },
               {
                 dataField: 'compliences',
                 text: 'Жалобы'
               },
               {
                 dataField: 'diagnosis',
                 text: 'Диагноз'
               }     
             ]}
           />
         </div>
       </div>
     )
   }
  }
  

Мы отрисовали компонент хидера и базовый компонент таблицы, передав в неё фиктивные данные.

Обратите внимание на свойство formatter в списке дексрипторов столбцов columns таблицы. С помощью этого свойства него мы можем форматировать вывод: результатом отрисовки ячейки таблицы будет то, что возвращает функция formatter. Поскольку мы имеем дело с датами в виде long, то прежде чем их отобразить, нам необходимо придать им читабельный вид. Для этого я использовал популярную библиотеку moment.js, а точнее её react-версию react-moment:


Код
    
  "moment": "2.24.0",
  "react-moment": "0.9.2"
  

Вот рабочий пример:




Как вы могли заметить, не хватает формы для фильтрации приёмов. Именно ей мы займёмся в следующем разделе!



5.5.5 Форма фильтра приёмов



5.5.5.1 Библиотека reactstrap


Прежде чем приступить к реализации формы фильтра, хочу познакомить вас с библиотекой reactstrap. Здесь собраны основные компоненты bootstrap для React. Прочитайте документацию и узнайте, что вы можете использовать в своих приложениях. Уверен, что польза будет ощутимая.


5.5.5.2 Базовые компоненты элементов формы


Форма фильтра имеет следующий вид:



Как видно из рисунка она состоит из четырёх элементов: двух полей выбора даты, текстового поля и чекбокса. Самое время создать соответствующие базовые компоненты.

Текстовое поле:


Код
    
  import React, { Component } from 'react'

  import cn from 'classname'
  import PropTypes from 'prop-types'
  import {Label, Input, FormGroup} from 'reactstrap'

  import './TextField.scss'

  export default class TextField extends Component {

      static propTypes = {
          type: PropTypes.oneOf(['text','textarea', 'email', 'password', 'date']),
          name: PropTypes.string,
          label: PropTypes.string,
          value: PropTypes.string,
          className: PropTypes.string,
          placeholder: PropTypes.string,
          onChange: PropTypes.func
      }

      static defaultProps = {
          type: 'text',
          value: '',
          onChange: function () {}
      }

      onChange = e => {
          const value = e.target.value
          const { name, onChange: cb } = this.props
          cb(name, value)
      }

      render () {
          const {
              type,
              name,
              label,
              value,
              className,
              placeholder
          } = this.props

          return (
              <FormGroup className={cn('TextField', className)}>
                  {label ? (
                      <Label className='TextField-Label'>
                        {label}
                      </Label>
                  ) : null}
                  <Input
                          type={type}
                          name={name}
                          value={value}
                          placeholder={placeholder}
                          className='TextField-Input'
                          onChange={this.onChange}
                  />
              </FormGroup>
          )
      }
  }
  

Стоит отметить, что здесь использованы такие компоненты библиотеки reactstrap: <FormGroup>, <Label> и <Input >. Компонент <FormGroup > использован в соответствии с правилами построения форм в reactstrap. В остальном же всё просто - мы делаем компонент-обёртку с нашими свойствами и дополнительными возможностями. Кстати это известный паттерн проектирования под названием декоратор.

Наш компонент <TextField> является контролируемым: он не содержит состояния, а текущее значение value устанавливается родителем.

Следующий наш компонент <CheckboxField>:


Код
    
  import React, {Component} from 'react'
  
  import cn from 'classname'
  import PropTypes from 'prop-types'
  import {Label, Input, FormGroup} from 'reactstrap'
  
  import './CheckboxField.scss'
  
  class CheckboxField extends Component {
  
      static propTypes = {
          name: PropTypes.string,
          label: PropTypes.string,
          value: PropTypes.bool,
          className: PropTypes.string,
          onChange: PropTypes.func
      }
  
      static defaultProps = {
          value: false,
          onChange: function () {}
      }
  
      onChange = e => {
          const value = e.target.checked
          const { name, onChange: cb } = this.props
          cb(name, value)
      }
  
      render() {
          const {
              label,
              value,
              className
          } = this.props
  
          return (
              <FormGroup check className={cn('CheckboxField', className)}>
                 <Label
                         check
                         onClick={this.onClick}
                         className='CheckboxField-Label'>
                    <Input
                            type='checkbox'
                            value={value}
                            onClick={this.onChange}
                            className='CheckboxField-Checkbox'
                    />
                    {label}
                </Label>
              </FormGroup>
          )
      }
  }
  
  export default CheckboxField;
  

Как видно, он очень похож на компонент <TextField>.

Ну и наконец компонент <DateField>:


Код
    
  import React, {Component} from 'react'
  
  import cn from 'classname'
  import PropTypes from 'prop-types'
  import DatePicker from 'react-datepicker'
  import {FormGroup, Label} from 'reactstrap'
  
  import './DateField.scss'
  
  export default class DateField extends Component {
  
      static propTypes = {
          name: PropTypes.string,
          label: PropTypes.string,
          hasTime: PropTypes.bool,
          placeholder: PropTypes.string,
          dateFormat: PropTypes.string,
          timeFormat: PropTypes.string,
          timeInterval: PropTypes.number,        
          className: PropTypes.string,        
          onChange: PropTypes.func
      }
  
      static defaultProps = {
          hasTime: false,
          dateFormat: 'dd/MM/yyyy',
          // формат времени, отображающийся в выпадающем списке
          timeFormat: 'HH:mm',
          // шаг выбора времени 
          timeInterval: 30,
          onChange: function () {}
      }
  
      onChange = (value) => {
          const { name, onChange: cb } = this.props
          cb(name, value)
      }
  
      render () {
          const {
              name,
              label,
              value,
  
              dateFormat,
  
              hasTime,            
              timeFormat,
              timeInterval,
  
              onChange,
              className,
              placeholder
          } = this.props
  
          return (
              <FormGroup className={cn('DateField', className)}>
                <div>
                  {label ? (
                      <Label className='DateField-Label'>
                          {label}
                      </Label>
                  ) : null}                
                  <DatePicker
                          name={name}
                          selected={value}
  
                          dateFormat={dateFormat}
  
                          timeFormat={timeFormat}
                          showTimeSelect={hasTime}
                          timeIntervals={timeInterval}
  
                          onChange={this.onChange}
                          placeholderText={placeholder}
                          className='DateField-Input form-control'
                  />
                </div>
              </FormGroup>
          )
      }
  }
  

Он немного более интересен, так как здесь я использовал популярную библиотеку react-datepicker. Она отлично поддерживается и решает большой спектр задач. Стоит изучить весь список её опций, чтобы понимать масштаб возможностей. Такая библиотека нужна практически в каждом проекте.

Что ж, все необходимые базовые компоненты формы готовы. Теперь давайте добавим больше фиктивных данных в MockData.js, чтобы лучше протестировать работу фильтра:


Код
    
  export const appointments = [
    {
      date: 1556863200000,
      clientName: 'Должанский Николай Сергеевич',
      status: 'Завершён',
      holderName: 'Иванов Иван Иванович',
      compliences: 'Боль в правом ухе',
      diagnosis: 'Застужено правое ухо'
    },
    {
      date: 1560778200000,
      clientName: 'Пертов Пётр Генадьевич',
      status: 'Завершён',
      holderName: 'Иванов Иван Иванович',
      compliences: 'Боль в горле',
      diagnosis: 'Ангина'
    },
    {
      date: 1560256200000,
      clientName: 'Буйкевич Галина Петровна',
      status: 'Завершён',
      holderName: 'Нестеров Валерий Викторович',
      compliences: 'Головные боли',
      diagnosis: 'Мигрень'
    },
    {
      date: 1561017600000,
      clientName: 'Астафьева Ирина Михайловна',
      status: 'Завершён',
      holderName: 'Сидоров Генадий Павлович',
      compliences: 'Тошнота',
      diagnosis: 'Ротавирус'
    }
  ]
  



5.5.6 Апгрейд компонента списка приёмов


Итак, сейчас у нас всё готово для того, чтобы реализовать фильтр в компоненте <Appointments>. Давайте сделаем это:


Код
    
  import React, { Component } from 'react'
  
  import {Form} from 'reactstrap'
  import Moment from 'react-moment'
  import {map, filter} from 'underscore'
  
  import Table from '../Table/Table'
  import Header from '../Header/Header'
  import TextField from '../Form/TextField/TextField'
  import DateField from '../Form/DateField/DateField'
  import CheckboxField from '../Form/CheckboxField/CheckboxField'
  
  import './Appointments.scss'
  
  import { ReactComponent as Appointment } from '../../images/appointment.svg'
  
  import { appointments as data } from '../../lib/MockData'
  
  const TITLE = 'Приёмы'
  
  const USER = 'Иванов Иван Иванович'
  
  export default class Appointments extends Component {
  
    state = {
      filter: {
        startDate: null,
        endDate: null,
        clientName: '',
        onlyMe: false
      }
    }
  
    onChangeFilterField = (name, value) => {
      const { filter } = this.state
  
      this.setState({
        filter: {...filter, ...{[name]: value}}
      })
    }
  
    onChangeFilterDateField = (name, value) => {
      const { filter } = this.state
  
      this.setState({
        filter: {...filter, ...{[name]: value && value.getTime()}}
      })
    }
  
    render() {
      const {
        startDate,
        endDate,
        clientName,
        onlyMe,
      } = this.state.filter
  
      let filtered = filter(data, o => {
        return (startDate ? o.date >= startDate : true) && 
        (endDate ? o.date <= endDate : true) && 
        (clientName ? (clientName.length > 2 ? o.clientName.includes(clientName) : true) : true) && 
        (onlyMe ? o.holderName === USER : true)
      })
  
      return (
        <div className='Appointments'>
          <Header
                  title={TITLE}
                  userName={USER}
                  className='Appointments-Header'
                  bodyClassName='Appointments-HeaderBody'
                  renderIcon={() => (
              <Appointment className='Header-Icon' />
            )}
          />
          <div className='Appointments-Body'>
            <div className='Appointments-Filter'>
              <Form className='Appointments-FilterForm'>
                <DateField
                        hasTime
                        name='startDate'
                        value={startDate}
                        dateFormat='dd/MM/yyyy HH:mm'
                        timeFormat='HH:mm'
                        placeholder='С'
                        className='Appointments-FilterField'
                        onChange={this.onChangeFilterDateField}
                />
                <DateField
                        hasTime
                        name='endDate'
                        value={endDate}
                        dateFormat='dd/MM/yyyy HH:mm'
                        timeFormat='HH:mm'
                        placeholder='По'
                        className='Appointments-FilterField'
                        onChange={this.onChangeFilterDateField}
                />
                <TextField
                        name='clientName'
                        value={clientName}
                        placeholder='Клиент'
                        className='Appointments-FilterField'
                        onChange={this.onChangeFilterField}
                />
                <CheckboxField
                        name='onlyMe'
                        label='Только я'
                        value={onlyMe}
                        className='Appointments-FilterField'
                        onChange={this.onChangeFilterField}
                />
              </Form>
            </div>
            <Table
                    data={filtered}
                    className='AppointmentList'
                    columns={[
                    {
                    dataField: 'date',
                  text: 'Дата',
                  headerStyle: {
                    width: '150px'
                  },
                  formatter: (v, row) => {
                    return (
                      <Moment date={v} format='DD.MM.YYYY HH.mm'/>
                    )
                  }
                },
                {
                  dataField: 'clientName',
                  text: 'Клиент',
                  headerStyle: {
                    width: '300px'
                  }
                },
                {
                  dataField: 'status',
                  text: 'Статус'
                },
                {
                  dataField: 'holderName',
                  text: 'Принимающий',
                  headerStyle: {
                    width: '300px'
                  }
                },
                {
                  dataField: 'compliences',
                  text: 'Жалобы',
                  headerStyle: {
                    width: '200px'
                  }
                },
                {
                  dataField: 'diagnosis',
                  text: 'Диагноз',
                  headerStyle: {
                    width: '200px'
                  }
                }      
              ]}
            />
          </div>
        </div>
      )
    }
  }
  

Давайте проанализируем наш обновлённый код.

Во-первых у компонента <Appointments> появилось состояние state, в котором есть переменная filter, хранящая данные фильтра. Во-вторых для изменения состояния фильтра здесь используются два метода onChangeFilterField и onChangeFilterDateField. Так сделано потому, что во второй приходит объект даты, а не примитивное значение, как в текстовом поле или чекбоксе. Мы могли бы использовать и один метод, но тогда нам нужно было бы делать определение типа переменной value, что определённо выглядело бы не так аккуратно.

Далее была добавлена сама логика фильтрации данных:


Код
    
  let filtered = filter(data, o => {
      return (startDate ? o.date >= startDate : true) &&
      (endDate ? o.date <= endDate : true) &&
      (clientName ? (clientName.length > 2 ? o.clientName.includes(clientName) : true) : true) &&
      (onlyMe ? o.holderName === USER : true)
  })
  

Ничего сложного: четыре условия для каждого поля, соединённых оператором && (логическое И). Обратите внимание на clientName.length > 2, это сделано для того, чтобы заставить фильтр срабатывать только тогда, когда пользователь введёт больше 2 символов в поле (это не обязательно).

В остальном всё знакомо. Изучите и протестируйте полную рабочую версию примера: