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 Main Data Entry Page of the Application

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.

Leave a Reply

Your email address will not be published. Required fields are marked *