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 +{ + + +
+
+
+ + +
+
+
+
+
+
+ + + + @foreach (var (id, name) in Continents) + { + +} + + +
+
+
+
+ + + +
+
+
+
+
+
+ + + +
+
+
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+
+ + +
+
+
+
+
+ + +
+
+
+
+
+
+ +
+
+
+} 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) -{ - - -
-
-
- - -
-
-
-
- - -
-
-
-
-
-
- - -
-
-
-
- - -
-
-
-
-
-
- - - - @foreach (var (id, name) in continents) - { - - } - - -
-
-
-
- - - -
-
-
-
-
-
- - - -
-
-
-
-
- - -
-
-
-
-
- -
-
-
-} - -@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