Migrate job listing edit/view/expire
This commit is contained in:
parent
7001e75ac2
commit
9de255d25a
@ -43,24 +43,6 @@ const routes: Array<RouteRecordRaw> = [
|
||||
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",
|
||||
|
@ -1,157 +0,0 @@
|
||||
<template>
|
||||
<article>
|
||||
<h3 class="pb-3" v-if="isNew">Add a Job Listing</h3>
|
||||
<h3 class="pb-3" v-else>Edit Job Listing</h3>
|
||||
<load-data :load="retrieveData">
|
||||
<form class="row g-3">
|
||||
<div class="col-12 col-sm-10 col-md-8 col-lg-6">
|
||||
<div class="form-floating">
|
||||
<input type="text" id="title" :class="{ 'form-control': true, 'is-invalid': v$.title.$error }"
|
||||
maxlength="255" v-model="v$.title.$model" placeholder="The title for the job listing">
|
||||
<div class="invalid-feedback">Please enter a title for the job listing</div>
|
||||
<label class="jjj-required" for="title">Title</label>
|
||||
</div>
|
||||
<div class="form-text">
|
||||
No need to put location here; it will always be show to seekers with continent and region
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-sm-6 col-md-4">
|
||||
<continent-list v-model="v$.continentId.$model" :isInvalid="v$.continentId.$error"
|
||||
@touch="v$.continentId.$touch() || true" />
|
||||
</div>
|
||||
<div class="col-12 col-sm-6 col-md-8">
|
||||
<div class="form-floating">
|
||||
<input type="text" id="region" :class="{ 'form-control': true, 'is-invalid': v$.region.$error }"
|
||||
maxlength="255" v-model="v$.region.$model" placeholder="Country, state, geographic area, etc.">
|
||||
<div class="invalid-feedback">Please enter a region</div>
|
||||
<label class="jjj-required" for="region">Region</label>
|
||||
</div>
|
||||
<div class="form-text">Country, state, geographic area, etc.</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" id="isRemote" class="form-check-input" v-model="v$.remoteWork.$model">
|
||||
<label class="form-check-label" for="isRemote">This opportunity is for remote work</label>
|
||||
</div>
|
||||
</div>
|
||||
<markdown-editor id="description" label="Job Description" v-model:text="v$.text.$model"
|
||||
:isInvalid="v$.text.$error" />
|
||||
<div class="col-12 col-md-4">
|
||||
<div class="form-floating">
|
||||
<input type="date" id="neededBy" class="form-control" v-model="v$.neededBy.$model"
|
||||
placeholder="Date by which this position needs to be filled">
|
||||
<label for="neededBy">Needed By</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<p v-if="v$.$error" class="text-danger">Please correct the errors above</p>
|
||||
<button class="btn btn-primary" @click.prevent="saveListing(true)">
|
||||
<icon :icon="mdiContentSaveOutline" /> Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</load-data>
|
||||
<maybe-save :saveAction="doSave" :validator="v$" />
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive } from "vue"
|
||||
import { useRoute, useRouter } from "vue-router"
|
||||
import { mdiContentSaveOutline } from "@mdi/js"
|
||||
import useVuelidate from "@vuelidate/core"
|
||||
import { required } from "@vuelidate/validators"
|
||||
|
||||
import api, { Listing, ListingForm, LogOnSuccess } from "@/api"
|
||||
import { toastError, toastSuccess } from "@/components/layout/AppToaster.vue"
|
||||
import { Mutations, useStore } from "@/store"
|
||||
|
||||
import ContinentList from "@/components/ContinentList.vue"
|
||||
import LoadData from "@/components/LoadData.vue"
|
||||
import MarkdownEditor from "@/components/MarkdownEditor.vue"
|
||||
import MaybeSave from "@/components/MaybeSave.vue"
|
||||
|
||||
const store = useStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
/** The currently logged-on user */
|
||||
const user = store.state.user as LogOnSuccess
|
||||
|
||||
/** A new job listing */
|
||||
const newListing : Listing = {
|
||||
id: "",
|
||||
citizenId: user.citizenId,
|
||||
createdOn: "",
|
||||
title: "",
|
||||
continentId: "",
|
||||
region: "",
|
||||
remoteWork: false,
|
||||
isExpired: false,
|
||||
updatedOn: "",
|
||||
text: "",
|
||||
neededBy: undefined,
|
||||
wasFilledHere: undefined
|
||||
}
|
||||
|
||||
/** The backing object for the form */
|
||||
const listing = reactive(new ListingForm())
|
||||
|
||||
/** The ID of the listing requested */
|
||||
const id = route.params.id as string
|
||||
|
||||
/** Is this a new job listing? */
|
||||
const isNew = computed(() => id === "new")
|
||||
|
||||
/** Validation rules for the form */
|
||||
const rules = computed(() => ({
|
||||
id: { },
|
||||
title: { required },
|
||||
continentId: { required },
|
||||
region: { required },
|
||||
remoteWork: { },
|
||||
text: { required },
|
||||
neededBy: { }
|
||||
}))
|
||||
|
||||
/** Initialize form validation */
|
||||
const v$ = useVuelidate(rules, listing, { $lazy: true })
|
||||
|
||||
/** Retrieve the listing being edited (or set up the form for a new listing) */
|
||||
const retrieveData = async (errors : string[]) => {
|
||||
if (isNew.value) store.commit(Mutations.SetTitle, "Add a Job Listing")
|
||||
const listResult = isNew.value ? newListing : await api.listings.retreive(id, user)
|
||||
if (typeof listResult === "string") {
|
||||
errors.push(listResult)
|
||||
} else if (typeof listResult === "undefined") {
|
||||
errors.push("Job listing not found")
|
||||
} else {
|
||||
listing.id = listResult.id
|
||||
listing.title = listResult.title
|
||||
listing.continentId = listResult.continentId
|
||||
listing.region = listResult.region
|
||||
listing.remoteWork = listResult.remoteWork
|
||||
listing.text = listResult.text
|
||||
listing.neededBy = listResult.neededBy
|
||||
}
|
||||
}
|
||||
|
||||
/** Save the job listing */
|
||||
const saveListing = async (navigate : boolean) => {
|
||||
v$.value.$touch()
|
||||
if (v$.value.$error) return
|
||||
const apiFunc = isNew.value ? api.listings.add : api.listings.update
|
||||
if (listing.neededBy === "") listing.neededBy = undefined
|
||||
const result = await apiFunc(listing, user)
|
||||
if (typeof result === "string") {
|
||||
toastError(result, "saving job listing")
|
||||
} else {
|
||||
toastSuccess(`Job Listing ${isNew.value ? "Add" : "Updat"}ed Successfully`)
|
||||
v$.value.$reset()
|
||||
if (navigate) await router.push("/listings/mine")
|
||||
}
|
||||
}
|
||||
|
||||
/** Parameterless save function (used to save when navigating away) */
|
||||
const doSave = async () => await saveListing(false)
|
||||
</script>
|
@ -1,107 +0,0 @@
|
||||
<template>
|
||||
<article>
|
||||
<load-data :load="retrieveListing">
|
||||
<h3 class="pb-3">Expire Job Listing ({{listing.title}})</h3>
|
||||
<p class="fst-italic">
|
||||
Expiring this listing will remove it from search results. You will be able to see it via your “My Job
|
||||
Listings” page, but you will not be able to “un-expire” it.
|
||||
</p>
|
||||
<form class="row g-3">
|
||||
<div class="col-12">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" id="fromHere" class="form-check-input" v-model="v$.fromHere.$model">
|
||||
<label class="form-check-label" for="fromHere">This job was filled due to its listing here</label>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="expiration.fromHere">
|
||||
<div class="col-12">
|
||||
<p>
|
||||
Consider telling your fellow citizens about your experience! Comments entered here will be visible to
|
||||
logged-on users here, but not to the general public.
|
||||
</p>
|
||||
</div>
|
||||
<markdown-editor id="successStory" label="Your Success Story" v-model:text="v$.successStory.$model" />
|
||||
</template>
|
||||
<div class="col-12">
|
||||
<button class="btn btn-primary" @click.prevent="expireListing">
|
||||
<icon :icon="mdiTextBoxRemoveOutline" /> Expire Listing
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</load-data>
|
||||
<maybe-save :saveAction="doSave" :validator="v$" />
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, Ref, ref } from "vue"
|
||||
import { useRoute, useRouter } from "vue-router"
|
||||
import { mdiTextBoxRemoveOutline } from "@mdi/js"
|
||||
import useVuelidate from "@vuelidate/core"
|
||||
|
||||
import api, { Listing, ListingExpireForm, LogOnSuccess } from "@/api"
|
||||
import { toastError, toastSuccess } from "@/components/layout/AppToaster.vue"
|
||||
import { useStore } from "@/store"
|
||||
|
||||
import LoadData from "@/components/LoadData.vue"
|
||||
import MarkdownEditor from "@/components/MarkdownEditor.vue"
|
||||
import MaybeSave from "@/components/MaybeSave.vue"
|
||||
|
||||
const store = useStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
/** The currently logged-on user */
|
||||
const user = store.state.user as LogOnSuccess
|
||||
|
||||
/** The ID of the listing being expired */
|
||||
const listingId = route.params.id as string
|
||||
|
||||
/** The listing being expired */
|
||||
const listing : Ref<Listing | undefined> = ref(undefined)
|
||||
|
||||
/** The data needed to expire a job listing */
|
||||
const expiration = reactive(new ListingExpireForm())
|
||||
expiration.successStory = ""
|
||||
|
||||
/** The validation rules for the form */
|
||||
const rules = computed(() => ({
|
||||
fromHere: { },
|
||||
successStory: { }
|
||||
}))
|
||||
|
||||
/** Initialize form validation */
|
||||
const v$ = useVuelidate(rules, expiration, { $lazy: true })
|
||||
|
||||
/** Retrieve the job listing being expired */
|
||||
const retrieveListing = async (errors : string[]) => {
|
||||
const listingResp = await api.listings.retreive(listingId, user)
|
||||
if (typeof listingResp === "string") {
|
||||
errors.push(listingResp)
|
||||
} else if (typeof listingResp === "undefined") {
|
||||
errors.push("Listing not found")
|
||||
} else {
|
||||
listing.value = listingResp
|
||||
}
|
||||
}
|
||||
|
||||
/** Expire the listing */
|
||||
const expireListing = async (navigate : boolean) => {
|
||||
v$.value.$touch()
|
||||
if (v$.value.$error) return
|
||||
if ((expiration.successStory ?? "").trim() === "") expiration.successStory = undefined
|
||||
const expireResult = await api.listings.expire(listingId, expiration, user)
|
||||
if (typeof expireResult === "string") {
|
||||
toastError(expireResult, "expiring job listing")
|
||||
} else {
|
||||
toastSuccess(`Job Listing Expired${expiration.successStory ? " and Success Story Recorded" : ""} Successfully`)
|
||||
v$.value.$reset()
|
||||
if (navigate) {
|
||||
await router.push("/listings/mine")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** No-parameter save function (used for save-on-navigate) */
|
||||
const doSave = async () => await expireListing(false)
|
||||
</script>
|
@ -1,78 +0,0 @@
|
||||
<template>
|
||||
<article>
|
||||
<load-data :load="retrieveListing">
|
||||
<h3>
|
||||
{{it.listing.title}}
|
||||
<span v-if="it.listing.isExpired" class="jjj-heading-label">
|
||||
<span class="badge bg-warning text-dark">Expired</span>
|
||||
<template v-if="it.listing.wasFilledHere">
|
||||
<span class="badge bg-success">Filled via Jobs, Jobs, Jobs</span>
|
||||
</template>
|
||||
</span>
|
||||
</h3>
|
||||
<h4 class="pb-3 text-muted">{{it.continent.name}} / {{it.listing.region}}</h4>
|
||||
<p>
|
||||
<template v-if="it.listing.neededBy">
|
||||
<strong><em>NEEDED BY {{neededBy(it.listing.neededBy)}}</em></strong> •
|
||||
</template>
|
||||
Listed by <!-- a :href="profileUrl" target="_blank" -->{{citizenName(citizen)}}<!-- /a -->
|
||||
</p>
|
||||
<hr>
|
||||
<div v-html="details" />
|
||||
</load-data>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, Ref } from "vue"
|
||||
import { useRoute } from "vue-router"
|
||||
|
||||
import { formatNeededBy } from "./"
|
||||
import api, { Citizen, ListingForView, LogOnSuccess } from "@/api"
|
||||
import { citizenName } from "@/App.vue"
|
||||
import { toHtml } from "@/markdown"
|
||||
import { Mutations, useStore } from "@/store"
|
||||
import LoadData from "@/components/LoadData.vue"
|
||||
|
||||
const store = useStore()
|
||||
const route = useRoute()
|
||||
|
||||
/** The currently logged-on user */
|
||||
const user = store.state.user as LogOnSuccess
|
||||
|
||||
/** The requested job listing */
|
||||
const it : Ref<ListingForView | undefined> = ref(undefined)
|
||||
|
||||
/** The citizen who posted this job listing */
|
||||
const citizen : Ref<Citizen | undefined> = ref(undefined)
|
||||
|
||||
/** Retrieve the job listing and supporting data */
|
||||
const retrieveListing = async (errors : string[]) => {
|
||||
const listingResp = await api.listings.retreiveForView(route.params.id as string, user)
|
||||
if (typeof listingResp === "string") {
|
||||
errors.push(listingResp)
|
||||
} else if (typeof listingResp === "undefined") {
|
||||
errors.push("Job Listing not found")
|
||||
} else {
|
||||
it.value = listingResp
|
||||
store.commit(Mutations.SetTitle, `${listingResp.listing.title} | Job Listing`)
|
||||
const citizenResp = await api.citizen.retrieve(listingResp.listing.citizenId, user)
|
||||
if (typeof citizenResp === "string") {
|
||||
errors.push(citizenResp)
|
||||
} else if (typeof citizenResp === "undefined") {
|
||||
errors.push("Listing Citizen not found (this should not happen)")
|
||||
} else {
|
||||
citizen.value = citizenResp
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The HTML details of the job listing */
|
||||
const details = computed(() => toHtml(it.value?.listing.text ?? ""))
|
||||
|
||||
/** The Mastodon profile URL for the citizen who posted this job listing */
|
||||
// const profileUrl = computed(() => citizen.value ? citizen.value.profileUrl : "")
|
||||
|
||||
/** The needed by date, formatted in SHOUTING MODE */
|
||||
const neededBy = (nb : string) => formatNeededBy(nb).toUpperCase()
|
||||
</script>
|
@ -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<Listing> row; Continent = toDocumentFrom<Continent> "cont_data" row }
|
||||
{ Listing = toDocument<Listing> row
|
||||
ContinentName = row.string "continent_name"
|
||||
ListedBy = Citizen.name (toDocumentFrom<Citizen> "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
|
||||
|
@ -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<AuthOptions> with
|
||||
override this.Value = this
|
||||
|
||||
|
||||
/// The various ways profiles can be searched
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type ProfileSearchForm =
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
||||
[<AutoOpen>]
|
||||
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<ILoggerFactory> ()
|
||||
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<IConfiguration> ()
|
||||
|
||||
/// Get the authorization configuration from the request context
|
||||
let authConfig (ctx : HttpContext) = (ctx.GetService<IOptions<AuthOptions>> ()).Value
|
||||
|
||||
/// Get the logger factory from the request context
|
||||
let logger (ctx : HttpContext) = ctx.GetService<ILoggerFactory> ()
|
||||
|
||||
@ -418,13 +396,6 @@ module Citizen =
|
||||
[<RequireQualifiedAccess>]
|
||||
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<AccountProfileForm> ()
|
||||
@ -490,62 +461,100 @@ module Home =
|
||||
[<RequireQualifiedAccess>]
|
||||
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<ExpireListingForm> ()
|
||||
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
|
||||
[<RequireQualifiedAccess>]
|
||||
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<ListingForm> ()
|
||||
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<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! 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<ListingForm> ()
|
||||
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<ListingExpireForm> ()
|
||||
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
|
||||
[<RequireQualifiedAccess>]
|
||||
module ListingApi =
|
||||
|
||||
// GET: /api/listing/search
|
||||
let search : HttpHandler = authorize >=> fun next ctx -> task {
|
||||
let search = ctx.BindQueryString<ListingSearch> ()
|
||||
@ -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 =
|
||||
[<RequireQualifiedAccess>]
|
||||
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" [
|
||||
|
@ -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
|
||||
[<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 data required to update a profile
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type EditProfileViewModel =
|
||||
@ -90,6 +136,20 @@ module EditProfileViewModel =
|
||||
}
|
||||
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
||||
|
||||
/// View model for the log on page
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type LogOnViewModel =
|
||||
|
@ -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()}"
|
||||
|
@ -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 //<!-- a :href="profileUrl" target="_blank" -->{{citizenName(citizen)}}<!-- /a -->
|
||||
]
|
||||
hr []
|
||||
div [] [ md2html it.Listing.Text ]
|
||||
]
|
||||
|
@ -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 []
|
||||
|
@ -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
|
||||
*/
|
||||
|
Loading…
Reference in New Issue
Block a user