parent
93da2831cb
commit
2e0bfa5524
@ -13,6 +13,9 @@ label.jjj-required::after {
|
||||
color: red;
|
||||
content: ' *';
|
||||
}
|
||||
label[for]:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
.jjj-heading-label {
|
||||
display: inline-block;
|
||||
font-size: 1rem;
|
||||
|
@ -25,6 +25,34 @@ module CitizenId =
|
||||
let value = function CitizenId guid -> guid
|
||||
|
||||
|
||||
/// Types of contacts supported by Jobs, Jobs, Jobs
|
||||
type ContactType =
|
||||
/// E-mail addresses
|
||||
| Email
|
||||
/// Phone numbers (home, work, cell, etc.)
|
||||
| Phone
|
||||
/// Websites (personal, social, etc.)
|
||||
| Website
|
||||
|
||||
/// Functions to support contact types
|
||||
module ContactType =
|
||||
|
||||
/// Parse a contact type from a string
|
||||
let parse typ =
|
||||
match typ with
|
||||
| "Email" -> Email
|
||||
| "Phone" -> Phone
|
||||
| "Website" -> Website
|
||||
| it -> invalidOp $"{it} is not a valid contact type"
|
||||
|
||||
/// Convert a contact type to its string representation
|
||||
let toString =
|
||||
function
|
||||
| Email -> "Email"
|
||||
| Phone -> "Phone"
|
||||
| Website -> "Website"
|
||||
|
||||
|
||||
/// The ID of a continent
|
||||
type ContinentId = ContinentId of Guid
|
||||
|
||||
@ -112,34 +140,6 @@ module ListingId =
|
||||
let value = function ListingId guid -> guid
|
||||
|
||||
|
||||
/// Types of contacts supported by Jobs, Jobs, Jobs
|
||||
type ContactType =
|
||||
/// E-mail addresses
|
||||
| Email
|
||||
/// Phone numbers (home, work, cell, etc.)
|
||||
| Phone
|
||||
/// Websites (personal, social, etc.)
|
||||
| Website
|
||||
|
||||
/// Functions to support contact types
|
||||
module ContactType =
|
||||
|
||||
/// Parse a contact type from a string
|
||||
let parse typ =
|
||||
match typ with
|
||||
| "Email" -> Email
|
||||
| "Phone" -> Phone
|
||||
| "Website" -> Website
|
||||
| it -> invalidOp $"{it} is not a valid contact type"
|
||||
|
||||
/// Convert a contact type to its string representation
|
||||
let toString =
|
||||
function
|
||||
| Email -> "Email"
|
||||
| Phone -> "Phone"
|
||||
| Website -> "Website"
|
||||
|
||||
|
||||
/// Another way to contact a citizen from this site
|
||||
[<NoComparison; NoEquality>]
|
||||
type OtherContact =
|
||||
@ -157,6 +157,34 @@ type OtherContact =
|
||||
}
|
||||
|
||||
|
||||
/// Visibility options for an employment profile
|
||||
type ProfileVisibility =
|
||||
/// Profile is only visible to authenticated users
|
||||
| Private
|
||||
/// Anonymous information is visible to public users
|
||||
| Anonymous
|
||||
/// The full employment profile is visible to public users
|
||||
| Public
|
||||
|
||||
/// Support functions for profile visibility
|
||||
module ProfileVisibility =
|
||||
|
||||
/// Parse a string into a profile visibility
|
||||
let parse viz =
|
||||
match viz with
|
||||
| "Private" -> Private
|
||||
| "Anonymous" -> Anonymous
|
||||
| "Public" -> Public
|
||||
| it -> invalidOp $"{it} is not a valid profile visibility value"
|
||||
|
||||
/// Convert a profile visibility to its string representation
|
||||
let toString =
|
||||
function
|
||||
| Private -> "Private"
|
||||
| Anonymous -> "Anonymous"
|
||||
| Public -> "Public"
|
||||
|
||||
|
||||
/// A skill the job seeker possesses
|
||||
[<NoComparison; NoEquality>]
|
||||
type Skill =
|
||||
@ -370,25 +398,19 @@ type Profile =
|
||||
{ /// The ID of the citizen to whom this profile belongs
|
||||
Id : CitizenId
|
||||
|
||||
/// Whether this citizen is actively seeking employment
|
||||
IsSeekingEmployment : bool
|
||||
|
||||
/// Whether this citizen allows their profile to be a part of the publicly-viewable, anonymous data
|
||||
IsPubliclySearchable : bool
|
||||
|
||||
/// Whether this citizen allows their profile to be viewed via a public link
|
||||
IsPubliclyLinkable : bool
|
||||
|
||||
/// The ID of the continent on which the citizen resides
|
||||
ContinentId : ContinentId
|
||||
|
||||
/// The region in which the citizen resides
|
||||
Region : string
|
||||
|
||||
/// Whether the citizen is looking for remote work
|
||||
/// Whether this citizen is actively seeking employment
|
||||
IsSeekingEmployment : bool
|
||||
|
||||
/// Whether the citizen is interested in remote work
|
||||
IsRemote : bool
|
||||
|
||||
/// Whether the citizen is looking for full-time work
|
||||
/// Whether the citizen is interested in full-time work
|
||||
IsFullTime : bool
|
||||
|
||||
/// The citizen's professional biography
|
||||
@ -403,6 +425,9 @@ type Profile =
|
||||
/// The citizen's experience (topical / chronological)
|
||||
Experience : MarkdownString option
|
||||
|
||||
/// The visibility of this profile
|
||||
Visibility : ProfileVisibility
|
||||
|
||||
/// When the citizen last updated their profile
|
||||
LastUpdatedOn : Instant
|
||||
|
||||
@ -415,20 +440,19 @@ module Profile =
|
||||
|
||||
// An empty profile
|
||||
let empty = {
|
||||
Id = CitizenId Guid.Empty
|
||||
IsSeekingEmployment = false
|
||||
IsPubliclySearchable = false
|
||||
IsPubliclyLinkable = false
|
||||
ContinentId = ContinentId Guid.Empty
|
||||
Region = ""
|
||||
IsRemote = false
|
||||
IsFullTime = false
|
||||
Biography = Text ""
|
||||
Skills = []
|
||||
History = []
|
||||
Experience = None
|
||||
LastUpdatedOn = Instant.MinValue
|
||||
IsLegacy = false
|
||||
Id = CitizenId Guid.Empty
|
||||
ContinentId = ContinentId Guid.Empty
|
||||
Region = ""
|
||||
IsSeekingEmployment = false
|
||||
IsRemote = false
|
||||
IsFullTime = false
|
||||
Biography = Text ""
|
||||
Skills = []
|
||||
History = []
|
||||
Experience = None
|
||||
Visibility = Private
|
||||
LastUpdatedOn = Instant.MinValue
|
||||
IsLegacy = false
|
||||
}
|
||||
|
||||
|
||||
|
@ -20,12 +20,12 @@ module Error =
|
||||
open System.Net
|
||||
|
||||
/// Handler that will return a status code 404 and the text "Not Found"
|
||||
let notFound : HttpHandler = fun next ctx ->
|
||||
let notFound : HttpHandler = fun _ ctx ->
|
||||
let fac = ctx.GetService<ILoggerFactory> ()
|
||||
let log = fac.CreateLogger "Handler"
|
||||
let path = string ctx.Request.Path
|
||||
log.LogInformation "Returning 404"
|
||||
RequestErrors.NOT_FOUND $"The URL {path} was not recognized as a valid URL" next ctx
|
||||
RequestErrors.NOT_FOUND $"The URL {path} was not recognized as a valid URL" earlyReturn ctx
|
||||
|
||||
|
||||
/// Handle unauthorized actions, redirecting to log on for GETs, otherwise returning a 401 Not Authorized response
|
||||
@ -78,19 +78,10 @@ let tryUser (ctx : HttpContext) =
|
||||
|> Option.ofObj
|
||||
|> Option.map (fun x -> x.Value)
|
||||
|
||||
/// Require a user to be logged in
|
||||
let authorize : HttpHandler =
|
||||
fun next ctx -> match tryUser ctx with Some _ -> next ctx | None -> Error.notAuthorized next ctx
|
||||
|
||||
/// Get the ID of the currently logged in citizen
|
||||
// NOTE: if no one is logged in, this will raise an exception
|
||||
let currentCitizenId ctx = (tryUser >> Option.get >> CitizenId.ofString) ctx
|
||||
|
||||
/// Return an empty OK response
|
||||
let ok : HttpHandler = Successful.OK ""
|
||||
|
||||
// -- NEW --
|
||||
|
||||
let antiForgerySvc (ctx : HttpContext) =
|
||||
ctx.RequestServices.GetRequiredService<IAntiforgery> ()
|
||||
|
||||
@ -168,6 +159,16 @@ let render pageTitle (_ : HttpFunc) (ctx : HttpContext) content = task {
|
||||
return! ctx.WriteHtmlViewAsync (renderFunc renderCtx)
|
||||
}
|
||||
|
||||
let renderBare (_ : HttpFunc) (ctx : HttpContext) content =
|
||||
({ IsLoggedOn = Option.isSome (tryUser ctx)
|
||||
CurrentUrl = ctx.Request.Path.Value
|
||||
PageTitle = ""
|
||||
Content = content
|
||||
Messages = []
|
||||
} : Layout.PageRenderContext)
|
||||
|> Layout.bare
|
||||
|> ctx.WriteHtmlViewAsync
|
||||
|
||||
/// Render as a composable HttpHandler
|
||||
let renderHandler pageTitle content : HttpHandler = fun next ctx ->
|
||||
render pageTitle next ctx content
|
||||
@ -194,3 +195,7 @@ let redirectToGet (url : string) next ctx = task {
|
||||
else RequestErrors.BAD_REQUEST "Invalid redirect URL"
|
||||
return! action next ctx
|
||||
}
|
||||
|
||||
/// Shorthand for Error.notFound for use in handler functions
|
||||
let notFound ctx =
|
||||
Error.notFound earlyReturn ctx
|
||||
|
@ -19,13 +19,14 @@ open NodaTime.Serialization.SystemTextJson
|
||||
/// JsonSerializer options that use the custom converters
|
||||
let options =
|
||||
let opts = JsonSerializerOptions ()
|
||||
[ WrappedJsonConverter (CitizenId.ofString, CitizenId.toString) :> JsonConverter
|
||||
WrappedJsonConverter (ContactType.parse, ContactType.toString)
|
||||
WrappedJsonConverter (ContinentId.ofString, ContinentId.toString)
|
||||
WrappedJsonConverter (ListingId.ofString, ListingId.toString)
|
||||
WrappedJsonConverter (Text, MarkdownString.toString)
|
||||
WrappedJsonConverter (SuccessId.ofString, SuccessId.toString)
|
||||
JsonFSharpConverter ()
|
||||
[ WrappedJsonConverter (CitizenId.ofString, CitizenId.toString) :> JsonConverter
|
||||
WrappedJsonConverter (ContactType.parse, ContactType.toString)
|
||||
WrappedJsonConverter (ContinentId.ofString, ContinentId.toString)
|
||||
WrappedJsonConverter (ListingId.ofString, ListingId.toString)
|
||||
WrappedJsonConverter (Text, MarkdownString.toString)
|
||||
WrappedJsonConverter (ProfileVisibility.parse, ProfileVisibility.toString)
|
||||
WrappedJsonConverter (SuccessId.ofString, SuccessId.toString)
|
||||
JsonFSharpConverter ()
|
||||
]
|
||||
|> List.iter opts.Converters.Add
|
||||
let _ = opts.ConfigureForNodaTime DateTimeZoneProviders.Tzdb
|
||||
|
@ -352,3 +352,10 @@ module Layout =
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
/// Render a bare view (used for components)
|
||||
let bare ctx =
|
||||
html [ _lang "en" ] [
|
||||
head [] [ title [] [] ]
|
||||
body [] [ ctx.Content ]
|
||||
]
|
||||
|
@ -129,24 +129,24 @@ task {
|
||||
|> List.map (fun p ->
|
||||
let experience = p["experience"].Value<string> ()
|
||||
{ Profile.empty with
|
||||
Id = CitizenId.ofString (p["id"].Value<string> ())
|
||||
IsSeekingEmployment = p["seekingEmployment"].Value<bool> ()
|
||||
IsPubliclySearchable = p["isPublic"].Value<bool> ()
|
||||
ContinentId = ContinentId.ofString (p["continentId"].Value<string> ())
|
||||
Region = p["region"].Value<string> ()
|
||||
IsRemote = p["remoteWork"].Value<bool> ()
|
||||
IsFullTime = p["fullTime"].Value<bool> ()
|
||||
Biography = Text (p["biography"].Value<string> ())
|
||||
LastUpdatedOn = getInstant p "lastUpdatedOn"
|
||||
Experience = if isNull experience then None else Some (Text experience)
|
||||
Skills = p["skills"].Children()
|
||||
|> Seq.map (fun s ->
|
||||
let notes = s["notes"].Value<string> ()
|
||||
{ Description = s["description"].Value<string> ()
|
||||
Notes = if isNull notes then None else Some notes
|
||||
})
|
||||
Id = CitizenId.ofString (p["id"].Value<string> ())
|
||||
ContinentId = ContinentId.ofString (p["continentId"].Value<string> ())
|
||||
Region = p["region"].Value<string> ()
|
||||
IsSeekingEmployment = p["seekingEmployment"].Value<bool> ()
|
||||
IsRemote = p["remoteWork"].Value<bool> ()
|
||||
IsFullTime = p["fullTime"].Value<bool> ()
|
||||
Biography = Text (p["biography"].Value<string> ())
|
||||
Experience = if isNull experience then None else Some (Text experience)
|
||||
Skills = p["skills"].Children()
|
||||
|> Seq.map (fun s ->
|
||||
let notes = s["notes"].Value<string> ()
|
||||
{ Description = s["description"].Value<string> ()
|
||||
Notes = if isNull notes then None else Some notes
|
||||
})
|
||||
|> List.ofSeq
|
||||
IsLegacy = true
|
||||
Visibility = if p["isPublic"].Value<bool> () then Anonymous else Private
|
||||
LastUpdatedOn = getInstant p "lastUpdatedOn"
|
||||
IsLegacy = true
|
||||
})
|
||||
for profile in newProfiles do
|
||||
do! Profiles.Data.save profile
|
||||
|
@ -27,15 +27,15 @@ module SkillForm =
|
||||
/// The data required to update a profile
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type EditProfileForm =
|
||||
{ /// Whether the citizen to whom this profile belongs is actively seeking employment
|
||||
IsSeekingEmployment : bool
|
||||
|
||||
/// The ID of the continent on which the citizen is located
|
||||
{ /// The ID of the continent on which the citizen is located
|
||||
ContinentId : string
|
||||
|
||||
/// The area within that continent where the citizen is located
|
||||
Region : string
|
||||
|
||||
/// Whether the citizen to whom this profile belongs is actively seeking employment
|
||||
IsSeekingEmployment : bool
|
||||
|
||||
/// If the citizen is available for remote work
|
||||
RemoteWork : bool
|
||||
|
||||
@ -45,17 +45,11 @@ type EditProfileForm =
|
||||
/// The user's professional biography
|
||||
Biography : string
|
||||
|
||||
/// The skills for the user
|
||||
Skills : SkillForm array
|
||||
|
||||
/// The user's past experience
|
||||
Experience : string option
|
||||
|
||||
/// Whether this profile should appear in the public search
|
||||
IsPubliclySearchable : bool
|
||||
|
||||
/// Whether this profile should be shown publicly
|
||||
IsPubliclyLinkable : bool
|
||||
/// The visibility for this profile
|
||||
Visibility : string
|
||||
}
|
||||
|
||||
/// Support functions for the ProfileForm type
|
||||
@ -63,30 +57,26 @@ module EditProfileForm =
|
||||
|
||||
/// An empty view model (used for new profiles)
|
||||
let empty =
|
||||
{ IsSeekingEmployment = false
|
||||
ContinentId = ""
|
||||
Region = ""
|
||||
RemoteWork = false
|
||||
FullTime = false
|
||||
Biography = ""
|
||||
Skills = [||]
|
||||
Experience = None
|
||||
IsPubliclySearchable = false
|
||||
IsPubliclyLinkable = false
|
||||
{ ContinentId = ""
|
||||
Region = ""
|
||||
IsSeekingEmployment = false
|
||||
RemoteWork = false
|
||||
FullTime = false
|
||||
Biography = ""
|
||||
Experience = None
|
||||
Visibility = ProfileVisibility.toString Private
|
||||
}
|
||||
|
||||
/// Create an instance of this form from the given profile
|
||||
let fromProfile (profile : Profile) =
|
||||
{ IsSeekingEmployment = profile.IsSeekingEmployment
|
||||
ContinentId = ContinentId.toString profile.ContinentId
|
||||
Region = profile.Region
|
||||
RemoteWork = profile.IsRemote
|
||||
FullTime = profile.IsFullTime
|
||||
Biography = MarkdownString.toString profile.Biography
|
||||
Skills = profile.Skills |> List.map SkillForm.fromSkill |> Array.ofList
|
||||
Experience = profile.Experience |> Option.map MarkdownString.toString
|
||||
IsPubliclySearchable = profile.IsPubliclySearchable
|
||||
IsPubliclyLinkable = profile.IsPubliclyLinkable
|
||||
{ ContinentId = ContinentId.toString profile.ContinentId
|
||||
Region = profile.Region
|
||||
IsSeekingEmployment = profile.IsSeekingEmployment
|
||||
RemoteWork = profile.IsRemote
|
||||
FullTime = profile.IsFullTime
|
||||
Biography = MarkdownString.toString profile.Biography
|
||||
Experience = profile.Experience |> Option.map MarkdownString.toString
|
||||
Visibility = ProfileVisibility.toString profile.Visibility
|
||||
}
|
||||
|
||||
|
||||
|
@ -23,25 +23,21 @@ let edit : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||
|
||||
// GET: /profile/edit/general
|
||||
let editGeneralInfo : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||
let citizenId = currentCitizenId ctx
|
||||
let! profile = Data.findById citizenId
|
||||
let! profile = Data.findById (currentCitizenId ctx)
|
||||
let! continents = Common.Data.Continents.all ()
|
||||
let isNew = Option.isNone profile
|
||||
let form = if isNew then EditProfileForm.empty else EditProfileForm.fromProfile profile.Value
|
||||
let title = $"""{if isNew then "Create" else "Edit"} Profile"""
|
||||
return! Views.editGeneralInfo form continents isNew citizenId (csrf ctx) |> render title next ctx
|
||||
let form = if Option.isNone profile then EditProfileForm.empty else EditProfileForm.fromProfile profile.Value
|
||||
return!
|
||||
Views.editGeneralInfo form continents (csrf ctx) |> render "General Information | Employment Profile" next ctx
|
||||
}
|
||||
|
||||
// POST: /profile/save
|
||||
let save : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||
let saveGeneralInfo : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||
let citizenId = currentCitizenId ctx
|
||||
let! theForm = ctx.BindFormAsync<EditProfileForm> ()
|
||||
let form = { theForm with Skills = theForm.Skills |> Array.filter (box >> isNull >> not) }
|
||||
let! form = ctx.BindFormAsync<EditProfileForm> ()
|
||||
let errors = [
|
||||
if form.ContinentId = "" then "Continent is required"
|
||||
if form.Region = "" then "Region is required"
|
||||
if form.Biography = "" then "Professional Biography is required"
|
||||
if form.Skills |> Array.exists (fun s -> s.Description = "") then "All skill Descriptions are required"
|
||||
]
|
||||
let! profile = task {
|
||||
match! Data.findById citizenId with
|
||||
@ -52,20 +48,15 @@ let save : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||
if List.isEmpty errors then
|
||||
do! Data.save
|
||||
{ profile with
|
||||
IsSeekingEmployment = form.IsSeekingEmployment
|
||||
ContinentId = ContinentId.ofString form.ContinentId
|
||||
Region = form.Region
|
||||
IsRemote = form.RemoteWork
|
||||
IsFullTime = form.FullTime
|
||||
Biography = Text form.Biography
|
||||
LastUpdatedOn = now ctx
|
||||
Skills = form.Skills
|
||||
|> Array.filter (fun s -> (box >> isNull >> not) s)
|
||||
|> Array.map SkillForm.toSkill
|
||||
|> List.ofArray
|
||||
Experience = noneIfBlank form.Experience |> Option.map Text
|
||||
IsPubliclySearchable = form.IsPubliclySearchable
|
||||
IsPubliclyLinkable = form.IsPubliclyLinkable
|
||||
ContinentId = ContinentId.ofString form.ContinentId
|
||||
Region = form.Region
|
||||
IsSeekingEmployment = form.IsSeekingEmployment
|
||||
IsRemote = form.RemoteWork
|
||||
IsFullTime = form.FullTime
|
||||
Biography = Text form.Biography
|
||||
LastUpdatedOn = now ctx
|
||||
Experience = noneIfBlank form.Experience |> Option.map Text
|
||||
Visibility = ProfileVisibility.parse form.Visibility
|
||||
}
|
||||
let action = if isNew then "cre" else "upd"
|
||||
do! addSuccess $"Employment Profile {action}ated successfully" ctx
|
||||
@ -74,8 +65,8 @@ let save : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||
do! addErrors errors ctx
|
||||
let! continents = Common.Data.Continents.all ()
|
||||
return!
|
||||
Views.editGeneralInfo form continents isNew citizenId (csrf ctx)
|
||||
|> render $"""{if isNew then "Create" else "Edit"} Profile""" next ctx
|
||||
Views.editGeneralInfo form continents (csrf ctx)
|
||||
|> render "General Information | Employment Profile" next ctx
|
||||
}
|
||||
|
||||
// GET: /profile/search
|
||||
@ -110,18 +101,63 @@ let seeking : HttpHandler = fun next ctx -> task {
|
||||
return! Views.publicSearch form continents results |> render "Profile Search" next ctx
|
||||
}
|
||||
|
||||
// GET: /profile/edit/skills
|
||||
let skills : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||
match! Data.findById (currentCitizenId ctx) with
|
||||
| Some profile -> return! Views.skills profile.Skills (csrf ctx) |> render "Skills | Employment Profile" next ctx
|
||||
| None -> return! notFound ctx
|
||||
}
|
||||
|
||||
// GET: /profile/edit/skill/[idx]
|
||||
let editSkill idx : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||
match! Data.findById (currentCitizenId ctx) with
|
||||
| Some profile ->
|
||||
if idx < -1 || idx >= List.length profile.Skills then return! notFound ctx
|
||||
else return! Views.editSkill profile.Skills idx (csrf ctx) |> renderBare next ctx
|
||||
| None -> return! notFound ctx
|
||||
}
|
||||
|
||||
// POST: /profile/edit/skill/[idx]
|
||||
let saveSkill idx : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task {
|
||||
match! Data.findById (currentCitizenId ctx) with
|
||||
| Some profile ->
|
||||
if idx < -1 || idx >= List.length profile.Skills then return! notFound ctx
|
||||
else
|
||||
let! form = ctx.BindFormAsync<SkillForm> ()
|
||||
let skill = SkillForm.toSkill form
|
||||
let skills =
|
||||
if idx = -1 then skill :: profile.Skills
|
||||
else profile.Skills |> List.mapi (fun skillIdx it -> if skillIdx = idx then skill else it)
|
||||
|> List.sortBy (fun it -> it.Description.ToLowerInvariant ())
|
||||
do! Data.save { profile with Skills = skills }
|
||||
return! Views.skillTable skills None (csrf ctx) |> renderBare next ctx
|
||||
| None -> return! notFound ctx
|
||||
}
|
||||
|
||||
// POST: /profile/edit/skill/[idx]/delete
|
||||
let deleteSkill idx : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task {
|
||||
match! Data.findById (currentCitizenId ctx) with
|
||||
| Some profile ->
|
||||
if idx < 0 || idx >= List.length profile.Skills then return! notFound ctx
|
||||
else
|
||||
let skills = profile.Skills |> List.indexed |> List.filter (fun it -> fst it <> idx) |> List.map snd
|
||||
do! Data.save { profile with Skills = skills }
|
||||
return! Views.skillTable skills None (csrf ctx) |> renderBare next ctx
|
||||
| None -> return! notFound ctx
|
||||
}
|
||||
|
||||
// GET: /profile/[id]/view
|
||||
let view citizenId : HttpHandler = fun next ctx -> task {
|
||||
let citId = CitizenId citizenId
|
||||
match! Data.findByIdForView citId with
|
||||
| Some profile ->
|
||||
let currentCitizen = tryUser ctx |> Option.map CitizenId.ofString
|
||||
if not profile.Profile.IsPubliclyLinkable && Option.isNone currentCitizen then
|
||||
if not (profile.Profile.Visibility = Public) && Option.isNone currentCitizen then
|
||||
return! Error.notAuthorized next ctx
|
||||
else
|
||||
let title = $"Employment Profile for {Citizen.name profile.Citizen}"
|
||||
return! Views.view profile currentCitizen |> render title next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
| None -> return! notFound ctx
|
||||
}
|
||||
|
||||
|
||||
@ -131,14 +167,18 @@ open Giraffe.EndpointRouting
|
||||
let endpoints =
|
||||
subRoute "/profile" [
|
||||
GET_HEAD [
|
||||
routef "/%O/view" view
|
||||
route "/edit" edit
|
||||
route "/edit/general" editGeneralInfo
|
||||
route "/search" search
|
||||
route "/seeking" seeking
|
||||
routef "/%O/view" view
|
||||
route "/edit" edit
|
||||
route "/edit/general" editGeneralInfo
|
||||
routef "/edit/skill/%i" editSkill
|
||||
route "/edit/skills" skills
|
||||
route "/search" search
|
||||
route "/seeking" seeking
|
||||
]
|
||||
POST [
|
||||
route "/delete" delete
|
||||
route "/save" save
|
||||
route "/delete" delete
|
||||
routef "/edit/skill/%i" saveSkill
|
||||
routef "/edit/skill/%i/delete" deleteSkill
|
||||
route "/save" saveGeneralInfo
|
||||
]
|
||||
]
|
||||
|
@ -1,12 +1,15 @@
|
||||
/// Views for /profile URLs
|
||||
module JobsJobsJobs.Profiles.Views
|
||||
|
||||
open Giraffe.Htmx.Common
|
||||
open Giraffe.ViewEngine
|
||||
open Giraffe.ViewEngine.Htmx
|
||||
open JobsJobsJobs.Common.Views
|
||||
open JobsJobsJobs.Domain
|
||||
open JobsJobsJobs.Profiles.Domain
|
||||
|
||||
// ~~~ PROFILE EDIT ~~~ //
|
||||
|
||||
/// The profile edit menu page
|
||||
let edit (profile : Profile) =
|
||||
let hasProfile = profile.Region <> ""
|
||||
@ -45,44 +48,32 @@ let edit (profile : Profile) =
|
||||
i [ _class "mdi mdi-file-account-outline" ] []; txt " View Your User Profile"
|
||||
]
|
||||
]
|
||||
hr []
|
||||
p [ _class "text-muted" ] [
|
||||
txt "If you want to delete your profile, or your entire account, "
|
||||
a [ _href "/citizen/so-long" ] [ txt "see your deletion options here" ]; txt "."
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
/// Render the skill edit template and existing skills
|
||||
let skillEdit (skills : SkillForm array) =
|
||||
let mapToInputs (idx : int) (skill : SkillForm) =
|
||||
div [ _id $"skillRow{idx}"; _class "row pb-3" ] [
|
||||
div [ _class "col-2 col-md-1 align-self-center" ] [
|
||||
button [ _class "btn btn-sm btn-outline-danger rounded-pill"; _title "Delete"
|
||||
_onclick $"jjj.profile.removeSkill(idx)" ] [ txt " − " ]
|
||||
]
|
||||
div [ _class "col-10 col-md-6" ] [
|
||||
div [ _class "form-floating" ] [
|
||||
input [ _type "text"; _id $"skillDesc{idx}"; _name $"Skills[{idx}].Description"
|
||||
_class "form-control"; _placeholder "A skill (language, design technique, process, etc.)"
|
||||
_maxlength "200"; _value skill.Description; _required ]
|
||||
label [ _class "jjj-required"; _for $"skillDesc{idx}" ] [ txt "Skill" ]
|
||||
]
|
||||
if idx < 1 then div [ _class "form-text" ] [ txt "A skill (language, design technique, process, etc.)" ]
|
||||
]
|
||||
div [ _class "col-12 col-md-5" ] [
|
||||
div [ _class "form-floating" ] [
|
||||
input [ _type "text"; _id $"skillNotes{idx}"; _name $"Skills[{idx}].Notes"; _class "form-control"
|
||||
_maxlength "1000"; _placeholder "A further description of the skill (1,000 characters max)"
|
||||
_value skill.Notes ]
|
||||
label [ _class "jjj-label"; _for $"skillNotes{idx}" ] [ txt "Notes" ]
|
||||
]
|
||||
if idx < 1 then div [ _class "form-text" ] [ txt "A further description of the skill" ]
|
||||
]
|
||||
]
|
||||
template [ _id "newSkill" ] [ mapToInputs -1 { Description = ""; Notes = "" } ]
|
||||
:: (skills |> Array.mapi mapToInputs |> List.ofArray)
|
||||
/// A link to allow the user to return to the profile edit menu page
|
||||
let backToEdit =
|
||||
p [ _class "mx-3" ] [ a [ _href "/profile/edit" ] [ txt "« Back to Profile Edit Menu" ] ]
|
||||
|
||||
|
||||
/// The profile edit page
|
||||
let editGeneralInfo (m : EditProfileForm) continents isNew citizenId csrf =
|
||||
pageWithTitle "My Employment Profile" [
|
||||
let editGeneralInfo (m : EditProfileForm) continents csrf =
|
||||
pageWithTitle "Employment Profile: General Information" [
|
||||
backToEdit
|
||||
form [ _class "row g-3"; _action "/profile/save"; _hxPost "/profile/save" ] [
|
||||
antiForgery csrf
|
||||
div [ _class "col-12 col-sm-6 col-md-4" ] [
|
||||
continentList [] (nameof m.ContinentId) continents None m.ContinentId true
|
||||
]
|
||||
div [ _class "col-12 col-sm-6 col-md-8" ] [
|
||||
textBox [ _type "text"; _maxlength "255" ] (nameof m.Region) m.Region "Region" true
|
||||
div [ _class "form-text" ] [ txt "Country, state, geographic area, etc." ]
|
||||
]
|
||||
div [ _class "col-12" ] [
|
||||
checkBox [] (nameof m.IsSeekingEmployment) m.IsSeekingEmployment "I am currently seeking employment"
|
||||
if m.IsSeekingEmployment then
|
||||
@ -91,66 +82,158 @@ let editGeneralInfo (m : EditProfileForm) continents isNew citizenId csrf =
|
||||
a [ _href "/success-story/new/edit" ] [ txt "telling your fellow citizens about it!" ]
|
||||
]
|
||||
]
|
||||
div [ _class "col-12 col-sm-6 col-md-4" ] [
|
||||
continentList [] (nameof m.ContinentId) continents None m.ContinentId true
|
||||
]
|
||||
div [ _class "col-12 col-sm-6 col-md-8" ] [
|
||||
textBox [ _type "text"; _maxlength "255" ] (nameof m.Region) m.Region "Region" true
|
||||
div [ _class "form-text" ] [ txt "Country, state, geographic area, etc." ]
|
||||
]
|
||||
markdownEditor [ _required ] (nameof m.Biography) m.Biography "Professional Biography"
|
||||
div [ _class "col-12 col-offset-md-2 col-md-4" ] [
|
||||
checkBox [] (nameof m.RemoteWork) m.RemoteWork "I am looking for remote work"
|
||||
checkBox [] (nameof m.RemoteWork) m.RemoteWork "I am interested in remote work"
|
||||
]
|
||||
div [ _class "col-12 col-md-4" ] [
|
||||
checkBox [] (nameof m.FullTime) m.FullTime "I am looking for full-time work"
|
||||
checkBox [] (nameof m.FullTime) m.FullTime "I am interested in full-time work"
|
||||
]
|
||||
div [ _class "col-12" ] [
|
||||
hr []
|
||||
h4 [ _class "pb-2" ] [
|
||||
txt "Skills "
|
||||
button [ _type "button"; _class "btn btn-sm btn-outline-primary rounded-pill"
|
||||
_onclick "jjj.profile.addSkill()" ] [ txt "Add a Skill" ]
|
||||
]
|
||||
]
|
||||
yield! skillEdit m.Skills
|
||||
markdownEditor [ _required ] (nameof m.Biography) m.Biography "Professional Biography"
|
||||
div [ _class "col-12" ] [
|
||||
hr []
|
||||
h4 [] [ txt "Experience" ]
|
||||
p [] [
|
||||
txt "This application does not have a place to individually list your chronological job history; "
|
||||
txt "however, you can use this area to list prior jobs, their dates, and anything else you want to "
|
||||
txt "include that’s not already a part of your Professional Biography above."
|
||||
txt "The information in this box is displayed after the list of skills and chronological job "
|
||||
txt "history, with no heading; it can be used to highlight your experiences apart from the history "
|
||||
txt "entries, provide closing notes, etc."
|
||||
]
|
||||
]
|
||||
markdownEditor [] (nameof m.Experience) (defaultArg m.Experience "") "Experience"
|
||||
div [ _class "col-12 col-xl-6" ] [
|
||||
checkBox [] (nameof m.IsPubliclySearchable) m.IsPubliclySearchable
|
||||
"Allow my profile to be searched publicly"
|
||||
]
|
||||
div [ _class "col-12 col-xl-6" ] [
|
||||
checkBox [] (nameof m.IsPubliclyLinkable) m.IsPubliclyLinkable
|
||||
"Show my profile to anyone who has the direct link to it"
|
||||
]
|
||||
div [ _class "col-12" ] [
|
||||
submitButton "content-save-outline" "Save"
|
||||
if not isNew then
|
||||
txt " "
|
||||
a [ _class "btn btn-outline-secondary"; _href $"/profile/{CitizenId.toString citizenId}/view" ] [
|
||||
i [ _color "#6c757d"; _class "mdi mdi-file-account-outline" ] []
|
||||
txt " View Your User Profile"
|
||||
hr []
|
||||
h4 [] [ txt "Visibility" ]
|
||||
div [ _class "form-check" ] [
|
||||
let pvt = ProfileVisibility.toString Private
|
||||
input [ _type "radio"; _id $"{nameof m.Visibility}Private"; _name (nameof m.Visibility)
|
||||
_class "form-check-input"; _value pvt; if m.Visibility = pvt then _checked ]
|
||||
label [ _class "form-check-label"; _for $"{nameof m.Visibility}Private" ] [
|
||||
strong [] [ txt "Private" ]
|
||||
txt " – only show my employment profile to other authenticated users"
|
||||
]
|
||||
]
|
||||
div [ _class "form-check" ] [
|
||||
let anon = ProfileVisibility.toString Anonymous
|
||||
input [ _type "radio"; _id $"{nameof m.Visibility}Anonymous"; _name (nameof m.Visibility)
|
||||
_class "form-check-input"; _value anon; if m.Visibility = anon then _checked ]
|
||||
label [ _class "form-check-label"; _for $"{nameof m.Visibility}Anonymous" ] [
|
||||
strong [] [ txt "Anonymous" ]
|
||||
txt " – show my location and skills to public users anonymously"
|
||||
]
|
||||
]
|
||||
div [ _class "form-check" ] [
|
||||
let pub = ProfileVisibility.toString Public
|
||||
input [ _type "radio"; _id $"{nameof m.Visibility}Public"; _name (nameof m.Visibility)
|
||||
_class "form-check-input"; _value pub; if m.Visibility = pub then _checked ]
|
||||
label [ _class "form-check-label"; _for $"{nameof m.Visibility}Public" ] [
|
||||
strong [] [ txt "Public" ]; txt " – show my full profile to public users"
|
||||
]
|
||||
]
|
||||
]
|
||||
div [ _class "col-12" ] [ submitButton "content-save-outline" "Save" ]
|
||||
]
|
||||
hr []
|
||||
p [ _class "text-muted fst-italic" ] [
|
||||
txt "(If you want to delete your profile, or your entire account, "
|
||||
a [ _href "/citizen/so-long" ] [ txt "see your deletion options here" ]; txt ".)"
|
||||
]
|
||||
jsOnLoad $"jjj.profile.nextIndex = {m.Skills.Length}"
|
||||
]
|
||||
|
||||
|
||||
/// Render the skill edit template and existing skills
|
||||
let skillForm (m : SkillForm) isNew =
|
||||
[ h4 [] [ txt $"""{if isNew then "Add a" else "Edit"} Skill""" ]
|
||||
div [ _class "col-12 col-md-6" ] [
|
||||
div [ _class "form-floating" ] [
|
||||
textBox [ _type "text"; _maxlength "200"; _autofocus ] (nameof m.Description) m.Description "Skill" true
|
||||
]
|
||||
div [ _class "form-text" ] [ txt "A skill (language, design technique, process, etc.)" ]
|
||||
]
|
||||
div [ _class "col-12 col-md-6" ] [
|
||||
div [ _class "form-floating" ] [
|
||||
textBox [ _type "text"; _maxlength "1000" ] (nameof m.Notes) m.Notes "Notes" false
|
||||
]
|
||||
div [ _class "form-text" ] [ txt "A further description of the skill" ]
|
||||
]
|
||||
div [ _class "col-12" ] [
|
||||
submitButton "content-save-outline" "Save"; txt " "
|
||||
a [ _href "/profile/edit/skills/list"; _hxGet "/profile/edit/skills/list"; _hxTarget "#skillList"
|
||||
_class "btn btn-secondary" ] [ i [ _class "mdi mdi-cancel"] []; txt " Cancel" ]
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
/// List the skills for an employment profile
|
||||
let skillTable (skills : Skill list) editIdx csrf =
|
||||
let editingIdx = defaultArg editIdx -2
|
||||
let isEditing = editingIdx >= -1
|
||||
let renderTable () =
|
||||
let editSkillForm skill idx =
|
||||
tr [] [
|
||||
td [ _colspan "3" ] [
|
||||
form [ _class "row g-3"; _hxPost $"/profile/edit/skill/{idx}"; _hxTarget "#skillList" ] [
|
||||
antiForgery csrf
|
||||
yield! skillForm (SkillForm.fromSkill skill) (idx = -1)
|
||||
]
|
||||
]
|
||||
]
|
||||
table [ _class "table table-sm table-hover pt-3" ] [
|
||||
thead [] [
|
||||
[ "Action"; "Skill"; "Notes" ]
|
||||
|> List.map (fun it -> th [ _scope "col" ] [ txt it ])
|
||||
|> tr []
|
||||
]
|
||||
tbody [] [
|
||||
if isEditing && editingIdx = -1 then editSkillForm { Skill.Description = ""; Notes = None } -1
|
||||
yield! skills |> List.mapi (fun idx skill ->
|
||||
if isEditing && editingIdx = idx then editSkillForm skill idx
|
||||
else
|
||||
tr [] [
|
||||
td [ if isEditing then _class "text-muted" ] [
|
||||
if isEditing then txt "Edit ~ Delete"
|
||||
else
|
||||
let link = $"/profile/edit/skill/{idx}"
|
||||
a [ _href link; _hxGet link ] [ txt "Edit" ]; txt " ~ "
|
||||
a [ _href $"{link}/delete"; _hxPost $"{link}/delete"; _class "text-danger"
|
||||
_hxConfirm "Are you sure you want to delete this skill?" ] [ txt "Delete" ]
|
||||
]
|
||||
td [] [ str skill.Description ]
|
||||
td [ if Option.isNone skill.Notes then _class "text-muted fst-italic" ] [
|
||||
str (defaultArg skill.Notes "None")
|
||||
]
|
||||
])
|
||||
]
|
||||
]
|
||||
|
||||
if List.isEmpty skills && not isEditing then
|
||||
p [ _id "skillList"; _class "text-muted fst-italic pt-3" ] [ txt "Your profile has no skills defined" ]
|
||||
else if List.isEmpty skills then
|
||||
form [ _id "skillList"; _hxTarget "this"; _hxPost "/profile/edit/skill/-1"; _hxSwap HxSwap.OuterHtml
|
||||
_class "row g-3" ] [
|
||||
antiForgery csrf
|
||||
yield! skillForm { Description = ""; Notes = "" } true
|
||||
]
|
||||
else if isEditing then div [ _id "skillList" ] [ renderTable () ]
|
||||
else // not editing, there are skills to show
|
||||
form [ _id "skillList"; _hxTarget "this"; _hxSwap HxSwap.OuterHtml ] [
|
||||
antiForgery csrf
|
||||
renderTable ()
|
||||
]
|
||||
|
||||
|
||||
/// The profile skills maintenance page
|
||||
let skills (skills : Skill list) csrf =
|
||||
pageWithTitle "Employment Profile: Skills" [
|
||||
backToEdit
|
||||
p [] [
|
||||
a [ _href "/profile/edit/skill/-1"; _hxGet "/profile/edit/skill/-1"; _hxTarget "#skillList"
|
||||
_hxSwap HxSwap.OuterHtml; _class "btn btn-sm btn-outline-primary rounded-pill" ] [ txt "Add a Skill" ]
|
||||
]
|
||||
skillTable skills None csrf
|
||||
]
|
||||
|
||||
|
||||
/// The skill edit component
|
||||
let editSkill (skills : Skill list) idx csrf =
|
||||
skillTable skills (Some idx) csrf
|
||||
|
||||
|
||||
// ~~~ PROFILE SEARCH ~~~ //
|
||||
|
||||
/// The public search page
|
||||
let publicSearch (m : PublicSearchForm) continents (results : PublicSearchResult list option) =
|
||||
pageWithTitle "People Seeking Work" [
|
||||
|
Loading…
Reference in New Issue
Block a user