From 47fb9884f13d874ba1f8c6bb0d874b6a8f8148f4 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Tue, 12 Jul 2022 22:43:01 -0400 Subject: [PATCH 01/36] WIP on F# 6 formatting / conversion --- src/PrayerTracker.Data/AppDbContext.fs | 134 +-- src/PrayerTracker.Data/DataAccess.fs | 577 +++++----- src/PrayerTracker.Data/Entities.fs | 1232 +++++++++++---------- src/PrayerTracker.Tests/Program.fs | 2 +- src/PrayerTracker.Tests/UI/UtilsTests.fs | 313 +++--- src/PrayerTracker.UI/Church.fs | 183 ++-- src/PrayerTracker.UI/CommonFunctions.fs | 146 +-- src/PrayerTracker.UI/Home.fs | 421 ++++---- src/PrayerTracker.UI/I18N.fs | 6 +- src/PrayerTracker.UI/Layout.fs | 461 ++++---- src/PrayerTracker.UI/PrayerRequest.fs | 613 ++++++----- src/PrayerTracker.UI/SmallGroup.fs | 915 ++++++++-------- src/PrayerTracker.UI/User.fs | 362 ++++--- src/PrayerTracker.UI/Utils.fs | 298 +++--- src/PrayerTracker.UI/ViewModels.fs | 1233 ++++++++++++---------- src/PrayerTracker/App.fs | 359 +++---- src/PrayerTracker/Church.fs | 76 +- src/PrayerTracker/CommonFunctions.fs | 307 +++--- src/PrayerTracker/Cookies.fs | 154 +-- src/PrayerTracker/Email.fs | 99 +- src/PrayerTracker/Extensions.fs | 64 +- src/PrayerTracker/Home.fs | 56 +- src/PrayerTracker/PrayerRequest.fs | 281 +++-- src/PrayerTracker/SmallGroup.fs | 423 ++++---- src/PrayerTracker/User.fs | 299 +++--- 25 files changed, 4498 insertions(+), 4516 deletions(-) diff --git a/src/PrayerTracker.Data/AppDbContext.fs b/src/PrayerTracker.Data/AppDbContext.fs index c4199c2..bad2e09 100644 --- a/src/PrayerTracker.Data/AppDbContext.fs +++ b/src/PrayerTracker.Data/AppDbContext.fs @@ -6,81 +6,85 @@ open PrayerTracker.Entities /// EF Core data context for PrayerTracker [] type AppDbContext (options : DbContextOptions) = - inherit DbContext (options) + inherit DbContext (options) - [] - val mutable private churches : DbSet - [] - val mutable private members : DbSet - [] - val mutable private prayerRequests : DbSet - [] - val mutable private preferences : DbSet - [] - val mutable private smallGroups : DbSet - [] - val mutable private timeZones : DbSet - [] - val mutable private users : DbSet - [] - val mutable private userGroupXref : DbSet + [] + val mutable private churches : DbSet + [] + val mutable private members : DbSet + [] + val mutable private prayerRequests : DbSet + [] + val mutable private preferences : DbSet + [] + val mutable private smallGroups : DbSet + [] + val mutable private timeZones : DbSet + [] + val mutable private users : DbSet + [] + val mutable private userGroupXref : DbSet - /// Churches - member this.Churches - with get() = this.churches - and set v = this.churches <- v + /// Churches + member this.Churches + with get() = this.churches + and set v = this.churches <- v - /// Small group members - member this.Members - with get() = this.members - and set v = this.members <- v + /// Small group members + member this.Members + with get() = this.members + and set v = this.members <- v - /// Prayer requests - member this.PrayerRequests - with get() = this.prayerRequests - and set v = this.prayerRequests <- v + /// Prayer requests + member this.PrayerRequests + with get() = this.prayerRequests + and set v = this.prayerRequests <- v - /// Request list preferences (by class) - member this.Preferences - with get() = this.preferences - and set v = this.preferences <- v + /// Request list preferences (by class) + member this.Preferences + with get() = this.preferences + and set v = this.preferences <- v - /// Small groups - member this.SmallGroups - with get() = this.smallGroups - and set v = this.smallGroups <- v + /// Small groups + member this.SmallGroups + with get() = this.smallGroups + and set v = this.smallGroups <- v - /// Time zones - member this.TimeZones - with get() = this.timeZones - and set v = this.timeZones <- v + /// Time zones + member this.TimeZones + with get() = this.timeZones + and set v = this.timeZones <- v - /// Users - member this.Users - with get() = this.users - and set v = this.users <- v + /// Users + member this.Users + with get() = this.users + and set v = this.users <- v - /// User / small group cross-reference - member this.UserGroupXref - with get() = this.userGroupXref - and set v = this.userGroupXref <- v + /// User / small group cross-reference + member this.UserGroupXref + with get() = this.userGroupXref + and set v = this.userGroupXref <- v - /// F#-style async for saving changes - member this.AsyncSaveChanges () = - this.SaveChangesAsync () |> Async.AwaitTask + /// F#-style async for saving changes + member this.AsyncSaveChanges () = + this.SaveChangesAsync () |> Async.AwaitTask - override __.OnModelCreating (modelBuilder : ModelBuilder) = - base.OnModelCreating modelBuilder + override _.OnConfiguring (optionsBuilder : DbContextOptionsBuilder) = + base.OnConfiguring optionsBuilder + optionsBuilder.UseQueryTrackingBehavior QueryTrackingBehavior.NoTracking |> ignore + + override _.OnModelCreating (modelBuilder : ModelBuilder) = + base.OnModelCreating modelBuilder - modelBuilder.HasDefaultSchema "pt" |> ignore + modelBuilder.HasDefaultSchema "pt" |> ignore - [ Church.configureEF - ListPreferences.configureEF - Member.configureEF - PrayerRequest.configureEF - SmallGroup.configureEF - TimeZone.configureEF - User.configureEF - UserSmallGroup.configureEF - ] - |> List.iter (fun x -> x modelBuilder) + [ Church.configureEF + ListPreferences.configureEF + Member.configureEF + PrayerRequest.configureEF + SmallGroup.configureEF + TimeZone.configureEF + User.configureEF + UserSmallGroup.configureEF + ] + |> List.iter (fun x -> x modelBuilder) diff --git a/src/PrayerTracker.Data/DataAccess.fs b/src/PrayerTracker.Data/DataAccess.fs index 6dfebcd..2749d66 100644 --- a/src/PrayerTracker.Data/DataAccess.fs +++ b/src/PrayerTracker.Data/DataAccess.fs @@ -1,380 +1,287 @@ [] module PrayerTracker.DataAccess -open Microsoft.EntityFrameworkCore -open PrayerTracker.Entities -open System.Collections.Generic open System.Linq +open PrayerTracker.Entities [] module private Helpers = - open Microsoft.FSharpLu - open System.Threading.Tasks + /// Central place to append sort criteria for prayer request queries + let reqSort sort (q : IQueryable) = + match sort with + | SortByDate -> + q.OrderByDescending(fun req -> req.updatedDate) + .ThenByDescending(fun req -> req.enteredDate) + .ThenBy (fun req -> req.requestor) + | SortByRequestor -> + q.OrderBy(fun req -> req.requestor) + .ThenByDescending(fun req -> req.updatedDate) + .ThenByDescending (fun req -> req.enteredDate) + + /// Paginate a prayer request query + let paginate pageNbr pageSize (q : IQueryable) = + q.Skip((pageNbr - 1) * pageSize).Take pageSize - /// Central place to append sort criteria for prayer request queries - let reqSort sort (q : IQueryable) = - match sort with - | SortByDate -> - query { - for req in q do - sortByDescending req.updatedDate - thenByDescending req.enteredDate - thenBy req.requestor - } - | SortByRequestor -> - query { - for req in q do - sortBy req.requestor - thenByDescending req.updatedDate - thenByDescending req.enteredDate - } - - /// Convert a possibly-null object to an option, wrapped as a task - let toOptionTask<'T> (item : 'T) = (Option.fromObject >> Task.FromResult) item +open System.Collections.Generic +open Microsoft.EntityFrameworkCore +open Microsoft.FSharpLu type AppDbContext with - (*-- DISCONNECTED DATA EXTENSIONS --*) + (*-- DISCONNECTED DATA EXTENSIONS --*) - /// Add an entity entry to the tracked data context with the status of Added - member this.AddEntry<'TEntity when 'TEntity : not struct> (e : 'TEntity) = - this.Entry<'TEntity>(e).State <- EntityState.Added - - /// Add an entity entry to the tracked data context with the status of Updated - member this.UpdateEntry<'TEntity when 'TEntity : not struct> (e : 'TEntity) = - this.Entry<'TEntity>(e).State <- EntityState.Modified + /// Add an entity entry to the tracked data context with the status of Added + member this.AddEntry<'TEntity when 'TEntity : not struct> (e : 'TEntity) = + this.Entry<'TEntity>(e).State <- EntityState.Added + + /// Add an entity entry to the tracked data context with the status of Updated + member this.UpdateEntry<'TEntity when 'TEntity : not struct> (e : 'TEntity) = + this.Entry<'TEntity>(e).State <- EntityState.Modified - /// Add an entity entry to the tracked data context with the status of Deleted - member this.RemoveEntry<'TEntity when 'TEntity : not struct> (e : 'TEntity) = - this.Entry<'TEntity>(e).State <- EntityState.Deleted + /// Add an entity entry to the tracked data context with the status of Deleted + member this.RemoveEntry<'TEntity when 'TEntity : not struct> (e : 'TEntity) = + this.Entry<'TEntity>(e).State <- EntityState.Deleted - (*-- CHURCH EXTENSIONS --*) + (*-- CHURCH EXTENSIONS --*) - /// Find a church by its Id - member this.TryChurchById cId = - query { - for ch in this.Churches.AsNoTracking () do - where (ch.churchId = cId) - exactlyOneOrDefault - } - |> toOptionTask - - /// Find all churches - member this.AllChurches () = - task { - let q = - query { - for ch in this.Churches.AsNoTracking () do - sortBy ch.name - } - let! churches = q.ToListAsync () - return List.ofSeq churches - } + /// Find a church by its Id + member this.TryChurchById cId = backgroundTask { + let! church = this.Churches.SingleOrDefaultAsync (fun ch -> ch.churchId = cId) + return Option.fromObject church + } + + /// Find all churches + member this.AllChurches () = backgroundTask { + let! churches = this.Churches.OrderBy(fun ch -> ch.name).ToListAsync () + return List.ofSeq churches + } - (*-- MEMBER EXTENSIONS --*) + (*-- MEMBER EXTENSIONS --*) - /// Get a small group member by its Id - member this.TryMemberById mId = - query { - for mbr in this.Members.AsNoTracking () do - where (mbr.memberId = mId) - select mbr - exactlyOneOrDefault - } - |> toOptionTask + /// Get a small group member by its Id + member this.TryMemberById mbrId = backgroundTask { + let! mbr = this.Members.SingleOrDefaultAsync (fun m -> m.memberId = mbrId) + return Option.fromObject mbr + } - /// Find all members for a small group - member this.AllMembersForSmallGroup gId = - task { - let q = - query { - for mbr in this.Members.AsNoTracking () do - where (mbr.smallGroupId = gId) - sortBy mbr.memberName - } - let! mbrs = q.ToListAsync () - return List.ofSeq mbrs - } + /// Find all members for a small group + member this.AllMembersForSmallGroup gId = backgroundTask { + let! members = + this.Members.Where(fun mbr -> mbr.smallGroupId = gId) + .OrderBy(fun mbr -> mbr.memberName) + .ToListAsync () + return List.ofSeq members + } - /// Count members for a small group - member this.CountMembersForSmallGroup gId = - this.Members.CountAsync (fun m -> m.smallGroupId = gId) + /// Count members for a small group + member this.CountMembersForSmallGroup gId = backgroundTask { + return! this.Members.CountAsync (fun m -> m.smallGroupId = gId) + } + + (*-- PRAYER REQUEST EXTENSIONS --*) - (*-- PRAYER REQUEST EXTENSIONS --*) + /// Get a prayer request by its Id + member this.TryRequestById reqId = backgroundTask { + let! req = this.PrayerRequests.SingleOrDefaultAsync (fun r -> r.prayerRequestId = reqId) + return Option.fromObject req + } - /// Get a prayer request by its Id - member this.TryRequestById reqId = - query { - for req in this.PrayerRequests.AsNoTracking () do - where (req.prayerRequestId = reqId) - exactlyOneOrDefault - } - |> toOptionTask + /// Get all (or active) requests for a small group as of now or the specified date + member this.AllRequestsForSmallGroup (grp : SmallGroup) clock listDate activeOnly pageNbr = backgroundTask { + let theDate = match listDate with Some dt -> dt | _ -> grp.localDateNow clock + let query = + this.PrayerRequests.Where(fun req -> req.smallGroupId = grp.smallGroupId) + |> function + | q when activeOnly -> + let asOf = theDate.AddDays(-(float grp.preferences.daysToExpire)).Date + q.Where(fun req -> + ( req.updatedDate > asOf + || req.expiration = Manual + || req.requestType = LongTermRequest + || req.requestType = Expecting) + && req.expiration <> Forced) + |> reqSort grp.preferences.requestSort + |> paginate pageNbr grp.preferences.pageSize + | q -> reqSort grp.preferences.requestSort q + let! reqs = query.ToListAsync () + return List.ofSeq reqs + } - /// Get all (or active) requests for a small group as of now or the specified date - // TODO: why not make this an async list like the rest of these methods? - member this.AllRequestsForSmallGroup (grp : SmallGroup) clock listDate activeOnly pageNbr : PrayerRequest seq = - let theDate = match listDate with Some dt -> dt | _ -> grp.localDateNow clock - query { - for req in this.PrayerRequests.AsNoTracking () do - where (req.smallGroupId = grp.smallGroupId) - } - |> function - | q when activeOnly -> - let asOf = theDate.AddDays(-(float grp.preferences.daysToExpire)).Date - query { - for req in q do - where ( ( req.updatedDate > asOf - || req.expiration = Manual - || req.requestType = LongTermRequest - || req.requestType = Expecting) - && req.expiration <> Forced) - } - | q -> q - |> reqSort grp.preferences.requestSort - |> function - | q -> - match activeOnly with - | true -> upcast q - | false -> - upcast query { - for req in q do - skip ((pageNbr - 1) * grp.preferences.pageSize) - take grp.preferences.pageSize - } - - /// Count prayer requests for the given small group Id - member this.CountRequestsBySmallGroup gId = - this.PrayerRequests.CountAsync (fun pr -> pr.smallGroupId = gId) + /// Count prayer requests for the given small group Id + member this.CountRequestsBySmallGroup gId = backgroundTask { + return! this.PrayerRequests.CountAsync (fun pr -> pr.smallGroupId = gId) + } - /// Count prayer requests for the given church Id - member this.CountRequestsByChurch cId = - this.PrayerRequests.CountAsync (fun pr -> pr.smallGroup.churchId = cId) + /// Count prayer requests for the given church Id + member this.CountRequestsByChurch cId = backgroundTask { + return! this.PrayerRequests.CountAsync (fun pr -> pr.smallGroup.churchId = cId) + } - /// Get all (or active) requests for a small group as of now or the specified date - // TODO: same as above... - member this.SearchRequestsForSmallGroup (grp : SmallGroup) (searchTerm : string) pageNbr : PrayerRequest seq = - let pgSz = grp.preferences.pageSize - let toSkip = (pageNbr - 1) * pgSz - let sql = - """ SELECT * FROM pt."PrayerRequest" WHERE "SmallGroupId" = {0} AND "Text" ILIKE {1} + /// Get all (or active) requests for a small group as of now or the specified date + member this.SearchRequestsForSmallGroup (grp : SmallGroup) (searchTerm : string) pageNbr = backgroundTask { + let sql = """ + SELECT * FROM pt."PrayerRequest" WHERE "SmallGroupId" = {0} AND "Text" ILIKE {1} UNION - SELECT * FROM pt."PrayerRequest" WHERE "SmallGroupId" = {0} AND COALESCE("Requestor", '') ILIKE {1}""" - let like = sprintf "%%%s%%" - this.PrayerRequests.FromSqlRaw(sql, grp.smallGroupId, like searchTerm).AsNoTracking () - |> reqSort grp.preferences.requestSort - |> function - | q -> - upcast query { - for req in q do - skip toSkip - take pgSz - } + SELECT * FROM pt."PrayerRequest" WHERE "SmallGroupId" = {0} AND COALESCE("Requestor", '') ILIKE {1}""" + let like = sprintf "%%%s%%" + let query = + this.PrayerRequests.FromSqlRaw(sql, grp.smallGroupId, like searchTerm) + |> reqSort grp.preferences.requestSort + |> paginate pageNbr grp.preferences.pageSize + let! reqs = query.ToListAsync () + return List.ofSeq reqs + } + + (*-- SMALL GROUP EXTENSIONS --*) - (*-- SMALL GROUP EXTENSIONS --*) + /// Find a small group by its Id + member this.TryGroupById gId = backgroundTask { + let! grp = + this.SmallGroups.Include(fun sg -> sg.preferences) + .SingleOrDefaultAsync (fun sg -> sg.smallGroupId = gId) + return Option.fromObject grp + } - /// Find a small group by its Id - member this.TryGroupById gId = - query { - for grp in this.SmallGroups.AsNoTracking().Include (fun sg -> sg.preferences) do - where (grp.smallGroupId = gId) - exactlyOneOrDefault - } - |> toOptionTask + /// Get small groups that are public or password protected + member this.PublicAndProtectedGroups () = backgroundTask { + let! groups = + this.SmallGroups.Include(fun sg -> sg.preferences).Include(fun sg -> sg.church) + .Where(fun sg -> + sg.preferences.isPublic + || (sg.preferences.groupPassword <> null && sg.preferences.groupPassword <> "")) + .OrderBy(fun sg -> sg.church.name).ThenBy(fun sg -> sg.name) + .ToListAsync () + return List.ofSeq groups + } - /// Get small groups that are public or password protected - member this.PublicAndProtectedGroups () = - task { - let smallGroups = this.SmallGroups.AsNoTracking().Include(fun sg -> sg.preferences).Include (fun sg -> sg.church) - let q = - query { - for grp in smallGroups do - where ( grp.preferences.isPublic - || (grp.preferences.groupPassword <> null && grp.preferences.groupPassword <> "")) - sortBy grp.church.name - thenBy grp.name - } - let! grps = q.ToListAsync () - return List.ofSeq grps - } + /// Get small groups that are password protected + member this.ProtectedGroups () = backgroundTask { + let! groups = + this.SmallGroups.Include(fun sg -> sg.church) + .Where(fun sg -> sg.preferences.groupPassword <> null && sg.preferences.groupPassword <> "") + .OrderBy(fun sg -> sg.church.name).ThenBy(fun sg -> sg.name) + .ToListAsync () + return List.ofSeq groups + } - /// Get small groups that are password protected - member this.ProtectedGroups () = - task { - let q = - query { - for grp in this.SmallGroups.AsNoTracking().Include (fun sg -> sg.church) do - where (grp.preferences.groupPassword <> null && grp.preferences.groupPassword <> "") - sortBy grp.church.name - thenBy grp.name - } - let! grps = q.ToListAsync () - return List.ofSeq grps - } + /// Get all small groups + member this.AllGroups () = backgroundTask { + let! groups = + this.SmallGroups + .Include(fun sg -> sg.church) + .Include(fun sg -> sg.preferences) + .Include(fun sg -> sg.preferences.timeZone) + .OrderBy(fun sg -> sg.name) + .ToListAsync () + return List.ofSeq groups + } - /// Get all small groups - member this.AllGroups () = - task { - let! grps = - this.SmallGroups.AsNoTracking() - .Include(fun sg -> sg.church) - .Include(fun sg -> sg.preferences) - .Include(fun sg -> sg.preferences.timeZone) - .OrderBy(fun sg -> sg.name) - .ToListAsync () - return List.ofSeq grps - } + /// Get a small group list by their Id, with their church prepended to their name + member this.GroupList () = backgroundTask { + let! groups = + this.SmallGroups.Include(fun sg -> sg.church) + .OrderBy(fun sg -> sg.church.name).ThenBy(fun sg -> sg.name) + .ToListAsync () + return groups + |> Seq.map (fun sg -> sg.smallGroupId.ToString "N", $"{sg.church.name} | {sg.name}") + |> List.ofSeq + } - /// Get a small group list by their Id, with their church prepended to their name - member this.GroupList () = - task { - let q = - query { - for grp in this.SmallGroups.AsNoTracking().Include (fun sg -> sg.church) do - sortBy grp.church.name - thenBy grp.name - } - let! grps = q.ToListAsync () - return grps - |> Seq.map (fun grp -> grp.smallGroupId.ToString "N", $"{grp.church.name} | {grp.name}") - |> List.ofSeq - } + /// Log on a small group + member this.TryGroupLogOnByPassword gId pw = backgroundTask { + match! this.TryGroupById gId with + | None -> return None + | Some grp -> return if pw = grp.preferences.groupPassword then Some grp else None + } - /// Log on a small group - member this.TryGroupLogOnByPassword gId pw = - task { - match! this.TryGroupById gId with - | None -> return None - | Some grp -> - match pw = grp.preferences.groupPassword with - | true -> return Some grp - | _ -> return None - } + /// Check a cookie log on for a small group + member this.TryGroupLogOnByCookie gId pwHash (hasher : string -> string) = backgroundTask { + match! this.TryGroupById gId with + | None -> return None + | Some grp -> return if pwHash = hasher grp.preferences.groupPassword then Some grp else None + } - /// Check a cookie log on for a small group - member this.TryGroupLogOnByCookie gId pwHash (hasher : string -> string) = - task { - match! this.TryGroupById gId with - | None -> return None - | Some grp -> - match pwHash = hasher grp.preferences.groupPassword with - | true -> return Some grp - | _ -> return None - } + /// Count small groups for the given church Id + member this.CountGroupsByChurch cId = backgroundTask { + return! this.SmallGroups.CountAsync (fun sg -> sg.churchId = cId) + } + + (*-- TIME ZONE EXTENSIONS --*) - /// Count small groups for the given church Id - member this.CountGroupsByChurch cId = - this.SmallGroups.CountAsync (fun sg -> sg.churchId = cId) - - (*-- TIME ZONE EXTENSIONS --*) + /// Get a time zone by its Id + member this.TryTimeZoneById tzId = backgroundTask { + let! zone = this.TimeZones.SingleOrDefaultAsync (fun tz -> tz.timeZoneId = tzId) + return Option.fromObject zone + } - /// Get a time zone by its Id - member this.TryTimeZoneById tzId = - query { - for tz in this.TimeZones do - where (tz.timeZoneId = tzId) - exactlyOneOrDefault - } - |> toOptionTask + /// Get all time zones + member this.AllTimeZones () = backgroundTask { + let! zones = this.TimeZones.OrderBy(fun tz -> tz.sortOrder).ToListAsync () + return List.ofSeq zones + } + + (*-- USER EXTENSIONS --*) - /// Get all time zones - member this.AllTimeZones () = - task { - let q = - query { - for tz in this.TimeZones do - sortBy tz.sortOrder - } - let! tzs = q.ToListAsync () - return List.ofSeq tzs - } - - (*-- USER EXTENSIONS --*) + /// Find a user by its Id + member this.TryUserById uId = backgroundTask { + let! usr = this.Users.SingleOrDefaultAsync (fun u -> u.userId = uId) + return Option.fromObject usr + } - /// Find a user by its Id - member this.TryUserById uId = - query { - for usr in this.Users.AsNoTracking () do - where (usr.userId = uId) - exactlyOneOrDefault - } - |> toOptionTask + /// Find a user by its e-mail address and authorized small group + member this.TryUserByEmailAndGroup email gId = backgroundTask { + let! usr = + this.Users.SingleOrDefaultAsync (fun u -> + u.emailAddress = email && u.smallGroups.Any (fun xref -> xref.smallGroupId = gId)) + return Option.fromObject usr + } + + /// Find a user by its Id, eagerly loading the user's groups + member this.TryUserByIdWithGroups uId = backgroundTask { + let! usr = this.Users.Include(fun u -> u.smallGroups).SingleOrDefaultAsync (fun u -> u.userId = uId) + return Option.fromObject usr + } - /// Find a user by its e-mail address and authorized small group - member this.TryUserByEmailAndGroup email gId = - query { - for usr in this.Users.AsNoTracking () do - where (usr.emailAddress = email && usr.smallGroups.Any (fun xref -> xref.smallGroupId = gId)) - exactlyOneOrDefault - } - |> toOptionTask + /// Get a list of all users + member this.AllUsers () = backgroundTask { + let! users = this.Users.OrderBy(fun u -> u.lastName).ThenBy(fun u -> u.firstName).ToListAsync () + return List.ofSeq users + } - /// Find a user by its Id (tracked entity), eagerly loading the user's groups - member this.TryUserByIdWithGroups uId = - query { - for usr in this.Users.AsNoTracking().Include (fun u -> u.smallGroups) do - where (usr.userId = uId) - exactlyOneOrDefault - } - |> toOptionTask + /// Get all PrayerTracker users as members (used to send e-mails) + member this.AllUsersAsMembers () = backgroundTask { + let! users = this.AllUsers () + return users |> List.map (fun u -> { Member.empty with email = u.emailAddress; memberName = u.fullName }) + } - /// Get a list of all users - member this.AllUsers () = - task { - let q = - query { - for usr in this.Users.AsNoTracking () do - sortBy usr.lastName - thenBy usr.firstName - } - let! usrs = q.ToListAsync () - return List.ofSeq usrs - } + /// Find a user based on their credentials + member this.TryUserLogOnByPassword email pwHash gId = backgroundTask { + let! usr = + this.Users.SingleOrDefaultAsync (fun u -> + u.emailAddress = email + && u.passwordHash = pwHash + && u.smallGroups.Any (fun xref -> xref.smallGroupId = gId)) + return Option.fromObject usr + } - /// Get all PrayerTracker users as members (used to send e-mails) - member this.AllUsersAsMembers () = - task { - let q = - query { - for usr in this.Users.AsNoTracking () do - sortBy usr.lastName - thenBy usr.firstName - select { Member.empty with email = usr.emailAddress; memberName = usr.fullName } - } - let! usrs = q.ToListAsync () - return List.ofSeq usrs - } + /// Find a user based on credentials stored in a cookie + member this.TryUserLogOnByCookie uId gId pwHash = backgroundTask { + match! this.TryUserByIdWithGroups uId with + | None -> return None + | Some usr -> + if pwHash = usr.passwordHash && usr.smallGroups |> Seq.exists (fun xref -> xref.smallGroupId = gId) then + return Some { usr with passwordHash = ""; salt = None; smallGroups = List() } + else return None + } - /// Find a user based on their credentials - member this.TryUserLogOnByPassword email pwHash gId = - query { - for usr in this.Users.AsNoTracking () do - where ( usr.emailAddress = email - && usr.passwordHash = pwHash - && usr.smallGroups.Any (fun xref -> xref.smallGroupId = gId)) - exactlyOneOrDefault - } - |> toOptionTask + /// Count the number of users for a small group + member this.CountUsersBySmallGroup gId = backgroundTask { + return! this.Users.CountAsync (fun u -> u.smallGroups.Any (fun xref -> xref.smallGroupId = gId)) + } - /// Find a user based on credentials stored in a cookie - member this.TryUserLogOnByCookie uId gId pwHash = - task { - match! this.TryUserByIdWithGroups uId with - | None -> return None - | Some usr -> - match pwHash = usr.passwordHash && usr.smallGroups |> Seq.exists (fun xref -> xref.smallGroupId = gId) with - | true -> - this.Entry(usr).State <- EntityState.Detached - return Some { usr with passwordHash = ""; salt = None; smallGroups = List() } - | _ -> return None - } - - /// Count the number of users for a small group - member this.CountUsersBySmallGroup gId = - this.Users.CountAsync (fun u -> u.smallGroups.Any (fun xref -> xref.smallGroupId = gId)) - - /// Count the number of users for a church - member this.CountUsersByChurch cId = - this.Users.CountAsync (fun u -> u.smallGroups.Any (fun xref -> xref.smallGroup.churchId = cId)) + /// Count the number of users for a church + member this.CountUsersByChurch cId = backgroundTask { + return! this.Users.CountAsync (fun u -> u.smallGroups.Any (fun xref -> xref.smallGroup.churchId = cId)) + } diff --git a/src/PrayerTracker.Data/Entities.fs b/src/PrayerTracker.Data/Entities.fs index 1780d11..4ab03c8 100644 --- a/src/PrayerTracker.Data/Entities.fs +++ b/src/PrayerTracker.Data/Entities.fs @@ -12,209 +12,223 @@ open System.Collections.Generic /// How as-of dates should (or should not) be displayed with requests type AsOfDateDisplay = - /// No as-of date should be displayed - | NoDisplay - /// The as-of date should be displayed in the culture's short date format - | ShortDate - /// The as-of date should be displayed in the culture's long date format - | LongDate + /// No as-of date should be displayed + | NoDisplay + /// The as-of date should be displayed in the culture's short date format + | ShortDate + /// The as-of date should be displayed in the culture's long date format + | LongDate with - /// Convert to a DU case from a single-character string - static member fromCode code = - match code with - | "N" -> NoDisplay - | "S" -> ShortDate - | "L" -> LongDate - | _ -> invalidArg "code" (sprintf "Unknown code %s" code) - /// Convert this DU case to a single-character string - member this.code = - match this with - | NoDisplay -> "N" - | ShortDate -> "S" - | LongDate -> "L" + + /// Convert to a DU case from a single-character string + static member fromCode code = + match code with + | "N" -> NoDisplay + | "S" -> ShortDate + | "L" -> LongDate + | _ -> invalidArg "code" $"Unknown code {code}" + + /// Convert this DU case to a single-character string + member this.code = + match this with + | NoDisplay -> "N" + | ShortDate -> "S" + | LongDate -> "L" /// Acceptable e-mail formats type EmailFormat = - /// HTML e-mail - | HtmlFormat - /// Plain-text e-mail - | PlainTextFormat + /// HTML e-mail + | HtmlFormat + /// Plain-text e-mail + | PlainTextFormat with - /// Convert to a DU case from a single-character string - static member fromCode code = - match code with - | "H" -> HtmlFormat - | "P" -> PlainTextFormat - | _ -> invalidArg "code" (sprintf "Unknown code %s" code) - /// Convert this DU case to a single-character string - member this.code = - match this with - | HtmlFormat -> "H" - | PlainTextFormat -> "P" + + /// Convert to a DU case from a single-character string + static member fromCode code = + match code with + | "H" -> HtmlFormat + | "P" -> PlainTextFormat + | _ -> invalidArg "code" $"Unknown code {code}" + + /// Convert this DU case to a single-character string + member this.code = + match this with + | HtmlFormat -> "H" + | PlainTextFormat -> "P" /// Expiration for requests type Expiration = - /// Follow the rules for normal expiration - | Automatic - /// Do not expire via rules - | Manual - /// Force immediate expiration - | Forced + /// Follow the rules for normal expiration + | Automatic + /// Do not expire via rules + | Manual + /// Force immediate expiration + | Forced with - /// Convert to a DU case from a single-character string - static member fromCode code = - match code with - | "A" -> Automatic - | "M" -> Manual - | "F" -> Forced - | _ -> invalidArg "code" (sprintf "Unknown code %s" code) - /// Convert this DU case to a single-character string - member this.code = - match this with - | Automatic -> "A" - | Manual -> "M" - | Forced -> "F" + + /// Convert to a DU case from a single-character string + static member fromCode code = + match code with + | "A" -> Automatic + | "M" -> Manual + | "F" -> Forced + | _ -> invalidArg "code" $"Unknown code {code}" + + /// Convert this DU case to a single-character string + member this.code = + match this with + | Automatic -> "A" + | Manual -> "M" + | Forced -> "F" /// Types of prayer requests type PrayerRequestType = - /// Current requests - | CurrentRequest - /// Long-term/ongoing request - | LongTermRequest - /// Expectant couples - | Expecting - /// Praise reports - | PraiseReport - /// Announcements - | Announcement + /// Current requests + | CurrentRequest + /// Long-term/ongoing request + | LongTermRequest + /// Expectant couples + | Expecting + /// Praise reports + | PraiseReport + /// Announcements + | Announcement with - /// Convert to a DU case from a single-character string - static member fromCode code = - match code with - | "C" -> CurrentRequest - | "L" -> LongTermRequest - | "E" -> Expecting - | "P" -> PraiseReport - | "A" -> Announcement - | _ -> invalidArg "code" (sprintf "Unknown code %s" code) - /// Convert this DU case to a single-character string - member this.code = - match this with - | CurrentRequest -> "C" - | LongTermRequest -> "L" - | Expecting -> "E" - | PraiseReport -> "P" - | Announcement -> "A" + + /// Convert to a DU case from a single-character string + static member fromCode code = + match code with + | "C" -> CurrentRequest + | "L" -> LongTermRequest + | "E" -> Expecting + | "P" -> PraiseReport + | "A" -> Announcement + | _ -> invalidArg "code" $"Unknown code {code}" + + /// Convert this DU case to a single-character string + member this.code = + match this with + | CurrentRequest -> "C" + | LongTermRequest -> "L" + | Expecting -> "E" + | PraiseReport -> "P" + | Announcement -> "A" /// How requests should be sorted type RequestSort = - /// Sort by date, then by requestor/subject - | SortByDate - /// Sort by requestor/subject, then by date - | SortByRequestor + /// Sort by date, then by requestor/subject + | SortByDate + /// Sort by requestor/subject, then by date + | SortByRequestor with - /// Convert to a DU case from a single-character string - static member fromCode code = - match code with - | "D" -> SortByDate - | "R" -> SortByRequestor - | _ -> invalidArg "code" (sprintf "Unknown code %s" code) - /// Convert this DU case to a single-character string - member this.code = - match this with - | SortByDate -> "D" - | SortByRequestor -> "R" + + /// Convert to a DU case from a single-character string + static member fromCode code = + match code with + | "D" -> SortByDate + | "R" -> SortByRequestor + | _ -> invalidArg "code" $"Unknown code {code}" + + /// Convert this DU case to a single-character string + member this.code = + match this with + | SortByDate -> "D" + | SortByRequestor -> "R" +/// EF Core value converters for the discriminated union types above module Converters = - open Microsoft.EntityFrameworkCore.Storage.ValueConversion - open Microsoft.FSharp.Linq.RuntimeHelpers - open System.Linq.Expressions - let private asOfFromDU = - <@ Func(fun (x : AsOfDateDisplay) -> x.code) @> - |> LeafExpressionConverter.QuotationToExpression - |> unbox>> + open Microsoft.EntityFrameworkCore.Storage.ValueConversion + open Microsoft.FSharp.Linq.RuntimeHelpers + open System.Linq.Expressions - let private asOfToDU = - <@ Func(AsOfDateDisplay.fromCode) @> - |> LeafExpressionConverter.QuotationToExpression - |> unbox>> - - let private emailFromDU = - <@ Func(fun (x : EmailFormat) -> x.code) @> - |> LeafExpressionConverter.QuotationToExpression - |> unbox>> + let private asOfFromDU = + <@ Func(fun (x : AsOfDateDisplay) -> x.code) @> + |> LeafExpressionConverter.QuotationToExpression + |> unbox>> - let private emailToDU = - <@ Func(EmailFormat.fromCode) @> - |> LeafExpressionConverter.QuotationToExpression - |> unbox>> - - let private expFromDU = - <@ Func(fun (x : Expiration) -> x.code) @> - |> LeafExpressionConverter.QuotationToExpression - |> unbox>> + let private asOfToDU = + <@ Func(AsOfDateDisplay.fromCode) @> + |> LeafExpressionConverter.QuotationToExpression + |> unbox>> + + let private emailFromDU = + <@ Func(fun (x : EmailFormat) -> x.code) @> + |> LeafExpressionConverter.QuotationToExpression + |> unbox>> - let private expToDU = - <@ Func(Expiration.fromCode) @> - |> LeafExpressionConverter.QuotationToExpression - |> unbox>> - - let private sortFromDU = - <@ Func(fun (x : RequestSort) -> x.code) @> - |> LeafExpressionConverter.QuotationToExpression - |> unbox>> + let private emailToDU = + <@ Func(EmailFormat.fromCode) @> + |> LeafExpressionConverter.QuotationToExpression + |> unbox>> + + let private expFromDU = + <@ Func(fun (x : Expiration) -> x.code) @> + |> LeafExpressionConverter.QuotationToExpression + |> unbox>> - let private sortToDU = - <@ Func(RequestSort.fromCode) @> - |> LeafExpressionConverter.QuotationToExpression - |> unbox>> - - let private typFromDU = - <@ Func(fun (x : PrayerRequestType) -> x.code) @> - |> LeafExpressionConverter.QuotationToExpression - |> unbox>> + let private expToDU = + <@ Func(Expiration.fromCode) @> + |> LeafExpressionConverter.QuotationToExpression + |> unbox>> + + let private sortFromDU = + <@ Func(fun (x : RequestSort) -> x.code) @> + |> LeafExpressionConverter.QuotationToExpression + |> unbox>> - let private typToDU = - <@ Func(PrayerRequestType.fromCode) @> - |> LeafExpressionConverter.QuotationToExpression - |> unbox>> - - /// Conversion between a string and an AsOfDateDisplay DU value - type AsOfDateDisplayConverter () = - inherit ValueConverter (asOfFromDU, asOfToDU) + let private sortToDU = + <@ Func(RequestSort.fromCode) @> + |> LeafExpressionConverter.QuotationToExpression + |> unbox>> + + let private typFromDU = + <@ Func(fun (x : PrayerRequestType) -> x.code) @> + |> LeafExpressionConverter.QuotationToExpression + |> unbox>> - /// Conversion between a string and an EmailFormat DU value - type EmailFormatConverter () = - inherit ValueConverter (emailFromDU, emailToDU) + let private typToDU = + <@ Func(PrayerRequestType.fromCode) @> + |> LeafExpressionConverter.QuotationToExpression + |> unbox>> + + /// Conversion between a string and an AsOfDateDisplay DU value + type AsOfDateDisplayConverter () = + inherit ValueConverter (asOfFromDU, asOfToDU) - /// Conversion between a string and an Expiration DU value - type ExpirationConverter () = - inherit ValueConverter (expFromDU, expToDU) + /// Conversion between a string and an EmailFormat DU value + type EmailFormatConverter () = + inherit ValueConverter (emailFromDU, emailToDU) - /// Conversion between a string and an AsOfDateDisplay DU value - type PrayerRequestTypeConverter () = - inherit ValueConverter (typFromDU, typToDU) + /// Conversion between a string and an Expiration DU value + type ExpirationConverter () = + inherit ValueConverter (expFromDU, expToDU) - /// Conversion between a string and a RequestSort DU value - type RequestSortConverter () = - inherit ValueConverter (sortFromDU, sortToDU) + /// Conversion between a string and an AsOfDateDisplay DU value + type PrayerRequestTypeConverter () = + inherit ValueConverter (typFromDU, typToDU) + + /// Conversion between a string and a RequestSort DU value + type RequestSortConverter () = + inherit ValueConverter (sortFromDU, sortToDU) /// Statistics for churches [] type ChurchStats = - { /// The number of small groups in the church - smallGroups : int - /// The number of prayer requests in the church - prayerRequests : int - /// The number of users who can access small groups in the church - users : int + { /// The number of small groups in the church + smallGroups : int + + /// The number of prayer requests in the church + prayerRequests : int + + /// The number of users who can access small groups in the church + users : int } /// PK type for the Church entity @@ -237,522 +251,570 @@ type UserId = Guid /// PK for User/SmallGroup cross-reference table type UserSmallGroupKey = - { userId : UserId - smallGroupId : SmallGroupId + { /// The ID of the user to whom access is granted + userId : UserId + + /// The ID of the small group to which the entry grants access + smallGroupId : SmallGroupId } (*-- ENTITIES --*) /// This represents a church type [] Church = - { /// The Id of this church - churchId : ChurchId - /// The name of the church - name : string - /// The city where the church is - city : string - /// The state where the church is - st : string - /// Does this church have an active interface with Virtual Prayer Room? - hasInterface : bool - /// The address for the interface - interfaceAddress : string option - - /// Small groups for this church - smallGroups : ICollection + { /// The Id of this church + churchId : ChurchId + + /// The name of the church + name : string + + /// The city where the church is + city : string + + /// The state where the church is + st : string + + /// Does this church have an active interface with Virtual Prayer Room? + hasInterface : bool + + /// The address for the interface + interfaceAddress : string option + + /// Small groups for this church + smallGroups : ICollection } - with +with /// An empty church // aww... how sad :( static member empty = - { churchId = Guid.Empty - name = "" - city = "" - st = "" - hasInterface = false - interfaceAddress = None - smallGroups = List () + { churchId = Guid.Empty + name = "" + city = "" + st = "" + hasInterface = false + interfaceAddress = None + smallGroups = List () } + /// Configure EF for this entity static member internal configureEF (mb : ModelBuilder) = - mb.Entity ( - fun m -> - m.ToTable "Church" |> ignore - m.Property(fun e -> e.churchId).HasColumnName "ChurchId" |> ignore - m.Property(fun e -> e.name).HasColumnName("Name").IsRequired () |> ignore - m.Property(fun e -> e.city).HasColumnName("City").IsRequired () |> ignore - m.Property(fun e -> e.st).HasColumnName("ST").IsRequired().HasMaxLength 2 |> ignore - m.Property(fun e -> e.hasInterface).HasColumnName "HasVirtualPrayerRoomInterface" |> ignore - m.Property(fun e -> e.interfaceAddress).HasColumnName "InterfaceAddress" |> ignore) - |> ignore - mb.Model.FindEntityType(typeof).FindProperty("interfaceAddress") - .SetValueConverter(OptionConverter ()) + mb.Entity (fun m -> + m.ToTable "Church" |> ignore + m.Property(fun e -> e.churchId).HasColumnName "ChurchId" |> ignore + m.Property(fun e -> e.name).HasColumnName("Name").IsRequired () |> ignore + m.Property(fun e -> e.city).HasColumnName("City").IsRequired () |> ignore + m.Property(fun e -> e.st).HasColumnName("ST").IsRequired().HasMaxLength 2 |> ignore + m.Property(fun e -> e.hasInterface).HasColumnName "HasVirtualPrayerRoomInterface" |> ignore + m.Property(fun e -> e.interfaceAddress).HasColumnName "InterfaceAddress" |> ignore) + |> ignore + mb.Model.FindEntityType(typeof).FindProperty("interfaceAddress") + .SetValueConverter(OptionConverter ()) /// Preferences for the form and format of the prayer request list and [] ListPreferences = - { /// The Id of the small group to which these preferences belong - smallGroupId : SmallGroupId - /// The days after which regular requests expire - daysToExpire : int - /// The number of days a new or updated request is considered new - daysToKeepNew : int - /// The number of weeks after which long-term requests are flagged for follow-up - longTermUpdateWeeks : int - /// The name from which e-mails are sent - emailFromName : string - /// The e-mail address from which e-mails are sent - emailFromAddress : string - /// The fonts to use in generating the list of prayer requests - listFonts : string - /// The color for the prayer request list headings - headingColor : string - /// The color for the lines offsetting the prayer request list headings - lineColor : string - /// The font size for the headings on the prayer request list - headingFontSize : int - /// The font size for the text on the prayer request list - textFontSize : int - /// The order in which the prayer requests are sorted - requestSort : RequestSort - /// The password used for "small group login" (view-only request list) - groupPassword : string - /// The default e-mail type for this class - defaultEmailType : EmailFormat - /// Whether this class makes its request list public - isPublic : bool - /// The time zone which this class uses (use tzdata names) - timeZoneId : TimeZoneId - /// The time zone information - timeZone : TimeZone - /// The number of requests displayed per page - pageSize : int - /// How the as-of date should be automatically displayed - asOfDateDisplay : AsOfDateDisplay + { /// The Id of the small group to which these preferences belong + smallGroupId : SmallGroupId + + /// The days after which regular requests expire + daysToExpire : int + + /// The number of days a new or updated request is considered new + daysToKeepNew : int + + /// The number of weeks after which long-term requests are flagged for follow-up + longTermUpdateWeeks : int + + /// The name from which e-mails are sent + emailFromName : string + + /// The e-mail address from which e-mails are sent + emailFromAddress : string + + /// The fonts to use in generating the list of prayer requests + listFonts : string + + /// The color for the prayer request list headings + headingColor : string + + /// The color for the lines offsetting the prayer request list headings + lineColor : string + + /// The font size for the headings on the prayer request list + headingFontSize : int + + /// The font size for the text on the prayer request list + textFontSize : int + + /// The order in which the prayer requests are sorted + requestSort : RequestSort + + /// The password used for "small group login" (view-only request list) + groupPassword : string + + /// The default e-mail type for this class + defaultEmailType : EmailFormat + + /// Whether this class makes its request list public + isPublic : bool + + /// The time zone which this class uses (use tzdata names) + timeZoneId : TimeZoneId + + /// The time zone information + timeZone : TimeZone + + /// The number of requests displayed per page + pageSize : int + + /// How the as-of date should be automatically displayed + asOfDateDisplay : AsOfDateDisplay } - with +with + /// A set of preferences with their default values static member empty = - { smallGroupId = Guid.Empty - daysToExpire = 14 - daysToKeepNew = 7 - longTermUpdateWeeks = 4 - emailFromName = "PrayerTracker" - emailFromAddress = "prayer@djs-consulting.com" - listFonts = "Century Gothic,Tahoma,Luxi Sans,sans-serif" - headingColor = "maroon" - lineColor = "navy" - headingFontSize = 16 - textFontSize = 12 - requestSort = SortByDate - groupPassword = "" - defaultEmailType = HtmlFormat - isPublic = false - timeZoneId = "America/Denver" - timeZone = TimeZone.empty - pageSize = 100 - asOfDateDisplay = NoDisplay - } + { smallGroupId = Guid.Empty + daysToExpire = 14 + daysToKeepNew = 7 + longTermUpdateWeeks = 4 + emailFromName = "PrayerTracker" + emailFromAddress = "prayer@djs-consulting.com" + listFonts = "Century Gothic,Tahoma,Luxi Sans,sans-serif" + headingColor = "maroon" + lineColor = "navy" + headingFontSize = 16 + textFontSize = 12 + requestSort = SortByDate + groupPassword = "" + defaultEmailType = HtmlFormat + isPublic = false + timeZoneId = "America/Denver" + timeZone = TimeZone.empty + pageSize = 100 + asOfDateDisplay = NoDisplay + } + /// Configure EF for this entity static member internal configureEF (mb : ModelBuilder) = - mb.Entity ( - fun m -> - m.ToTable "ListPreference" |> ignore - m.HasKey (fun e -> e.smallGroupId :> obj) |> ignore - m.Property(fun e -> e.smallGroupId).HasColumnName "SmallGroupId" |> ignore - m.Property(fun e -> e.daysToKeepNew) - .HasColumnName("DaysToKeepNew") - .IsRequired() - .HasDefaultValue 7 - |> ignore - m.Property(fun e -> e.daysToExpire) - .HasColumnName("DaysToExpire") - .IsRequired() - .HasDefaultValue 14 - |> ignore - m.Property(fun e -> e.longTermUpdateWeeks) - .HasColumnName("LongTermUpdateWeeks") - .IsRequired() - .HasDefaultValue 4 - |> ignore - m.Property(fun e -> e.emailFromName) - .HasColumnName("EmailFromName") - .IsRequired() - .HasDefaultValue "PrayerTracker" - |> ignore - m.Property(fun e -> e.emailFromAddress) - .HasColumnName("EmailFromAddress") - .IsRequired() - .HasDefaultValue "prayer@djs-consulting.com" - |> ignore - m.Property(fun e -> e.listFonts) - .HasColumnName("ListFonts") - .IsRequired() - .HasDefaultValue "Century Gothic,Tahoma,Luxi Sans,sans-serif" - |> ignore - m.Property(fun e -> e.headingColor) - .HasColumnName("HeadingColor") - .IsRequired() - .HasDefaultValue "maroon" - |> ignore - m.Property(fun e -> e.lineColor) - .HasColumnName("LineColor") - .IsRequired() - .HasDefaultValue "navy" - |> ignore - m.Property(fun e -> e.headingFontSize) - .HasColumnName("HeadingFontSize") - .IsRequired() - .HasDefaultValue 16 - |> ignore - m.Property(fun e -> e.textFontSize) - .HasColumnName("TextFontSize") - .IsRequired() - .HasDefaultValue 12 - |> ignore - m.Property(fun e -> e.requestSort) - .HasColumnName("RequestSort") - .IsRequired() - .HasMaxLength(1) - .HasDefaultValue SortByDate - |> ignore - m.Property(fun e -> e.groupPassword) - .HasColumnName("GroupPassword") - .IsRequired() - .HasDefaultValue "" - |> ignore - m.Property(fun e -> e.defaultEmailType) - .HasColumnName("DefaultEmailType") - .IsRequired() - .HasDefaultValue HtmlFormat - |> ignore - m.Property(fun e -> e.isPublic) - .HasColumnName("IsPublic") - .IsRequired() - .HasDefaultValue false - |> ignore - m.Property(fun e -> e.timeZoneId) - .HasColumnName("TimeZoneId") - .IsRequired() - .HasDefaultValue "America/Denver" - |> ignore - m.Property(fun e -> e.pageSize) - .HasColumnName("PageSize") - .IsRequired() - .HasDefaultValue 100 - |> ignore - m.Property(fun e -> e.asOfDateDisplay) - .HasColumnName("AsOfDateDisplay") - .IsRequired() - .HasMaxLength(1) - .HasDefaultValue NoDisplay - |> ignore) - |> ignore - mb.Model.FindEntityType(typeof).FindProperty("requestSort") - .SetValueConverter(Converters.RequestSortConverter ()) - mb.Model.FindEntityType(typeof).FindProperty("defaultEmailType") - .SetValueConverter(Converters.EmailFormatConverter ()) - mb.Model.FindEntityType(typeof).FindProperty("asOfDateDisplay") - .SetValueConverter(Converters.AsOfDateDisplayConverter ()) + mb.Entity (fun m -> + m.ToTable "ListPreference" |> ignore + m.HasKey (fun e -> e.smallGroupId :> obj) |> ignore + m.Property(fun e -> e.smallGroupId).HasColumnName "SmallGroupId" |> ignore + m.Property(fun e -> e.daysToKeepNew) + .HasColumnName("DaysToKeepNew") + .IsRequired() + .HasDefaultValue 7 + |> ignore + m.Property(fun e -> e.daysToExpire) + .HasColumnName("DaysToExpire") + .IsRequired() + .HasDefaultValue 14 + |> ignore + m.Property(fun e -> e.longTermUpdateWeeks) + .HasColumnName("LongTermUpdateWeeks") + .IsRequired() + .HasDefaultValue 4 + |> ignore + m.Property(fun e -> e.emailFromName) + .HasColumnName("EmailFromName") + .IsRequired() + .HasDefaultValue "PrayerTracker" + |> ignore + m.Property(fun e -> e.emailFromAddress) + .HasColumnName("EmailFromAddress") + .IsRequired() + .HasDefaultValue "prayer@djs-consulting.com" + |> ignore + m.Property(fun e -> e.listFonts) + .HasColumnName("ListFonts") + .IsRequired() + .HasDefaultValue "Century Gothic,Tahoma,Luxi Sans,sans-serif" + |> ignore + m.Property(fun e -> e.headingColor) + .HasColumnName("HeadingColor") + .IsRequired() + .HasDefaultValue "maroon" + |> ignore + m.Property(fun e -> e.lineColor) + .HasColumnName("LineColor") + .IsRequired() + .HasDefaultValue "navy" + |> ignore + m.Property(fun e -> e.headingFontSize) + .HasColumnName("HeadingFontSize") + .IsRequired() + .HasDefaultValue 16 + |> ignore + m.Property(fun e -> e.textFontSize) + .HasColumnName("TextFontSize") + .IsRequired() + .HasDefaultValue 12 + |> ignore + m.Property(fun e -> e.requestSort) + .HasColumnName("RequestSort") + .IsRequired() + .HasMaxLength(1) + .HasDefaultValue SortByDate + |> ignore + m.Property(fun e -> e.groupPassword) + .HasColumnName("GroupPassword") + .IsRequired() + .HasDefaultValue "" + |> ignore + m.Property(fun e -> e.defaultEmailType) + .HasColumnName("DefaultEmailType") + .IsRequired() + .HasDefaultValue HtmlFormat + |> ignore + m.Property(fun e -> e.isPublic) + .HasColumnName("IsPublic") + .IsRequired() + .HasDefaultValue false + |> ignore + m.Property(fun e -> e.timeZoneId) + .HasColumnName("TimeZoneId") + .IsRequired() + .HasDefaultValue "America/Denver" + |> ignore + m.Property(fun e -> e.pageSize) + .HasColumnName("PageSize") + .IsRequired() + .HasDefaultValue 100 + |> ignore + m.Property(fun e -> e.asOfDateDisplay) + .HasColumnName("AsOfDateDisplay") + .IsRequired() + .HasMaxLength(1) + .HasDefaultValue NoDisplay + |> ignore) + |> ignore + mb.Model.FindEntityType(typeof).FindProperty("requestSort") + .SetValueConverter(Converters.RequestSortConverter ()) + mb.Model.FindEntityType(typeof).FindProperty("defaultEmailType") + .SetValueConverter(Converters.EmailFormatConverter ()) + mb.Model.FindEntityType(typeof).FindProperty("asOfDateDisplay") + .SetValueConverter(Converters.AsOfDateDisplayConverter ()) /// A member of a small group and [] Member = - { /// The Id of the member - memberId : MemberId - /// The Id of the small group to which this member belongs - smallGroupId : SmallGroupId - /// The name of the member - memberName : string - /// The e-mail address for the member - email : string - /// The type of e-mail preferred by this member (see constants) - format : string option // TODO - do I need a custom formatter for this? - /// The small group to which this member belongs - smallGroup : SmallGroup + { /// The Id of the member + memberId : MemberId + + /// The Id of the small group to which this member belongs + smallGroupId : SmallGroupId + + /// The name of the member + memberName : string + + /// The e-mail address for the member + email : string + + /// The type of e-mail preferred by this member (see constants) + format : string option // TODO - do I need a custom formatter for this? + + /// The small group to which this member belongs + smallGroup : SmallGroup } - with +with + /// An empty member static member empty = - { memberId = Guid.Empty - smallGroupId = Guid.Empty - memberName = "" - email = "" - format = None - smallGroup = SmallGroup.empty + { memberId = Guid.Empty + smallGroupId = Guid.Empty + memberName = "" + email = "" + format = None + smallGroup = SmallGroup.empty } + /// Configure EF for this entity static member internal configureEF (mb : ModelBuilder) = - mb.Entity ( - fun m -> - m.ToTable "Member" |> ignore - m.Property(fun e -> e.memberId).HasColumnName "MemberId" |> ignore - m.Property(fun e -> e.smallGroupId).HasColumnName "SmallGroupId" |> ignore - m.Property(fun e -> e.memberName).HasColumnName("MemberName").IsRequired() |> ignore - m.Property(fun e -> e.email).HasColumnName("Email").IsRequired() |> ignore - m.Property(fun e -> e.format).HasColumnName "Format" |> ignore) - |> ignore - mb.Model.FindEntityType(typeof).FindProperty("format").SetValueConverter(OptionConverter ()) + mb.Entity (fun m -> + m.ToTable "Member" |> ignore + m.Property(fun e -> e.memberId).HasColumnName "MemberId" |> ignore + m.Property(fun e -> e.smallGroupId).HasColumnName "SmallGroupId" |> ignore + m.Property(fun e -> e.memberName).HasColumnName("MemberName").IsRequired() |> ignore + m.Property(fun e -> e.email).HasColumnName("Email").IsRequired() |> ignore + m.Property(fun e -> e.format).HasColumnName "Format" |> ignore) + |> ignore + mb.Model.FindEntityType(typeof).FindProperty("format").SetValueConverter(OptionConverter ()) /// This represents a single prayer request and [] PrayerRequest = - { /// The Id of this request - prayerRequestId : PrayerRequestId - /// The type of the request - requestType : PrayerRequestType - /// The user who entered the request - userId : UserId - /// The small group to which this request belongs - smallGroupId : SmallGroupId - /// The date/time on which this request was entered - enteredDate : DateTime - /// The date/time this request was last updated - updatedDate : DateTime - /// The name of the requestor or subject, or title of announcement - requestor : string option - /// The text of the request - text : string - /// Whether the chaplain should be notified for this request - notifyChaplain : bool - /// The user who entered this request - user : User - /// The small group to which this request belongs - smallGroup : SmallGroup - /// Is this request expired? - expiration : Expiration + { /// The Id of this request + prayerRequestId : PrayerRequestId + + /// The type of the request + requestType : PrayerRequestType + + /// The user who entered the request + userId : UserId + + /// The small group to which this request belongs + smallGroupId : SmallGroupId + + /// The date/time on which this request was entered + enteredDate : DateTime + + /// The date/time this request was last updated + updatedDate : DateTime + + /// The name of the requestor or subject, or title of announcement + requestor : string option + + /// The text of the request + text : string + + /// Whether the chaplain should be notified for this request + notifyChaplain : bool + + /// The user who entered this request + user : User + + /// The small group to which this request belongs + smallGroup : SmallGroup + + /// Is this request expired? + expiration : Expiration } - with +with + /// An empty request static member empty = - { prayerRequestId = Guid.Empty - requestType = CurrentRequest - userId = Guid.Empty - smallGroupId = Guid.Empty - enteredDate = DateTime.MinValue - updatedDate = DateTime.MinValue - requestor = None - text = "" - notifyChaplain = false - user = User.empty - smallGroup = SmallGroup.empty - expiration = Automatic + { prayerRequestId = Guid.Empty + requestType = CurrentRequest + userId = Guid.Empty + smallGroupId = Guid.Empty + enteredDate = DateTime.MinValue + updatedDate = DateTime.MinValue + requestor = None + text = "" + notifyChaplain = false + user = User.empty + smallGroup = SmallGroup.empty + expiration = Automatic } + /// Is this request expired? member this.isExpired (curr : DateTime) expDays = - match this.expiration with - | Forced -> true - | Manual -> false - | Automatic -> - match this.requestType with - | LongTermRequest - | Expecting -> false - | _ -> curr.AddDays(-(float expDays)).Date > this.updatedDate.Date // Automatic expiration + match this.expiration with + | Forced -> true + | Manual -> false + | Automatic -> + match this.requestType with + | LongTermRequest + | Expecting -> false + | _ -> curr.AddDays(-(float expDays)).Date > this.updatedDate.Date // Automatic expiration /// Is an update required for this long-term request? member this.updateRequired curr expDays updWeeks = - match this.isExpired curr expDays with - | true -> false - | false -> curr.AddDays(-(float (updWeeks * 7))).Date > this.updatedDate.Date + match this.isExpired curr expDays with + | true -> false + | false -> curr.AddDays(-(float (updWeeks * 7))).Date > this.updatedDate.Date /// Configure EF for this entity static member internal configureEF (mb : ModelBuilder) = - mb.Entity ( - fun m -> - m.ToTable "PrayerRequest" |> ignore - m.Property(fun e -> e.prayerRequestId).HasColumnName "PrayerRequestId" |> ignore - m.Property(fun e -> e.requestType).HasColumnName("RequestType").IsRequired() |> ignore - m.Property(fun e -> e.userId).HasColumnName "UserId" |> ignore - m.Property(fun e -> e.smallGroupId).HasColumnName "SmallGroupId" |> ignore - m.Property(fun e -> e.enteredDate).HasColumnName "EnteredDate" |> ignore - m.Property(fun e -> e.updatedDate).HasColumnName "UpdatedDate" |> ignore - m.Property(fun e -> e.requestor).HasColumnName "Requestor" |> ignore - m.Property(fun e -> e.text).HasColumnName("Text").IsRequired() |> ignore - m.Property(fun e -> e.notifyChaplain).HasColumnName "NotifyChaplain" |> ignore - m.Property(fun e -> e.expiration).HasColumnName "Expiration" |> ignore) - |> ignore - mb.Model.FindEntityType(typeof).FindProperty("requestType") - .SetValueConverter(Converters.PrayerRequestTypeConverter ()) - mb.Model.FindEntityType(typeof).FindProperty("requestor") - .SetValueConverter(OptionConverter ()) - mb.Model.FindEntityType(typeof).FindProperty("expiration") - .SetValueConverter(Converters.ExpirationConverter ()) + mb.Entity (fun m -> + m.ToTable "PrayerRequest" |> ignore + m.Property(fun e -> e.prayerRequestId).HasColumnName "PrayerRequestId" |> ignore + m.Property(fun e -> e.requestType).HasColumnName("RequestType").IsRequired() |> ignore + m.Property(fun e -> e.userId).HasColumnName "UserId" |> ignore + m.Property(fun e -> e.smallGroupId).HasColumnName "SmallGroupId" |> ignore + m.Property(fun e -> e.enteredDate).HasColumnName "EnteredDate" |> ignore + m.Property(fun e -> e.updatedDate).HasColumnName "UpdatedDate" |> ignore + m.Property(fun e -> e.requestor).HasColumnName "Requestor" |> ignore + m.Property(fun e -> e.text).HasColumnName("Text").IsRequired() |> ignore + m.Property(fun e -> e.notifyChaplain).HasColumnName "NotifyChaplain" |> ignore + m.Property(fun e -> e.expiration).HasColumnName "Expiration" |> ignore) + |> ignore + mb.Model.FindEntityType(typeof).FindProperty("requestType") + .SetValueConverter(Converters.PrayerRequestTypeConverter ()) + mb.Model.FindEntityType(typeof).FindProperty("requestor") + .SetValueConverter(OptionConverter ()) + mb.Model.FindEntityType(typeof).FindProperty("expiration") + .SetValueConverter(Converters.ExpirationConverter ()) /// This represents a small group (Sunday School class, Bible study group, etc.) and [] SmallGroup = - { /// The Id of this small group - smallGroupId : SmallGroupId - /// The church to which this group belongs - churchId : ChurchId - /// The name of the group - name : string - /// The church to which this small group belongs - church : Church - /// The preferences for the request list - preferences : ListPreferences - /// The members of the group - members : ICollection - /// Prayer requests for this small group - prayerRequests : ICollection - /// The users authorized to manage this group - users : ICollection + { /// The Id of this small group + smallGroupId : SmallGroupId + /// The church to which this group belongs + churchId : ChurchId + /// The name of the group + name : string + /// The church to which this small group belongs + church : Church + /// The preferences for the request list + preferences : ListPreferences + /// The members of the group + members : ICollection + /// Prayer requests for this small group + prayerRequests : ICollection + /// The users authorized to manage this group + users : ICollection } - with +with + /// An empty small group static member empty = - { smallGroupId = Guid.Empty - churchId = Guid.Empty - name = "" - church = Church.empty - preferences = ListPreferences.empty - members = List () - prayerRequests = List () - users = List () + { smallGroupId = Guid.Empty + churchId = Guid.Empty + name = "" + church = Church.empty + preferences = ListPreferences.empty + members = List () + prayerRequests = List () + users = List () } /// Get the local date for this group member this.localTimeNow (clock : IClock) = - match clock with null -> nullArg "clock" | _ -> () - let tz = - match DateTimeZoneProviders.Tzdb.Ids.Contains this.preferences.timeZoneId with - | true -> DateTimeZoneProviders.Tzdb.[this.preferences.timeZoneId] - | false -> DateTimeZone.Utc - clock.GetCurrentInstant().InZone(tz).ToDateTimeUnspecified() + match clock with null -> nullArg "clock" | _ -> () + let tz = + if DateTimeZoneProviders.Tzdb.Ids.Contains this.preferences.timeZoneId then + DateTimeZoneProviders.Tzdb[this.preferences.timeZoneId] + else DateTimeZone.Utc + clock.GetCurrentInstant().InZone(tz).ToDateTimeUnspecified () /// Get the local date for this group member this.localDateNow clock = - (this.localTimeNow clock).Date + (this.localTimeNow clock).Date /// Configure EF for this entity static member internal configureEF (mb : ModelBuilder) = - mb.Entity ( - fun m -> - m.ToTable "SmallGroup" |> ignore - m.Property(fun e -> e.smallGroupId).HasColumnName "SmallGroupId" |> ignore - m.Property(fun e -> e.churchId).HasColumnName "ChurchId" |> ignore - m.Property(fun e -> e.name).HasColumnName("Name").IsRequired() |> ignore - m.HasOne(fun e -> e.preferences) |> ignore) - |> ignore + mb.Entity (fun m -> + m.ToTable "SmallGroup" |> ignore + m.Property(fun e -> e.smallGroupId).HasColumnName "SmallGroupId" |> ignore + m.Property(fun e -> e.churchId).HasColumnName "ChurchId" |> ignore + m.Property(fun e -> e.name).HasColumnName("Name").IsRequired() |> ignore + m.HasOne(fun e -> e.preferences) |> ignore) + |> ignore /// This represents a time zone in which a class may reside and [] TimeZone = - { /// The Id for this time zone (uses tzdata names) - timeZoneId : TimeZoneId - /// The description of this time zone - description : string - /// The order in which this timezone should be displayed - sortOrder : int - /// Whether this timezone is active - isActive : bool + { /// The Id for this time zone (uses tzdata names) + timeZoneId : TimeZoneId + /// The description of this time zone + description : string + /// The order in which this timezone should be displayed + sortOrder : int + /// Whether this timezone is active + isActive : bool } - with +with + /// An empty time zone static member empty = - { timeZoneId = "" - description = "" - sortOrder = 0 - isActive = false + { timeZoneId = "" + description = "" + sortOrder = 0 + isActive = false } + /// Configure EF for this entity static member internal configureEF (mb : ModelBuilder) = - mb.Entity ( - fun m -> - m.ToTable "TimeZone" |> ignore - m.Property(fun e -> e.timeZoneId).HasColumnName "TimeZoneId" |> ignore - m.Property(fun e -> e.description).HasColumnName("Description").IsRequired() |> ignore - m.Property(fun e -> e.sortOrder).HasColumnName "SortOrder" |> ignore - m.Property(fun e -> e.isActive).HasColumnName "IsActive" |> ignore) - |> ignore + mb.Entity ( fun m -> + m.ToTable "TimeZone" |> ignore + m.Property(fun e -> e.timeZoneId).HasColumnName "TimeZoneId" |> ignore + m.Property(fun e -> e.description).HasColumnName("Description").IsRequired() |> ignore + m.Property(fun e -> e.sortOrder).HasColumnName "SortOrder" |> ignore + m.Property(fun e -> e.isActive).HasColumnName "IsActive" |> ignore) + |> ignore /// This represents a user of PrayerTracker and [] User = - { /// The Id of this user - userId : UserId - /// The first name of this user - firstName : string - /// The last name of this user - lastName : string - /// The e-mail address of the user - emailAddress : string - /// Whether this user is a PrayerTracker system administrator - isAdmin : bool - /// The user's hashed password - passwordHash : string - /// The salt for the user's hashed password - salt : Guid option - /// The small groups which this user is authorized - smallGroups : ICollection + { /// The Id of this user + userId : UserId + /// The first name of this user + firstName : string + /// The last name of this user + lastName : string + /// The e-mail address of the user + emailAddress : string + /// Whether this user is a PrayerTracker system administrator + isAdmin : bool + /// The user's hashed password + passwordHash : string + /// The salt for the user's hashed password + salt : Guid option + /// The small groups which this user is authorized + smallGroups : ICollection } - with +with + /// An empty user static member empty = - { userId = Guid.Empty - firstName = "" - lastName = "" - emailAddress = "" - isAdmin = false - passwordHash = "" - salt = None - smallGroups = List () + { userId = Guid.Empty + firstName = "" + lastName = "" + emailAddress = "" + isAdmin = false + passwordHash = "" + salt = None + smallGroups = List () } + /// The full name of the user member this.fullName = - sprintf "%s %s" this.firstName this.lastName + $"{this.firstName} {this.lastName}" /// Configure EF for this entity static member internal configureEF (mb : ModelBuilder) = - mb.Entity ( - fun m -> - m.ToTable "User" |> ignore - m.Ignore(fun e -> e.fullName :> obj) |> ignore - m.Property(fun e -> e.userId).HasColumnName "UserId" |> ignore - m.Property(fun e -> e.firstName).HasColumnName("FirstName").IsRequired() |> ignore - m.Property(fun e -> e.lastName).HasColumnName("LastName").IsRequired() |> ignore - m.Property(fun e -> e.emailAddress).HasColumnName("EmailAddress").IsRequired() |> ignore - m.Property(fun e -> e.isAdmin).HasColumnName "IsSystemAdmin" |> ignore - m.Property(fun e -> e.passwordHash).HasColumnName("PasswordHash").IsRequired() |> ignore - m.Property(fun e -> e.salt).HasColumnName "Salt" |> ignore) - |> ignore - mb.Model.FindEntityType(typeof).FindProperty("salt") - .SetValueConverter(OptionConverter ()) + mb.Entity (fun m -> + m.ToTable "User" |> ignore + m.Ignore(fun e -> e.fullName :> obj) |> ignore + m.Property(fun e -> e.userId).HasColumnName "UserId" |> ignore + m.Property(fun e -> e.firstName).HasColumnName("FirstName").IsRequired() |> ignore + m.Property(fun e -> e.lastName).HasColumnName("LastName").IsRequired() |> ignore + m.Property(fun e -> e.emailAddress).HasColumnName("EmailAddress").IsRequired() |> ignore + m.Property(fun e -> e.isAdmin).HasColumnName "IsSystemAdmin" |> ignore + m.Property(fun e -> e.passwordHash).HasColumnName("PasswordHash").IsRequired() |> ignore + m.Property(fun e -> e.salt).HasColumnName "Salt" |> ignore) + |> ignore + mb.Model.FindEntityType(typeof).FindProperty("salt") + .SetValueConverter(OptionConverter ()) /// Cross-reference between user and small group and [] UserSmallGroup = - { /// The Id of the user who has access to the small group - userId : UserId - /// The Id of the small group to which the user has access - smallGroupId : SmallGroupId - /// The user who has access to the small group - user : User - /// The small group to which the user has access - smallGroup : SmallGroup + { /// The Id of the user who has access to the small group + userId : UserId + /// The Id of the small group to which the user has access + smallGroupId : SmallGroupId + /// The user who has access to the small group + user : User + /// The small group to which the user has access + smallGroup : SmallGroup } - with +with + /// An empty user/small group xref static member empty = - { userId = Guid.Empty - smallGroupId = Guid.Empty - user = User.empty - smallGroup = SmallGroup.empty + { userId = Guid.Empty + smallGroupId = Guid.Empty + user = User.empty + smallGroup = SmallGroup.empty } + /// Configure EF for this entity static member internal configureEF (mb : ModelBuilder) = - mb.Entity ( - fun m -> - m.ToTable "User_SmallGroup" |> ignore - m.HasKey(fun e -> { userId = e.userId; smallGroupId = e.smallGroupId } :> obj) |> ignore - m.Property(fun e -> e.userId).HasColumnName "UserId" |> ignore - m.Property(fun e -> e.smallGroupId).HasColumnName "SmallGroupId" |> ignore - m.HasOne(fun e -> e.user) - .WithMany(fun e -> e.smallGroups :> IEnumerable) - .HasForeignKey(fun e -> e.userId :> obj) - |> ignore - m.HasOne(fun e -> e.smallGroup) - .WithMany(fun e -> e.users :> IEnumerable) - .HasForeignKey(fun e -> e.smallGroupId :> obj) - |> ignore) - |> ignore + mb.Entity (fun m -> + m.ToTable "User_SmallGroup" |> ignore + m.HasKey(fun e -> { userId = e.userId; smallGroupId = e.smallGroupId } :> obj) |> ignore + m.Property(fun e -> e.userId).HasColumnName "UserId" |> ignore + m.Property(fun e -> e.smallGroupId).HasColumnName "SmallGroupId" |> ignore + m.HasOne(fun e -> e.user) + .WithMany(fun e -> e.smallGroups :> IEnumerable) + .HasForeignKey(fun e -> e.userId :> obj) + |> ignore + m.HasOne(fun e -> e.smallGroup) + .WithMany(fun e -> e.users :> IEnumerable) + .HasForeignKey(fun e -> e.smallGroupId :> obj) + |> ignore) + |> ignore diff --git a/src/PrayerTracker.Tests/Program.fs b/src/PrayerTracker.Tests/Program.fs index c3e7e04..fbce67e 100644 --- a/src/PrayerTracker.Tests/Program.fs +++ b/src/PrayerTracker.Tests/Program.fs @@ -2,4 +2,4 @@ [] let main argv = - runTestsInAssembly defaultConfig argv + runTestsInAssembly defaultConfig argv diff --git a/src/PrayerTracker.Tests/UI/UtilsTests.fs b/src/PrayerTracker.Tests/UI/UtilsTests.fs index 7fc94c4..e5285f3 100644 --- a/src/PrayerTracker.Tests/UI/UtilsTests.fs +++ b/src/PrayerTracker.Tests/UI/UtilsTests.fs @@ -5,189 +5,192 @@ open PrayerTracker [] let ckEditorToTextTests = - testList "ckEditorToText" [ - test "replaces newline/tab sequence with nothing" { - Expect.equal (ckEditorToText "Here is some \n\ttext") "Here is some text" - "Newline/tab sequence should have been removed" - } - test "replaces   with a space" { - Expect.equal (ckEditorToText "Test text") "Test text" "  should have been replaced with a space" - } - test "replaces double space with one non-breaking space and one regular space" { - Expect.equal (ckEditorToText "Test text") "Test  text" - "double space should have been replaced with one non-breaking space and one regular space" - } - test "replaces paragraph break with two line breaks" { - Expect.equal (ckEditorToText "some

