Integrate item merge dialog into home page

This commit is contained in:
eikek 2021-08-15 14:18:33 +02:00
parent 5782166273
commit 22d331f082
9 changed files with 777 additions and 3 deletions

View File

@ -0,0 +1,479 @@
{-
Copyright 2020 Docspell Contributors
SPDX-License-Identifier: GPL-3.0-or-later
-}
module Comp.ItemMerge exposing
( Model
, Msg
, init
, initQuery
, update
, view
)
import Api
import Api.Model.BasicResult exposing (BasicResult)
import Api.Model.ItemLight exposing (ItemLight)
import Api.Model.ItemLightList exposing (ItemLightList)
import Comp.MenuBar as MB
import Data.Direction
import Data.Fields
import Data.Flags exposing (Flags)
import Data.Icons as Icons
import Data.ItemQuery exposing (ItemQuery)
import Data.ItemTemplate as IT
import Data.SearchMode exposing (SearchMode)
import Data.UiSettings exposing (UiSettings)
import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (onClick)
import Html5.DragDrop as DD
import Http
import Messages.Comp.ItemMerge exposing (Texts)
import Styles as S
import Util.CustomField
import Util.Item
import Util.List
type alias Model =
{ items : List ItemLight
, showInfoText : Bool
, dragDrop : DDModel
, formState : FormState
}
init : List ItemLight -> Model
init items =
{ items = items
, showInfoText = False
, dragDrop = DD.init
, formState = FormStateInitial
}
initQuery : Flags -> SearchMode -> ItemQuery -> ( Model, Cmd Msg )
initQuery flags searchMode query =
let
itemQuery =
{ offset = Just 0
, limit = Just 50
, withDetails = Just True
, searchMode = Just (Data.SearchMode.asString searchMode)
, query = Data.ItemQuery.render query
}
in
( init [], Api.itemSearch flags itemQuery ItemResp )
type alias Dropped =
{ sourceIdx : Int
, targetIdx : Int
}
type alias DDModel =
DD.Model Int Int
type alias DDMsg =
DD.Msg Int Int
type FormState
= FormStateInitial
| FormStateHttp Http.Error
| FormStateMergeSuccessful
| FormStateError String
--- Update
type alias UpdateResult =
{ model : Model
, cmd : Cmd Msg
, done : Bool
}
notDoneResult : ( Model, Cmd Msg ) -> UpdateResult
notDoneResult t =
{ model = Tuple.first t
, cmd = Tuple.second t
, done = False
}
type Msg
= ItemResp (Result Http.Error ItemLightList)
| ToggleInfoText
| DragDrop (DD.Msg Int Int)
| SubmitMerge
| CancelMerge
| MergeResp (Result Http.Error BasicResult)
update : Msg -> Model -> UpdateResult
update msg model =
case msg of
ItemResp (Ok list) ->
notDoneResult ( init (flatten list), Cmd.none )
ItemResp (Err err) ->
notDoneResult ( { model | formState = FormStateHttp err }, Cmd.none )
MergeResp (Ok result) ->
if result.success then
{ model = { model | formState = FormStateMergeSuccessful }
, cmd = Cmd.none
, done = True
}
else
{ model = { model | formState = FormStateError result.message }
, cmd = Cmd.none
, done = False
}
MergeResp (Err err) ->
{ model = { model | formState = FormStateHttp err }
, cmd = Cmd.none
, done = False
}
ToggleInfoText ->
notDoneResult
( { model | showInfoText = not model.showInfoText }
, Cmd.none
)
DragDrop lmsg ->
let
( m, res ) =
DD.update lmsg model.dragDrop
dropped =
Maybe.map (\( idx1, idx2, _ ) -> Dropped idx1 idx2) res
model_ =
{ model | dragDrop = m }
in
case dropped of
Just data ->
let
items =
Util.List.changePosition data.sourceIdx data.targetIdx model.items
in
notDoneResult ( { model_ | items = items }, Cmd.none )
Nothing ->
notDoneResult ( model_, Cmd.none )
SubmitMerge ->
notDoneResult ( model, Cmd.none )
CancelMerge ->
{ model = model
, cmd = Cmd.none
, done = True
}
flatten : ItemLightList -> List ItemLight
flatten list =
list.groups |> List.concatMap .items
--- View
view : Texts -> UiSettings -> Model -> Html Msg
view texts settings model =
div [ class "px-2 mb-4" ]
[ h1 [ class S.header1 ]
[ text texts.title
, a
[ class "ml-2"
, class S.link
, href "#"
, onClick ToggleInfoText
]
[ i [ class "fa fa-info-circle" ] []
]
]
, p
[ class S.infoMessage
, classList [ ( "hidden", not model.showInfoText ) ]
]
[ text texts.infoText
]
, p
[ class S.warnMessage
, class "mt-2"
]
[ text texts.deleteWarn
]
, MB.view <|
{ start =
[ MB.PrimaryButton
{ tagger = SubmitMerge
, title = texts.submitMergeTitle
, icon = Just "fa fa-less-than"
, label = texts.submitMerge
}
, MB.SecondaryButton
{ tagger = CancelMerge
, title = texts.cancelMergeTitle
, icon = Just "fa fa-times"
, label = texts.cancelMerge
}
]
, end = []
, rootClasses = "my-4"
}
, renderFormState texts model
, div [ class "flex-col px-2" ]
(List.indexedMap (itemCard texts settings model) model.items)
]
itemCard : Texts -> UiSettings -> Model -> Int -> ItemLight -> Html Msg
itemCard texts settings model index item =
let
previewUrl =
Api.itemBasePreviewURL item.id
fieldHidden f =
Data.UiSettings.fieldHidden settings f
dirIcon =
i
[ class (Data.Direction.iconFromMaybe2 item.direction)
, class "mr-2 w-4 text-center"
, classList [ ( "hidden", fieldHidden Data.Fields.Direction ) ]
, IT.render IT.direction (templateCtx texts) item |> title
]
[]
titlePattern =
settings.cardTitleTemplate.template
subtitlePattern =
settings.cardSubtitleTemplate.template
dropActive =
let
currentDrop =
getDropId model
currentDrag =
getDragId model
in
currentDrop == Just index && currentDrag /= Just index && currentDrag /= Just (index - 1)
in
div
([ classList [ ( "pt-12 mx-2", dropActive ) ]
]
++ droppable DragDrop index
)
[ div
([ class "flex flex-col sm:flex-row rounded"
, class "cursor-pointer items-center"
, classList
[ ( "border-2 border-blue-500 dark:border-blue-500", index == 0 )
, ( "bg-blue-100 dark:bg-lightblue-900", index == 0 )
, ( "border border-gray-400 dark:border-bluegray-600 dark:hover:border-bluegray-500 bg-white dark:bg-bluegray-700 mt-2", index /= 0 )
, ( "bg-yellow-50 dark:bg-lime-900 mt-4", dropActive )
]
, id ("merge-" ++ item.id)
]
++ draggable DragDrop index
)
[ div
[ class "mr-2 sm:rounded-l w-16 bg-white"
, classList [ ( "hidden", fieldHidden Data.Fields.PreviewImage ) ]
]
[ img
[ class "preview-image mx-auto pt-1"
, classList
[ ( "sm:rounded-l", True )
]
, src previewUrl
]
[]
]
, div [ class "flex-grow flex flex-col py-1 px-2" ]
[ div [ class "flex flex-col sm:flex-row items-center" ]
[ div
[ class "font-bold text-lg"
, classList [ ( "hidden", IT.render titlePattern (templateCtx texts) item == "" ) ]
]
[ dirIcon
, IT.render titlePattern (templateCtx texts) item |> text
]
, div
[ classList
[ ( "opacity-75 sm:ml-2", True )
, ( "hidden", IT.render subtitlePattern (templateCtx texts) item == "" )
]
]
[ IT.render subtitlePattern (templateCtx texts) item |> text
]
]
, mainData texts settings item
, mainTagsAndFields2 settings item
]
]
]
mainData : Texts -> UiSettings -> ItemLight -> Html Msg
mainData texts settings item =
let
ctx =
templateCtx texts
corr =
IT.render (Util.Item.corrTemplate settings) ctx item
conc =
IT.render (Util.Item.concTemplate settings) ctx item
in
div [ class "flex flex-row space-x-2" ]
[ div
[ classList
[ ( "hidden", corr == "" )
]
]
[ Icons.correspondentIcon2 "mr-1"
, text corr
]
, div
[ classList
[ ( "hidden", conc == "" )
]
, class "ml-2"
]
[ Icons.concernedIcon2 "mr-1"
, text conc
]
]
mainTagsAndFields2 : UiSettings -> ItemLight -> Html Msg
mainTagsAndFields2 settings item =
let
fieldHidden f =
Data.UiSettings.fieldHidden settings f
hideTags =
item.tags == [] || fieldHidden Data.Fields.Tag
hideFields =
item.customfields == [] || fieldHidden Data.Fields.CustomFields
showTag tag =
div
[ class "label mt-1 font-semibold"
, class (Data.UiSettings.tagColorString2 tag settings)
]
[ i [ class "fa fa-tag mr-2" ] []
, span [] [ text tag.name ]
]
showField fv =
Util.CustomField.renderValue2
[ ( S.basicLabel, True )
, ( "mt-1 font-semibold", True )
]
Nothing
fv
renderFields =
if hideFields then
[]
else
List.sortBy Util.CustomField.nameOrLabel item.customfields
|> List.map showField
renderTags =
if hideTags then
[]
else
List.map showTag item.tags
in
div
[ classList
[ ( "flex flex-row items-center flex-wrap text-xs font-medium my-1 space-x-2", True )
, ( "hidden", hideTags && hideFields )
]
]
(renderFields ++ renderTags)
renderFormState : Texts -> Model -> Html Msg
renderFormState texts model =
case model.formState of
FormStateInitial ->
span [ class "hidden" ] []
FormStateError msg ->
div
[ class S.errorMessage
, class "py-2"
]
[ text msg
]
FormStateHttp err ->
div
[ class S.errorMessage
, class "py-2"
]
[ text (texts.httpError err)
]
FormStateMergeSuccessful ->
div
[ class S.successMessage
, class "py-2"
]
[ text texts.mergeSuccessful
]
templateCtx : Texts -> IT.TemplateContext
templateCtx texts =
{ dateFormatLong = texts.formatDateLong
, dateFormatShort = texts.formatDateShort
, directionLabel = \_ -> ""
}
droppable : (DDMsg -> msg) -> Int -> List (Attribute msg)
droppable tagger dropId =
DD.droppable tagger dropId
draggable : (DDMsg -> msg) -> Int -> List (Attribute msg)
draggable tagger itemId =
DD.draggable tagger itemId
getDropId : Model -> Maybe Int
getDropId model =
DD.getDropId model.dragDrop
getDragId : Model -> Maybe Int
getDragId model =
DD.getDragId model.dragDrop

