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()
.UseAuthentication()
.UseAuthorization()
.UseGiraffeErrorHandler(Handlers.Error.unexpectedError)
.UseEndpoints(fun e ->
e.MapGiraffeEndpoints Handlers.allEndpoints
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
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)

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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"
}
}

View File

@ -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">

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> {
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()}`),

View File

@ -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>

View File

@ -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
}
}
})

View File

@ -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> &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>
<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>
<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))
}