From 5de13f7561a41b6a14ab80fac62bce31166e34e7 Mon Sep 17 00:00:00 2001 From: Evan Czaplicki Date: Wed, 20 Jul 2016 14:59:04 -0700 Subject: [PATCH] Initial commit with draft API --- .gitignore | 1 + LICENSE | 30 ++ README.md | 1 + elm-package.json | 17 ++ examples/Presidents.elm | 131 +++++++++ src/Table.elm | 591 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 771 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 elm-package.json create mode 100644 examples/Presidents.elm create mode 100644 src/Table.elm diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e185314 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +elm-stuff \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0edfd04 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ac2531c --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# Sortable Tables \ No newline at end of file diff --git a/elm-package.json b/elm-package.json new file mode 100644 index 0000000..7df4518 --- /dev/null +++ b/elm-package.json @@ -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" +} diff --git a/examples/Presidents.elm b/examples/Presidents.elm new file mode 100644 index 0000000..4646ad1 --- /dev/null +++ b/examples/Presidents.elm @@ -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" + ] \ No newline at end of file diff --git a/src/Table.elm b/src/Table.elm new file mode 100644 index 0000000..b1616b3 --- /dev/null +++ b/src/Table.elm @@ -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 +“getting out of sync” 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` — turn a `Person` into a unique ID. This lets us use + [`Html.Keyed`][keyed] under the hood to make resorts faster. + - `columns` — specify some columns to show. + - `toMsg` — 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 `` tag. You can add +a `` to group columns in weird ways. You can have a `` tag for +summaries of various columns. And maybe you want to put attributes on `` +or on particular rows in the body. All these customizations are available to you. + +**Note:** The level of craziness possible in `` and `` 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 `
` which can be styled via CSS. You can do crazy stuff with +`
` 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 `` 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) \ No newline at end of file