diff --git a/docs/netlify.toml b/docs/netlify.toml index b87b8d3..9afb9c6 100644 --- a/docs/netlify.toml +++ b/docs/netlify.toml @@ -1,3 +1,7 @@ +[build] + publish = "public" + command = "npm i elm-spa@beta && npx elm-spa build" + [[redirects]] from = "/*" to = "/index.html" diff --git a/docs/public/content/examples.md b/docs/public/content/examples.md new file mode 100644 index 0000000..1b976a9 --- /dev/null +++ b/docs/public/content/examples.md @@ -0,0 +1 @@ +# Examples \ No newline at end of file diff --git a/docs/public/content/examples/authentication.md b/docs/public/content/examples/authentication.md new file mode 100644 index 0000000..e3069d7 --- /dev/null +++ b/docs/public/content/examples/authentication.md @@ -0,0 +1 @@ +# User Authentication \ No newline at end of file diff --git a/docs/public/content/guide/cli.md b/docs/public/content/guide/cli.md index 9e5a13e..902d90e 100644 --- a/docs/public/content/guide/cli.md +++ b/docs/public/content/guide/cli.md @@ -3,7 +3,7 @@ The [official __elm-spa__ CLI tool](https://npmjs.org/elm-spa) has a few commands to help you build single page applications. As we saw in [the previous section](/guide/overview), you can use the CLI from your terminal by running: ```terminal -npm install -g elm-spa@latest +npm install -g elm-spa@beta ``` At any time running `elm-spa` or `elm-spa help` will show you the available commands: @@ -24,20 +24,22 @@ The `new` command creates a new project in the current folder: elm-spa new ``` -This command will only create a few files, so don't worry about getting overwhelmed with new files in your repo! Other than a `.gitignore`, there are only 2 new files created. +This command will only create a few files, so don't worry about getting overwhelmed with new files in your repo! Other than a `.gitignore`, there are only 3 new files created. File | Description --- | --- `elm.json` | Your project's dependencies. -`src` | An empty folder for your Elm code. +`src/Pages/Home_.elm` | The homepage. `public/index.html` | The entrypoint to your application. ``` your-project/ - - elm.json - - src/ - - public/ - - index.html +|- elm.json +|- src/ +| |- Pages/ +| |- Home_.elm +|- public/ + |- index.html ``` The `public` folder is a place for static assets! For example, a file at `./public/style.css` will be available at `/style.css` in your web browser. diff --git a/docs/public/style.css b/docs/public/style.css index 7e760ea..72d691a 100644 --- a/docs/public/style.css +++ b/docs/public/style.css @@ -65,8 +65,8 @@ pre { background-color: white; } -main .markdown { - animation: fadeIn 200ms 300ms ease-in forwards; +main { + animation: fadeIn 200ms 400ms ease-in forwards; opacity: 0; } diff --git a/docs/src/Domain/Index.elm b/docs/src/Domain/Index.elm index 2d2a76a..f299422 100644 --- a/docs/src/Domain/Index.elm +++ b/docs/src/Domain/Index.elm @@ -1,12 +1,14 @@ module Domain.Index exposing ( Index, decoder , Link, search + , Section, sections ) {-| @docs Index, decoder @docs Link, search +@docs Section, sections -} @@ -81,3 +83,73 @@ search query index = } ) |> List.filter (\link -> Utils.String.caseInsensitiveContains query link.label) + + + +-- SECTIONS + + +type alias Section = + { header : String + , url : String + , pages : List SectionLink + } + + +type alias SectionLink = + { label : String + , url : String + } + + +sections : Index -> List Section +sections index = + let + sectionOrder = + [ "Guide" + , "Examples" + ] + + toLabelUrls = + List.filterMap + (\doc -> + doc.headers + |> Dict.filter (\_ level -> level == 1) + |> Dict.toList + |> List.head + |> Maybe.map (Tuple.first >> (\label -> { label = label, url = doc.url })) + ) + + topLevelLabelUrls : List { label : String, url : String } + topLevelLabelUrls = + let + isOneLevelDeep doc = + List.length (String.split "/" doc.url) == 2 + in + index + |> List.filter isOneLevelDeep + |> toLabelUrls + + toSection top children = + { header = top.label + , url = top.url + , pages = children + } + in + topLevelLabelUrls + |> List.map + (\top -> + index + |> List.filter (.url >> (\url -> String.startsWith top.url url && url /= top.url)) + |> toLabelUrls + |> toSection top + ) + |> List.sortBy + (\section -> + sectionOrder + |> List.indexedMap Tuple.pair + |> List.filter (Tuple.second >> (==) section.header) + |> List.map Tuple.first + |> List.head + |> Maybe.withDefault -1 + ) diff --git a/docs/src/Pages/Guide/Section_/Article_.elm b/docs/src/Pages/Examples.elm similarity index 79% rename from docs/src/Pages/Guide/Section_/Article_.elm rename to docs/src/Pages/Examples.elm index 8e5f698..d0a5a86 100644 --- a/docs/src/Pages/Guide/Section_/Article_.elm +++ b/docs/src/Pages/Examples.elm @@ -1,4 +1,4 @@ -module Pages.Guide.Section_.Article_ exposing (Model, Msg, page) +module Pages.Examples exposing (Model, Msg, page) import Page exposing (Page) import Request exposing (Request) diff --git a/docs/src/Pages/Examples/Section_.elm b/docs/src/Pages/Examples/Section_.elm new file mode 100644 index 0000000..5e5ff14 --- /dev/null +++ b/docs/src/Pages/Examples/Section_.elm @@ -0,0 +1,19 @@ +module Pages.Examples.Section_ exposing (Model, Msg, page) + +import Page exposing (Page) +import Request exposing (Request) +import Shared +import UI.Docs + + +page : Shared.Model -> Request params -> Page Model Msg +page = + UI.Docs.page + + +type alias Model = + UI.Docs.Model + + +type alias Msg = + UI.Docs.Msg diff --git a/docs/src/Pages/Home_.elm b/docs/src/Pages/Home_.elm index fc61892..171e157 100644 --- a/docs/src/Pages/Home_.elm +++ b/docs/src/Pages/Home_.elm @@ -6,7 +6,6 @@ import Request exposing (Request) import Shared import UI import UI.Layout -import Url exposing (Url) import View exposing (View) diff --git a/docs/src/Pages/NotFound.elm b/docs/src/Pages/NotFound.elm index 387f932..3c40ce0 100644 --- a/docs/src/Pages/NotFound.elm +++ b/docs/src/Pages/NotFound.elm @@ -6,7 +6,6 @@ import Request exposing (Request) import Shared import UI import UI.Layout -import Url exposing (Url) import View exposing (View) diff --git a/docs/src/Shared.elm b/docs/src/Shared.elm index 8db2e91..1fa7aeb 100644 --- a/docs/src/Shared.elm +++ b/docs/src/Shared.elm @@ -21,9 +21,14 @@ type alias Flags = type alias Model = { index : Index + , token : Maybe Token } +type alias Token = + () + + type Msg = NoOp diff --git a/docs/src/UI/Docs.elm b/docs/src/UI/Docs.elm index dd8447c..4d3dde3 100644 --- a/docs/src/UI/Docs.elm +++ b/docs/src/UI/Docs.elm @@ -1,6 +1,5 @@ module UI.Docs exposing (Model, Msg, page) -import Gen.Route as Route import Http import Page exposing (Page) import Request exposing (Request) @@ -47,16 +46,6 @@ withDefault fallback fetchable = fallback -toMaybe : Fetchable value -> Maybe value -toMaybe fetchable = - case fetchable of - Success value -> - Just value - - _ -> - Nothing - - init : Url -> ( Model, Cmd Msg ) init url = ( Model UI.Layout.init Loading diff --git a/docs/src/UI/Layout.elm b/docs/src/UI/Layout.elm index ccf9448..f28f93e 100644 --- a/docs/src/UI/Layout.elm +++ b/docs/src/UI/Layout.elm @@ -15,7 +15,6 @@ module UI.Layout exposing import Html exposing (Html) import Html.Attributes as Attr -import Html.Events as Events import Page exposing (Page, shared) import Request exposing (Request) import Shared @@ -81,12 +80,16 @@ viewDocumentation options markdownContent view = , url = options.url } ] - , Html.main_ [ Attr.class "col flex" ] view - , Html.div [ Attr.class "hidden-mobile sticky pad-y-lg", Attr.style "width" "16em" ] - [ UI.Sidebar.viewTableOfContents - { content = markdownContent - , url = options.url - } + , Html.main_ [ Attr.class "flex" ] + [ UI.row.lg [ UI.align.top ] + [ Html.div [ Attr.class "col flex" ] view + , Html.div [ Attr.class "hidden-mobile sticky pad-y-lg", Attr.style "width" "16em" ] + [ UI.Sidebar.viewTableOfContents + { content = markdownContent + , url = options.url + } + ] + ] ] ] ] diff --git a/docs/src/UI/Sidebar.elm b/docs/src/UI/Sidebar.elm index d733482..490236d 100644 --- a/docs/src/UI/Sidebar.elm +++ b/docs/src/UI/Sidebar.elm @@ -12,27 +12,6 @@ import Url exposing (Url) import Utils.String -sidebarSections : List Section -sidebarSections = - [ Section "Guide" - "/guide" - [ Link "Overview" "/guide" - , Link "The CLI" "/guide/cli" - , Link "Routing" "/guide/routing" - , Link "Pages" "/guide/pages" - , Link "Shared State" "/guide/shared-state" - , Link "Requests" "/guide/requests" - , Link "Views" "/guide/views" - ] - , Section "Examples" - "/guide" - [ Link "User Authentication" "/guide/users" - , Link "Elm UI" "/guide/apis" - , Link "Page Transitions" "/guide/transitions" - ] - ] - - parseTableOfContents : String -> List Section parseTableOfContents = Markdown.Parser.parse @@ -46,12 +25,12 @@ parseTableOfContents = type alias Section = { header : String , url : String - , links : List Link + , pages : List Link } type alias Link = - { name : String + { label : String , url : String } @@ -76,16 +55,16 @@ headersToSections = in case ( level, current ) of ( Heading2, Just existing ) -> - ( sections ++ [ existing ], Just { header = text, url = url, links = [] } ) + ( sections ++ [ existing ], Just { header = text, url = url, pages = [] } ) ( Heading2, Nothing ) -> - ( sections, Just { header = text, url = url, links = [] } ) + ( sections, Just { header = text, url = url, pages = [] } ) ( Heading3, Just existing ) -> - ( sections, Just { existing | links = existing.links ++ [ { name = text, url = url } ] } ) + ( sections, Just { existing | pages = existing.pages ++ [ { label = text, url = url } ] } ) ( Heading3, Nothing ) -> - ( sections ++ [ { header = text, url = url, links = [] } ], Nothing ) + ( sections ++ [ { header = text, url = url, pages = [] } ], Nothing ) in List.foldl loop ( [], Nothing ) >> (\( sections, maybe ) -> @@ -135,7 +114,7 @@ tableOfContentsRenderer = viewSidebar : { url : Url, index : Index } -> Html msg -viewSidebar { url } = +viewSidebar { url, index } = let viewSidebarLink : Link -> Html msg viewSidebarLink link__ = @@ -144,15 +123,15 @@ viewSidebar { url } = viewSidebarSection : Section -> Html msg viewSidebarSection section = UI.col.sm [] - [ Html.h4 [ Attr.class "h4 bold" ] [ Html.text section.header ] - , if List.isEmpty section.links then + [ Html.a [ Attr.href section.url, Attr.class "h4 bold" ] [ Html.text section.header ] + , if List.isEmpty section.pages then Html.text "" else - UI.col.md [ Attr.class "border-left pad-y-sm pad-x-md align-left" ] (List.map viewSidebarLink section.links) + UI.col.md [ Attr.class "border-left pad-y-sm pad-x-md align-left" ] (List.map viewSidebarLink section.pages) ] in - UI.col.md [] (List.map viewSidebarSection sidebarSections) + UI.col.md [] (List.map viewSidebarSection (Domain.Index.sections index)) viewDocumentationLink : Bool -> Link -> Html msg @@ -162,7 +141,7 @@ viewDocumentationLink isActive link__ = , Attr.classList [ ( "bold text-blue", isActive ) ] , Attr.href link__.url ] - [ Html.text link__.name ] + [ Html.text link__.label ] viewTableOfContents : { url : Url, content : String } -> Html msg @@ -175,13 +154,13 @@ viewTableOfContents { url, content } = viewTocSection : Section -> Html msg viewTocSection section = Html.div [ Attr.class "col gap-xs align-left" ] - [ viewTableOfContentsLink { name = section.header, url = section.url } - , if List.isEmpty section.links then + [ viewTableOfContentsLink { label = section.header, url = section.url } + , if List.isEmpty section.pages then Html.text "" else Html.div [ Attr.class "col pad-left-sm pad-xs gap-sm" ] - (section.links + (section.pages |> List.map (\l -> Html.div [ Attr.class "h6" ] [ viewTableOfContentsLink l ]) ) ] diff --git a/elm.json b/elm.json index c440607..8872aef 100644 --- a/elm.json +++ b/elm.json @@ -3,11 +3,12 @@ "name": "ryannhg/elm-spa", "summary": "Single page apps made easy.", "license": "BSD-3-Clause", - "version": "5.0.0", + "version": "5.1.0", "exposed-modules": [ "ElmSpa.Request", "ElmSpa.Page", - "ElmSpa.Beta" + "ElmSpa.Beta", + "ElmSpa.Internals.Page" ], "elm-version": "0.19.0 <= v < 0.20.0", "dependencies": { diff --git a/src/ElmSpa/Internals/Page.elm b/src/ElmSpa/Internals/Page.elm new file mode 100644 index 0000000..91f3c2c --- /dev/null +++ b/src/ElmSpa/Internals/Page.elm @@ -0,0 +1,427 @@ +module ElmSpa.Internals.Page exposing + ( Page + , static, sandbox, element, advanced + , protected + , Bundle, bundle + ) + +{-| + + +# **Pages** + +@docs Page + +@docs static, sandbox, element, advanced + + +# **User Authentication** + +@docs protected + + +# For generated code + +@docs Bundle, bundle + +-} + +import Browser.Navigation exposing (Key) +import ElmSpa.Request +import Url exposing (Url) + + +{-| Pages are the building blocks of **elm-spa**. + +Instead of importing this module, your project will have a `Page` module with a much simpler type: + + module Page exposing (Page, ...) + + type Page model msg + +This makes all the generic `route`, `effect`, and `view` arguments disappear! + +-} +type Page shared request route effect view model msg + = Page (Internals shared request route effect view model msg) + + +{-| A page that only needs to render a static view. + + import Page + + page : Page () Never + page = + Page.static + { view = view + } + + -- view : View Never + +-} +static : + effect + -> + { view : view + } + -> Page shared request route effect view () msg +static none page = + Page + (\_ _ -> + Ok + { init = \_ -> ( (), none ) + , update = \_ _ -> ( (), none ) + , view = \_ -> page.view + , subscriptions = \_ -> Sub.none + } + ) + + +{-| A page that can keep track of application state. + +( Inspired by [`Browser.sandbox`](https://package.elm-lang.org/packages/elm/browser/latest/Browser#sandbox) ) + + import Page + + page : Page Model Msg + page = + Page.sandbox + { init = init + , update = update + , view = view + } + + -- init : Model + -- update : Msg -> Model -> Model + -- view : Model -> View Msg + +-} +sandbox : + effect + -> + { init : model + , update : msg -> model -> model + , view : model -> view + } + -> Page shared request route effect view model msg +sandbox none page = + Page + (\_ _ -> + Ok + { init = \_ -> ( page.init, none ) + , update = \msg model -> ( page.update msg model, none ) + , view = page.view + , subscriptions = \_ -> Sub.none + } + ) + + +{-| A page that can handle effects like [HTTP requests or subscriptions](https://guide.elm-lang.org/effects/). + +( Inspired by [`Browser.element`](https://package.elm-lang.org/packages/elm/browser/latest/Browser#element) ) + + import Page + + page : Page Model Msg + page = + Page.element + { init = init + , update = update + , view = view + , subscriptions = subscriptions + } + + -- init : ( Model, Cmd Msg ) + -- update : Msg -> Model -> ( Model, Cmd Msg ) + -- view : Model -> View Msg + -- subscriptions : Model -> Sub Msg + +-} +element : + (Cmd msg -> effect) + -> + { init : ( model, Cmd msg ) + , update : msg -> model -> ( model, Cmd msg ) + , view : model -> view + , subscriptions : model -> Sub msg + } + -> Page shared request route effect view model msg +element fromCmd page = + Page + (\_ _ -> + Ok + { init = \_ -> page.init |> Tuple.mapSecond fromCmd + , update = \msg model -> page.update msg model |> Tuple.mapSecond fromCmd + , view = page.view + , subscriptions = page.subscriptions + } + ) + + +{-| A page that can handles **custom** effects like sending a `Shared.Msg` or other general user-defined effects. + + import Effect + import Page + + page : Page Model Msg + page = + Page.advanced + { init = init + , update = update + , view = view + , subscriptions = subscriptions + } + + -- init : ( Model, Effect Msg ) + -- update : Msg -> Model -> ( Model, Effect Msg ) + -- view : Model -> View Msg + -- subscriptions : Model -> Sub Msg + +-} +advanced : + { init : ( model, effect ) + , update : msg -> model -> ( model, effect ) + , view : model -> view + , subscriptions : model -> Sub msg + } + -> Page shared request route effect view model msg +advanced page = + Page + (\_ _ -> + Ok + { init = always page.init + , update = page.update + , view = page.view + , subscriptions = page.subscriptions + } + ) + + +{-| Prefixing any of the four functions above with `protected` will guarantee that the page has access to a user. Here's an example with `sandbox`: + + import Page + + page : Page Model Msg + page = + Page.protected.sandbox + { init = init + , update = update + , view = view + } + + -- init : User -> Model + -- update : User -> Msg -> Model -> Model + -- update : User -> Model -> View Msg + +-} +protected : + { effectNone : effect + , fromCmd : Cmd msg -> effect + , user : shared -> request -> Maybe user + , route : route + } + -> + { static : + { view : user -> view + } + -> Page shared request route effect view () msg + , sandbox : + { init : user -> model + , update : user -> msg -> model -> model + , view : user -> model -> view + } + -> Page shared request route effect view model msg + , element : + { init : user -> ( model, Cmd msg ) + , update : user -> msg -> model -> ( model, Cmd msg ) + , view : user -> model -> view + , subscriptions : user -> model -> Sub msg + } + -> Page shared request route effect view model msg + , advanced : + { init : user -> ( model, effect ) + , update : user -> msg -> model -> ( model, effect ) + , view : user -> model -> view + , subscriptions : user -> model -> Sub msg + } + -> Page shared request route effect view model msg + } +protected options = + let + protect pageWithUser page = + Page + (\shared req -> + case options.user shared req of + Just user -> + Ok (pageWithUser user page) + + Nothing -> + Err options.route + ) + in + { static = + protect + (\user page -> + { init = \_ -> ( (), options.effectNone ) + , update = \_ model -> ( model, options.effectNone ) + , view = \_ -> page.view user + , subscriptions = \_ -> Sub.none + } + ) + , sandbox = + protect + (\user page -> + { init = \_ -> ( page.init user, options.effectNone ) + , update = \msg model -> ( page.update user msg model, options.effectNone ) + , view = page.view user + , subscriptions = \_ -> Sub.none + } + ) + , element = + protect + (\user page -> + { init = \_ -> page.init user |> Tuple.mapSecond options.fromCmd + , update = \msg model -> page.update user msg model |> Tuple.mapSecond options.fromCmd + , view = page.view user + , subscriptions = page.subscriptions user + } + ) + , advanced = + protect + (\user page -> + { init = \_ -> page.init user + , update = page.update user + , view = page.view user + , subscriptions = page.subscriptions user + } + ) + } + + + +-- UPGRADING FOR GENERATED CODE + + +type alias Request route params = + ElmSpa.Request.Request route params + + +{-| -} +type alias Bundle params model msg shared effect pagesModel pagesMsg pagesView = + { init : params -> shared -> Url -> Key -> ( pagesModel, effect ) + , update : params -> msg -> model -> shared -> Url -> Key -> ( pagesModel, effect ) + , view : params -> model -> shared -> Url -> Key -> pagesView + , subscriptions : params -> model -> shared -> Url -> Key -> Sub pagesMsg + } + + +{-| This function is used by the generated code to connect your pages together. + +It's big, spooky, and makes writing **elm-spa** pages really nice! + +-} +bundle : + { redirecting : { model : pagesModel, view : pagesView } + , toRoute : Url -> route + , toUrl : route -> String + , fromCmd : Cmd any -> pagesEffect + , mapEffect : effect -> pagesEffect + , mapView : view -> pagesView + , page : shared -> Request route params -> Page shared (Request route params) route effect view model msg + , toModel : params -> model -> pagesModel + , toMsg : msg -> pagesMsg + } + -> Bundle params model msg shared pagesEffect pagesModel pagesMsg pagesView + + + +-- { init : params -> shared -> Url -> Key -> ( pagesModel, pagesEffect ) +-- , update : params -> msg -> model -> shared -> Url -> Key -> ( pagesModel, pagesEffect ) +-- , view : params -> model -> shared -> Url -> Key -> pagesView +-- , subscriptions : params -> model -> shared -> Url -> Key -> Sub pagesMsg +-- } + + +bundle { redirecting, toRoute, toUrl, fromCmd, mapEffect, mapView, page, toModel, toMsg } = + { init = + \params shared url key -> + let + req = + ElmSpa.Request.create (toRoute url) params url key + in + case toResult page shared req of + Ok record -> + record.init () + |> Tuple.mapBoth (toModel req.params) mapEffect + + Err route -> + ( redirecting.model, fromCmd <| Browser.Navigation.replaceUrl req.key (toUrl route) ) + , update = + \params msg model shared url key -> + let + req = + ElmSpa.Request.create (toRoute url) params url key + in + case toResult page shared req of + Ok record -> + record.update msg model + |> Tuple.mapBoth (toModel req.params) mapEffect + + Err route -> + ( redirecting.model, fromCmd <| Browser.Navigation.replaceUrl req.key (toUrl route) ) + , view = + \params model shared url key -> + let + req = + ElmSpa.Request.create (toRoute url) params url key + in + case toResult page shared req of + Ok record -> + record.view model + |> mapView + + Err _ -> + redirecting.view + , subscriptions = + \params model shared url key -> + let + req = + ElmSpa.Request.create (toRoute url) params url key + in + case toResult page shared req of + Ok record -> + record.subscriptions model + |> Sub.map toMsg + + Err _ -> + Sub.none + } + + +toResult : + (shared -> Request route params -> Page shared (Request route params) route effect view model msg) + -> shared + -> Request route params + -> Result route (PageRecord effect view model msg) +toResult toPage shared req = + let + (Page toResult_) = + toPage shared req + in + toResult_ shared req + + + +-- INTERNALS + + +type alias Internals shared request route effect view model msg = + shared -> request -> Result route (PageRecord effect view model msg) + + +type alias PageRecord effect view model msg = + { init : () -> ( model, effect ) + , update : msg -> model -> ( model, effect ) + , view : model -> view + , subscriptions : model -> Sub msg + } diff --git a/src/cli/package-lock.json b/src/cli/package-lock.json index a70cb63..baa7e3f 100644 --- a/src/cli/package-lock.json +++ b/src/cli/package-lock.json @@ -1,6 +1,6 @@ { "name": "elm-spa", - "version": "6.0.3--beta", + "version": "6.0.4--beta", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/src/cli/package.json b/src/cli/package.json index 96b6c18..0aef20a 100644 --- a/src/cli/package.json +++ b/src/cli/package.json @@ -1,6 +1,6 @@ { "name": "elm-spa", - "version": "6.0.3--beta", + "version": "6.0.4--beta", "description": "single page apps made easy", "bin": "dist/src/index.js", "scripts": { diff --git a/src/cli/src/config.ts b/src/cli/src/config.ts index 0f181d9..93ddd2b 100644 --- a/src/cli/src/config.ts +++ b/src/cli/src/config.ts @@ -2,6 +2,7 @@ import path from 'path' const reserved = { homepage: 'Home_', + redirecting: 'Redirecting_', notFound: 'NotFound' } @@ -35,6 +36,7 @@ const config = { terser: `npx terser` }, defaults: [ + [ 'Effect.elm' ], [ 'Main.elm' ], [ 'Shared.elm' ], [ `Pages`, `${reserved.notFound}.elm` ], diff --git a/src/cli/src/defaults/Effect.elm b/src/cli/src/defaults/Effect.elm new file mode 100644 index 0000000..b1a8ed6 --- /dev/null +++ b/src/cli/src/defaults/Effect.elm @@ -0,0 +1,80 @@ +module Effect exposing + ( Effect, none, map, batch + , fromCmd, fromShared + , toCmd + ) + +{-| + +@docs Effect, none, map, batch +@docs fromCmd, fromShared +@docs toCmd + +-} + +import Shared +import Task + + +type Effect msg + = None + | Cmd (Cmd msg) + | Shared Shared.Msg + | Batch (List (Effect msg)) + + +none : Effect msg +none = + None + + +map : (a -> b) -> Effect a -> Effect b +map fn effect = + case effect of + None -> + None + + Cmd cmd -> + Cmd (Cmd.map fn cmd) + + Shared msg -> + Shared msg + + Batch list -> + Batch (List.map (map fn) list) + + +fromCmd : Cmd msg -> Effect msg +fromCmd = + Cmd + + +fromShared : Shared.Msg -> Effect msg +fromShared = + Shared + + +batch : List (Effect msg) -> Effect msg +batch = + Batch + + + +-- Used by Main.elm + + +toCmd : ( Shared.Msg -> msg, pageMsg -> msg ) -> Effect pageMsg -> Cmd msg +toCmd ( fromSharedMsg, fromPageMsg ) effect = + case effect of + None -> + Cmd.none + + Cmd cmd -> + Cmd.map fromPageMsg cmd + + Shared msg -> + Task.succeed msg + |> Task.perform fromSharedMsg + + Batch list -> + Cmd.batch (List.map (toCmd ( fromSharedMsg, fromPageMsg )) list) diff --git a/src/cli/src/defaults/Main.elm b/src/cli/src/defaults/Main.elm index 0ad7ffb..93d68c3 100644 --- a/src/cli/src/defaults/Main.elm +++ b/src/cli/src/defaults/Main.elm @@ -2,9 +2,10 @@ module Main exposing (main) import Browser import Browser.Navigation as Nav exposing (Key) +import Effect import Gen.Pages as Pages import Gen.Route as Route -import Request exposing (Request) +import Request import Shared import Url exposing (Url) import View @@ -40,14 +41,13 @@ init flags url key = ( shared, sharedCmd ) = Shared.init (Request.create () url key) flags - ( page, pageCmd, sharedPageCmd ) = + ( page, effect ) = Pages.init (Route.fromUrl url) shared url key in ( Model url key shared page , Cmd.batch [ Cmd.map Shared sharedCmd - , Cmd.map Shared sharedPageCmd - , Cmd.map Page pageCmd + , Effect.toCmd ( Shared, Page ) effect ] ) @@ -79,14 +79,11 @@ update msg model = ChangedUrl url -> if url.path /= model.url.path then let - ( page, pageCmd, sharedPageCmd ) = + ( page, effect ) = Pages.init (Route.fromUrl url) model.shared url model.key in ( { model | url = url, page = page } - , Cmd.batch - [ Cmd.map Page pageCmd - , Cmd.map Shared sharedPageCmd - ] + , Effect.toCmd ( Shared, Page ) effect ) else @@ -103,14 +100,11 @@ update msg model = Page pageMsg -> let - ( page, pageCmd, sharedPageCmd ) = + ( page, effect ) = Pages.update pageMsg model.page model.shared model.url model.key in ( { model | page = page } - , Cmd.batch - [ Cmd.map Page pageCmd - , Cmd.map Shared sharedPageCmd - ] + , Effect.toCmd ( Shared, Page ) effect ) diff --git a/src/cli/src/defaults/Page.elm b/src/cli/src/defaults/Page.elm index d08d70b..37577f7 100644 --- a/src/cli/src/defaults/Page.elm +++ b/src/cli/src/defaults/Page.elm @@ -1,26 +1,54 @@ module Page exposing ( Page - , static, sandbox, element, shared + , static, sandbox, element, advanced + , protected ) {-| @docs Page -@docs static, sandbox, element, shared +@docs static, sandbox, element, advanced -} -import ElmSpa.Page +import Effect exposing (Effect) +import ElmSpa.Internals.Page as ElmSpa +import Gen.Route exposing (Route) +import Request exposing (Request) import Shared import View exposing (View) + +-- PROTECTED OPTIONS + + +{-| Replace "()" with your User type +-} +type alias User = + () + + +{-| This function attempts to get your user from shared state. +-} +getUser : Shared.Model -> Request () -> Maybe User +getUser _ _ = + Nothing + + +{-| This is the route elm-spa redirects to when a user is not signed in on a protected page. +-} +unauthorizedRoute : Route +unauthorizedRoute = + Gen.Route.NotFound + + + +-- PAGES + + type alias Page model msg = - { init : () -> ( model, Cmd msg, List Shared.Msg ) - , update : msg -> model -> ( model, Cmd msg, List Shared.Msg ) - , view : model -> View msg - , subscriptions : model -> Sub msg - } + ElmSpa.Page Shared.Model (Request ()) Gen.Route.Route (Effect msg) (View msg) model msg static : @@ -28,7 +56,7 @@ static : } -> Page () Never static = - ElmSpa.Page.static + ElmSpa.static Effect.none sandbox : @@ -38,7 +66,7 @@ sandbox : } -> Page model msg sandbox = - ElmSpa.Page.sandbox + ElmSpa.sandbox Effect.none element : @@ -49,15 +77,50 @@ element : } -> Page model msg element = - ElmSpa.Page.element + ElmSpa.element Effect.fromCmd -shared : - { init : ( model, Cmd msg, List Shared.Msg ) - , update : msg -> model -> ( model, Cmd msg, List Shared.Msg ) +advanced : + { init : ( model, Effect msg ) + , update : msg -> model -> ( model, Effect msg ) , view : model -> View msg , subscriptions : model -> Sub msg } -> Page model msg -shared = - ElmSpa.Page.shared +advanced = + ElmSpa.advanced + + +protected : + { static : + { view : User -> View msg + } + -> Page () msg + , sandbox : + { init : User -> model + , update : User -> msg -> model -> model + , view : User -> model -> View msg + } + -> Page model msg + , element : + { init : User -> ( model, Cmd msg ) + , update : User -> msg -> model -> ( model, Cmd msg ) + , view : User -> model -> View msg + , subscriptions : User -> model -> Sub msg + } + -> Page model msg + , advanced : + { init : User -> ( model, Effect msg ) + , update : User -> msg -> model -> ( model, Effect msg ) + , view : User -> model -> View msg + , subscriptions : User -> model -> Sub msg + } + -> Page model msg + } +protected = + ElmSpa.protected + { effectNone = Effect.none + , fromCmd = Effect.fromCmd + , user = getUser + , route = unauthorizedRoute + } diff --git a/src/cli/src/defaults/Shared.elm b/src/cli/src/defaults/Shared.elm index 0dc7ca7..033d15f 100644 --- a/src/cli/src/defaults/Shared.elm +++ b/src/cli/src/defaults/Shared.elm @@ -7,10 +7,8 @@ module Shared exposing , update ) -import Browser.Navigation exposing (Key) import Json.Decode as Json import Request exposing (Request) -import Url exposing (Url) type alias Flags = @@ -26,17 +24,17 @@ type Msg init : Request () -> Flags -> ( Model, Cmd Msg ) -init _ flags = +init _ _ = ( {}, Cmd.none ) update : Request () -> Msg -> Model -> ( Model, Cmd Msg ) -update request msg model = +update _ msg model = case msg of NoOp -> ( model, Cmd.none ) subscriptions : Request () -> Model -> Sub Msg -subscriptions request model = +subscriptions _ _ = Sub.none diff --git a/src/cli/src/defaults/View.elm b/src/cli/src/defaults/View.elm index a9b86d5..86faea8 100644 --- a/src/cli/src/defaults/View.elm +++ b/src/cli/src/defaults/View.elm @@ -1,4 +1,4 @@ -module View exposing (View, map, placeholder, toBrowserDocument) +module View exposing (View, map, none, placeholder, toBrowserDocument) import Browser import Html exposing (Html) @@ -17,6 +17,11 @@ placeholder str = } +none : View msg +none = + placeholder "" + + map : (a -> b) -> View a -> View b map fn view = { title = view.title diff --git a/src/cli/src/templates/model.ts b/src/cli/src/templates/model.ts index fa317ea..bb76ca8 100644 --- a/src/cli/src/templates/model.ts +++ b/src/cli/src/templates/model.ts @@ -1,3 +1,4 @@ +import config from "../config" import { pagesImports, paramsImports, pagesModelDefinition, @@ -11,6 +12,6 @@ ${paramsImports(pages)} ${pagesImports(pages)} -${pagesModelDefinition(pages, options)} +${pagesModelDefinition([ [ config.reserved.redirecting ] ].concat(pages), options)} `.trimLeft() diff --git a/src/cli/src/templates/pages.ts b/src/cli/src/templates/pages.ts index fc0db40..acb86d7 100644 --- a/src/cli/src/templates/pages.ts +++ b/src/cli/src/templates/pages.ts @@ -14,13 +14,15 @@ export default (pages : string[][], options : Options) : string => ` module Gen.Pages exposing (Model, Msg, init, subscriptions, update, view) import Browser.Navigation exposing (Key) -import Request exposing (Request) +import Effect exposing (Effect) +import ElmSpa.Internals.Page ${paramsImports(pages)} import Gen.Model as Model import Gen.Msg as Msg import Gen.Route as Route exposing (Route) import Page exposing (Page) ${pagesImports(pages)} +import Request exposing (Request) import Shared import Task import Url exposing (Url) @@ -35,12 +37,12 @@ type alias Msg = Msg.Msg -init : Route -> Shared.Model -> Url -> Key -> ( Model, Cmd Msg, Cmd Shared.Msg ) +init : Route -> Shared.Model -> Url -> Key -> ( Model, Effect Msg ) init route = ${pagesInitBody(pages)} -update : Msg -> Model -> Shared.Model -> Url -> Key -> ( Model, Cmd Msg, Cmd Shared.Msg ) +update : Msg -> Model -> Shared.Model -> Url -> Key -> ( Model, Effect Msg ) update msg_ model_ = ${pagesUpdateBody(pages.filter(page => options.isStatic(page) === false), options)} ${pages.length > 1 ? pagesUpdateCatchAll : ''} @@ -67,49 +69,24 @@ ${pagesBundleDefinition(pages, options)} type alias Bundle params model msg = - { init : params -> Shared.Model -> Url -> Key -> ( Model, Cmd Msg, Cmd Shared.Msg ) - , update : params -> msg -> model -> Shared.Model -> Url -> Key -> ( Model, Cmd Msg, Cmd Shared.Msg ) - , view : params -> model -> Shared.Model -> Url -> Key -> View Msg - , subscriptions : params -> model -> Shared.Model -> Url -> Key -> Sub Msg - } + ElmSpa.Internals.Page.Bundle params model msg Shared.Model (Effect Msg) Model Msg (View Msg) -bundle : - (Shared.Model -> Request params -> Page model msg) - -> (params -> model -> Model) - -> (msg -> Msg) - -> Bundle params model msg bundle page toModel toMsg = - let - mapTriple : - params - -> ( model, Cmd msg, List Shared.Msg ) - -> ( Model, Cmd Msg, Cmd Shared.Msg ) - mapTriple params ( model, cmd, sharedMsgList ) = - ( toModel params model - , Cmd.map toMsg cmd - , sharedMsgList - |> List.map (Task.succeed >> Task.perform identity) - |> Cmd.batch - ) - in - { init = - \\params shared url key -> - (page shared (Request.create params url key)).init () - |> mapTriple params - , update = - \\params msg model shared url key -> - (page shared (Request.create params url key)).update msg model - |> mapTriple params - , view = - \\params model shared url key -> - (page shared (Request.create params url key)).view model - |> View.map toMsg - , subscriptions = - \\params model shared url key -> - (page shared (Request.create params url key)).subscriptions model - |> Sub.map toMsg - } + ElmSpa.Internals.Page.bundle + { redirecting = + { model = Model.Redirecting_ + , view = View.none + } + , toRoute = Route.fromUrl + , toUrl = Route.toHref + , fromCmd = Effect.fromCmd + , mapEffect = Effect.map toMsg + , mapView = View.map toMsg + , toModel = toModel + , toMsg = toMsg + , page = page + } type alias Static params = @@ -118,8 +95,8 @@ type alias Static params = static : View Never -> (params -> Model) -> Static params static view_ toModel = - { init = \\params _ _ _ -> ( toModel params, Cmd.none, Cmd.none ) - , update = \\params _ _ _ _ _ -> ( toModel params, Cmd.none, Cmd.none ) + { init = \\params _ _ _ -> ( toModel params, Effect.none ) + , update = \\params _ _ _ _ _ -> ( toModel params, Effect.none ) , view = \\_ _ _ _ _ -> View.map never view_ , subscriptions = \\_ _ _ _ _ -> Sub.none } diff --git a/src/cli/src/templates/utils.ts b/src/cli/src/templates/utils.ts index ce6834d..43675ff 100644 --- a/src/cli/src/templates/utils.ts +++ b/src/cli/src/templates/utils.ts @@ -166,10 +166,12 @@ const pageModuleName = (path : string[]) : string => export const pagesModelDefinition = (paths : string[][], options : Options) : string => customType('Model', - paths.map(path => - options.isStatic(path) - ? `${modelVariant(path)} ${params(path)}` - : `${modelVariant(path)} ${params(path)} ${model(path)}` + paths.map(path => + path[0] === config.reserved.redirecting + ? config.reserved.redirecting + : options.isStatic(path) + ? `${modelVariant(path)} ${params(path)}` + : `${modelVariant(path)} ${params(path)} ${model(path)}` ) ) @@ -238,10 +240,10 @@ export const pagesUpdateBody = (paths: string[][], options : Options) : string = export const pagesUpdateCatchAll = ` _ -> - \\_ _ _ -> ( model_, Cmd.none, Cmd.none )` + \\_ _ _ -> ( model_, Effect.none )` export const pagesViewBody = (paths: string[][], options : Options) : string => - indent(caseExpression(paths, { + indent(caseExpressionWithRedirectingModel(`\\_ _ _ -> View.none`, paths, { variable: 'model_', condition: path => `${destructuredModel(path, options)}`, result: path => `pages.${bundleName(path)}.view ${pageModelArguments(path, options)}` @@ -249,12 +251,23 @@ export const pagesViewBody = (paths: string[][], options : Options) : string => export const pagesSubscriptionsBody = (paths: string[][], options : Options) : string => - indent(caseExpression(paths, { + indent(caseExpressionWithRedirectingModel(`\\_ _ _ -> Sub.none`, paths, { variable: 'model_', condition: path => `${destructuredModel(path, options)}`, result: path => `pages.${bundleName(path)}.subscriptions ${pageModelArguments(path, options)}` })) +const caseExpressionWithRedirectingModel = (fallback : string, items: string[][], options : { variable : string, condition : (item: string[]) => string, result: (item: string[]) => string }) => + caseExpression([ [ config.reserved.redirecting ] ].concat(items), { + variable: options.variable, + condition: (item) => item[0] === config.reserved.redirecting + ? `Model.${config.reserved.redirecting}` + : options.condition(item), + result: (item) => item[0] === config.reserved.redirecting + ? fallback + : options.result(item) + }) + const caseExpression = (items: T[], options : { variable : string, condition : (item: T) => string, result: (item: T) => string }) => `case ${options.variable} of ${items.map(item => ` ${options.condition(item)} ->\n ${options.result(item)}`).join('\n\n')}`