Column Ordering (DnD)

Drag headers to reorder columns using HTML5 drag events. Ports examples/react/column-dnd from TanStack Table.

ColumnDnd.elm
module ColumnDnd exposing (main)

{-| Column Ordering (DnD).

Ports `examples/react/column-dnd/src/main.tsx` from TanStack Table. The
React example drives `dnd-kit`; this one uses plain HTML5 drag and drop
(`Shared.Drag`) and ends in the same place: `Table.setColumnOrder` with the
dragged column moved to the drop target's slot.

Drag the 🟰 handle in a header onto another header to reorder the columns.

-}

import Browser
import Html exposing (Html, button, div, pre, span, table, tbody, td, text, th, thead, tr)
import Html.Attributes exposing (class, classList, colspan, draggable, style, title)
import Html.Events exposing (onClick)
import Shared.Drag as Drag
import Shared.People as People exposing (Person)
import Shared.StateJson as StateJson
import Table
import Table.Value as Value



-- COLUMNS


config : Table.Config Person
config =
    Table.config
        [ Table.column "firstName" (.firstName >> Value.String)
            |> Table.withSize 150
        , Table.column "lastName" (.lastName >> maybeString)
            |> Table.withHeader "Last Name"
            |> Table.withSize 150
        , Table.column "age" (.age >> toFloat >> Value.Number)
            |> Table.withHeader "Age"
            |> Table.withSize 120
        , Table.column "visits" (.visits >> maybeNumber)
            |> Table.withHeader "Visits"
            |> Table.withSize 120
        , Table.column "status" (.status >> People.statusToString >> Value.String)
            |> Table.withHeader "Status"
            |> Table.withSize 150
        , Table.column "progress" (.progress >> toFloat >> Value.Number)
            |> Table.withHeader "Profile Progress"
            |> Table.withSize 180
        ]
        |> Table.withGetRowId (\person _ _ -> person.id)


maybeString : Maybe String -> Value.Value
maybeString =
    Maybe.map Value.String >> Maybe.withDefault Value.Null


maybeNumber : Maybe Int -> Value.Value
maybeNumber =
    Maybe.map (toFloat >> Value.Number) >> Maybe.withDefault Value.Null



-- MODEL


type alias Model =
    { state : Table.State
    , data : List Person
    , seed : Int
    , dragging : Maybe String
    , over : Maybe String
    }


init : Model
init =
    { -- `initialState: { columnOrder: columns.map(c => c.id) }` in the React
      -- example: the DnD order has to start out explicit.
      state =
        Table.setColumnOrder
            (List.map Table.columnId (Table.leafColumns config))
            Table.initialState
    , data = People.makeData 42 [ 20 ]
    , seed = 42
    , dragging = Nothing
    , over = Nothing
    }


type Msg
    = RegenerateData
    | StressTest
    | DragStarted String
    | DraggedOver String
    | Dropped String
    | DragEnded


update : Msg -> Model -> Model
update msg model =
    case msg of
        RegenerateData ->
            { model | seed = model.seed + 1, data = People.makeData (model.seed + 1) [ 20 ] }

        StressTest ->
            { model | seed = model.seed + 1, data = People.makeData (model.seed + 1) [ 1000 ] }

        DragStarted columnId ->
            { model | dragging = Just columnId, over = Nothing }

        DraggedOver columnId ->
            { model | over = Just columnId }

        Dropped columnId ->
            case model.dragging of
                Just from ->
                    { model
                        | state = Table.setColumnOrder (Drag.move from columnId model.state.columnOrder) model.state
                        , dragging = Nothing
                        , over = Nothing
                    }

                Nothing ->
                    { model | over = Nothing }

        DragEnded ->
            { model | dragging = Nothing, over = Nothing }



-- VIEW


view : Model -> Html Msg
view model =
    let
        rowModel : Table.RowModel Person
        rowModel =
            Table.coreRowModelFromList config model.state model.data
    in
    div [ class "demo-root" ]
        [ div [ class "spacer-md" ] []
        , div [ class "button-row" ]
            [ button [ onClick RegenerateData ] [ text "Regenerate Data" ]
            , button [ onClick StressTest ] [ text "Stress Test (1k rows)" ]
            ]
        , div [ class "spacer-md" ] []
        , table []
            [ thead [] (List.map (viewHeaderRow model) (Table.headerGroups config model.state))
            , tbody [] (List.map (viewRow model) rowModel.rows)
            ]
        , pre [ class "state-dump" ] [ text (StateJson.dump model.state) ]
        ]


viewHeaderRow : Model -> Table.HeaderGroup Person -> Html Msg
viewHeaderRow model group =
    tr [] (List.map (viewHeaderCell model) group.headers)


viewHeaderCell : Model -> Table.Header Person -> Html Msg
viewHeaderCell model header =
    let
        columnId : String
        columnId =
            Table.headerColumnId header
    in
    th
        [ colspan (Table.headerColSpan header)
        , classList
            [ ( "dragging", model.dragging == Just columnId )
            , ( "drop-target", model.over == Just columnId && model.dragging /= Just columnId )
            ]
        , style "width" (px (columnSize model.state columnId))
        , style "white-space" "nowrap"
        , Drag.onDragOver (DraggedOver columnId)
        , Drag.onDrop (Dropped columnId)
        ]
        [ span [] [ text (headerLabel header) ]
        , button
            [ draggable "true"
            , title "Drag to reorder"
            , Drag.onDragStart (DragStarted columnId)
            , Drag.onDragEnd DragEnded
            ]
            [ text "🟰" ]
        ]


viewRow : Model -> Table.Row Person -> Html Msg
viewRow model row =
    tr [] (List.map (viewCell model) (Table.getAllCells config model.state row))


viewCell : Model -> Table.Cell -> Html Msg
viewCell model cell =
    td
        [ classList [ ( "dragging", model.dragging == Just cell.columnId ) ]
        , style "width" (px (columnSize model.state cell.columnId))
        ]
        [ text (Value.toString cell.value) ]


columnSize : Table.State -> String -> Float
columnSize state columnId =
    Table.findColumn config columnId
        |> Maybe.map (Table.getColumnSize config state)
        |> Maybe.withDefault 0


px : Float -> String
px n =
    String.fromFloat n ++ "px"


headerLabel : Table.Header Person -> String
headerLabel header =
    Table.findColumn config (Table.headerColumnId header)
        |> Maybe.andThen Table.columnHeader
        |> Maybe.withDefault (Table.headerColumnId header)


main : Program () Model Msg
main =
    Browser.sandbox { init = init, update = update, view = view }