From 837ab35da5327cda20ef17321d5d2cb255ad4d08 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sat, 14 Aug 2021 17:05:37 -0400 Subject: [PATCH] Add listing edit page, refine my listings (#16) --- src/JobsJobsJobs/Api/Data.fs | 130 ++++++++---- src/JobsJobsJobs/Api/Handlers.fs | 72 ++++++- src/JobsJobsJobs/App/src/api/index.ts | 37 +++- src/JobsJobsJobs/App/src/api/types.ts | 26 +++ .../App/src/components/ContinentList.vue | 59 ++++++ .../App/src/components/FullDateTime.vue | 2 +- .../App/src/views/citizen/Dashboard.vue | 6 +- .../App/src/views/listing/ListingEdit.vue | 189 +++++++++++++++++- .../App/src/views/listing/MyListings.vue | 18 +- src/JobsJobsJobs/Domain/SharedTypes.fs | 28 +++ 10 files changed, 509 insertions(+), 58 deletions(-) create mode 100644 src/JobsJobsJobs/App/src/components/ContinentList.vue diff --git a/src/JobsJobsJobs/Api/Data.fs b/src/JobsJobsJobs/Api/Data.fs index 5a6e5ab..88ff47b 100644 --- a/src/JobsJobsJobs/Api/Data.fs +++ b/src/JobsJobsJobs/Api/Data.fs @@ -201,7 +201,6 @@ open RethinkDb.Driver.Ast module Profile = open JobsJobsJobs.Domain - open RethinkDb.Driver.Ast let count conn = withReconn(conn).ExecuteAsync(fun () -> @@ -210,14 +209,14 @@ module Profile = .RunResultAsync conn) /// Find a profile by citizen ID - let findById (citizenId : CitizenId) conn = task { - let! profile = - withReconn(conn).ExecuteAsync(fun () -> + let findById (citizenId : CitizenId) conn = + withReconn(conn).ExecuteAsync(fun () -> task { + let! profile = r.Table(Table.Profile) .Get(citizenId) - .RunResultAsync conn) - return toOption profile - } + .RunResultAsync conn + return toOption profile + }) /// Insert or update a profile let save (profile : Profile) conn = @@ -331,24 +330,24 @@ module Profile = module Citizen = /// Find a citizen by their ID - let findById (citizenId : CitizenId) conn = task { - let! citizen = - withReconn(conn).ExecuteAsync(fun () -> + let findById (citizenId : CitizenId) conn = + withReconn(conn).ExecuteAsync(fun () -> task { + let! citizen = r.Table(Table.Citizen) .Get(citizenId) - .RunResultAsync conn) - return toOption citizen - } + .RunResultAsync conn + return toOption citizen + }) /// Find a citizen by their No Agenda Social username - let findByNaUser (naUser : string) conn = task { - let! citizen = - withReconn(conn).ExecuteAsync(fun () -> + let findByNaUser (naUser : string) conn = + withReconn(conn).ExecuteAsync(fun () -> task { + let! citizen = r.Table(Table.Citizen) .GetAll(naUser).OptArg("index", "naUser").Nth(0) - .RunResultAsync conn) - return toOption citizen - } + .RunResultAsync conn + return toOption citizen + }) /// Add a citizen let add (citizen : Citizen) conn = @@ -381,6 +380,11 @@ module Citizen = .GetAll(citizenId).OptArg("index", "citizenId") .Delete() .RunWriteAsync conn + let! _ = + r.Table(Table.Listing) + .GetAll(citizenId).OptArg("index", "citizenId") + .Delete() + .RunWriteAsync conn let! _ = r.Table(Table.Citizen) .Get(citizenId) @@ -412,26 +416,70 @@ module Continent = .RunResultAsync conn) /// Get a continent by its ID - let findById (contId : ContinentId) conn = task { - let! continent = - withReconn(conn).ExecuteAsync(fun () -> + let findById (contId : ContinentId) conn = + withReconn(conn).ExecuteAsync(fun () -> task { + let! continent = r.Table(Table.Continent) .Get(contId) - .RunResultAsync conn) - return toOption continent - } + .RunResultAsync conn + return toOption continent + }) /// Job listing data access functions [] module Listing = + /// This is how RethinkDB is going to return our listing/continent combo + // fsharplint:disable RecordFieldNames + [] + type ListingAndContinent = { + left : Listing + right : Continent + } + // fsharplint:enable + /// Find all job listings posted by the given citizen let findByCitizen (citizenId : CitizenId) conn = - withReconn(conn).ExecuteAsync(fun () -> - r.Table(Table.Listing) - .GetAll(citizenId).OptArg("index", nameof citizenId) - .RunResultAsync conn) + withReconn(conn).ExecuteAsync(fun () -> task { + let! both = + r.Table(Table.Listing) + .GetAll(citizenId).OptArg("index", nameof citizenId) + .EqJoin("continentId", r.Table(Table.Continent)) + .RunResultAsync conn + return both |> List.map (fun b -> { listing = b.left; continent = b.right}) + }) + + /// Find a listing by its ID + let findById (listingId : ListingId) conn = + withReconn(conn).ExecuteAsync(fun () -> task { + let! listing = + r.Table(Table.Listing) + .Get(listingId) + .RunResultAsync conn + return toOption listing + }) + + /// Add a listing + let add (listing : Listing) conn = + withReconn(conn).ExecuteAsync(fun () -> task { + let! _ = + r.Table(Table.Listing) + .Insert(listing) + .RunWriteAsync conn + () + }) + + /// Update a listing + let update (listing : Listing) conn = + withReconn(conn).ExecuteAsync(fun () -> task { + let! _ = + r.Table(Table.Listing) + .Get(listing.id) + .Replace(listing) + .RunWriteAsync conn + () + }) /// Success story data access functions @@ -439,25 +487,25 @@ module Listing = module Success = /// Find a success report by its ID - let findById (successId : SuccessId) conn = task { - let! success = - withReconn(conn).ExecuteAsync(fun () -> + let findById (successId : SuccessId) conn = + withReconn(conn).ExecuteAsync(fun () -> task { + let! success = r.Table(Table.Success) .Get(successId) - .RunResultAsync conn) - return toOption success - } + .RunResultAsync conn + return toOption success + }) /// Insert or update a success story - let save (success : Success) conn = task { - let! _ = - withReconn(conn).ExecuteAsync(fun () -> + let save (success : Success) conn = + withReconn(conn).ExecuteAsync(fun () -> task { + let! _ = r.Table(Table.Success) .Get(success.id) .Replace(success) - .RunWriteAsync conn) - () - } + .RunWriteAsync conn + () + }) // Retrieve all success stories let all conn = diff --git a/src/JobsJobsJobs/Api/Handlers.fs b/src/JobsJobsJobs/Api/Handlers.fs index 874c6c9..b37ba66 100644 --- a/src/JobsJobsJobs/Api/Handlers.fs +++ b/src/JobsJobsJobs/Api/Handlers.fs @@ -170,7 +170,13 @@ module Continent = [] module Listing = - // GET: /api/listing/mine + open NodaTime + open System + + /// Parse the string we receive from JSON into a NodaTime local date + let private parseDate = DateTime.Parse >> LocalDate.FromDateTime + + // GET: /api/listings/mine let mine : HttpHandler = authorize >=> fun next ctx -> task { @@ -178,6 +184,61 @@ module Listing = return! json listings next ctx } + // GET: /api/listing/[id] + let get listingId : HttpHandler = + authorize + >=> fun next ctx -> task { + match! Data.Listing.findById (ListingId listingId) (conn ctx) 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 () + let now = (clock ctx).GetCurrentInstant () + do! Data.Listing.add + { id = ListingId.create () + citizenId = currentCitizenId ctx + createdOn = now + title = form.title + continentId = ContinentId.ofString form.continentId + region = form.region + remoteWork = form.remoteWork + isExpired = false + updatedOn = now + text = Text form.text + neededBy = (form.neededBy |> Option.map parseDate) + wasFilledHere = None + } (conn ctx) + return! ok next ctx + } + + // PUT: /api/listing/[id] + let update listingId : HttpHandler = + authorize + >=> fun next ctx -> task { + let dbConn = conn ctx + match! Data.Listing.findById (ListingId listingId) dbConn with + | Some listing when listing.citizenId <> (currentCitizenId ctx) -> return! Error.notAuthorized next ctx + | Some listing -> + let! form = ctx.BindJsonAsync () + do! Data.Listing.update + { listing with + title = form.title + continentId = ContinentId.ofString form.continentId + region = form.region + remoteWork = form.remoteWork + text = Text form.text + neededBy = form.neededBy |> Option.map parseDate + updatedOn = (clock ctx).GetCurrentInstant () + } dbConn + return! ok next ctx + | None -> return! Error.notFound next ctx + } + /// Handlers for /api/profile routes [] module Profile = @@ -380,7 +441,14 @@ let allEndpoints = [ GET_HEAD [ route "/continent/all" Continent.all ] subRoute "/listing" [ GET_HEAD [ - route "s/mine" Listing.mine + routef "/%O" Listing.get + route "s/mine" Listing.mine + ] + POST [ + route "s" Listing.add + ] + PUT [ + routef "/%O" Listing.update ] ] subRoute "/profile" [ diff --git a/src/JobsJobsJobs/App/src/api/index.ts b/src/JobsJobsJobs/App/src/api/index.ts index 3326562..0cd8667 100644 --- a/src/JobsJobsJobs/App/src/api/index.ts +++ b/src/JobsJobsJobs/App/src/api/index.ts @@ -4,6 +4,8 @@ import { Continent, Count, Listing, + ListingForm, + ListingForView, LogOnSuccess, Profile, ProfileForm, @@ -139,14 +141,45 @@ export default { /** API functions for job listings */ listings: { + /** + * Add a new job listing + * + * @param listing The profile data to be saved + * @param user The currently logged-on user + * @returns True if the addition was successful, an error string if not + */ + add: async (listing : ListingForm, user : LogOnSuccess) : Promise => + apiSend(await fetch(apiUrl('listings'), reqInit('POST', user, listing)), 'adding job listing'), + /** * Retrieve the job listings posted by the current citizen * * @param user The currently logged-on user * @returns The job listings the user has posted, or an error string */ - mine: async (user : LogOnSuccess) : Promise => - apiResult(await fetch(apiUrl('listings/mine'), reqInit('GET', user)), 'retrieving your job listings') + mine: async (user : LogOnSuccess) : Promise => + apiResult(await fetch(apiUrl('listings/mine'), reqInit('GET', user)), + 'retrieving your job listings'), + + /** + * Retrieve a job listing + * + * @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 + */ + retreive: async (id : string, user : LogOnSuccess) : Promise => + apiResult(await fetch(apiUrl(`listing/${id}`), reqInit('GET', user)), 'retrieving job listing'), + + /** + * Update an existing job listing + * + * @param listing The profile data to be saved + * @param user The currently logged-on user + * @returns True if the update was successful, an error string if not + */ + update: async (listing : ListingForm, user : LogOnSuccess) : Promise => + apiSend(await fetch(apiUrl(`listing/${listing.id}`), reqInit('PUT', user, listing)), 'updating job listing') }, /** API functions for profiles */ diff --git a/src/JobsJobsJobs/App/src/api/types.ts b/src/JobsJobsJobs/App/src/api/types.ts index 5c4b7ef..f822b64 100644 --- a/src/JobsJobsJobs/App/src/api/types.ts +++ b/src/JobsJobsJobs/App/src/api/types.ts @@ -59,6 +59,32 @@ export interface Listing { wasFilledHere : boolean | undefined } +/** The data required to add or edit a job listing */ +export class ListingForm { + /** The ID of the listing */ + id = '' + /** The listing title */ + title = '' + /** The ID of the continent on which this opportunity exists */ + continentId = '' + /** The region in which this opportunity exists */ + region = '' + /** Whether this is a remote work opportunity */ + remoteWork = false + /** The text of the job listing */ + text = '' + /** The date by which this job listing is needed */ + neededBy : string | undefined +} + +/** The data required to view a listing */ +export interface ListingForView { + /** The listing itself */ + listing : Listing + /** The continent for the listing */ + continent : Continent +} + /** A successful logon */ export interface LogOnSuccess { /** The JSON Web Token (JWT) to use for API access */ diff --git a/src/JobsJobsJobs/App/src/components/ContinentList.vue b/src/JobsJobsJobs/App/src/components/ContinentList.vue new file mode 100644 index 0000000..2ed03a7 --- /dev/null +++ b/src/JobsJobsJobs/App/src/components/ContinentList.vue @@ -0,0 +1,59 @@ + + + diff --git a/src/JobsJobsJobs/App/src/components/FullDateTime.vue b/src/JobsJobsJobs/App/src/components/FullDateTime.vue index 6bad23f..d532ae1 100644 --- a/src/JobsJobsJobs/App/src/components/FullDateTime.vue +++ b/src/JobsJobsJobs/App/src/components/FullDateTime.vue @@ -17,7 +17,7 @@ export default defineComponent({ }, setup (props) { return { - formatted: format(parseToUtc(props.date), 'PPPppp') + formatted: format(parseToUtc(props.date), 'PPPp') } } }) diff --git a/src/JobsJobsJobs/App/src/views/citizen/Dashboard.vue b/src/JobsJobsJobs/App/src/views/citizen/Dashboard.vue index 19086e8..acb10b2 100644 --- a/src/JobsJobsJobs/App/src/views/citizen/Dashboard.vue +++ b/src/JobsJobsJobs/App/src/views/citizen/Dashboard.vue @@ -9,7 +9,7 @@
Your Profile
- Last updated + Last updated

Your profile currently lists {{profile.skills.length}} @@ -73,13 +73,13 @@ import { defineComponent, Ref, ref } from 'vue' import api, { LogOnSuccess, Profile } from '@/api' import { useStore } from '@/store' -import FullDate from '@/components/FullDate.vue' +import FullDateTime from '@/components/FullDateTime.vue' import LoadData from '@/components/LoadData.vue' export default defineComponent({ name: 'Dashboard', components: { - FullDate, + FullDateTime, LoadData }, setup () { diff --git a/src/JobsJobsJobs/App/src/views/listing/ListingEdit.vue b/src/JobsJobsJobs/App/src/views/listing/ListingEdit.vue index 420fa14..8f24022 100644 --- a/src/JobsJobsJobs/App/src/views/listing/ListingEdit.vue +++ b/src/JobsJobsJobs/App/src/views/listing/ListingEdit.vue @@ -1,3 +1,190 @@ + + diff --git a/src/JobsJobsJobs/App/src/views/listing/MyListings.vue b/src/JobsJobsJobs/App/src/views/listing/MyListings.vue index d8ebb41..39ef743 100644 --- a/src/JobsJobsJobs/App/src/views/listing/MyListings.vue +++ b/src/JobsJobsJobs/App/src/views/listing/MyListings.vue @@ -6,21 +6,23 @@ Add a New Job Listing

- +
+ - - - - - + + + + + +
Action TitleContinent / Region Created Updated
Edit{{listing.Title}}
Edit{{it.listing.title}}{{it.continent.name}} / {{it.listing.region}}
@@ -31,7 +33,7 @@