My First F# Application Part 3
In this 3rd part in this series, I describe my development of a F# UI App with FuncUI, to track my time spent on work for customers. You will find the code on gitub. If you haven’t already done so, I suggest you read part1 and part 2 of this blog series.
Introducing F# UI App with FuncUI
An application that uses FuncUI is a MVU architecture based application using the Avalonia UI application. The Model – View – Update architecture is designed to to maximize the use of functional programming techniques. After coming from the MVVM for some time in C#, this took me a while to figure out. There are some examples around in my search specifically for Funcui. These were of great help. However I hope this is application will provide a somewhat more complex example, and one more like a real – world application.
The UI DSL
The most notable change to working with MVU, is the definition of the UI for a DSL (Domain Specific Language). The way the architecture works is by defining the UI based on the current state of a model (the UI state model) with framework applying the differences from the previous UI DSL definition to the window or user control. There are plenty of tutorials in general out there on MVU or ‘Elm Architecture’ or ‘Elmish’ that you can easily find.
The Time Tracker Application Structure
I have the domain models and database layers in a class library as described in the previous parts of this blog series. The UI is simply a window with Tab control. One tab for the data entry, one for reports, one for the ‘About’ information. I may also have a settings tab page. The data entry page is a side-by-side master-detail-detail. Each customer can have work items. Each work item will have time entries. There are splitters in between each list. When a customer is selected, the list of work items is loaded. When a work item is selected, the time entries are loaded. In each of the lists I have paging controls. Let’s look at a picture of the current state as it stands. Not the entries within are completely figments of my imagination.
The Entry Page Model
As in all MVU applications we start with the state model (The ‘M’ in ‘MVU’). As declared in “EntryPage.fs”, let’s take a look at it. What it is the data behind the entry page tab., i.e. it holds the state information from the page. As is often the case I have named the model ‘State’. It is surprising that behind what looks like a UI with a fair amount of detail, how fairly simple the state model can look.
type Paging =
{ PageNo: int
ItemsPerPage: int
TotalPages: int
TotalCount: int64
EnableGoButtons: bool }
static member Empty() =
{ PageNo = 1
ItemsPerPage = 30
TotalPages = 1
TotalCount = 0L
EnableGoButtons = false }
member this.CanGoFirst = this.EnableGoButtons && this.PageNo > 1
member this.CanGoPrevious = this.EnableGoButtons && this.PageNo > 1
member this.CanGoLast =
this.EnableGoButtons
&& this.PageNo < this.TotalPages
member this.CanGoNext =
this.EnableGoButtons
&& this.PageNo < this.TotalPages
type State =
{ Customers: Customer list
CustomerPaging: Paging
IncludeInactiveCustomers: bool
SelectedCustomerId: CustomerId option
WorkItems: WorkItem list
WorkItemPaging: Paging
IncludeCompletedWorkItems: bool
SelectedWorkItemId: WorkItemId option
TimeEntries: TimeEntry list
TimeEntryPaging: Paging
SelectedTimeEntryId: TimeEntryId option }
The Message Type
The other component of MVU style application is the ‘Message’. If the state is the ui data definition, the message is the data operations definition. A parameter of this type is the input to our ‘Update’ function (the ‘U’ in ‘MVU’). Our message describes everything that occurs with, to or on our data. It is generally allwas a discriminated union type. This is what ours looks like here.
type PagingButton =
| First
| Previous
| Next
| Last
type Msg =
| RequestLoadCustomers
| LoadCustomersDone of Result<ListResults<Customer>, exn>
| SelectCustomer of CustomerId option
| RequestLoadWorkItemsForSelectedCustomerId
| LoadWorkItemsDone of Result<ListResults<WorkItem>, exn>
| SelectWorkItem of WorkItemId option
| RequestLoadTimeEntriesForSelectedWorkItemId
| LoadTimeEntriesDone of Result<ListResults<TimeEntry>, exn>
| SelectTimeEntry of TimeEntryId option
| SelectTimeEntryIndex of int
| ShowErrorMessage of string
| AddCustomer
| ReviewSelectedCustomer
| ReviewCustomer of customerId: CustomerId
| CustomerDialogClosed of Result<DialogResult, string>
| AddWorkItem
| ReviewSelectedWorkItem
| ReviewWorkItem of workItemId: WorkItemId
| WorkItemDialogClosed of Result<DialogResult, string>
| AddTimeEntry
| ReviewSelectedTimeEntry
| ReviewTimeEntry of timeEntryId: TimeEntryId
| TimeEntryDialogClosed of Result<DialogResult, string>
| NothingMsg
| IncludeInactiveCustomers of bool
| IncludeCompletedWorkitems of bool
| CustomerItemsPerPageChange of int
| CustomerListCurrentPageChange of int
| WorkItemItemsPerPageChange of int
| WorkItemListCurrentPageChange of int
| TimeEntryItemsPerPageChange of int
| TimeEntryListCurrentPageChange of int
| CustomerPagingButtonClicked of PagingButton
| WorkItemPagingButtonClicked of PagingButton
| TimeEntryPagingButtonClicked of PagingButton
| EnableCustomerPaging of bool
| EnableWorkItemPaging of bool
| EnableTimeEntryPaging of bool
| ClearTimeEntries
| ClearWorkItems
The Update function
The update function takes the current state data, the message, processes the message and produces the resulting state, or a tuple of the resulting stage and another message action (a command). By looking at this function and the definition of the message type you can easily follow the program file. That is especially so if the message and update is written in the order that the logic flows as much as possible (though the programm will work with the items declared in whatever order).
let update msg state : State * Cmd<_> =
match msg with
| RequestLoadCustomers -> state, Cmd.OfFunc.perform loadCustomers state LoadCustomersDone
| LoadCustomersDone customerResults -> processCustomersResults state customerResults
| SelectCustomer selectedCustId ->
if state.SelectedCustomerId <> selectedCustId then
{ state with
SelectedCustomerId = selectedCustId
SelectedWorkItemId = None
SelectedTimeEntryId = None
WorkItems = List<WorkItem>.Empty
TimeEntries = List<TimeEntry>.Empty },
match selectedCustId with
| Some _ -> Cmd.ofMsg RequestLoadWorkItemsForSelectedCustomerId
| None ->
Cmd.batch [ Cmd.ofMsg ClearWorkItems
Cmd.ofMsg ClearTimeEntries ]
else
state, Cmd.none
| RequestLoadWorkItemsForSelectedCustomerId -> state, Cmd.OfFunc.perform loadWorkItems state LoadWorkItemsDone
| LoadWorkItemsDone workItemsResults -> processWorkItemsResults state workItemsResults
| SelectWorkItem selectedWorkItemId ->
if state.SelectedWorkItemId <> selectedWorkItemId then
{ state with
SelectedWorkItemId = selectedWorkItemId
SelectedTimeEntryId = None
TimeEntries = List<TimeEntry>.Empty },
match selectedWorkItemId with
| Some _ ->
Cmd.ofMsg
<| RequestLoadTimeEntriesForSelectedWorkItemId
| None -> Cmd.ofMsg ClearTimeEntries
else
state, Cmd.none
| RequestLoadTimeEntriesForSelectedWorkItemId ->
state, Cmd.OfFunc.perform loadTimeEntries state LoadTimeEntriesDone
| LoadTimeEntriesDone timeEntriesResults -> processTimeEntriesResults state timeEntriesResults
| SelectTimeEntry selectedTimeEntryId ->
if state.SelectedTimeEntryId <> selectedTimeEntryId then
{ state with SelectedTimeEntryId = selectedTimeEntryId },
match selectedTimeEntryId with
| Some _ -> Cmd.none
| None -> Cmd.none
else
state, Cmd.none
| SelectTimeEntryIndex idx ->
state,
List.tryItem idx state.TimeEntries
|> Option.map (fun te -> te.TimeEntryId)
|> SelectTimeEntry
|> Cmd.ofMsg
| ShowErrorMessage errorMessageString ->
let windowService = Globals.GetWindowService()
state, Cmd.OfTask.perform windowService.ShowErrorMsg errorMessageString (fun _ -> NothingMsg)
| AddCustomer -> state, Cmd.OfTask.perform customerDialog None CustomerDialogClosed
| ReviewSelectedCustomer ->
state,
match state.SelectedCustomerId with
| Some selectedCustomerId -> Cmd.ofMsg <| ReviewCustomer selectedCustomerId
| None -> Cmd.none
| ReviewCustomer customerId -> state, Cmd.OfTask.perform customerDialog (Some customerId) CustomerDialogClosed
| CustomerDialogClosed dialogResult -> processCustomerDialogClosed state dialogResult
| AddWorkItem ->
let workItemId: WorkItemId option = None
state,
match state.SelectedCustomerId with
| Some customerId -> Cmd.OfTask.perform (workItemDialog customerId) workItemId WorkItemDialogClosed
| None -> Cmd.none //shouldn't happen
| ReviewSelectedWorkItem ->
state,
match state.SelectedCustomerId with
| Some customerId ->
match state.SelectedWorkItemId with
| Some workItemId -> Cmd.ofMsg <| ReviewWorkItem workItemId
| None -> Cmd.none //shouldn't happen - button shouldn't be enabled
| None -> Cmd.none //shouldn't happen - button shouldn't be enabled
| ReviewWorkItem workItemId ->
state,
match state.SelectedCustomerId with
| Some customerId -> Cmd.OfTask.perform (workItemDialog customerId) (Some workItemId) WorkItemDialogClosed
| None -> Cmd.none //shouldn't happen
| WorkItemDialogClosed dialogResult -> processWorkItemDialogClosed state dialogResult
| AddTimeEntry ->
let timeEntryId: TimeEntryId option = None
state,
match state.SelectedWorkItemId with
| Some workItemId -> Cmd.OfTask.perform (timeEntryDialog workItemId) timeEntryId TimeEntryDialogClosed
| None -> Cmd.none //shouldn't happen
| ReviewSelectedTimeEntry ->
state,
match state.SelectedWorkItemId with
| Some _ ->
match state.SelectedTimeEntryId with
| Some timeEntryId -> Cmd.ofMsg <| ReviewTimeEntry timeEntryId
| None -> Cmd.none //shouldn't happen - button shouldn't be enabled
| None -> Cmd.none //shouldn't happen - button shouldn't be enabled
| ReviewTimeEntry timeEntryId ->
state,
match state.SelectedWorkItemId with
| Some workItemId ->
Cmd.OfTask.perform (timeEntryDialog workItemId) (Some timeEntryId) TimeEntryDialogClosed
| None -> Cmd.none //shouldn't happen
| TimeEntryDialogClosed dialogResult -> processTimeEntryDialogClosed state dialogResult
| IncludeInactiveCustomers includeInactiveCustomers ->
{ state with IncludeInactiveCustomers = includeInactiveCustomers }, Cmd.ofMsg <| RequestLoadCustomers
| IncludeCompletedWorkitems includeCompleted ->
let newState = { state with IncludeCompletedWorkItems = includeCompleted }
newState,
Cmd.ofMsg
<| RequestLoadWorkItemsForSelectedCustomerId
| NothingMsg _ -> state, Cmd.none
| CustomerItemsPerPageChange itemsPerPage ->
if state.CustomerPaging.ItemsPerPage <> itemsPerPage then
let getSelectedCustomerIndex () =
match state.SelectedCustomerId with
| Some id -> List.tryFindIndex (fun (cust: Customer) -> cust.CustomerId = id) state.Customers
| None -> None
let newPageNo =
calculateNewPageNumberFromNewItemsPerPage state.CustomerPaging itemsPerPage getSelectedCustomerIndex
{ state with CustomerPaging = { state.CustomerPaging with ItemsPerPage = itemsPerPage } },
Cmd.ofMsg
<| CustomerListCurrentPageChange newPageNo
else
state, Cmd.none
| CustomerListCurrentPageChange pageNo ->
{ state with CustomerPaging = { state.CustomerPaging with PageNo = pageNo } },
Cmd.ofMsg RequestLoadCustomers
| WorkItemItemsPerPageChange itemsPerPage ->
if state.WorkItemPaging.ItemsPerPage <> itemsPerPage then
let getSelectedWorkItemIndex () =
match state.SelectedWorkItemId with
| Some id -> List.tryFindIndex (fun (cust: WorkItem) -> cust.WorkItemId = id) state.WorkItems
| None -> None
let newPageNo =
calculateNewPageNumberFromNewItemsPerPage state.WorkItemPaging itemsPerPage getSelectedWorkItemIndex
{ state with WorkItemPaging = { state.WorkItemPaging with ItemsPerPage = itemsPerPage } },
Cmd.ofMsg
<| WorkItemListCurrentPageChange newPageNo
else
state, Cmd.none
| WorkItemListCurrentPageChange pageNo ->
{ state with WorkItemPaging = { state.WorkItemPaging with PageNo = pageNo } },
Cmd.ofMsg RequestLoadWorkItemsForSelectedCustomerId
| TimeEntryItemsPerPageChange itemsPerPage ->
if state.TimeEntryPaging.ItemsPerPage <> itemsPerPage then
let getSelectedTimeEntryIndex () =
match state.SelectedTimeEntryId with
| Some id -> List.tryFindIndex (fun (cust: TimeEntry) -> cust.TimeEntryId = id) state.TimeEntries
| None -> None
let newPageNo =
calculateNewPageNumberFromNewItemsPerPage
state.TimeEntryPaging
itemsPerPage
getSelectedTimeEntryIndex
{ state with TimeEntryPaging = { state.TimeEntryPaging with ItemsPerPage = itemsPerPage } },
Cmd.ofMsg
<| TimeEntryListCurrentPageChange newPageNo
else
state, Cmd.none
| TimeEntryListCurrentPageChange pageNo ->
{ state with TimeEntryPaging = { state.TimeEntryPaging with PageNo = pageNo } },
Cmd.ofMsg RequestLoadTimeEntriesForSelectedWorkItemId
| CustomerPagingButtonClicked button ->
let newPageNo =
calculateNewPageNumberFromButtonClickedAndPaging state.CustomerPaging button
state,
Cmd.batch [ Cmd.ofMsg <| EnableCustomerPaging false
Cmd.ofMsg
<| CustomerListCurrentPageChange newPageNo ]
| WorkItemPagingButtonClicked button ->
let newPageNo =
calculateNewPageNumberFromButtonClickedAndPaging state.WorkItemPaging button
state,
Cmd.batch [ Cmd.ofMsg <| EnableWorkItemPaging false
Cmd.ofMsg
<| WorkItemListCurrentPageChange newPageNo ]
| TimeEntryPagingButtonClicked button ->
let newPageNo =
calculateNewPageNumberFromButtonClickedAndPaging state.TimeEntryPaging button
state,
Cmd.batch [ Cmd.ofMsg <| EnableTimeEntryPaging false
Cmd.ofMsg
<| TimeEntryListCurrentPageChange newPageNo ]
| EnableCustomerPaging enabled ->
{ state with CustomerPaging = { state.CustomerPaging with EnableGoButtons = false } }, Cmd.none
| EnableWorkItemPaging enabled ->
{ state with WorkItemPaging = { state.WorkItemPaging with EnableGoButtons = false } }, Cmd.none
| EnableTimeEntryPaging enabled ->
{ state with TimeEntryPaging = { state.TimeEntryPaging with EnableGoButtons = false } }, Cmd.none
| ClearTimeEntries ->
{ state with
TimeEntries = []
TimeEntryPaging = Paging.Empty() },
Cmd.none
| ClearWorkItems ->
{ state with
WorkItems = []
WorkItemPaging = Paging.Empty() },
Cmd.none
I have left out the intermediary functions that is called during our update. Those intermediary functions, each being able to be tested individually. You will notice the use of the ‘Cmd’ module that has a function ‘ofMsg’ and submodules ‘OfFunc’, ‘OfTask’ and ‘OfAsync that allow you to call functions whose results can be piped into a message type. This allows you to create a message flow. For example load the list of customers and when done, feed the results back in via the ‘LoadCustomesDone’ message with customer list payload.
Summary
Here I have attempted to introduce the UI part of this time tracker application written in F#. We have introduced ‘FuncUI’, ‘MVU’ concepts. We have shown the Model, the Message and the Update parts for the main entry page of the application. I am going to discuss the view definition in the next part of this series. Until then, keep smiling and looking up.