View File

@ -0,0 +1,68 @@
{-
Copyright 2020 Docspell Contributors
SPDX-License-Identifier: GPL-3.0-or-later
-}
module Messages.Comp.ItemMerge exposing
( Texts
, de
, gb
)
import Http
import Messages.Basics
import Messages.Comp.HttpError
import Messages.DateFormat
import Messages.UiLanguage
type alias Texts =
{ basics : Messages.Basics.Texts
, httpError : Http.Error -> String
, title : String
, infoText : String
, deleteWarn : String
, formatDateLong : Int -> String
, formatDateShort : Int -> String
, submitMerge : String
, cancelMerge : String
, submitMergeTitle : String
, cancelMergeTitle : String
, mergeSuccessful : String
}
gb : Texts
gb =
{ basics = Messages.Basics.gb
, httpError = Messages.Comp.HttpError.gb
, title = "Merge Items"
, infoText = "When merging items the first item in the list acts as the target. Every other items metadata is copied into the target item. If the property is a single value (like correspondent), it is only set if not already present. Tags, custom fields and attachments are added. The items can be reordered using drag&drop."
, deleteWarn = "Note that all items but the first one is deleted after a successful merge!"
, formatDateLong = Messages.DateFormat.formatDateLong Messages.UiLanguage.English
, formatDateShort = Messages.DateFormat.formatDateShort Messages.UiLanguage.English
, submitMerge = "Merge"
, submitMergeTitle = "Merge the documents now"
, cancelMerge = "Cancel"
, cancelMergeTitle = "Back to select view"
, mergeSuccessful = "Items merged successfully"
}
de : Texts
de =
{ basics = Messages.Basics.de
, httpError = Messages.Comp.HttpError.de
, title = "Dokumente zusammenführen"
, infoText = "Beim Zusammenführen der Dokumente, wird das erste in der Liste als Zieldokument verwendet. Die Metadaten der anderen Dokumente werden der Reihe nach auf des Zieldokument geschrieben. Metadaten die nur einen Wert haben, werden nur gesetzt falls noch kein Wert existiert. Tags, Benutzerfelder und Anhänge werden zu dem Zieldokument hinzugefügt. Die Einträge können mit Drag&Drop umgeordnet werden."
, deleteWarn = "Bitte beachte, dass nach erfolgreicher Zusammenführung alle anderen Dokumente gelöscht werden!"
, formatDateLong = Messages.DateFormat.formatDateLong Messages.UiLanguage.German
, formatDateShort = Messages.DateFormat.formatDateShort Messages.UiLanguage.German
, submitMerge = "Zusammenführen"
, submitMergeTitle = "Dokumente jetzt zusammenführen"
, cancelMerge = "Abbrechen"
, cancelMergeTitle = "Zurück zur Auswahl"
, mergeSuccessful = "Die Dokumente wurden erfolgreich zusammengeführt."
}

