Feature repo reorg, phase 2

Each feature in its own project
This commit is contained in:
2023-01-19 21:49:13 -05:00
parent 6eaea09f31
commit c7a535626d
33 changed files with 177 additions and 50 deletions

View File

@@ -0,0 +1,69 @@
module JobsJobsJobs.Listings.Data
open JobsJobsJobs.Common.Data
open JobsJobsJobs.Domain
open JobsJobsJobs.Listings.Domain
open Npgsql.FSharp
/// The SQL to select a listing view
let viewSql =
$"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.Citizen} u ON u.id = l.data ->> 'citizenId'"
/// Map a result for a listing view
let private toListingForView row =
{ Listing = toDocument<Listing> row
ContinentName = row.string "continent_name"
Citizen = toDocumentFrom<Citizen> "cit_data" row
}
/// Find all job listings posted by the given citizen
let findByCitizen citizenId =
dataSource ()
|> Sql.query $"{viewSql} WHERE l.data ->> 'citizenId' = @citizenId AND l.data ->> 'isLegacy' = 'false'"
|> Sql.parameters [ "@citizenId", Sql.string (CitizenId.toString citizenId) ]
|> Sql.executeAsync toListingForView
/// Find a listing by its ID
let findById listingId = backgroundTask {
match! dataSource () |> getDocument<Listing> Table.Listing (ListingId.toString listingId) with
| Some listing when not listing.IsLegacy -> return Some listing
| Some _
| None -> return None
}
/// Find a listing by its ID for viewing (includes continent information)
let findByIdForView listingId = backgroundTask {
let! tryListing =
dataSource ()
|> 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
}
/// Save a listing
let save (listing : Listing) =
dataSource () |> saveDocument Table.Listing (ListingId.toString listing.Id) <| mkDoc listing
/// Search job listings
let search (search : ListingSearchForm) =
let searches = [
if search.ContinentId <> "" then
"l.data ->> 'continentId' = @continentId", [ "@continentId", Sql.string search.ContinentId ]
if search.Region <> "" then
"l.data ->> 'region' ILIKE @region", [ "@region", like search.Region ]
if search.RemoteWork <> "" then
"l.data ->> 'isRemote' = @remote", [ "@remote", jsonBool (search.RemoteWork = "yes") ]
if search.Text <> "" then
"l.data ->> 'text' ILIKE @text", [ "@text", like search.Text ]
]
dataSource ()
|> Sql.query $"
{viewSql}
WHERE l.data ->> 'isExpired' = 'false' AND l.data ->> 'isLegacy' = 'false'
{searchSql searches}"
|> Sql.parameters (searches |> List.collect snd)
|> Sql.executeAsync toListingForView

View File

@@ -0,0 +1,94 @@
module JobsJobsJobs.Listings.Domain
open JobsJobsJobs.Domain
/// The data required to add or edit a job listing
[<CLIMutable; NoComparison; NoEquality>]
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 form submitted to expire a listing
[<CLIMutable; NoComparison; NoEquality>]
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
}
/// The data needed to display a listing
[<NoComparison; NoEquality>]
type ListingForView =
{ /// The listing itself
Listing : Listing
/// The name of the continent for the listing
ContinentName : string
/// The citizen who owns the listing
Citizen : Citizen
}
/// The various ways job listings can be searched
[<CLIMutable; NoComparison; NoEquality>]
type ListingSearchForm =
{ /// Retrieve job listings for this continent
ContinentId : string
/// Text for a search within a region
Region : string
/// Whether to retrieve job listings for remote work
RemoteWork : string
/// Text for a search with the job listing description
Text : string
}

View File