text") "some

text" - "paragraph break should have been replaced with two line breaks" - } - test "removes start and end paragraph tags" { - Expect.equal (ckEditorToText "

something something

") "something something" - "start/end paragraph tags should have been removed" - } - test "trims the result" { - Expect.equal (ckEditorToText " abc ") "abc" "Should have trimmed the resulting text" - } - test "does all the replacements and removals at one time" { - Expect.equal (ckEditorToText "

Paragraph 1\n\t line two

Paragraph 2 x

") - "Paragraph 1 line two

Paragraph 2  x" - "all replacements and removals were not made correctly" - } + testList "ckEditorToText" [ + test "replaces newline/tab sequence with nothing" { + Expect.equal (ckEditorToText "Here is some \n\ttext") "Here is some text" + "Newline/tab sequence should have been removed" + } + test "replaces   with a space" { + Expect.equal (ckEditorToText "Test text") "Test text" "  should have been replaced with a space" + } + test "replaces double space with one non-breaking space and one regular space" { + Expect.equal (ckEditorToText "Test text") "Test  text" + "double space should have been replaced with one non-breaking space and one regular space" + } + test "replaces paragraph break with two line breaks" { + Expect.equal (ckEditorToText "some

text") "some

text" + "paragraph break should have been replaced with two line breaks" + } + test "removes start and end paragraph tags" { + Expect.equal (ckEditorToText "

