Finish listing search; add shell of listing view (#17)
Also updated deps on the app side
This commit is contained in:
parent
69e386d91b
commit
f25fabe064
|
@ -17,6 +17,7 @@ let configureApp (app : IApplicationBuilder) =
|
|||
.UseRouting()
|
||||
.UseAuthentication()
|
||||
.UseAuthorization()
|
||||
.UseGiraffeErrorHandler(Handlers.Error.unexpectedError)
|
||||
.UseEndpoints(fun e ->
|
||||
e.MapGiraffeEndpoints Handlers.allEndpoints
|
||||
e.MapFallbackToFile "index.html" |> ignore)
|
||||
|
|
|
@ -191,7 +191,7 @@ let withReconn (conn : IConnection) =
|
|||
/// Sanitize user input, and create a "contains" pattern for use with RethinkDB queries
|
||||
let regexContains (it : string) =
|
||||
System.Text.RegularExpressions.Regex.Escape it
|
||||
|> sprintf "(?i).*%s.*"
|
||||
|> sprintf "(?i)%s"
|
||||
|
||||
open JobsJobsJobs.Domain
|
||||
open JobsJobsJobs.Domain.SharedTypes
|
||||
|
@ -435,8 +435,7 @@ module Listing =
|
|||
r.Table(Table.Listing)
|
||||
.GetAll(citizenId).OptArg("index", nameof citizenId)
|
||||
.EqJoin("continentId", r.Table(Table.Continent))
|
||||
.Merge(ReqlFunction1(fun it -> upcast r.HashMap("listing", it.G("left")).With("continent", it.G("right"))))
|
||||
.Pluck("listing", "continent")
|
||||
.Map(ReqlFunction1(fun it -> upcast r.HashMap("listing", it.G("left")).With("continent", it.G("right"))))
|
||||
.RunResultAsync<ListingForView list> conn)
|
||||
|
||||
/// Find a listing by its ID
|
||||
|
@ -449,6 +448,18 @@ module Listing =
|
|||
return toOption listing
|
||||
})
|
||||
|
||||
/// Find a listing by its ID for viewing (includes continent information)
|
||||
let findByIdForView (listingId : ListingId) conn =
|
||||
withReconn(conn).ExecuteAsync(fun () -> task {
|
||||
let! listing =
|
||||
r.Table(Table.Listing)
|
||||
.Filter(r.HashMap("id", listingId))
|
||||
.EqJoin("continentId", r.Table(Table.Continent))
|
||||
.Map(ReqlFunction1(fun it -> upcast r.HashMap("listing", it.G("left")).With("continent", it.G("right"))))
|
||||
.RunResultAsync<ListingForView list> conn
|
||||
return List.tryHead listing
|
||||
})
|
||||
|
||||
/// Add a listing
|
||||
let add (listing : Listing) conn =
|
||||
withReconn(conn).ExecuteAsync(fun () -> task {
|
||||
|
@ -482,24 +493,26 @@ module Listing =
|
|||
match srch.region with
|
||||
| Some rgn ->
|
||||
yield (fun q ->
|
||||
q.Filter(ReqlFunction1(fun s -> upcast s.G("region").Match(regexContains rgn))) :> ReqlExpr)
|
||||
q.Filter(ReqlFunction1(fun it ->
|
||||
upcast it.G(nameof srch.region).Match(regexContains rgn))) :> ReqlExpr)
|
||||
| None -> ()
|
||||
match srch.remoteWork with
|
||||
| "" -> ()
|
||||
| _ -> yield (fun q -> q.Filter(r.HashMap(nameof srch.remoteWork, srch.remoteWork = "yes")) :> ReqlExpr)
|
||||
| _ ->
|
||||
yield (fun q -> q.Filter(r.HashMap(nameof srch.remoteWork, srch.remoteWork = "yes")) :> ReqlExpr)
|
||||
match srch.text with
|
||||
| Some text ->
|
||||
yield (fun q ->
|
||||
q.Filter(ReqlFunction1(fun it -> upcast it.G("text").Match(regexContains text))) :> ReqlExpr)
|
||||
q.Filter(ReqlFunction1(fun it ->
|
||||
upcast it.G(nameof srch.text).Match(regexContains text))) :> ReqlExpr)
|
||||
| None -> ()
|
||||
}
|
||||
|> Seq.toList
|
||||
|> List.fold
|
||||
(fun q f -> f q)
|
||||
(r.Table(Table.Listing)
|
||||
.EqJoin("continentId", r.Table(Table.Continent)) :> ReqlExpr))
|
||||
.Merge(ReqlFunction1(fun it -> upcast r.HashMap("listing", it.G("left")).With("continent", it.G("right"))))
|
||||
.Pluck("listing", "continent")
|
||||
(r.Table(Table.Listing) :> ReqlExpr))
|
||||
.EqJoin("continentId", r.Table(Table.Continent))
|
||||
.Map(ReqlFunction1(fun it -> upcast r.HashMap("listing", it.G("left")).With("continent", it.G("right"))))
|
||||
.RunResultAsync<ListingForView list> conn)
|
||||
|
||||
|
||||
|
|
|
@ -21,17 +21,22 @@ module Error =
|
|||
|
||||
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 fac = ctx.GetService<ILoggerFactory>()
|
||||
let log = fac.CreateLogger("Handler")
|
||||
match [ "GET"; "HEAD" ] |> List.contains ctx.Request.Method with
|
||||
| true ->
|
||||
| true when vueUrls |> List.exists (fun url -> ctx.Request.Path.ToString().StartsWith url) ->
|
||||
log.LogInformation "Returning Vue app"
|
||||
// TODO: check for valid URL prefixes
|
||||
return! Vue.app next ctx
|
||||
| false ->
|
||||
| _ ->
|
||||
log.LogInformation "Returning 404"
|
||||
return! RequestErrors.NOT_FOUND $"The URL {string ctx.Request.Path} was not recognized as a valid URL" next
|
||||
ctx
|
||||
|
@ -41,6 +46,11 @@ module Error =
|
|||
let notAuthorized : HttpHandler =
|
||||
setStatusCode 403 >=> fun _ _ -> Task.FromResult<HttpContext option> None
|
||||
|
||||
/// Handler to log 500s and return a message we can display in the application
|
||||
let unexpectedError (ex: exn) (log : ILogger) =
|
||||
log.LogError(ex, "An unexpected error occurred")
|
||||
clearResponse >=> ServerErrors.INTERNAL_ERROR ex.Message
|
||||
|
||||
|
||||
/// Helper functions
|
||||
[<AutoOpen>]
|
||||
|
@ -193,6 +203,15 @@ module Listing =
|
|||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
// GET: /api/listing/view/[id]
|
||||
let view listingId : HttpHandler =
|
||||
authorize
|
||||
>=> fun next ctx -> task {
|
||||
match! Data.Listing.findByIdForView (ListingId listingId) (conn ctx) with
|
||||
| Some listing -> return! json listing next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
// POST: /listings
|
||||
let add : HttpHandler =
|
||||
authorize
|
||||
|
@ -451,9 +470,10 @@ let allEndpoints = [
|
|||
GET_HEAD [ route "/continent/all" Continent.all ]
|
||||
subRoute "/listing" [
|
||||
GET_HEAD [
|
||||
routef "/%O" Listing.get
|
||||
route "/search" Listing.search
|
||||
route "s/mine" Listing.mine
|
||||
routef "/%O" Listing.get
|
||||
route "/search" Listing.search
|
||||
routef "/view/%O" Listing.view
|
||||
route "s/mine" Listing.mine
|
||||
]
|
||||
POST [
|
||||
route "s" Listing.add
|
||||
|
|
956
src/JobsJobsJobs/App/package-lock.json
generated
956
src/JobsJobsJobs/App/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
|
@ -10,39 +10,39 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@mdi/font": "5.9.55",
|
||||
"@vuelidate/core": "^2.0.0-alpha.22",
|
||||
"@vuelidate/validators": "^2.0.0-alpha.19",
|
||||
"@vuelidate/core": "^2.0.0-alpha.24",
|
||||
"@vuelidate/validators": "^2.0.0-alpha.21",
|
||||
"bootstrap": "^5.1.0",
|
||||
"core-js": "^3.6.5",
|
||||
"core-js": "^3.16.1",
|
||||
"date-fns": "^2.23.0",
|
||||
"date-fns-tz": "^1.1.4",
|
||||
"date-fns-tz": "^1.1.6",
|
||||
"marked": "^2.1.3",
|
||||
"vue": "^3.0.0",
|
||||
"vue-router": "^4.0.0-0",
|
||||
"vue": "^3.2.2",
|
||||
"vue-router": "^4.0.11",
|
||||
"vuex": "^4.0.0-0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bootstrap": "^5.1.0",
|
||||
"@types/bootstrap": "^5.1.1",
|
||||
"@types/marked": "^2.0.4",
|
||||
"@typescript-eslint/eslint-plugin": "^4.18.0",
|
||||
"@typescript-eslint/parser": "^4.18.0",
|
||||
"@typescript-eslint/eslint-plugin": "^4.29.1",
|
||||
"@typescript-eslint/parser": "^4.29.1",
|
||||
"@vue/cli-plugin-babel": "~4.5.0",
|
||||
"@vue/cli-plugin-eslint": "~4.5.0",
|
||||
"@vue/cli-plugin-router": "~4.5.0",
|
||||
"@vue/cli-plugin-typescript": "~4.5.0",
|
||||
"@vue/cli-plugin-vuex": "~4.5.0",
|
||||
"@vue/cli-service": "~4.5.0",
|
||||
"@vue/compiler-sfc": "^3.0.0",
|
||||
"@vue/eslint-config-standard": "^5.1.2",
|
||||
"@vue/compiler-sfc": "^3.2.2",
|
||||
"@vue/eslint-config-standard": "^6.1.0",
|
||||
"@vue/eslint-config-typescript": "^7.0.0",
|
||||
"eslint": "^6.7.2",
|
||||
"eslint-plugin-import": "^2.20.2",
|
||||
"eslint": "^7.32.0",
|
||||
"eslint-plugin-import": "^2.24.0",
|
||||
"eslint-plugin-node": "^11.1.0",
|
||||
"eslint-plugin-promise": "^4.2.1",
|
||||
"eslint-plugin-standard": "^4.0.0",
|
||||
"eslint-plugin-vue": "^7.0.0",
|
||||
"sass": "~1.32.0",
|
||||
"eslint-plugin-promise": "^5.0.0",
|
||||
"eslint-plugin-standard": "^5.0.0",
|
||||
"eslint-plugin-vue": "^7.16.0",
|
||||
"sass": "~1.37.0",
|
||||
"sass-loader": "^10.0.0",
|
||||
"typescript": "~4.1.5"
|
||||
"typescript": "~4.3.5"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,6 +18,7 @@
|
|||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
import { Citizen } from './api'
|
||||
import AppFooter from './components/layout/AppFooter.vue'
|
||||
import AppNav from './components/layout/AppNav.vue'
|
||||
import AppToaster from './components/layout/AppToaster.vue'
|
||||
|
@ -45,6 +46,16 @@ export default defineComponent({
|
|||
export function yesOrNo (cond : boolean) : string {
|
||||
return cond ? 'Yes' : 'No'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the display name for a citizen (the first available among real, display, or NAS handle)
|
||||
*
|
||||
* @param cit The citizen
|
||||
* @returns The citizen's display name
|
||||
*/
|
||||
export function citizenName (cit : Citizen) : string {
|
||||
return cit.realName || cit.displayName || cit.naUser
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="sass">
|
||||
|
|
|
@ -75,6 +75,8 @@ async function apiResult<T> (resp : Response, action : string) : Promise<T | und
|
|||
*/
|
||||
async function apiSend (resp : Response, action : string) : Promise<boolean | string> {
|
||||
if (resp.status === 200) return true
|
||||
// HTTP 422 (Unprocessable Entity) is what the API returns for an expired JWT
|
||||
if (resp.status === 422) return `Error ${action} - Your login has expired; refresh this page to renew it`
|
||||
return `Error ${action} - (${resp.status}) ${await resp.text()}`
|
||||
}
|
||||
|
||||
|
@ -172,6 +174,17 @@ export default {
|
|||
retreive: async (id : string, user : LogOnSuccess) : Promise<Listing | undefined | string> =>
|
||||
apiResult<Listing>(await fetch(apiUrl(`listing/${id}`), reqInit('GET', user)), 'retrieving job listing'),
|
||||
|
||||
/**
|
||||
* Retrieve a job listing for viewing (also contains continent information)
|
||||
*
|
||||
* @param id The ID of the job listing to retrieve
|
||||
* @param user The currently logged-on user
|
||||
* @returns The job listing (if found), undefined (if not found), or an error string
|
||||
*/
|
||||
retreiveForView: async (id : string, user : LogOnSuccess) : Promise<ListingForView | undefined | string> =>
|
||||
apiResult<ListingForView>(await fetch(apiUrl(`listing/view/${id}`), reqInit('GET', user)),
|
||||
'retrieving job listing'),
|
||||
|
||||
/**
|
||||
* Search for job listings using the given parameters
|
||||
*
|
||||
|
@ -182,7 +195,7 @@ export default {
|
|||
search: async (query : ListingSearch, user : LogOnSuccess) : Promise<ListingForView[] | string | undefined> => {
|
||||
const params = new URLSearchParams()
|
||||
if (query.continentId) params.append('continentId', query.continentId)
|
||||
if (query.region) params.append('skill', query.region)
|
||||
if (query.region) params.append('region', query.region)
|
||||
params.append('remoteWork', query.remoteWork)
|
||||
if (query.text) params.append('text', query.text)
|
||||
return apiResult<ListingForView[]>(await fetch(apiUrl(`listing/search?${params.toString()}`),
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
<template v-if="errors.length > 0">
|
||||
<p>The following error<template v-if="errors.length !== 1">s</template> occurred:</p>
|
||||
<ul>
|
||||
<li v-for="(error, idx) in errors" :key="idx"><pre>{{error}}</pre></li>
|
||||
<li v-for="(error, idx) in errors" :key="idx">{{error}}</li>
|
||||
</ul>
|
||||
</template>
|
||||
<slot v-else></slot>
|
||||
|
@ -21,3 +21,8 @@ export default defineComponent({
|
|||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="sass" scoped>
|
||||
ul li
|
||||
font-family: monospace
|
||||
</style>
|
||||
|
|
|
@ -28,7 +28,7 @@
|
|||
<td>{{it.listing.title}}</td>
|
||||
<td>{{it.continent.name}} / {{it.listing.region}}</td>
|
||||
<td class="text-center">{{yesOrNo(it.listing.remoteWork)}}</td>
|
||||
<td v-if="it.listing.neededBy" class="text-center">{{format(Date.parse(it.listing.neededBy), 'PPP')}}</td>
|
||||
<td v-if="it.listing.neededBy" class="text-center">{{formatNeededBy(it.listing.neededBy)}}</td>
|
||||
<td v-else class="text-center">N/A</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
@ -43,7 +43,8 @@
|
|||
<script lang="ts">
|
||||
import { defineComponent, ref, Ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { format } from 'date-fns'
|
||||
|
||||
import { formatNeededBy } from './ListingView.vue'
|
||||
import { yesOrNo } from '@/App.vue'
|
||||
import api, { ListingForView, ListingSearch, LogOnSuccess } from '@/api'
|
||||
import { queryValue } from '@/router'
|
||||
|
@ -138,7 +139,7 @@ export default defineComponent({
|
|||
searched,
|
||||
results,
|
||||
yesOrNo,
|
||||
format
|
||||
formatNeededBy
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
@ -1,3 +1,88 @@
|
|||
<template>
|
||||
<p>TODO: write</p>
|
||||
<article>
|
||||
<page-title :title="pageTitle" />
|
||||
<load-data :load="retrieveListing">
|
||||
<h3>{{it.listing.title}}</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 {{formatNeededBy(it.listing.neededBy)}}</em></strong> •
|
||||
</template>
|
||||
Listed by {{citizenName(citizen)}} –
|
||||
<a :href="`https://noagendasocial.com/@${citizen.naUser}`" target="_blank">View No Agenda Social profile</a>
|
||||
</p>
|
||||
<hr>
|
||||
<div v-html="details"></div>
|
||||
</load-data>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, ref, Ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { format } from 'date-fns'
|
||||
import marked from 'marked'
|
||||
|
||||
import api, { Citizen, ListingForView, LogOnSuccess, markedOptions } from '@/api'
|
||||
import { citizenName } from '@/App.vue'
|
||||
import { useStore } from '@/store'
|
||||
import LoadData from '@/components/LoadData.vue'
|
||||
|
||||
/**
|
||||
* Format the needed by date for display
|
||||
*
|
||||
* @param neededBy The defined needed by date
|
||||
* @returns The date to display
|
||||
*/
|
||||
export function formatNeededBy (neededBy : string) : string {
|
||||
return format(Date.parse(neededBy), 'PPP')
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ListingView',
|
||||
components: { LoadData },
|
||||
setup () {
|
||||
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
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
pageTitle: computed(() => it.value ? `${it.value.listing.title} | Job Listing` : 'Loading Job Listing...'),
|
||||
retrieveListing,
|
||||
it,
|
||||
details: computed(() => marked(it.value?.listing.text || '', markedOptions)),
|
||||
citizen,
|
||||
citizenName,
|
||||
formatNeededBy
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
<article>
|
||||
<page-title :title="pageTitle" />
|
||||
<load-data :load="retrieveProfile">
|
||||
<h2><a :href="it.citizen.profileUrl" target="_blank">{{citizenName}}</a></h2>
|
||||
<h2><a :href="it.citizen.profileUrl" target="_blank">{{it.citizen.citizenName()}}</a></h2>
|
||||
<h4 class="pb-3">{{it.continent.name}}, {{it.profile.region}}</h4>
|
||||
<p v-html="workTypes"></p>
|
||||
<hr>
|
||||
|
@ -38,12 +38,14 @@
|
|||
import { computed, defineComponent, ref, Ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import marked from 'marked'
|
||||
|
||||
import api, { LogOnSuccess, markedOptions, ProfileForView } from '@/api'
|
||||
import { citizenName } from '@/App.vue'
|
||||
import { useStore } from '@/store'
|
||||
import LoadData from '@/components/LoadData.vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ProfileEdit',
|
||||
name: 'ProfileView',
|
||||
components: { LoadData },
|
||||
setup () {
|
||||
const store = useStore()
|
||||
|
@ -55,12 +57,6 @@ export default defineComponent({
|
|||
/** The requested profile */
|
||||
const it : Ref<ProfileForView | undefined> = ref(undefined)
|
||||
|
||||
/** The citizen's name (real, display, or NAS, whichever is found first) */
|
||||
const citizenName = computed(() => {
|
||||
const c = it.value?.citizen
|
||||
return c?.realName || c?.displayName || c?.naUser || ''
|
||||
})
|
||||
|
||||
/** The work types for the top of the page */
|
||||
const workTypes = computed(() => {
|
||||
const parts : string[] = []
|
||||
|
@ -90,12 +86,12 @@ export default defineComponent({
|
|||
}
|
||||
|
||||
return {
|
||||
pageTitle: computed(() => it.value ? `Employment profile for ${citizenName.value}` : 'Loading Profile...'),
|
||||
pageTitle: computed(() =>
|
||||
it.value ? `Employment profile for ${citizenName(it.value.citizen)}` : 'Loading Profile...'),
|
||||
user,
|
||||
retrieveProfile,
|
||||
it,
|
||||
workTypes,
|
||||
citizenName,
|
||||
bioHtml: computed(() => marked(it.value?.profile.biography || '', markedOptions)),
|
||||
expHtml: computed(() => marked(it.value?.profile.experience || '', markedOptions))
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue
Block a user