diff --git a/src/JobsJobsJobs/App/src/router/index.ts b/src/JobsJobsJobs/App/src/router/index.ts index 4a6beae..615970f 100644 --- a/src/JobsJobsJobs/App/src/router/index.ts +++ b/src/JobsJobsJobs/App/src/router/index.ts @@ -129,18 +129,6 @@ const routes: Array = [ meta: { auth: true, title: "My Job Listings" } }, // Profile URLs - { - path: "/profile/:id/view", - name: "ViewProfile", - component: () => import(/* webpackChunkName: "profview" */ "../views/profile/ProfileView.vue"), - meta: { auth: true, title: "Loading Profile..." } - }, - { - path: "/profile/edit", - name: "EditProfile", - component: () => import(/* webpackChunkName: "profedit" */ "../views/profile/EditProfile.vue"), - meta: { auth: true, title: "Edit Profile" } - }, { path: "/profile/search", name: "SearchProfiles", diff --git a/src/JobsJobsJobs/App/src/views/profile/EditProfile.vue b/src/JobsJobsJobs/App/src/views/profile/EditProfile.vue deleted file mode 100644 index 05820a7..0000000 --- a/src/JobsJobsJobs/App/src/views/profile/EditProfile.vue +++ /dev/null @@ -1,206 +0,0 @@ - - - diff --git a/src/JobsJobsJobs/App/src/views/profile/ProfileView.vue b/src/JobsJobsJobs/App/src/views/profile/ProfileView.vue deleted file mode 100644 index 0e218c4..0000000 --- a/src/JobsJobsJobs/App/src/views/profile/ProfileView.vue +++ /dev/null @@ -1,88 +0,0 @@ - - - diff --git a/src/JobsJobsJobs/Data/Json.fs b/src/JobsJobsJobs/Data/Json.fs index 0d22e95..4ef0a81 100644 --- a/src/JobsJobsJobs/Data/Json.fs +++ b/src/JobsJobsJobs/Data/Json.fs @@ -24,7 +24,6 @@ let options = WrappedJsonConverter (ListingId.ofString, ListingId.toString) WrappedJsonConverter (Text, MarkdownString.toString) WrappedJsonConverter (OtherContactId.ofString, OtherContactId.toString) - WrappedJsonConverter (SkillId.ofString, SkillId.toString) WrappedJsonConverter (SuccessId.ofString, SuccessId.toString) JsonFSharpConverter () ] diff --git a/src/JobsJobsJobs/Domain/SharedTypes.fs b/src/JobsJobsJobs/Domain/SharedTypes.fs index e66415d..b84f0fb 100644 --- a/src/JobsJobsJobs/Domain/SharedTypes.fs +++ b/src/JobsJobsJobs/Domain/SharedTypes.fs @@ -163,18 +163,6 @@ type AuthOptions () = override this.Value = this -/// The fields required for a skill -type SkillForm = - { /// The ID of this skill - Id : string - - /// The description of the skill - Description : string - - /// Notes regarding the skill - Notes : string option - } - /// The data required to update a profile [] type ProfileForm = @@ -203,7 +191,7 @@ type ProfileForm = Experience : string option /// The skills for the user - Skills : SkillForm list + Skills : Skill list } /// Support functions for the ProfileForm type @@ -220,11 +208,6 @@ module ProfileForm = Biography = MarkdownString.toString profile.Biography Experience = profile.Experience |> Option.map MarkdownString.toString Skills = profile.Skills - |> List.map (fun s -> - { Id = string s.Id - Description = s.Description - Notes = s.Notes - }) } diff --git a/src/JobsJobsJobs/Domain/SupportTypes.fs b/src/JobsJobsJobs/Domain/SupportTypes.fs index 94076d9..a62e913 100644 --- a/src/JobsJobsJobs/Domain/SupportTypes.fs +++ b/src/JobsJobsJobs/Domain/SupportTypes.fs @@ -141,20 +141,14 @@ type OtherContact = } -/// The ID of a skill -type SkillId = SkillId of Guid - -/// Support functions for skill IDs -module SkillId = - - /// Create a new skill ID - let create () = (Guid.NewGuid >> SkillId) () - - /// A string representation of a skill ID - let toString = function SkillId it -> ShortGuid.fromGuid it - - /// Parse a string into a skill ID - let ofString = ShortGuid.toGuid >> SkillId +/// A skill the job seeker possesses +type Skill = + { /// A description of the skill + Description : string + + /// Notes regarding this skill (level, duration, etc.) + Notes : string option + } /// The ID of a success report diff --git a/src/JobsJobsJobs/Domain/Types.fs b/src/JobsJobsJobs/Domain/Types.fs index 3cfb5e1..0cbfcfd 100644 --- a/src/JobsJobsJobs/Domain/Types.fs +++ b/src/JobsJobsJobs/Domain/Types.fs @@ -178,19 +178,6 @@ module SecurityInfo = } -/// A skill the job seeker possesses -type Skill = - { /// The ID of the skill - Id : SkillId - - /// A description of the skill - Description : string - - /// Notes regarding this skill (level, duration, etc.) - Notes : string option - } - - /// A job seeker profile [] type Profile = diff --git a/src/JobsJobsJobs/JobsJobsJobs.V3Migration/Program.fs b/src/JobsJobsJobs/JobsJobsJobs.V3Migration/Program.fs index 3c531f0..7fee02c 100644 --- a/src/JobsJobsJobs/JobsJobsJobs.V3Migration/Program.fs +++ b/src/JobsJobsJobs/JobsJobsJobs.V3Migration/Program.fs @@ -141,8 +141,7 @@ task { Skills = p["skills"].Children() |> Seq.map (fun s -> let notes = s["notes"].Value () - { Skill.Id = SkillId.ofString (s["id"].Value ()) - Description = s["description"].Value () + { Description = s["description"].Value () Notes = if isNull notes then None else Some notes }) |> List.ofSeq diff --git a/src/JobsJobsJobs/Server/Handlers.fs b/src/JobsJobsJobs/Server/Handlers.fs index 9dbb1ac..e329ac6 100644 --- a/src/JobsJobsJobs/Server/Handlers.fs +++ b/src/JobsJobsJobs/Server/Handlers.fs @@ -69,6 +69,7 @@ module Error = clearResponse >=> ServerErrors.INTERNAL_ERROR ex.Message +open System open NodaTime /// Helper functions @@ -170,6 +171,12 @@ module Helpers = let addError msg ctx = task { do! addMessage "error" msg ctx } + + /// Add a list of errors to the response + let addErrors (errors : string list) ctx = task { + let errMsg = String.Join ("
  • ", errors) + do! addError $"Please correct the following errors:
    • {errMsg}
    " ctx + } /// Render a page-level view let render pageTitle (_ : HttpFunc) (ctx : HttpContext) content = task { @@ -213,7 +220,6 @@ module Helpers = } -open System open JobsJobsJobs.Data open JobsJobsJobs.ViewModels @@ -387,8 +393,7 @@ module Citizen = do! addError "There is already an account registered to the e-mail address provided" ctx return! refreshPage () next ctx else - let errMsg = String.Join ("
  • ", errors) - do! addError $"Please correct the following errors:
    • {errMsg}
    " ctx + do! addErrors errors ctx return! refreshPage () next ctx } @@ -581,7 +586,7 @@ module Listing = module Profile = // GET: /profile/edit - let edit = requireUser >=> fun next ctx -> task { + let edit : HttpHandler = requireUser >=> fun next ctx -> task { let! profile = Profiles.findById (currentCitizenId ctx) let! continents = Continents.all () let isNew = Option.isNone profile @@ -590,6 +595,72 @@ module Profile = return! Profile.edit form continents isNew (csrf ctx) |> render title next ctx } + // POST: /profile/save + let save : HttpHandler = requireUser >=> fun next ctx -> task { + let citizenId = currentCitizenId ctx + let! theForm = ctx.BindFormAsync () + let form = { theForm with Skills = theForm.Skills |> Array.filter (box >> isNull >> not) } + 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! Profiles.findById citizenId with + | Some p -> return p + | None -> return { Profile.empty with Id = citizenId } + } + let isNew = profile.Region = "" + if List.isEmpty errors then + do! Profiles.save + { profile with + IsSeekingEmployment = form.IsSeekingEmployment + IsPubliclySearchable = form.IsPublic + ContinentId = ContinentId.ofString form.ContinentId + Region = form.Region + IsRemote = form.RemoteWork + IsFullTime = form.FullTime + Biography = Text form.Biography + LastUpdatedOn = now ctx + Experience = noneIfBlank form.Experience |> Option.map Text + Skills = form.Skills + |> Array.filter (fun s -> (box >> isNull >> not) s) + |> Array.map SkillForm.toSkill + |> List.ofArray + } + let action = if isNew then "cre" else "upd" + do! addSuccess $"Employment Profile {action}ated successfully" ctx + return! redirectToGet "/profile/edit" next ctx + else + do! addErrors errors ctx + let! continents = Continents.all () + return! + Profile.edit form continents isNew (csrf ctx) + |> render $"""{if isNew then "Create" else "Edit"} Profile""" next ctx + } + + // GET: /profile/[id]/view + let view citizenId : HttpHandler = fun next ctx -> task { + let citId = CitizenId.ofString citizenId + match! Citizens.findById citId with + | Some citizen -> + match! Profiles.findById citId with + | Some profile -> + let currentCitizen = tryUser ctx |> Option.map CitizenId.ofString + if not profile.IsPubliclyLinkable && Option.isNone currentCitizen then + return! Error.notAuthorized next ctx + else + let! continent = Continents.findById profile.ContinentId + let continentName = match continent with Some c -> c.Name | None -> "not found" + let title = $"Employment Profile for {Citizen.name citizen}" + return! + Profile.view citizen profile continentName title currentCitizen + |> render title next ctx + | None -> return! Error.notFound next ctx + | None -> return! Error.notFound next ctx + } + /// Handlers for /api/profile routes [] @@ -624,37 +695,6 @@ module ProfileApi = return! json {| Count = theCount |} next ctx } - // POST: /api/profile/save - let save : HttpHandler = authorize >=> fun next ctx -> task { - let citizenId = currentCitizenId ctx - let! form = ctx.BindJsonAsync() - let! profile = task { - match! Profiles.findById citizenId with - | Some p -> return p - | None -> return { Profile.empty with Id = citizenId } - } - do! Profiles.save - { profile with - IsSeekingEmployment = form.IsSeekingEmployment - IsPubliclySearchable = form.IsPublic - ContinentId = ContinentId.ofString form.ContinentId - Region = form.Region - IsRemote = form.RemoteWork - IsFullTime = form.FullTime - Biography = Text form.Biography - LastUpdatedOn = now ctx - Experience = noneIfBlank form.Experience |> Option.map Text - Skills = form.Skills - |> List.map (fun s -> - { Id = if s.Id.StartsWith "new" then SkillId.create () - else SkillId.ofString s.Id - Description = s.Description - Notes = noneIfBlank s.Notes - }) - } - return! ok next ctx - } - // PATCH: /api/profile/employment-found let employmentFound : HttpHandler = authorize >=> fun next ctx -> task { match! Profiles.findById (currentCitizenId ctx) with @@ -756,8 +796,10 @@ let allEndpoints = [ GET_HEAD [ route "/privacy-policy" Home.privacyPolicy ] subRoute "/profile" [ GET_HEAD [ - route "/edit" Profile.edit + routef "/%s/view" Profile.view + route "/edit" Profile.edit ] + POST [ route "/save" Profile.save ] ] GET_HEAD [ route "/terms-of-service" Home.termsOfService ] @@ -794,7 +836,6 @@ let allEndpoints = [ route "/search" ProfileApi.search ] PATCH [ route "/employment-found" ProfileApi.employmentFound ] - POST [ route "" ProfileApi.save ] ] subRoute "/success" [ GET_HEAD [ diff --git a/src/JobsJobsJobs/Server/ViewModels.fs b/src/JobsJobsJobs/Server/ViewModels.fs index f710a5e..9d5f593 100644 --- a/src/JobsJobsJobs/Server/ViewModels.fs +++ b/src/JobsJobsJobs/Server/ViewModels.fs @@ -6,16 +6,24 @@ open JobsJobsJobs.Domain /// The fields required for a skill [] type SkillForm = - { /// The ID of this skill - Id : string - - /// The description of the skill - Description : string + { Description : string /// Notes regarding the skill Notes : string option } +/// Functions to support skill forms +module SkillForm = + + /// Create a skill form from a skill + let fromSkill (skill : Skill) = + { SkillForm.Description = skill.Description; Notes = skill.Notes } + + /// Create a skill from a skill form + let toSkill (form : SkillForm) = + { Skill.Description = form.Description; Notes = form.Notes } + + /// The data required to update a profile [] type EditProfileViewModel = @@ -73,13 +81,7 @@ module EditProfileViewModel = FullTime = profile.IsFullTime Biography = MarkdownString.toString profile.Biography Experience = profile.Experience |> Option.map MarkdownString.toString - Skills = profile.Skills - |> List.map (fun s -> - { Id = string s.Id - Description = s.Description - Notes = s.Notes - }) - |> Array.ofList + Skills = profile.Skills |> List.map SkillForm.fromSkill |> Array.ofList } diff --git a/src/JobsJobsJobs/Server/Views/Citizen.fs b/src/JobsJobsJobs/Server/Views/Citizen.fs index a58df6f..349617e 100644 --- a/src/JobsJobsJobs/Server/Views/Citizen.fs +++ b/src/JobsJobsJobs/Server/Views/Citizen.fs @@ -54,7 +54,8 @@ let dashboard (citizen : Citizen) (profile : Profile option) profileCount = div [ _class "card-footer" ] [ match profile with | Some p -> - a [ _href $"/profile/{citizen.Id}/view"; _class "btn btn-outline-secondary" ] [ + a [ _href $"/profile/{CitizenId.toString citizen.Id}/view" + _class "btn btn-outline-secondary" ] [ rawText "View Profile" ]; rawText "   " a [ _href "/profile/edit"; _class "btn btn-outline-secondary" ] [ rawText "Edit Profile" ] diff --git a/src/JobsJobsJobs/Server/Views/Profile.fs b/src/JobsJobsJobs/Server/Views/Profile.fs index 3f6f7ca..4198f96 100644 --- a/src/JobsJobsJobs/Server/Views/Profile.fs +++ b/src/JobsJobsJobs/Server/Views/Profile.fs @@ -2,7 +2,6 @@ [] module JobsJobsJobs.Views.Profile -open Giraffe open Giraffe.ViewEngine open Giraffe.ViewEngine.Htmx open JobsJobsJobs.ViewModels @@ -10,45 +9,42 @@ open JobsJobsJobs.ViewModels /// Render the skill edit template and existing skills let skillEdit (skills : SkillForm array) = let mapToInputs (idx : int) (skill : SkillForm) = - div [ _id $"skillRow{skill.Id}"; _class "row pb-3" ] [ + 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('{skill.Id}')" ] [ + _onclick $"jjj.profile.removeSkill(idx)" ] [ rawText " − " ] ] div [ _class "col-10 col-md-6" ] [ div [ _class "form-floating" ] [ - input [ _type "text"; _id $"skillDesc{skill.Id}"; _name $"Skills[{idx}].Description" + 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-label"; _for $"skillDesc{skill.Id}" ] [ rawText "Skill" ] + label [ _class "jjj-label"; _for $"skillDesc{idx}" ] [ rawText "Skill" ] ] if idx < 1 then div [ _class "form-text" ] [ rawText "A skill (language, design technique, process, etc.)" ] ] div [ _class "col-12 col-md-5" ] [ div [ _class "form-floating" ] [ - input [ _type "text"; _id $"skillNotes{skill.Id}"; _name $"Skills[{idx}].Notes"; - _class "form-control"; _maxlength "1000" - _placeholder "A further description of the skill (1,000 characters max)" + 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 (defaultArg skill.Notes "") ] - label [ _class "jjj-label"; _for $"skillNotes{skill.Id}" ] [ rawText "Notes" ] + label [ _class "jjj-label"; _for $"skillNotes{idx}" ] [ rawText "Notes" ] ] if idx < 1 then div [ _class "form-text" ] [ rawText "A further description of the skill" ] ] ] - template [ _id "newSkill" ] [ mapToInputs -1 { Id = ""; Description = ""; Notes = None } ] - :: (skills - |> Array.mapi mapToInputs - |> List.ofArray) + template [ _id "newSkill" ] [ mapToInputs -1 { Description = ""; Notes = None } ] + :: (skills |> Array.mapi mapToInputs |> List.ofArray) /// The profile edit page let edit (m : EditProfileViewModel) continents isNew csrf = article [] [ h3 [ _class "pb-3" ] [ rawText "My Employment Profile" ] - form [ _class "row g-3"; _action "/profile/edit"; _hxPost "/profile/edit" ] [ + form [ _class "row g-3"; _action "/profile/save"; _hxPost "/profile/save" ] [ antiForgery csrf div [ _class "col-12" ] [ div [ _class "form-check" ] [ @@ -100,7 +96,8 @@ let edit (m : EditProfileViewModel) continents isNew csrf = hr [] h4 [ _class "pb-2" ] [ rawText "Skills   " - button [ _class "btn btn-sm btn-outline-primary rounded-pill"; _onclick "jjj.profile.addSkill()" ] [ + button [ _type "button"; _class "btn btn-sm btn-outline-primary rounded-pill" + _onclick "jjj.profile.addSkill()" ] [ rawText "Add a Skill" ] ] @@ -149,3 +146,49 @@ let edit (m : EditProfileViewModel) continents isNew csrf = rawText "})" ] ] + +open JobsJobsJobs.Domain + +let view (citizen : Citizen) (profile : Profile) (continentName : string) pageTitle currentId = + article [] [ + h3 [ _class "pb-3" ] [ str pageTitle ] + h2 [] [ + // TODO: link to preferred profile + a [ _href "#"; _target "_blank"; _rel "noopener" ] [ str (Citizen.name citizen) ] + if profile.IsSeekingEmployment then + span [ _class "jjj-heading-label" ] [ + rawText "   "; span [ _class "badge bg-dark" ] [ rawText "Currently Seeking Employment" ] + ] + ] + h4 [ _class "pb-3" ] [ str $"{continentName}, {profile.Region}" ] + p [] [ + rawText (if profile.IsFullTime then "I" else "Not i"); rawText "nterested in full-time employment" + rawText " • " + rawText (if profile.IsRemote then "I" else "Not i"); rawText "nterested in remote opportunities" + ] + hr [] + div [] [ rawText (MarkdownString.toHtml profile.Biography) ] + if not (List.isEmpty profile.Skills) then + hr [] + h4 [ _class "pb-3" ] [ rawText "Skills" ] + profile.Skills |> List.map (fun skill -> + li [] [ + str skill.Description + match skill.Notes with + | Some notes -> + rawText "  ("; str notes; rawText ")" + | None -> () + ]) + |> ul [] + match profile.Experience with + | Some exp -> + hr [] + h4 [ _class "pb-3" ] [ rawText "Experience / Employment History" ] + div [] [ rawText (MarkdownString.toHtml exp) ] + | None -> () + if Option.isSome currentId && currentId.Value = citizen.Id then + br []; br [] + a [ _href "/profile/edit"; _class "btn btn-primary" ] [ + i [ _class "mdi mdi-pencil" ] []; rawText "  Edit Your Profile" + ] + ] diff --git a/src/JobsJobsJobs/Server/wwwroot/script.js b/src/JobsJobsJobs/Server/wwwroot/script.js index 6d295a7..007cbff 100644 --- a/src/JobsJobsJobs/Server/wwwroot/script.js +++ b/src/JobsJobsJobs/Server/wwwroot/script.js @@ -145,28 +145,28 @@ this.jjj = { * Add a skill to the profile form */ addSkill() { - const newId = `new${this.nextIndex}` + const next = this.nextIndex /** @type {HTMLTemplateElement} */ const newSkillTemplate = document.getElementById("newSkill") /** @type {HTMLDivElement} */ const newSkill = newSkillTemplate.content.firstElementChild.cloneNode(true) - newSkill.setAttribute("id", `skillRow${newId}`) + newSkill.setAttribute("id", `skillRow${next}`) const cols = newSkill.children // Button column - cols[0].querySelector("button").setAttribute("onclick", `jjj.profile.removeSkill('${newId}')`) + cols[0].querySelector("button").setAttribute("onclick", `jjj.profile.removeSkill('${next}')`) // Skill column const skillField = cols[1].querySelector("input") - skillField.setAttribute("id", `skillDesc${newId}`) + skillField.setAttribute("id", `skillDesc${next}`) skillField.setAttribute("name", `Skills[${this.nextIndex}].Description`) - cols[1].querySelector("label").setAttribute("for", `skillDesc${newId}`) + cols[1].querySelector("label").setAttribute("for", `skillDesc${next}`) if (this.nextIndex > 0) cols[1].querySelector("div.form-text").remove() // Notes column const notesField = cols[2].querySelector("input") - notesField.setAttribute("id", `skillNotes${newId}`) + notesField.setAttribute("id", `skillNotes${next}`) notesField.setAttribute("name", `Skills[${this.nextIndex}].Notes`) - cols[2].querySelector("label").setAttribute("for", `skillNotes${newId}`) + cols[2].querySelector("label").setAttribute("for", `skillNotes${next}`) if (this.nextIndex > 0) cols[2].querySelector("div.form-text").remove() // Add the row @@ -179,7 +179,7 @@ this.jjj = { /** * Remove a skill row from the profile form - * @param {string} id The ID of the skill row to remove + * @param {number} id The ID of the skill row to remove */ removeSkill(id) { document.getElementById(`skillRow${id}`).remove()