something something

") "something something" + "start/end paragraph tags should have been removed" + } + test "trims the result" { + Expect.equal (ckEditorToText " abc ") "abc" "Should have trimmed the resulting text" + } + test "does all the replacements and removals at one time" { + Expect.equal (ckEditorToText "

Paragraph 1\n\t line two

Paragraph 2 x

") + "Paragraph 1 line two

Paragraph 2  x" + "all replacements and removals were not made correctly" + } ] [] let htmlToPlainTextTests = - testList "htmlToPlainText" [ - test "decodes HTML-encoded entities" { - Expect.equal (htmlToPlainText "1 > 0") "1 > 0" "HTML-encoded entities should have been decoded" - } - test "trims the input HTML" { - Expect.equal (htmlToPlainText " howdy ") "howdy" "HTML input string should have been trimmed" - } - test "replaces line breaks with new lines" { - Expect.equal (htmlToPlainText "Lots
of
new
lines") "Lots\nof\nnew\nlines" - "Break tags should have been converted to newline characters" - } - test "replaces non-breaking spaces with spaces" { - Expect.equal (htmlToPlainText "Here is some more text") "Here is some more text" - "Non-breaking spaces should have been replaced with spaces" - } - test "does all replacements at one time" { - Expect.equal (htmlToPlainText " < <
test") "< <\ntest" "All replacements were not made correctly" - } - test "does not fail when passed null" { - Expect.equal (htmlToPlainText null) "" "Should return an empty string for null input" - } - test "does not fail when passed an empty string" { - Expect.equal (htmlToPlainText "") "" "Should return an empty string when given an empty string" - } - test "preserves blank lines for two consecutive line breaks" { - let expected = "Paragraph 1\n\nParagraph 2\n\n...and paragraph 3" - Expect.equal (htmlToPlainText "Paragraph 1

