HTML のドラッグ & ドロップ API を使って、SAFE Stack の ToDo アプリで項目の並べ替えをできるようにした。 変更したファイルは src/Client/Index.fs 、 src/Server/Server.fs 、 src/Shared/Shared.fs の 3 つだった。

F# で書いたコードから Fable を使って HTML の API を呼び出せることを確認できた。 JavaScript でお馴染の on… (…のところはイベント名) のようなイベントハンドラに F# の関数が渡せるのは分っていたけど、自分の書いたコードで実際に動いてくれると更に感動する。 また、アクティブパターンの利用例によくある正規表現のパターンマッチも使えて、思っていた以上に F# そのものがクライアントのコードで動いてくれた。 Fable 、とても有り難い。

ソースコードをこのページに載せる。差分は SAFE Stack のプロジェクトを作成したときのデフォルトのサンプルとの比較になっている。

一覧

差分

src/Client/Index.fs の差分

diff --git a/src/Client/Index.fs b/src/Client/Index.fs
--- a/src/Client/Index.fs
+++ b/src/Client/Index.fs
@@ -2,15 +2,34 @@ module Index
 
 open Elmish
 open Fable.Remoting.Client
+open Browser.Types
 open Shared
 
+open System.Text.RegularExpressions
+let (|RE|_|) re str =
+   let m = Regex.Match(str, re)
+   if m.Success
+   then Some (List.tail [ for x in m.Groups -> x.Value ])
+   else None
-type Model = { Todos: Todo list; Input: string }
 
 type Msg =
     | GotTodos of Todo list
     | SetInput of string
     | AddTodo
     | AddedTodo of Todo
+    | MoveTodo  of string * string
+    | MovedTodo of Todo List
+    | DragStart of string
+    | DragOver  of string
+    | DragEnter of string
+    | DragLeave of string
+
+type Model = {
+    Todos: Todo list;
+    Input: string;
+    DragFrom: string option;
+    DragOn:   string option;
+}
 
 let todosApi =
     Remoting.createApi ()
@@ -18,13 +37,49 @@ let todosApi =
     |> Remoting.buildProxy<ITodosApi>
 
 let init () : Model * Cmd<Msg> =
-    let model = { Todos = []; Input = "" }
+    let model = {
+        Todos = [];
+        Input = "";
+        DragFrom = None;
+        DragOn   = None;
+    }
 
     let cmd =
         Cmd.OfAsync.perform todosApi.getTodos () GotTodos
 
     model, cmd
 
+let todo_idx2label (idx: int) =
+    "todo:" + (string idx)
+
+let todo_label2idxOpt (label: string) =
+    let elems = label.Split [|':'|]
+    if Array.length elems = 2 then
+        let idxStr = Array.get elems 1
+        match idxStr with
+        | RE @"^\d+$" [] -> Some (int idxStr)
+        | _ -> None
+    else
+        None
+
+let dropTarget_pos2label (pos: MoveDstPosition) =
+    let posStr =
+        match pos with
+        | InFrontOf idx -> string idx
+        | Last          -> "last"
+    "dropTarget:" + posStr
+
+let dropTarget_label2posOpt (label: string) =
+    let elems = label.Split [|':'|]
+    if Array.length elems = 2 then
+        let posStr = Array.get elems 1
+        match posStr with
+        | "last"         -> Some Last
+        | RE @"^\d+$" [] -> Some (InFrontOf (int posStr))
+        | _ -> None
+    else
+        None
+
 let update (msg: Msg) (model: Model) : Model * Cmd<Msg> =
     match msg with
     | GotTodos todos -> { model with Todos = todos }, Cmd.none
@@ -40,6 +95,46 @@ let update (msg: Msg) (model: Model) : Model * Cmd<Msg> =
         { model with
               Todos = model.Todos @ [ todo ] },
         Cmd.none
