elm-spa icon indicating copy to clipboard operation
elm-spa copied to clipboard

Need some help regarding structuring of code

Open ericwern opened this issue 1 year ago • 2 comments

Hi, as always thanks for the great work on elm-spa!

I have an issue, it might just be that I've missed something but I hope maybe you could provide an example of how to do this.

So I have created an own module Confirm.elm which is a dialog with a confirm msg. some use case could be "are you really sure you want to delete this whatver{id}. I want to make a general function in Effect.elm which can be called on whatever page am I on. The problem here is that I struggle with the mapping of messages.

Lets say i have a Page.elm where i have a Msg DeleteWhatever Int, from here when i call the ConfirmDialog from Shared.elm i need some kind of mapping from Page Msg to Shared Msg.

I've been stuck on this for a long time and I really hope that you guys could help me out. I'll try to provide as much as info as i can for you to understand my implementation.

this is the implementation of Confirm.elm

{-| Contains a general type and combinators for a confirmation dialog.

You should prefer to use `init` and builder functions
rather than constructing this manually.

-}
type alias Confirm msg =
    { title : String
    , prompt : String
    , cancelText : String
    , confirmText : String
    , attributes : List (H.Attribute msg)
    , clickOutside : Bool
    , onConfirm : msg
    , onCancel : Maybe msg
    }


{-| Maps a function over `ConfirmConfig`
-}
map : (a -> msg) -> Confirm a -> Confirm msg
map f confirm =
    { title = confirm.title
    , prompt = confirm.prompt
    , confirmText = confirm.confirmText
    , cancelText = confirm.cancelText
    , attributes = List.map (A.map f) confirm.attributes
    , clickOutside = confirm.clickOutside
    , onConfirm = f confirm.onConfirm
    , onCancel = Maybe.map f confirm.onCancel
    }


{-| Set the title of a confirm.
-}
setTitle : String -> Confirm msg -> Confirm msg
setTitle title confirm =
    { confirm | title = title }


{-| Set the prompt text of a confirm.
-}
setPrompt : String -> Confirm msg -> Confirm msg
setPrompt prompt confirm =
    { confirm | prompt = prompt }


{-| Set the cancel button text of a confirm.
-}
setCancelText : String -> Confirm msg -> Confirm msg
setCancelText cancelText confirm =
    { confirm | cancelText = cancelText }


{-| Set the confirm button text of a confirm.
-}
setConfirmText : String -> Confirm msg -> Confirm msg
setConfirmText confirmText confirm =
    { confirm | confirmText = confirmText }


{-| Set the container attributes of a confirm.
The given attribute list will be applied to the outermost
dialog container.
-}
setAttributes : List (H.Attribute msg) -> Confirm msg -> Confirm msg
setAttributes attributes confirm =
    { confirm | attributes = attributes }


{-| Close the confirm dialog when the user clicks on
the overlay outside the dialog.
-}
closeOnClickOutside : Confirm msg -> Confirm msg
closeOnClickOutside confirm =
    { confirm | clickOutside = True }


{-| Set the confirm action of a confirm.
-}
setOnConfirm : msg -> Confirm msg -> Confirm msg
setOnConfirm onConfirm confirm =
    { confirm | onConfirm = onConfirm }


{-| Set the cancel action of a confirm.
-}
setOnCancel : msg -> Confirm msg -> Confirm msg
setOnCancel onCancel confirm =
    { confirm | onCancel = Just onCancel }


{-| `Confirm` with default values.

Using this function and provided builder functions should
be your preferred approach when creating `Confirm` instances.

-}
init : msg -> Confirm msg
init onConfirm =
    { title = "Confirm"
    , prompt = ""
    , cancelText = "Cancel"
    , confirmText = "Confirm"
    , attributes = []
    , clickOutside = False
    , onConfirm = onConfirm
    , onCancel = Nothing
    }

This is the implementation of main.elm

mappers : ( (a -> b) -> PageView a -> PageView b, (c -> d) -> PageView c -> PageView d )
mappers =
    ( PageView.map, PageView.map )


toDocument : Shared -> PageView (Spa.Msg Shared.Msg pageMsg) -> Document (Spa.Msg Shared.Msg pageMsg)
toDocument shared view =
    View.baseView shared view


main =
    Spa.init
        { defaultView = PageView.defaultView
        , extractIdentity = Shared.identity
        }
        |> Spa.addProtectedPage mappers (Route.matchAppRoot Route.Page1) Page1.page
        |> Spa.addProtectedPage mappers (Route.matchAppRoot Route.Page2) Page2.page
        |> Spa.beforeRouteChange Shared.beforeRouteChange
        |> Spa.application PageView.map
            { init = Shared.init
            , subscriptions = Shared.subscriptions
            , update = Shared.update
            , toRoute = Route.toRoute
            , toDocument = toDocument
            , protectPage = Route.toUrl >> Just >> Route.SignIn >> Route.toUrl
            }
        |> Spa.onUrlRequest Shared.clickedLink
        |> Browser.application

this is PageView.elm