View File

@ -13,6 +13,7 @@ module Messages.Page.Home exposing
import Messages.Basics
import Messages.Comp.ItemCardList
import Messages.Comp.ItemMerge
import Messages.Comp.SearchStatsView
import Messages.Page.HomeSideMenu
@ -22,6 +23,7 @@ type alias Texts =
, itemCardList : Messages.Comp.ItemCardList.Texts
, searchStatsView : Messages.Comp.SearchStatsView.Texts
, sideMenu : Messages.Page.HomeSideMenu.Texts
, itemMerge : Messages.Comp.ItemMerge.Texts
, contentSearch : String
, searchInNames : String
, selectModeTitle : String
@ -39,6 +41,7 @@ type alias Texts =
, selectNone : String
, resetSearchForm : String
, exitSelectMode : String
, mergeItemsTitle : Int -> String
}
@ -48,6 +51,7 @@ gb =
, itemCardList = Messages.Comp.ItemCardList.gb
, searchStatsView = Messages.Comp.SearchStatsView.gb
, sideMenu = Messages.Page.HomeSideMenu.gb
, itemMerge = Messages.Comp.ItemMerge.gb
, contentSearch = "Content search"
, searchInNames = "Search in names"
, selectModeTitle = "Select Mode"
@ -65,6 +69,7 @@ gb =
, selectNone = "Select none"
, resetSearchForm = "Reset search form"
, exitSelectMode = "Exit Select Mode"
, mergeItemsTitle = \n -> "Merge " ++ String.fromInt n ++ " selected items"
}
@ -74,6 +79,7 @@ de =
, itemCardList = Messages.Comp.ItemCardList.de
, searchStatsView = Messages.Comp.SearchStatsView.de
, sideMenu = Messages.Page.HomeSideMenu.de
, itemMerge = Messages.Comp.ItemMerge.de
, contentSearch = "Volltextsuche"
, searchInNames = "Suche in Namen"
, selectModeTitle = "Auswahlmodus"
@ -91,4 +97,5 @@ de =
, selectNone = "Wähle alle Dokumente ab"
, resetSearchForm = "Suchformular zurücksetzen"
, exitSelectMode = "Auswahlmodus verlassen"
, mergeItemsTitle = \n -> String.fromInt n ++ " gewählte Dokumente zusammenführen"
}

