vigetlabs/ars-arsenal

View on GitHub
src/containers/load-collection.tsx

Summary

Maintainability
A
1 hr
Test Coverage
/**
 * LoadCollection
 * Fetch and present a list of items
 */

import * as React from 'react'
import OptionsContext from '../contexts/options'
import { Record } from '../record'
import { DEFAULT_OPTIONS, SortableColumn, ArsOptionsWithDeprecations } from '../options'
import { stringify } from 'query-string'
import ScrollMonitor from '../components/scroll-monitor'
import { dedupe } from '../utils/collection'
import { LogLevel } from '../logger'

export interface CollectionResult {
  data: Record[]
  fetching: boolean
  error: string | null
}

interface Props extends ArsOptionsWithDeprecations {
  sort: SortableColumn
  search: string
  render: (result: CollectionResult) => React.ReactNode | null
}

interface State {
  data: Record[]
  error: string | null
  fetching: boolean
  targetUrl: string
  search: string
  sort: SortableColumn
  page: number
}

type Request = {
  error: string | null
  fetching: boolean
  data: Record[]
  xhr?: XMLHttpRequest
  valid: boolean
}

function nextPage(nextProps: Props, lastState: State) {
  if (nextProps.search !== lastState.search || nextProps.sort !== lastState.sort) {
    return 0
  }

  return lastState.page
}

class CollectionFetcher extends React.Component<Props, State> {
  static defaultProps: Props = {
    ...DEFAULT_OPTIONS,
    sort: 'id',
    search: '',
    render: () => null
  }

  static getDerivedStateFromProps(nextProps: Props, lastState: State) {
    let { listUrl, listQuery, url, sort, search, logger } = nextProps

    let page = nextPage(nextProps, lastState)
    let baseUrl = listUrl(url)
    let query = { search, page, sort }
    let queryString = stringify(listQuery(query))

    if ('makeURL' in nextProps) {
      baseUrl = nextProps.makeURL(url)

      logger(LogLevel.Warning, 'ArsArsenal option makeURL is deprecated. Use listUrl instead.')
    }

    if ('makeQuery' in nextProps) {
      queryString = nextProps.makeQuery(search)

      logger(LogLevel.Warning, 'ArsArsenal option makeURL is deprecated. Use listUrl instead.')
    }

    return { page, sort, search, targetUrl: baseUrl + '?' + queryString }
  }

  requests: Request[] = []

  state: State = {
    data: [],
    page: 0,
    error: null,
    fetching: false,
    sort: 'id',
    search: '',
    targetUrl: ''
  }

  componentDidMount() {
    this.fetch()
  }

  componentDidUpdate(lastProps: Props, lastState: State) {
    if (lastState.search !== this.state.search || lastState.sort !== this.state.sort) {
      this.abort()
    }

    if (lastState.targetUrl !== this.state.targetUrl) {
      this.fetch()
    }
  }

  componentWillUnmount() {
    this.abort()
  }

  fetch() {
    let request: Request = {
      fetching: true,
      error: null,
      valid: true,
      data: []
    }

    request.xhr = this.props.request(
      this.state.targetUrl,
      this.onSuccess.bind(this, request),
      this.onFailure.bind(this, request)
    )

    this.requests.push(request)

    this.commit()
  }

  accumulate(requests: Request[]): Record[] {
    let data = []
    let newContent = requests.some(request => request.valid && !request.fetching)

    for (var i = 0; i < requests.length; i += 1) {
      // Should we hit a case where new content is mixed with old, invalid content,
      // Filter out invalid requests. But only do this if there is new content,
      // otherwise the collection will flash empty while new results load in
      if (newContent && !requests[i].valid) {
        continue
      }

      if (requests[i].fetching) {
        break
      }

      data.push(...requests[i].data)
    }

    let unique = dedupe(data, 'id')

    if (unique.length < data.length) {
      this.props.logger(
        LogLevel.Error,
        `Duplicate records were returned from ${this.state.targetUrl}. ` +
          'ArsArsenal has deduplicated them, however check that your API response is ' +
          'returning unique results.'
      )
    }

    return unique
  }

  onSuccess(request: Request, body: Object) {
    request.data = this.props.onFetch(body) as Record[]
    request.fetching = false

    // Filter invalid requests on success. This allows data to
    // remain in a reasonable state while other data loads
    this.requests = this.requests.filter(r => r.valid)

    this.commit()
  }

  onFailure(request: Request, error: Error) {
    request.data = []
    request.error = this.props.onError(error)
    request.fetching = false

    this.commit()
  }

  lastError() {
    let errors = this.requests.map(request => request.error).filter(Boolean)
    return errors ? errors.pop() : null
  }

  commit() {
    this.setState({
      data: this.accumulate(this.requests),
      error: this.lastError(),
      fetching: this.requests.some(item => item.fetching === true)
    })
  }

  abort() {
    this.requests.forEach(request => {
      if (request.xhr) {
        request.xhr.abort()
      }

      request.valid = false
    })
  }

  render() {
    // Refresh scrolling when these fields change. This causes the monitor to
    // start from a clean slate whenever new results come in.
    let token = [this.state.search, this.state.sort].join(':')

    return (
      <ScrollMonitor refresh={token} page={this.state.page} onPage={this.onPage}>
        {this.props.render(this.state)}
      </ScrollMonitor>
    )
  }

  private onPage = (page: number) => this.setState({ page })
}

type LoadCollectionProps = {
  search: string
  sort: SortableColumn
  render: (result: CollectionResult) => React.ReactNode | null
}

export default function LoadCollection(props: LoadCollectionProps) {
  return (
    <OptionsContext.Consumer>
      {options => <CollectionFetcher {...options} {...props} />}
    </OptionsContext.Consumer>
  )
}