From fe3510b818a6d139deb0c604df20995e93fc784a Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sun, 27 Dec 2020 22:30:07 -0500 Subject: [PATCH] Skills now saved / returned Edging closer to #2 completion; need to finish styles, create display page, and put some interesting stuff on the dashboard --- .../Client/Pages/Citizen/EditProfile.razor | 187 ++++++++++-------- .../Client/Pages/Citizen/EditProfile.razor.cs | 81 ++++++-- src/JobsJobsJobs/Client/ServerApi.cs | 25 +++ .../Client/Shared/SkillEdit.razor | 22 +++ .../Client/Shared/SkillEdit.razor.cs | 29 +++ .../Api/Controllers/ProfileController.cs | 41 +++- .../Server/Data/ProfileExtensions.cs | 79 ++++++++ src/JobsJobsJobs/Server/Startup.cs | 11 +- src/JobsJobsJobs/Shared/Api/ProfileForm.cs | 13 +- src/JobsJobsJobs/Shared/Api/SkillForm.cs | 28 +++ src/JobsJobsJobs/Shared/Domain/SkillId.cs | 10 + 11 files changed, 424 insertions(+), 102 deletions(-) create mode 100644 src/JobsJobsJobs/Client/Shared/SkillEdit.razor create mode 100644 src/JobsJobsJobs/Client/Shared/SkillEdit.razor.cs create mode 100644 src/JobsJobsJobs/Shared/Api/SkillForm.cs diff --git a/src/JobsJobsJobs/Client/Pages/Citizen/EditProfile.razor b/src/JobsJobsJobs/Client/Pages/Citizen/EditProfile.razor index 40eeb39..ac2d451 100644 --- a/src/JobsJobsJobs/Client/Pages/Citizen/EditProfile.razor +++ b/src/JobsJobsJobs/Client/Pages/Citizen/EditProfile.razor @@ -2,88 +2,115 @@

Employment Profile

