diff --git a/src/JobsJobsJobs/Client/Pages/Citizen/Dashboard.razor b/src/JobsJobsJobs/Client/Pages/Citizen/Dashboard.razor
index 74111df..bfa6ba2 100644
--- a/src/JobsJobsJobs/Client/Pages/Citizen/Dashboard.razor
+++ b/src/JobsJobsJobs/Client/Pages/Citizen/Dashboard.razor
@@ -25,7 +25,7 @@ else
@code {
bool retrievingProfile = true;
- JobsJobsJobs.Shared.Profile? profile = null;
+ Profile? profile = null;
string? errorMessage = null;
protected override async Task OnInitializedAsync()
diff --git a/src/JobsJobsJobs/Client/Pages/Citizen/EditProfile.razor b/src/JobsJobsJobs/Client/Pages/Citizen/EditProfile.razor
new file mode 100644
index 0000000..40eeb39
--- /dev/null
+++ b/src/JobsJobsJobs/Client/Pages/Citizen/EditProfile.razor
@@ -0,0 +1,89 @@
+@page "/citizen/profile"
+
+
Employment Profile
+
+@if (ErrorMessage != "")
+{
+ @ErrorMessage
+}
+else
+{
+
+
+
+
+
+
+
+
+
+
+}
diff --git a/src/JobsJobsJobs/Client/Pages/Citizen/EditProfile.razor.cs b/src/JobsJobsJobs/Client/Pages/Citizen/EditProfile.razor.cs
new file mode 100644
index 0000000..5a0f199
--- /dev/null
+++ b/src/JobsJobsJobs/Client/Pages/Citizen/EditProfile.razor.cs
@@ -0,0 +1,84 @@
+using JobsJobsJobs.Shared;
+using JobsJobsJobs.Shared.Api;
+using Microsoft.AspNetCore.Components;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net.Http;
+using System.Net.Http.Json;
+using System.Threading.Tasks;
+
+namespace JobsJobsJobs.Client.Pages.Citizen
+{
+ ///
+ /// Profile edit page (called EditProfile so as not to create naming conflicts)
+ ///
+ public partial class EditProfile : ComponentBase
+ {
+ ///
+ /// The form for this page
+ ///
+ private ProfileForm ProfileForm { get; set; } = new ProfileForm();
+
+ ///
+ /// All continents
+ ///
+ private IEnumerable Continents { get; set; } = Enumerable.Empty();
+
+ ///
+ /// Error message from API access
+ ///
+ private string ErrorMessage { get; set; } = "";
+
+ ///
+ /// HTTP client instance to use for API access
+ ///
+ [Inject]
+ private HttpClient Http { get; set; } = default!;
+
+ ///
+ /// Application state
+ ///
+ [Inject]
+ private AppState State { get; set; } = default!;
+
+ protected override async Task OnInitializedAsync()
+ {
+ ServerApi.SetJwt(Http, State);
+ var continentResult = await ServerApi.AllContinents(Http, State);
+ if (continentResult.IsOk)
+ {
+ Continents = continentResult.Ok;
+ }
+ else
+ {
+ ErrorMessage = continentResult.Error;
+ }
+
+ var result = await ServerApi.RetrieveProfile(Http, State);
+ if (result.IsOk)
+ {
+ System.Console.WriteLine($"Result is null? {result.Ok == null}");
+ ProfileForm = (result.Ok == null) ? new ProfileForm() : ProfileForm.FromProfile(result.Ok);
+ }
+ else
+ {
+ ErrorMessage = result.Error;
+ }
+ }
+
+ public async Task SaveProfile()
+ {
+ var res = await Http.PostAsJsonAsync("/api/profile/save", ProfileForm);
+ if (res.IsSuccessStatusCode)
+ {
+ // TODO: success notification
+ }
+ else
+ {
+ // TODO: probably not the best way to handle this...
+ ErrorMessage = await res.Content.ReadAsStringAsync();
+ }
+ }
+
+ }
+}
diff --git a/src/JobsJobsJobs/Client/Pages/Citizen/Profile.razor b/src/JobsJobsJobs/Client/Pages/Citizen/Profile.razor
deleted file mode 100644
index d5e5cbd..0000000
--- a/src/JobsJobsJobs/Client/Pages/Citizen/Profile.razor
+++ /dev/null
@@ -1,130 +0,0 @@
-@page "/citizen/profile"
-@using JobsJobsJobs.Client.ViewModels
-@inject HttpClient http
-@inject AppState state
-
-Employment Profile
-
-@if (errorMessage != "")
-{
- @errorMessage
-}
-else if (profileForm != null)
-{
-
-
-
-
-
-
-
-
-
-}
-
-@code {
-
- public JobsJobsJobs.Shared.Profile? profile = null;
-
- public ProfileForm? profileForm = null;
-
- private IEnumerable continents = Enumerable.Empty();
-
- public string errorMessage = "";
-
- protected override async Task OnInitializedAsync()
- {
- var continentResult = await ServerApi.AllContinents(http, state);
- if (continentResult.IsOk)
- {
- continents = continentResult.Ok;
- }
- else
- {
- errorMessage = continentResult.Error;
- }
-
- var result = await ServerApi.RetrieveProfile(http, state);
- if (result.IsOk)
- {
- profile = result.Ok;
- profileForm = (profile == null) ? new ProfileForm() : ProfileForm.FromProfile(profile);
- }
- else
- {
- errorMessage = result.Error;
- }
- }
-
- public void SaveProfile()
- {
- // TODO: save profile
- }
-}
diff --git a/src/JobsJobsJobs/Client/ServerApi.cs b/src/JobsJobsJobs/Client/ServerApi.cs
index b548138..5c57e82 100644
--- a/src/JobsJobsJobs/Client/ServerApi.cs
+++ b/src/JobsJobsJobs/Client/ServerApi.cs
@@ -1,5 +1,7 @@
using JobsJobsJobs.Shared;
using JobsJobsJobs.Shared.Api;
+using NodaTime;
+using NodaTime.Serialization.SystemTextJson;
using System;
using System.Collections.Generic;
using System.Linq;
@@ -7,6 +9,7 @@ using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Net.Http.Json;
+using System.Text.Json;
using System.Threading.Tasks;
namespace JobsJobsJobs.Client
@@ -16,6 +19,25 @@ namespace JobsJobsJobs.Client
///
public static class ServerApi
{
+ ///
+ /// System.Text.Json options configured for NodaTime
+ ///
+ private static readonly JsonSerializerOptions _serializerOptions;
+
+ ///
+ /// Static constructor
+ ///
+ static ServerApi()
+ {
+
+ var options = new JsonSerializerOptions
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase
+ };
+ options.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb);
+ _serializerOptions = options;
+ }
+
///
/// Create an API URL
///
@@ -37,6 +59,14 @@ namespace JobsJobsJobs.Client
return req;
}
+ ///
+ /// Set the JSON Web Token (JWT) bearer header for the given HTTP client
+ ///
+ /// The HTTP client whose authentication header should be set
+ /// The current application state
+ public static void SetJwt(HttpClient http, AppState state) =>
+ http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", state.Jwt);
+
///
/// Log on a user with the authorization code received from No Agenda Social
///
@@ -73,7 +103,8 @@ namespace JobsJobsJobs.Client
return true switch
{
_ when res.StatusCode == HttpStatusCode.NoContent => Result.AsOk(null),
- _ when res.IsSuccessStatusCode => Result.AsOk(await res.Content.ReadFromJsonAsync()),
+ _ when res.IsSuccessStatusCode => Result.AsOk(
+ await res.Content.ReadFromJsonAsync(_serializerOptions)),
_ => Result.AsError(await res.Content.ReadAsStringAsync()),
};
}
diff --git a/src/JobsJobsJobs/Client/Shared/MarkdownEditor.razor.css b/src/JobsJobsJobs/Client/Shared/MarkdownEditor.razor.css
index 6ab1dc2..7502f1b 100644
--- a/src/JobsJobsJobs/Client/Shared/MarkdownEditor.razor.css
+++ b/src/JobsJobsJobs/Client/Shared/MarkdownEditor.razor.css
@@ -1,4 +1,5 @@
section.preview {
border: solid 1px darkgray;
- border-radius: 2rem;
+ border-radius: .5rem;
+ padding: .25rem;
}
diff --git a/src/JobsJobsJobs/Client/wwwroot/css/app.css b/src/JobsJobsJobs/Client/wwwroot/css/app.css
index 7ea8e68..877de2d 100644
--- a/src/JobsJobsJobs/Client/wwwroot/css/app.css
+++ b/src/JobsJobsJobs/Client/wwwroot/css/app.css
@@ -56,3 +56,12 @@ a.audio {
a.audio:hover {
cursor: pointer;
}
+
+label.jjj-required {
+ font-weight:bold;
+}
+
+label.jjj-required::after {
+ color: red;
+ content: ' *';
+}
diff --git a/src/JobsJobsJobs/Server/Areas/Api/Controllers/ProfileController.cs b/src/JobsJobsJobs/Server/Areas/Api/Controllers/ProfileController.cs
index dde9e4d..0e53f5c 100644
--- a/src/JobsJobsJobs/Server/Areas/Api/Controllers/ProfileController.cs
+++ b/src/JobsJobsJobs/Server/Areas/Api/Controllers/ProfileController.cs
@@ -1,9 +1,11 @@
using JobsJobsJobs.Server.Data;
using JobsJobsJobs.Shared;
+using JobsJobsJobs.Shared.Api;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
+using NodaTime;
using Npgsql;
using System;
using System.Collections.Generic;
@@ -17,6 +19,7 @@ namespace JobsJobsJobs.Server.Areas.Api.Controllers
/// API controller for employment profile information
///
[Route("api/[controller]")]
+ [Authorize]
[ApiController]
public class ProfileController : ControllerBase
{
@@ -25,16 +28,21 @@ namespace JobsJobsJobs.Server.Areas.Api.Controllers
///
private readonly NpgsqlConnection _db;
+ ///
+ /// The NodaTime clock instance
+ ///
+ private readonly IClock _clock;
+
///
/// Constructor
///
/// The database connection to use for this request
- public ProfileController(NpgsqlConnection db)
+ public ProfileController(NpgsqlConnection db, IClock clock)
{
_db = db;
+ _clock = clock;
}
- [Authorize]
[HttpGet("")]
public async Task Get()
{
@@ -43,5 +51,32 @@ namespace JobsJobsJobs.Server.Areas.Api.Controllers
CitizenId.Parse(User.FindFirst(ClaimTypes.NameIdentifier)!.Value));
return profile == null ? NoContent() : Ok(profile);
}
+
+ [HttpPost("save")]
+ public async Task Save([FromBody] ProfileForm form)
+ {
+ var citizenId = CitizenId.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier));
+ await _db.OpenAsync();
+ var existing = await _db.FindProfileByCitizen(citizenId);
+ var profile = existing == null
+ ? new Profile(citizenId, form.IsSeekingEmployment, form.IsPublic, ContinentId.Parse(form.ContinentId),
+ form.Region, form.RemoteWork, form.FullTime, new MarkdownString(form.Biography),
+ _clock.GetCurrentInstant(),
+ string.IsNullOrEmpty(form.Experience) ? null : new MarkdownString(form.Experience))
+ : existing with
+ {
+ SeekingEmployment = form.IsSeekingEmployment,
+ IsPublic = form.IsPublic,
+ ContinentId = ContinentId.Parse(form.ContinentId),
+ Region = form.Region,
+ RemoteWork = form.RemoteWork,
+ FullTime = form.FullTime,
+ Biography = new MarkdownString(form.Biography),
+ LastUpdatedOn = _clock.GetCurrentInstant(),
+ Experience = string.IsNullOrEmpty(form.Experience) ? null : new MarkdownString(form.Experience)
+ };
+ await _db.SaveProfile(profile);
+ return Ok();
+ }
}
}
diff --git a/src/JobsJobsJobs/Server/Data/ProfileExtensions.cs b/src/JobsJobsJobs/Server/Data/ProfileExtensions.cs
index 363ba32..e70748a 100644
--- a/src/JobsJobsJobs/Server/Data/ProfileExtensions.cs
+++ b/src/JobsJobsJobs/Server/Data/ProfileExtensions.cs
@@ -20,7 +20,7 @@ namespace JobsJobsJobs.Server.Data
private static Profile ToProfile(NpgsqlDataReader rdr)
{
var continentId = ContinentId.Parse(rdr.GetString("continent_id"));
- return new Profile(CitizenId.Parse(rdr.GetString("id")), rdr.GetBoolean("seeking_employment"),
+ return new Profile(CitizenId.Parse(rdr.GetString("citizen_id")), rdr.GetBoolean("seeking_employment"),
rdr.GetBoolean("is_public"), continentId, rdr.GetString("region"), rdr.GetBoolean("remote_work"),
rdr.GetBoolean("full_time"), new MarkdownString(rdr.GetString("biography")),
rdr.GetInstant("last_updated_on"),
@@ -48,5 +48,45 @@ namespace JobsJobsJobs.Server.Data
using var rdr = await cmd.ExecuteReaderAsync().ConfigureAwait(false);
return await rdr.ReadAsync().ConfigureAwait(false) ? ToProfile(rdr) : null;
}
+
+ ///
+ /// Save a profile
+ ///
+ /// The profile to be saved
+ public static async Task SaveProfile(this NpgsqlConnection conn, Profile profile)
+ {
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText =
+ @"INSERT INTO profile (
+ citizen_id, seeking_employment, is_public, continent_id, region, remote_work, full_time,
+ biography, last_updated_on, experience
+ ) VALUES (
+ @citizen_id, @seeking_employment, @is_public, @continent_id, @region, @remote_work, @full_time,
+ @biography, @last_updated_on, @experience
+ ) ON CONFLICT (citizen_id) DO UPDATE
+ SET seeking_employment = @seeking_employment,
+ is_public = @is_public,
+ continent_id = @continent_id,
+ region = @region,
+ remote_work = @remote_work,
+ full_time = @full_time,
+ biography = @biography,
+ last_updated_on = @last_updated_on,
+ experience = @experience
+ WHERE profile.citizen_id = excluded.citizen_id";
+ cmd.Parameters.Add(new NpgsqlParameter("@citizen_id", profile.Id.ToString()));
+ cmd.Parameters.Add(new NpgsqlParameter("@seeking_employment", profile.SeekingEmployment));
+ cmd.Parameters.Add(new NpgsqlParameter("@is_public", profile.IsPublic));
+ cmd.Parameters.Add(new NpgsqlParameter("@continent_id", profile.ContinentId.ToString()));
+ cmd.Parameters.Add(new NpgsqlParameter("@region", profile.Region));
+ cmd.Parameters.Add(new NpgsqlParameter("@remote_work", profile.RemoteWork));
+ cmd.Parameters.Add(new NpgsqlParameter("@full_time", profile.FullTime));
+ cmd.Parameters.Add(new NpgsqlParameter("@biography", profile.Biography.Text));
+ cmd.Parameters.Add(new NpgsqlParameter("@last_updated_on", profile.LastUpdatedOn));
+ cmd.Parameters.Add(new NpgsqlParameter("@experience",
+ profile.Experience == null ? DBNull.Value : profile.Experience.Text));
+
+ await cmd.ExecuteNonQueryAsync().ConfigureAwait(false);
+ }
}
}
diff --git a/src/JobsJobsJobs/Client/ViewModels/ProfileForm.cs b/src/JobsJobsJobs/Shared/Api/ProfileForm.cs
similarity index 94%
rename from src/JobsJobsJobs/Client/ViewModels/ProfileForm.cs
rename to src/JobsJobsJobs/Shared/Api/ProfileForm.cs
index fa6c19c..65ee9fe 100644
--- a/src/JobsJobsJobs/Client/ViewModels/ProfileForm.cs
+++ b/src/JobsJobsJobs/Shared/Api/ProfileForm.cs
@@ -1,7 +1,6 @@
-using JobsJobsJobs.Shared;
-using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations;
-namespace JobsJobsJobs.Client.ViewModels
+namespace JobsJobsJobs.Shared.Api
{
///
/// The data required to update a profile