View File

@ -26,12 +26,14 @@ module Page.Home.Data exposing
import Api
import Api.Model.BasicResult exposing (BasicResult)
import Api.Model.ItemLight exposing (ItemLight)
import Api.Model.ItemLightList exposing (ItemLightList)
import Api.Model.SearchStats exposing (SearchStats)
import Browser.Dom as Dom
import Comp.ItemCardList
import Comp.ItemDetail.FormChange exposing (FormChange)
import Comp.ItemDetail.MultiEditMenu exposing (SaveNameState(..))
import Comp.ItemMerge
import Comp.LinkTarget exposing (LinkTarget)
import Comp.PowerSearchInput
import Comp.SearchMenu
@ -76,6 +78,7 @@ type alias SelectViewModel =
, action : SelectActionMode
, confirmModal : Maybe ConfirmModalValue
, editModel : Comp.ItemDetail.MultiEditMenu.Model
, mergeModel : Comp.ItemMerge.Model
, saveNameState : SaveNameState
, saveCustomFieldState : Set String
}
@ -87,6 +90,7 @@ initSelectViewModel =
, action = NoneAction
, confirmModal = Nothing
, editModel = Comp.ItemDetail.MultiEditMenu.init
, mergeModel = Comp.ItemMerge.init []
, saveNameState = SaveSuccess
, saveCustomFieldState = Set.empty
}
@ -205,6 +209,8 @@ type Msg
| ReprocessSelectedConfirmed
| ClientSettingsSaveResp UiSettings (Result Http.Error BasicResult)
| RemoveItem String
| MergeSelectedItems
| MergeItemsMsg Comp.ItemMerge.Msg
type SearchType
@ -218,6 +224,7 @@ type SelectActionMode
| EditSelected
| ReprocessSelected
| RestoreSelected
| MergeSelected
type alias SearchParam =

