Version 3 #67

Merged
danieljsummers merged 53 commits from version-3 into master 2021-10-26 23:39:59 +00:00
4 changed files with 491 additions and 387 deletions
Showing only changes of commit 765636ee88 - Show all commits

View File

@ -6,32 +6,6 @@ module MyPrayerJournal.Handlers
open Giraffe open Giraffe
open Giraffe.Htmx 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 open System
/// Handlers for error conditions /// 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 /// Handle 404s from the API, sending known URL paths to the Vue app so that they can be handled there
let notFound : HttpHandler = let notFound : HttpHandler =
fun next ctx -> setStatusCode 404 >=> text "Not found"
[ "/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
open Cuid
open LiteDB
open System.Security.Claims
open Microsoft.Net.Http.Headers
/// Handler helpers /// Handler helpers
[<AutoOpen>] [<AutoOpen>]
module private Helpers = module private Helpers =
open Cuid
open LiteDB
open Microsoft.AspNetCore.Http open Microsoft.AspNetCore.Http
open Microsoft.Extensions.Logging
open Microsoft.Net.Http.Headers
open System.Security.Claims
open System.Threading.Tasks open System.Threading.Tasks
let debug (ctx : HttpContext) message =
let fac = ctx.GetService<ILoggerFactory>()
let log = fac.CreateLogger "Debug"
log.LogInformation message
/// Get the LiteDB database /// Get the LiteDB database
let db (ctx : HttpContext) = ctx.GetService<LiteDatabase>() let db (ctx : HttpContext) = ctx.GetService<LiteDatabase>()
@ -107,20 +81,31 @@ module private Helpers =
let authorize : HttpHandler = let authorize : HttpHandler =
fun next ctx -> match user ctx with Some _ -> next ctx | None -> notAuthorized next ctx 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 /// Render a component result
let renderComponent nodes : HttpHandler = let renderComponent nodes : HttpHandler =
fun next ctx -> task { fun next ctx -> task {
return! ctx.WriteHtmlStringAsync (ViewEngine.RenderView.AsString.htmlNodes nodes) 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 /// Strongly-typed models for post requests
module Models = module Models =
@ -131,7 +116,7 @@ module Models =
/// The status of the history update /// The status of the history update
status : string status : string
/// The text of the update /// The text of the update
updateText : string updateText : string option
} }
/// An additional note /// An additional note
@ -154,7 +139,7 @@ module Models =
[<CLIMutable>] [<CLIMutable>]
type Request = { type Request = {
/// The ID of the request /// The ID of the request
id : string requestId : string
/// The text of the request /// The text of the request
requestText : string requestText : string
/// The additional status to record /// The additional status to record
@ -175,6 +160,8 @@ module Models =
} }
open MyPrayerJournal.Data.Extensions
/// Handlers for less-than-full-page HTML requests /// Handlers for less-than-full-page HTML requests
module Components = module Components =
@ -194,13 +181,41 @@ module Components =
return! renderComponent [ Views.Journal.journalItems jrnl ] next 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 /// / URL
module Home = module Home =
// GET / // GET /
let home : HttpHandler = let home : HttpHandler =
withMenuRefresh >=> partialIfNotRefresh "Welcome!" Views.Home.home partialIfNotRefresh "Welcome!" Views.Home.home
// GET /user/log-on // GET /user/log-on
let logOn : HttpHandler = let logOn : HttpHandler =
@ -213,7 +228,6 @@ module Journal =
// GET /journal // GET /journal
let journal : HttpHandler = let journal : HttpHandler =
authorize authorize
>=> withMenuRefresh
>=> fun next ctx -> task { >=> fun next ctx -> task {
let usr = ctx.Request.Headers.["X-Given-Name"].[0] let usr = ctx.Request.Headers.["X-Given-Name"].[0]
return! partialIfNotRefresh "Your Prayer Journal" (Views.Journal.journal usr) next ctx return! partialIfNotRefresh "Your Prayer Journal" (Views.Journal.journal usr) next ctx
@ -225,11 +239,11 @@ module Legal =
// GET /legal/privacy-policy // GET /legal/privacy-policy
let privacyPolicy : HttpHandler = let privacyPolicy : HttpHandler =
withMenuRefresh >=> partialIfNotRefresh "Privacy Policy" Views.Legal.privacyPolicy partialIfNotRefresh "Privacy Policy" Views.Legal.privacyPolicy
// GET /legal/terms-of-service // GET /legal/terms-of-service
let termsOfService : HttpHandler = 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) /// 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 /// /api/request and /request(s) URLs
module Request = 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<Models.Request> ()
let func = match form.id with "new" -> addRequest | _ -> updateRequest
return! func form next ctx
}
/// POST /api/request/[req-id]/history /// POST /api/request/[req-id]/history
let addHistory requestId : HttpHandler = let addHistory requestId : HttpHandler =
authorize authorize
@ -320,7 +268,7 @@ module Request =
do! Data.addHistory reqId usrId do! Data.addHistory reqId usrId
{ asOf = now { asOf = now
status = act status = act
text = match hist.updateText with null | "" -> None | x -> Some x text = match hist.updateText with None | Some "" -> None | x -> x
} db } db
match act with match act with
| Prayed -> | Prayed ->
@ -338,7 +286,6 @@ module Request =
/// POST /api/request/[req-id]/note /// POST /api/request/[req-id]/note
let addNote requestId : HttpHandler = let addNote requestId : HttpHandler =
authorize authorize
// >=> allowSyncIO
>=> fun next ctx -> task { >=> fun next ctx -> task {
let db = db ctx let db = db ctx
let usrId = userId ctx let usrId = userId ctx
@ -355,7 +302,6 @@ module Request =
/// GET /requests/active /// GET /requests/active
let active : HttpHandler = let active : HttpHandler =
authorize authorize
>=> withMenuRefresh
>=> fun next ctx -> task { >=> fun next ctx -> task {
let! reqs = Data.journalByUserId (userId ctx) (db ctx) let! reqs = Data.journalByUserId (userId ctx) (db ctx)
return! partialIfNotRefresh "Active Requests" (Views.Request.active reqs) next ctx return! partialIfNotRefresh "Active Requests" (Views.Request.active reqs) next ctx
@ -364,7 +310,6 @@ module Request =
/// GET /requests/answered /// GET /requests/answered
let answered : HttpHandler = let answered : HttpHandler =
authorize authorize
>=> withMenuRefresh
>=> fun next ctx -> task { >=> fun next ctx -> task {
let! reqs = Data.answeredRequests (userId ctx) (db ctx) let! reqs = Data.answeredRequests (userId ctx) (db ctx)
return! partialIfNotRefresh "Answered Requests" (Views.Request.answered reqs) next ctx return! partialIfNotRefresh "Answered Requests" (Views.Request.answered reqs) next ctx
@ -382,7 +327,6 @@ module Request =
/// GET /request/[req-id]/full /// GET /request/[req-id]/full
let getFull requestId : HttpHandler = let getFull requestId : HttpHandler =
authorize authorize
>=> withMenuRefresh
>=> fun next ctx -> task { >=> fun next ctx -> task {
match! Data.tryFullRequestById (toReqId requestId) (userId ctx) (db ctx) with match! Data.tryFullRequestById (toReqId requestId) (userId ctx) (db ctx) with
| Some req -> return! partialIfNotRefresh "Full Prayer Request" (Views.Request.full req) next ctx | 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 | 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<Models.Request> ()
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<Models.Request> ()
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 open Giraffe.EndpointRouting
@ -458,6 +465,8 @@ let routes =
GET_HEAD [ GET_HEAD [
route "journal-items" Components.journalItems route "journal-items" Components.journalItems
route "nav-items" Components.navItems route "nav-items" Components.navItems
routef "request/%s/edit" Components.requestEdit
routef "request/%s/item" Components.requestItem
] ]
] ]
GET_HEAD [ route "/journal" Journal.journal ] GET_HEAD [ route "/journal" Journal.journal ]
@ -472,10 +481,12 @@ let routes =
route "s/active" Request.active route "s/active" Request.active
route "s/answered" Request.answered route "s/answered" Request.answered
routef "/%s/full" Request.getFull routef "/%s/full" Request.getFull
routef "/%s/edit" Request.edit ]
PATCH [
route "" Request.update
] ]
POST [ POST [
route "/request" Request.save route "" Request.add
] ]
] ]
GET_HEAD [ route "/user/log-on" Home.logOn ] GET_HEAD [ route "/user/log-on" Home.logOn ]

View File

@ -63,15 +63,15 @@ module Legal =
/// View for the "Privacy Policy" page /// View for the "Privacy Policy" page
let privacyPolicy = article [ _class "container mt-3" ] [ let privacyPolicy = article [ _class "container mt-3" ] [
div [ _class "card" ] [ h2 [ _class "mb-2" ] [ str "Privacy Policy" ]
h5 [ _class "card-header" ] [ str "Privacy Policy" ] h6 [ _class "text-muted pb-3" ] [ str "as of May 21"; sup [] [ str "st"]; str ", 2018" ]
div [ _class "card-body" ] [ p [] [
h6 [ _class "card-subtitle text-muted" ] [ str "as of May 21"; sup [] [ str "st"]; str ", 2018" ] str "The nature of the service is one where privacy is a must. The items below will help you understand the data "
p [ _class "card-text" ] [ str "we collect, access, and store on your behalf as you use this service."
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 [] div [ _class "card" ] [
div [ _class "list-group list-group-flush" ] [
div [ _class "list-group-item"] [
h3 [] [ str "Third Party Services" ] h3 [] [ str "Third Party Services" ]
p [ _class "card-text" ] [ p [ _class "card-text" ] [
str "myPrayerJournal utilizes a third-party authentication and identity provider. You should familiarize " str "myPrayerJournal utilizes a third-party authentication and identity provider. You should familiarize "
@ -83,7 +83,8 @@ module Legal =
a [ _href "https://policies.google.com/privacy"; _target "_blank" ] [ str "Google" ] a [ _href "https://policies.google.com/privacy"; _target "_blank" ] [ str "Google" ]
str ")." str ")."
] ]
hr [] ]
div [ _class "list-group-item" ] [
h3 [] [ str "What We Collect" ] h3 [] [ str "What We Collect" ]
h4 [] [ str "Identifying Data" ] h4 [] [ str "Identifying Data" ]
ul [] [ ul [] [
@ -100,15 +101,16 @@ module Legal =
] ]
] ]
h4 [] [ str "User Provided Data" ] h4 [] [ str "User Provided Data" ]
ul [] [ ul [ _class "mb-0" ] [
li [] [ li [] [
str "myPrayerJournal stores the information you provide, including the text of prayer requests, updates, " 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." str "and notes; and the date/time when certain actions are taken."
] ]
] ]
hr [] ]
div [ _class "list-group-item" ] [
h3 [] [ str "How Your Data Is Accessed / Secured" ] h3 [] [ str "How Your Data Is Accessed / Secured" ]
ul [] [ ul [ _class "mb-0" ] [
li [] [ li [] [
str "Your provided data is returned to you, as required, to display your journal or your answered " 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." str "requests. On the server, it is stored in a controlled-access database."
@ -131,7 +133,8 @@ module Legal =
str "Access to servers and backups is strictly controlled and monitored for unauthorized access attempts." str "Access to servers and backups is strictly controlled and monitored for unauthorized access attempts."
] ]
] ]
hr [] ]
div [ _class "list-group-item" ] [
h3 [] [ str "Removing Your Data" ] h3 [] [ str "Removing Your Data" ]
p [ _class "card-text" ] [ p [ _class "card-text" ] [
str "At any time, you may choose to discontinue using this service. Both Microsoft and Google provide ways " str "At any time, you may choose to discontinue using this service. Both Microsoft and Google provide ways "
@ -142,31 +145,37 @@ module Legal =
] ]
] ]
] ]
]
/// View for the "Terms of Service" page /// View for the "Terms of Service" page
let termsOfService = article [ _class "container mt-3" ] [ 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" ] [ div [ _class "card" ] [
h5 [ _class "card-header" ] [ str "Terms of Service" ] div [ _class "list-group list-group-flush" ] [
div [ _class "card-body" ] [ div [ _class "list-group-item" ] [
h6 [ _class "card-subtitle text-muted"] [ str "as of May 21"; sup [] [ str "st" ]; str ", 2018" ]
h3 [] [ str "1. Acceptance of Terms" ] h3 [] [ str "1. Acceptance of Terms" ]
p [ _class "card-text" ] [ 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 "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 "are responsible to ensure that your use of this site complies with all applicable laws. Your "
str "use of this site implies your acceptance of these terms." str "continued use of this site implies your acceptance of these terms."
] ]
]
div [ _class "list-group-item" ] [
h3 [] [ str "2. Description of Service and Registration" ] h3 [] [ str "2. Description of Service and Registration" ]
p [ _class "card-text" ] [ p [ _class "card-text" ] [
str "myPrayerJournal is a service that allows individuals to enter and amend their prayer requests. It " 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 "requires no registration by itself, but access is granted based on a successful login with an "
str "identity provider. See " str "external identity provider. See "
pageLink "/legal/privacy-policy" [] [ str "our privacy policy" ] pageLink "/legal/privacy-policy" [] [ str "our privacy policy" ]
str " for details on how that information is accessed and stored." str " for details on how that information is accessed and stored."
] ]
]
div [ _class "list-group-item" ] [
h3 [] [ str "3. Third Party Services" ] h3 [] [ str "3. Third Party Services" ]
p [ _class "card-text" ] [ p [ _class "card-text" ] [
str "This service utilizes a third-party service provider for identity management. Review the terms of " str "This service utilizes a third-party service provider for identity management. Review the terms of "
str "service for" str "service for "
a [ _href "https://auth0.com/terms"; _target "_blank" ] [ str "Auth0"] a [ _href "https://auth0.com/terms"; _target "_blank" ] [ str "Auth0"]
str ", as well as those for the selected authorization provider (" str ", as well as those for the selected authorization provider ("
a [ _href "https://www.microsoft.com/en-us/servicesagreement"; _target "_blank" ] [ str "Microsoft"] a [ _href "https://www.microsoft.com/en-us/servicesagreement"; _target "_blank" ] [ str "Microsoft"]
@ -174,27 +183,31 @@ module Legal =
a [ _href "https://policies.google.com/terms"; _target "_blank" ] [ str "Google" ] a [ _href "https://policies.google.com/terms"; _target "_blank" ] [ str "Google" ]
str ")." str ")."
] ]
]
div [ _class "list-group-item" ] [
h3 [] [ str "4. Liability" ] h3 [] [ str "4. Liability" ]
p [ _class "card-text" ] [ p [ _class "card-text" ] [
rawText "This service is provided &ldquo;as is&rdquo;, and no warranty (express or implied) exists. The " rawText "This service is provided &ldquo;as is&rdquo;, 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 "service and its developers may not be held liable for any damages that may arise through the use of "
str "this service." str "this service."
] ]
]
div [ _class "list-group-item" ] [
h3 [] [ str "5. Updates to Terms" ] h3 [] [ str "5. Updates to Terms" ]
p [ _class "card-text" ] [ 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 "These terms and conditions may be updated at any time, and this service does not have the capability "
str "notify users when these change. The date at the top of the page will be updated when any of the text of " str "to notify users when these change. The date at the top of the page will be updated when any of the "
str "these terms is updated." str "text of these terms is updated."
] ]
hr [] ]
p [ _class "card-text" ] [ ]
]
p [ _class "pt-3" ] [
str "You may also wish to review our " str "You may also wish to review our "
pageLink "/legal/privacy-policy" [] [ str "privacy policy" ] pageLink "/legal/privacy-policy" [] [ str "privacy policy" ]
str " to learn how we handle your data." str " to learn how we handle your data."
] ]
] ]
]
]
/// Views for navigation support /// Views for navigation support
@ -272,21 +285,20 @@ module Request =
let isAnswered = req.lastStatus = Answered let isAnswered = req.lastStatus = Answered
let isSnoozed = Ticks.toLong req.snoozedUntil > jsNow let isSnoozed = Ticks.toLong req.snoozedUntil > jsNow
let isPending = (not isSnoozed) && Ticks.toLong req.showAfter > jsNow let isPending = (not isSnoozed) && Ticks.toLong req.showAfter > jsNow
let btnClass = _class "btn btn-light" let btnClass = _class "btn btn-light mx-2"
tr [] [ div [
td [ _class "action-cell" ] [ _class "list-group-item px-0 d-flex flex-row align-items-start"
div [ _class "btn-group btn-group-sm"; Accessibility._roleGroup ] [ _hxTarget "this"
_hxSwap HxSwap.OuterHtml
] [
pageLink $"/request/{reqId}/full" [ btnClass; _title "View Full Request" ] [ icon "description" ] pageLink $"/request/{reqId}/full" [ btnClass; _title "View Full Request" ] [ icon "description" ]
if not isAnswered then if not isAnswered then
pageLink $"/request/{reqId}/edit" [ btnClass; _title "Edit Request" ] [ icon "edit" ] 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 // TODO: these next two should use hx-patch, targeting replacement of this tr when complete
if isSnoozed then if isSnoozed then
pageLink $"/request/{reqId}/cancel-snooze" [ btnClass; _title "Cancel Snooze" ] [ icon "restore" ] pageLink $"/request/{reqId}/cancel-snooze" [ btnClass; _title "Cancel Snooze" ] [ icon "restore" ]
if isPending then if isPending then
pageLink $"/request/{reqId}/show-now" [ btnClass; _title "Show Now" ] [ icon "restore" ] pageLink $"/request/{reqId}/show-now" [ btnClass; _title "Show Now" ] [ icon "restore" ]
]
]
td [] [
p [ _class "mpj-request-text mb-0" ] [ p [ _class "mpj-request-text mb-0" ] [
str req.text str req.text
if isSnoozed || isPending || isAnswered then if isSnoozed || isPending || isAnswered then
@ -306,25 +318,16 @@ module Request =
] ]
] ]
] ]
]
/// Create a list of requests /// Create a list of requests
let reqList reqs = 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 reqs
|> List.map reqListItem |> List.map reqListItem
|> tbody [] |> div [ _class "list-group" ]
]
/// View for Active Requests page /// View for Active Requests page
let active reqs = article [ _class "container mt-3" ] [ let active reqs = article [ _class "container mt-3" ] [
h2 [] [ str "Active Requests" ] h2 [ _class "pb-3" ] [ str "Active Requests" ]
match reqs |> List.isEmpty with match reqs |> List.isEmpty with
| true -> | true ->
noResults "No Active Requests" "/journal" "Return to your journal" noResults "No Active Requests" "/journal" "Return to your journal"
@ -334,7 +337,7 @@ module Request =
/// View for Answered Requests page /// View for Answered Requests page
let answered reqs = article [ _class "container mt-3" ] [ let answered reqs = article [ _class "container mt-3" ] [
h2 [] [ str "Answered Requests" ] h2 [ _class "pb-3" ] [ str "Answered Requests" ]
match reqs |> List.isEmpty with match reqs |> List.isEmpty with
| true -> | true ->
noResults "No Active Requests" "/journal" "Return to your journal" [ noResults "No Active Requests" "/journal" "Return to your journal" [
@ -346,7 +349,7 @@ module Request =
/// View for Snoozed Requests page /// View for Snoozed Requests page
let snoozed reqs = article [ _class "container mt-3" ] [ let snoozed reqs = article [ _class "container mt-3" ] [
h2 [] [ str "Snoozed Requests" ] h2 [ _class "pb-3" ] [ str "Snoozed Requests" ]
reqList reqs reqList reqs
] ]
@ -409,19 +412,23 @@ module Request =
] ]
] ]
let edit (req : JournalRequest) isNew = article [ _class "container mt-3" ] [ /// View for the edit request component
form [ _hxPost "/request" ] [ 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 [ input [
_type "hidden" _type "hidden"
_name "requestId" _name "requestId"
_value (match isNew with true -> "new" | false -> RequestId.toString req.requestId) _value (match isNew with true -> "new" | false -> RequestId.toString req.requestId)
] ]
div [ _class "form-floating mb-3" ] [ div [ _class "form-floating pb-3" ] [
textarea [ textarea [
_id "requestText" _id "requestText"
_name "requestText" _name "requestText"
_class "form-control" _class "form-control"
_style "min-height: 4rem;" _style "min-height: 8rem;"
_placeholder "Enter the text of the request" _placeholder "Enter the text of the request"
_autofocus; _required _autofocus; _required
] [ str req.text ] ] [ str req.text ]
@ -429,7 +436,9 @@ module Request =
] ]
br [] br []
match isNew with match isNew with
| true -> | true -> ()
| false ->
div [ _class "pb-3" ] [
label [] [ str "Also Mark As" ] label [] [ str "Also Mark As" ]
br [] br []
div [ _class "form-check form-check-inline" ] [ div [ _class "form-check form-check-inline" ] [
@ -444,39 +453,39 @@ module Request =
input [ _type "radio"; _class "form-check-input"; _id "sA"; _name "status"; _value "Answered" ] input [ _type "radio"; _class "form-check-input"; _id "sA"; _name "status"; _value "Answered" ]
label [ _for "sA" ] [ str "Answered" ] label [ _for "sA" ] [ str "Answered" ]
] ]
br [] ]
| false -> () div [ _class "row" ] [
label [] [ div [ _class "col-12 offset-md-2 col-md-8 offset-lg-3 col-lg-6" ] [
rawText "Recurrence &nbsp; &nbsp; " p [] [
strong [] [ rawText "Recurrence &nbsp; " ]
em [ _class "text-muted" ] [ rawText "After prayer, request reappears&hellip;" ] em [ _class "text-muted" ] [ rawText "After prayer, request reappears&hellip;" ]
] ]
br [] div [ _class "d-flex flex-row flex-wrap justify-content-center align-items-center" ] [
div [ _class "row" ] [ div [ _class "form-check mx-2" ] [
div [ _class "col-4 form-check" ] [
input [ input [
_type "radio" _type "radio"
_class "form-check-input" _class "form-check-input"
_id "rI" _id "rI"
_name "recurType" _name "recurType"
_value "Immediate" _value "Immediate"
_onclick "toggleRecurrence" _onclick "mpj.edit.toggleRecurrence(event)"
match req.recurType with Immediate -> _checked | _ -> () match req.recurType with Immediate -> _checked | _ -> ()
] ]
label [ _for "rI" ] [ str "Immediately" ] label [ _for "rI" ] [ str "Immediately" ]
] ]
div [ _class "col-3 form-check"] [ div [ _class "form-check mx-2"] [
input [ input [
_type "radio" _type "radio"
_class "form-check-input" _class "form-check-input"
_id "rO" _id "rO"
_name "recurType" _name "recurType"
_value "Other" _value "Other"
_onclick "toggleRecurrence" _onclick "mpj.edit.toggleRecurrence(event)"
match req.recurType with Immediate -> () | _ -> _checked match req.recurType with Immediate -> () | _ -> _checked
] ]
label [ _for "rO" ] [ rawText "Every&hellip;" ] label [ _for "rO" ] [ rawText "Every&hellip;" ]
] ]
div [ _class "col-2 form-floating"] [ div [ _class "form-floating mx-2"] [
input [ input [
_type "number" _type "number"
_class "form-control" _class "form-control"
@ -484,12 +493,21 @@ module Request =
_name "recurCount" _name "recurCount"
_placeholder "0" _placeholder "0"
_value (string req.recurCount) _value (string req.recurCount)
_style "width:6rem;"
_required _required
match req.recurType with Immediate -> _disabled | _ -> ()
] ]
label [ _for "recurCount" ] [ str "Count" ] label [ _for "recurCount" ] [ str "Count" ]
] ]
div [ _class "col-3 form-floating" ] [ div [ _class "form-floating mx-2" ] [
select [ _class "form-control"; _id "recurInterval"; _name "recurInterval"; _required ] [ 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 "Hours"; match req.recurType with Hours -> _selected | _ -> () ] [ str "hours" ]
option [ _value "Days"; match req.recurType with Days -> _selected | _ -> () ] [ str "days" ] option [ _value "Days"; match req.recurType with Days -> _selected | _ -> () ] [ str "days" ]
option [ _value "Weeks"; match req.recurType with Weeks -> _selected | _ -> () ] [ str "weeks" ] option [ _value "Weeks"; match req.recurType with Weeks -> _selected | _ -> () ] [ str "weeks" ]
@ -497,22 +515,21 @@ module Request =
label [ _form "recurInterval" ] [ str "Interval" ] label [ _form "recurInterval" ] [ str "Interval" ]
] ]
] ]
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 [] [ div [ _class "text-end pt-3" ] [
rawText """toggleRecurrence ({ target }) { button [ _class "btn btn-primary me-2"; _type "submit" ] [ icon "save"; str " Save" ]
const isDisabled = target.value === "Immediate" a [ _class "btn btn-secondary ms-2"; _href cancelUrl; _hxGet cancelUrl ] [icon "arrow_back"; str " Cancel"]
;["recurCount","recurInterval"].forEach(it => document.getElementById(it).disabled = isDisabled) ]
}"""
] ]
] ]
/// Layout views /// Layout views
module Layout = module Layout =
open Giraffe.ViewEngine.Accessibility
/// The HTML `head` element
let htmlHead pageTitle = let htmlHead pageTitle =
head [] [ head [] [
title [] [ str pageTitle; rawText " &#xab; myPrayerJournal" ] title [] [ str pageTitle; rawText " &#xab; 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 = let htmlFoot =
footer [ _class "container-fluid" ] [ footer [ _class "container-fluid" ] [
p [ _class "text-muted text-end" ] [ p [ _class "text-muted text-end" ] [
@ -564,7 +588,8 @@ module Layout =
htmlHead pageTitle htmlHead pageTitle
body [ _hxHeaders "" ] [ body [ _hxHeaders "" ] [
Navigation.navBar Navigation.navBar
main [ _hxTrigger "setTitle from:body" ] [ content ] main [] [ content ]
toaster
htmlFoot htmlFoot
] ]
] ]

View File

@ -7,7 +7,7 @@ const mpj = {
/** The Auth0 client */ /** The Auth0 client */
auth0: null, auth0: null,
/** Configure the Auth0 client */ /** Configure the Auth0 client */
configureClient: async () => { async configureClient () {
const response = await fetch("/auth-config.json") const response = await fetch("/auth-config.json")
const config = await response.json() const config = await response.json()
mpj.auth.auth0 = await createAuth0Client({ mpj.auth.auth0 = await createAuth0Client({
@ -21,16 +21,72 @@ const mpj = {
isAuthenticated: false, isAuthenticated: false,
/** Whether we should redirect to the journal the next time the menu items are refreshed */ /** Whether we should redirect to the journal the next time the menu items are refreshed */
redirToJournal: false, 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() e.preventDefault()
await mpj.auth.auth0.loginWithRedirect({ redirect_uri: `${window.location.origin}/user/log-on` }) 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() e.preventDefault()
mpj.auth.auth0.logout({ returnTo: window.location.origin }) 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 => `<span class="me-auto"><strong>${typ.toUpperCase()}</strong></span>`
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 () => { window.onload = async () => {
@ -65,12 +121,17 @@ window.onload = async () => {
} }
htmx.on("htmx:afterOnLoad", function (evt) { htmx.on("htmx:afterOnLoad", function (evt) {
const hdrs = evt.detail.xhr.getAllResponseHeaders()
// Set the page title if a header was in the response // 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") const title = document.querySelector("title")
title.innerText = evt.detail.xhr.getResponseHeader("x-page-title") title.innerText = evt.detail.xhr.getResponseHeader("x-page-title")
title.innerHTML += " &#xab; myPrayerJournal" title.innerHTML += " &#xab; 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) { htmx.on("htmx:afterSettle", function (evt) {
// Redirect to the journal (once menu items load after log on) // Redirect to the journal (once menu items load after log on)

View File

@ -34,6 +34,13 @@ form {
.action-cell .material-icons { .action-cell .material-icons {
font-size: 1.1rem ; font-size: 1.1rem ;
} }
.material-icons {
vertical-align: bottom;
}
#toastHost {
position: sticky;
bottom: 0;
}
footer { footer {
border-top: solid 1px lightgray; border-top: solid 1px lightgray;