From 9de255d25a7c97cf2e9781e5e9ef56eb79c0a4fb Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sun, 15 Jan 2023 21:48:49 -0500 Subject: [PATCH] Migrate job listing edit/view/expire --- src/JobsJobsJobs/App/src/router/index.ts | 18 -- .../App/src/views/listing/ListingEdit.vue | 157 ---------- .../App/src/views/listing/ListingExpire.vue | 107 ------- .../App/src/views/listing/ListingView.vue | 78 ----- src/JobsJobsJobs/Data/Data.fs | 12 +- src/JobsJobsJobs/Domain/SharedTypes.fs | 60 +--- src/JobsJobsJobs/Server/Auth.fs | 28 -- src/JobsJobsJobs/Server/Handlers.fs | 275 ++++++++---------- src/JobsJobsJobs/Server/ViewModels.fs | 60 ++++ src/JobsJobsJobs/Server/Views/Common.fs | 21 +- src/JobsJobsJobs/Server/Views/Listing.fs | 100 ++++++- src/JobsJobsJobs/Server/Views/Profile.fs | 17 +- src/JobsJobsJobs/Server/wwwroot/script.js | 17 ++ 13 files changed, 328 insertions(+), 622 deletions(-) delete mode 100644 src/JobsJobsJobs/App/src/views/listing/ListingEdit.vue delete mode 100644 src/JobsJobsJobs/App/src/views/listing/ListingExpire.vue delete mode 100644 src/JobsJobsJobs/App/src/views/listing/ListingView.vue diff --git a/src/JobsJobsJobs/App/src/router/index.ts b/src/JobsJobsJobs/App/src/router/index.ts index a5b0b1c..f5b5132 100644 --- a/src/JobsJobsJobs/App/src/router/index.ts +++ b/src/JobsJobsJobs/App/src/router/index.ts @@ -43,24 +43,6 @@ const routes: Array = [ component: () => import(/* webpackChunkName: "joblist" */ "../views/listing/HelpWanted.vue"), meta: { auth: true, title: "Help Wanted" } }, - { - path: "/listing/:id/edit", - name: "EditListing", - component: () => import(/* webpackChunkName: "jobedit" */ "../views/listing/ListingEdit.vue"), - meta: { auth: true, title: "Edit Job Listing" } - }, - { - path: "/listing/:id/expire", - name: "ExpireListing", - component: () => import(/* webpackChunkName: "jobedit" */ "../views/listing/ListingExpire.vue"), - meta: { auth: true, title: "Expire Job Listing" } - }, - { - path: "/listing/:id/view", - name: "ViewListing", - component: () => import(/* webpackChunkName: "joblist" */ "../views/listing/ListingView.vue"), - meta: { auth: true, title: "Loading Job Listing..." } - }, // Success Story URLs { path: "/success-story/list", diff --git a/src/JobsJobsJobs/App/src/views/listing/ListingEdit.vue b/src/JobsJobsJobs/App/src/views/listing/ListingEdit.vue deleted file mode 100644 index b28ebcc..0000000 --- a/src/JobsJobsJobs/App/src/views/listing/ListingEdit.vue +++ /dev/null @@ -1,157 +0,0 @@ - - - diff --git a/src/JobsJobsJobs/App/src/views/listing/ListingExpire.vue b/src/JobsJobsJobs/App/src/views/listing/ListingExpire.vue deleted file mode 100644 index 422bc05..0000000 --- a/src/JobsJobsJobs/App/src/views/listing/ListingExpire.vue +++ /dev/null @@ -1,107 +0,0 @@ - - - diff --git a/src/JobsJobsJobs/App/src/views/listing/ListingView.vue b/src/JobsJobsJobs/App/src/views/listing/ListingView.vue deleted file mode 100644 index 2e05cfc..0000000 --- a/src/JobsJobsJobs/App/src/views/listing/ListingView.vue +++ /dev/null @@ -1,78 +0,0 @@ - - - diff --git a/src/JobsJobsJobs/Data/Data.fs b/src/JobsJobsJobs/Data/Data.fs index 6db56e8..881d291 100644 --- a/src/JobsJobsJobs/Data/Data.fs +++ b/src/JobsJobsJobs/Data/Data.fs @@ -331,13 +331,17 @@ module Listings = /// The SQL to select a listing view let viewSql = - $"SELECT l.*, c.data AS cont_data + $"SELECT l.*, c.data ->> 'name' AS continent_name, u.data AS cit_data FROM {Table.Listing} l - INNER JOIN {Table.Continent} c ON c.id = l.data ->> 'continentId'" + INNER JOIN {Table.Continent} c ON c.id = l.data ->> 'continentId' + INNER JOIN {Table.Citizen} u ON u.id = l.data ->> 'citizenId'" /// Map a result for a listing view let private toListingForView row = - { Listing = toDocument row; Continent = toDocumentFrom "cont_data" row } + { Listing = toDocument row + ContinentName = row.string "continent_name" + ListedBy = Citizen.name (toDocumentFrom "cit_data" row) + } /// Find all job listings posted by the given citizen let findByCitizen citizenId = @@ -358,7 +362,7 @@ module Listings = let findByIdForView listingId = backgroundTask { let! tryListing = connection () - |> Sql.query $"{viewSql} WHERE id = @id AND l.data ->> 'isLegacy' = 'false'" + |> Sql.query $"{viewSql} WHERE l.id = @id AND l.data ->> 'isLegacy' = 'false'" |> Sql.parameters [ "@id", Sql.string (ListingId.toString listingId) ] |> Sql.executeAsync toListingForView return List.tryHead tryListing diff --git a/src/JobsJobsJobs/Domain/SharedTypes.fs b/src/JobsJobsJobs/Domain/SharedTypes.fs index 1545b05..a8a4626 100644 --- a/src/JobsJobsJobs/Domain/SharedTypes.fs +++ b/src/JobsJobsJobs/Domain/SharedTypes.fs @@ -68,48 +68,16 @@ type CitizenRegistrationForm = } -/// The data required to add or edit a job listing -type ListingForm = - { /// The ID of the listing - Id : string - - /// The listing title - Title : string - - /// The ID of the continent on which this opportunity exists - ContinentId : string - - /// The region in which this opportunity exists - Region : string - - /// Whether this is a remote work opportunity - RemoteWork : bool - - /// The text of the job listing - Text : string - - /// The date by which this job listing is needed - NeededBy : string option - } - - /// The data needed to display a listing type ListingForView = { /// The listing itself Listing : Listing - /// The continent for that listing - Continent : Continent - } + /// The name of the continent for the listing + ContinentName : string - -/// The form submitted to expire a listing -type ListingExpireForm = - { /// Whether the job was filled from here - FromHere : bool - - /// The success story written by the user - SuccessStory : string option + /// The display name of the citizen who owns the listing + ListedBy : string } @@ -130,16 +98,6 @@ type ListingSearch = } -/// The fields needed to log on to Jobs, Jobs, Jobs -type LogOnForm = - { /// The e-mail address for the citizen - Email : string - - /// The password provided by the user - Password : string - } - - /// A successful logon type LogOnSuccess = { /// The JSON Web Token (JWT) to use for API access @@ -153,16 +111,6 @@ type LogOnSuccess = } -/// The authorization options for Jobs, Jobs, Jobs -type AuthOptions () = - - /// The secret with which the server signs the JWTs it issues once a user logs on - member val ServerSecret = "" with get, set - - interface IOptions with - override this.Value = this - - /// The various ways profiles can be searched [] type ProfileSearchForm = diff --git a/src/JobsJobsJobs/Server/Auth.fs b/src/JobsJobsJobs/Server/Auth.fs index 707dcc2..40b0023 100644 --- a/src/JobsJobsJobs/Server/Auth.fs +++ b/src/JobsJobsJobs/Server/Auth.fs @@ -28,31 +28,3 @@ module Passwords = | PasswordVerificationResult.Success -> Some false | PasswordVerificationResult.SuccessRehashNeeded -> Some true | _ -> None - - -open System.IdentityModel.Tokens.Jwt -open System.Security.Claims -open Microsoft.IdentityModel.Tokens -open JobsJobsJobs.Domain.SharedTypes - -/// Create a JSON Web Token for this citizen to use for further requests to this API -let createJwt (citizen : Citizen) (cfg : AuthOptions) = - - let tokenHandler = JwtSecurityTokenHandler () - let token = - tokenHandler.CreateToken ( - SecurityTokenDescriptor ( - Subject = ClaimsIdentity [| - Claim (ClaimTypes.NameIdentifier, CitizenId.toString citizen.Id) - Claim (ClaimTypes.Name, Citizen.name citizen) - |], - Expires = DateTime.UtcNow.AddHours 2., - Issuer = "https://noagendacareers.com", - Audience = "https://noagendacareers.com", - SigningCredentials = SigningCredentials ( - SymmetricSecurityKey ( - Encoding.UTF8.GetBytes cfg.ServerSecret), SecurityAlgorithms.HmacSha256Signature) - ) - ) - tokenHandler.WriteToken token - diff --git a/src/JobsJobsJobs/Server/Handlers.fs b/src/JobsJobsJobs/Server/Handlers.fs index 89181e2..0e09e0b 100644 --- a/src/JobsJobsJobs/Server/Handlers.fs +++ b/src/JobsJobsJobs/Server/Handlers.fs @@ -2,20 +2,13 @@ module JobsJobsJobs.Server.Handlers open Giraffe +open Giraffe.Htmx open JobsJobsJobs.Domain open JobsJobsJobs.Domain.SharedTypes open JobsJobsJobs.Views open Microsoft.AspNetCore.Http open Microsoft.Extensions.Logging -/// Handler to return the files required for the Vue client app -module Vue = - - /// Handler that returns index.html (the Vue client app) - let app = htmlFile "wwwroot/index.html" - - -open Giraffe.Htmx [] module private HtmxHelpers = @@ -29,27 +22,15 @@ module private HtmxHelpers = module Error = open System.Net - open System.Threading.Tasks - - /// URL prefixes for the Vue app - let vueUrls = - [ "/how-it-works"; "/privacy-policy"; "/terms-of-service"; "/citizen"; "/help-wanted"; "/listing"; "/profile" - "/so-long"; "/success-story" - ] /// Handler that will return a status code 404 and the text "Not Found" - let notFound : HttpHandler = fun next ctx -> task { + let notFound : HttpHandler = fun next ctx -> let fac = ctx.GetService () let log = fac.CreateLogger "Handler" let path = string ctx.Request.Path - match [ "GET"; "HEAD" ] |> List.contains ctx.Request.Method with - | true when path = "/" || vueUrls |> List.exists path.StartsWith -> - log.LogInformation "Returning Vue app" - return! Vue.app next ctx - | _ -> - log.LogInformation "Returning 404" - return! RequestErrors.NOT_FOUND $"The URL {path} was not recognized as a valid URL" next ctx - } + log.LogInformation "Returning 404" + RequestErrors.NOT_FOUND $"The URL {path} was not recognized as a valid URL" next ctx + /// Handle unauthorized actions, redirecting to log on for GETs, otherwise returning a 401 Not Authorized response let notAuthorized : HttpHandler = fun next ctx -> @@ -90,9 +71,6 @@ module Helpers = /// Get the application configuration from the request context let config (ctx : HttpContext) = ctx.GetService () - /// Get the authorization configuration from the request context - let authConfig (ctx : HttpContext) = (ctx.GetService> ()).Value - /// Get the logger factory from the request context let logger (ctx : HttpContext) = ctx.GetService () @@ -418,13 +396,6 @@ module Citizen = [] module CitizenApi = - // GET: /api/citizen/[id] - let get citizenId : HttpHandler = authorize >=> fun next ctx -> task { - match! Citizens.findById (CitizenId citizenId) with - | Some citizen -> return! json { citizen with PasswordHash = "" } next ctx - | None -> return! Error.notFound next ctx - } - // PATCH: /api/citizen/account let account : HttpHandler = authorize >=> fun next ctx -> task { let! form = ctx.BindJsonAsync () @@ -490,62 +461,100 @@ module Home = [] module Listing = + /// Parse the string we receive from JSON into a NodaTime local date + let private parseDate = DateTime.Parse >> LocalDate.FromDateTime + + // GET: /listing/[id]/edit + let edit listId : HttpHandler = requireUser >=> fun next ctx -> task { + let citizenId = currentCitizenId ctx + let! theListing = task { + match listId with + | "new" -> return Some { Listing.empty with CitizenId = citizenId } + | _ -> return! Listings.findById (ListingId.ofString listId) + } + match theListing with + | Some listing when listing.CitizenId = citizenId -> + let! continents = Continents.all () + return! + Listing.edit (EditListingForm.fromListing listing listId) continents (listId = "new") (csrf ctx) + |> render $"""{if listId = "new" then "Add a" else "Edit"} Job Listing""" next ctx + | Some _ -> return! Error.notAuthorized next ctx + | None -> return! Error.notFound next ctx + } + + // GET: /listing/[id]/expire + let expire listingId : HttpHandler = requireUser >=> fun next ctx -> task { + match! Listings.findById (ListingId listingId) with + | Some listing when listing.CitizenId = currentCitizenId ctx -> + if listing.IsExpired then + do! addError $"The listing “{listing.Title}” is already expired" ctx + return! redirectToGet "/listings/mine" next ctx + else + let form = { Id = ListingId.toString listing.Id; FromHere = false; SuccessStory = "" } + return! Listing.expire form listing (csrf ctx) |> render "Expire Job Listing" next ctx + | Some _ -> return! Error.notAuthorized next ctx + | None -> return! Error.notFound next ctx + } + + // POST: /listing/expire + let doExpire : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task { + let citizenId = currentCitizenId ctx + let now = now ctx + let! form = ctx.BindFormAsync () + match! Listings.findById (ListingId.ofString form.Id) with + | Some listing when listing.CitizenId = citizenId -> + if listing.IsExpired then + return! RequestErrors.BAD_REQUEST "Request is already expired" next ctx + else + do! Listings.save + { listing with + IsExpired = true + WasFilledHere = Some form.FromHere + UpdatedOn = now + } + if form.SuccessStory <> "" then + do! Successes.save + { Id = SuccessId.create() + CitizenId = citizenId + RecordedOn = now + IsFromHere = form.FromHere + Source = "listing" + Story = (Text >> Some) form.SuccessStory + } + let extraMsg = if form.SuccessStory <> "" then " and success story recorded" else "" + do! addSuccess $"Job listing expired{extraMsg} successfully" ctx + return! redirectToGet "/listings/mine" next ctx + | Some _ -> return! Error.notAuthorized next ctx + | None -> return! Error.notFound next ctx + } + // GET: /listings/mine let mine : HttpHandler = requireUser >=> fun next ctx -> task { let! listings = Listings.findByCitizen (currentCitizenId ctx) return! Listing.mine listings (timeZone ctx) |> render "My Job Listings" next ctx } - -/// Handlers for /api/listing[s] routes -[] -module ListingApi = - - /// Parse the string we receive from JSON into a NodaTime local date - let private parseDate = DateTime.Parse >> LocalDate.FromDateTime - - // GET: /api/listing/[id] - let get listingId : HttpHandler = authorize >=> fun next ctx -> task { - match! Listings.findById (ListingId listingId) with - | Some listing -> return! json listing next ctx - | None -> return! Error.notFound next ctx - } - - // GET: /api/listing/view/[id] - let view listingId : HttpHandler = authorize >=> fun next ctx -> task { - match! Listings.findByIdForView (ListingId listingId) with - | Some listing -> return! json listing next ctx - | None -> return! Error.notFound next ctx - } - - // POST: /listings - let add : HttpHandler = authorize >=> fun next ctx -> task { - let! form = ctx.BindJsonAsync () - let now = now ctx - do! Listings.save { - Id = ListingId.create () - CitizenId = currentCitizenId ctx - CreatedOn = now - Title = form.Title - ContinentId = ContinentId.ofString form.ContinentId - Region = form.Region - IsRemote = form.RemoteWork - IsExpired = false - UpdatedOn = now - Text = Text form.Text - NeededBy = (form.NeededBy |> Option.map parseDate) - WasFilledHere = None - IsLegacy = false + // POST: /listing/save + let save : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task { + let citizenId = currentCitizenId ctx + let now = now ctx + let! form = ctx.BindFormAsync () + let! theListing = task { + match form.Id with + | "new" -> + return Some + { Listing.empty with + Id = ListingId.create () + CitizenId = currentCitizenId ctx + CreatedOn = now + IsExpired = false + WasFilledHere = None + IsLegacy = false + } + | _ -> return! Listings.findById (ListingId.ofString form.Id) } - return! ok next ctx - } - - // PUT: /api/listing/[id] - let update listingId : HttpHandler = authorize >=> fun next ctx -> task { - match! Listings.findById (ListingId listingId) with - | Some listing when listing.CitizenId <> (currentCitizenId ctx) -> return! Error.notAuthorized next ctx - | Some listing -> - let! form = ctx.BindJsonAsync () + match theListing with + | Some listing when listing.CitizenId = citizenId -> do! Listings.save { listing with Title = form.Title @@ -553,41 +562,28 @@ module ListingApi = Region = form.Region IsRemote = form.RemoteWork Text = Text form.Text - NeededBy = form.NeededBy |> Option.map parseDate - UpdatedOn = now ctx + NeededBy = noneIfEmpty form.NeededBy |> Option.map parseDate + UpdatedOn = now } - return! ok next ctx + do! addSuccess $"""Job listing {if form.Id = "new" then "add" else "updat"}ed successfully""" ctx + return! redirectToGet $"/listing/{ListingId.toString listing.Id}/edit" next ctx + | Some _ -> return! Error.notAuthorized next ctx | None -> return! Error.notFound next ctx - } - - // PATCH: /api/listing/[id] - let expire listingId : HttpHandler = authorize >=> fun next ctx -> task { - let now = now ctx - match! Listings.findById (ListingId listingId) with - | Some listing when listing.CitizenId <> (currentCitizenId ctx) -> return! Error.notAuthorized next ctx - | Some listing -> - let! form = ctx.BindJsonAsync () - do! Listings.save - { listing with - IsExpired = true - WasFilledHere = Some form.FromHere - UpdatedOn = now - } - match form.SuccessStory with - | Some storyText -> - do! Successes.save - { Id = SuccessId.create() - CitizenId = currentCitizenId ctx - RecordedOn = now - IsFromHere = form.FromHere - Source = "listing" - Story = (Text >> Some) storyText - } - | None -> () - return! ok next ctx + + } + + // GET: /listing/[id]/view + let view listingId : HttpHandler = requireUser >=> fun next ctx -> task { + match! Listings.findByIdForView (ListingId listingId) with + | Some listing -> return! Listing.view listing |> render $"{listing.Listing.Title} | Job Listing" next ctx | None -> return! Error.notFound next ctx } + +/// Handlers for /api/listing[s] routes +[] +module ListingApi = + // GET: /api/listing/search let search : HttpHandler = authorize >=> fun next ctx -> task { let search = ctx.BindQueryString () @@ -698,7 +694,7 @@ module Profile = // GET: /profile/[id]/view let view citizenId : HttpHandler = fun next ctx -> task { - let citId = CitizenId.ofString citizenId + let citId = CitizenId citizenId match! Citizens.findById citId with | Some citizen -> match! Profiles.findById citId with @@ -722,35 +718,6 @@ module Profile = [] module ProfileApi = - // GET: /api/profile - // This returns the current citizen's profile, or a 204 if it is not found (a citizen not having a profile yet - // is not an error). The "get" handler returns a 404 if a profile is not found. - let current : HttpHandler = authorize >=> fun next ctx -> task { - match! Profiles.findById (currentCitizenId ctx) with - | Some profile -> return! json profile next ctx - | None -> return! Successful.NO_CONTENT next ctx - } - - // GET: /api/profile/get/[id] - let get citizenId : HttpHandler = authorize >=> fun next ctx -> task { - match! Profiles.findById (CitizenId citizenId) with - | Some profile -> return! json profile next ctx - | None -> return! Error.notFound next ctx - } - - // GET: /api/profile/view/[id] - let view citizenId : HttpHandler = authorize >=> fun next ctx -> task { - match! Profiles.findByIdForView (CitizenId citizenId) with - | Some profile -> return! json profile next ctx - | None -> return! Error.notFound next ctx - } - - // GET: /api/profile/count - let count : HttpHandler = authorize >=> fun next ctx -> task { - let! theCount = Profiles.count () - return! json {| Count = theCount |} next ctx - } - // PATCH: /api/profile/employment-found let employmentFound : HttpHandler = authorize >=> fun next ctx -> task { match! Profiles.findById (currentCitizenId ctx) with @@ -833,13 +800,20 @@ let allEndpoints = [ GET_HEAD [ route "/how-it-works" Home.howItWorks ] subRoute "/listing" [ GET_HEAD [ - route "s/mine" Listing.mine + route "s/mine" Listing.mine + routef "/%s/edit" Listing.edit + routef "/%O/expire" Listing.expire + routef "/%O/view" Listing.view + ] + POST [ + route "/expire" Listing.doExpire + route "/save" Listing.save ] ] GET_HEAD [ route "/privacy-policy" Home.privacyPolicy ] subRoute "/profile" [ GET_HEAD [ - routef "/%s/view" Profile.view + routef "/%O/view" Profile.view route "/edit" Profile.edit route "/search" Profile.search route "/seeking" Profile.seeking @@ -853,7 +827,6 @@ let allEndpoints = [ subRoute "/api" [ subRoute "/citizen" [ - GET_HEAD [ routef "/%O" CitizenApi.get ] PATCH [ route "/account" CitizenApi.account ] @@ -861,21 +834,11 @@ let allEndpoints = [ GET_HEAD [ route "/continents" Continent.all ] subRoute "/listing" [ GET_HEAD [ - routef "/%O" ListingApi.get route "/search" ListingApi.search - routef "/%O/view" ListingApi.view ] - PATCH [ routef "/%O" ListingApi.expire ] - POST [ route "s" ListingApi.add ] - PUT [ routef "/%O" ListingApi.update ] ] POST [ route "/markdown-preview" Api.markdownPreview ] subRoute "/profile" [ - GET_HEAD [ - route "" ProfileApi.current - route "/count" ProfileApi.count - routef "/%O" ProfileApi.get - ] PATCH [ route "/employment-found" ProfileApi.employmentFound ] ] subRoute "/success" [ diff --git a/src/JobsJobsJobs/Server/ViewModels.fs b/src/JobsJobsJobs/Server/ViewModels.fs index 6d6c794..1d16504 100644 --- a/src/JobsJobsJobs/Server/ViewModels.fs +++ b/src/JobsJobsJobs/Server/ViewModels.fs @@ -24,6 +24,52 @@ module SkillForm = { Skill.Description = form.Description; Notes = if form.Notes = "" then None else Some form.Notes } +/// The data required to add or edit a job listing +[] +type EditListingForm = + { /// The ID of the listing + Id : string + + /// The listing title + Title : string + + /// The ID of the continent on which this opportunity exists + ContinentId : string + + /// The region in which this opportunity exists + Region : string + + /// Whether this is a remote work opportunity + RemoteWork : bool + + /// The text of the job listing + Text : string + + /// The date by which this job listing is needed + NeededBy : string + } + +/// Support functions to support listings +module EditListingForm = + + open NodaTime.Text + + /// Create a listing form from an existing listing + let fromListing (listing : Listing) theId = + let neededBy = + match listing.NeededBy with + | Some dt -> (LocalDatePattern.CreateWithCurrentCulture "yyyy-MM-dd").Format dt + | None -> "" + { Id = theId + Title = listing.Title + ContinentId = ContinentId.toString listing.ContinentId + Region = listing.Region + RemoteWork = listing.IsRemote + Text = MarkdownString.toString listing.Text + NeededBy = neededBy + } + + /// The data required to update a profile [] type EditProfileViewModel = @@ -90,6 +136,20 @@ module EditProfileViewModel = } +/// The form submitted to expire a listing +[] +type ExpireListingForm = + { /// The ID of the listing to expire + Id : string + + /// Whether the job was filled from here + FromHere : bool + + /// The success story written by the user + SuccessStory : string + } + + /// View model for the log on page [] type LogOnViewModel = diff --git a/src/JobsJobsJobs/Server/Views/Common.fs b/src/JobsJobsJobs/Server/Views/Common.fs index 65e7af8..ac5c006 100644 --- a/src/JobsJobsJobs/Server/Views/Common.fs +++ b/src/JobsJobsJobs/Server/Views/Common.fs @@ -27,10 +27,12 @@ let textBox attrs name value fieldLabel isRequired = ] /// Create a checkbox that will post "true" if checked -let checkBox name isChecked checkLabel = +let checkBox attrs name isChecked checkLabel = div [ _class "form-check" ] [ - input [ _type "checkbox"; _id name; _name name; _class "form-check-input"; _value "true" - if isChecked then _checked ] + List.append attrs + [ _type "checkbox"; _id name; _name name; _class "form-check-input"; _value "true" + if isChecked then _checked ] + |> input label [ _class "form-check-label"; _for name ] [ str checkLabel ] ] @@ -49,7 +51,7 @@ let continentList attrs name (continents : Continent list) emptyLabel selectedVa /// Create a Markdown editor let markdownEditor attrs name value editorLabel = - div [ _class "col-12" ] [ + div [ _class "col-12"; _id $"{name}EditRow" ] [ nav [ _class "nav nav-pills pb-1" ] [ button [ _type "button"; _id $"{name}EditButton"; _class "btn btn-primary btn-sm rounded-pill" ] [ rawText "Markdown" @@ -93,17 +95,22 @@ let collapsePanel header content = let yesOrNo value = if value then "Yes" else "No" +/// Markdown as a raw HTML text node +let md2html value = + rawText (MarkdownString.toHtml value) + + open NodaTime open NodaTime.Text /// Generate a full date in the citizen's local time zone let fullDate (value : Instant) tz = (ZonedDateTimePattern.CreateWithCurrentCulture ("MMMM d, yyyy", DateTimeZoneProviders.Tzdb)) - .Format(value.InZone(DateTimeZoneProviders.Tzdb[tz])) + .Format(value.InZone DateTimeZoneProviders.Tzdb[tz]) /// Generate a full date/time in the citizen's local time let fullDateTime (value : Instant) tz = let dtPattern = ZonedDateTimePattern.CreateWithCurrentCulture ("MMMM d, yyyy h:mm", DateTimeZoneProviders.Tzdb) let amPmPattern = ZonedDateTimePattern.CreateWithCurrentCulture ("tt", DateTimeZoneProviders.Tzdb) - let tzValue = value.InZone(DateTimeZoneProviders.Tzdb[tz]) - $"{dtPattern.Format(tzValue)} {amPmPattern.Format(tzValue).ToLowerInvariant()}" + let tzValue = value.InZone DateTimeZoneProviders.Tzdb[tz] + $"{dtPattern.Format(tzValue)}{amPmPattern.Format(tzValue).ToLowerInvariant()}" diff --git a/src/JobsJobsJobs/Server/Views/Listing.fs b/src/JobsJobsJobs/Server/Views/Listing.fs index 59c028e..e94ce8c 100644 --- a/src/JobsJobsJobs/Server/Views/Listing.fs +++ b/src/JobsJobsJobs/Server/Views/Listing.fs @@ -9,6 +9,75 @@ open JobsJobsJobs.Domain.SharedTypes open JobsJobsJobs.ViewModels +/// Job listing edit page +let edit (m : EditListingForm) continents isNew csrf = + article [] [ + h3 [ _class "pb-3" ] [ rawText (if isNew then "Add a" else "Edit"); rawText " Job Listing" ] + form [ _class "row g-3"; _method "POST"; _action "/listing/save" ] [ + antiForgery csrf + input [ _type "hidden"; _name (nameof m.Id); _value m.Id ] + div [ _class "col-12 col-sm-10 col-md-8 col-lg-6" ] [ + textBox [ _type "text"; _maxlength "255"; _autofocus ] (nameof m.Title) m.Title "Title" true + div [ _class "form-text" ] [ + rawText "No need to put location here; it will always be show to seekers with continent and region" + ] + ] + 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" ] [ rawText "Country, state, geographic area, etc." ] + ] + div [ _class "col-12" ] [ + checkBox [] (nameof m.RemoteWork) m.RemoteWork "This opportunity is for remote work" + ] + markdownEditor [ _required ] (nameof m.Text) m.Text "Job Description" + div [ _class "col-12 col-md-4" ] [ + textBox [ _type "date" ] (nameof m.NeededBy) m.NeededBy "Needed By" false + ] + div [ _class "col-12" ] [ + button [ _type "submit"; _class "btn btn-primary" ] [ + i [ _class "mdi mdi-content-save-outline" ] []; rawText "  Save" + ] + ] + ] + ] + + +let expire (m : ExpireListingForm) (listing : Listing) csrf = + article [] [ + h3 [ _class "pb-3" ] [ rawText "Expire Job Listing ("; str listing.Title; rawText ")" ] + p [ _class "fst-italic" ] [ + rawText "Expiring this listing will remove it from search results. You will be able to see it via your " + rawText "“My Job Listings” page, but you will not be able to “un-expire” it." + ] + form [ _class "row g-3"; _method "POST"; _action "/listing/expire" ] [ + antiForgery csrf + input [ _type "hidden"; _name (nameof m.Id); _value m.Id ] + div [ _class "col-12" ] [ + checkBox [ _onclick "jjj.listing.toggleFromHere()" ] (nameof m.FromHere) m.FromHere + "This job was filled due to its listing here" + ] + div [ _class "col-12"; _id "successRow" ] [ + p [] [ + rawText "Consider telling your fellow citizens about your experience! Comments entered here will " + rawText "be visible to logged-on users here, but not to the general public." + ] + ] + markdownEditor [] (nameof m.SuccessStory) m.SuccessStory "Your Success Story" + div [ _class "col-12" ] [ + button [ _type "submit"; _class "btn btn-primary" ] [ + i [ _class "mdi mdi-text-box-remove-outline" ] []; rawText "  Expire Listing" + ] + ] + ] + script [] [ + rawText """document.addEventListener("DOMContentLoaded", function () { jjj.listing.toggleFromHere() })""" + ] + ] + + /// "My Listings" page let mine (listings : ListingForView list) tz = let active = listings |> List.filter (fun it -> not it.Listing.IsExpired) @@ -36,7 +105,7 @@ let mine (listings : ListingForView list) tz = a [ _href $"/listing/{listId}/expire" ] [ rawText "Expire" ] ] td [] [ str it.Listing.Title ] - td [] [ str it.Continent.Name; rawText " / "; str it.Listing.Region ] + td [] [ str it.ContinentName; rawText " / "; str it.Listing.Region ] td [] [ str (fullDateTime it.Listing.CreatedOn tz) ] td [] [ str (fullDateTime it.Listing.UpdatedOn tz) ] ]) @@ -61,3 +130,32 @@ let mine (listings : ListingForView list) tz = |> tbody [] ] ] + +open NodaTime.Text + +/// The job listing view page +let view (it : ListingForView) = + article [] [ + h3 [] [ + str it.Listing.Title + if it.Listing.IsExpired then + span [ _class "jjj-heading-label" ] [ + rawText "     "; span [ _class "badge bg-warning text-dark" ] [ rawText "Expired" ] + if defaultArg it.Listing.WasFilledHere false then + rawText "     " + span [ _class "badge bg-success" ] [ rawText "Filled via Jobs, Jobs, Jobs" ] + ] + ] + h4 [ _class "pb-3 text-muted" ] [ str it.ContinentName; rawText " / "; str it.Listing.Region ] + p [] [ + match it.Listing.NeededBy with + | Some needed -> + let format dt = + (LocalDatePattern.CreateWithCurrentCulture "MMMM d, yyyy").Format(dt).ToUpperInvariant () + strong [] [ em [] [ rawText "NEEDED BY "; str (format needed) ] ]; rawText " • " + | None -> () + rawText "Listed by "; str it.ListedBy //{{citizenName(citizen)}} + ] + hr [] + div [] [ md2html it.Listing.Text ] + ] diff --git a/src/JobsJobsJobs/Server/Views/Profile.fs b/src/JobsJobsJobs/Server/Views/Profile.fs index 5a5b78b..27d2f94 100644 --- a/src/JobsJobsJobs/Server/Views/Profile.fs +++ b/src/JobsJobsJobs/Server/Views/Profile.fs @@ -49,7 +49,7 @@ let edit (m : EditProfileViewModel) continents isNew citizenId csrf = form [ _class "row g-3"; _action "/profile/save"; _hxPost "/profile/save" ] [ antiForgery csrf 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 p [] [ em [] [ @@ -67,10 +67,10 @@ let edit (m : EditProfileViewModel) continents isNew citizenId csrf = ] 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 looking for 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 looking for full-time work" ] div [ _class "col-12" ] [ hr [] @@ -95,11 +95,11 @@ let edit (m : EditProfileViewModel) continents isNew citizenId csrf = ] markdownEditor [] (nameof m.Experience) (defaultArg m.Experience "") "Experience" div [ _class "col-12 col-xl-6" ] [ - checkBox (nameof m.IsPubliclySearchable) m.IsPubliclySearchable + 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 + checkBox [] (nameof m.IsPubliclyLinkable) m.IsPubliclyLinkable "Show my profile to anyone who has the direct link to it" ] div [ _class "col-12" ] [ @@ -312,7 +312,7 @@ let view (citizen : Citizen) (profile : Profile) (continentName : string) pageTi rawText (if profile.IsRemote then "I" else "Not i"); rawText "nterested in remote opportunities" ] hr [] - div [] [ rawText (MarkdownString.toHtml profile.Biography) ] + div [] [ md2html profile.Biography ] if not (List.isEmpty profile.Skills) then hr [] h4 [ _class "pb-3" ] [ rawText "Skills" ] @@ -327,10 +327,7 @@ let view (citizen : Citizen) (profile : Profile) (continentName : string) pageTi ]) |> ul [] match profile.Experience with - | Some exp -> - hr [] - h4 [ _class "pb-3" ] [ rawText "Experience / Employment History" ] - div [] [ rawText (MarkdownString.toHtml exp) ] + | Some exp -> hr []; h4 [ _class "pb-3" ] [ rawText "Experience / Employment History" ]; div [] [ md2html exp ] | None -> () if Option.isSome currentId && currentId.Value = citizen.Id then br []; br [] diff --git a/src/JobsJobsJobs/Server/wwwroot/script.js b/src/JobsJobsJobs/Server/wwwroot/script.js index 007cbff..2ca3b95 100644 --- a/src/JobsJobsJobs/Server/wwwroot/script.js +++ b/src/JobsJobsJobs/Server/wwwroot/script.js @@ -130,6 +130,23 @@ this.jjj = { editDiv.classList.add("jjj-shown") }, + /** + * Script for listing pages + */ + listing: { + + /** + * Show or hide the success story prompt based on whether a job was filled here + */ + toggleFromHere() { + /** @type {HTMLInputElement} */ + const isFromHere = document.getElementById("FromHere") + const display = isFromHere.checked ? "unset" : "none" + document.getElementById("successRow").style.display = display + document.getElementById("SuccessStoryEditRow").style.display = display + } + }, + /** * Script for profile pages */