Finish listing search; add shell of listing view (#17)

Also updated deps on the app side
This commit is contained in:
Daniel J. Summers 2021-08-15 20:55:50 -04:00
parent 69e386d91b
commit f25fabe064
11 changed files with 809 additions and 392 deletions

View File

@ -17,6 +17,7 @@ let configureApp (app : IApplicationBuilder) =
.UseRouting() .UseRouting()
.UseAuthentication() .UseAuthentication()
.UseAuthorization() .UseAuthorization()
.UseGiraffeErrorHandler(Handlers.Error.unexpectedError)
.UseEndpoints(fun e -> .UseEndpoints(fun e ->
e.MapGiraffeEndpoints Handlers.allEndpoints e.MapGiraffeEndpoints Handlers.allEndpoints
e.MapFallbackToFile "index.html" |> ignore) e.MapFallbackToFile "index.html" |> ignore)

View File

@ -191,7 +191,7 @@ let withReconn (conn : IConnection) =
/// Sanitize user input, and create a "contains" pattern for use with RethinkDB queries /// Sanitize user input, and create a "contains" pattern for use with RethinkDB queries
let regexContains (it : string) = let regexContains (it : string) =
System.Text.RegularExpressions.Regex.Escape it System.Text.RegularExpressions.Regex.Escape it
|> sprintf "(?i).*%s.*" |> sprintf "(?i)%s"
open JobsJobsJobs.Domain open JobsJobsJobs.Domain
open JobsJobsJobs.Domain.SharedTypes open JobsJobsJobs.Domain.SharedTypes
@ -435,8 +435,7 @@ module Listing =
r.Table(Table.Listing) r.Table(Table.Listing)
.GetAll(citizenId).OptArg("index", nameof citizenId) .GetAll(citizenId).OptArg("index", nameof citizenId)
.EqJoin("continentId", r.Table(Table.Continent)) .EqJoin("continentId", r.Table(Table.Continent))
.Merge(ReqlFunction1(fun it -> upcast r.HashMap("listing", it.G("left")).With("continent", it.G("right")))) .Map(ReqlFunction1(fun it -> upcast r.HashMap("listing", it.G("left")).With("continent", it.G("right"))))
.Pluck("listing", "continent")
.RunResultAsync<ListingForView list> conn) .RunResultAsync<ListingForView list> conn)
/// Find a listing by its ID /// Find a listing by its ID
@ -449,6 +448,18 @@ module Listing =
return toOption 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 /// Add a listing
let add (listing : Listing) conn = let add (listing : Listing) conn =
withReconn(conn).ExecuteAsync(fun () -> task { withReconn(conn).ExecuteAsync(fun () -> task {
@ -482,24 +493,26 @@ module Listing =
match srch.region with match srch.region with
| Some rgn -> | Some rgn ->
yield (fun q -> 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 -> () | None -> ()
match srch.remoteWork with 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 match srch.text with
| Some text -> | Some text ->
yield (fun q -> 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 -> () | None -> ()
} }
|> Seq.toList |> Seq.toList
|> List.fold |> List.fold
(fun q f -> f q) (fun q f -> f q)
(r.Table(Table.Listing) (r.Table(Table.Listing) :> ReqlExpr))
.EqJoin("continentId", r.Table(Table.Continent)) :> ReqlExpr)) .EqJoin("continentId", r.Table(Table.Continent))
.Merge(ReqlFunction1(fun it -> upcast r.HashMap("listing", it.G("left")).With("continent", it.G("right")))) .Map(ReqlFunction1(fun it -> upcast r.HashMap("listing", it.G("left")).With("continent", it.G("right"))))
.Pluck("listing", "continent")
.RunResultAsync<ListingForView list> conn) .RunResultAsync<ListingForView list> conn)

View File

