Version 3 #40
|
@ -13,6 +13,9 @@ label.jjj-required::after {
|
||||||
color: red;
|
color: red;
|
||||||
content: ' *';
|
content: ' *';
|
||||||
}
|
}
|
||||||
|
label[for]:hover {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
.jjj-heading-label {
|
.jjj-heading-label {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
|
|
|
@ -25,6 +25,34 @@ module CitizenId =
|
||||||
let value = function CitizenId guid -> guid
|
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
|
/// The ID of a continent
|
||||||
type ContinentId = ContinentId of Guid
|
type ContinentId = ContinentId of Guid
|
||||||
|
|
||||||
|
@ -112,34 +140,6 @@ module ListingId =
|
||||||
let value = function ListingId guid -> guid
|
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
|
/// Another way to contact a citizen from this site
|
||||||
[<NoComparison; NoEquality>]
|
[<NoComparison; NoEquality>]
|
||||||
type OtherContact =
|
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
|
/// A skill the job seeker possesses
|
||||||
[<NoComparison; NoEquality>]
|
[<NoComparison; NoEquality>]
|
||||||
type Skill =
|
type Skill =
|
||||||
|
@ -370,25 +398,19 @@ type Profile =
|
||||||
{ /// The ID of the citizen to whom this profile belongs
|
{ /// The ID of the citizen to whom this profile belongs
|
||||||
Id : CitizenId
|
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
|
/// The ID of the continent on which the citizen resides
|
||||||
ContinentId : ContinentId
|
ContinentId : ContinentId
|
||||||
|
|
||||||
/// The region in which the citizen resides
|
/// The region in which the citizen resides
|
||||||
Region : string
|
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
|
IsRemote : bool
|
||||||
|
|
||||||
/// Whether the citizen is looking for full-time work
|
/// Whether the citizen is interested in full-time work
|
||||||
IsFullTime : bool
|
IsFullTime : bool
|
||||||
|
|
||||||
/// The citizen's professional biography
|
/// The citizen's professional biography
|
||||||
|
@ -403,6 +425,9 @@ type Profile =
|
||||||
/// The citizen's experience (topical / chronological)
|
/// The citizen's experience (topical / chronological)
|
||||||
Experience : MarkdownString option
|
Experience : MarkdownString option
|
||||||
|
|
||||||
|
/// The visibility of this profile
|
||||||
|
Visibility : ProfileVisibility
|
||||||
|
|
||||||
/// When the citizen last updated their profile
|
/// When the citizen last updated their profile
|
||||||
LastUpdatedOn : Instant
|
LastUpdatedOn : Instant
|
||||||
|
|
||||||
|
@ -415,20 +440,19 @@ module Profile =
|
||||||
|
|
||||||
// An empty profile
|
// An empty profile
|
||||||
let empty = {
|
let empty = {
|
||||||
Id = CitizenId Guid.Empty
|
Id = CitizenId Guid.Empty
|
||||||
IsSeekingEmployment = false
|
ContinentId = ContinentId Guid.Empty
|
||||||
IsPubliclySearchable = false
|
Region = ""
|
||||||
IsPubliclyLinkable = false
|
IsSeekingEmployment = false
|
||||||
ContinentId = ContinentId Guid.Empty
|
IsRemote = false
|
||||||
Region = ""
|
IsFullTime = false
|
||||||
IsRemote = false
|
Biography = Text ""
|
||||||
IsFullTime = false
|
Skills = []
|
||||||
Biography = Text ""
|
History = []
|
||||||
Skills = []
|
Experience = None
|
||||||
History = []
|
Visibility = Private
|
||||||
Experience = None
|
LastUpdatedOn = Instant.MinValue
|
||||||
LastUpdatedOn = Instant.MinValue
|
IsLegacy = false
|
||||||
IsLegacy = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -20,12 +20,12 @@ module Error =
|
||||||
open System.Net
|
open System.Net
|
||||||
|
|
||||||
/// Handler that will return a status code 404 and the text "Not Found"
|
/// 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 fac = ctx.GetService<ILoggerFactory> ()
|
||||||
let log = fac.CreateLogger "Handler"
|
let log = fac.CreateLogger "Handler"
|
||||||
let path = string ctx.Request.Path
|
let path = string ctx.Request.Path
|
||||||
log.LogInformation "Returning 404"
|
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
|
/// 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.ofObj
|
||||||
|> Option.map (fun x -> x.Value)
|
|> 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
|
/// Get the ID of the currently logged in citizen
|
||||||
// NOTE: if no one is logged in, this will raise an exception
|
// NOTE: if no one is logged in, this will raise an exception
|
||||||
let currentCitizenId ctx = (tryUser >> Option.get >> CitizenId.ofString) ctx
|
let currentCitizenId ctx = (tryUser >> Option.get >> CitizenId.ofString) ctx
|
||||||
|
|
||||||
/// Return an empty OK response
|
|
||||||
let ok : HttpHandler = Successful.OK ""
|
|
||||||
|
|
||||||
// -- NEW --
|
|
||||||
|
|
||||||
let antiForgerySvc (ctx : HttpContext) =
|
let antiForgerySvc (ctx : HttpContext) =
|
||||||
ctx.RequestServices.GetRequiredService<IAntiforgery> ()
|
ctx.RequestServices.GetRequiredService<IAntiforgery> ()
|
||||||
|
|
||||||
|
@ -168,6 +159,16 @@ let render pageTitle (_ : HttpFunc) (ctx : HttpContext) content = task {
|
||||||
return! ctx.WriteHtmlViewAsync (renderFunc renderCtx)
|
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
|
/// Render as a composable HttpHandler
|
||||||
let renderHandler pageTitle content : HttpHandler = fun next ctx ->
|
let renderHandler pageTitle content : HttpHandler = fun next ctx ->
|
||||||
render pageTitle next ctx content
|
render pageTitle next ctx content
|
||||||
|
@ -194,3 +195,7 @@ let redirectToGet (url : string) next ctx = task {
|
||||||
else RequestErrors.BAD_REQUEST "Invalid redirect URL"
|
else RequestErrors.BAD_REQUEST "Invalid redirect URL"
|
||||||
return! action next ctx
|
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
|
/// JsonSerializer options that use the custom converters
|
||||||
let options =
|
let options =
|
||||||
let opts = JsonSerializerOptions ()
|
let opts = JsonSerializerOptions ()
|
||||||
[ WrappedJsonConverter (CitizenId.ofString, CitizenId.toString) :> JsonConverter
|
[ WrappedJsonConverter (CitizenId.ofString, CitizenId.toString) :> JsonConverter
|
||||||
WrappedJsonConverter (ContactType.parse, ContactType.toString)
|
WrappedJsonConverter (ContactType.parse, ContactType.toString)
|
||||||
WrappedJsonConverter (ContinentId.ofString, ContinentId.toString)
|
WrappedJsonConverter (ContinentId.ofString, ContinentId.toString)
|
||||||
WrappedJsonConverter (ListingId.ofString, ListingId.toString)
|
WrappedJsonConverter (ListingId.ofString, ListingId.toString)
|
||||||
WrappedJsonConverter (Text, MarkdownString.toString)
|
WrappedJsonConverter (Text, MarkdownString.toString)
|
||||||
WrappedJsonConverter (SuccessId.ofString, SuccessId.toString)
|
WrappedJsonConverter (ProfileVisibility.parse, ProfileVisibility.toString)
|
||||||
JsonFSharpConverter ()
|
WrappedJsonConverter (SuccessId.ofString, SuccessId.toString)
|
||||||
|
JsonFSharpConverter ()
|
||||||
]
|
]
|
||||||
|> List.iter opts.Converters.Add
|
|> List.iter opts.Converters.Add
|
||||||
let _ = opts.ConfigureForNodaTime DateTimeZoneProviders.Tzdb
|
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 ->
|
|> List.map (fun p ->
|
||||||
let experience = p["experience"].Value<string> ()
|
let experience = p["experience"].Value<string> ()
|
||||||
{ Profile.empty with
|
{ Profile.empty with
|
||||||
Id = CitizenId.ofString (p["id"].Value<string> ())
|
Id = CitizenId.ofString (p["id"].Value<string> ())
|
||||||
IsSeekingEmployment = p["seekingEmployment"].Value<bool> ()
|
ContinentId = ContinentId.ofString (p["continentId"].Value<string> ())
|
||||||
IsPubliclySearchable = p["isPublic"].Value<bool> ()
|
Region = p["region"].Value<string> ()
|
||||||
ContinentId = ContinentId.ofString (p["continentId"].Value<string> ())
|
IsSeekingEmployment = p["seekingEmployment"].Value<bool> ()
|
||||||
Region = p["region"].Value<string> ()
|
IsRemote = p["remoteWork"].Value<bool> ()
|
||||||
IsRemote = p["remoteWork"].Value<bool> ()
|
IsFullTime = p["fullTime"].Value<bool> ()
|
||||||
IsFullTime = p["fullTime"].Value<bool> ()
|
Biography = Text (p["biography"].Value<string> ())
|
||||||
Biography = Text (p["biography"].Value<string> ())
|
Experience = if isNull experience then None else Some (Text experience)
|
||||||
LastUpdatedOn = getInstant p "lastUpdatedOn"
|
Skills = p["skills"].Children()
|
||||||
Experience = if isNull experience then None else Some (Text experience)
|
|> Seq.map (fun s ->
|
||||||
Skills = p["skills"].Children()
|
let notes = s["notes"].Value<string> ()
|
||||||
|> Seq.map (fun s ->
|
{ Description = s["description"].Value<string> ()
|
||||||
let notes = s["notes"].Value<string> ()
|
Notes = if isNull notes then None else Some notes
|
||||||
{ Description = s["description"].Value<string> ()
|
})
|
||||||
Notes = if isNull notes then None else Some notes
|
|
||||||
})
|
|
||||||
|> List.ofSeq
|
|> 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
|
for profile in newProfiles do
|
||||||
do! Profiles.Data.save profile
|
do! Profiles.Data.save profile
|
||||||
|
|
|
@ -27,15 +27,15 @@ module SkillForm =
|
||||||
/// The data required to update a profile
|
/// The data required to update a profile
|
||||||
[<CLIMutable; NoComparison; NoEquality>]
|
[<CLIMutable; NoComparison; NoEquality>]
|
||||||
type EditProfileForm =
|
type EditProfileForm =
|
||||||
{ /// Whether the citizen to whom this profile belongs is actively seeking employment
|
{ /// The ID of the continent on which the citizen is located
|
||||||
IsSeekingEmployment : bool
|
|
||||||
|
|
||||||
/// The ID of the continent on which the citizen is located
|
|
||||||
ContinentId : string
|
ContinentId : string
|
||||||
|
|
||||||
/// The area within that continent where the citizen is located
|
/// The area within that continent where the citizen is located
|
||||||
Region : string
|
Region : string
|
||||||
|
|
||||||
|
/// Whether the citizen to whom this profile belongs is actively seeking employment
|
||||||
|
IsSeekingEmployment : bool
|
||||||
|
|
||||||
/// If the citizen is available for remote work
|
/// If the citizen is available for remote work
|
||||||
RemoteWork : bool
|
RemoteWork : bool
|
||||||
|
|
||||||
|
@ -45,17 +45,11 @@ type EditProfileForm =
|
||||||
/// The user's professional biography
|
/// The user's professional biography
|
||||||
Biography : string
|
Biography : string
|
||||||
|
|
||||||
/// The skills for the user
|
|
||||||
Skills : SkillForm array
|
|
||||||
|
|
||||||
/// The user's past experience
|
/// The user's past experience
|
||||||
Experience : string option
|
Experience : string option
|
||||||
|
|
||||||
/// Whether this profile should appear in the public search
|
/// The visibility for this profile
|
||||||
IsPubliclySearchable : bool
|
Visibility : string
|
||||||
|
|
||||||
/// Whether this profile should be shown publicly
|
|
||||||
IsPubliclyLinkable : bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Support functions for the ProfileForm type
|
/// Support functions for the ProfileForm type
|
||||||
|
@ -63,30 +57,26 @@ module EditProfileForm =
|
||||||
|
|
||||||
/// An empty view model (used for new profiles)
|
/// An empty view model (used for new profiles)
|
||||||
let empty =
|
let empty =
|
||||||
{ IsSeekingEmployment = false
|
{ ContinentId = ""
|
||||||
ContinentId = ""
|
Region = ""
|
||||||
Region = ""
|
IsSeekingEmployment = false
|
||||||
RemoteWork = false
|
RemoteWork = false
|
||||||
FullTime = false
|
FullTime = false
|
||||||
Biography = ""
|
Biography = ""
|
||||||
Skills = [||]
|
Experience = None
|
||||||
Experience = None
|
Visibility = ProfileVisibility.toString Private
|
||||||
IsPubliclySearchable = false
|
|
||||||
IsPubliclyLinkable = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create an instance of this form from the given profile
|
/// Create an instance of this form from the given profile
|
||||||
let fromProfile (profile : Profile) =
|
let fromProfile (profile : Profile) =
|
||||||
{ IsSeekingEmployment = profile.IsSeekingEmployment
|
{ ContinentId = ContinentId.toString profile.ContinentId
|
||||||
ContinentId = ContinentId.toString profile.ContinentId
|
Region = profile.Region
|
||||||
Region = profile.Region
|
IsSeekingEmployment = profile.IsSeekingEmployment
|
||||||
RemoteWork = profile.IsRemote
|
RemoteWork = profile.IsRemote
|
||||||
FullTime = profile.IsFullTime
|
FullTime = profile.IsFullTime
|
||||||
Biography = MarkdownString.toString profile.Biography
|
Biography = MarkdownString.toString profile.Biography
|
||||||
Skills = profile.Skills |> List.map SkillForm.fromSkill |> Array.ofList
|
Experience = profile.Experience |> Option.map MarkdownString.toString
|
||||||
Experience = profile.Experience |> Option.map MarkdownString.toString
|
Visibility = ProfileVisibility.toString profile.Visibility
|
||||||
IsPubliclySearchable = profile.IsPubliclySearchable
|
|
||||||
IsPubliclyLinkable = profile.IsPubliclyLinkable
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -23,25 +23,21 @@ let edit : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||||
|
|
||||||
// GET: /profile/edit/general
|
// GET: /profile/edit/general
|
||||||
let editGeneralInfo : HttpHandler = requireUser >=> fun next ctx -> task {
|
let editGeneralInfo : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||||
let citizenId = currentCitizenId ctx
|
let! profile = Data.findById (currentCitizenId ctx)
|
||||||
let! profile = Data.findById citizenId
|
|
||||||
let! continents = Common.Data.Continents.all ()
|
let! continents = Common.Data.Continents.all ()
|
||||||
let isNew = Option.isNone profile
|
let form = if Option.isNone profile then EditProfileForm.empty else EditProfileForm.fromProfile profile.Value
|
||||||
let form = if isNew then EditProfileForm.empty else EditProfileForm.fromProfile profile.Value
|
return!
|
||||||
let title = $"""{if isNew then "Create" else "Edit"} Profile"""
|
Views.editGeneralInfo form continents (csrf ctx) |> render "General Information | Employment Profile" next ctx
|
||||||
return! Views.editGeneralInfo form continents isNew citizenId (csrf ctx) |> render title next ctx
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST: /profile/save
|
// POST: /profile/save
|
||||||
let save : HttpHandler = requireUser >=> fun next ctx -> task {
|
let saveGeneralInfo : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||||
let citizenId = currentCitizenId ctx
|
let citizenId = currentCitizenId ctx
|
||||||
let! theForm = ctx.BindFormAsync<EditProfileForm> ()
|
let! form = ctx.BindFormAsync<EditProfileForm> ()
|
||||||
let form = { theForm with Skills = theForm.Skills |> Array.filter (box >> isNull >> not) }
|
|
||||||
let errors = [
|
let errors = [
|
||||||
if form.ContinentId = "" then "Continent is required"
|
if form.ContinentId = "" then "Continent is required"
|
||||||
if form.Region = "" then "Region is required"
|
if form.Region = "" then "Region is required"
|
||||||
if form.Biography = "" then "Professional Biography 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 {
|
let! profile = task {
|
||||||
match! Data.findById citizenId with
|
match! Data.findById citizenId with
|
||||||
|
@ -52,20 +48,15 @@ let save : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||||
if List.isEmpty errors then
|
if List.isEmpty errors then
|
||||||
do! Data.save
|
do! Data.save
|
||||||
{ profile with
|
{ profile with
|
||||||
IsSeekingEmployment = form.IsSeekingEmployment
|
ContinentId = ContinentId.ofString form.ContinentId
|
||||||
ContinentId = ContinentId.ofString form.ContinentId
|
Region = form.Region
|
||||||
Region = form.Region
|
IsSeekingEmployment = form.IsSeekingEmployment
|
||||||
IsRemote = form.RemoteWork
|
IsRemote = form.RemoteWork
|
||||||
IsFullTime = form.FullTime
|
IsFullTime = form.FullTime
|
||||||
Biography = Text form.Biography
|
Biography = Text form.Biography
|
||||||
LastUpdatedOn = now ctx
|
LastUpdatedOn = now ctx
|
||||||
Skills = form.Skills
|
Experience = noneIfBlank form.Experience |> Option.map Text
|
||||||
|> Array.filter (fun s -> (box >> isNull >> not) s)
|
Visibility = ProfileVisibility.parse form.Visibility
|
||||||
|> Array.map SkillForm.toSkill
|
|
||||||
|> List.ofArray
|
|
||||||
Experience = noneIfBlank form.Experience |> Option.map Text
|
|
||||||
IsPubliclySearchable = form.IsPubliclySearchable
|
|
||||||
IsPubliclyLinkable = form.IsPubliclyLinkable
|
|
||||||
}
|
}
|
||||||
let action = if isNew then "cre" else "upd"
|
let action = if isNew then "cre" else "upd"
|
||||||
do! addSuccess $"Employment Profile {action}ated successfully" ctx
|
do! addSuccess $"Employment Profile {action}ated successfully" ctx
|
||||||
|
@ -74,8 +65,8 @@ let save : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||||
do! addErrors errors ctx
|
do! addErrors errors ctx
|
||||||
let! continents = Common.Data.Continents.all ()
|
let! continents = Common.Data.Continents.all ()
|
||||||
return!
|
return!
|
||||||
Views.editGeneralInfo form continents isNew citizenId (csrf ctx)
|
Views.editGeneralInfo form continents (csrf ctx)
|
||||||
|> render $"""{if isNew then "Create" else "Edit"} Profile""" next ctx
|
|> render "General Information | Employment Profile" next ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET: /profile/search
|
// 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
|
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
|
// GET: /profile/[id]/view
|
||||||
let view citizenId : HttpHandler = fun next ctx -> task {
|
let view citizenId : HttpHandler = fun next ctx -> task {
|
||||||
let citId = CitizenId citizenId
|
let citId = CitizenId citizenId
|
||||||
match! Data.findByIdForView citId with
|
match! Data.findByIdForView citId with
|
||||||
| Some profile ->
|
| Some profile ->
|
||||||
let currentCitizen = tryUser ctx |> Option.map CitizenId.ofString
|
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
|
return! Error.notAuthorized next ctx
|
||||||
else
|
else
|
||||||
let title = $"Employment Profile for {Citizen.name profile.Citizen}"
|
let title = $"Employment Profile for {Citizen.name profile.Citizen}"
|
||||||
return! Views.view profile currentCitizen |> render title next ctx
|
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 =
|
let endpoints =
|
||||||
subRoute "/profile" [
|
subRoute "/profile" [
|
||||||
GET_HEAD [
|
GET_HEAD [
|
||||||
routef "/%O/view" view
|
routef "/%O/view" view
|
||||||
route "/edit" edit
|
route "/edit" edit
|
||||||
route "/edit/general" editGeneralInfo
|
route "/edit/general" editGeneralInfo
|
||||||
route "/search" search
|
routef "/edit/skill/%i" editSkill
|
||||||
route "/seeking" seeking
|
route "/edit/skills" skills
|
||||||
|
route "/search" search
|
||||||
|
route "/seeking" seeking
|
||||||
]
|
]
|
||||||
POST [
|
POST [
|
||||||
route "/delete" delete
|
route "/delete" delete
|
||||||
route "/save" save
|
routef "/edit/skill/%i" saveSkill
|
||||||
|
routef "/edit/skill/%i/delete" deleteSkill
|
||||||
|
route "/save" saveGeneralInfo
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
|
|
|
@ -1,12 +1,15 @@
|
||||||
/// Views for /profile URLs
|
/// Views for /profile URLs
|
||||||
module JobsJobsJobs.Profiles.Views
|
module JobsJobsJobs.Profiles.Views
|
||||||
|
|
||||||
|
open Giraffe.Htmx.Common
|
||||||
open Giraffe.ViewEngine
|
open Giraffe.ViewEngine
|
||||||
open Giraffe.ViewEngine.Htmx
|
open Giraffe.ViewEngine.Htmx
|
||||||
open JobsJobsJobs.Common.Views
|
open JobsJobsJobs.Common.Views
|
||||||
open JobsJobsJobs.Domain
|
open JobsJobsJobs.Domain
|
||||||
open JobsJobsJobs.Profiles.Domain
|
open JobsJobsJobs.Profiles.Domain
|
||||||
|
|
||||||
|
// ~~~ PROFILE EDIT ~~~ //
|
||||||
|
|
||||||
/// The profile edit menu page
|
/// The profile edit menu page
|
||||||
let edit (profile : Profile) =
|
let edit (profile : Profile) =
|
||||||
let hasProfile = profile.Region <> ""
|
let hasProfile = profile.Region <> ""
|
||||||
|
@ -45,44 +48,32 @@ let edit (profile : Profile) =
|
||||||
i [ _class "mdi mdi-file-account-outline" ] []; txt " View Your User 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
|
/// A link to allow the user to return to the profile edit menu page
|
||||||
let skillEdit (skills : SkillForm array) =
|
let backToEdit =
|
||||||
let mapToInputs (idx : int) (skill : SkillForm) =
|
p [ _class "mx-3" ] [ a [ _href "/profile/edit" ] [ txt "« Back to Profile Edit Menu" ] ]
|
||||||
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)
|
|
||||||
|
|
||||||
/// The profile edit page
|
/// The profile edit page
|
||||||
let editGeneralInfo (m : EditProfileForm) continents isNew citizenId csrf =
|
let editGeneralInfo (m : EditProfileForm) continents csrf =
|
||||||
pageWithTitle "My Employment Profile" [
|
pageWithTitle "Employment Profile: General Information" [
|
||||||
|
backToEdit
|
||||||
form [ _class "row g-3"; _action "/profile/save"; _hxPost "/profile/save" ] [
|
form [ _class "row g-3"; _action "/profile/save"; _hxPost "/profile/save" ] [
|
||||||
antiForgery csrf
|
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" ] [
|
div [ _class "col-12" ] [
|
||||||
checkBox [] (nameof m.IsSeekingEmployment) m.IsSeekingEmployment "I am currently seeking employment"
|
checkBox [] (nameof m.IsSeekingEmployment) m.IsSeekingEmployment "I am currently seeking employment"
|
||||||
if m.IsSeekingEmployment then
|
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!" ]
|
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" ] [
|
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" ] [
|
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" ] [
|
markdownEditor [ _required ] (nameof m.Biography) m.Biography "Professional Biography"
|
||||||
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
|
|
||||||
div [ _class "col-12" ] [
|
div [ _class "col-12" ] [
|
||||||
hr []
|
hr []
|
||||||
h4 [] [ txt "Experience" ]
|
h4 [] [ txt "Experience" ]
|
||||||
p [] [
|
p [] [
|
||||||
txt "This application does not have a place to individually list your chronological job history; "
|
txt "The information in this box is displayed after the list of skills and chronological job "
|
||||||
txt "however, you can use this area to list prior jobs, their dates, and anything else you want to "
|
txt "history, with no heading; it can be used to highlight your experiences apart from the history "
|
||||||
txt "include that’s not already a part of your Professional Biography above."
|
txt "entries, provide closing notes, etc."
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
markdownEditor [] (nameof m.Experience) (defaultArg m.Experience "") "Experience"
|
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" ] [
|
div [ _class "col-12" ] [
|
||||||
submitButton "content-save-outline" "Save"
|
hr []
|
||||||
if not isNew then
|
h4 [] [ txt "Visibility" ]
|
||||||
txt " "
|
div [ _class "form-check" ] [
|
||||||
a [ _class "btn btn-outline-secondary"; _href $"/profile/{CitizenId.toString citizenId}/view" ] [
|
let pvt = ProfileVisibility.toString Private
|
||||||
i [ _color "#6c757d"; _class "mdi mdi-file-account-outline" ] []
|
input [ _type "radio"; _id $"{nameof m.Visibility}Private"; _name (nameof m.Visibility)
|
||||||
txt " View Your User Profile"
|
_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
|
/// The public search page
|
||||||
let publicSearch (m : PublicSearchForm) continents (results : PublicSearchResult list option) =
|
let publicSearch (m : PublicSearchForm) continents (results : PublicSearchResult list option) =
|
||||||
pageWithTitle "People Seeking Work" [
|
pageWithTitle "People Seeking Work" [
|
||||||
|
|
Loading…
Reference in New Issue
Block a user