+    | MoveTodo (srcLabel, dstLabel) ->
+        // ToDo: implement: more gentle error handling
+        let cmd =
+            if Some srcLabel <> model.DragFrom then
+                // error: mismatched source labels
+                Cmd.none
+            else
+                let srcIdxOpt = todo_label2idxOpt       srcLabel
+                let dstPosOpt = dropTarget_label2posOpt dstLabel
+                match (srcIdxOpt, dstPosOpt) with
+                | (None, _) -> Cmd.none     // error: bad source lable
+                | (_, None) -> Cmd.none     // error: bad destination label
+                | (Some s, Some d) -> Cmd.OfAsync.perform todosApi.moveTodo (s, d) MovedTodo
+        { model with
+              DragFrom = None;
+              DragOn   = None;
+        },
+        cmd
+    | MovedTodo todos ->
+        { model with
+              Todos = todos; },
+        Cmd.none
+    | DragStart label ->
+        { model with
+              DragFrom = Some label;
+        },
+        Cmd.none
+    | DragOver label ->
+        model,
+        Cmd.none
+    | DragEnter label ->
+        { model with
+              DragOn = Some label;
+        },
+        Cmd.none
+    | DragLeave label ->
+        { model with
+              DragOn = None;
+        },
+        Cmd.none
 
 open Feliz
 open Feliz.Bulma
@@ -58,12 +153,74 @@ let navBrand =
         ]
     ]
 
+let evtHdlr_dragStart (dispatch: Msg -> unit) (label: string) (evt: DragEvent) =
+    evt.dataTransfer.setData ("text/plain", label) |> ignore
+    evt.dataTransfer.effectAllowed <- "move"
+    DragStart label |> dispatch
+
+let evtHdlr_dragOver (dispatch: Msg -> unit) (label: string) (evt: DragEvent) =
+    evt.preventDefault ()
+    evt.dataTransfer.dropEffect <- "move"
+    DragOver label |> dispatch
+
+let evtHdlr_dragEnter (dispatch: Msg -> unit) (label: string) (evt: DragEvent) =
+    evt.preventDefault ()
+    evt.dataTransfer.dropEffect <- "move"
+    DragEnter label |> dispatch
+
+let evtHdlr_dragLeave (dispatch: Msg -> unit) (label: string) (evt: DragEvent) =
+    DragLeave label |> dispatch
+
+let evtHdlr_drop (dispatch: Msg -> unit) (label: string) (evt: DragEvent) =
+    let srcLabel = evt.dataTransfer.getData ("text/plain")
+    evt.preventDefault ()
+    MoveTodo (srcLabel, label) |> dispatch
+
+let htmlLi_todoItem (model: Model) (dispatch: Msg -> unit) (todo: Todo) (idx: int) =
+    let label = todo_idx2label idx
+    let text = (idx + 1 |> string) + ". " + todo.Description
+    Html.li [
+        prop.id label
+        prop.draggable true
+        prop.onDragStart <| evtHdlr_dragStart dispatch label
+        prop.style [
+            style.listStyleType.none
+            style.margin 0
+        ]
+        prop.text text
+    ]
+
+let htmlLi_dropTarget (model: Model) (dispatch: Msg -> unit) (pos: MoveDstPosition) =
+    let label = dropTarget_pos2label pos
+    let style =
+        let basic_style = [
+                style.listStyleType.none
+                style.margin 0
+                style.padding (length.em 0.5)
+            ]
+        if model.DragOn = Some label then
+            style.backgroundColor.pink :: basic_style
+        else
+            basic_style
+    Html.li [
+        prop.id label
+        prop.onDragOver  <| evtHdlr_dragOver  dispatch label
+        prop.onDragEnter <| evtHdlr_dragEnter dispatch label
+        prop.onDragLeave <| evtHdlr_dragLeave dispatch label
+        prop.onDrop      <| evtHdlr_drop      dispatch label
+        prop.style style
+        prop.text ""
+    ]
+
 let containerBox (model: Model) (dispatch: Msg -> unit) =
