Feature repo reorg, phase 2
Each feature in its own project
This commit is contained in:
69
src/JobsJobsJobs/Listings/Data.fs
Normal file
69
src/JobsJobsJobs/Listings/Data.fs
Normal 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
|
||||
94
src/JobsJobsJobs/Listings/Domain.fs
Normal file
94
src/JobsJobsJobs/Listings/Domain.fs
Normal 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
|
||||
}
|
||||
|
||||
163
src/JobsJobsJobs/Listings/Handlers.fs
Normal file
163
src/JobsJobsJobs/Listings/Handlers.fs
Normal 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 “{listing.Title}” 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
|
||||
]
|
||||
]
|
||||
]
|
||||
20
src/JobsJobsJobs/Listings/JobsJobsJobs.Listings.fsproj
Normal file
20
src/JobsJobsJobs/Listings/JobsJobsJobs.Listings.fsproj
Normal 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>
|
||||
228
src/JobsJobsJobs/Listings/Views.fs
Normal file
228
src/JobsJobsJobs/Listings/Views.fs
Normal 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 "“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 [] [
|
||||
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 “Search” 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 " "; span [ _class "badge bg-warning text-dark" ] [ txt "Expired" ]
|
||||
if defaultArg it.Listing.WasFilledHere false then
|
||||
txt " "; 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 " • "
|
||||
| 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 ]
|
||||
]
|
||||
Reference in New Issue
Block a user