@@ -0,0 +1,163 @@
module JobsJobsJobs.Listings.Handlers
open System
open Giraffe
open JobsJobsJobs
open JobsJobsJobs.Common.Handlers
open JobsJobsJobs.Domain
open JobsJobsJobs.Listings.Domain
open NodaTime
/// 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! Data.findById (ListingId.ofString listId)
}
match theListing with
| Some listing when listing.CitizenId = citizenId ->
let! continents = Common.Data.Continents.all ()
return!
Views.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! Data.findById (ListingId listingId) with
| Some listing when listing.CitizenId = currentCitizenId ctx ->
if listing.IsExpired then
do! addError $"The listing &ldquo;{listing.Title}&rdquo; is already expired" ctx
return! redirectToGet "/listings/mine" next ctx
else
let form = { Id = ListingId.toString listing.Id; FromHere = false; SuccessStory = "" }
return! Views.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<ExpireListingForm> ()
match! Data.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! Data.save
{ listing with
IsExpired = true
WasFilledHere = Some form.FromHere
UpdatedOn = now
}
if form.SuccessStory <> "" then
do! SuccessStories.Data.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 = Data.findByCitizen (currentCitizenId ctx)
return! Views.mine listings (timeZone ctx) |> render "My Job Listings" next ctx
}
// POST: /listing/save
let save : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task {
let citizenId = currentCitizenId ctx
let now = now ctx
let! form = ctx.BindFormAsync<EditListingForm> ()
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! Data.findById (ListingId.ofString form.Id)
}
match theListing with
| Some listing when listing.CitizenId = citizenId ->
do! Data.save
{ listing with
Title = form.Title
ContinentId = ContinentId.ofString form.ContinentId
Region = form.Region
IsRemote = form.RemoteWork
Text = Text form.Text
NeededBy = noneIfEmpty form.NeededBy |> Option.map parseDate
UpdatedOn = now
}
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
}
// GET: /help-wanted
let search : HttpHandler = requireUser >=> fun next ctx -> task {
let! continents = Common.Data.Continents.all ()
let form =
match ctx.TryBindQueryString<ListingSearchForm> () with
| Ok f -> f
| Error _ -> { ContinentId = ""; Region = ""; RemoteWork = ""; Text = "" }
let! results = task {
if string ctx.Request.Query["searched"] = "true" then
let! it = Data.search form
return Some it
else return None
}
return! Views.search form continents results |> render "Help Wanted" next ctx
}
// GET: /listing/[id]/view
let view listingId : HttpHandler = requireUser >=> fun next ctx -> task {
match! Data.findByIdForView (ListingId listingId) with
| Some listing -> return! Views.view listing |> render $"{listing.Listing.Title} | Job Listing" next ctx
| None -> return! Error.notFound next ctx
}
open Giraffe.EndpointRouting
/// All endpoints for this feature
let endpoints = [
GET_HEAD [ route "/help-wanted" search ]
subRoute "/listing" [
GET_HEAD [
route "s/mine" mine
routef "/%s/edit" edit
routef "/%O/expire" expire
routef "/%O/view" view
]
POST [
route "/expire" doExpire
route "/save" save
]
]
]

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
<ItemGroup>
<Compile Include="Domain.fs" />
<Compile Include="Data.fs" />
<Compile Include="Views.fs" />
<Compile Include="Handlers.fs" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Common\JobsJobsJobs.Common.fsproj" />
<ProjectReference Include="..\SuccessStories\JobsJobsJobs.SuccessStories.fsproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,228 @@
/// Views for /profile URLs
module JobsJobsJobs.Listings.Views
open Giraffe.ViewEngine
open JobsJobsJobs.Common.Views
open JobsJobsJobs.Domain
open JobsJobsJobs.Listings.Domain
/// Job listing edit page
let edit (m : EditListingForm) continents isNew csrf =
pageWithTitle $"""{if isNew then "Add a" else "Edit"} 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" ] [
txt "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" ] [ txt "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" ] [ submitButton "content-save-outline" "Save" ]
]
]
open System.Net
/// Page to expire a job listing
let expire (m : ExpireListingForm) (listing : Listing) csrf =
pageWithTitle $"Expire Job Listing ({WebUtility.HtmlEncode listing.Title})" [
p [ _class "fst-italic" ] [
txt "Expiring this listing will remove it from search results. You will be able to see it via your "
txt "&ldquo;My Job Listings&rdquo; page, but you will not be able to &ldquo;un-expire&rdquo; 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 [] [
txt "Consider telling your fellow citizens about your experience! Comments entered here will be "
txt "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" ] [ submitButton "text-box-remove-outline" "Expire Listing" ]
]
jsOnLoad "jjj.listing.toggleFromHere()"
]
/// "My Listings" page
let mine (listings : ListingForView list) tz =
let active = listings |> List.filter (fun it -> not it.Listing.IsExpired)
let expired = listings |> List.filter (fun it -> it.Listing.IsExpired)
pageWithTitle "My Job Listings" [
p [] [ a [ _href "/listing/new/edit"; _class "btn btn-outline-primary" ] [ txt "Add a New Job Listing" ] ]
if not (List.isEmpty expired) then h4 [ _class "pb-2" ] [ txt "Active Job Listings" ]
if List.isEmpty active then p [ _class "pb-3 fst-italic" ] [ txt "You have no active job listings" ]
else
table [ _class "pb-3 table table-sm table-hover pt-3" ] [
thead [] [
[ "Action"; "Title"; "Continent / Region"; "Created"; "Updated" ]
|> List.map (fun it -> th [ _scope "col" ] [ txt it ])
|> tr []
]
active
|> List.map (fun it ->
let listId = ListingId.toString it.Listing.Id
tr [] [
td [] [
a [ _href $"/listing/{listId}/edit" ] [ txt "Edit" ]; txt " ~ "
a [ _href $"/listing/{listId}/view" ] [ txt "View" ]; txt " ~ "
a [ _href $"/listing/{listId}/expire" ] [ txt "Expire" ]
]
td [] [ str it.Listing.Title ]
td [] [ str it.ContinentName; rawText " / "; str it.Listing.Region ]
td [] [ str (fullDateTime it.Listing.CreatedOn tz) ]
td [] [ str (fullDateTime it.Listing.UpdatedOn tz) ]
])
|> tbody []
]
if not (List.isEmpty expired) then
h4 [ _class "pb-2" ] [ txt "Expired Job Listings" ]
table [ _class "table table-sm table-hover pt-3" ] [
thead [] [
[ "Action"; "Title"; "Filled Here?"; "Expired" ]
|> List.map (fun it -> th [ _scope "col" ] [ txt it ])
|> tr []
]
expired
|> List.map (fun it ->
tr [] [
td [] [ a [ _href $"/listing/{ListingId.toString it.Listing.Id}/view" ] [ txt "View" ] ]
td [] [ str it.Listing.Title ]
td [] [ str (yesOrNo (defaultArg it.Listing.WasFilledHere false)) ]
td [] [ str (fullDateTime it.Listing.UpdatedOn tz) ]
])
|> tbody []
]
]
open NodaTime.Text
/// Format the needed by date
let private neededBy dt =
(LocalDatePattern.CreateWithCurrentCulture "MMMM d, yyyy").Format dt
let search (m : ListingSearchForm) continents (listings : ListingForView list option) =
pageWithTitle "Help Wanted" [
if Option.isNone listings then
p [] [
txt "Enter relevant criteria to find results, or just click &ldquo;Search&rdquo; to see all active job "
txt "listings."
]
collapsePanel "Search Criteria" [
form [ _class "container"; _method "GET"; _action "/help-wanted" ] [
input [ _type "hidden"; _name "searched"; _value "true" ]
div [ _class "row" ] [
div [ _class "col-12 col-sm-6 col-md-4 col-lg-3" ] [
continentList [] "ContinentId" continents (Some "Any") m.ContinentId false
]
div [ _class "col-12 col-sm-6 col-md-4 col-lg-3" ] [
textBox [ _maxlength "1000" ] (nameof m.Region) m.Region "Region" false
div [ _class "form-text" ] [ txt "(free-form text)" ]
]
div [ _class "col-12 col-sm-6 col-offset-md-2 col-lg-3 col-offset-lg-0" ] [
label [ _class "jjj-label" ] [ txt "Seeking Remote Work?" ]; br []
div [ _class "form-check form-check-inline" ] [
input [ _type "radio"; _id "remoteNull"; _name (nameof m.RemoteWork); _value ""
_class "form-check-input"; if m.RemoteWork = "" then _checked ]
label [ _class "form-check-label"; _for "remoteNull" ] [ txt "No Selection" ]
]
div [ _class "form-check form-check-inline" ] [
input [ _type "radio"; _id "remoteYes"; _name (nameof m.RemoteWork); _value "yes"
_class "form-check-input"; if m.RemoteWork = "yes" then _checked ]
label [ _class "form-check-label"; _for "remoteYes" ] [ txt "Yes" ]
]
div [ _class "form-check form-check-inline" ] [
input [ _type "radio"; _id "remoteNo"; _name (nameof m.RemoteWork); _value "no"
_class "form-check-input"; if m.RemoteWork = "no" then _checked ]
label [ _class "form-check-label"; _for "remoteNo" ] [ txt "No" ]
]
]
div [ _class "col-12 col-sm-6 col-lg-3" ] [
textBox [ _maxlength "1000" ] (nameof m.Text) m.Text "Job Listing Text" false
div [ _class "form-text" ] [ txt "(free-form text)" ]
]
]
div [ _class "row" ] [
div [ _class "col" ] [
br []
button [ _type "submit"; _class "btn btn-outline-primary" ] [ txt "Search" ]
]
]
]
]
match listings with
| Some r when List.isEmpty r ->
p [ _class "pt-3" ] [ txt "No job listings found for the specified criteria" ]
| Some r ->
table [ _class "table table-sm table-hover pt-3" ] [
thead [] [
tr [] [
th [ _scope "col" ] [ txt "Listing" ]
th [ _scope "col" ] [ txt "Title" ]
th [ _scope "col" ] [ txt "Location" ]
th [ _scope "col"; _class "text-center" ] [ txt "Remote?" ]
th [ _scope "col"; _class "text-center" ] [ txt "Needed By" ]
]
]
r |> List.map (fun it ->
tr [] [
td [] [ a [ _href $"/listing/{ListingId.toString it.Listing.Id}/view" ] [ txt "View" ] ]
td [] [ str it.Listing.Title ]
td [] [ str it.ContinentName; rawText " / "; str it.Listing.Region ]
td [ _class "text-center" ] [ str (yesOrNo it.Listing.IsRemote) ]
td [ _class "text-center" ] [
match it.Listing.NeededBy with Some needed -> str (neededBy needed) | None -> txt "N/A"
]
])
|> tbody []
]
| None -> ()
]
/// 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" ] [
txt " &nbsp; &nbsp; "; span [ _class "badge bg-warning text-dark" ] [ txt "Expired" ]
if defaultArg it.Listing.WasFilledHere false then
txt " &nbsp; &nbsp; "; span [ _class "badge bg-success" ] [ txt "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 ->
strong [] [ em [] [ txt "NEEDED BY "; str ((neededBy needed).ToUpperInvariant ()) ] ]; txt " &bull; "
| None -> ()
txt "Listed by "; strong [ _class "me-4" ] [ str (Citizen.name it.Citizen) ]; br []
span [ _class "ms-3" ] []; yield! contactInfo it.Citizen false
]
hr []
div [] [ md2html it.Listing.Text ]
]