+    let todoIdxs = seq { 0 .. model.Todos.Length - 1 } |> List.ofSeq
     Bulma.box [
         Bulma.content [
             Html.ol [
-                for todo in model.Todos do
-                    Html.li [ prop.text todo.Description ]
+                for todo, idx in List.zip model.Todos todoIdxs do
+                    htmlLi_dropTarget model dispatch (InFrontOf idx)
+                    htmlLi_todoItem   model dispatch todo idx
+                htmlLi_dropTarget model dispatch Last
             ]
         ]
         Bulma.field.div [
@@ -76,6 +233,7 @@ let containerBox (model: Model) (dispatch: Msg -> unit) =
                             prop.value model.Input
                             prop.placeholder "What needs to be done?"
                             prop.onChange (fun x -> SetInput x |> dispatch)
+                            prop.onKeyUp (key.enter, fun _ -> dispatch AddTodo)
                         ]
                     ]
                 ]

src/Server/Server.fs の差分

diff --git a/src/Server/Server.fs b/src/Server/Server.fs
--- a/src/Server/Server.fs
+++ b/src/Server/Server.fs
@@ -7,6 +7,7 @@ open Saturn
 open Shared
 
 type Storage() =
+    // ResizeArray is System.Collections.Generic.List
     let todos = ResizeArray<_>()
 
     member __.GetTodos() = List.ofSeq todos
@@ -18,6 +19,19 @@ type Storage() =
         else
             Error "Invalid todo"
 
+    // // List version
+    // member __.MoveTodo (srcIdx: int) (dstPos: MoveDstPosition) =
+    //     let todosList = List.ofSeq todos
+    //     let newTodoList = TodoList.move todosList srcIdx dstPos
+    //     todos.Clear()
+    //     todos.AddRange(newTodoList)
+    //     Ok newTodoList
+
+    // ResizeArray version
+    member __.MoveTodo (srcIdx: int) (dstPos: MoveDstPosition) =
+        TodoResizeArray.move todos srcIdx dstPos
+        Ok (List.ofSeq todos)
+
 let storage = Storage()
 
 storage.AddTodo(Todo.create "Create new SAFE project")
@@ -37,6 +51,13 @@ let todosApi =
                   match storage.AddTodo todo with
                   | Ok () -> return todo
                   | Error e -> return failwith e
+              }
+      moveTodo =
+          fun (srcIdx,  dstPos) ->
+              async {
+                  match storage.MoveTodo srcIdx dstPos with
+                  | Ok newTodoList -> return newTodoList
+                  | Error e -> return failwith e
               } }
 
 let webApp =

src/Shared/Shared.fs の差分

diff --git a/src/Shared/Shared.fs b/src/Shared/Shared.fs
--- a/src/Shared/Shared.fs
+++ b/src/Shared/Shared.fs
@@ -4,6 +4,10 @@ open System
 
 type Todo = { Id: Guid; Description: string }
 
+type MoveDstPosition =
+    | InFrontOf of int
+    | Last
+
 module Todo =
     let isValid (description: string) =
         String.IsNullOrWhiteSpace description |> not
@@ -12,10 +16,68 @@ module Todo =
         { Id = Guid.NewGuid()
           Description = description }
 
