From 15473775279e2747e7c9d6168ff1126a8f007e4c Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sun, 31 Jul 2022 17:56:32 -0400 Subject: [PATCH] Add htmx targets to forms (#36) - Derive layout based on htmx headers --- src/PrayerTracker.UI/Church.fs | 118 +-- src/PrayerTracker.UI/CommonFunctions.fs | 18 + src/PrayerTracker.UI/Home.fs | 4 +- src/PrayerTracker.UI/Layout.fs | 116 +-- src/PrayerTracker.UI/PrayerRequest.fs | 451 ++++++------ .../Views/Home/PrivacyPolicy.es.resx | 2 +- src/PrayerTracker.UI/SmallGroup.fs | 682 +++++++++--------- src/PrayerTracker.UI/User.fs | 217 +++--- src/PrayerTracker.UI/ViewModels.fs | 16 + src/PrayerTracker/Church.fs | 4 +- src/PrayerTracker/CommonFunctions.fs | 48 +- src/PrayerTracker/PrayerRequest.fs | 4 +- src/PrayerTracker/PrayerTracker.fsproj | 1 + src/PrayerTracker/SmallGroup.fs | 14 +- src/PrayerTracker/User.fs | 10 +- 15 files changed, 886 insertions(+), 819 deletions(-) diff --git a/src/PrayerTracker.UI/Church.fs b/src/PrayerTracker.UI/Church.fs index 9ebe2ea..3149a5d 100644 --- a/src/PrayerTracker.UI/Church.fs +++ b/src/PrayerTracker.UI/Church.fs @@ -8,53 +8,53 @@ open PrayerTracker.ViewModels let edit (m : EditChurch) ctx vi = let pageTitle = if m.IsNew then "Add a New Church" else "Edit Church" let s = I18N.localizer.Force () - [ form [ _action "/church/save"; _method "post"; _class "pt-center-columns" ] [ - style [ _scoped ] [ - rawText "#name { width: 20rem; } #city { width: 10rem; } #st { width: 3rem; } #interfaceAddress { width: 30rem; }" - ] - csrfToken ctx - input [ _type "hidden"; _name (nameof m.ChurchId); _value (flatGuid m.ChurchId) ] - div [ _class "pt-field-row" ] [ - div [ _class "pt-field" ] [ - label [ _for "name" ] [ locStr s["Church Name"] ] - input [ _type "text"; _name (nameof m.Name); _id "name"; _required; _autofocus; _value m.Name ] + [ form [ _action "/church/save"; _method "post"; _class "pt-center-columns"; Target.content ] [ + style [ _scoped ] [ + rawText "#name { width: 20rem; } #city { width: 10rem; } #st { width: 3rem; } #interfaceAddress { width: 30rem; }" ] - div [ _class "pt-field" ] [ - label [ _for "City"] [ locStr s["City"] ] - input [ _type "text"; _name (nameof m.City); _id "city"; _required; _value m.City ] + csrfToken ctx + input [ _type "hidden"; _name (nameof m.ChurchId); _value (flatGuid m.ChurchId) ] + div [ _class "pt-field-row" ] [ + div [ _class "pt-field" ] [ + label [ _for "name" ] [ locStr s["Church Name"] ] + input [ _type "text"; _name (nameof m.Name); _id "name"; _required; _autofocus; _value m.Name ] + ] + div [ _class "pt-field" ] [ + label [ _for "City"] [ locStr s["City"] ] + input [ _type "text"; _name (nameof m.City); _id "city"; _required; _value m.City ] + ] + div [ _class "pt-field" ] [ + label [ _for "state" ] [ locStr s["State or Province"] ] + input [ _type "text" + _name (nameof m.State) + _id "state" + _minlength "2"; _maxlength "2" + _value m.State + _required ] + ] ] - div [ _class "pt-field" ] [ - label [ _for "state" ] [ locStr s["State or Province"] ] - input [ _type "text" - _name (nameof m.State) - _id "state" - _required - _minlength "2"; _maxlength "2" - _value m.State ] + div [ _class "pt-field-row" ] [ + div [ _class "pt-checkbox-field" ] [ + input [ _type "checkbox" + _name (nameof m.HasInterface) + _id "hasInterface" + _value "True" + if defaultArg m.HasInterface false then _checked ] + label [ _for "hasInterface" ] [ locStr s["Has an interface with Virtual Prayer Room"] ] + ] ] - ] - div [ _class "pt-field-row" ] [ - div [ _class "pt-checkbox-field" ] [ - input [ _type "checkbox" - _name (nameof m.HasInterface) - _id "hasInterface" - _value "True" - if defaultArg m.HasInterface false then _checked ] - label [ _for "hasInterface" ] [ locStr s["Has an interface with Virtual Prayer Room"] ] + div [ _class "pt-field-row pt-fadeable"; _id "divInterfaceAddress" ] [ + div [ _class "pt-field" ] [ + label [ _for "interfaceAddress" ] [ locStr s["VPR Interface URL"] ] + input [ _type "url" + _name (nameof m.InterfaceAddress) + _id "interfaceAddress"; + _value (defaultArg m.InterfaceAddress "") ] + ] ] + div [ _class "pt-field-row" ] [ submit [] "save" s["Save Church"] ] ] - div [ _class "pt-field-row pt-fadeable"; _id "divInterfaceAddress" ] [ - div [ _class "pt-field" ] [ - label [ _for "interfaceAddress" ] [ locStr s["VPR Interface URL"] ] - input [ _type "url" - _name (nameof m.InterfaceAddress) - _id "interfaceAddress"; - _value (defaultArg m.InterfaceAddress "") ] - ] - ] - div [ _class "pt-field-row" ] [ submit [] "save" s["Save Church"] ] - ] - script [] [ rawText "PT.onLoad(PT.church.edit.onPageLoad)" ] + script [] [ rawText "PT.onLoad(PT.church.edit.onPageLoad)" ] ] |> Layout.Content.standard |> Layout.standard vi pageTitle @@ -87,12 +87,13 @@ let maintain (churches : Church list) (stats : Map) ctx vi $"""{s["Church"].Value.ToLower ()} ({ch.name})"""] tr [] [ td [] [ - a [ _href $"/church/{chId}/edit"; _title s["Edit This Church"].Value ] [ icon "edit" ] - a [ _href delAction - _title s["Delete This Church"].Value - _onclick $"return PT.confirmDelete('{delAction}','{delPrompt}')" ] - [ icon "delete_forever" ] - ] + a [ _href $"/church/{chId}/edit"; _title s["Edit This Church"].Value ] [ icon "edit" ] + a [ _href delAction + _title s["Delete This Church"].Value + _onclick $"return PT.confirmDelete('{delAction}','{delPrompt}')" ] [ + icon "delete_forever" + ] + ] td [] [ str ch.name ] td [] [ str ch.city; rawText ", "; str ch.st ] td [ _class "pt-right-text" ] [ rawText (stats[chId].smallGroups.ToString "N0") ] @@ -102,16 +103,17 @@ let maintain (churches : Church list) (stats : Map) ctx vi ]) |> tbody [] ] - [ div [ _class "pt-center-text" ] [ - br [] - a [ _href $"/church/{emptyGuid}/edit"; _title s["Add a New Church"].Value ] - [ icon "add_circle"; rawText "  "; locStr s["Add a New Church"] ] - br [] - br [] - ] - tableSummary churches.Length s - chTbl - form [ _id "DeleteForm"; _action ""; _method "post" ] [ csrfToken ctx ] + [ div [ _class "pt-center-text" ] [ + br [] + a [ _href $"/church/{emptyGuid}/edit"; _title s["Add a New Church"].Value ] [ + icon "add_circle"; rawText "  "; locStr s["Add a New Church"] + ] + br [] + br [] + ] + tableSummary churches.Length s + chTbl + form [ _id "DeleteForm"; _action ""; _method "post" ] [ csrfToken ctx ] ] |> Layout.Content.wide |> Layout.standard vi "Maintain Churches" diff --git a/src/PrayerTracker.UI/CommonFunctions.fs b/src/PrayerTracker.UI/CommonFunctions.fs index 03a07ca..fee6843 100644 --- a/src/PrayerTracker.UI/CommonFunctions.fs +++ b/src/PrayerTracker.UI/CommonFunctions.fs @@ -113,6 +113,12 @@ let flatGuid (x : Guid) = x.ToString "N" /// An empty GUID string (used for "add" actions) let emptyGuid = flatGuid Guid.Empty +/// Create an HTML onsubmit event handler +let _onsubmit = attr "onsubmit" + +/// A "rel='noopener'" attribute +let _relNoOpener = _rel "noopener" + /// The name this function used to have when the view engine was part of Giraffe let renderHtmlNode = RenderView.AsString.htmlNode @@ -143,3 +149,15 @@ module TimeZones = let name tzId (s : IStringLocalizer) = try s[xref[tzId]] with :? KeyNotFoundException -> LocalizedString (tzId, tzId) + + +open Giraffe.ViewEngine.Htmx + +/// Known htmx targets +module Target = + + /// htmx links target the body element + let body = _hxTarget "body" + + /// htmx links target the #pt-body element + let content = _hxTarget "#pt-body" diff --git a/src/PrayerTracker.UI/Home.fs b/src/PrayerTracker.UI/Home.fs index d5baf2d..c7fa794 100644 --- a/src/PrayerTracker.UI/Home.fs +++ b/src/PrayerTracker.UI/Home.fs @@ -50,7 +50,6 @@ let index vi = let l = I18N.forView "Home/Index" use sw = new StringWriter () let raw = rawLocText sw - [ p [] [ raw l["Welcome to {0}!", s["PrayerTracker"]] space @@ -128,7 +127,6 @@ let privacyPolicy vi = let l = I18N.forView "Home/PrivacyPolicy" use sw = new StringWriter () let raw = rawLocText sw - [ p [ _class "pt-right-text" ] [ small [] [ em [] [ raw l["(as of July 31, 2018)"] ] ] ] p [] [ raw l["The nature of the service is one where privacy is a must."] @@ -150,7 +148,7 @@ let privacyPolicy vi = rawText " – " raw l["{0} stores the text of prayer requests.", s["PrayerTracker"]] space - raw l["It also stores names and e-mail addreses of small group members, and plain-text passwords for small groups with password-protected lists."] + raw l["It also stores names and e-mail addresses of small group members, and plain-text passwords for small groups with password-protected lists."] ] ] h3 [] [ raw l["How Your Data Is Accessed / Secured"] ] diff --git a/src/PrayerTracker.UI/Layout.fs b/src/PrayerTracker.UI/Layout.fs index 4cb0b7e..25ae505 100644 --- a/src/PrayerTracker.UI/Layout.fs +++ b/src/PrayerTracker.UI/Layout.fs @@ -3,39 +3,26 @@ module PrayerTracker.Views.Layout open Giraffe.ViewEngine open Giraffe.ViewEngine.Accessibility -open Giraffe.ViewEngine.Htmx -open PrayerTracker open PrayerTracker.ViewModels -open System open System.Globalization - /// Get the two-character language code for the current request let langCode () = if CultureInfo.CurrentCulture.Name.StartsWith "es" then "es" else "en" -/// Known htmx targets -module Target = - - /// htmx links target the body element - let body = _hxTarget "body" - - /// htmx links target the #pt-body element - let content = _hxTarget "#pt-body" - - /// Navigation items module Navigation = /// Top navigation bar let top m = - let s = I18N.localizer.Force () + let s = I18N.localizer.Force () let menuSpacer = rawText "  " + let _dropdown = _class "dropbtn" let leftLinks = [ match m.User with | Some u -> li [ _class "dropdown" ] [ - a [ _class "dropbtn"; _ariaLabel s["Requests"].Value; _title s["Requests"].Value; _roleButton ] [ + a [ _dropdown; _ariaLabel s["Requests"].Value; _title s["Requests"].Value; _roleButton ] [ icon "question_answer"; space; locStr s["Requests"]; space; icon "keyboard_arrow_down" ] div [ _class "dropdown-content"; _roleMenuBar ] [ @@ -48,7 +35,7 @@ module Navigation = ] ] li [ _class "dropdown" ] [ - a [ _class "dropbtn"; _ariaLabel s["Group"].Value; _title s["Group"].Value; _roleButton ] [ + a [ _dropdown; _ariaLabel s["Group"].Value; _title s["Group"].Value; _roleButton ] [ icon "group"; space; locStr s["Group"]; space; icon "keyboard_arrow_down" ] div [ _class "dropdown-content"; _roleMenuBar ] [ @@ -65,7 +52,7 @@ module Navigation = ] if u.isAdmin then li [ _class "dropdown" ] [ - a [ _class "dropbtn" + a [ _dropdown _ariaLabel s["Administration"].Value _title s["Administration"].Value _roleButton ] [ @@ -89,7 +76,7 @@ module Navigation = ] | None -> li [ _class "dropdown" ] [ - a [ _class "dropbtn"; _ariaLabel s["Log On"].Value; _title s["Log On"].Value; _roleButton ] [ + a [ _dropdown; _ariaLabel s["Log On"].Value; _title s["Log On"].Value; _roleButton ] [ icon "security"; space; locStr s["Log On"]; space; icon "keyboard_arrow_down" ] div [ _class "dropdown-content"; _roleMenuBar ] [ @@ -111,7 +98,7 @@ module Navigation = _ariaLabel s["Help"].Value _title s["View Help"].Value _target "_blank" - _rel "noopener" ] [ + _relNoOpener ] [ icon "help"; space; locStr s["Help"] ] ] @@ -130,10 +117,7 @@ module Navigation = ] | None -> () li [] [ - a [ _href "/log-off" - _ariaLabel s["Log Off"].Value - _title s["Log Off"].Value - _hxTarget "body" ] [ + a [ _href "/log-off"; _ariaLabel s["Log Off"].Value; _title s["Log Off"].Value; Target.body ] [ icon "power_settings_new"; space; locStr s["Log Off"] ] ] @@ -222,10 +206,11 @@ let private htmlHead m pageTitle = yield! commonHead for cssFile in m.Style do link [ _rel "stylesheet"; _href $"/css/{cssFile}.css"; _type "text/css" ] - for jsFile in m.Script do - script [ _src $"/js/{jsFile}.js" ] [] ] - + + +open Giraffe.ViewEngine.Htmx + /// Render a link to the help page for the current page let private helpLink link = let s = I18N.localizer.Force () @@ -241,7 +226,7 @@ let private helpLink link = /// Render the page title, and optionally a help link let private renderPageTitle m pageTitle = h2 [ _id "pt-page-title" ] [ - match m.HelpLink with Some link -> Help.fullLink (langCode ()) link |> helpLink | None -> () + match m.HelpLink with Some link -> PrayerTracker.Utils.Help.fullLink (langCode ()) link |> helpLink | None -> () locStr pageTitle ] @@ -268,6 +253,9 @@ let private messages m = ] ]) + +open System + /// Render the