From 765636ee887faed183b811392f8469254a9e48ec Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Tue, 5 Oct 2021 16:47:37 -0400 Subject: [PATCH] Request update works; toasts work --- src/MyPrayerJournal/Server/Handlers.fs | 271 ++++----- src/MyPrayerJournal/Server/Views.fs | 525 +++++++++--------- .../Server/wwwroot/script/mpj.js | 75 ++- .../Server/wwwroot/style/style.css | 7 + 4 files changed, 491 insertions(+), 387 deletions(-) diff --git a/src/MyPrayerJournal/Server/Handlers.fs b/src/MyPrayerJournal/Server/Handlers.fs index 9ffcbab..075917f 100644 --- a/src/MyPrayerJournal/Server/Handlers.fs +++ b/src/MyPrayerJournal/Server/Handlers.fs @@ -6,32 +6,6 @@ module MyPrayerJournal.Handlers open Giraffe open Giraffe.Htmx -open MyPrayerJournal.Data.Extensions - -let writeView view : HttpHandler = - fun next ctx -> task { - return! ctx.WriteHtmlViewAsync view - } - -/// Send a partial result if this is not a full page load -let partialIfNotRefresh (pageTitle : string) content : HttpHandler = - fun next ctx -> - (next, ctx) - ||> match ctx.Request.IsHtmx && not ctx.Request.IsHtmxRefresh with - | true -> - ctx.Response.Headers.["X-Page-Title"] <- Microsoft.Extensions.Primitives.StringValues pageTitle - withHxTriggerAfterSettle "menu-refresh" >=> writeView content - | false -> writeView (Views.Layout.view pageTitle content) - -/// Handler to return Vue files -module Vue = - - /// The application index page - let app : HttpHandler = - withHxTrigger "menu-refresh" - >=> partialIfNotRefresh "" (ViewEngine.HtmlElements.str "It works") - - open System /// Handlers for error conditions @@ -46,26 +20,26 @@ module Error = /// Handle 404s from the API, sending known URL paths to the Vue app so that they can be handled there let notFound : HttpHandler = - fun next ctx -> - [ "/journal"; "/legal"; "/request"; "/user" ] - |> List.filter ctx.Request.Path.Value.StartsWith - |> List.length - |> function - | 0 -> (setStatusCode 404 >=> json ([ "error", "not found" ] |> dict)) next ctx - | _ -> Vue.app next ctx + setStatusCode 404 >=> text "Not found" -open Cuid -open LiteDB -open System.Security.Claims -open Microsoft.Net.Http.Headers /// Handler helpers [] module private Helpers = + open Cuid + open LiteDB open Microsoft.AspNetCore.Http + open Microsoft.Extensions.Logging + open Microsoft.Net.Http.Headers + open System.Security.Claims open System.Threading.Tasks + let debug (ctx : HttpContext) message = + let fac = ctx.GetService() + let log = fac.CreateLogger "Debug" + log.LogInformation message + /// Get the LiteDB database let db (ctx : HttpContext) = ctx.GetService() @@ -107,20 +81,31 @@ module private Helpers = let authorize : HttpHandler = fun next ctx -> match user ctx with Some _ -> next ctx | None -> notAuthorized next ctx - /// Flip JSON result so we can pipe into it - let asJson<'T> next ctx (o : 'T) = - json o next ctx - - /// Trigger a menu item refresh - let withMenuRefresh : HttpHandler = - withHxTriggerAfterSettle "menu-refresh" - /// Render a component result let renderComponent nodes : HttpHandler = fun next ctx -> task { return! ctx.WriteHtmlStringAsync (ViewEngine.RenderView.AsString.htmlNodes nodes) } + /// Composable handler to write a view to the output + let writeView view : HttpHandler = + fun next ctx -> task { + return! ctx.WriteHtmlViewAsync view + } + + /// Send a partial result if this is not a full page load + let partialIfNotRefresh (pageTitle : string) content : HttpHandler = + fun next ctx -> + (next, ctx) + ||> match ctx.Request.IsHtmx && not ctx.Request.IsHtmxRefresh with + | true -> + ctx.Response.Headers.["X-Page-Title"] <- Microsoft.Extensions.Primitives.StringValues pageTitle + withHxTriggerAfterSettle "menu-refresh" >=> writeView content + | false -> writeView (Views.Layout.view pageTitle content) + + /// Add a success message header to the response + let withSuccessMessage : string -> HttpHandler = + sprintf "success|||%s" >> setHttpHeader "X-Toast" /// Strongly-typed models for post requests module Models = @@ -131,7 +116,7 @@ module Models = /// The status of the history update status : string /// The text of the update - updateText : string + updateText : string option } /// An additional note @@ -154,15 +139,15 @@ module Models = [] type Request = { /// The ID of the request - id : string + requestId : string /// The text of the request - requestText : string + requestText : string /// The additional status to record - status : string option + status : string option /// The recurrence type - recurType : string + recurType : string /// The recurrence count - recurCount : int16 option + recurCount : int16 option /// The recurrence interval recurInterval : string option } @@ -175,6 +160,8 @@ module Models = } +open MyPrayerJournal.Data.Extensions + /// Handlers for less-than-full-page HTML requests module Components = @@ -193,6 +180,34 @@ module Components = let! jrnl = Data.journalByUserId (userId ctx) (db ctx) return! renderComponent [ Views.Journal.journalItems jrnl ] next ctx } + + // GET /components/request/[req-id]/edit + let requestEdit requestId : HttpHandler = + authorize + >=> fun next ctx -> task { + match requestId with + | "new" -> + return! partialIfNotRefresh "Add Prayer Request" + (Views.Request.edit (JournalRequest.ofRequestLite Request.empty) false) next ctx + | _ -> + match! Data.tryJournalById (RequestId.ofString requestId) (userId ctx) (db ctx) with + | Some req -> + return! partialIfNotRefresh "Edit Prayer Request" (Views.Request.edit req false) next ctx + | None -> return! Error.notFound next ctx + } + + // GET /components/request-item/[req-id] + let requestItem reqId : HttpHandler = + authorize + >=> fun next ctx -> task { + match! Data.tryJournalById (RequestId.ofString reqId) (userId ctx) (db ctx) with + | Some req -> + debug ctx "Found the item" + return! renderComponent [ Views.Request.reqListItem req ] next ctx + | None -> + debug ctx "Did not find the item" + return! Error.notFound next ctx + } /// / URL @@ -200,7 +215,7 @@ module Home = // GET / let home : HttpHandler = - withMenuRefresh >=> partialIfNotRefresh "Welcome!" Views.Home.home + partialIfNotRefresh "Welcome!" Views.Home.home // GET /user/log-on let logOn : HttpHandler = @@ -213,7 +228,6 @@ module Journal = // GET /journal let journal : HttpHandler = authorize - >=> withMenuRefresh >=> fun next ctx -> task { let usr = ctx.Request.Headers.["X-Given-Name"].[0] return! partialIfNotRefresh "Your Prayer Journal" (Views.Journal.journal usr) next ctx @@ -225,11 +239,11 @@ module Legal = // GET /legal/privacy-policy let privacyPolicy : HttpHandler = - withMenuRefresh >=> partialIfNotRefresh "Privacy Policy" Views.Legal.privacyPolicy + partialIfNotRefresh "Privacy Policy" Views.Legal.privacyPolicy // GET /legal/terms-of-service let termsOfService : HttpHandler = - withMenuRefresh >=> partialIfNotRefresh "Terms of Service" Views.Legal.termsOfService + partialIfNotRefresh "Terms of Service" Views.Legal.termsOfService /// Alias for the Ply task module (The F# "task" CE can't handle differing types well within the same CE) @@ -239,72 +253,6 @@ module Ply = FSharp.Control.Tasks.Affine /// /api/request and /request(s) URLs module Request = - // GET /request/[req-id]/edit - let edit requestId : HttpHandler = - authorize - >=> fun next ctx -> task { - match requestId with - | "new" -> - return! partialIfNotRefresh "Add Prayer Request" - (Views.Request.edit (JournalRequest.ofRequestLite Request.empty) false) next ctx - | _ -> - match! Data.tryJournalById (RequestId.ofString requestId) (userId ctx) (db ctx) with - | Some req -> return! partialIfNotRefresh "Edit Prayer Request" (Views.Request.edit req false) next ctx - | None -> return! Error.notFound next ctx - } - - /// Add a new prayer request - let private addRequest (form : Models.Request) : HttpHandler = - fun next ctx -> task { - let db = db ctx - let usrId = userId ctx - let now = jsNow () - let req = - { Request.empty with - userId = usrId - enteredOn = now - showAfter = Ticks 0L - recurType = Recurrence.fromString (match form.recurInterval with Some x -> x | _ -> "Immediate") - recurCount = defaultArg form.recurCount (int16 0) - history = [ - { asOf = now - status = Created - text = Some form.requestText - } - ] - } - Data.addRequest req db - do! db.saveChanges () - return! (withHxRedirect "/journal" >=> createdAt (RequestId.toString req.id |> sprintf "/request/%s")) next ctx - } - - /// Update a prayer request - let private updateRequest (form : Models.Request) : HttpHandler = - fun next ctx -> Ply.task { - let db = db ctx - let usrId = userId ctx - match! Data.tryJournalById (RequestId.ofString form.id) usrId db with - | Some req -> - // TODO: Update recurrence if changed - let text = - match form.requestText.Trim () = req.text with - | true -> None - | false -> form.requestText.Trim () |> Some - do! Data.addHistory req.requestId usrId - { asOf = jsNow (); status = (Option.get >> RequestAction.fromString) form.status; text = text } db - return! setStatusCode 200 next ctx - | None -> return! Error.notFound next ctx - } - - /// POST /request - let save : HttpHandler = - authorize - >=> fun next ctx -> task { - let! form = ctx.BindModelAsync () - let func = match form.id with "new" -> addRequest | _ -> updateRequest - return! func form next ctx - } - /// POST /api/request/[req-id]/history let addHistory requestId : HttpHandler = authorize @@ -320,7 +268,7 @@ module Request = do! Data.addHistory reqId usrId { asOf = now status = act - text = match hist.updateText with null | "" -> None | x -> Some x + text = match hist.updateText with None | Some "" -> None | x -> x } db match act with | Prayed -> @@ -338,7 +286,6 @@ module Request = /// POST /api/request/[req-id]/note let addNote requestId : HttpHandler = authorize - // >=> allowSyncIO >=> fun next ctx -> task { let db = db ctx let usrId = userId ctx @@ -355,7 +302,6 @@ module Request = /// GET /requests/active let active : HttpHandler = authorize - >=> withMenuRefresh >=> fun next ctx -> task { let! reqs = Data.journalByUserId (userId ctx) (db ctx) return! partialIfNotRefresh "Active Requests" (Views.Request.active reqs) next ctx @@ -364,7 +310,6 @@ module Request = /// GET /requests/answered let answered : HttpHandler = authorize - >=> withMenuRefresh >=> fun next ctx -> task { let! reqs = Data.answeredRequests (userId ctx) (db ctx) return! partialIfNotRefresh "Answered Requests" (Views.Request.answered reqs) next ctx @@ -382,7 +327,6 @@ module Request = /// GET /request/[req-id]/full let getFull requestId : HttpHandler = authorize - >=> withMenuRefresh >=> fun next ctx -> task { match! Data.tryFullRequestById (toReqId requestId) (userId ctx) (db ctx) with | Some req -> return! partialIfNotRefresh "Full Prayer Request" (Views.Request.full req) next ctx @@ -448,6 +392,69 @@ module Request = | None -> return! Error.notFound next ctx } + /// Derive a recurrence and interval from its primitive representation in the form + let private parseRecurrence (form : Models.Request) = + (Recurrence.fromString (match form.recurInterval with Some x -> x | _ -> "Immediate"), + defaultArg form.recurCount (int16 0)) + + // POST /request + let add : HttpHandler = + fun next ctx -> task { + let! form = ctx.BindModelAsync () + let db = db ctx + let usrId = userId ctx + let now = jsNow () + let (recur, interval) = parseRecurrence form + let req = + { Request.empty with + userId = usrId + enteredOn = now + showAfter = Ticks 0L + recurType = recur + recurCount = interval + history = [ + { asOf = now + status = Created + text = Some form.requestText + } + ] + } + Data.addRequest req db + do! db.saveChanges () + // TODO: this is not right + return! (withHxRedirect "/journal" >=> createdAt (RequestId.toString req.id |> sprintf "/request/%s")) next ctx + } + + // PATCH /request + let update : HttpHandler = + fun next ctx -> Ply.task { + let! form = ctx.BindModelAsync () + let db = db ctx + let usrId = userId ctx + match! Data.tryJournalById (RequestId.ofString form.requestId) usrId db with + | Some req -> + // step 1 - update recurrence if changed + let (recur, interval) = parseRecurrence form + match recur = req.recurType && interval = req.recurCount with + | true -> () + | false -> + do! Data.updateRecurrence req.requestId usrId recur interval db + match recur with + | Immediate -> do! Data.updateShowAfter req.requestId usrId (Ticks 0L) db + | _ -> () + // step 2 - append history + let upd8Text = form.requestText.Trim () + let text = match upd8Text = req.text with true -> None | false -> Some upd8Text + do! Data.addHistory req.requestId usrId + { asOf = jsNow (); status = (Option.get >> RequestAction.fromString) form.status; text = text } db + do! db.saveChanges () + // step 3 - return updated view + return! (withSuccessMessage "Prayer request updated successfully" + >=> Components.requestItem (RequestId.toString req.requestId)) next ctx + | None -> return! Error.notFound next ctx + } + + open Giraffe.EndpointRouting @@ -456,8 +463,10 @@ let routes = [ GET_HEAD [ route "/" Home.home ] subRoute "/components/" [ GET_HEAD [ - route "journal-items" Components.journalItems - route "nav-items" Components.navItems + route "journal-items" Components.journalItems + route "nav-items" Components.navItems + routef "request/%s/edit" Components.requestEdit + routef "request/%s/item" Components.requestItem ] ] GET_HEAD [ route "/journal" Journal.journal ] @@ -472,10 +481,12 @@ let routes = route "s/active" Request.active route "s/answered" Request.answered routef "/%s/full" Request.getFull - routef "/%s/edit" Request.edit + ] + PATCH [ + route "" Request.update ] POST [ - route "/request" Request.save + route "" Request.add ] ] GET_HEAD [ route "/user/log-on" Home.logOn ] diff --git a/src/MyPrayerJournal/Server/Views.fs b/src/MyPrayerJournal/Server/Views.fs index e78b4a3..13db1fd 100644 --- a/src/MyPrayerJournal/Server/Views.fs +++ b/src/MyPrayerJournal/Server/Views.fs @@ -63,81 +63,85 @@ module Legal = /// View for the "Privacy Policy" page let privacyPolicy = article [ _class "container mt-3" ] [ + h2 [ _class "mb-2" ] [ str "Privacy Policy" ] + h6 [ _class "text-muted pb-3" ] [ str "as of May 21"; sup [] [ str "st"]; str ", 2018" ] + p [] [ + str "The nature of the service is one where privacy is a must. The items below will help you understand the data " + str "we collect, access, and store on your behalf as you use this service." + ] div [ _class "card" ] [ - h5 [ _class "card-header" ] [ str "Privacy Policy" ] - div [ _class "card-body" ] [ - h6 [ _class "card-subtitle text-muted" ] [ str "as of May 21"; sup [] [ str "st"]; str ", 2018" ] - p [ _class "card-text" ] [ - str "The nature of the service is one where privacy is a must. The items below will help you understand " - str "the data we collect, access, and store on your behalf as you use this service." - ] - hr [] - h3 [] [ str "Third Party Services" ] - p [ _class "card-text" ] [ - str "myPrayerJournal utilizes a third-party authentication and identity provider. You should familiarize " - str "yourself with the privacy policy for " - a [ _href "https://auth0.com/privacy"; _target "_blank" ] [ str "Auth0" ] - str ", as well as your chosen provider (" - a [ _href "https://privacy.microsoft.com/en-us/privacystatement"; _target "_blank" ] [ str "Microsoft"] - str " or " - a [ _href "https://policies.google.com/privacy"; _target "_blank" ] [ str "Google" ] - str ")." - ] - hr [] - h3 [] [ str "What We Collect" ] - h4 [] [ str "Identifying Data" ] - ul [] [ - li [] [ - rawText "The only identifying data myPrayerJournal stores is the subscriber (“sub”) field " - str "from the token we receive from Auth0, once you have signed in through their hosted service. " - str "All information is associated with you via this field." - ] - li [] [ - str "While you are signed in, within your browser, the service has access to your first and last names, " - str "along with a URL to the profile picture (provided by your selected identity provider). This " - rawText "information is not transmitted to the server, and is removed when “Log Off” is " - str "clicked." + div [ _class "list-group list-group-flush" ] [ + div [ _class "list-group-item"] [ + h3 [] [ str "Third Party Services" ] + p [ _class "card-text" ] [ + str "myPrayerJournal utilizes a third-party authentication and identity provider. You should familiarize " + str "yourself with the privacy policy for " + a [ _href "https://auth0.com/privacy"; _target "_blank" ] [ str "Auth0" ] + str ", as well as your chosen provider (" + a [ _href "https://privacy.microsoft.com/en-us/privacystatement"; _target "_blank" ] [ str "Microsoft"] + str " or " + a [ _href "https://policies.google.com/privacy"; _target "_blank" ] [ str "Google" ] + str ")." ] ] - h4 [] [ str "User Provided Data" ] - ul [] [ - li [] [ - str "myPrayerJournal stores the information you provide, including the text of prayer requests, updates, " - str "and notes; and the date/time when certain actions are taken." + div [ _class "list-group-item" ] [ + h3 [] [ str "What We Collect" ] + h4 [] [ str "Identifying Data" ] + ul [] [ + li [] [ + rawText "The only identifying data myPrayerJournal stores is the subscriber (“sub”) field " + str "from the token we receive from Auth0, once you have signed in through their hosted service. " + str "All information is associated with you via this field." + ] + li [] [ + str "While you are signed in, within your browser, the service has access to your first and last names, " + str "along with a URL to the profile picture (provided by your selected identity provider). This " + rawText "information is not transmitted to the server, and is removed when “Log Off” is " + str "clicked." + ] + ] + h4 [] [ str "User Provided Data" ] + ul [ _class "mb-0" ] [ + li [] [ + str "myPrayerJournal stores the information you provide, including the text of prayer requests, updates, " + str "and notes; and the date/time when certain actions are taken." + ] ] ] - hr [] - h3 [] [ str "How Your Data Is Accessed / Secured" ] - ul [] [ - li [] [ - str "Your provided data is returned to you, as required, to display your journal or your answered " - str "requests. On the server, it is stored in a controlled-access database." - ] - li [] [ - str "Your data is backed up, along with other Bit Badger Solutions hosted systems, in a rolling manner; " - str "backups are preserved for the prior 7 days, and backups from the 1" - sup [] [ str "st" ] - str " and 15" - sup [] [ str "th" ] - str " are preserved for 3 months. These backups are stored in a private cloud data repository." - ] - li [] [ - str "The data collected and stored is the absolute minimum necessary for the functionality of the " - rawText "service. There are no plans to “monetize” this service, and storing the minimum " - str "amount of information means that the data we have is not interesting to purchasers (or those who " - str "may have more nefarious purposes)." - ] - li [] [ - str "Access to servers and backups is strictly controlled and monitored for unauthorized access attempts." + div [ _class "list-group-item" ] [ + h3 [] [ str "How Your Data Is Accessed / Secured" ] + ul [ _class "mb-0" ] [ + li [] [ + str "Your provided data is returned to you, as required, to display your journal or your answered " + str "requests. On the server, it is stored in a controlled-access database." + ] + li [] [ + str "Your data is backed up, along with other Bit Badger Solutions hosted systems, in a rolling manner; " + str "backups are preserved for the prior 7 days, and backups from the 1" + sup [] [ str "st" ] + str " and 15" + sup [] [ str "th" ] + str " are preserved for 3 months. These backups are stored in a private cloud data repository." + ] + li [] [ + str "The data collected and stored is the absolute minimum necessary for the functionality of the " + rawText "service. There are no plans to “monetize” this service, and storing the minimum " + str "amount of information means that the data we have is not interesting to purchasers (or those who " + str "may have more nefarious purposes)." + ] + li [] [ + str "Access to servers and backups is strictly controlled and monitored for unauthorized access attempts." + ] ] ] - hr [] - h3 [] [ str "Removing Your Data" ] - p [ _class "card-text" ] [ - str "At any time, you may choose to discontinue using this service. Both Microsoft and Google provide ways " - str "to revoke access from this application. However, if you want your data removed from the database, " - str "please contact daniel at bitbadger.solutions (via e-mail, replacing at with @) prior to doing so, to " - str "ensure we can determine which subscriber ID belongs to you." + div [ _class "list-group-item" ] [ + h3 [] [ str "Removing Your Data" ] + p [ _class "card-text" ] [ + str "At any time, you may choose to discontinue using this service. Both Microsoft and Google provide ways " + str "to revoke access from this application. However, if you want your data removed from the database, " + str "please contact daniel at bitbadger.solutions (via e-mail, replacing at with @) prior to doing so, to " + str "ensure we can determine which subscriber ID belongs to you." + ] ] ] ] @@ -145,55 +149,64 @@ module Legal = /// View for the "Terms of Service" page let termsOfService = article [ _class "container mt-3" ] [ + h2 [ _class "mb-2" ] [ str "Terms of Service" ] + h6 [ _class "text-muted pb-3"] [ str "as of May 21"; sup [] [ str "st" ]; str ", 2018" ] div [ _class "card" ] [ - h5 [ _class "card-header" ] [ str "Terms of Service" ] - div [ _class "card-body" ] [ - h6 [ _class "card-subtitle text-muted"] [ str "as of May 21"; sup [] [ str "st" ]; str ", 2018" ] - h3 [] [ str "1. Acceptance of Terms" ] - p [ _class "card-text" ] [ - str "By accessing this web site, you are agreeing to be bound by these Terms and Conditions, and that you " - str "are responsible to ensure that your use of this site complies with all applicable laws. Your continued " - str "use of this site implies your acceptance of these terms." + div [ _class "list-group list-group-flush" ] [ + div [ _class "list-group-item" ] [ + h3 [] [ str "1. Acceptance of Terms" ] + p [ _class "card-text" ] [ + str "By accessing this web site, you are agreeing to be bound by these Terms and Conditions, and that you " + str "are responsible to ensure that your use of this site complies with all applicable laws. Your " + str "continued use of this site implies your acceptance of these terms." + ] ] - h3 [] [ str "2. Description of Service and Registration" ] - p [ _class "card-text" ] [ - str "myPrayerJournal is a service that allows individuals to enter and amend their prayer requests. It " - str "requires no registration by itself, but access is granted based on a successful login with an external " - str "identity provider. See " - pageLink "/legal/privacy-policy" [] [ str "our privacy policy" ] - str " for details on how that information is accessed and stored." + div [ _class "list-group-item" ] [ + h3 [] [ str "2. Description of Service and Registration" ] + p [ _class "card-text" ] [ + str "myPrayerJournal is a service that allows individuals to enter and amend their prayer requests. It " + str "requires no registration by itself, but access is granted based on a successful login with an " + str "external identity provider. See " + pageLink "/legal/privacy-policy" [] [ str "our privacy policy" ] + str " for details on how that information is accessed and stored." + ] ] - h3 [] [ str "3. Third Party Services" ] - p [ _class "card-text" ] [ - str "This service utilizes a third-party service provider for identity management. Review the terms of " - str "service for" - a [ _href "https://auth0.com/terms"; _target "_blank" ] [ str "Auth0"] - str ", as well as those for the selected authorization provider (" - a [ _href "https://www.microsoft.com/en-us/servicesagreement"; _target "_blank" ] [ str "Microsoft"] - str " or " - a [ _href "https://policies.google.com/terms"; _target "_blank" ] [ str "Google" ] - str ")." + div [ _class "list-group-item" ] [ + h3 [] [ str "3. Third Party Services" ] + p [ _class "card-text" ] [ + str "This service utilizes a third-party service provider for identity management. Review the terms of " + str "service for " + a [ _href "https://auth0.com/terms"; _target "_blank" ] [ str "Auth0"] + str ", as well as those for the selected authorization provider (" + a [ _href "https://www.microsoft.com/en-us/servicesagreement"; _target "_blank" ] [ str "Microsoft"] + str " or " + a [ _href "https://policies.google.com/terms"; _target "_blank" ] [ str "Google" ] + str ")." + ] ] - h3 [] [ str "4. Liability" ] - p [ _class "card-text" ] [ - rawText "This service is provided “as is”, and no warranty (express or implied) exists. The " - str "service and its developers may not be held liable for any damages that may arise through the use of " - str "this service." + div [ _class "list-group-item" ] [ + h3 [] [ str "4. Liability" ] + p [ _class "card-text" ] [ + rawText "This service is provided “as is”, and no warranty (express or implied) exists. The " + str "service and its developers may not be held liable for any damages that may arise through the use of " + str "this service." + ] ] - h3 [] [ str "5. Updates to Terms" ] - p [ _class "card-text" ] [ - str "These terms and conditions may be updated at any time, and this service does not have the capability to " - str "notify users when these change. The date at the top of the page will be updated when any of the text of " - str "these terms is updated." - ] - hr [] - p [ _class "card-text" ] [ - str "You may also wish to review our " - pageLink "/legal/privacy-policy" [] [ str "privacy policy" ] - str " to learn how we handle your data." + div [ _class "list-group-item" ] [ + h3 [] [ str "5. Updates to Terms" ] + p [ _class "card-text" ] [ + str "These terms and conditions may be updated at any time, and this service does not have the capability " + str "to notify users when these change. The date at the top of the page will be updated when any of the " + str "text of these terms is updated." + ] ] ] ] + p [ _class "pt-3" ] [ + str "You may also wish to review our " + pageLink "/legal/privacy-policy" [] [ str "privacy policy" ] + str " to learn how we handle your data." + ] ] @@ -272,59 +285,49 @@ module Request = let isAnswered = req.lastStatus = Answered let isSnoozed = Ticks.toLong req.snoozedUntil > jsNow let isPending = (not isSnoozed) && Ticks.toLong req.showAfter > jsNow - let btnClass = _class "btn btn-light" - tr [] [ - td [ _class "action-cell" ] [ - div [ _class "btn-group btn-group-sm"; Accessibility._roleGroup ] [ - pageLink $"/request/{reqId}/full" [ btnClass; _title "View Full Request" ] [ icon "description" ] - if not isAnswered then - pageLink $"/request/{reqId}/edit" [ btnClass; _title "Edit Request" ] [ icon "edit" ] - // TODO: these next two should use hx-patch, targeting replacement of this tr when complete - if isSnoozed then - pageLink $"/request/{reqId}/cancel-snooze" [ btnClass; _title "Cancel Snooze" ] [ icon "restore" ] - if isPending then - pageLink $"/request/{reqId}/show-now" [ btnClass; _title "Show Now" ] [ icon "restore" ] - ] - ] - td [] [ - p [ _class "mpj-request-text mb-0" ] [ - str req.text - if isSnoozed || isPending || isAnswered then - br [] - small [ _class "text-muted" ] [ - em [] [ - if isSnoozed then - str "Snooze expires " - relativeDate req.snoozedUntil - if isPending then - str "Request appears next " - relativeDate req.showAfter - if isAnswered then - str "Answered " - relativeDate req.asOf - ] + let btnClass = _class "btn btn-light mx-2" + div [ + _class "list-group-item px-0 d-flex flex-row align-items-start" + _hxTarget "this" + _hxSwap HxSwap.OuterHtml + ] [ + pageLink $"/request/{reqId}/full" [ btnClass; _title "View Full Request" ] [ icon "description" ] + if not isAnswered then + button [ btnClass; _hxGet $"/components/request/{reqId}/edit"; _title "Edit Request" ] [ icon "edit" ] + // TODO: these next two should use hx-patch, targeting replacement of this tr when complete + if isSnoozed then + pageLink $"/request/{reqId}/cancel-snooze" [ btnClass; _title "Cancel Snooze" ] [ icon "restore" ] + if isPending then + pageLink $"/request/{reqId}/show-now" [ btnClass; _title "Show Now" ] [ icon "restore" ] + p [ _class "mpj-request-text mb-0" ] [ + str req.text + if isSnoozed || isPending || isAnswered then + br [] + small [ _class "text-muted" ] [ + em [] [ + if isSnoozed then + str "Snooze expires " + relativeDate req.snoozedUntil + if isPending then + str "Request appears next " + relativeDate req.showAfter + if isAnswered then + str "Answered " + relativeDate req.asOf ] - ] + ] ] ] /// Create a list of requests let reqList reqs = - table [ _class "table table-hover table-sm align-top" ] [ - thead [] [ - tr [] [ - th [ _scope "col" ] [ str "Actions" ] - th [ _scope "col" ] [ str "Request" ] - ] - ] - reqs - |> List.map reqListItem - |> tbody [] - ] + reqs + |> List.map reqListItem + |> div [ _class "list-group" ] /// View for Active Requests page let active reqs = article [ _class "container mt-3" ] [ - h2 [] [ str "Active Requests" ] + h2 [ _class "pb-3" ] [ str "Active Requests" ] match reqs |> List.isEmpty with | true -> noResults "No Active Requests" "/journal" "Return to your journal" @@ -334,7 +337,7 @@ module Request = /// View for Answered Requests page let answered reqs = article [ _class "container mt-3" ] [ - h2 [] [ str "Answered Requests" ] + h2 [ _class "pb-3" ] [ str "Answered Requests" ] match reqs |> List.isEmpty with | true -> noResults "No Active Requests" "/journal" "Return to your journal" [ @@ -346,7 +349,7 @@ module Request = /// View for Snoozed Requests page let snoozed reqs = article [ _class "container mt-3" ] [ - h2 [] [ str "Snoozed Requests" ] + h2 [ _class "pb-3" ] [ str "Snoozed Requests" ] reqList reqs ] @@ -409,110 +412,124 @@ module Request = ] ] - let edit (req : JournalRequest) isNew = article [ _class "container mt-3" ] [ - form [ _hxPost "/request" ] [ - input [ - _type "hidden" - _name "requestId" - _value (match isNew with true -> "new" | false -> RequestId.toString req.requestId) - ] - div [ _class "form-floating mb-3" ] [ - textarea [ - _id "requestText" - _name "requestText" - _class "form-control" - _style "min-height: 4rem;" - _placeholder "Enter the text of the request" - _autofocus; _required - ] [ str req.text ] - label [ _for "requestText" ] [ str "Prayer Request" ] - ] - br [] - match isNew with - | true -> - label [] [ str "Also Mark As" ] - br [] - div [ _class "form-check form-check-inline" ] [ - input [ _type "radio"; _class "form-check-input"; _id "sU"; _name "status"; _value "Updated"; _checked ] - label [ _for "sU" ] [ str "Updated" ] - ] - div [ _class "form-check form-check-inline" ] [ - input [ _type "radio"; _class "form-check-input"; _id "sP"; _name "status"; _value "Prayed" ] - label [ _for "sP" ] [ str "Prayed" ] - ] - div [ _class "form-check form-check-inline" ] [ - input [ _type "radio"; _class "form-check-input"; _id "sA"; _name "status"; _value "Answered" ] - label [ _for "sA" ] [ str "Answered" ] - ] - br [] - | false -> () - label [] [ - rawText "Recurrence     " - em [ _class "text-muted" ] [ rawText "After prayer, request reappears…" ] - ] - br [] - div [ _class "row" ] [ - div [ _class "col-4 form-check" ] [ - input [ - _type "radio" - _class "form-check-input" - _id "rI" - _name "recurType" - _value "Immediate" - _onclick "toggleRecurrence" - match req.recurType with Immediate -> _checked | _ -> () - ] - label [ _for "rI" ] [ str "Immediately" ] + /// View for the edit request component + let edit (req : JournalRequest) isNew = + let cancelUrl = req.requestId |> (RequestId.toString >> sprintf "/components/request/%s/item") + section [ _class "container list-group-item"; _hxTarget "this"; _hxSwap HxSwap.OuterHtml ] [ + h5 [ _class "pb-3" ] [ (match isNew with true -> "Add" | false -> "Edit") |> strf "%s Prayer Request" ] + form [ "/request" |> match isNew with true -> _hxPost | false -> _hxPatch ] [ + input [ + _type "hidden" + _name "requestId" + _value (match isNew with true -> "new" | false -> RequestId.toString req.requestId) ] - div [ _class "col-3 form-check"] [ - input [ - _type "radio" - _class "form-check-input" - _id "rO" - _name "recurType" - _value "Other" - _onclick "toggleRecurrence" - match req.recurType with Immediate -> () | _ -> _checked - ] - label [ _for "rO" ] [ rawText "Every…" ] - ] - div [ _class "col-2 form-floating"] [ - input [ - _type "number" + div [ _class "form-floating pb-3" ] [ + textarea [ + _id "requestText" + _name "requestText" _class "form-control" - _id "recurCount" - _name "recurCount" - _placeholder "0" - _value (string req.recurCount) - _required - ] - label [ _for "recurCount" ] [ str "Count" ] + _style "min-height: 8rem;" + _placeholder "Enter the text of the request" + _autofocus; _required + ] [ str req.text ] + label [ _for "requestText" ] [ str "Prayer Request" ] ] - div [ _class "col-3 form-floating" ] [ - select [ _class "form-control"; _id "recurInterval"; _name "recurInterval"; _required ] [ - option [ _value "Hours"; match req.recurType with Hours -> _selected | _ -> () ] [ str "hours" ] - option [ _value "Days"; match req.recurType with Days -> _selected | _ -> () ] [ str "days" ] - option [ _value "Weeks"; match req.recurType with Weeks -> _selected | _ -> () ] [ str "weeks" ] + br [] + match isNew with + | true -> () + | false -> + div [ _class "pb-3" ] [ + label [] [ str "Also Mark As" ] + br [] + div [ _class "form-check form-check-inline" ] [ + input [ _type "radio"; _class "form-check-input"; _id "sU"; _name "status"; _value "Updated"; _checked ] + label [ _for "sU" ] [ str "Updated" ] + ] + div [ _class "form-check form-check-inline" ] [ + input [ _type "radio"; _class "form-check-input"; _id "sP"; _name "status"; _value "Prayed" ] + label [ _for "sP" ] [ str "Prayed" ] + ] + div [ _class "form-check form-check-inline" ] [ + input [ _type "radio"; _class "form-check-input"; _id "sA"; _name "status"; _value "Answered" ] + label [ _for "sA" ] [ str "Answered" ] + ] + ] + div [ _class "row" ] [ + div [ _class "col-12 offset-md-2 col-md-8 offset-lg-3 col-lg-6" ] [ + p [] [ + strong [] [ rawText "Recurrence   " ] + em [ _class "text-muted" ] [ rawText "After prayer, request reappears…" ] + ] + div [ _class "d-flex flex-row flex-wrap justify-content-center align-items-center" ] [ + div [ _class "form-check mx-2" ] [ + input [ + _type "radio" + _class "form-check-input" + _id "rI" + _name "recurType" + _value "Immediate" + _onclick "mpj.edit.toggleRecurrence(event)" + match req.recurType with Immediate -> _checked | _ -> () + ] + label [ _for "rI" ] [ str "Immediately" ] + ] + div [ _class "form-check mx-2"] [ + input [ + _type "radio" + _class "form-check-input" + _id "rO" + _name "recurType" + _value "Other" + _onclick "mpj.edit.toggleRecurrence(event)" + match req.recurType with Immediate -> () | _ -> _checked + ] + label [ _for "rO" ] [ rawText "Every…" ] + ] + div [ _class "form-floating mx-2"] [ + input [ + _type "number" + _class "form-control" + _id "recurCount" + _name "recurCount" + _placeholder "0" + _value (string req.recurCount) + _style "width:6rem;" + _required + match req.recurType with Immediate -> _disabled | _ -> () + ] + label [ _for "recurCount" ] [ str "Count" ] + ] + div [ _class "form-floating mx-2" ] [ + select [ + _class "form-control" + _id "recurInterval" + _name "recurInterval" + _style "width:6rem;" + _required + match req.recurType with Immediate -> _disabled | _ -> () + ] [ + option [ _value "Hours"; match req.recurType with Hours -> _selected | _ -> () ] [ str "hours" ] + option [ _value "Days"; match req.recurType with Days -> _selected | _ -> () ] [ str "days" ] + option [ _value "Weeks"; match req.recurType with Weeks -> _selected | _ -> () ] [ str "weeks" ] + ] + label [ _form "recurInterval" ] [ str "Interval" ] + ] + ] ] - label [ _form "recurInterval" ] [ str "Interval" ] + ] + div [ _class "text-end pt-3" ] [ + button [ _class "btn btn-primary me-2"; _type "submit" ] [ icon "save"; str " Save" ] + a [ _class "btn btn-secondary ms-2"; _href cancelUrl; _hxGet cancelUrl ] [icon "arrow_back"; str " Cancel"] ] ] - div [ _class "text-right" ] [ - button [ _class "btn btn-primary"; _type "submit" ] [ icon "save"; str " Save" ] - a [ _class "btn btn-secondary"; _href "#"; _onclick "history.go(-1)" ] [icon "arrow_back"; str " Cancel"] - ] ] - script [] [ - rawText """toggleRecurrence ({ target }) { - const isDisabled = target.value === "Immediate" - ;["recurCount","recurInterval"].forEach(it => document.getElementById(it).disabled = isDisabled) - }""" - ] - ] /// Layout views module Layout = - + + open Giraffe.ViewEngine.Accessibility + + /// The HTML `head` element let htmlHead pageTitle = head [] [ title [] [ str pageTitle; rawText " « myPrayerJournal" ] @@ -531,6 +548,13 @@ module Layout = ] [] ] + /// Element used to display toasts + let toaster = + div [ _ariaLive "polite"; _ariaAtomic "true"; _id "toastHost" ] [ + div [ _class "toast-container position-absolute p-3 bottom-0 end-0"; _id "toasts" ] [] + ] + + /// The page's `footer` element let htmlFoot = footer [ _class "container-fluid" ] [ p [ _class "text-muted text-end" ] [ @@ -564,7 +588,8 @@ module Layout = htmlHead pageTitle body [ _hxHeaders "" ] [ Navigation.navBar - main [ _hxTrigger "setTitle from:body" ] [ content ] + main [] [ content ] + toaster htmlFoot ] ] diff --git a/src/MyPrayerJournal/Server/wwwroot/script/mpj.js b/src/MyPrayerJournal/Server/wwwroot/script/mpj.js index 6a62369..af865fe 100644 --- a/src/MyPrayerJournal/Server/wwwroot/script/mpj.js +++ b/src/MyPrayerJournal/Server/wwwroot/script/mpj.js @@ -7,7 +7,7 @@ const mpj = { /** The Auth0 client */ auth0: null, /** Configure the Auth0 client */ - configureClient: async () => { + async configureClient () { const response = await fetch("/auth-config.json") const config = await response.json() mpj.auth.auth0 = await createAuth0Client({ @@ -21,16 +21,72 @@ const mpj = { isAuthenticated: false, /** Whether we should redirect to the journal the next time the menu items are refreshed */ redirToJournal: false, - /** Process a log on request */ - logOn: async (e) => { + /** + * Process a log on request + * @param {Event} e The HTML event from the `onclick` event + */ + async logOn (e) { e.preventDefault() await mpj.auth.auth0.loginWithRedirect({ redirect_uri: `${window.location.origin}/user/log-on` }) }, - /** Log the user off */ - logOff: (e) => { + /** + * Log the user off + * @param {Event} e The HTML event from the `onclick` event + */ + logOff (e) { e.preventDefault() mpj.auth.auth0.logout({ returnTo: window.location.origin }) - } + }, + /** + * Show a message via toast + * @param {string} message The message to show + */ + showToast (message) { + const [level, msg] = message.split("|||") + + let header + if (level !== "success") { + const heading = typ => `${typ.toUpperCase()}` + + header = document.createElement("div") + header.className = "toast-header" + header.innerHTML = heading(level === "warning" ? level : "error") + + const close = document.createElement("button") + close.type = "button" + close.className = "btn-close" + close.setAttribute("data-bs-dismiss", "toast") + close.setAttribute("aria-label", "Close") + header.appendChild(close) + } + + const body = document.createElement("div") + body.className = "toast-body" + body.innerText = msg + + const toastEl = document.createElement("div") + toastEl.className = `toast bg-${level} text-white` + toastEl.setAttribute("role", "alert") + toastEl.setAttribute("aria-live", "assertlive") + toastEl.setAttribute("aria-atomic", "true") + toastEl.addEventListener("hidden.bs.toast", e => e.target.remove()) + if (header) toastEl.appendChild(header) + + toastEl.appendChild(body) + document.getElementById("toasts").appendChild(toastEl) + new bootstrap.Toast(toastEl, { autohide: level === "success" }).show() + }, + /** Script for the request edit component */ + edit: { + /** + * Toggle the recurrence input fields + * @param {Event} e The click event + */ + toggleRecurrence ({ target }) { + const isDisabled = target.value === "Immediate" + ;["recurCount","recurInterval"].forEach(it => document.getElementById(it).disabled = isDisabled) + } + }, } window.onload = async () => { @@ -65,12 +121,17 @@ window.onload = async () => { } htmx.on("htmx:afterOnLoad", function (evt) { + const hdrs = evt.detail.xhr.getAllResponseHeaders() // Set the page title if a header was in the response - if (evt.detail.xhr.getAllResponseHeaders().indexOf("x-page-title") >= 0) { + if (hdrs.indexOf("x-page-title") >= 0) { const title = document.querySelector("title") title.innerText = evt.detail.xhr.getResponseHeader("x-page-title") title.innerHTML += " « myPrayerJournal" } + // Show a message if there was one in the response + if (hdrs.indexOf("x-toast") >= 0) { + mpj.showToast(evt.detail.xhr.getResponseHeader("x-toast")) + } }) htmx.on("htmx:afterSettle", function (evt) { // Redirect to the journal (once menu items load after log on) diff --git a/src/MyPrayerJournal/Server/wwwroot/style/style.css b/src/MyPrayerJournal/Server/wwwroot/style/style.css index d8a49aa..5962461 100644 --- a/src/MyPrayerJournal/Server/wwwroot/style/style.css +++ b/src/MyPrayerJournal/Server/wwwroot/style/style.css @@ -34,6 +34,13 @@ form { .action-cell .material-icons { font-size: 1.1rem ; } +.material-icons { + vertical-align: bottom; +} +#toastHost { + position: sticky; + bottom: 0; +} footer { border-top: solid 1px lightgray;