sonhanguyen
12/24/2017 - 11:05 PM

typed grid

typed grid

import * as React from 'react'
import Grid from './Grid'
import './App.css'

interface BaseLineItem {
  id: string
  name: string
  cost: number
}

interface LineItemPackage extends BaseLineItem {
  type: 'package'
  lineItems: BaseLineItem[]
}

const DataGrid: React.ComponentType<Grid.Props<BaseLineItem>> = Grid
function Column(props: Grid.Column.Props) {
  return props.children
}

export default props =>
  <DataGrid
    data={[
      { id: 'a', name: 'AAAAAA', cost: 3 },
      { id: 'b', name: 'BBBBBB', cost: 4 },
      { id: 'b', name: 'BBBBBB', type: 'package',
        lineItems: [
          { id: 'a', name: 'AAAAAA', cost: 3 },
          { id: 'b', name: 'BBBBBB', cost: 4 }
        ],
        get cost() {
          return 2
        }
      } as LineItemPackage
    ]}
    columnOrdering={['id', 'cost', 'name']}
    rowComponent={(props: Grid.Row.Props<LineItemPackage>) =>
      props.record.type === 'package'
        ? <>
          <td colSpan={100}>Package {props.record.name} {props.record.cost}</td>
          {props.record.lineItems.map(lineItem => {
            const Cell: Grid.Cell = (props) => {
              const { type: Col, props: colProps } = props.children
              return <Grid.Cell {...props}>
                <Col {...colProps}>{lineItem[props.columnName]}</Col>
              </Grid.Cell>
            }
            return <Grid.Row {...props} record={lineItem} cellComponent={Cell}/>
          })}
        </>
        : <Grid.Row {...props} />
    }
    columns={{
      id: {
        header: 'ID',
        component: Column
      },
      name: {
        header: 'Name',
        component: Column
      },
      cost: {
        header: 'Cost',
        component: Column
      }
    }}
    onClick={() => false}
  />
import * as React from 'react'
import { ReactElement, ComponentType, ReactNode } from 'react'

// "context" in the sense that these props get passed down from the grid to the cell, not via the actual react context
type RowContext<Record=any> = {
  isSelected?: boolean
  index: number
  id: number | string
  record: Record
}

type ColumnContext = {
  columnName: string
}

module Grid {
  export type Props<Record, Keys extends string = keyof Record> =
    Pick<Row.Props<Record>, 'onClick'> &
    {
      rowComponent?: Row // main extension point
      headerComponent?: Header
      columns: {
        [colName in Keys]:
          {
            header: string
            component: Column
          }
      }
      keyBy?: string
      data: Record[]
      columnOrdering: Array<Keys>
    }

  export type Component<Record> = ComponentType<Grid.Props<Record>>

  export namespace Column {
    export type Props<ValueType=any> =
      RowContext &
      ColumnContext &
      {
        children: ValueType
      }
  }
  export type Column<ValueType=any> = ComponentType<Column.Props<ValueType>>

  export namespace Cell {
    export type Props<ValueType=any> =
      RowContext &
      ColumnContext &
      {
        columnComponent?: Column<ValueType>
        children: ReactElement<Props> 
      }
  }
  export type Cell<ValueType=any> = ComponentType<Cell.Props<ValueType>>

  export namespace Row {
    export type Props<Record=any, Children=Array<ReactElement<Cell.Props>>> =
      RowContext<Record> &
      {
        onClick(cell: any, col: string, row: Record): boolean | void
        cellComponent?: Cell
        isSelectable?: boolean
        children: Children
      }
  }
  export type Row<
    Col0=any, Col1=any, Col2=any, Col3=any, Col4=any, Col5=any, Col6=any
  > = ComponentType<
    Row.Props &
    {
      children: Row.Props['children'] & {
        0?: ReactElement<Cell.Props<Col0>>
        1?: ReactElement<Cell.Props<Col1>>
        2?: ReactElement<Cell.Props<Col2>>
        3?: ReactElement<Cell.Props<Col3>>
        4?: ReactElement<Cell.Props<Col4>>
        5?: ReactElement<Cell.Props<Col5>>
        6?: ReactElement<Cell.Props<Col6>>
      }
    }
  >

  export namespace Header {
    export type Props = {
      children: ColumnContext['columnName']
    }
  }
  export type Header = ComponentType<Header.Props>

  export namespace Table {
    export type Props<Row=ReactElement<Row.Props>> = {
      headerElements: ReactNode
      children: Array<Row>
    }
  }
}

class Row extends React.Component<Grid.Row.Props> {
  static defaultProps = {
    cellComponent(props: Grid.Cell.Props) {
      const Column = props.columnComponent as Grid.Column
      props = { ...props }
      props.columnComponent = undefined
      return <td><Column {...props} /></td>
    }
  }

  render() {
    const Cell = this.props.cellComponent as Grid.Cell
    return <>
      {this.props.children.map((cell: ReactElement<Grid.Cell.Props>) =>
        <Cell {...cell.props} columnComponent={cell.type as Grid.Cell} />
      )}
    </>
  }
}

class Grid<Record> extends React.Component<Grid.Props<Record>> {
  // default components to reuse when override
  static Cell: Grid.Cell = Row.defaultProps.cellComponent
  static Row: Grid.Row = Row

  static Header(props) {
    return props.children
  }

  static Table(props: Grid.Table.Props) {
    const { map } = React.Children
      
    return <table>
      <tr>{map(props.headerElements, col => <th>{col}</th>)}</tr>
      <tbody>{map(props.children, row => <tr>{row}</tr>)}</tbody>
    </table>
  }

  static defaultProps = {
    rowComponent: Grid.Row,
    headerComponent: Grid.Header,
    keyBy: 'id',
    onClick() {}
  }

  private renderRow = (record: any, index: number) => {
    const Row = this.props.rowComponent as Grid.Row
    const id = '' + record[this.props.keyBy as any]
    const rowContext = { index, id, record }
    const props: Partial<Grid.Row.Props> = {
      ...rowContext,
      children: this.props.columnOrdering.map((col: string) => {
        const {header, component: Column} = this.props.columns[col]
        return <Column {...rowContext} columnName={header} key={header}>
          {record[col] as any}
        </Column>
      })
    }

    return <Row key={id} {...props as Grid.Row.Props} />
  }

  render() {
    const Header = this.props.headerComponent as Grid.Header
    const props = {
      headerElements: this.props.columnOrdering.map((col: string) =>
        <th><Header children={this.props.columns[col].header} /></th>
      ),
      onClick: this.props.onClick,
      children: this.props.data.map(this.renderRow)
    }

    return <Grid.Table {...props} />
  }
}

export default Grid