Initial commit with draft API

This commit is contained in:
Evan Czaplicki 2016-07-20 14:59:04 -07:00
commit 5de13f7561
6 changed files with 771 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
elm-stuff

30
LICENSE Normal file
View File

@ -0,0 +1,30 @@
Copyright (c) 2016-present, Evan Czaplicki
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following
disclaimer in the documentation and/or other materials provided
with the distribution.
* Neither the name of Evan Czaplicki nor the names of other
contributors may be used to endorse or promote products derived
from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

1
README.md Normal file
View File

@ -0,0 +1 @@
# Sortable Tables

17
elm-package.json Normal file
View File

@ -0,0 +1,17 @@
{
"version": "1.0.0",
"summary": "Sortable tables for data of any shape.",
"repository": "https://github.com/evancz/elm-table.git",
"license": "BSD3",
"source-directories": [
"src"
],
"exposed-modules": [
"Table"
],
"dependencies": {
"elm-lang/core": "4.0.0 <= v < 5.0.0",
"elm-lang/html": "1.1.0 <= v < 2.0.0"
},
"elm-version": "0.17.0 <= v < 0.18.0"
}

131
examples/Presidents.elm Normal file
View File

@ -0,0 +1,131 @@
import Html exposing (Html, div, h1, text)
import Html.App as App
import Table
main =
App.program
{ init = init presidents
, update = update
, view = view
, subscriptions = \_ -> Sub.none
}
-- MODEL
type alias Model =
{ people : List Person
, tableState : Table.State
}
init : List Person -> ( Model, Cmd Msg )
init people =
( Model people (Table.ascending "Year")
, Cmd.none
)
-- UPDATE
type Msg
= UpdateTableState Table.State
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
UpdateTableState newState ->
( Model model.people newState, Cmd.none )
-- VIEW
view : Model -> Html Msg
view {people, tableState} =
div []
[ h1 [] [ text "Birthplaces of U.S. Presidents" ]
, Table.view config tableState people
]
config : Table.Config Person Msg
config =
Table.config
{ toId = .name
, toMsg = UpdateTableState
, columns =
[ Table.stringColumn "Name" .name
, Table.intColumn "Year" .year
, Table.stringColumn "City" .city
, Table.stringColumn "State" .state
]
}
-- PEOPLE
type alias Person =
{ name : String
, year : Int
, city : String
, state : String
}
presidents : List Person
presidents =
[ Person "George Washington" 1732 "Westmoreland County" "Virginia"
, Person "John Adams" 1735 "Braintree" "Massachusetts"
, Person "Thomas Jefferson" 1743 "Shadwell" "Virginia"
, Person "James Madison" 1751 "Port Conway" "Virginia"
, Person "James Monroe" 1758 "Monroe Hall" "Virginia"
, Person "Andrew Jackson" 1767 "Waxhaws Region" "South/North Carolina"
, Person "John Quincy Adams" 1767 "Braintree" "Massachusetts"
, Person "William Henry Harrison" 1773 "Charles City County" "Virginia"
, Person "Martin Van Buren" 1782 "Kinderhook" "New York"
, Person "Zachary Taylor" 1784 "Barboursville" "Virginia"
, Person "John Tyler" 1790 "Charles City County" "Virginia"
, Person "James Buchanan" 1791 "Cove Gap" "Pennsylvania"
, Person "James K. Polk" 1795 "Pineville" "North Carolina"
, Person "Millard Fillmore" 1800 "Summerhill" "New York"
, Person "Franklin Pierce" 1804 "Hillsborough" "New Hampshire"
, Person "Andrew Johnson" 1808 "Raleigh" "North Carolina"
, Person "Abraham Lincoln" 1809 "Sinking spring" "Kentucky"
, Person "Ulysses S. Grant" 1822 "Point Pleasant" "Ohio"
, Person "Rutherford B. Hayes" 1822 "Delaware" "Ohio"
, Person "Chester A. Arthur" 1829 "Fairfield" "Vermont"
, Person "James A. Garfield" 1831 "Moreland Hills" "Ohio"
, Person "Benjamin Harrison" 1833 "North Bend" "Ohio"
, Person "Grover Cleveland" 1837 "Caldwell" "New Jersey"
, Person "William McKinley" 1843 "Niles" "Ohio"
, Person "Woodrow Wilson" 1856 "Staunton" "Virginia"
, Person "William Howard Taft" 1857 "Cincinnati" "Ohio"
, Person "Theodore Roosevelt" 1858 "New York City" "New York"
, Person "Warren G. Harding" 1865 "Blooming Grove" "Ohio"
, Person "Calvin Coolidge" 1872 "Plymouth" "Vermont"
, Person "Herbert Hoover" 1874 "West Branch" "Iowa"
, Person "Franklin D. Roosevelt" 1882 "Hyde Park" "New York"
, Person "Harry S. Truman" 1884 "Lamar" "Missouri"
, Person "Dwight D. Eisenhower" 1890 "Denison" "Texas"
, Person "Lyndon B. Johnson" 1908 "Stonewall" "Texas"
, Person "Ronald Reagan" 1911 "Tampico" "Illinois"
, Person "Richard M. Nixon" 1913 "Yorba Linda" "California"
, Person "Gerald R. Ford" 1913 "Omaha" "Nebraska"
, Person "John F. Kennedy" 1917 "Brookline" "Massachusetts"
, Person "George H. W. Bush" 1924 "Milton" "Massachusetts"
, Person "Jimmy Carter" 1924 "Plains" "Georgia"
, Person "George W. Bush" 1946 "New Haven" "Connecticut"
, Person "Bill Clinton" 1946 "Hope" "Arkansas"
, Person "Barack Obama" 1961 "Honolulu" "Hawaii"
]