Paragraph 2

...and paragraph 3") - expected "Blank lines not preserved for consecutive line breaks" - } + testList "htmlToPlainText" [ + test "decodes HTML-encoded entities" { + Expect.equal (htmlToPlainText "1 > 0") "1 > 0" "HTML-encoded entities should have been decoded" + } + test "trims the input HTML" { + Expect.equal (htmlToPlainText " howdy ") "howdy" "HTML input string should have been trimmed" + } + test "replaces line breaks with new lines" { + Expect.equal (htmlToPlainText "Lots
of
new
lines") "Lots\nof\nnew\nlines" + "Break tags should have been converted to newline characters" + } + test "replaces non-breaking spaces with spaces" { + Expect.equal (htmlToPlainText "Here is some more text") "Here is some more text" + "Non-breaking spaces should have been replaced with spaces" + } + test "does all replacements at one time" { + Expect.equal (htmlToPlainText " < <
test") "< <\ntest" + "All replacements were not made correctly" + } + test "does not fail when passed null" { + Expect.equal (htmlToPlainText null) "" "Should return an empty string for null input" + } + test "does not fail when passed an empty string" { + Expect.equal (htmlToPlainText "") "" "Should return an empty string when given an empty string" + } + test "preserves blank lines for two consecutive line breaks" { + let expected = "Paragraph 1\n\nParagraph 2\n\n...and paragraph 3" + Expect.equal + (htmlToPlainText "Paragraph 1