-@if (ErrorMessage != "") +@if (AllLoaded) { -

@ErrorMessage

+ @if (ErrorMessages.Count > 0) + { +

Error

+ @foreach (var msg in ErrorMessages) + { +

@msg

+ } + } + else + { + + +
+
+
+ + +
+
+
+
+
+
+ + + + @foreach (var (id, name) in Continents) + { + + } + + +
+
+
+
+ + + +
+
+
+
+
+
+ + + +
+
+
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+

+ Skills   + +

+ @foreach (var skill in ProfileForm.Skills) + { + + } +
+

Experience

+

+ This application does not have a place to individually list your chronological job history; however, you can + use this area to list prior jobs, their dates, and anything else you want to include that’s not already a + part of your Professional Biography above. +

+
+
+ +
+
+
+
+
+ + +
+
+
+
+
+
+ +
+
+
+ } } else { - - -
-
-
- - -
-
-
-
-
-
- - - - @foreach (var (id, name) in Continents) - { - -} - - -
-
-
-
- - - -
-
-
-
-
-
- - - -
-
-
-
-
-
- - -
-
-
-
- - -
-
-
-
-
- - -
-
-
-
-
- - -
-
-
-
-
-
- -
-
-
+

Loading your profile...

} diff --git a/src/JobsJobsJobs/Client/Pages/Citizen/EditProfile.razor.cs b/src/JobsJobsJobs/Client/Pages/Citizen/EditProfile.razor.cs index 5a0f199..7efc578 100644 --- a/src/JobsJobsJobs/Client/Pages/Citizen/EditProfile.razor.cs +++ b/src/JobsJobsJobs/Client/Pages/Citizen/EditProfile.razor.cs @@ -14,6 +14,16 @@ namespace JobsJobsJobs.Client.Pages.Citizen /// public partial class EditProfile : ComponentBase { + /// + /// Counter for IDs when "Add a Skill" button is clicked + /// + private int _newSkillCounter = 0; + + /// + /// A flag that indicates all the required API calls have completed, and the form is ready to be displayed + /// + private bool AllLoaded { get; set; } = false; + /// /// The form for this page /// @@ -25,9 +35,9 @@ namespace JobsJobsJobs.Client.Pages.Citizen private IEnumerable Continents { get; set; } = Enumerable.Empty(); /// - /// Error message from API access + /// Error messages from API access /// - private string ErrorMessage { get; set; } = ""; + private IList ErrorMessages { get; } = new List(); /// /// HTTP client instance to use for API access @@ -44,30 +54,77 @@ namespace JobsJobsJobs.Client.Pages.Citizen protected override async Task OnInitializedAsync() { ServerApi.SetJwt(Http, State); - var continentResult = await ServerApi.AllContinents(Http, State); - if (continentResult.IsOk) + var continentTask = ServerApi.RetrieveMany(Http, "continent/all"); + var profileTask = ServerApi.RetrieveProfile(Http, State); + var skillTask = ServerApi.RetrieveMany(Http, "profile/skills"); + + await Task.WhenAll(continentTask, profileTask, skillTask); + + if (continentTask.Result.IsOk) { - Continents = continentResult.Ok; + Continents = continentTask.Result.Ok; } else { - ErrorMessage = continentResult.Error; + ErrorMessages.Add(continentTask.Result.Error); } - var result = await ServerApi.RetrieveProfile(Http, State); - if (result.IsOk) + if (profileTask.Result.IsOk) { - System.Console.WriteLine($"Result is null? {result.Ok == null}"); - ProfileForm = (result.Ok == null) ? new ProfileForm() : ProfileForm.FromProfile(result.Ok); + ProfileForm = (profileTask.Result.Ok == null) + ? new ProfileForm() + : ProfileForm.FromProfile(profileTask.Result.Ok); } else { - ErrorMessage = result.Error; + ErrorMessages.Add(profileTask.Result.Error); } + + if (skillTask.Result.IsOk) + { + foreach (var skill in skillTask.Result.Ok) + { + ProfileForm.Skills.Add(new SkillForm + { + Id = skill.Id.ToString(), + Description = skill.Description, + Notes = skill.Notes ?? "" + }); + } + if (ProfileForm.Skills.Count == 0) AddNewSkill(); + } + else + { + ErrorMessages.Add(skillTask.Result.Error); + } + + AllLoaded = true; } + /// + /// Add a new skill to the form + /// + private void AddNewSkill() => + ProfileForm.Skills.Add(new SkillForm { Id = $"new{_newSkillCounter++}" }); + + /// + /// Remove the skill for the given ID + /// + /// The ID of the skill to remove + private void RemoveSkill(string skillId) => + ProfileForm.Skills.Remove(ProfileForm.Skills.First(s => s.Id == skillId)); + + /// + /// Save changes to the current profile + /// public async Task SaveProfile() { + // Remove any skills left blank + var blankSkills = ProfileForm.Skills + .Where(s => string.IsNullOrEmpty(s.Description) && string.IsNullOrEmpty(s.Notes)) + .ToList(); + foreach (var blankSkill in blankSkills) ProfileForm.Skills.Remove(blankSkill); + var res = await Http.PostAsJsonAsync("/api/profile/save", ProfileForm); if (res.IsSuccessStatusCode) { @@ -76,7 +133,7 @@ namespace JobsJobsJobs.Client.Pages.Citizen else { // TODO: probably not the best way to handle this... - ErrorMessage = await res.Content.ReadAsStringAsync(); + ErrorMessages.Add(await res.Content.ReadAsStringAsync()); } } diff --git a/src/JobsJobsJobs/Client/ServerApi.cs b/src/JobsJobsJobs/Client/ServerApi.cs index 5c57e82..3f69335 100644 --- a/src/JobsJobsJobs/Client/ServerApi.cs +++ b/src/JobsJobsJobs/Client/ServerApi.cs @@ -125,5 +125,30 @@ namespace JobsJobsJobs.Client } return Result>.AsError(await res.Content.ReadAsStringAsync()); } + + /// + /// Retrieve many items from the given URL + /// + /// The type of item expected + /// The HTTP client to use for server communication + /// The API URL to use call + /// A result with the items, or an error if one occurs + /// The caller is responsible for setting the JWT on the HTTP client + public static async Task>> RetrieveMany(HttpClient http, string url) + { + try + { + var results = await http.GetFromJsonAsync>($"/api/{url}", _serializerOptions); + return Result>.AsOk(results ?? Enumerable.Empty()); + } + catch (HttpRequestException ex) + { + return Result>.AsError(ex.Message); + } + catch (JsonException ex) + { + return Result>.AsError($"Unable to parse result: {ex.Message}"); + } + } } } diff --git a/src/JobsJobsJobs/Client/Shared/SkillEdit.razor b/src/JobsJobsJobs/Client/Shared/SkillEdit.razor new file mode 100644 index 0000000..b834709 --- /dev/null +++ b/src/JobsJobsJobs/Client/Shared/SkillEdit.razor @@ -0,0 +1,22 @@ +
+
+
+ +
+
+
+ + + +
+
+
+
+ + + +
+
+
diff --git a/src/JobsJobsJobs/Client/Shared/SkillEdit.razor.cs b/src/JobsJobsJobs/Client/Shared/SkillEdit.razor.cs new file mode 100644 index 0000000..84a0d94 --- /dev/null +++ b/src/JobsJobsJobs/Client/Shared/SkillEdit.razor.cs @@ -0,0 +1,29 @@ +using JobsJobsJobs.Shared.Api; +using Microsoft.AspNetCore.Components; +using System.Threading.Tasks; + +namespace JobsJobsJobs.Client.Shared +{ + /// + /// A component that allows a skill to be edited + /// + public partial class SkillEdit : ComponentBase + { + /// + /// The skill being edited + /// + [Parameter] + public SkillForm Skill { get; set; } = default!; + + /// + /// Callback used if the remove button is clicked + /// + [Parameter] + public EventCallback OnRemove { get; set; } = default!; + + /// + /// Remove this skill from the skill collection + /// + private Task RemoveMe() => OnRemove.InvokeAsync(Skill.Id); + } +} diff --git a/src/JobsJobsJobs/Server/Areas/Api/Controllers/ProfileController.cs b/src/JobsJobsJobs/Server/Areas/Api/Controllers/ProfileController.cs index 0e53f5c..1f66fd5 100644 --- a/src/JobsJobsJobs/Server/Areas/Api/Controllers/ProfileController.cs +++ b/src/JobsJobsJobs/Server/Areas/Api/Controllers/ProfileController.cs @@ -43,25 +43,31 @@ namespace JobsJobsJobs.Server.Areas.Api.Controllers _clock = clock; } + /// + /// The current citizen ID + /// + private CitizenId CurrentCitizenId => CitizenId.Parse(User.FindFirst(ClaimTypes.NameIdentifier)!.Value); + [HttpGet("")] public async Task Get() { await _db.OpenAsync(); - var profile = await _db.FindProfileByCitizen( - CitizenId.Parse(User.FindFirst(ClaimTypes.NameIdentifier)!.Value)); + var profile = await _db.FindProfileByCitizen(CurrentCitizenId); return profile == null ? NoContent() : Ok(profile); } [HttpPost("save")] - public async Task Save([FromBody] ProfileForm form) + public async Task Save(ProfileForm form) { - var citizenId = CitizenId.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)); await _db.OpenAsync(); - var existing = await _db.FindProfileByCitizen(citizenId); + var txn = await _db.BeginTransactionAsync(); + + // Profile + var existing = await _db.FindProfileByCitizen(CurrentCitizenId); 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(), + ? new Profile(CurrentCitizenId, 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 { @@ -76,7 +82,26 @@ namespace JobsJobsJobs.Server.Areas.Api.Controllers Experience = string.IsNullOrEmpty(form.Experience) ? null : new MarkdownString(form.Experience) }; await _db.SaveProfile(profile); + + // Skills + var skills = new List(); + foreach (var skill in form.Skills) { + skills.Add(new Skill(skill.Id.StartsWith("new") ? await SkillId.Create() : SkillId.Parse(skill.Id), + CurrentCitizenId, skill.Description, string.IsNullOrEmpty(skill.Notes) ? null : skill.Notes)); + } + + foreach (var skill in skills) await _db.SaveSkill(skill); + await _db.DeleteMissingSkills(CurrentCitizenId, skills.Select(s => s.Id)); + + await txn.CommitAsync(); return Ok(); } + + [HttpGet("skills")] + public async Task GetSkills() + { + await _db.OpenAsync(); + return Ok(await _db.FindSkillsByCitizen(CurrentCitizenId)); + } } } diff --git a/src/JobsJobsJobs/Server/Data/ProfileExtensions.cs b/src/JobsJobsJobs/Server/Data/ProfileExtensions.cs index e70748a..76c1b04 100644 --- a/src/JobsJobsJobs/Server/Data/ProfileExtensions.cs +++ b/src/JobsJobsJobs/Server/Data/ProfileExtensions.cs @@ -3,6 +3,7 @@ using Npgsql; using System; using System.Collections.Generic; using System.Linq; +using System.Text; using System.Threading.Tasks; namespace JobsJobsJobs.Server.Data @@ -30,6 +31,15 @@ namespace JobsJobsJobs.Server.Data }; } + /// + /// Populate a skill object from the given data reader + /// + /// The data reader from which values should be obtained + /// The populated skill + private static Skill ToSkill(NpgsqlDataReader rdr) => + new Skill(SkillId.Parse(rdr.GetString("id")), CitizenId.Parse(rdr.GetString("citizen_id")), + rdr.GetString("skill"), rdr.IsDBNull("notes") ? null : rdr.GetString("notes")); + /// /// Retrieve an employment profile by a citizen ID /// @@ -88,5 +98,74 @@ namespace JobsJobsJobs.Server.Data await cmd.ExecuteNonQueryAsync().ConfigureAwait(false); } + + /// + /// Retrieve all skills for the given citizen + /// + /// The ID of the citizen whose skills should be retrieved + /// The skills defined for this citizen + public static async Task> FindSkillsByCitizen(this NpgsqlConnection conn, + CitizenId citizenId) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT * FROM skill WHERE citizen_id = @citizen_id"; + cmd.Parameters.Add(new NpgsqlParameter("citizen_id", citizenId.ToString())); + + var result = new List(); + using var rdr = await cmd.ExecuteReaderAsync().ConfigureAwait(false); + while (await rdr.ReadAsync().ConfigureAwait(false)) + { + result.Add(ToSkill(rdr)); + } + + return result; + } + + /// + /// Save a skill + /// + /// The skill to be saved + public static async Task SaveSkill(this NpgsqlConnection conn, Skill skill) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = + @"INSERT INTO skill ( + id, citizen_id, skill, notes + ) VALUES ( + @id, @citizen_id, @skill, @notes + ) ON CONFLICT (id) DO UPDATE + SET skill = @skill, + notes = @notes + WHERE skill.id = excluded.id"; + cmd.Parameters.Add(new NpgsqlParameter("id", skill.Id.ToString())); + cmd.Parameters.Add(new NpgsqlParameter("citizen_id", skill.CitizenId.ToString())); + cmd.Parameters.Add(new NpgsqlParameter("skill", skill.Description)); + cmd.Parameters.Add(new NpgsqlParameter("notes", skill.Notes == null ? DBNull.Value : skill.Notes)); + + await cmd.ExecuteNonQueryAsync().ConfigureAwait(false); + } + + /// + /// Delete any skills that are not in the list of current skill IDs + /// + /// The ID of the citizen to whom the skills belong + /// The IDs of their current skills + public static async Task DeleteMissingSkills(this NpgsqlConnection conn, CitizenId citizenId, + IEnumerable ids) + { + if (!ids.Any()) return; + + var count = 0; + using var cmd = conn.CreateCommand(); + cmd.CommandText = new StringBuilder("DELETE FROM skill WHERE citizen_id = @citizen_id AND id NOT IN (") + .Append(string.Join(", ", ids.Select(_ => $"@id{count++}").ToArray())) + .Append(')') + .ToString(); + cmd.Parameters.Add(new NpgsqlParameter("citizen_id", citizenId.ToString())); + count = 0; + foreach (var id in ids) cmd.Parameters.Add(new NpgsqlParameter($"id{count++}", id.ToString())); + + await cmd.ExecuteNonQueryAsync().ConfigureAwait(false); + } } } diff --git a/src/JobsJobsJobs/Server/Startup.cs b/src/JobsJobsJobs/Server/Startup.cs index dda0659..747abc6 100644 --- a/src/JobsJobsJobs/Server/Startup.cs +++ b/src/JobsJobsJobs/Server/Startup.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.HttpsPolicy; using Microsoft.AspNetCore.ResponseCompression; using Microsoft.Extensions.Configuration; @@ -11,6 +12,7 @@ using NodaTime; using NodaTime.Serialization.SystemTextJson; using Npgsql; using System.Text; +using System.Threading.Tasks; namespace JobsJobsJobs.Server { @@ -79,11 +81,18 @@ namespace JobsJobsJobs.Server app.UseAuthentication(); app.UseAuthorization(); + static Task send404(HttpContext context) + { + context.Response.StatusCode = StatusCodes.Status404NotFound; + return Task.FromResult(0); + } + app.UseEndpoints(endpoints => { endpoints.MapRazorPages(); endpoints.MapControllers(); - endpoints.MapFallbackToFile("index.html"); + endpoints.MapFallback("api/{**slug}", send404); + endpoints.MapFallbackToFile("{**slug}", "index.html"); }); } } diff --git a/src/JobsJobsJobs/Shared/Api/ProfileForm.cs b/src/JobsJobsJobs/Shared/Api/ProfileForm.cs index 65ee9fe..7eb86ed 100644 --- a/src/JobsJobsJobs/Shared/Api/ProfileForm.cs +++ b/src/JobsJobsJobs/Shared/Api/ProfileForm.cs @@ -1,4 +1,5 @@ -using System.ComponentModel.DataAnnotations; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; namespace JobsJobsJobs.Shared.Api { @@ -52,7 +53,17 @@ namespace JobsJobsJobs.Shared.Api /// The user's past experience ///
public string Experience { get; set; } = ""; + + /// + /// The skills for the user + /// + public ICollection Skills { get; set; } = new List(); + /// + /// Create an instance of this form from the given profile + /// + /// The profile off which this form will be based + /// The profile form, popluated with values from the given profile public static ProfileForm FromProfile(Profile profile) => new ProfileForm { diff --git a/src/JobsJobsJobs/Shared/Api/SkillForm.cs b/src/JobsJobsJobs/Shared/Api/SkillForm.cs new file mode 100644 index 0000000..46ec898 --- /dev/null +++ b/src/JobsJobsJobs/Shared/Api/SkillForm.cs @@ -0,0 +1,28 @@ +using System.ComponentModel.DataAnnotations; + +namespace JobsJobsJobs.Shared.Api +{ + /// + /// The fields required for a skill + /// + public class SkillForm + { + /// + /// The ID of this skill + /// + [Required] + public string Id { get; set; } = ""; + + /// + /// The description of the skill + /// + [StringLength(100)] + public string Description { get; set; } = ""; + + /// + /// Notes regarding the skill + /// + [StringLength(100)] + public string? Notes { get; set; } = null; + } +} diff --git a/src/JobsJobsJobs/Shared/Domain/SkillId.cs b/src/JobsJobsJobs/Shared/Domain/SkillId.cs index 8921c3c..d571e7e 100644 --- a/src/JobsJobsJobs/Shared/Domain/SkillId.cs +++ b/src/JobsJobsJobs/Shared/Domain/SkillId.cs @@ -12,5 +12,15 @@ namespace JobsJobsJobs.Shared /// /// A new skill ID public static async Task Create() => new SkillId(await ShortId.Create()); + + /// + /// Attempt to create a skill ID from a string + /// + /// The prospective ID + /// The skill ID + /// If the string is not a valid skill ID + public static SkillId Parse(string id) => new SkillId(ShortId.Parse(id)); + + public override string ToString() => Id.ToString(); } }