+module TodoList =
+    let move (todos: Todo list) (srcIdx: int) (dstPos: MoveDstPosition) =
+        let length = List.length todos
+        let lastIdx = length - 1
+        let todoSrc = List.item srcIdx todos
+        let todosSub1 = todos.[0 .. (srcIdx - 1)]
+        let todosSub2 = todos.[(srcIdx + 1) .. lastIdx]
+        match dstPos with
+        | Last -> todosSub1 @ todosSub2 @ [todoSrc]
+        | InFrontOf dstIdx ->
+            if dstIdx = srcIdx then
+                todos
+            else if dstIdx < srcIdx then
+                let splitIdx = dstIdx
+                let (todosSub1a, todosSub1b) = List.splitAt splitIdx todosSub1
+                todosSub1a @ [todoSrc] @ todosSub1b @ todosSub2
+            else // dstIdx > srcIdx
+                let splitIdx = dstIdx - (srcIdx + 1)
+                let (todosSub2a, todosSub2b) = List.splitAt splitIdx todosSub2
+                todosSub1 @ todosSub2a @ [todoSrc] @ todosSub2b
+
+module TodoResizeArray =
+    let move (todos: ResizeArray<Todo>) (srcIdx: int) (dstPos: MoveDstPosition) =
+        let todoSrc   = todos.[srcIdx]
+        let todosSub1 = todos.GetRange(0, srcIdx)
+        let todosSub2 = todos.GetRange(srcIdx + 1, todos.Count - (srcIdx + 1))
+        match dstPos with
+        | Last ->
+            todos.Clear ()
+            todos.AddRange todosSub1
+            todos.AddRange todosSub2
+            todos.AddRange [todoSrc]
+            ()
+        | InFrontOf dstIdx ->
+            if dstIdx = srcIdx then
+                ()
+            else if dstIdx < srcIdx then
+                let splitIdx = dstIdx
+                let todosSub1a = todosSub1.GetRange(0, splitIdx)
+                let todosSub1b = todosSub1.GetRange(splitIdx, todosSub1.Count - splitIdx)
+                todos.Clear ()
+                todos.AddRange todosSub1a
+                todos.AddRange [todoSrc]
+                todos.AddRange todosSub1b
+                todos.AddRange todosSub2
+                ()
+            else // dstIdx > srcIdx
+                let splitIdx = dstIdx - (srcIdx + 1)
+                let todosSub2a = todosSub2.GetRange(0, splitIdx)
+                let todosSub2b = todosSub2.GetRange(splitIdx, todosSub2.Count - splitIdx)
+                todos.Clear ()
+                todos.AddRange todosSub1
+                todos.AddRange todosSub2a
+                todos.AddRange [todoSrc]
+                todos.AddRange todosSub2b
+                ()
+
 module Route =
     let builder typeName methodName =
         sprintf "/api/%s/%s" typeName methodName
 
 type ITodosApi =
     { getTodos: unit -> Async<Todo list>
-      addTodo: Todo -> Async<Todo> }
+      addTodo: Todo -> Async<Todo>
+      moveTodo: int * MoveDstPosition -> Async<Todo List> }

全文

src/Client/Index.fs の全文

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
module Index

open Elmish
open Fable.Remoting.Client
open Browser.Types
open Shared

open System.Text.RegularExpressions
let (|RE|_|) re str =
   let m = Regex.Match(str, re)
   if m.Success
   then Some (List.tail [ for x in m.Groups -> x.Value ])
   else None

type Msg =
    | GotTodos of Todo list
    | SetInput of string
    | AddTodo
    | AddedTodo of Todo
    | MoveTodo  of string * string
    | MovedTodo of Todo List
    | DragStart of string
    | DragOver  of string
    | DragEnter of string
    | DragLeave of string

type Model = {
    Todos: Todo list;
    Input: string;
    DragFrom: string option;
    DragOn:   string option;
}

let todosApi =
    Remoting.createApi ()
    |> Remoting.withRouteBuilder Route.builder
    |> Remoting.buildProxy<ITodosApi>

let init () : Model * Cmd<Msg> =
    let model = {
        Todos = [];
        Input = "";
        DragFrom = None;
        DragOn   = None;
    }

    let cmd =
        Cmd.OfAsync.perform todosApi.getTodos () GotTodos

    model, cmd

let todo_idx2label (idx: int) =
    "todo:" + (string idx)

let todo_label2idxOpt (label: string) =
    let elems = label.Split [|':'|]
    if Array.length elems = 2 then
        let idxStr = Array.get elems 1
        match idxStr with
        | RE @"^\d+$" [] -> Some (int idxStr)
        | _ -> None
    else
        None

let dropTarget_pos2label (pos: MoveDstPosition) =
    let posStr =
        match pos with
        | InFrontOf idx -> string idx
        | Last          -> "last"
    "dropTarget:" + posStr