@ -21,17 +21,22 @@ module Error =
open System.Threading.Tasks 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" /// Handler that will return a status code 404 and the text "Not Found"
let notFound : HttpHandler = let notFound : HttpHandler =
fun next ctx -> task { fun next ctx -> task {
let fac = ctx.GetService<ILoggerFactory>() let fac = ctx.GetService<ILoggerFactory>()
let log = fac.CreateLogger("Handler") let log = fac.CreateLogger("Handler")
match [ "GET"; "HEAD" ] |> List.contains ctx.Request.Method with 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" log.LogInformation "Returning Vue app"
// TODO: check for valid URL prefixes
return! Vue.app next ctx return! Vue.app next ctx
| false -> | _ ->
log.LogInformation "Returning 404" log.LogInformation "Returning 404"
return! RequestErrors.NOT_FOUND $"The URL {string ctx.Request.Path} was not recognized as a valid URL" next return! RequestErrors.NOT_FOUND $"The URL {string ctx.Request.Path} was not recognized as a valid URL" next
ctx ctx
@ -41,6 +46,11 @@ module Error =
let notAuthorized : HttpHandler = let notAuthorized : HttpHandler =
setStatusCode 403 >=> fun _ _ -> Task.FromResult<HttpContext option> None 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 /// Helper functions
[<AutoOpen>] [<AutoOpen>]
@ -193,6 +203,15 @@ module Listing =
| None -> return! Error.notFound next ctx | 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 // POST: /listings
let add : HttpHandler = let add : HttpHandler =
authorize authorize
@ -451,9 +470,10 @@ let allEndpoints = [
GET_HEAD [ route "/continent/all" Continent.all ] GET_HEAD [ route "/continent/all" Continent.all ]
subRoute "/listing" [ subRoute "/listing" [
GET_HEAD [ GET_HEAD [
routef "/%O" Listing.get routef "/%O" Listing.get
route "/search" Listing.search route "/search" Listing.search
route "s/mine" Listing.mine routef "/view/%O" Listing.view
route "s/mine" Listing.mine
] ]
POST [ POST [
route "s" Listing.add route "s" Listing.add

File diff suppressed because it is too large Load Diff

View File

@ -10,39 +10,39 @@
}, },
"dependencies": { "dependencies": {
"@mdi/font": "5.9.55", "@mdi/font": "5.9.55",
"@vuelidate/core": "^2.0.0-alpha.22", "@vuelidate/core": "^2.0.0-alpha.24",
"@vuelidate/validators": "^2.0.0-alpha.19", "@vuelidate/validators": "^2.0.0-alpha.21",
"bootstrap": "^5.1.0", "bootstrap": "^5.1.0",
"core-js": "^3.6.5", "core-js": "^3.16.1",
"date-fns": "^2.23.0", "date-fns": "^2.23.0",
"date-fns-tz": "^1.1.4", "date-fns-tz": "^1.1.6",
"marked": "^2.1.3", "marked": "^2.1.3",
"vue": "^3.0.0", "vue": "^3.2.2",
"vue-router": "^4.0.0-0", "vue-router": "^4.0.11",
"vuex": "^4.0.0-0" "vuex": "^4.0.0-0"
}, },
"devDependencies": { "devDependencies": {
"@types/bootstrap": "^5.1.0", "@types/bootstrap": "^5.1.1",
"@types/marked": "^2.0.4", "@types/marked": "^2.0.4",
"@typescript-eslint/eslint-plugin": "^4.18.0", "@typescript-eslint/eslint-plugin": "^4.29.1",
"@typescript-eslint/parser": "^4.18.0", "@typescript-eslint/parser": "^4.29.1",
"@vue/cli-plugin-babel": "~4.5.0", "@vue/cli-plugin-babel": "~4.5.0",
"@vue/cli-plugin-eslint": "~4.5.0", "@vue/cli-plugin-eslint": "~4.5.0",
"@vue/cli-plugin-router": "~4.5.0", "@vue/cli-plugin-router": "~4.5.0",
"@vue/cli-plugin-typescript": "~4.5.0", "@vue/cli-plugin-typescript": "~4.5.0",
"@vue/cli-plugin-vuex": "~4.5.0", "@vue/cli-plugin-vuex": "~4.5.0",
"@vue/cli-service": "~4.5.0", "@vue/cli-service": "~4.5.0",
"@vue/compiler-sfc": "^3.0.0", "@vue/compiler-sfc": "^3.2.2",
"@vue/eslint-config-standard": "^5.1.2", "@vue/eslint-config-standard": "^6.1.0",
"@vue/eslint-config-typescript": "^7.0.0", "@vue/eslint-config-typescript": "^7.0.0",
"eslint": "^6.7.2", "eslint": "^7.32.0",
"eslint-plugin-import": "^2.20.2", "eslint-plugin-import": "^2.24.0",
"eslint-plugin-node": "^11.1.0", "eslint-plugin-node": "^11.1.0",
"eslint-plugin-promise": "^4.2.1", "eslint-plugin-promise": "^5.0.0",
"eslint-plugin-standard": "^4.0.0", "eslint-plugin-standard": "^5.0.0",
"eslint-plugin-vue": "^7.0.0", "eslint-plugin-vue": "^7.16.0",
"sass": "~1.32.0", "sass": "~1.37.0",
"sass-loader": "^10.0.0", "sass-loader": "^10.0.0",
"typescript": "~4.1.5" "typescript": "~4.3.5"
} }
} }

View File

@ -18,6 +18,7 @@
<script lang="ts"> <script lang="ts">
import { defineComponent } from 'vue' import { defineComponent } from 'vue'
import { Citizen } from './api'
import AppFooter from './components/layout/AppFooter.vue' import AppFooter from './components/layout/AppFooter.vue'
import AppNav from './components/layout/AppNav.vue' import AppNav from './components/layout/AppNav.vue'
import AppToaster from './components/layout/AppToaster.vue' import AppToaster from './components/layout/AppToaster.vue'
@ -45,6 +46,16 @@ export default defineComponent({
export function yesOrNo (cond : boolean) : string { export function yesOrNo (cond : boolean) : string {
return cond ? 'Yes' : 'No' 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> </script>
<style lang="sass"> <style lang="sass">

View File

@ -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> { async function apiSend (resp : Response, action : string) : Promise<boolean | string> {
if (resp.status === 200) return true 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()}` return `Error ${action} - (${resp.status}) ${await resp.text()}`
} }
@ -172,6 +174,17 @@ export default {
retreive: async (id : string, user : LogOnSuccess) : Promise<Listing | undefined | string> => retreive: async (id : string, user : LogOnSuccess) : Promise<Listing | undefined | string> =>
apiResult<Listing>(await fetch(apiUrl(`listing/${id}`), reqInit('GET', user)), 'retrieving job listing'), 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 * Search for job listings using the given parameters
* *
@ -182,7 +195,7 @@ export default {
search: async (query : ListingSearch, user : LogOnSuccess) : Promise<ListingForView[] | string | undefined> => { search: async (query : ListingSearch, user : LogOnSuccess) : Promise<ListingForView[] | string | undefined> => {
const params = new URLSearchParams() const params = new URLSearchParams()
if (query.continentId) params.append('continentId', query.continentId) 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) params.append('remoteWork', query.remoteWork)
if (query.text) params.append('text', query.text) if (query.text) params.append('text', query.text)
return apiResult<ListingForView[]>(await fetch(apiUrl(`listing/search?${params.toString()}`), return apiResult<ListingForView[]>(await fetch(apiUrl(`listing/search?${params.toString()}`),

View File

@ -2,7 +2,7 @@
<template v-if="errors.length > 0"> <template v-if="errors.length > 0">
<p>The following error<template v-if="errors.length !== 1">s</template> occurred:</p> <p>The following error<template v-if="errors.length !== 1">s</template> occurred:</p>
<ul> <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> </ul>
</template> </template>
<slot v-else></slot> <slot v-else></slot>
@ -21,3 +21,8 @@ export default defineComponent({
} }
}) })
</script> </script>
<style lang="sass" scoped>
ul li
font-family: monospace
</style>

View File

@ -28,7 +28,7 @@
<td>{{it.listing.title}}</td> <td>{{it.listing.title}}</td>
<td>{{it.continent.name}} / {{it.listing.region}}</td> <td>{{it.continent.name}} / {{it.listing.region}}</td>
<td class="text-center">{{yesOrNo(it.listing.remoteWork)}}</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> <td v-else class="text-center">N/A</td>
</tr> </tr>
</tbody> </tbody>
@ -43,7 +43,8 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, ref, Ref, watch } from 'vue' import { defineComponent, ref, Ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { format } from 'date-fns'
import { formatNeededBy } from './ListingView.vue'
import { yesOrNo } from '@/App.vue' import { yesOrNo } from '@/App.vue'
import api, { ListingForView, ListingSearch, LogOnSuccess } from '@/api' import api, { ListingForView, ListingSearch, LogOnSuccess } from '@/api'
import { queryValue } from '@/router' import { queryValue } from '@/router'
@ -138,7 +139,7 @@ export default defineComponent({
searched, searched,
results, results,
yesOrNo, yesOrNo,
format formatNeededBy
} }
} }
}) })

View File

@ -1,3 +1,88 @@
<template> <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> &bull;&nbsp;
</template>
Listed by {{citizenName(citizen)}} &ndash;
<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> </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>

View File

@ -2,7 +2,7 @@
<article> <article>
<page-title :title="pageTitle" /> <page-title :title="pageTitle" />
<load-data :load="retrieveProfile"> <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> <h4 class="pb-3">{{it.continent.name}}, {{it.profile.region}}</h4>
<p v-html="workTypes"></p> <p v-html="workTypes"></p>
<hr> <hr>
@ -38,12 +38,14 @@
import { computed, defineComponent, ref, Ref } from 'vue' import { computed, defineComponent, ref, Ref } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import marked from 'marked' import marked from 'marked'
import api, { LogOnSuccess, markedOptions, ProfileForView } from '@/api' import api, { LogOnSuccess, markedOptions, ProfileForView } from '@/api'
import { citizenName } from '@/App.vue'
import { useStore } from '@/store' import { useStore } from '@/store'
import LoadData from '@/components/LoadData.vue' import LoadData from '@/components/LoadData.vue'
export default defineComponent({ export default defineComponent({
name: 'ProfileEdit', name: 'ProfileView',
components: { LoadData }, components: { LoadData },
setup () { setup () {
const store = useStore() const store = useStore()
@ -55,12 +57,6 @@ export default defineComponent({
/** The requested profile */ /** The requested profile */
const it : Ref<ProfileForView | undefined> = ref(undefined) 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 */ /** The work types for the top of the page */
const workTypes = computed(() => { const workTypes = computed(() => {
const parts : string[] = [] const parts : string[] = []
@ -90,12 +86,12 @@ export default defineComponent({
} }
return { 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, user,
retrieveProfile, retrieveProfile,
it, it,
workTypes, workTypes,
citizenName,
bioHtml: computed(() => marked(it.value?.profile.biography || '', markedOptions)), bioHtml: computed(() => marked(it.value?.profile.biography || '', markedOptions)),
expHtml: computed(() => marked(it.value?.profile.experience || '', markedOptions)) expHtml: computed(() => marked(it.value?.profile.experience || '', markedOptions))
} }