View File

@ -16,6 +16,7 @@ import Browser.Navigation as Nav
import Comp.ItemCardList
import Comp.ItemDetail.FormChange exposing (FormChange(..))
import Comp.ItemDetail.MultiEditMenu exposing (SaveNameState(..))
import Comp.ItemMerge
import Comp.LinkTarget exposing (LinkTarget)
import Comp.PowerSearchInput
import Comp.SearchMenu
@ -361,6 +362,7 @@ update mId key flags settings msg model =
_ ->
noSub ( model, Cmd.none )
RestoreSelectedConfirmed ->
case model.viewMode of
SelectView svm ->
@ -383,7 +385,6 @@ update mId key flags settings msg model =
_ ->
noSub ( model, Cmd.none )
DeleteAllResp (Ok res) ->
if res.success then
let
@ -535,6 +536,70 @@ update mId key flags settings msg model =
_ ->
noSub ( model, Cmd.none )
MergeSelectedItems ->
case model.viewMode of
SelectView svm ->
if svm.action == MergeSelected then
noSub
( { model
| viewMode =
SelectView
{ svm
| action = NoneAction
, mergeModel = Comp.ItemMerge.init []
}
}
, Cmd.none
)
else if svm.ids == Set.empty then
noSub ( model, Cmd.none )
else
let
( mm, mc ) =
Comp.ItemMerge.initQuery
flags
model.searchMenuModel.searchMode
(Q.ItemIdIn (Set.toList svm.ids))
in
noSub
( { model
| viewMode =
SelectView
{ svm
| action = MergeSelected
, mergeModel = mm
}
}
, Cmd.map MergeItemsMsg mc
)
_ ->
noSub ( model, Cmd.none )
MergeItemsMsg lmsg ->
case model.viewMode of
SelectView svm ->
let
result =
Comp.ItemMerge.update lmsg svm.mergeModel
nextView =
if result.done then
SelectView { svm | action = NoneAction }
else
SelectView { svm | mergeModel = result.model }
in
noSub
( { model | viewMode = nextView }
, Cmd.map MergeItemsMsg result.cmd
)
_ ->
noSub ( model, Cmd.none )
EditMenuMsg lmsg ->
case model.viewMode of
SelectView svm ->

View File