591
src/Table.elm Normal file
View File

@ -0,0 +1,591 @@
module Table exposing
( view
, State, initialSort
, Config, config
, Column, stringColumn, intColumn, floatColumn, column
, Sorter, unsortable, increasingBy, decreasingBy
, increasingOrDecreasingBy, decreasingOrIncreasingBy
)
{-|
This library helps you create sortable tables. The crucial feature is that it
lets you own your data separately and keep it in whatever format is best for
you. This way you are free to change your data without worrying about the table
&ldquo;getting out of sync&rdquo; with the data. Having a single source of
truth is pretty great!
I recommend checking out the [examples][] to get a feel for how it works.
[examples]: https://github.com/evancz/elm-tables/tree/master/examples
# View
@docs view
# Configuration
@docs config, stringColumn, intColumn, floatColumn
# State
@docs State, initialSort
# Crazy Customization
If you are new to this library, you can probably stop reading here. After this
point are a bunch of ways to customize your table further. If it does not
provide what you need, you may just want to write a custom table yourself. It
is not that crazy.
## Custom Columns
@docs Column, customColumn, veryCustomColumn,
Sorter, unsortable, increasingBy, decreasingBy,
increasingOrDecreasingBy, decreasingOrIncreasingBy
## Custom Tables
@docs Config, customConfig,
Customizations, HtmlDetails, Status, defaultCustomizations
-}
import Html exposing (Html, Attribute)
import Html.Attributes as Attr
import Html.Events as E
import Html.Keyed as Keyed
import Html.Lazy exposing (lazy2, lazy3)
-- STATE
{-| Tracks which column to sort by.
-}
type State =
State String Bool
{-| Create a table state. By providing a column name, you determine which
column should be used for sorting by default. So if you want your table of
yachts to be sorted by length by default, you might say:
import Table
Table.initialSort "Length"
-}
initialSort : String -> State
initialSort header =
State header False
-- CONFIG
{-| Configuration for your table, describing your columns.
**Note:** Your `Config` should *never* be held in your model.
It should only appear in `view` code.
-}
type Config data msg =
Config
{ toId : data -> String
, toMsg : State -> msg
, columns : List (Column data msg)
, customizations : Customizations data msg
}
{-| Create the `Config` for your `view` function. Everything you need to
render your columns efficiently and handle selection of columns.
Say we have a `List Person` that we want to show as a table. The table should
have a column for name and age. We would create a `Config` like this:
import Table
type Msg = NewTableState State | ...
config : Table.Config Person Msg
config =
Table.config
{ toId = .name
, toMsg = NewTableState
, columns =
[ Table.stringColumn "Name" .name
, Table.intColumn "Age" .age
]
}
You provide the following information in your table configuration:
- `toId` &mdash; turn a `Person` into a unique ID. This lets us use
[`Html.Keyed`][keyed] under the hood to make resorts faster.
- `columns` &mdash; specify some columns to show.
- `toMsg` &mdash; a way send new table states to your app as messages.
See the [examples][] to get a better feel for this!
[keyed]: http://package.elm-lang.org/packages/elm-lang/html/latest/Html-Keyed
[examples]: https://github.com/evancz/elm-tables/tree/master/examples
-}
config
: { toId : data -> String
, toMsg : State -> msg
, columns : List (Column data msg)
}
-> Config data msg
config { toId, toMsg, columns } =
Config
{ toId = toId
, toMsg = toMsg
, columns = List.map (\(Column cData) -> cData) columns
, customizations = defaultCustomizations
}
customConfig
: { toId : data -> String
, toMsg : State -> msg
, columns : List (Column data msg)
, customizations : Customizations data msg
}
-> Config data msg
customConfig { toId, toMsg, columns, customizations } =
Config
{ toId = toId
, toMsg = toMsg
, columns = List.map (\(Column cData) -> cData) columns
, customizations = customizations
}
{-| There are quite a lot of ways to customize the `<table>` tag. You can add
a `<caption>` which can be styled via CSS. You can do crazy stuff with
`<thead>` to group columns in weird ways. You can have a `<tfoot>` tag for
summaries of various columns. And maybe you want to put attributes on `<tbody>`
or on particular rows in the body. All these customizations are available to you.
**Note:** The level of craziness possible in `<thead>` and `<tfoot>` are so
high that I could not see how to provide the full functionality *and* make it
impossible to do bad stuff. So just be aware of that, and share any stories
you have. Stories make it possible to design better!
-}
type alias Cusomizations data msg =
{ tableAttrs : List (Attribute msg)
, caption : Maybe (HtmlDetails msg)
, thead : List (String, Status, Attribute msg) -> HtmlDetails msg
, tfoot : Maybe (HtmlDetails msg)
, tbodyAttrs : List (Attribute msg)
, rowAttrs : data -> List (Attribute msg)
}
{-| Sometimes you must use a `<td>` tag, but the attributes and children are up
to you. This type lets you specify all the details of an HTML node except the
tag name.
-}
type alias HtmlDetails msg =
{ attributes : List (Attribute msg)
, children : List (Html msg)
}
defaultCustomizations : Customizations data msg
defaultCustomizations =
{ tableAttrs = []
, caption = Nothing
, thead = simpleThead
, tfoot = Nothing
, tbodyAttrs = []
, rowAttrs = simpleRowAttrs
}
simpleThead : List (String, Status, Attribute msg) -> HtmlDetails msg
simpleThead headers =
HtmlDetails [] (List.map simpleTheadHelp headers)
simpleTheadHelp : ( String, Status, Attribute msg ) -> Html msg
simpleTheadHelp (name, status, onClick) =
let
content =
case status of
Unsortable ->
[ text name ]
Sortable selected ->
[ text name
, if selected then darkGrey "" else lightGrey ""
]
Reversable Nothing ->
[ text name
, lightGrey ""
]
Reversable (Just isReversed) ->
[ text name
, darkGrey (if isReversed then "" else "")
]
in
Html.th [ onClick ] content
darkGrey : String -> Html msg
darkGrey symbol =
Html.span [ Attr.style [("color", "#ccc")] ] [ Html.text (" " ++ symbol) ]
lightGrey : String -> Html msg
lightGrey symbol =
Html.span [ Attr.style [("color", "#999")] ] [ Html.text (" " ++ symbol) ]
simpleRowAttrs : data -> Attribute msg
simpleRowAttrs _ =
[]
type Status
= Unsortable
| Sortable Bool
| Reversable (Maybe Bool)
-- COLUMNS
{-| Describes how to turn `data` into a column in your table.
-}
type Column data msg =
Column (ColumnData data msg)
type alias ColumnData data msg =
{ name : String
, viewData : data -> HtmlDetails msg
, sorter : Sorter data
}
{-|-}
stringColumn : String -> (data -> String) -> Column data msg
stringColumn name toStr =
Column
{ name = name
, viewData = textDetails << toStr
, sorter = increasingOrDecreasingBy toStr
}
{-|-}
intColumn : String -> (data -> Int) -> Column data msg
intColumn name toInt =
Column
{ name = name
, viewData = textDetails << toString << toInt
, sorter = increasingOrDecreasingBy toInt
}
{-|-}
floatColumn : String -> (data -> Float) -> Column data msg
floatColumn name toFloat =
Column
{ name = name
, viewData = textDetails << toString << toFloat
, sorter = increasingOrDecreasingBy toFloat
}
textDetails : String -> HtmlDetails msg
textDetails str =
HtmlDetails [] [ Html.text str ]
{-| Perhaps the basic columns are not quite what you want. Maybe you want to
display monetary values in thousands of dollars, and `floatColumn` does not
quite cut it. You could define a custom column like this:
import Table
dollarColumn : String -> (data -> Float) -> Column data msg
dollarColumn name toDollars =
Table.customColumn
{ name = name
, viewData = \data -> viewDollars (toDollars data)
, sorter = Table.decreasingBy toDollars
}
viewDollars : Float -> String
viewDollars dollars =
"$" ++ toString (round (dollars / 1000)) ++ "k"
The `viewData` field means we will displays the number `12345.67` as `$12k`.
The `sorter` field specifies how the column can be sorted. In `dollarColumn` we
are saying that it can *only* be shown from highest-to-lowest monetary value.
More about sorters soon!
-}
customColumn
: { name : String
, viewData : data -> String
, sorter : Sorter data
}
-> Column data msg
customColumn { name, viewData, sorter } =
Column <|
ColumnData name (textDetails << viewData) sorter
{-| It is *possible* that you want something crazier than `customColumn`. In
that unlikely scenario, this function lets you have full control over the
attributes and children of each `<td>` cell in this column.
So maybe you want to a dollars column, and the dollar signs should be green.
import Html exposing (Html, Attribute, span, text)
import Html.Attributes exposing (style)
import Table
dollarColumn : String -> (data -> Float) -> Column data msg
dollarColumn name toDollars =
Table.veryCustomColumn
{ name = name
, viewData = \data -> viewDollars (toDollars data)
, sorter = Table.decreasingBy toDollars
}
viewDollars : Float -> Table.HtmlDetails msg
viewDollars dollars =
Table.HtmlDetails []
[ span [ style [("color","green")] ] [ text "$" ]
, text (toString (round (dollars / 1000)) ++ "k")
]
-}
veryCustomColumn
: { name : String
, viewData : data -> HtmlDetails msg
, sorter : Sorter data
}
-> Column data msg
veryCustomColumn =
Column
-- VIEW
{-| Take a list of data and turn it into a table. The `Config` argument is the
configuration for the table. It describes the columns that we want to show. The
`State` argument describes which column we are sorting by at the moment.
**Note:** The `State` and `List data` should live in your `Model`. The `Config`
for the table belongs in your `view` code. I very strongly recommend against
putting `Config` in your model. Describe any potential table configurations
statically, and look for a different library if you need something crazier than
that.
-}
view : Config data msg -> State -> List data -> Html msg
view (Config { toId, toMsg, columns, customizations }) state data =
let
sortedData =
sort state columns data
theadDetails =
customizations.thead (List.map Debug.crash columns)
thead =
Html.thead theadDetails.attributes theadDetails.children
tbody =
List.map (viewRow toId columns) sortedData
withFoot =
case customizations.tfoot of
Nothing ->
tbody
Just { attributes, children } ->
Html.tfoot attributes children :: tbody
in
Html.table customizations.tableAttrs <|
case customizations.caption of
Nothing ->
thead :: withFoot
Just { attributes, children } ->
Html.caption attributes children :: thead :: withFoot
viewHeader : List (ColumnData a msg) -> (State -> msg) -> State -> Html msg
viewHeader columnData toMsg state =
Html.tr [] (List.map (lazy3 viewHeaderHelp toMsg state) columnData)
viewHeaderHelp : (State -> msg) -> State -> ColumnData a msg -> Html msg
viewHeaderHelp toMsg state ({name} as column) =
let
Html.th
[ class (String.join " " classes)
, onClick name state toMsg
]
[ html
]
onClick : String -> State -> (State -> msg) -> Attribute msg
onClick name (State selectedColumn isReversed) toMsg =
E.on "click" <| Json.map toMsg <|
Json.object2
State
(Json.succed name)
(Json.succed (name == selectedColumn && not isReversed)
descendingClass : Attribute msg
descendingClass =
class "elm-table-selected elm-table-descending"
ascendingClass : Attribute msg
ascendingClass =
class "elm-table-selected elm-table-ascending"
viewRow : (a -> String) -> List (ColumnData a msg) -> a -> ( String, Html msg )
viewRow toId columnData entry =
( toId entry
, lazy2 viewRowHelp columnData entry
)
viewRowHelp : List (ColumnData a msg) -> a -> Html msg
viewRowHelp columnData entry =
Html.tr [] (List.map (\{toCell} -> Html.td [] [ toCell entry ]) columnData)
-- SORTING
sort : State -> List (ColumnData data msg) -> List data -> List data
sort (State selectedColumn isReversed) columnData data =
case findSorter selectedColumn columnData of
Nothing ->
data
Just sorter ->
applySorter isReversed sorter data
applySorter : Bool -> Sorter data -> List data -> List data
applySorter isReversed sorter data =
case sorter of
None ->
data
Increasing sort ->
sort data
Decreasing sort ->
List.reverse (sort data)
IncOrDec sort ->
if isReversed then List.reverse (sort data) else sort data
DecOrInc sort ->
if isReversed then sort data else List.reverse (sort data)
findSorter : String -> List (ColumnData data msg) -> Maybe (Sorter data)
findSorter selectedColumn columnData =
case columnData of
[] ->
Nothing
{name, sorter} :: remainingColumnData ->
if name == selectedColumn then
sorter
else
findSorter selectedColumn remainingColumnData
-- SORTERS
{-| Specifies a particular way of sorting data.
-}
type Sorter data
= None
| Increasing (List data -> List data)
| Decreasing (List data -> List data)
| IncOrDec (List data -> List data)
| DecOrInc (List data -> List data)
{-| A sorter for columns that are unsortable. Maybe you have a column in your
table for delete buttons that delete the row. It would not make any sense to
sort based on that column.
-}
unsortable : Sorter data
unsortable =
None
{-| Create a sorter that can only display the data in increasing order. If we
want a table of people, sorted alphabetically by name, we would say this:
sorter : Sorter { a | name : comparable }
sorter =
increasingBy .name
-}
increasingBy : (data -> comparable) -> Sorter data
increasingBy toComparable =
Increasing (List.sortBy toComparable)
{-| Create a sorter that can only display the data in decreasing order. If we
want a table of countries, sorted by population from highest to lowest, we
would say this:
sorter : Sorter { a | population : comparable }
sorter =
decreasingBy .population
-}
decreasingBy : (data -> comparable) -> Sorter data
decreasingBy toComparable =
Decreasing (List.sortBy toComparable)
{-| Sometimes you want to be able to sort data in increasing *or* decreasing
order. Maybe you have a bunch of data about orange juice, and you want to know
both which has the most sugar, and which has the least sugar. Both interesting!
This function lets you see both, starting with decreasing order.
sorter : Sorter { a | sugar : comparable }
sorter =
decreasingOrIncreasingBy .sugar
-}
decreasingOrIncreasingBy : (data -> comparable) -> Sorter data
decreasingOrIncreasingBy toComparable =
DecOrInc (List.sortBy toComparable)
{-| Sometimes you want to be able to sort data in increasing *or* decreasing
order. Maybe you have race times for the 100 meter sprint. This function lets
sort by best time by default, but also see the other order.
sorter : Sorter { a | time : comparable }
sorter =
increasingOrDecreasingBy .time
-}
increasingOrDecreasingBy : (data -> comparable) -> Sorter data
increasingOrDecreasingBy toComparable =
IncOrDec (List.sortBy toComparable)