let dropTarget_label2posOpt (label: string) =
    let elems = label.Split [|':'|]
    if Array.length elems = 2 then
        let posStr = Array.get elems 1
        match posStr with
        | "last"         -> Some Last
        | RE @"^\d+$" [] -> Some (InFrontOf (int posStr))
        | _ -> None
    else
        None

let update (msg: Msg) (model: Model) : Model * Cmd<Msg> =
    match msg with
    | GotTodos todos -> { model with Todos = todos }, Cmd.none
    | SetInput value -> { model with Input = value }, Cmd.none
    | AddTodo ->
        let todo = Todo.create model.Input

        let cmd =
            Cmd.OfAsync.perform todosApi.addTodo todo AddedTodo

        { model with Input = "" }, cmd
    | AddedTodo todo ->
        { model with
              Todos = model.Todos @ [ todo ] },
        Cmd.none
    | MoveTodo (srcLabel, dstLabel) ->
        // ToDo: implement: more gentle error handling
        let cmd =
            if Some srcLabel <> model.DragFrom then
                // error: mismatched source labels
                Cmd.none
            else
                let srcIdxOpt = todo_label2idxOpt       srcLabel
                let dstPosOpt = dropTarget_label2posOpt dstLabel
                match (srcIdxOpt, dstPosOpt) with
                | (None, _) -> Cmd.none     // error: bad source lable
                | (_, None) -> Cmd.none     // error: bad destination label
                | (Some s, Some d) -> Cmd.OfAsync.perform todosApi.moveTodo (s, d) MovedTodo
        { model with
              DragFrom = None;
              DragOn   = None;
        },
        cmd
    | MovedTodo todos ->
        { model with
              Todos = todos; },
        Cmd.none
    | DragStart label ->
        { model with
              DragFrom = Some label;
        },
        Cmd.none
    | DragOver label ->
        model,
        Cmd.none
    | DragEnter label ->
        { model with
              DragOn = Some label;
        },
        Cmd.none
    | DragLeave label ->
        { model with
              DragOn = None;
        },
        Cmd.none

open Feliz
open Feliz.Bulma

let navBrand =
    Bulma.navbarBrand.div [
        Bulma.navbarItem.a [
            prop.href "https://safe-stack.github.io/"
            navbarItem.isActive
            prop.children [
                Html.img [
                    prop.src "/favicon.png"
                    prop.alt "Logo"
                ]
            ]
        ]
    ]

let evtHdlr_dragStart (dispatch: Msg -> unit) (label: string) (evt: DragEvent) =
    evt.dataTransfer.setData ("text/plain", label) |> ignore
    evt.dataTransfer.effectAllowed <- "move"
    DragStart label |> dispatch

let evtHdlr_dragOver (dispatch: Msg -> unit) (label: string) (evt: DragEvent) =
    evt.preventDefault ()
    evt.dataTransfer.dropEffect <- "move"
    DragOver label |> dispatch

let evtHdlr_dragEnter (dispatch: Msg -> unit) (label: string) (evt: DragEvent) =
    evt.preventDefault ()
    evt.dataTransfer.dropEffect <- "move"
    DragEnter label |> dispatch

let evtHdlr_dragLeave (dispatch: Msg -> unit) (label: string) (evt: DragEvent) =
    DragLeave label |> dispatch

let evtHdlr_drop (dispatch: Msg -> unit) (label: string) (evt: DragEvent) =
    let srcLabel = evt.dataTransfer.getData ("text/plain")
    evt.preventDefault ()
    MoveTodo (srcLabel, label) |> dispatch

let htmlLi_todoItem (model: Model) (dispatch: Msg -> unit) (todo: Todo) (idx: int) =
    let label = todo_idx2label idx
    let text = (idx + 1 |> string) + ". " + todo.Description
    Html.li [
        prop.id label
        prop.draggable true
        prop.onDragStart <| evtHdlr_dragStart dispatch label
        prop.style [
            style.listStyleType.none
            style.margin 0
        ]
        prop.text text
    ]

