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
+ {
+
+
+
+
+
+
+
+
+ Skills
+ Add a Skill
+
+ @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
{
-
-
-
-
-
-
-
-
-
-
+ 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();
}
}