type alias PageView msg =
    { dialogs : List (Dialog msg)
    , view : Html msg
    }


init : Html msg -> PageView msg
init view =
    { dialogs = []
    , view = view
    }


setView : Html msg -> PageView msg -> PageView msg
setView view pageView =
    { pageView | view = view }


setDialogs : List (Dialog msg) -> PageView msg -> PageView msg
setDialogs dialogs pageView =
    { pageView | dialogs = dialogs }


map : (a -> msg) -> PageView a -> PageView msg
map f pageView =
    { dialogs = List.map (Dialog.map f) pageView.dialogs
    , view = H.map f pageView.view
    }


defaultView : PageView msg
defaultView =
    init
        (H.div []
            [ H.text
                "You should not see this page unless you forgot to add pages to your application"
            ]
        )

and this is the baseView in View.elm

baseView : Shared -> PageView (Spa.Msg Shared.Msg pageMsg) -> Document (Spa.Msg Shared.Msg pageMsg)
baseView shared { view, dialogs } =
    { title = ""
    , body =
        viewHeader shared
            :: navigationRow shared.environment shared.currentRoute
            :: H.div [ A.class "page_content_container" ]
                [ view
                ]
            :: Dialog.viewList dialogs
            :: (shared.toasts
                    |> Toast.viewList
                    |> H.map (\( id, msg ) -> Spa.mapSharedMsg (Shared.setToastMsg id msg))
               )
            :: List.map
                (\error ->
                    error
                        |> Dialog.fromError shared.environment.language (Spa.mapSharedMsg Shared.closeErrorDialog)
                        |> Dialog.view
                )
                (List.take 1 shared.errors)
            ++ List.map
                (\confirm ->
                    confirm
                        |> Confirm.setOnCancel (Spa.mapSharedMsg Shared.closeConfirmDialog)
                        |> Confirm.setOnConfirm (Spa.mapSharedMsg Shared.confirmConfirmDialog)
                        |> Dialog.fromConfirm
                        |> Dialog.view
                )
                (List.take 1 shared.confirms |> List.map (\x -> Confirm.map Spa.mapSharedMsg x))
    }

so far i have this in Shared.elm and this is where my problems start

type alias Shared =
    { key : Nav.Key
    , identity : Maybe Identity
    , currentRoute : Maybe Route
    , preventReroute : Bool
    , confirms : List (Confirm Msg)
    }


type Msg
    = ReplaceRoute Route
    | BeforeRouteChange Route
    | ShowConfirmDialog (Confirm Msg)
    | CloseConfirmDialog
    | ConfirmHeadConfirmDialog
    | ClickedLink Browser.UrlRequest

update : Msg -> Shared -> ( Shared, Cmd Msg )
update msg shared =
    case msg of
        ShowConfirmDialog confirm ->
            ( { shared | confirms = confirm :: shared.confirms }, Cmd.none )

        CloseConfirmDialog ->
            case shared.confirms of
                confirm :: rest ->
                    ( { shared | confirms = rest }
                    , confirm.onCancel
                        |> Maybe.map TaskUtil.doTask
                        |> Maybe.withDefault Cmd.none
                    )

                [] ->
                    ( shared, Cmd.none )

        ConfirmHeadConfirmDialog ->
            case shared.confirms of
                confirm :: rest ->
                    ( { shared | confirms = rest }, TaskUtil.doTask confirm.onConfirm )

                [] ->
                    ( shared, Cmd.none )






--THIS IS WRONG 
showConfirmDialog : Confirm Msg -> Msg
showConfirmDialog confirm =
    ShowConfirmDialog confirm

and for a use case in Page.elm

        ClickConfirmDialog ->
            let
                confirmDialog =
                    Confirm.init (DeleteWhatever 2000)
                        |> Confirm.setPrompt "Are you sure you want to do this?"
            in
            model |> Effect.withShared (Shared.showConfirmDialog confirmDialog)

ericwern avatar Jan 24 '24 08:01 ericwern

The way I handle this for now is that the dialog state is handled by the page update itself, possibly in a sub-component. So, when building the dialog, the page will give it a message wrapper, and the dialog will not emit shared messages but messages for the page itself. And the dialog is part of the page view, so the "ClickConfirmDialog" should set what's needed in the page model for the view to add the dialog in its "dialogs" list.

I may be unclear, don't hesitate to ask for more precise explanations.

In the future I intend to change thinks so the shared module can emit messages for the app, hence for a page that asked for it. But it causes a bunch of difficulties and I did not have time to tackle them (yet).

cdevienne avatar Jan 24 '24 11:01 cdevienne

Thanks for the quick reply 😄 Your solution is my current solution as well. Would be really convenient with the shared module emitting messages for the app and I can see more use-cases than a simple confirm-dialog.

I understand the complexity and difficulties in implementing such feature and it might not be in the top of your backlog, but do you have any ETA?

ericwern avatar Jan 24 '24 12:01 ericwern

No ETA sorry. It is a difficult problem and I am not even sure it has a decent solution.

cdevienne avatar Jul 03 '24 14:07 cdevienne