let htmlLi_dropTarget (model: Model) (dispatch: Msg -> unit) (pos: MoveDstPosition) =
    let label = dropTarget_pos2label pos
    let style =
        let basic_style = [
                style.listStyleType.none
                style.margin 0
                style.padding (length.em 0.5)
            ]
        if model.DragOn = Some label then
            style.backgroundColor.pink :: basic_style
        else
            basic_style
    Html.li [
        prop.id label
        prop.onDragOver  <| evtHdlr_dragOver  dispatch label
        prop.onDragEnter <| evtHdlr_dragEnter dispatch label
        prop.onDragLeave <| evtHdlr_dragLeave dispatch label
        prop.onDrop      <| evtHdlr_drop      dispatch label
        prop.style style
        prop.text ""
    ]

let containerBox (model: Model) (dispatch: Msg -> unit) =
    let todoIdxs = seq { 0 .. model.Todos.Length - 1 } |> List.ofSeq
    Bulma.box [
        Bulma.content [
            Html.ol [
                for todo, idx in List.zip model.Todos todoIdxs do
                    htmlLi_dropTarget model dispatch (InFrontOf idx)
                    htmlLi_todoItem   model dispatch todo idx
                htmlLi_dropTarget model dispatch Last
            ]
        ]
        Bulma.field.div [
            field.isGrouped
            prop.children [
                Bulma.control.p [
                    control.isExpanded
                    prop.children [
                        Bulma.input.text [
                            prop.value model.Input
                            prop.placeholder "What needs to be done?"
                            prop.onChange (fun x -> SetInput x |> dispatch)
                            prop.onKeyUp (key.enter, fun _ -> dispatch AddTodo)
                        ]
                    ]
                ]
                Bulma.control.p [
                    Bulma.button.a [
                        color.isPrimary
                        prop.disabled (Todo.isValid model.Input |> not)
                        prop.onClick (fun _ -> dispatch AddTodo)
                        prop.text "Add"
                    ]
                ]
            ]
        ]
    ]

let view (model: Model) (dispatch: Msg -> unit) =
    Bulma.hero [
        hero.isFullHeight
        color.isPrimary
        prop.style [
            style.backgroundSize "cover"
            style.backgroundImageUrl "https://unsplash.it/1200/900?random"
            style.backgroundPosition "no-repeat center center fixed"
        ]
        prop.children [
            Bulma.heroHead [
                Bulma.navbar [
                    Bulma.container [ navBrand ]
                ]
            ]
            Bulma.heroBody [
                Bulma.container [
                    Bulma.column [
                        column.is6
                        column.isOffset3
                        prop.children [
                            Bulma.title [
                                text.hasTextCentered
                                prop.text "todo app"
                            ]
                            containerBox model dispatch
                        ]
                    ]
                ]
            ]
        ]
    ]

src/Server/Server.fs の全文

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
module Server

open Fable.Remoting.Server
open Fable.Remoting.Giraffe
open Saturn

open Shared

type Storage() =
    // ResizeArray is System.Collections.Generic.List
    let todos = ResizeArray<_>()

    member __.GetTodos() = List.ofSeq todos

    member __.AddTodo(todo: Todo) =
        if Todo.isValid todo.Description then
            todos.Add todo
            Ok()
        else
            Error "Invalid todo"

    // // List version
    // member __.MoveTodo (srcIdx: int) (dstPos: MoveDstPosition) =
    //     let todosList = List.ofSeq todos
    //     let newTodoList = TodoList.move todosList srcIdx dstPos
    //     todos.Clear()
    //     todos.AddRange(newTodoList)
    //     Ok newTodoList

    // ResizeArray version
    member __.MoveTodo (srcIdx: int) (dstPos: MoveDstPosition) =
        TodoResizeArray.move todos srcIdx dstPos
        Ok (List.ofSeq todos)

let storage = Storage()

storage.AddTodo(Todo.create "Create new SAFE project")
|> ignore

storage.AddTodo(Todo.create "Write your app")
|> ignore

storage.AddTodo(Todo.create "Ship it !!!")
|> ignore

