├── .gitignore ├── .travis.yml ├── tests └── elm-verify-examples.json ├── package.json ├── elm.json ├── LICENSE ├── README.md └── src └── Reorderable.elm /.gitignore: -------------------------------------------------------------------------------- 1 | elm-stuff 2 | node_modules 3 | tests/Doc 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | node_js: "node" 4 | os: linux 5 | -------------------------------------------------------------------------------- /tests/elm-verify-examples.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": "../src", 3 | "tests": ["Reorderable"] 4 | } 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "test": "elm-format --validate src && elm-verify-examples --run-tests" 4 | }, 5 | "dependencies": { 6 | "elm": "0.19.0-no-deps", 7 | "elm-format": "^0.8.2", 8 | "elm-verify-examples": "^4.0.0" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /elm.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "package", 3 | "name": "zwilias/elm-reorderable", 4 | "summary": "Reorder entries while maintaining a key/value correspondence.", 5 | "license": "BSD-3-Clause", 6 | "version": "1.3.0", 7 | "exposed-modules": [ 8 | "Reorderable" 9 | ], 10 | "elm-version": "0.19.0 <= v < 0.20.0", 11 | "dependencies": { 12 | "elm/core": "1.0.0 <= v < 2.0.0" 13 | }, 14 | "test-dependencies": { 15 | "elm-explorations/test": "1.0.0 <= v < 2.0.0" 16 | } 17 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2017, Ilias Van Peer 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # elm-reorderable [![Build Status](https://travis-ci.org/zwilias/elm-reorderable.svg?branch=master)](https://travis-ci.org/zwilias/elm-reorderable) 2 | > Reorder entries while maintaining a key/value correspondence. 3 | 4 | Having a list of entries that need to accept input, need to render efficiently, 5 | *and* can be arbitrarily reordered is fairly common. `Keyed` nodes are a great 6 | tool for implementing that, but there's a catch: every item needs to have some 7 | sort of unique identifier. 8 | 9 | In reality, you don't always want every single item to have an identifier. You 10 | could of course add that to your model, _just_ for the sake of making it 11 | possible to use `Keyed` nodes, but that's not always practical, and arguably 12 | it's simply not the best solution. 13 | 14 | `Reorderable` attempts to take some of that pain away by keeping track of a 15 | sequential identifier for everything you add to it; while also making sure that 16 | the identifier survives things like dropping items, swapping items at places or 17 | arbitrarily moving an item through the structure. 18 | 19 | ## [Example](https://ellie-app.com/mKsXW3R6sa1/0) 20 | 21 | ```elm 22 | module Main exposing (main) 23 | 24 | import Browser 25 | import Html exposing (Html, text) 26 | import Html.Attributes as Attr 27 | import Html.Events as Events 28 | import Html.Keyed as Keyed 29 | import Reorderable exposing (Reorderable) 30 | import Tuple 31 | 32 | --- updated to Elm 0.19 33 | 34 | type alias Model = 35 | Reorderable String 36 | 37 | 38 | type Msg 39 | = Add 40 | | Remove Int 41 | | MoveUp Int 42 | | MoveDown Int 43 | | Input Int String 44 | 45 | 46 | initialModel : Model 47 | initialModel = 48 | Reorderable.singleton "" 49 | 50 | 51 | update : Msg -> Model -> Model 52 | update msg model = 53 | case msg of 54 | Add -> 55 | Reorderable.push "" model 56 | 57 | Remove idx -> 58 | Reorderable.drop idx model 59 | 60 | MoveUp idx -> 61 | Reorderable.moveUp idx model 62 | 63 | MoveDown idx -> 64 | Reorderable.moveDown idx model 65 | 66 | Input idx input -> 67 | Reorderable.set idx input model 68 | 69 | 70 | view : Model -> Html Msg 71 | view items = 72 | Html.div [] 73 | [ Keyed.node "div" [] <| 74 | List.indexedMap viewItem (Reorderable.toKeyedList items) 75 | , Html.button 76 | [ Events.onClick Add ] 77 | [ text "Add another entry" ] 78 | ] 79 | 80 | 81 | viewItem : Int -> ( String, String ) -> ( String, Html Msg ) 82 | viewItem idx ( key, value ) = 83 | Html.div [ Attr.class "side-by-side" ] 84 | [ Html.textarea [ Events.onInput <| Input idx ] [] 85 | , Html.ul [ Attr.class "actions" ] 86 | [ Html.li [ Events.onClick <| Remove idx ] [ text "remove" ] 87 | , Html.li [ Events.onClick <| MoveUp idx ] [ text "move up" ] 88 | , Html.li [ Events.onClick <| MoveDown idx ] [ text "move down" ] 89 | ] 90 | ] 91 | |> Tuple.pair key 92 | 93 | 94 | main = 95 | Browser.sandbox 96 | { init = initialModel 97 | , update = update 98 | , view = view 99 | } 100 | ``` 101 | --- 102 | 103 | Made with ❤️ 104 | -------------------------------------------------------------------------------- /src/Reorderable.elm: -------------------------------------------------------------------------------- 1 | module Reorderable exposing 2 | ( Reorderable, empty, isEmpty, length, singleton, push, get, set, update 3 | , swap, moveUp, moveDown, move, insertAt, insertAfter, drop, reverse 4 | , fromList, toList, toKeyedList 5 | , map, indexedMap 6 | ) 7 | 8 | {-| `Reorderable` is useful for structures where you want to allow a user to 9 | reorder things, while still wanting to make effective use of `Html.Keyed`. The 10 | idea is to have a datastructure that (internally) keeps the association between 11 | some an incremental key and data (like an Array), but retains that association 12 | while shuffling data around. 13 | 14 | 15 | # Basics 16 | 17 | @docs Reorderable, empty, isEmpty, length, singleton, push, get, set, update 18 | 19 | 20 | # Manipulation 21 | 22 | @docs swap, moveUp, moveDown, move, insertAt, insertAfter, drop, reverse 23 | 24 | 25 | # List-y stuff 26 | 27 | @docs fromList, toList, toKeyedList 28 | 29 | 30 | # Transformation 31 | 32 | @docs map, indexedMap 33 | 34 | -} 35 | 36 | import Array exposing (Array) 37 | 38 | 39 | {-| -} 40 | type Reorderable a 41 | = Reorderable Int (Array ( Int, a )) 42 | 43 | 44 | {-| Does what one would expect it to do: create an empty `Reorderable`. Note 45 | that checking equality against `empty` is not a good way to check if there are 0 46 | values. Pushing and subsequently dropping entries will increment the internal 47 | key. 48 | 49 | toList empty 50 | --> [] 51 | 52 | fromList [] 53 | --> empty 54 | 55 | -} 56 | empty : Reorderable a 57 | empty = 58 | Reorderable 0 Array.empty 59 | 60 | 61 | {-| Checks if a reorderable contains zero entries. 62 | 63 | isEmpty empty 64 | --> True 65 | 66 | 67 | singleton "hi" 68 | |> isEmpty 69 | --> False 70 | 71 | 72 | fromList [ "hello", "world" ] 73 | |> drop 0 74 | |> drop 0 75 | |> isEmpty 76 | --> True 77 | 78 | fromList [ "hello", "world" ] 79 | |> drop 0 80 | |> drop 0 81 | |> (==) empty 82 | --> False 83 | 84 | -} 85 | isEmpty : Reorderable a -> Bool 86 | isEmpty (Reorderable _ values) = 87 | Array.isEmpty values 88 | 89 | 90 | {-| Determine the length of a `Reorderable`. 91 | 92 | empty 93 | |> length 94 | --> 0 95 | 96 | singleton "hi" 97 | |> length 98 | --> 1 99 | 100 | fromList [ "hello", "world" ] 101 | |> length 102 | --> 2 103 | 104 | -} 105 | length : Reorderable a -> Int 106 | length (Reorderable _ values) = 107 | Array.length values 108 | 109 | 110 | {-| Create a reorderable from a single piece of data. 111 | 112 | singleton "hi" 113 | |> toList 114 | --> [ "hi" ] 115 | 116 | -} 117 | singleton : a -> Reorderable a 118 | singleton v = 119 | push v empty 120 | 121 | 122 | {-| Pushes a piece of data onto the end of a `Reorderable`. 123 | 124 | empty 125 | |> push "hello" 126 | |> push "world" 127 | |> toList 128 | --> [ "hello", "world" ] 129 | 130 | -} 131 | push : a -> Reorderable a -> Reorderable a 132 | push val (Reorderable nextKey values) = 133 | Reorderable (nextKey + 1) <| Array.push ( nextKey, val ) values 134 | 135 | 136 | {-| Inserts a piece of data into a specifc position of the `Reorderable`. 137 | 138 | letters : Reorderable String 139 | letters = 140 | fromList [ "a", "b", "c", "d" ] 141 | 142 | 143 | letters 144 | |> insertAt 0 "foo" 145 | |> toList 146 | --> [ "foo", "a", "b", "c", "d" ] 147 | 148 | 149 | letters 150 | |> insertAt 4 "foo" 151 | |> toList 152 | --> [ "a", "b", "c", "d", "foo" ] 153 | 154 | 155 | letters 156 | |> insertAt 2 "foo" 157 | |> toKeyedList 158 | --> [ ( "0", "a" ) 159 | --> , ( "1", "b" ) 160 | --> , ( "4", "foo" ) 161 | --> , ( "2", "c" ) 162 | --> , ( "3", "d" ) 163 | --> ] 164 | 165 | -} 166 | insertAt : Int -> a -> Reorderable a -> Reorderable a 167 | insertAt index val (Reorderable nextKey values) = 168 | let 169 | before = 170 | Array.slice 0 index values 171 | 172 | after = 173 | Array.slice index (Array.length values) values 174 | in 175 | Reorderable (nextKey + 1) <| 176 | Array.append (Array.push ( nextKey, val ) before) after 177 | 178 | 179 | {-| Convenience function to insert something after a specified index. 180 | 181 | fromList [ "a", "c" ] 182 | |> insertAfter 0 "b" 183 | |> toList 184 | --> [ "a", "b", "c" ] 185 | 186 | 187 | fromList [ "a", "b" ] 188 | |> insertAfter 1 "c" 189 | |> toList 190 | --> [ "a", "b", "c" ] 191 | 192 | -} 193 | insertAfter : Int -> a -> Reorderable a -> Reorderable a 194 | insertAfter index = 195 | insertAt (index + 1) 196 | 197 | 198 | {-| Drops the entry at a certain index. 199 | 200 | fromList [ "a", "removeMe", "b" ] 201 | |> drop 1 202 | |> toKeyedList 203 | --> [ ( "0", "a" ), ( "2", "b" ) ] 204 | 205 | -} 206 | drop : Int -> Reorderable a -> Reorderable a 207 | drop idx (Reorderable nextKey values) = 208 | Reorderable nextKey <| 209 | Array.append 210 | (Array.slice 0 idx values) 211 | (Array.slice (idx + 1) (Array.length values) values) 212 | 213 | 214 | {-| Swaps two entries, unless either of the indices is out of bounds. 215 | 216 | fromList [ "a", "d", "c", "b" ] 217 | |> swap 1 3 218 | |> toKeyedList 219 | --> [ ( "0", "a" ) 220 | --> , ( "3", "b" ) 221 | --> , ( "2", "c" ) 222 | --> , ( "1", "d" ) 223 | --> ] 224 | 225 | 226 | fromList [ "hi", "there" ] 227 | |> swap 0 2 228 | |> toList 229 | --> [ "hi", "there" ] 230 | 231 | -} 232 | swap : Int -> Int -> Reorderable a -> Reorderable a 233 | swap a b (Reorderable nextKey values) = 234 | Maybe.map2 235 | (\first second -> 236 | values 237 | |> Array.set a second 238 | |> Array.set b first 239 | ) 240 | (Array.get a values) 241 | (Array.get b values) 242 | |> Maybe.withDefault values 243 | |> Reorderable nextKey 244 | 245 | 246 | {-| Convenience function to move an item "up", i.e. "back". 247 | 248 | fromList [ "a", "c", "b" ] 249 | |> moveUp 2 250 | |> toKeyedList 251 | --> [ ( "0", "a" ) 252 | --> , ( "2", "b" ) 253 | --> , ( "1", "c" ) 254 | --> ] 255 | 256 | -} 257 | moveUp : Int -> Reorderable a -> Reorderable a 258 | moveUp idx = 259 | swap (idx - 1) idx 260 | 261 | 262 | {-| Convenience function to move an item "down", i.e. "forward". 263 | 264 | fromList [ "a", "c", "b" ] 265 | |> moveDown 1 266 | |> toKeyedList 267 | --> [ ( "0", "a" ) 268 | --> , ( "2", "b" ) 269 | --> , ( "1", "c" ) 270 | --> ] 271 | 272 | -} 273 | moveDown : Int -> Reorderable a -> Reorderable a 274 | moveDown idx = 275 | swap idx (idx + 1) 276 | 277 | 278 | {-| Move an item from one location to another location. 279 | 280 | fromList [ "b", "c", "a" ] 281 | |> move 2 0 282 | |> toList 283 | --> [ "a", "b", "c" ] 284 | 285 | fromList [ "a", "c", "b"] 286 | |> move 2 1 287 | |> toList 288 | --> [ "a", "b", "c" ] 289 | 290 | -} 291 | move : Int -> Int -> Reorderable a -> Reorderable a 292 | move from to ((Reorderable nextKey values) as original) = 293 | case Array.get from values of 294 | Nothing -> 295 | original 296 | 297 | Just value -> 298 | let 299 | firstHole = 300 | if from < to then 301 | 1 302 | 303 | else 304 | 0 305 | 306 | secondHole = 307 | if from > to then 308 | 1 309 | 310 | else 311 | 0 312 | 313 | before = 314 | Array.slice 0 (min from to) values 315 | 316 | between = 317 | Array.slice 318 | (min from to + firstHole) 319 | (max from to + firstHole) 320 | values 321 | 322 | after = 323 | Array.slice 324 | (max from to + firstHole + secondHole) 325 | (Array.length values) 326 | values 327 | in 328 | Reorderable nextKey <| 329 | case compare from to of 330 | LT -> 331 | Array.append before between 332 | |> Array.push value 333 | |> (\x -> Array.append x after) 334 | 335 | EQ -> 336 | values 337 | 338 | GT -> 339 | Array.push value before 340 | |> (\x -> Array.append x between) 341 | |> (\x -> Array.append x after) 342 | 343 | 344 | {-| Reverse the order of the entries. 345 | 346 | fromList [ "first", "second", "third" ] 347 | |> reverse 348 | |> toKeyedList 349 | --> [ ( "2", "third" ) 350 | --> , ( "1", "second" ) 351 | --> , ( "0", "first" ) 352 | --> ] 353 | 354 | -} 355 | reverse : Reorderable a -> Reorderable a 356 | reverse (Reorderable nextKey values) = 357 | Reorderable nextKey (arrayReverse values) 358 | 359 | 360 | arrayReverse : Array a -> Array a 361 | arrayReverse = 362 | Array.toList >> List.reverse >> Array.fromList 363 | 364 | 365 | {-| Run an update-function on a specified item. 366 | 367 | fromList [ "UPPER", "lower", "UPPER"] 368 | |> update 1 String.toUpper 369 | |> toKeyedList 370 | --> [ ( "0", "UPPER" ) 371 | --> , ( "1", "LOWER" ) 372 | --> , ( "2", "UPPER" ) 373 | --> ] 374 | 375 | -} 376 | update : Int -> (a -> a) -> Reorderable a -> Reorderable a 377 | update idx f (Reorderable nextKey values) = 378 | Reorderable nextKey <| 379 | case Array.get idx values of 380 | Just ( key, val ) -> 381 | Array.set idx ( key, f val ) values 382 | 383 | Nothing -> 384 | values 385 | 386 | 387 | {-| Try to get the item at a specified index. 388 | 389 | fromList [ "a", "b", "c" ] 390 | |> get 1 391 | --> Just "b" 392 | 393 | -} 394 | get : Int -> Reorderable a -> Maybe a 395 | get idx (Reorderable _ values) = 396 | Array.get idx values |> Maybe.map Tuple.second 397 | 398 | 399 | {-| Set the value at a specified index (maintaining the key). Basically 400 | shorthand for `update index (always val) reorderable`. 401 | 402 | If the specified index does not exist, this does nothing. 403 | 404 | -} 405 | set : Int -> a -> Reorderable a -> Reorderable a 406 | set index val = 407 | update index (always val) 408 | 409 | 410 | {-| Convert a `Reorderable a` to a plain old `List a`. 411 | 412 | fromList [ "a", "b", "c" ] 413 | |> toList 414 | --> [ "a", "b", "c" ] 415 | 416 | -} 417 | toList : Reorderable a -> List a 418 | toList (Reorderable _ values) = 419 | Array.foldr (\( _, val ) acc -> val :: acc) [] values 420 | 421 | 422 | {-| Convert a `Reorderable a` to a `List (String, a)`, useful for eventually 423 | rendering a `Html.Keyed` node. 424 | 425 | fromList [ "a", "b", "c" ] 426 | |> toKeyedList 427 | --> [ ( "0", "a" ) 428 | --> , ( "1", "b" ) 429 | --> , ( "2", "c" ) 430 | --> ] 431 | 432 | This retains the key during swap/insertAt/drop/move\*/.. operations, so that your 433 | `Html.Keyed` node can work correctly. 434 | 435 | -} 436 | toKeyedList : Reorderable a -> List ( String, a ) 437 | toKeyedList (Reorderable _ values) = 438 | Array.foldr (\( key, val ) acc -> ( String.fromInt key, val ) :: acc) [] values 439 | 440 | 441 | {-| Initialize a `Reorderable a` from a `List a`. Useful for initializing data 442 | and decoding with `Json.Decode.map Reorderable.fromList`. 443 | -} 444 | fromList : List a -> Reorderable a 445 | fromList values = 446 | List.foldl push empty values 447 | 448 | 449 | {-| Transform the values in a reorderable 450 | -} 451 | map : (a -> b) -> Reorderable a -> Reorderable b 452 | map f (Reorderable nextKey values) = 453 | Reorderable nextKey (Array.map (Tuple.mapSecond f) values) 454 | 455 | 456 | {-| Transform the values in a reorderable with their index 457 | -} 458 | indexedMap : (Int -> a -> b) -> Reorderable a -> Reorderable b 459 | indexedMap f (Reorderable nextKey values) = 460 | Reorderable nextKey (Array.indexedMap (\idx ( x, val ) -> ( x, f idx val )) values) 461 | --------------------------------------------------------------------------------