Paragraph 2

...and paragraph 3") + expected "Blank lines not preserved for consecutive line breaks" + } ] [] let makeUrlTests = - testList "makeUrl" [ - test "returns the URL when there are no parameters" { - Expect.equal (makeUrl "/test" []) "/test" "The URL should not have had any query string parameters added" - } - test "returns the URL with one query string parameter" { - Expect.equal (makeUrl "/test" [ "unit", "true" ]) "/test?unit=true" "The URL was not constructed properly" - } - test "returns the URL with multiple encoded query string parameters" { - let url = makeUrl "/test" [ "space", "a space"; "turkey", "=" ] - Expect.equal url "/test?space=a+space&turkey=%3D" "The URL was not constructed properly" - } + testList "makeUrl" [ + test "returns the URL when there are no parameters" { + Expect.equal (makeUrl "/test" []) "/test" "The URL should not have had any query string parameters added" + } + test "returns the URL with one query string parameter" { + Expect.equal (makeUrl "/test" [ "unit", "true" ]) "/test?unit=true" "The URL was not constructed properly" + } + test "returns the URL with multiple encoded query string parameters" { + let url = makeUrl "/test" [ "space", "a space"; "turkey", "=" ] + Expect.equal url "/test?space=a+space&turkey=%3D" "The URL was not constructed properly" + } ] [] let sndAsStringTests = - testList "sndAsString" [ - test "converts the second item to a string" { - Expect.equal (sndAsString ("a", 5)) "5" "The second part of the tuple should have been converted to a string" - } + testList "sndAsString" [ + test "converts the second item to a string" { + Expect.equal (sndAsString ("a", 5)) "5" + "The second part of the tuple should have been converted to a string" + } ] module StringTests = - open PrayerTracker.Utils.String + open PrayerTracker.Utils.String - [] - let replaceFirstTests = - testList "String.replaceFirst" [ - test "replaces the first occurrence when it is found at the beginning of the string" { - let testString = "unit unit unit" - Expect.equal (replaceFirst "unit" "test" testString) "test unit unit" - "First occurrence of a substring was not replaced properly at the beginning of the string" - } - test "replaces the first occurrence when it is found in the center of the string" { - let testString = "test unit test" - Expect.equal (replaceFirst "unit" "test" testString) "test test test" - "First occurrence of a substring was not replaced properly when it is in the center of the string" - } - test "returns the original string if the replacement isn't found" { - let testString = "unit tests" - Expect.equal (replaceFirst "tested" "testing" testString) "unit tests" - "String which did not have the target substring was not returned properly" - } - ] + [] + let replaceFirstTests = + testList "String.replaceFirst" [ + test "replaces the first occurrence when it is found at the beginning of the string" { + let testString = "unit unit unit" + Expect.equal (replaceFirst "unit" "test" testString) "test unit unit" + "First occurrence of a substring was not replaced properly at the beginning of the string" + } + test "replaces the first occurrence when it is found in the center of the string" { + let testString = "test unit test" + Expect.equal (replaceFirst "unit" "test" testString) "test test test" + "First occurrence of a substring was not replaced properly when it is in the center of the string" + } + test "returns the original string if the replacement isn't found" { + let testString = "unit tests" + Expect.equal (replaceFirst "tested" "testing" testString) "unit tests" + "String which did not have the target substring was not returned properly" + } + ] - [] - let replaceTests = - testList "String.replace" [ - test "succeeds" { - Expect.equal (replace "a" "b" "abacab") "bbbcbb" "String did not replace properly" - } - ] + [] + let replaceTests = + testList "String.replace" [ + test "succeeds" { + Expect.equal (replace "a" "b" "abacab") "bbbcbb" "String did not replace properly" + } + ] - [] - let trimTests = - testList "String.trim" [ - test "succeeds" { - Expect.equal (trim " abc ") "abc" "Space not trimmed from string properly" - } - ] + [] + let trimTests = + testList "String.trim" [ + test "succeeds" { + Expect.equal (trim " abc ") "abc" "Space not trimmed from string properly" + } + ] [] let stripTagsTests = - let testString = "

Here is some text

and some more

" - testList "stripTags" [ - test "does nothing if all tags are allowed" { - Expect.equal (stripTags [ "p"; "br" ] testString) testString - "There should have been no replacements in the target string" - } - test "strips the start/end tag for non allowed tag" { - Expect.equal (stripTags [ "br" ] testString) "Here is some text

and some more" - "There should have been no \"p\" tag, but all \"br\" tags, in the returned string" - } - test "strips void/self-closing tags" { - Expect.equal (stripTags [] testString) "Here is some text and some more" - "There should have been no tags; all void and self-closing tags should have been stripped" - } + let testString = "

Here is some text

and some more

" + testList "stripTags" [ + test "does nothing if all tags are allowed" { + Expect.equal (stripTags [ "p"; "br" ] testString) testString + "There should have been no replacements in the target string" + } + test "strips the start/end tag for non allowed tag" { + Expect.equal (stripTags [ "br" ] testString) "Here is some text