let todosApi =
    { getTodos = fun () -> async { return storage.GetTodos() }
      addTodo =
          fun todo ->
              async {
                  match storage.AddTodo todo with
                  | Ok () -> return todo
                  | Error e -> return failwith e
              }
      moveTodo =
          fun (srcIdx,  dstPos) ->
              async {
                  match storage.MoveTodo srcIdx dstPos with
                  | Ok newTodoList -> return newTodoList
                  | Error e -> return failwith e
              } }

let webApp =
    Remoting.createApi ()
    |> Remoting.withRouteBuilder Route.builder
    |> Remoting.fromValue todosApi
    |> Remoting.buildHttpHandler

let app =
    application {
        url "http://0.0.0.0:8085"
        use_router webApp
        memory_cache
        use_static "public"
        use_gzip
    }

run app

src/Shared/Shared.fs の全文

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
namespace Shared

open System

type Todo = { Id: Guid; Description: string }

type MoveDstPosition =
    | InFrontOf of int
    | Last

module Todo =
    let isValid (description: string) =
        String.IsNullOrWhiteSpace description |> not

    let create (description: string) =
        { Id = Guid.NewGuid()
          Description = description }

module TodoList =
    let move (todos: Todo list) (srcIdx: int) (dstPos: MoveDstPosition) =
        let length = List.length todos
        let lastIdx = length - 1
        let todoSrc = List.item srcIdx todos
        let todosSub1 = todos.[0 .. (srcIdx - 1)]
        let todosSub2 = todos.[(srcIdx + 1) .. lastIdx]
        match dstPos with
        | Last -> todosSub1 @ todosSub2 @ [todoSrc]
        | InFrontOf dstIdx ->
            if dstIdx = srcIdx then
                todos
            else if dstIdx < srcIdx then
                let splitIdx = dstIdx
                let (todosSub1a, todosSub1b) = List.splitAt splitIdx todosSub1
                todosSub1a @ [todoSrc] @ todosSub1b @ todosSub2
            else // dstIdx > srcIdx
                let splitIdx = dstIdx - (srcIdx + 1)
                let (todosSub2a, todosSub2b) = List.splitAt splitIdx todosSub2
                todosSub1 @ todosSub2a @ [todoSrc] @ todosSub2b

module TodoResizeArray =
    let move (todos: ResizeArray<Todo>) (srcIdx: int) (dstPos: MoveDstPosition) =
        let todoSrc   = todos.[srcIdx]
        let todosSub1 = todos.GetRange(0, srcIdx)
        let todosSub2 = todos.GetRange(srcIdx + 1, todos.Count - (srcIdx + 1))
        match dstPos with
        | Last ->
            todos.Clear ()
            todos.AddRange todosSub1
            todos.AddRange todosSub2
            todos.AddRange [todoSrc]
            ()
        | InFrontOf dstIdx ->
            if dstIdx = srcIdx then
                ()
            else if dstIdx < srcIdx then
                let splitIdx = dstIdx
                let todosSub1a = todosSub1.GetRange(0, splitIdx)
                let todosSub1b = todosSub1.GetRange(splitIdx, todosSub1.Count - splitIdx)
                todos.Clear ()
                todos.AddRange todosSub1a
                todos.AddRange [todoSrc]
                todos.AddRange todosSub1b
                todos.AddRange todosSub2
                ()
            else // dstIdx > srcIdx
                let splitIdx = dstIdx - (srcIdx + 1)
                let todosSub2a = todosSub2.GetRange(0, splitIdx)
                let todosSub2b = todosSub2.GetRange(splitIdx, todosSub2.Count - splitIdx)
                todos.Clear ()
                todos.AddRange todosSub1
                todos.AddRange todosSub2a
                todos.AddRange [todoSrc]
                todos.AddRange todosSub2b
                ()

module Route =
    let builder typeName methodName =
        sprintf "/api/%s/%s" typeName methodName

type ITodosApi =
    { getTodos: unit -> Async<Todo list>
      addTodo: Todo -> Async<Todo>
      moveTodo: int * MoveDstPosition -> Async<Todo List> }