Version 3 #67
|
@ -36,7 +36,7 @@ module Error =
|
||||||
log.LogError (EventId(), ex, "An unhandled exception has occurred while executing the request.")
|
log.LogError (EventId(), ex, "An unhandled exception has occurred while executing the request.")
|
||||||
clearResponse
|
clearResponse
|
||||||
>=> setStatusCode 500
|
>=> setStatusCode 500
|
||||||
>=> setHttpHeader (sprintf "error|||%s: %s" (ex.GetType().Name) ex.Message) "X-Toast"
|
>=> setHttpHeader "X-Toast" (sprintf "error|||%s: %s" (ex.GetType().Name) ex.Message)
|
||||||
>=> text ex.Message
|
>=> text ex.Message
|
||||||
|
|
||||||
/// Handle unauthorized actions, redirecting to log on for GETs, otherwise returning a 401 Not Authorized reponse
|
/// Handle unauthorized actions, redirecting to log on for GETs, otherwise returning a 401 Not Authorized reponse
|
||||||
|
@ -52,30 +52,6 @@ module Error =
|
||||||
setStatusCode 404 >=> text "Not found"
|
setStatusCode 404 >=> text "Not found"
|
||||||
|
|
||||||
|
|
||||||
/// Hold messages across redirects
|
|
||||||
module Messages =
|
|
||||||
|
|
||||||
/// The messages being held
|
|
||||||
let mutable private messages : Map<string, (string * string)> = Map.empty
|
|
||||||
|
|
||||||
/// Locked update to prevent updates by multiple threads
|
|
||||||
let private upd8 = obj ()
|
|
||||||
|
|
||||||
/// Push a new message into the list
|
|
||||||
let push userId message url = lock upd8 (fun () ->
|
|
||||||
messages <- messages.Add (userId, (message, url)))
|
|
||||||
|
|
||||||
/// Add a success message header to the response
|
|
||||||
let pushSuccess userId message url =
|
|
||||||
push userId (sprintf "success|||%s" message) url
|
|
||||||
|
|
||||||
/// Pop the messages for the given user
|
|
||||||
let pop userId = lock upd8 (fun () ->
|
|
||||||
let msg = messages.TryFind userId
|
|
||||||
msg |> Option.iter (fun _ -> messages <- messages.Remove userId)
|
|
||||||
msg)
|
|
||||||
|
|
||||||
|
|
||||||
/// Handler helpers
|
/// Handler helpers
|
||||||
[<AutoOpen>]
|
[<AutoOpen>]
|
||||||
module private Helpers =
|
module private Helpers =
|
||||||
|
@ -115,6 +91,10 @@ module private Helpers =
|
||||||
(sprintf "%s://%s%s" ctx.Request.Scheme ctx.Request.Host.Value url |> setHttpHeader HeaderNames.Location
|
(sprintf "%s://%s%s" ctx.Request.Scheme ctx.Request.Host.Value url |> setHttpHeader HeaderNames.Location
|
||||||
>=> created) next ctx
|
>=> created) next ctx
|
||||||
|
|
||||||
|
/// Return a 303 SEE OTHER response (forces a GET on the redirected URL)
|
||||||
|
let seeOther (url : string) =
|
||||||
|
setStatusCode 303 >=> setHttpHeader "Location" url
|
||||||
|
|
||||||
/// The "now" time in JavaScript as Ticks
|
/// The "now" time in JavaScript as Ticks
|
||||||
let jsNow () =
|
let jsNow () =
|
||||||
DateTime.UtcNow.Subtract(DateTime (1970, 1, 1, 0, 0, 0)).TotalSeconds |> (int64 >> ( * ) 1_000L >> Ticks)
|
DateTime.UtcNow.Subtract(DateTime (1970, 1, 1, 0, 0, 0)).TotalSeconds |> (int64 >> ( * ) 1_000L >> Ticks)
|
||||||
|
@ -140,6 +120,29 @@ module private Helpers =
|
||||||
return! ctx.WriteHtmlViewAsync view
|
return! ctx.WriteHtmlViewAsync view
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Hold messages across redirects
|
||||||
|
module Messages =
|
||||||
|
|
||||||
|
/// The messages being held
|
||||||
|
let mutable private messages : Map<string, (string * string)> = Map.empty
|
||||||
|
|
||||||
|
/// Locked update to prevent updates by multiple threads
|
||||||
|
let private upd8 = obj ()
|
||||||
|
|
||||||
|
/// Push a new message into the list
|
||||||
|
let push ctx message url = lock upd8 (fun () ->
|
||||||
|
messages <- messages.Add (ctx |> (user >> Option.get), (message, url)))
|
||||||
|
|
||||||
|
/// Add a success message header to the response
|
||||||
|
let pushSuccess ctx message url =
|
||||||
|
push ctx (sprintf "success|||%s" message) url
|
||||||
|
|
||||||
|
/// Pop the messages for the given user
|
||||||
|
let pop userId = lock upd8 (fun () ->
|
||||||
|
let msg = messages.TryFind userId
|
||||||
|
msg |> Option.iter (fun _ -> messages <- messages.Remove userId)
|
||||||
|
msg)
|
||||||
|
|
||||||
/// Send a partial result if this is not a full page load
|
/// Send a partial result if this is not a full page load
|
||||||
let partialIfNotRefresh (pageTitle : string) content : HttpHandler =
|
let partialIfNotRefresh (pageTitle : string) content : HttpHandler =
|
||||||
fun next ctx ->
|
fun next ctx ->
|
||||||
|
@ -165,14 +168,14 @@ module private Helpers =
|
||||||
module Models =
|
module Models =
|
||||||
|
|
||||||
/// An additional note
|
/// An additional note
|
||||||
[<CLIMutable>]
|
[<CLIMutable; NoComparison; NoEquality>]
|
||||||
type NoteEntry = {
|
type NoteEntry = {
|
||||||
/// The notes being added
|
/// The notes being added
|
||||||
notes : string
|
notes : string
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A prayer request
|
/// A prayer request
|
||||||
[<CLIMutable>]
|
[<CLIMutable; NoComparison; NoEquality>]
|
||||||
type Request = {
|
type Request = {
|
||||||
/// The ID of the request
|
/// The ID of the request
|
||||||
requestId : string
|
requestId : string
|
||||||
|
@ -191,7 +194,7 @@ module Models =
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The time until which a request should not appear in the journal
|
/// The time until which a request should not appear in the journal
|
||||||
[<CLIMutable>]
|
[<CLIMutable; NoComparison; NoEquality>]
|
||||||
type SnoozeUntil = {
|
type SnoozeUntil = {
|
||||||
/// The time at which the request should reappear
|
/// The time at which the request should reappear
|
||||||
until : int64
|
until : int64
|
||||||
|
@ -213,20 +216,6 @@ module Components =
|
||||||
return! renderComponent [ Views.Journal.journalItems shown ] next ctx
|
return! renderComponent [ Views.Journal.journalItems shown ] next ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /components/request/[req-id]/edit
|
|
||||||
let requestEdit requestId : HttpHandler =
|
|
||||||
requiresAuthentication Error.notAuthorized
|
|
||||||
>=> 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]
|
// GET /components/request-item/[req-id]
|
||||||
let requestItem reqId : HttpHandler =
|
let requestItem reqId : HttpHandler =
|
||||||
requiresAuthentication Error.notAuthorized
|
requiresAuthentication Error.notAuthorized
|
||||||
|
@ -236,6 +225,14 @@ module Components =
|
||||||
| None -> return! Error.notFound next ctx
|
| None -> return! Error.notFound next ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// GET /components/request/[req-id]/notes
|
||||||
|
let notes requestId : HttpHandler =
|
||||||
|
requiresAuthentication Error.notAuthorized
|
||||||
|
>=> fun next ctx -> task {
|
||||||
|
let! notes = Data.notesById (RequestId.ofString requestId) (userId ctx) (db ctx)
|
||||||
|
return! renderComponent (Views.Request.notes notes) next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/// / URL
|
/// / URL
|
||||||
module Home =
|
module Home =
|
||||||
|
@ -257,7 +254,8 @@ module Journal =
|
||||||
|> Seq.tryFind (fun c -> c.Type = ClaimTypes.GivenName)
|
|> Seq.tryFind (fun c -> c.Type = ClaimTypes.GivenName)
|
||||||
|> Option.map (fun c -> c.Value)
|
|> Option.map (fun c -> c.Value)
|
||||||
|> Option.defaultValue "Your"
|
|> Option.defaultValue "Your"
|
||||||
return! partialIfNotRefresh (sprintf "%s Prayer Journal" usr) (Views.Journal.journal usr) next ctx
|
let title = usr |> match usr with "Your" -> sprintf "%s" | _ -> sprintf "%s's"
|
||||||
|
return! partialIfNotRefresh (sprintf "%s Prayer Journal" title) (Views.Journal.journal usr) next ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -381,14 +379,6 @@ module Request =
|
||||||
| None -> return! Error.notFound next ctx
|
| None -> return! Error.notFound next ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GET /api/request/[req-id]/notes
|
|
||||||
let getNotes requestId : HttpHandler =
|
|
||||||
requiresAuthentication Error.notAuthorized
|
|
||||||
>=> fun next ctx -> task {
|
|
||||||
let! notes = Data.notesById (RequestId.ofString requestId) (userId ctx) (db ctx)
|
|
||||||
return! json notes next ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
// PATCH /request/[req-id]/show
|
// PATCH /request/[req-id]/show
|
||||||
let show requestId : HttpHandler =
|
let show requestId : HttpHandler =
|
||||||
requiresAuthentication Error.notAuthorized
|
requiresAuthentication Error.notAuthorized
|
||||||
|
@ -465,12 +455,8 @@ module Request =
|
||||||
}
|
}
|
||||||
Data.addRequest req db
|
Data.addRequest req db
|
||||||
do! db.saveChanges ()
|
do! db.saveChanges ()
|
||||||
Messages.pushSuccess (ctx |> (user >> Option.get)) "Added prayer request" "/journal"
|
Messages.pushSuccess ctx "Added prayer request" "/journal"
|
||||||
// TODO: this is not right
|
return! seeOther "/journal" next ctx
|
||||||
return! (
|
|
||||||
redirectTo false "/journal"
|
|
||||||
// >=> createdAt (RequestId.toString req.id |> sprintf "/request/%s")
|
|
||||||
) next ctx
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// PATCH /request
|
// PATCH /request
|
||||||
|
@ -497,8 +483,13 @@ module Request =
|
||||||
do! Data.addHistory req.requestId usrId
|
do! Data.addHistory req.requestId usrId
|
||||||
{ asOf = jsNow (); status = (Option.get >> RequestAction.ofString) form.status; text = text } db
|
{ asOf = jsNow (); status = (Option.get >> RequestAction.ofString) form.status; text = text } db
|
||||||
do! db.saveChanges ()
|
do! db.saveChanges ()
|
||||||
return! (withSuccessMessage "Prayer request updated successfully"
|
let nextUrl =
|
||||||
>=> Components.requestItem (RequestId.toString req.requestId)) next ctx
|
match form.returnTo with
|
||||||
|
| "active" -> "/requests/active"
|
||||||
|
| "snoozed" -> "/requests/snoozed"
|
||||||
|
| _ (* "journal" *) -> "/journal"
|
||||||
|
Messages.pushSuccess ctx "Prayer request updated successfully" nextUrl
|
||||||
|
return! seeOther nextUrl next ctx
|
||||||
| None -> return! Error.notFound next ctx
|
| None -> return! Error.notFound next ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -530,8 +521,8 @@ let routes =
|
||||||
subRoute "/components/" [
|
subRoute "/components/" [
|
||||||
GET_HEAD [
|
GET_HEAD [
|
||||||
route "journal-items" Components.journalItems
|
route "journal-items" Components.journalItems
|
||||||
routef "request/%s/edit" Components.requestEdit
|
|
||||||
routef "request/%s/item" Components.requestItem
|
routef "request/%s/item" Components.requestItem
|
||||||
|
routef "request/%s/notes" Components.notes
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
GET_HEAD [ route "/journal" Journal.journal ]
|
GET_HEAD [ route "/journal" Journal.journal ]
|
||||||
|
@ -568,7 +559,6 @@ let routes =
|
||||||
subRoute "/api/" [
|
subRoute "/api/" [
|
||||||
GET [
|
GET [
|
||||||
subRoute "request" [
|
subRoute "request" [
|
||||||
routef "/%s/notes" Request.getNotes
|
|
||||||
routef "/%s" Request.get
|
routef "/%s" Request.get
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
|
|
|
@ -29,7 +29,7 @@ module private Helpers =
|
||||||
/// Create a link that targets the `#top` element and pushes a URL to history
|
/// Create a link that targets the `#top` element and pushes a URL to history
|
||||||
let pageLink href attrs =
|
let pageLink href attrs =
|
||||||
attrs
|
attrs
|
||||||
|> List.append [ _href href; _hxBoost; _hxTarget "#top"; _hxPushUrl ]
|
|> List.append [ _href href; _hxBoost; _hxTarget "#top"; _hxSwap HxSwap.InnerHtml; _hxPushUrl ]
|
||||||
|> a
|
|> a
|
||||||
|
|
||||||
/// Create a Material icon
|
/// Create a Material icon
|
||||||
|
@ -269,27 +269,33 @@ module Journal =
|
||||||
|
|
||||||
/// Display a card for this prayer request
|
/// Display a card for this prayer request
|
||||||
let journalCard req =
|
let journalCard req =
|
||||||
|
let reqId = RequestId.toString req.requestId
|
||||||
|
let spacer = span [] [ rawText " " ]
|
||||||
div [ _class "col" ] [
|
div [ _class "col" ] [
|
||||||
div [ _class "card h-100" ] [
|
div [ _class "card h-100" ] [
|
||||||
div [ _class "card-header p-0 text-end"; _roleToolBar ] [
|
div [ _class "card-header p-0 d-flex"; _roleToolBar ] [
|
||||||
|
pageLink $"/request/{reqId}/edit" [ _class "btn btn-secondary"; _title "Edit Request" ] [ icon "edit" ]
|
||||||
|
spacer
|
||||||
button [
|
button [
|
||||||
_class "btn btn-success"
|
_type "button"
|
||||||
_hxPatch $"/request/{RequestId.toString req.requestId}/prayed"
|
_class "btn btn-secondary"
|
||||||
_title "Mark as Prayed"
|
_title "Add Notes"
|
||||||
] [ icon "done" ]
|
_data "bs-toggle" "modal"
|
||||||
// span
|
_data "bs-target" "#notesModal"
|
||||||
// md-button(@click.stop='showEdit()').md-icon-button.md-raised
|
_data "request-id" reqId
|
||||||
// md-icon edit
|
] [ icon "comment" ]
|
||||||
// md-tooltip(md-direction='top'
|
spacer
|
||||||
// md-delay=1000) Edit Request
|
|
||||||
// md-button(@click.stop='showNotes()').md-icon-button.md-raised
|
|
||||||
// md-icon comment
|
|
||||||
// md-tooltip(md-direction='top'
|
|
||||||
// md-delay=1000) Add Notes
|
|
||||||
// md-button(@click.stop='snooze()').md-icon-button.md-raised
|
// md-button(@click.stop='snooze()').md-icon-button.md-raised
|
||||||
// md-icon schedule
|
// md-icon schedule
|
||||||
// md-tooltip(md-direction='top'
|
// md-tooltip(md-direction='top'
|
||||||
// md-delay=1000) Snooze Request
|
// md-delay=1000) Snooze Request
|
||||||
|
div [ _class "flex-grow-1" ] []
|
||||||
|
button [
|
||||||
|
_type "button"
|
||||||
|
_class "btn btn-success w-25"
|
||||||
|
_hxPatch $"/request/{reqId}/prayed"
|
||||||
|
_title "Mark as Prayed"
|
||||||
|
] [ icon "done" ]
|
||||||
]
|
]
|
||||||
div [ _class "card-body" ] [
|
div [ _class "card-body" ] [
|
||||||
p [ _class "request-text" ] [ str req.text ]
|
p [ _class "request-text" ] [ str req.text ]
|
||||||
|
@ -317,6 +323,40 @@ module Journal =
|
||||||
_hxSwap HxSwap.OuterHtml
|
_hxSwap HxSwap.OuterHtml
|
||||||
_hxTrigger HxTrigger.Load
|
_hxTrigger HxTrigger.Load
|
||||||
] [ rawText "Loading your prayer journal…" ]
|
] [ rawText "Loading your prayer journal…" ]
|
||||||
|
div [
|
||||||
|
_id "notesModal"
|
||||||
|
_class "modal fade"
|
||||||
|
_tabindex "-1"
|
||||||
|
_ariaLabelledBy "nodesModalLabel"
|
||||||
|
_ariaHidden "true"
|
||||||
|
] [
|
||||||
|
div [ _class "modal-dialog modal-dialog-scrollable" ] [
|
||||||
|
div [ _class "modal-content" ] [
|
||||||
|
div [ _class "modal-header" ] [
|
||||||
|
h5 [ _class "modal-title"; _id "nodesModalLabel" ] [ str "Add Notes to Prayer Request" ]
|
||||||
|
button [ _type "button"; _class "btn-close"; _data "bs-dismiss" "modal"; _ariaLabel "Close" ] []
|
||||||
|
]
|
||||||
|
div [ _class "modal-body" ] [
|
||||||
|
form [ _id "notesForm"; _method "POST"; _action ""; _hxBoost; _hxTarget "#top" ] [
|
||||||
|
str "TODO"
|
||||||
|
button [ _type "submit"; _class "btn btn-primary" ] [ str "Add Notes" ]
|
||||||
|
]
|
||||||
|
hr []
|
||||||
|
div [
|
||||||
|
_id "notesLoad"
|
||||||
|
_class "btn btn-secondary"
|
||||||
|
_hxGet ""
|
||||||
|
_hxSwap HxSwap.OuterHtml
|
||||||
|
_hxTarget "this"
|
||||||
|
] [ str "Load Prior Notes" ]
|
||||||
|
]
|
||||||
|
div [ _class "modal-footer" ] [
|
||||||
|
button [ _type "button"; _class "btn btn-secondary"; _data "bs-dismiss" "modal" ] [ str "Close" ]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
script [] [ str "setTimeout(function () { mpj.journal.setUp() }, 1000)" ]
|
||||||
]
|
]
|
||||||
|
|
||||||
/// The journal items
|
/// The journal items
|
||||||
|
@ -596,6 +636,15 @@ module Request =
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
|
|
||||||
|
/// Display a list of notes for a request
|
||||||
|
let notes notes =
|
||||||
|
let toItem (note : Note) = p [] [ small [ _class "text-muted" ] [ relativeDate note.asOf ]; br []; str note.notes ]
|
||||||
|
[ p [ _class "text-center" ] [ strong [] [ str "Prior Notes for This Request" ] ]
|
||||||
|
match notes with
|
||||||
|
| [] -> p [ _class "text-center text-muted" ] [ str "There are no prior notes for this request" ]
|
||||||
|
| _ -> yield! notes |> List.map toItem
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
/// Layout views
|
/// Layout views
|
||||||
module Layout =
|
module Layout =
|
||||||
|
|
|
@ -49,9 +49,22 @@ const mpj = {
|
||||||
*/
|
*/
|
||||||
toggleRecurrence ({ target }) {
|
toggleRecurrence ({ target }) {
|
||||||
const isDisabled = target.value === "Immediate"
|
const isDisabled = target.value === "Immediate"
|
||||||
;["recurCount","recurInterval"].forEach(it => document.getElementById(it).disabled = isDisabled)
|
;["recurCount", "recurInterval"].forEach(it => document.getElementById(it).disabled = isDisabled)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
/** Script for the journal page */
|
||||||
|
journal: {
|
||||||
|
/**
|
||||||
|
* Set up the journal page modals
|
||||||
|
*/
|
||||||
|
setUp () {
|
||||||
|
document.getElementById("notesModal").addEventListener("show.bs.modal", function (event) {
|
||||||
|
const reqId = event.relatedTarget.getAttribute("data-request-id")
|
||||||
|
document.getElementById("notesForm").setAttribute("action", `/request/${reqId}/note`)
|
||||||
|
document.getElementById("notesLoad").setAttribute("hx-get", `/components/request/${reqId}/notes`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
htmx.on("htmx:afterOnLoad", function (evt) {
|
htmx.on("htmx:afterOnLoad", function (evt) {
|
||||||
|
|
Loading…
Reference in New Issue
Block a user