and some more" + "There should have been no \"p\" tag, but all \"br\" tags, in the returned string" + } + test "strips void/self-closing tags" { + Expect.equal (stripTags [] testString) "Here is some text and some more" + "There should have been no tags; all void and self-closing tags should have been stripped" + } ] [] let wordWrapTests = - testList "wordWrap" [ - test "breaks where it is supposed to" { - let testString = "The quick brown fox jumps over the lazy dog\nIt does!" - Expect.equal (wordWrap 20 testString) "The quick brown fox\njumps over the lazy\ndog\nIt does!\n" - "Line not broken correctly" - } - test "wraps long line without a space" { - let testString = "Asamatteroffact, the dog does too" - Expect.equal (wordWrap 10 testString) "Asamattero\nffact, the\ndog does\ntoo\n" - "Longer line not broken correctly" - } - test "preserves blank lines" { - let testString = "Here is\n\na string with blank lines" - Expect.equal (wordWrap 80 testString) testString "Blank lines were not preserved" - } + testList "wordWrap" [ + test "breaks where it is supposed to" { + let testString = "The quick brown fox jumps over the lazy dog\nIt does!" + Expect.equal (wordWrap 20 testString) "The quick brown fox\njumps over the lazy\ndog\nIt does!\n" + "Line not broken correctly" + } + test "wraps long line without a space" { + let testString = "Asamatteroffact, the dog does too" + Expect.equal (wordWrap 10 testString) "Asamattero\nffact, the\ndog does\ntoo\n" + "Longer line not broken correctly" + } + test "preserves blank lines" { + let testString = "Here is\n\na string with blank lines" + Expect.equal (wordWrap 80 testString) testString "Blank lines were not preserved" + } ] [] let wordWrapBTests = - testList "wordWrapB" [ - test "breaks where it is supposed to" { - let testString = "The quick brown fox jumps over the lazy dog\nIt does!" - Expect.equal (wordWrap 20 testString) "The quick brown fox\njumps over the lazy\ndog\nIt does!\n" - "Line not broken correctly" - } - test "wraps long line without a space and a line with exact length" { - let testString = "Asamatteroffact, the dog does too" - Expect.equal (wordWrap 10 testString) "Asamattero\nffact, the\ndog does\ntoo\n" - "Longer line not broken correctly" - } - test "wraps long line without a space and a line with non-exact length" { - let testString = "Asamatteroffact, that dog does too" - Expect.equal (wordWrap 10 testString) "Asamattero\nffact,\nthat dog\ndoes too\n" - "Longer line not broken correctly" - } - test "preserves blank lines" { - let testString = "Here is\n\na string with blank lines" - Expect.equal (wordWrap 80 testString) testString "Blank lines were not preserved" - } + testList "wordWrapB" [ + test "breaks where it is supposed to" { + let testString = "The quick brown fox jumps over the lazy dog\nIt does!" + Expect.equal (wordWrap 20 testString) "The quick brown fox\njumps over the lazy\ndog\nIt does!\n" + "Line not broken correctly" + } + test "wraps long line without a space and a line with exact length" { + let testString = "Asamatteroffact, the dog does too" + Expect.equal (wordWrap 10 testString) "Asamattero\nffact, the\ndog does\ntoo\n" + "Longer line not broken correctly" + } + test "wraps long line without a space and a line with non-exact length" { + let testString = "Asamatteroffact, that dog does too" + Expect.equal (wordWrap 10 testString) "Asamattero\nffact,\nthat dog\ndoes too\n" + "Longer line not broken correctly" + } + test "preserves blank lines" { + let testString = "Here is\n\na string with blank lines" + Expect.equal (wordWrap 80 testString) testString "Blank lines were not preserved" + } ] diff --git a/src/PrayerTracker.UI/Church.fs b/src/PrayerTracker.UI/Church.fs index cdeed04..a235f7f 100644 --- a/src/PrayerTracker.UI/Church.fs +++ b/src/PrayerTracker.UI/Church.fs @@ -6,104 +6,107 @@ open PrayerTracker.ViewModels /// View for the church edit page let edit (m : EditChurch) ctx vi = - let pageTitle = match m.isNew () with true -> "Add a New Church" | false -> "Edit Church" - let s = I18N.localizer.Force () - [ form [ _action "/web/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 "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 "name"; _id "name"; _required; _autofocus; _value m.name ] - ] - div [ _class "pt-field" ] [ - label [ _for "City"] [ locStr s.["City"] ] - input [ _type "text"; _name "city"; _id "city"; _required; _value m.city ] - ] - div [ _class "pt-field" ] [ - label [ _for "ST" ] [ locStr s.["State"] ] - input [ _type "text"; _name "st"; _id "st"; _required; _minlength "2"; _maxlength "2"; _value m.st ] - ] + let pageTitle = match m.isNew () with true -> "Add a New Church" | false -> "Edit Church" + let s = I18N.localizer.Force () + [ form [ _action "/web/church/save"; _method "post"; _class "pt-center-columns" ] [ + style [ _scoped ] [ + rawText "#name { width: 20rem; } #city { width: 10rem; } #st { width: 3rem; } #interfaceAddress { width: 30rem; }" ] - div [ _class "pt-field-row" ] [ - div [ _class "pt-checkbox-field" ] [ - input [ _type "checkbox" - _name "hasInterface" - _id "hasInterface" - _value "True" - match m.hasInterface with Some x when x -> _checked | _ -> () ] - label [ _for "hasInterface" ] [ locStr s.["Has an interface with Virtual Prayer Room"] ] - ] + csrfToken ctx + input [ _type "hidden"; _name "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 "name"; _id "name"; _required; _autofocus; _value m.name ] + ] + div [ _class "pt-field" ] [ + label [ _for "City"] [ locStr s["City"] ] + input [ _type "text"; _name "city"; _id "city"; _required; _value m.city ] + ] + div [ _class "pt-field" ] [ + label [ _for "ST" ] [ locStr s["State"] ] + input [ _type "text"; _name "st"; _id "st"; _required; _minlength "2"; _maxlength "2"; _value m.st ] + ] ] - 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 "interfaceAddress"; _id "interfaceAddress"; - _value (match m.interfaceAddress with Some ia -> ia | None -> "") ] - ] + div [ _class "pt-field-row" ] [ + div [ _class "pt-checkbox-field" ] [ + input [ _type "checkbox" + _name "hasInterface" + _id "hasInterface" + _value "True" + match m.hasInterface with Some x when x -> _checked | _ -> () ] + label [ _for "hasInterface" ] [ locStr s["Has an interface with Virtual Prayer Room"] ] + ] ] - div [ _class "pt-field-row" ] [ submit [] "save" s.["Save Church"] ] - ] - script [] [ rawText "PT.onLoad(PT.church.edit.onPageLoad)" ] + 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 "interfaceAddress"; _id "interfaceAddress"; + _value (match m.interfaceAddress with Some ia -> ia | None -> "") + ] + ] + ] + div [ _class "pt-field-row" ] [ submit [] "save" s["Save Church"] ] + ] + script [] [ rawText "PT.onLoad(PT.church.edit.onPageLoad)" ] ] - |> Layout.Content.standard - |> Layout.standard vi pageTitle + |> Layout.Content.standard + |> Layout.standard vi pageTitle /// View for church maintenance page let maintain (churches : Church list) (stats : Map) ctx vi = - let s = I18N.localizer.Force () - let chTbl = - match churches with - | [] -> space - | _ -> - table [ _class "pt-table pt-action-table" ] [ - thead [] [ - tr [] [ - th [] [ locStr s.["Actions"] ] - th [] [ locStr s.["Name"] ] - th [] [ locStr s.["Location"] ] - th [] [ locStr s.["Groups"] ] - th [] [ locStr s.["Requests"] ] - th [] [ locStr s.["Users"] ] - th [] [ locStr s.["Interface?"] ] - ] + let s = I18N.localizer.Force () + let chTbl = + match churches with + | [] -> space + | _ -> + table [ _class "pt-table pt-action-table" ] [ + thead [] [ + tr [] [ + th [] [ locStr s["Actions"] ] + th [] [ locStr s["Name"] ] + th [] [ locStr s["Location"] ] + th [] [ locStr s["Groups"] ] + th [] [ locStr s["Requests"] ] + th [] [ locStr s["Users"] ] + th [] [ locStr s["Interface?"] ] + ] + ] + churches + |> List.map (fun ch -> + let chId = flatGuid ch.churchId + let delAction = $"/web/church/{chId}/delete" + let delPrompt = s["Are you sure you want to delete this {0}? This action cannot be undone.", + $"""{s["Church"].Value.ToLower ()} ({ch.name})"""] + tr [] [ + td [] [ + a [ _href $"/web/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") ] + td [ _class "pt-right-text" ] [ rawText (stats[chId].prayerRequests.ToString "N0") ] + td [ _class "pt-right-text" ] [ rawText (stats[chId].users.ToString "N0") ] + td [ _class "pt-center-text" ] [ locStr s[if ch.hasInterface then "Yes" else "No"] ] + ]) + |> tbody [] ] - churches - |> List.map (fun ch -> - let chId = flatGuid ch.churchId - let delAction = $"/web/church/{chId}/delete" - let delPrompt = s.["Are you sure you want to delete this {0}? This action cannot be undone.", - $"""{s.["Church"].Value.ToLower ()} ({ch.name})"""] - tr [] [ - td [] [ - a [ _href $"/web/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") ] - td [ _class "pt-right-text" ] [ rawText (stats.[chId].prayerRequests.ToString "N0") ] - td [ _class "pt-right-text" ] [ rawText (stats.[chId].users.ToString "N0") ] - td [ _class "pt-center-text" ] [ locStr s.[match ch.hasInterface with true -> "Yes" | false -> "No"] ] - ]) - |> tbody [] - ] - [ div [ _class "pt-center-text" ] [ - br [] - a [ _href $"/web/church/{emptyGuid}/edit"; _title s.["Add a New Church"].Value ] - [ icon "add_circle"; rawText "  "; locStr s.["Add a New Church"] ] - br [] - br [] + [ div [ _class "pt-center-text" ] [ + br [] + a [ _href $"/web/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 ] + tableSummary churches.Length s + chTbl + form [ _id "DeleteForm"; _action ""; _method "post" ] [ csrfToken ctx ] ] - |> Layout.Content.wide - |> Layout.standard vi "Maintain Churches" + |> Layout.Content.wide + |> Layout.standard vi "Maintain Churches" diff --git a/src/PrayerTracker.UI/CommonFunctions.fs b/src/PrayerTracker.UI/CommonFunctions.fs index 8d55bc8..17692f6 100644 --- a/src/PrayerTracker.UI/CommonFunctions.fs +++ b/src/PrayerTracker.UI/CommonFunctions.fs @@ -1,26 +1,24 @@ [] module PrayerTracker.Views.CommonFunctions +open System.IO +open System.Text.Encodings.Web open Giraffe open Giraffe.ViewEngine open Microsoft.AspNetCore.Antiforgery -open Microsoft.AspNetCore.Html open Microsoft.AspNetCore.Http open Microsoft.AspNetCore.Mvc.Localization open Microsoft.Extensions.Localization -open System -open System.IO -open System.Text.Encodings.Web /// Encoded text for a localized string let locStr (text : LocalizedString) = str text.Value /// Raw text for a localized HTML string let rawLocText (writer : StringWriter) (text : LocalizedHtmlString) = - text.WriteTo (writer, HtmlEncoder.Default) - let txt = string writer - writer.GetStringBuilder().Clear () |> ignore - rawText txt + text.WriteTo (writer, HtmlEncoder.Default) + let txt = string writer + writer.GetStringBuilder().Clear () |> ignore + rawText txt /// A space (used for back-to-back localization string breaks) let space = rawText " " @@ -33,69 +31,73 @@ let iconSized size name = i [ _class $"material-icons md-{size}" ] [ rawText nam /// Generate a CSRF prevention token let csrfToken (ctx : HttpContext) = - let antiForgery = ctx.GetService () - let tokenSet = antiForgery.GetAndStoreTokens ctx - input [ _type "hidden"; _name tokenSet.FormFieldName; _value tokenSet.RequestToken ] + let antiForgery = ctx.GetService () + let tokenSet = antiForgery.GetAndStoreTokens ctx + input [ _type "hidden"; _name tokenSet.FormFieldName; _value tokenSet.RequestToken ] /// Create a summary for a table of items let tableSummary itemCount (s : IStringLocalizer) = - div [ _class "pt-center-text" ] [ - small [] [ - match itemCount with - | 0 -> s.["No Entries to Display"] - | 1 -> s.["Displaying {0} Entry", itemCount] - | _ -> s.["Displaying {0} Entries", itemCount] - |> locStr - ] + div [ _class "pt-center-text" ] [ + small [] [ + match itemCount with + | 0 -> s["No Entries to Display"] + | 1 -> s["Displaying {0} Entry", itemCount] + | _ -> s["Displaying {0} Entries", itemCount] + |> locStr + ] ] /// Generate a list of named HTML colors let namedColorList name selected attrs (s : IStringLocalizer) = - /// The list of HTML named colors (name, display, text color) - seq { - ("aqua", s.["Aqua"], "black") - ("black", s.["Black"], "white") - ("blue", s.["Blue"], "white") - ("fuchsia", s.["Fuchsia"], "black") - ("gray", s.["Gray"], "white") - ("green", s.["Green"], "white") - ("lime", s.["Lime"], "black") - ("maroon", s.["Maroon"], "white") - ("navy", s.["Navy"], "white") - ("olive", s.["Olive"], "white") - ("purple", s.["Purple"], "white") - ("red", s.["Red"], "black") - ("silver", s.["Silver"], "black") - ("teal", s.["Teal"], "white") - ("white", s.["White"], "black") - ("yellow", s.["Yellow"], "black") + // The list of HTML named colors (name, display, text color) + seq { + ("aqua", s["Aqua"], "black") + ("black", s["Black"], "white") + ("blue", s["Blue"], "white") + ("fuchsia", s["Fuchsia"], "black") + ("gray", s["Gray"], "white") + ("green", s["Green"], "white") + ("lime", s["Lime"], "black") + ("maroon", s["Maroon"], "white") + ("navy", s["Navy"], "white") + ("olive", s["Olive"], "white") + ("purple", s["Purple"], "white") + ("red", s["Red"], "black") + ("silver", s["Silver"], "black") + ("teal", s["Teal"], "white") + ("white", s["White"], "black") + ("yellow", s["Yellow"], "black") } - |> Seq.map (fun color -> - let (colorName, dispText, txtColor) = color - option [ yield _value colorName - yield _style $"background-color:{colorName};color:{txtColor};" - match colorName = selected with true -> yield _selected | false -> () ] [ - encodedText (dispText.Value.ToLower ()) - ]) - |> List.ofSeq - |> select (_name name :: attrs) + |> Seq.map (fun color -> + let colorName, text, txtColor = color + option + [ _value colorName + _style $"background-color:{colorName};color:{txtColor};" + if colorName = selected then _selected + ] [ encodedText (text.Value.ToLower ()) ]) + |> List.ofSeq + |> select (_name name :: attrs) /// Generate an input[type=radio] that is selected if its value is the current value let radio name domId value current = - input [ _type "radio" + input + [ _type "radio" _name name _id domId _value value - match value = current with true -> _checked | false -> () ] + if value = current then _checked + ] /// Generate a select list with the current value selected let selectList name selected attrs items = - items - |> Seq.map (fun (value, text) -> - option [ _value value - match value = selected with true -> _selected | false -> () ] [ encodedText text ]) - |> List.ofSeq - |> select (List.concat [ [ _name name; _id name ]; attrs ]) + items + |> Seq.map (fun (value, text) -> + option + [ _value value + if value = selected then _selected + ] [ encodedText text ]) + |> List.ofSeq + |> select (List.concat [ [ _name name; _id name ]; attrs ]) /// Generate the text for a default entry at the top of a select list let selectDefault text = $"— {text} —" @@ -103,6 +105,9 @@ let selectDefault text = $"— {text} —" /// Generate a standard submit button with icon and text let submit attrs ico text = button (_type "submit" :: attrs) [ icon ico; rawText "  "; locStr text ] + +open System + /// Format a GUID with no dashes (used for URLs and forms) let flatGuid (x : Guid) = x.ToString "N" @@ -129,6 +134,9 @@ let _scoped = flag "scoped" /// The name this function used to have when the view engine was part of Giraffe let renderHtmlNode = RenderView.AsString.htmlNode + +open Microsoft.AspNetCore.Html + /// Render an HTML node, then return the value as an HTML string let renderHtmlString = renderHtmlNode >> HtmlString @@ -136,20 +144,20 @@ let renderHtmlString = renderHtmlNode >> HtmlString /// Utility methods to help with time zones (and localization of their names) module TimeZones = - open System.Collections.Generic + open System.Collections.Generic - /// Cross-reference between time zone Ids and their English names - let private xref = - [ "America/Chicago", "Central" - "America/Denver", "Mountain" - "America/Los_Angeles", "Pacific" - "America/New_York", "Eastern" - "America/Phoenix", "Mountain (Arizona)" - "Europe/Berlin", "Central European" - ] - |> Map.ofList + /// Cross-reference between time zone Ids and their English names + let private xref = + [ "America/Chicago", "Central" + "America/Denver", "Mountain" + "America/Los_Angeles", "Pacific" + "America/New_York", "Eastern" + "America/Phoenix", "Mountain (Arizona)" + "Europe/Berlin", "Central European" + ] + |> Map.ofList - /// Get the name of a time zone, given its Id - let name tzId (s : IStringLocalizer) = - try s.[xref.[tzId]] - with :? KeyNotFoundException -> LocalizedString (tzId, tzId) + /// Get the name of a time zone, given its Id + let name tzId (s : IStringLocalizer) = + try s[xref[tzId]] + with :? KeyNotFoundException -> LocalizedString (tzId, tzId) diff --git a/src/PrayerTracker.UI/Home.fs b/src/PrayerTracker.UI/Home.fs index b987660..06abe48 100644 --- a/src/PrayerTracker.UI/Home.fs +++ b/src/PrayerTracker.UI/Home.fs @@ -1,262 +1,261 @@ /// Views associated with the home page, or those that don't fit anywhere else module PrayerTracker.Views.Home -open Giraffe.ViewEngine -open Microsoft.AspNetCore.Html -open PrayerTracker.ViewModels open System.IO +open Giraffe.ViewEngine +open PrayerTracker.ViewModels /// The error page let error code vi = - let s = I18N.localizer.Force () - let l = I18N.forView "Home/Error" - use sw = new StringWriter () - let raw = rawLocText sw - let is404 = "404" = code - let pageTitle = match is404 with true -> "Page Not Found" | false -> "Server Error" - [ yield! - match is404 with - | true -> - [ p [] [ - raw l.["The page you requested cannot be found."] - raw l.["Please use your “Back” button to return to {0}.", s.["PrayerTracker"]] + let s = I18N.localizer.Force () + let l = I18N.forView "Home/Error" + use sw = new StringWriter () + let raw = rawLocText sw + let is404 = "404" = code + let pageTitle = if is404 then "Page Not Found" else "Server Error" + [ yield! + if is404 then + [ p [] [ + raw l["The page you requested cannot be found."] + raw l["Please use your “Back” button to return to {0}.", s["PrayerTracker"]] ] - p [] [ - raw l.["If you reached this page from a link within {0}, please copy the link from the browser's address bar, and send it to support, along with the group for which you were currently authenticated (if any).", - s.["PrayerTracker"]] + p [] [ + raw l["If you reached this page from a link within {0}, please copy the link from the browser's address bar, and send it to support, along with the group for which you were currently authenticated (if any).", + s["PrayerTracker"]] ] ] - | false -> - [ p [] [ - raw l.["An error ({0}) has occurred.", code] - raw l.["Please use your “Back” button to return to {0}.", s.["PrayerTracker"]] + else + [ p [] [ + raw l["An error ({0}) has occurred.", code] + raw l["Please use your “Back” button to return to {0}.", s["PrayerTracker"]] ] ] - br [] - hr [] - div [ _style "font-size:70%;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen-Sans,Ubuntu,Cantarell,'Helvetica Neue',sans-serif" ] [ - img [ _src $"""/img/%A{s.["footer_en"]}.png""" - _alt $"""%A{s.["PrayerTracker"]} %A{s.["from Bit Badger Solutions"]}""" - _title $"""%A{s.["PrayerTracker"]} %A{s.["from Bit Badger Solutions"]}""" - _style "vertical-align:text-bottom;" ] - str vi.version + br [] + hr [] + div [ _style "font-size:70%;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen-Sans,Ubuntu,Cantarell,'Helvetica Neue',sans-serif" ] [ + img [ _src $"""/img/%A{s["footer_en"]}.png""" + _alt $"""%A{s["PrayerTracker"]} %A{s["from Bit Badger Solutions"]}""" + _title $"""%A{s["PrayerTracker"]} %A{s["from Bit Badger Solutions"]}""" + _style "vertical-align:text-bottom;" ] + str vi.version ] ] - |> div [] - |> Layout.bare pageTitle + |> div [] + |> Layout.bare pageTitle /// The home page let index vi = - let s = I18N.localizer.Force () - let l = I18N.forView "Home/Index" - use sw = new StringWriter () - let raw = rawLocText sw + let s = I18N.localizer.Force () + let l = I18N.forView "Home/Index" + use sw = new StringWriter () + let raw = rawLocText sw - [ p [] [ - raw l.["Welcome to {0}!", s.["PrayerTracker"]] - space - raw l.["{0} is an interactive website that provides churches, Sunday School classes, and other organizations an easy way to keep up with their prayer requests.", - s.["PrayerTracker"]] - space - raw l.["It is provided at no charge, as a ministry and a community service."] + [ p [] [ + raw l["Welcome to {0}!", s["PrayerTracker"]] + space + raw l["{0} is an interactive website that provides churches, Sunday School classes, and other organizations an easy way to keep up with their prayer requests.", + s["PrayerTracker"]] + space + raw l["It is provided at no charge, as a ministry and a community service."] ] - h4 [] [ raw l.["What Does It Do?"] ] - p [] [ - raw l.["{0} has what you need to make maintaining a prayer request list a breeze.", s.["PrayerTracker"]] - space - raw l.["Some of the things it can do..."] + h4 [] [ raw l["What Does It Do?"] ] + p [] [ + raw l["{0} has what you need to make maintaining a prayer request list a breeze.", s["PrayerTracker"]] + space + raw l["Some of the things it can do..."] ] - ul [] [ - li [] [ - raw l.["It drops old requests off the list automatically."] - space - raw l.["Requests other than “{0}” requests will expire at 14 days, though this can be changed by the organization.", - s.["Long-Term Requests"]] - space - raw l.["This expiration is based on the last update, not the initial request."] - space - raw l.["(And, once requests do “drop off”, they are not gone - they may be recovered if needed.)"] - ] - li [] [ - raw l.["Requests can be viewed any time."] - space - raw l.["Lists can be made public, or they can be secured with a password, if desired."] - ] - li [] [ - raw l.["Lists can be e-mailed to a pre-defined list of members."] - space - raw l.["This can be useful for folks who may not be able to write down all the requests during class, but want a list so that they can pray for them the rest of week."] - space - raw l.["E-mails are sent individually to each person, which keeps the e-mail list private and keeps the messages from being flagged as spam."] - ] - li [] [ - raw l.["The look and feel of the list can be configured for each group."] - space - raw l.["All fonts, colors, and sizes can be customized."] - space - raw l.["This allows for configuration of large-print lists, among other things."] - ] + ul [] [ + li [] [ + raw l["It drops old requests off the list automatically."] + space + raw l["Requests other than “{0}” requests will expire at 14 days, though this can be changed by the organization.", + s["Long-Term Requests"]] + space + raw l["This expiration is based on the last update, not the initial request."] + space + raw l["(And, once requests do “drop off”, they are not gone - they may be recovered if needed.)"] + ] + li [] [ + raw l["Requests can be viewed any time."] + space + raw l["Lists can be made public, or they can be secured with a password, if desired."] + ] + li [] [ + raw l["Lists can be e-mailed to a pre-defined list of members."] + space + raw l["This can be useful for folks who may not be able to write down all the requests during class, but want a list so that they can pray for them the rest of week."] + space + raw l["E-mails are sent individually to each person, which keeps the e-mail list private and keeps the messages from being flagged as spam."] + ] + li [] [ + raw l["The look and feel of the list can be configured for each group."] + space + raw l["All fonts, colors, and sizes can be customized."] + space + raw l["This allows for configuration of large-print lists, among other things."] + ] ] - h4 [] [ raw l.["How Can Your Organization Use {0}?", s.["PrayerTracker"]] ] - p [] [ - raw l.["Like God’s gift of salvation, {0} is free for the asking for any church, Sunday School class, or other organization who wishes to use it.", - s.["PrayerTracker"]] - space - raw l.["If your organization would like to get set up, just e-mail Daniel and let him know.", - s.["PrayerTracker"]] + h4 [] [ raw l["How Can Your Organization Use {0}?", s["PrayerTracker"]] ] + p [] [ + raw l["Like God’s gift of salvation, {0} is free for the asking for any church, Sunday School class, or other organization who wishes to use it.", + s["PrayerTracker"]] + space + raw l["If your organization would like to get set up, just e-mail Daniel and let him know.", + s["PrayerTracker"]] ] - h4 [] [ raw l.["Do I Have to Register to See the Requests?"] ] - p [] [ - raw l.["This depends on the group."] - space - raw l.["Lists can be configured to be password-protected, but they do not have to be."] - space - raw l.["If you click on the “{0}” link above, you will see a list of groups - those that do not indicate that they require logging in are publicly viewable.", - s.["View Request List"]] + h4 [] [ raw l["Do I Have to Register to See the Requests?"] ] + p [] [ + raw l["This depends on the group."] + space + raw l["Lists can be configured to be password-protected, but they do not have to be."] + space + raw l["If you click on the “{0}” link above, you will see a list of groups - those that do not indicate that they require logging in are publicly viewable.", + s["View Request List"]] ] - h4 [] [ raw l.["How Does It Work?"] ] - p [] [ - raw l.["Check out the “{0}” link above - it details each of the processes and how they work.", s.["Help"]] + h4 [] [ raw l["How Does It Work?"] ] + p [] [ + raw l["Check out the “{0}” link above - it details each of the processes and how they work.", s["Help"]] ] ] - |> Layout.Content.standard - |> Layout.standard vi "Welcome!" + |> Layout.Content.standard + |> Layout.standard vi "Welcome!" /// Privacy Policy page let privacyPolicy vi = - let s = I18N.localizer.Force () - let l = I18N.forView "Home/PrivacyPolicy" - use sw = new StringWriter () - let raw = rawLocText sw + let s = I18N.localizer.Force () + 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."] - space - raw l.["The items below will help you understand the data we collect, access, and store on your behalf as you use this service."] - ] - h3 [] [ raw l.["What We Collect"] ] - ul [] [ - li [] [ - strong [] [ raw l.["Identifying Data"] ] - rawText " – " - raw l.["{0} stores the first and last names, e-mail addresses, and hashed passwords of all authorized users.", s.["PrayerTracker"]] - space - raw l.["Users are also associated with one or more small groups."] + [ 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."] + space + raw l["The items below will help you understand the data we collect, access, and store on your behalf as you use this service."] ] - li [] [ - strong [] [ raw l.["User Provided Data"] ] - 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."] + h3 [] [ raw l["What We Collect"] ] + ul [] [ + li [] [ + strong [] [ raw l["Identifying Data"] ] + rawText " – " + raw l["{0} stores the first and last names, e-mail addresses, and hashed passwords of all authorized users.", + s["PrayerTracker"]] + space + raw l["Users are also associated with one or more small groups."] + ] + li [] [ + strong [] [ raw l["User Provided Data"] ] + 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."] + ] + ] + h3 [] [ raw l["How Your Data Is Accessed / Secured"] ] + ul [] [ + li [] [ + raw l["While you are signed in, {0} utilizes a session cookie, and transmits that cookie to the server to establish your identity.", + s["PrayerTracker"]] + space + raw l["If you utilize the “{0}” box on sign in, a second cookie is stored, and transmitted to establish a session; this cookie is removed by clicking the “{1}” link.", + s["Remember Me"], s["Log Off"]] + space + raw l["Both of these cookies are encrypted, both in your browser and in transit."] + space + raw l["Finally, a third cookie is used to maintain your currently selected language, so that this selection is maintained across browser sessions."] + ] + li [] [ + raw l["Data for your small group is returned to you, as required, to display and edit."] + space + raw l["{0} also sends e-mails on behalf of the configured owner of a small group; these e-mails are sent from prayer@djs-consulting.com, with the “Reply To” header set to the configured owner of the small group.", + s["PrayerTracker"]] + space + raw l["Distinct e-mails are sent to each user, as to not disclose the other recipients."] + space + raw l["On the server, all data is stored in a controlled-access database."] + ] + li [] [ + raw l["Your data is backed up, along with other Bit Badger Solutions hosted systems, in a rolling manner; backups are preserved for the prior 7 days, and backups from the 1st and 15th are preserved for 3 months."] + space + raw l["These backups are stored in a private cloud data repository."] + ] + li [] [ + raw l["Access to servers and backups is strictly controlled and monitored for unauthorized access attempts."] + ] + ] + h3 [] [ raw l["Removing Your Data"] ] + p [] [ + raw l["At any time, you may choose to discontinue using {0}; just e-mail Daniel, as you did to register, and request deletion of your small group.", + s["PrayerTracker"]] ] ] - h3 [] [ raw l.["How Your Data Is Accessed / Secured"] ] - ul [] [ - li [] [ - raw l.["While you are signed in, {0} utilizes a session cookie, and transmits that cookie to the server to establish your identity.", - s.["PrayerTracker"]] - space - raw l.["If you utilize the “{0}” box on sign in, a second cookie is stored, and transmitted to establish a session; this cookie is removed by clicking the “{1}” link.", - s.["Remember Me"], s.["Log Off"]] - space - raw l.["Both of these cookies are encrypted, both in your browser and in transit."] - space - raw l.["Finally, a third cookie is used to maintain your currently selected language, so that this selection is maintained across browser sessions."] - ] - li [] [ - raw l.["Data for your small group is returned to you, as required, to display and edit."] - space - raw l.["{0} also sends e-mails on behalf of the configured owner of a small group; these e-mails are sent from prayer@djs-consulting.com, with the “Reply To” header set to the configured owner of the small group.", - s.["PrayerTracker"]] - space - raw l.["Distinct e-mails are sent to each user, as to not disclose the other recipients."] - space - raw l.["On the server, all data is stored in a controlled-access database."] - ] - li [] [ - raw l.["Your data is backed up, along with other Bit Badger Solutions hosted systems, in a rolling manner; backups are preserved for the prior 7 days, and backups from the 1st and 15th are preserved for 3 months."] - space - raw l.["These backups are stored in a private cloud data repository."] - ] - li [] [ - raw l.["Access to servers and backups is strictly controlled and monitored for unauthorized access attempts."] - ] - ] - h3 [] [ raw l.["Removing Your Data"] ] - p [] [ - raw l.["At any time, you may choose to discontinue using {0}; just e-mail Daniel, as you did to register, and request deletion of your small group.", - s.["PrayerTracker"]] - ] - ] - |> Layout.Content.standard - |> Layout.standard vi "Privacy Policy" + |> Layout.Content.standard + |> Layout.standard vi "Privacy Policy" /// Terms of Service page let termsOfService vi = - let s = I18N.localizer.Force () - let l = I18N.forView "Home/TermsOfService" - use sw = new StringWriter () - let raw = rawLocText sw - let ppLink = - a [ _href "/web/legal/privacy-policy" ] [ str (s.["Privacy Policy"].Value.ToLower ()) ] - |> renderHtmlString + let s = I18N.localizer.Force () + let l = I18N.forView "Home/TermsOfService" + use sw = new StringWriter () + let raw = rawLocText sw + let ppLink = + a [ _href "/web/legal/privacy-policy" ] [ str (s["Privacy Policy"].Value.ToLower ()) ] + |> renderHtmlString - [ p [ _class "pt-right-text" ] [ small [] [ em [] [ raw l.["(as of May 24, 2018)"] ] ] ] - h3 [] [ str "1. "; raw l.["Acceptance of Terms"] ] - p [] [ - raw l.["By accessing this web site, you are agreeing to be bound by these Terms and Conditions, and that you are responsible to ensure that your use of this site complies with all applicable laws."] - space - raw l.["Your continued use of this site implies your acceptance of these terms."] + [ p [ _class "pt-right-text" ] [ small [] [ em [] [ raw l["(as of May 24, 2018)"] ] ] ] + h3 [] [ str "1. "; raw l["Acceptance of Terms"] ] + p [] [ + raw l["By accessing this web site, you are agreeing to be bound by these Terms and Conditions, and that you are responsible to ensure that your use of this site complies with all applicable laws."] + space + raw l["Your continued use of this site implies your acceptance of these terms."] ] - h3 [] [ str "2. "; raw l.["Description of Service and Registration"] ] - p [] [ - raw l.["{0} is a service that allows individuals to enter and amend prayer requests on behalf of organizations.", - s.["PrayerTracker"]] - space - raw l.["Registration is accomplished via e-mail to Daniel Summers (daniel at bitbadger dot solutions, substituting punctuation)."] - space - raw l.["See our {0} for details on the personal (user) information we maintain.", ppLink] + h3 [] [ str "2. "; raw l["Description of Service and Registration"] ] + p [] [ + raw l["{0} is a service that allows individuals to enter and amend prayer requests on behalf of organizations.", + s["PrayerTracker"]] + space + raw l["Registration is accomplished via e-mail to Daniel Summers (daniel at bitbadger dot solutions, substituting punctuation)."] + space + raw l["See our {0} for details on the personal (user) information we maintain.", ppLink] ] - h3 [] [ str "3. "; raw l.["Liability"] ] - p [] [ - raw l.["This service is provided “as is”, and no warranty (express or implied) exists."] - space - raw l.["The service and its developers may not be held liable for any damages that may arise through the use of this service."] + h3 [] [ str "3. "; raw l["Liability"] ] + p [] [ + raw l["This service is provided “as is”, and no warranty (express or implied) exists."] + space + raw l["The service and its developers may not be held liable for any damages that may arise through the use of this service."] ] - h3 [] [ str "4. "; raw l.["Updates to Terms"] ] - p [] [ - raw l.["These terms and conditions may be updated at any time."] - space - raw l.["When these terms are updated, users will be notified by a system-generated announcement."] - space - raw l.["Additionally, the date at the top of this page will be updated."] + h3 [] [ str "4. "; raw l["Updates to Terms"] ] + p [] [ + raw l["These terms and conditions may be updated at any time."] + space + raw l["When these terms are updated, users will be notified by a system-generated announcement."] + space + raw l["Additionally, the date at the top of this page will be updated."] ] - hr [] - p [] [ raw l.["You may also wish to review our {0} to learn how we handle your data.", ppLink] ] + hr [] + p [] [ raw l["You may also wish to review our {0} to learn how we handle your data.", ppLink] ] ] - |> Layout.Content.standard - |> Layout.standard vi "Terms of Service" + |> Layout.Content.standard + |> Layout.standard vi "Terms of Service" /// View for unauthorized page let unauthorized vi = - let s = I18N.localizer.Force () - let l = I18N.forView "Home/Unauthorized" - use sw = new StringWriter () - let raw = rawLocText sw - [ p [] [ - raw l.["If you feel you have reached this page in error, please contact Daniel and provide the details as to what you were doing (i.e., what link did you click, where had you been, etc.).", - s.["PrayerTracker"]] + let s = I18N.localizer.Force () + let l = I18N.forView "Home/Unauthorized" + use sw = new StringWriter () + let raw = rawLocText sw + [ p [] [ + raw l["If you feel you have reached this page in error, please contact Daniel and provide the details as to what you were doing (i.e., what link did you click, where had you been, etc.).", + s["PrayerTracker"]] ] - p [] [ - raw l.["Otherwise, you may select one of the links above to get back into an authorized portion of {0}.", - s.["PrayerTracker"]] + p [] [ + raw l["Otherwise, you may select one of the links above to get back into an authorized portion of {0}.", + s["PrayerTracker"]] ] ] - |> Layout.Content.standard - |> Layout.standard vi "Unauthorized Access" + |> Layout.Content.standard + |> Layout.standard vi "Unauthorized Access" diff --git a/src/PrayerTracker.UI/I18N.fs b/src/PrayerTracker.UI/I18N.fs index 7360e0a..9fcb1d2 100644 --- a/src/PrayerTracker.UI/I18N.fs +++ b/src/PrayerTracker.UI/I18N.fs @@ -11,12 +11,12 @@ let private resAsmName = typeof.Assembly.GetName().Name /// Set up the string and HTML localizer factories let setUpFactories fac = - stringLocFactory <- fac - htmlLocFactory <- HtmlLocalizerFactory stringLocFactory + stringLocFactory <- fac + htmlLocFactory <- HtmlLocalizerFactory stringLocFactory /// An instance of the common string localizer let localizer = lazy (stringLocFactory.Create ("Common", resAsmName)) /// Get a view localizer let forView (view : string) = - htmlLocFactory.Create ($"""Views.{view.Replace ('/', '.')}""", resAsmName) + htmlLocFactory.Create ($"""Views.{view.Replace ('/', '.')}""", resAsmName) diff --git a/src/PrayerTracker.UI/Layout.fs b/src/PrayerTracker.UI/Layout.fs index 4d299ab..cc3ae4b 100644 --- a/src/PrayerTracker.UI/Layout.fs +++ b/src/PrayerTracker.UI/Layout.fs @@ -6,285 +6,288 @@ open PrayerTracker open PrayerTracker.ViewModels open System open System.Globalization - + /// Get the two-character language code for the current request -let langCode () = match CultureInfo.CurrentCulture.Name.StartsWith "es" with true -> "es" | _ -> "en" +let langCode () = if CultureInfo.CurrentCulture.Name.StartsWith "es" then "es" else "en" /// Navigation items module Navigation = - /// Top navigation bar - let top m = - let s = PrayerTracker.Views.I18N.localizer.Force () - let menuSpacer = rawText "  " - let leftLinks = [ - match m.user with - | Some u -> - li [ _class "dropdown" ] [ - a [ _class "dropbtn"; _role "button"; _aria "label" s.["Requests"].Value; _title s.["Requests"].Value ] - [ icon "question_answer"; space; locStr s.["Requests"]; space; icon "keyboard_arrow_down" ] - div [ _class "dropdown-content"; _role "menu" ] [ - a [ _href "/web/prayer-requests" ] [ icon "compare_arrows"; menuSpacer; locStr s.["Maintain"] ] - a [ _href "/web/prayer-requests/view" ] [ icon "list"; menuSpacer; locStr s.["View List"] ] - ] - ] - li [ _class "dropdown" ] [ - a [ _class "dropbtn"; _role "button"; _aria "label" s.["Group"].Value; _title s.["Group"].Value ] - [ icon "group"; space; locStr s.["Group"]; space; icon "keyboard_arrow_down" ] - div [ _class "dropdown-content"; _role "menu" ] [ - a [ _href "/web/small-group/members" ] [ icon "email"; menuSpacer; locStr s.["Maintain Group Members"] ] - a [ _href "/web/small-group/announcement" ] [ icon "send"; menuSpacer; locStr s.["Send Announcement"] ] - a [ _href "/web/small-group/preferences" ] [ icon "build"; menuSpacer; locStr s.["Change Preferences"] ] - ] - ] - match u.isAdmin with - | true -> - li [ _class "dropdown" ] [ - a [ _class "dropbtn"; _role "button"; _aria "label" s.["Administration"].Value; _title s.["Administration"].Value ] - [ icon "settings"; space; locStr s.["Administration"]; space; icon "keyboard_arrow_down" ] - div [ _class "dropdown-content"; _role "menu" ] [ - a [ _href "/web/churches" ] [ icon "home"; menuSpacer; locStr s.["Churches"] ] - a [ _href "/web/small-groups" ] [ icon "send"; menuSpacer; locStr s.["Groups"] ] - a [ _href "/web/users" ] [ icon "build"; menuSpacer; locStr s.["Users"] ] - ] - ] - | false -> () - | None -> - match m.group with - | Some _ -> - li [] [ - a [ _href "/web/prayer-requests/view" - _aria "label" s.["View Request List"].Value - _title s.["View Request List"].Value ] - [ icon "list"; space; locStr s.["View Request List"] ] - ] - | None -> - li [ _class "dropdown" ] [ - a [ _class "dropbtn"; _role "button"; _aria "label" s.["Log On"].Value; _title s.["Log On"].Value ] - [ icon "security"; space; locStr s.["Log On"]; space; icon "keyboard_arrow_down" ] - div [ _class "dropdown-content"; _role "menu" ] [ - a [ _href "/web/user/log-on" ] [ icon "person"; menuSpacer; locStr s.["User"] ] - a [ _href "/web/small-group/log-on" ] [ icon "group"; menuSpacer; locStr s.["Group"] ] - ] - ] - li [] [ - a [ _href "/web/prayer-requests/lists" - _aria "label" s.["View Request List"].Value - _title s.["View Request List"].Value ] - [ icon "list"; space; locStr s.["View Request List"] ] - ] - li [] [ - a [ _href $"https://docs.prayer.bitbadger.solutions/{langCode ()}" - _aria "label" s.["Help"].Value; - _title s.["View Help"].Value - _target "_blank" - ] - [ icon "help"; space; locStr s.["Help"] ] - ] - ] - let rightLinks = - match m.group with - | Some _ -> - [ match m.user with - | Some _ -> - li [] [ - a [ _href "/web/user/password" - _aria "label" s.["Change Your Password"].Value - _title s.["Change Your Password"].Value ] - [ icon "lock"; space; locStr s.["Change Your Password"] ] - ] - | None -> () - li [] [ - a [ _href "/web/log-off"; _aria "label" s.["Log Off"].Value; _title s.["Log Off"].Value ] - [ icon "power_settings_new"; space; locStr s.["Log Off"] ] - ] - ] - | None -> List.empty - header [ _class "pt-title-bar" ] [ - section [ _class "pt-title-bar-left" ] [ - span [ _class "pt-title-bar-home" ] [ - a [ _href "/web/"; _title s.["Home"].Value ] [ locStr s.["PrayerTracker"] ] - ] - ul [] leftLinks - ] - section [ _class "pt-title-bar-center" ] [] - section [ _class "pt-title-bar-right"; _role "toolbar" ] [ - ul [] rightLinks - ] - ] - - /// Identity bar (below top nav) - let identity m = - let s = I18N.localizer.Force () - header [ _id "pt-language" ] [ - div [] [ - span [ _class "u" ] [ locStr s.["Language"]; rawText ": " ] - match langCode () with - | "es" -> - locStr s.["Spanish"] - rawText "   •   " - a [ _href "/web/language/en" ] [ locStr s.["Change to English"] ] - | _ -> - locStr s.["English"] - rawText "   •   " - a [ _href "/web/language/es" ] [ locStr s.["Cambie a Español"] ] - ] - match m.group with - | Some g -> - [ match m.user with - | Some u -> - span [ _class "u" ] [ locStr s.["Currently Logged On"] ] - rawText "   " - icon "person" - strong [] [ str u.fullName ] - rawText "    " - | None -> - locStr s.["Logged On as a Member of"] - rawText "  " - icon "group" - space + /// Top navigation bar + let top m = + let s = I18N.localizer.Force () + let menuSpacer = rawText "  " + let leftLinks = [ match m.user with - | Some _ -> a [ _href "/web/small-group" ] [ strong [] [ str g.name ] ] - | None -> strong [] [ str g.name ] - rawText "  " + | Some u -> + li [ _class "dropdown" ] [ + a [ _class "dropbtn"; _role "button"; _aria "label" s["Requests"].Value; _title s["Requests"].Value ] + [ icon "question_answer"; space; locStr s["Requests"]; space; icon "keyboard_arrow_down" ] + div [ _class "dropdown-content"; _role "menu" ] [ + a [ _href "/web/prayer-requests" ] [ icon "compare_arrows"; menuSpacer; locStr s["Maintain"] ] + a [ _href "/web/prayer-requests/view" ] [ icon "list"; menuSpacer; locStr s["View List"] ] + ] + ] + li [ _class "dropdown" ] [ + a [ _class "dropbtn"; _role "button"; _aria "label" s["Group"].Value; _title s["Group"].Value ] + [ icon "group"; space; locStr s["Group"]; space; icon "keyboard_arrow_down" ] + div [ _class "dropdown-content"; _role "menu" ] [ + a [ _href "/web/small-group/members" ] + [ icon "email"; menuSpacer; locStr s["Maintain Group Members"] ] + a [ _href "/web/small-group/announcement" ] + [ icon "send"; menuSpacer; locStr s["Send Announcement"] ] + a [ _href "/web/small-group/preferences" ] + [ icon "build"; menuSpacer; locStr s["Change Preferences"] ] + ] + ] + if u.isAdmin then + li [ _class "dropdown" ] [ + a [ _class "dropbtn" + _role "button" + _aria "label" s["Administration"].Value + _title s["Administration"].Value + ] [ icon "settings"; space; locStr s["Administration"]; space; icon "keyboard_arrow_down" ] + div [ _class "dropdown-content"; _role "menu" ] [ + a [ _href "/web/churches" ] [ icon "home"; menuSpacer; locStr s["Churches"] ] + a [ _href "/web/small-groups" ] [ icon "send"; menuSpacer; locStr s["Groups"] ] + a [ _href "/web/users" ] [ icon "build"; menuSpacer; locStr s["Users"] ] + ] + ] + | None -> + match m.group with + | Some _ -> + li [] [ + a [ _href "/web/prayer-requests/view" + _aria "label" s["View Request List"].Value + _title s["View Request List"].Value + ] [ icon "list"; space; locStr s["View Request List"] ] + ] + | None -> + li [ _class "dropdown" ] [ + a [ _class "dropbtn" + _role "button" + _aria "label" s["Log On"].Value + _title s["Log On"].Value + ] [ icon "security"; space; locStr s["Log On"]; space; icon "keyboard_arrow_down" ] + div [ _class "dropdown-content"; _role "menu" ] [ + a [ _href "/web/user/log-on" ] [ icon "person"; menuSpacer; locStr s["User"] ] + a [ _href "/web/small-group/log-on" ] [ icon "group"; menuSpacer; locStr s["Group"] ] + ] + ] + li [] [ + a [ _href "/web/prayer-requests/lists" + _aria "label" s["View Request List"].Value + _title s["View Request List"].Value + ] [ icon "list"; space; locStr s["View Request List"] ] + ] + li [] [ + a [ _href $"https://docs.prayer.bitbadger.solutions/{langCode ()}" + _aria "label" s["Help"].Value; + _title s["View Help"].Value + _target "_blank" + ] [ icon "help"; space; locStr s["Help"] ] ] - | None -> [] - |> div [] - ] + ] + let rightLinks = + match m.group with + | Some _ -> [ + match m.user with + | Some _ -> + li [] [ + a [ _href "/web/user/password" + _aria "label" s["Change Your Password"].Value + _title s["Change Your Password"].Value + ] [ icon "lock"; space; locStr s["Change Your Password"] ] + ] + | None -> () + li [] [ + a [ _href "/web/log-off"; _aria "label" s["Log Off"].Value; _title s["Log Off"].Value ] + [ icon "power_settings_new"; space; locStr s["Log Off"] ] + ] + ] + | None -> [] + header [ _class "pt-title-bar" ] [ + section [ _class "pt-title-bar-left" ] [ + span [ _class "pt-title-bar-home" ] [ + a [ _href "/web/"; _title s["Home"].Value ] [ locStr s["PrayerTracker"] ] + ] + ul [] leftLinks + ] + section [ _class "pt-title-bar-center" ] [] + section [ _class "pt-title-bar-right"; _role "toolbar" ] [ + ul [] rightLinks + ] + ] + + /// Identity bar (below top nav) + let identity m = + let s = I18N.localizer.Force () + header [ _id "pt-language" ] [ + div [] [ + span [ _class "u" ] [ locStr s["Language"]; rawText ": " ] + match langCode () with + | "es" -> + locStr s["Spanish"] + rawText "   •   " + a [ _href "/web/language/en" ] [ locStr s["Change to English"] ] + | _ -> + locStr s["English"] + rawText "   •   " + a [ _href "/web/language/es" ] [ locStr s["Cambie a Español"] ] + ] + match m.group with + | Some g ->[ + match m.user with + | Some u -> + span [ _class "u" ] [ locStr s["Currently Logged On"] ] + rawText "   " + icon "person" + strong [] [ str u.fullName ] + rawText "    " + | None -> + locStr s["Logged On as a Member of"] + rawText "  " + icon "group" + space + match m.user with + | Some _ -> a [ _href "/web/small-group" ] [ strong [] [ str g.name ] ] + | None -> strong [] [ str g.name ] + rawText "  " + ] + | None -> [] + |> div [] + ] /// Content layouts module Content = - /// Content layout that tops at 60rem - let standard = div [ _class "pt-content" ] + + /// Content layout that tops at 60rem + let standard = div [ _class "pt-content" ] - /// Content layout that uses the full width of the browser window - let wide = div [ _class "pt-content pt-full-width" ] + /// Content layout that uses the full width of the browser window + let wide = div [ _class "pt-content pt-full-width" ] /// Separator for parts of the title let private titleSep = rawText " « " +/// Common HTML head tag items let private commonHead = - [ meta [ _name "viewport"; _content "width=device-width, initial-scale=1" ] - meta [ _name "generator"; _content "Giraffe" ] - link [ _rel "stylesheet"; _href "https://fonts.googleapis.com/icon?family=Material+Icons" ] - link [ _rel "stylesheet"; _href "/css/app.css" ] - script [ _src "/js/app.js" ] [] + [ meta [ _name "viewport"; _content "width=device-width, initial-scale=1" ] + meta [ _name "generator"; _content "Giraffe" ] + link [ _rel "stylesheet"; _href "https://fonts.googleapis.com/icon?family=Material+Icons" ] + link [ _rel "stylesheet"; _href "/css/app.css" ] + script [ _src "/js/app.js" ] [] ] /// Render the portion of the page let private htmlHead m pageTitle = - let s = I18N.localizer.Force () - head [] [ - meta [ _charset "UTF-8" ] - title [] [ locStr pageTitle; titleSep; locStr s.["PrayerTracker"] ] - 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" ] [] + let s = I18N.localizer.Force () + head [] [ + meta [ _charset "UTF-8" ] + title [] [ locStr pageTitle; titleSep; locStr s["PrayerTracker"] ] + 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" ] [] ] /// Render a link to the help page for the current page let private helpLink link = - let s = I18N.localizer.Force () - sup [] [ - a [ _href link - _title s.["Click for Help on This Page"].Value - _onclick $"return PT.showHelp('{link}')" ] [ - icon "help_outline" - ] + let s = I18N.localizer.Force () + sup [] [ + a [ _href link; _title s["Click for Help on This Page"].Value; _onclick $"return PT.showHelp('{link}')" ] + [ icon "help_outline" ] ] /// 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 -> () - locStr pageTitle + h2 [ _id "pt-page-title" ] [ + match m.helpLink with Some link -> Help.fullLink (langCode ()) link |> helpLink | None -> () + locStr pageTitle ] /// Render the messages that may need to be displayed to the user let private messages m = - let s = I18N.localizer.Force () - m.messages - |> List.map (fun msg -> - table [ _class $"pt-msg {msg.level.ToLower ()}" ] [ - tr [] [ - td [] [ - match msg.level with - | "Info" -> () - | lvl -> - strong [] [ locStr s.[lvl] ] - rawText " » " - rawText msg.text.Value - match msg.description with - | Some desc -> - br [] - div [ _class "description" ] [ rawText desc.Value ] - | None -> () + let s = I18N.localizer.Force () + m.messages + |> List.map (fun msg -> + table [ _class $"pt-msg {msg.level.ToLower ()}" ] [ + tr [] [ + td [] [ + match msg.level with + | "Info" -> () + | lvl -> + strong [] [ locStr s[lvl] ] + rawText " » " + rawText msg.text.Value + match msg.description with + | Some desc -> + br [] + div [ _class "description" ] [ rawText desc.Value ] + | None -> () + ] ] - ] ]) /// Render the