@ -10,6 +10,7 @@ module Page.Home.View2 exposing (viewContent, viewSidebar)
import Comp.Basic as B
import Comp.ConfirmModal
import Comp.ItemCardList
import Comp.ItemMerge
import Comp.MenuBar as MB
import Comp.PowerSearchInput
import Comp.SearchMenu
@ -50,7 +51,11 @@ viewContent texts flags settings model =
]
(searchStats texts flags settings model
++ itemsBar texts flags settings model
++ itemCardList texts flags settings model
++ [ div [ class "relative" ]
(itemMergeView texts settings model
++ itemCardList texts flags settings model
)
]
++ confirmModal texts model
)
@ -59,6 +64,27 @@ viewContent texts flags settings model =
--- Helpers
itemMergeView : Texts -> UiSettings -> Model -> List (Html Msg)
itemMergeView texts settings model =
case model.viewMode of
SelectView svm ->
case svm.action of
MergeSelected ->
[ div
[ class S.dimmerMerge
]
[ Html.map MergeItemsMsg
(Comp.ItemMerge.view texts.itemMerge settings svm.mergeModel)
]
]
_ ->
[]
_ ->
[]
confirmModal : Texts -> Model -> List (Html Msg)
confirmModal texts model =
let
@ -251,6 +277,7 @@ editMenuBar texts model svm =
, inputClass =
[ ( btnStyle, True )
, ( "bg-gray-200 dark:bg-bluegray-600", svm.action == EditSelected )
, ( "hidden", model.searchMenuModel.searchMode == Data.SearchMode.Trashed )
]
}
, MB.CustomButton
@ -261,6 +288,7 @@ editMenuBar texts model svm =
, inputClass =
[ ( btnStyle, True )
, ( "bg-gray-200 dark:bg-bluegray-600", svm.action == ReprocessSelected )
, ( "hidden", model.searchMenuModel.searchMode == Data.SearchMode.Trashed )
]
}
, MB.CustomButton
@ -285,6 +313,17 @@ editMenuBar texts model svm =
, ( "hidden", model.searchMenuModel.searchMode == Data.SearchMode.Normal )
]
}
, MB.CustomButton
{ tagger = MergeSelectedItems
, label = ""
, icon = Just "fa fa-less-than"
, title = texts.mergeItemsTitle selectCount
, inputClass =
[ ( btnStyle, True )
, ( "bg-gray-200 dark:bg-bluegray-600", svm.action == MergeSelected )
, ( "hidden", model.searchMenuModel.searchMode == Data.SearchMode.Trashed )
]
}
]
, end =
[ MB.CustomButton

View File

@ -343,6 +343,11 @@ dimmerCard =
" absolute top-0 left-0 w-full h-full bg-black bg-opacity-60 dark:bg-lightblue-900 dark:bg-opacity-60 z-30 flex flex-col items-center justify-center px-4 py-2 "
dimmerMerge : String
dimmerMerge =
" absolute top-0 left-0 w-full h-full bg-white bg-opacity-100 dark:bg-bluegray-800 dark:bg-opacity-100 z-40 flex flex-col"
tableMain : String
tableMain =
"border-collapse table w-full"

View File

@ -0,0 +1,62 @@
{-
Copyright 2020 Docspell Contributors
SPDX-License-Identifier: GPL-3.0-or-later
-}
module Util.Item exposing
( concTemplate
, corrTemplate
)
import Api.Model.ItemLight exposing (ItemLight)
import Data.Fields
import Data.ItemTemplate as IT exposing (ItemTemplate)
import Data.UiSettings exposing (UiSettings)
corrTemplate : UiSettings -> ItemTemplate
corrTemplate settings =
let
fieldHidden f =
Data.UiSettings.fieldHidden settings f
hiddenTuple =
( fieldHidden Data.Fields.CorrOrg, fieldHidden Data.Fields.CorrPerson )
in
case hiddenTuple of
( True, True ) ->
IT.empty
( True, False ) ->
IT.corrPerson
( False, True ) ->
IT.corrOrg
( False, False ) ->
IT.correspondent
concTemplate : UiSettings -> ItemTemplate
concTemplate settings =
let
fieldHidden f =
Data.UiSettings.fieldHidden settings f
hiddenTuple =
( fieldHidden Data.Fields.ConcPerson, fieldHidden Data.Fields.ConcEquip )
in
case hiddenTuple of
( True, True ) ->
IT.empty
( True, False ) ->
IT.concEquip
( False, True ) ->
IT.concPerson
( False, False ) ->
IT.concerning

View File

@ -6,7 +6,8 @@
module Util.List exposing
( distinct
( changePosition
, distinct
, dropRight
, find
, findIndexed
@ -16,6 +17,47 @@ module Util.List exposing
, sliding
)
import Html.Attributes exposing (list)
changePosition : Int -> Int -> List a -> List a
changePosition source target list =
let
len =
List.length list
noChange =
source == target || source + 1 == target
outOfBounds n =
n < 0 || n >= len
concat el acc =
let
idx =
Tuple.first el
ela =
Tuple.second el
in
if idx == source then
( target, ela ) :: acc
else if idx >= target then
( idx + 1, ela ) :: acc
else
( idx, ela ) :: acc
in
if noChange || outOfBounds source || outOfBounds target then
list
else
List.indexedMap Tuple.pair list
|> List.foldl concat []
|> List.sortBy Tuple.first
|> List.map Tuple.second
get : List a -> Int -> Maybe a
get list index =