Skills now saved / returned
Edging closer to #2 completion; need to finish styles, create display page, and put some interesting stuff on the dashboard
This commit is contained in:
parent
a48af190fa
commit
fe3510b818
|
@ -2,88 +2,115 @@
|
||||||
|
|
||||||
<h3>Employment Profile</h3>
|
<h3>Employment Profile</h3>
|
||||||
|
|
||||||
@if (ErrorMessage != "")
|
@if (AllLoaded)
|
||||||
{
|
{
|
||||||
<p>@ErrorMessage</p>
|
@if (ErrorMessages.Count > 0)
|
||||||
|
{
|
||||||
|
<p><strong>Error</strong></p>
|
||||||
|
@foreach (var msg in ErrorMessages)
|
||||||
|
{
|
||||||
|
<p>@msg</p>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<EditForm Model="@ProfileForm" OnValidSubmit="@SaveProfile">
|
||||||
|
<DataAnnotationsValidator />
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="col">
|
||||||
|
<div class="form-check">
|
||||||
|
<InputCheckbox id="seeking" class="form-check-input" @bind-Value="@ProfileForm.IsSeekingEmployment" />
|
||||||
|
<label for="seeking" class="form-check-label">I am currently seeking employment</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="col col-xs-12 col-sm-6 col-md-4">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="continentId" class="jjj-required">Continent</label>
|
||||||
|
<InputSelect id="continentId" @bind-Value="@ProfileForm.ContinentId" class="form-control">
|
||||||
|
<option>– Select –</option>
|
||||||
|
@foreach (var (id, name) in Continents)
|
||||||
|
{
|
||||||
|
<option value="@id">@name</option>
|
||||||
|
}
|
||||||
|
</InputSelect>
|
||||||
|
<ValidationMessage For="@(() => ProfileForm.ContinentId)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col col-xs-12 col-sm-6 col-md-8">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="region" class="jjj-required">Region</label>
|
||||||
|
<InputText id="region" @bind-Value="@ProfileForm.Region" class="form-control"
|
||||||
|
placeholder="Country, state, geographic area, etc." />
|
||||||
|
<ValidationMessage For="@(() => ProfileForm.Region)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="col">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="bio" class="jjj-required">Professional Biography</label>
|
||||||
|
<MarkdownEditor Id="bio" @bind-Text="@ProfileForm.Biography" />
|
||||||
|
<ValidationMessage For="@(() => ProfileForm.Biography)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="col col-xs-12 col-sm-12 offset-md-2 col-md-4">
|
||||||
|
<div class="form-check">
|
||||||
|
<InputCheckbox id="isRemote" class="form-check-input" @bind-Value="@ProfileForm.RemoteWork" />
|
||||||
|
<label for="isRemote" class="form-check-label">I am looking for remote work</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col col-xs-12 col-sm-12 col-md-4">
|
||||||
|
<div class="form-check">
|
||||||
|
<InputCheckbox id="isFull" class="form-check-input" @bind-Value="@ProfileForm.FullTime" />
|
||||||
|
<label for="isFull" class="form-check-label">I am looking for full-time work</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
<h4>
|
||||||
|
Skills
|
||||||
|
<button type="button" class="btn btn-outline-primary" @onclick="@AddNewSkill">Add a Skill</button>
|
||||||
|
</h4>
|
||||||
|
@foreach (var skill in ProfileForm.Skills)
|
||||||
|
{
|
||||||
|
<SkillEdit Skill="@skill" OnRemove="@RemoveSkill" />
|
||||||
|
}
|
||||||
|
<hr>
|
||||||
|
<h4>Experience</h4>
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="col">
|
||||||
|
<MarkdownEditor Id="experience" @bind-Text="@ProfileForm.Experience" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="col">
|
||||||
|
<div class="form-check">
|
||||||
|
<InputCheckbox id="isPublic" class="form-check-input" @bind-Value="@ProfileForm.IsPublic" />
|
||||||
|
<label for="isPublic" class="form-check-label">
|
||||||
|
Allow my profile to be searched publicly (outside NA Social)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="col">
|
||||||
|
<br>
|
||||||
|
<button type="submit" class="btn btn-outline-primary">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</EditForm>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<EditForm Model="@ProfileForm" OnValidSubmit="@SaveProfile">
|
<p>Loading your profile...</p>
|
||||||
<DataAnnotationsValidator />
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="col">
|
|
||||||
<div class="form-check">
|
|
||||||
<InputCheckbox id="seeking" class="form-check-input" @bind-Value="@ProfileForm.IsSeekingEmployment" />
|
|
||||||
<label for="seeking" class="form-check-label">I am currently seeking employment</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="col col-xs-12 col-sm-6 col-md-4">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="continentId" class="jjj-required">Continent</label>
|
|
||||||
<InputSelect id="continentId" @bind-Value="@ProfileForm.ContinentId" class="form-control">
|
|
||||||
<option>– Select –</option>
|
|
||||||
@foreach (var (id, name) in Continents)
|
|
||||||
{
|
|
||||||
<option value="@id">@name</option>
|
|
||||||
}
|
|
||||||
</InputSelect>
|
|
||||||
<ValidationMessage For="@(() => ProfileForm.ContinentId)" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col col-xs-12 col-sm-6 col-md-8">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="region" class="jjj-required">Region</label>
|
|
||||||
<InputText id="region" @bind-Value="@ProfileForm.Region" class="form-control" />
|
|
||||||
<ValidationMessage For="@(() => ProfileForm.Region)" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="col">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="bio" class="jjj-required">Professional Biography</label>
|
|
||||||
<MarkdownEditor Id="bio" @bind-Text="@ProfileForm.Biography" />
|
|
||||||
<ValidationMessage For="@(() => ProfileForm.Biography)" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="col col-xs-12 col-sm-12 offset-md-2 col-md-4">
|
|
||||||
<div class="form-check">
|
|
||||||
<InputCheckbox id="isRemote" class="form-check-input" @bind-Value="@ProfileForm.RemoteWork" />
|
|
||||||
<label for="isRemote" class="form-check-label">I am looking for remote work</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col col-xs-12 col-sm-12 col-md-4">
|
|
||||||
<div class="form-check">
|
|
||||||
<InputCheckbox id="isFull" class="form-check-input" @bind-Value="@ProfileForm.FullTime" />
|
|
||||||
<label for="isFull" class="form-check-label">I am looking for full-time work</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="col">
|
|
||||||
<label for="experience">Experience</label>
|
|
||||||
<MarkdownEditor Id="experience" @bind-Text="@ProfileForm.Experience" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="col">
|
|
||||||
<div class="form-check">
|
|
||||||
<InputCheckbox id="isPublic" class="form-check-input" @bind-Value="@ProfileForm.IsPublic" />
|
|
||||||
<label for="isPublic" class="form-check-label">
|
|
||||||
Allow my profile to be searched publicly (outside NA Social)
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="col">
|
|
||||||
<br>
|
|
||||||
<button type="submit" class="btn btn-outline-primary">Save</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</EditForm>
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,6 +14,16 @@ namespace JobsJobsJobs.Client.Pages.Citizen
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class EditProfile : ComponentBase
|
public partial class EditProfile : ComponentBase
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Counter for IDs when "Add a Skill" button is clicked
|
||||||
|
/// </summary>
|
||||||
|
private int _newSkillCounter = 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A flag that indicates all the required API calls have completed, and the form is ready to be displayed
|
||||||
|
/// </summary>
|
||||||
|
private bool AllLoaded { get; set; } = false;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The form for this page
|
/// The form for this page
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
@ -25,9 +35,9 @@ namespace JobsJobsJobs.Client.Pages.Citizen
|
||||||
private IEnumerable<Continent> Continents { get; set; } = Enumerable.Empty<Continent>();
|
private IEnumerable<Continent> Continents { get; set; } = Enumerable.Empty<Continent>();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Error message from API access
|
/// Error messages from API access
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private string ErrorMessage { get; set; } = "";
|
private IList<string> ErrorMessages { get; } = new List<string>();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// HTTP client instance to use for API access
|
/// HTTP client instance to use for API access
|
||||||
|
@ -44,30 +54,77 @@ namespace JobsJobsJobs.Client.Pages.Citizen
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
ServerApi.SetJwt(Http, State);
|
ServerApi.SetJwt(Http, State);
|
||||||
var continentResult = await ServerApi.AllContinents(Http, State);
|
var continentTask = ServerApi.RetrieveMany<Continent>(Http, "continent/all");
|
||||||
if (continentResult.IsOk)
|
var profileTask = ServerApi.RetrieveProfile(Http, State);
|
||||||
|
var skillTask = ServerApi.RetrieveMany<Skill>(Http, "profile/skills");
|
||||||
|
|
||||||
|
await Task.WhenAll(continentTask, profileTask, skillTask);
|
||||||
|
|
||||||
|
if (continentTask.Result.IsOk)
|
||||||
{
|
{
|
||||||
Continents = continentResult.Ok;
|
Continents = continentTask.Result.Ok;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
ErrorMessage = continentResult.Error;
|
ErrorMessages.Add(continentTask.Result.Error);
|
||||||
}
|
}
|
||||||
|
|
||||||
var result = await ServerApi.RetrieveProfile(Http, State);
|
if (profileTask.Result.IsOk)
|
||||||
if (result.IsOk)
|
|
||||||
{
|
{
|
||||||
System.Console.WriteLine($"Result is null? {result.Ok == null}");
|
ProfileForm = (profileTask.Result.Ok == null)
|
||||||
ProfileForm = (result.Ok == null) ? new ProfileForm() : ProfileForm.FromProfile(result.Ok);
|
? new ProfileForm()
|
||||||
|
: ProfileForm.FromProfile(profileTask.Result.Ok);
|
||||||
}
|
}
|
||||||
else
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Add a new skill to the form
|
||||||
|
/// </summary>
|
||||||
|
private void AddNewSkill() =>
|
||||||
|
ProfileForm.Skills.Add(new SkillForm { Id = $"new{_newSkillCounter++}" });
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Remove the skill for the given ID
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="skillId">The ID of the skill to remove</param>
|
||||||
|
private void RemoveSkill(string skillId) =>
|
||||||
|
ProfileForm.Skills.Remove(ProfileForm.Skills.First(s => s.Id == skillId));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Save changes to the current profile
|
||||||
|
/// </summary>
|
||||||
public async Task SaveProfile()
|
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);
|
var res = await Http.PostAsJsonAsync("/api/profile/save", ProfileForm);
|
||||||
if (res.IsSuccessStatusCode)
|
if (res.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
|
@ -76,7 +133,7 @@ namespace JobsJobsJobs.Client.Pages.Citizen
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// TODO: probably not the best way to handle this...
|
// TODO: probably not the best way to handle this...
|
||||||
ErrorMessage = await res.Content.ReadAsStringAsync();
|
ErrorMessages.Add(await res.Content.ReadAsStringAsync());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -125,5 +125,30 @@ namespace JobsJobsJobs.Client
|
||||||
}
|
}
|
||||||
return Result<IEnumerable<Continent>>.AsError(await res.Content.ReadAsStringAsync());
|
return Result<IEnumerable<Continent>>.AsError(await res.Content.ReadAsStringAsync());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieve many items from the given URL
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="T">The type of item expected</typeparam>
|
||||||
|
/// <param name="http">The HTTP client to use for server communication</param>
|
||||||
|
/// <param name="url">The API URL to use call</param>
|
||||||
|
/// <returns>A result with the items, or an error if one occurs</returns>
|
||||||
|
/// <remarks>The caller is responsible for setting the JWT on the HTTP client</remarks>
|
||||||
|
public static async Task<Result<IEnumerable<T>>> RetrieveMany<T>(HttpClient http, string url)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var results = await http.GetFromJsonAsync<IEnumerable<T>>($"/api/{url}", _serializerOptions);
|
||||||
|
return Result<IEnumerable<T>>.AsOk(results ?? Enumerable.Empty<T>());
|
||||||
|
}
|
||||||
|
catch (HttpRequestException ex)
|
||||||
|
{
|
||||||
|
return Result<IEnumerable<T>>.AsError(ex.Message);
|
||||||
|
}
|
||||||
|
catch (JsonException ex)
|
||||||
|
{
|
||||||
|
return Result<IEnumerable<T>>.AsError($"Unable to parse result: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
22
src/JobsJobsJobs/Client/Shared/SkillEdit.razor
Normal file
22
src/JobsJobsJobs/Client/Shared/SkillEdit.razor
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="col col-xs-2 col-sm-2 col-md-1">
|
||||||
|
<br>
|
||||||
|
<button type="button" class="btn btn-outline-danger" title="Delete" @onclick="@RemoveMe">−</button>
|
||||||
|
</div>
|
||||||
|
<div class="col col-xs-10 col-sm-10 col-md-6">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="skillDesc@(Skill.Id)">Skill</label>
|
||||||
|
<input type="text" id="skillDesc@(Skill.Id)" @bind="@Skill.Description" class="form-control" maxlength="100"
|
||||||
|
placeholder="A skill (language, design technique, process, etc.)">
|
||||||
|
<ValidationMessage For="@(() => Skill.Description)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col col-xs-12 col-sm-12 col-md-5">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="skillNotes@(Skill.Id)">Notes</label>
|
||||||
|
<input type="text" id="skillNotes@(Skill.Id)" @bind="@Skill.Notes" class="form-control" maxlength="100"
|
||||||
|
placeholder="A further description of the skill (100 characters max)">
|
||||||
|
<ValidationMessage For="@(() => Skill.Notes)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
29
src/JobsJobsJobs/Client/Shared/SkillEdit.razor.cs
Normal file
29
src/JobsJobsJobs/Client/Shared/SkillEdit.razor.cs
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
using JobsJobsJobs.Shared.Api;
|
||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace JobsJobsJobs.Client.Shared
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// A component that allows a skill to be edited
|
||||||
|
/// </summary>
|
||||||
|
public partial class SkillEdit : ComponentBase
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The skill being edited
|
||||||
|
/// </summary>
|
||||||
|
[Parameter]
|
||||||
|
public SkillForm Skill { get; set; } = default!;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Callback used if the remove button is clicked
|
||||||
|
/// </summary>
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback<string> OnRemove { get; set; } = default!;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Remove this skill from the skill collection
|
||||||
|
/// </summary>
|
||||||
|
private Task RemoveMe() => OnRemove.InvokeAsync(Skill.Id);
|
||||||
|
}
|
||||||
|
}
|
|
@ -43,25 +43,31 @@ namespace JobsJobsJobs.Server.Areas.Api.Controllers
|
||||||
_clock = clock;
|
_clock = clock;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The current citizen ID
|
||||||
|
/// </summary>
|
||||||
|
private CitizenId CurrentCitizenId => CitizenId.Parse(User.FindFirst(ClaimTypes.NameIdentifier)!.Value);
|
||||||
|
|
||||||
[HttpGet("")]
|
[HttpGet("")]
|
||||||
public async Task<IActionResult> Get()
|
public async Task<IActionResult> Get()
|
||||||
{
|
{
|
||||||
await _db.OpenAsync();
|
await _db.OpenAsync();
|
||||||
var profile = await _db.FindProfileByCitizen(
|
var profile = await _db.FindProfileByCitizen(CurrentCitizenId);
|
||||||
CitizenId.Parse(User.FindFirst(ClaimTypes.NameIdentifier)!.Value));
|
|
||||||
return profile == null ? NoContent() : Ok(profile);
|
return profile == null ? NoContent() : Ok(profile);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("save")]
|
[HttpPost("save")]
|
||||||
public async Task<IActionResult> Save([FromBody] ProfileForm form)
|
public async Task<IActionResult> Save(ProfileForm form)
|
||||||
{
|
{
|
||||||
var citizenId = CitizenId.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier));
|
|
||||||
await _db.OpenAsync();
|
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
|
var profile = existing == null
|
||||||
? new Profile(citizenId, form.IsSeekingEmployment, form.IsPublic, ContinentId.Parse(form.ContinentId),
|
? new Profile(CurrentCitizenId, form.IsSeekingEmployment, form.IsPublic,
|
||||||
form.Region, form.RemoteWork, form.FullTime, new MarkdownString(form.Biography),
|
ContinentId.Parse(form.ContinentId), form.Region, form.RemoteWork, form.FullTime,
|
||||||
_clock.GetCurrentInstant(),
|
new MarkdownString(form.Biography), _clock.GetCurrentInstant(),
|
||||||
string.IsNullOrEmpty(form.Experience) ? null : new MarkdownString(form.Experience))
|
string.IsNullOrEmpty(form.Experience) ? null : new MarkdownString(form.Experience))
|
||||||
: existing with
|
: existing with
|
||||||
{
|
{
|
||||||
|
@ -76,7 +82,26 @@ namespace JobsJobsJobs.Server.Areas.Api.Controllers
|
||||||
Experience = string.IsNullOrEmpty(form.Experience) ? null : new MarkdownString(form.Experience)
|
Experience = string.IsNullOrEmpty(form.Experience) ? null : new MarkdownString(form.Experience)
|
||||||
};
|
};
|
||||||
await _db.SaveProfile(profile);
|
await _db.SaveProfile(profile);
|
||||||
|
|
||||||
|
// Skills
|
||||||
|
var skills = new List<Skill>();
|
||||||
|
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();
|
return Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("skills")]
|
||||||
|
public async Task<IActionResult> GetSkills()
|
||||||
|
{
|
||||||
|
await _db.OpenAsync();
|
||||||
|
return Ok(await _db.FindSkillsByCitizen(CurrentCitizenId));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,7 @@ using Npgsql;
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
namespace JobsJobsJobs.Server.Data
|
namespace JobsJobsJobs.Server.Data
|
||||||
|
@ -30,6 +31,15 @@ namespace JobsJobsJobs.Server.Data
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Populate a skill object from the given data reader
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="rdr">The data reader from which values should be obtained</param>
|
||||||
|
/// <returns>The populated skill</returns>
|
||||||
|
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"));
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Retrieve an employment profile by a citizen ID
|
/// Retrieve an employment profile by a citizen ID
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
@ -88,5 +98,74 @@ namespace JobsJobsJobs.Server.Data
|
||||||
|
|
||||||
await cmd.ExecuteNonQueryAsync().ConfigureAwait(false);
|
await cmd.ExecuteNonQueryAsync().ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieve all skills for the given citizen
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="citizenId">The ID of the citizen whose skills should be retrieved</param>
|
||||||
|
/// <returns>The skills defined for this citizen</returns>
|
||||||
|
public static async Task<IEnumerable<Skill>> 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<Skill>();
|
||||||
|
using var rdr = await cmd.ExecuteReaderAsync().ConfigureAwait(false);
|
||||||
|
while (await rdr.ReadAsync().ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
result.Add(ToSkill(rdr));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Save a skill
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="skill">The skill to be saved</param>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Delete any skills that are not in the list of current skill IDs
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="citizenId">The ID of the citizen to whom the skills belong</param>
|
||||||
|
/// <param name="ids">The IDs of their current skills</param>
|
||||||
|
public static async Task DeleteMissingSkills(this NpgsqlConnection conn, CitizenId citizenId,
|
||||||
|
IEnumerable<SkillId> 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
using Microsoft.AspNetCore.Builder;
|
using Microsoft.AspNetCore.Builder;
|
||||||
using Microsoft.AspNetCore.Hosting;
|
using Microsoft.AspNetCore.Hosting;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.HttpsPolicy;
|
using Microsoft.AspNetCore.HttpsPolicy;
|
||||||
using Microsoft.AspNetCore.ResponseCompression;
|
using Microsoft.AspNetCore.ResponseCompression;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
|
@ -11,6 +12,7 @@ using NodaTime;
|
||||||
using NodaTime.Serialization.SystemTextJson;
|
using NodaTime.Serialization.SystemTextJson;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
namespace JobsJobsJobs.Server
|
namespace JobsJobsJobs.Server
|
||||||
{
|
{
|
||||||
|
@ -79,11 +81,18 @@ namespace JobsJobsJobs.Server
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
|
|
||||||
|
static Task send404(HttpContext context)
|
||||||
|
{
|
||||||
|
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||||
|
return Task.FromResult(0);
|
||||||
|
}
|
||||||
|
|
||||||
app.UseEndpoints(endpoints =>
|
app.UseEndpoints(endpoints =>
|
||||||
{
|
{
|
||||||
endpoints.MapRazorPages();
|
endpoints.MapRazorPages();
|
||||||
endpoints.MapControllers();
|
endpoints.MapControllers();
|
||||||
endpoints.MapFallbackToFile("index.html");
|
endpoints.MapFallback("api/{**slug}", send404);
|
||||||
|
endpoints.MapFallbackToFile("{**slug}", "index.html");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
using System.ComponentModel.DataAnnotations;
|
using System.Collections.Generic;
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
namespace JobsJobsJobs.Shared.Api
|
namespace JobsJobsJobs.Shared.Api
|
||||||
{
|
{
|
||||||
|
@ -53,6 +54,16 @@ namespace JobsJobsJobs.Shared.Api
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Experience { get; set; } = "";
|
public string Experience { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The skills for the user
|
||||||
|
/// </summary>
|
||||||
|
public ICollection<SkillForm> Skills { get; set; } = new List<SkillForm>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Create an instance of this form from the given profile
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="profile">The profile off which this form will be based</param>
|
||||||
|
/// <returns>The profile form, popluated with values from the given profile</returns>
|
||||||
public static ProfileForm FromProfile(Profile profile) =>
|
public static ProfileForm FromProfile(Profile profile) =>
|
||||||
new ProfileForm
|
new ProfileForm
|
||||||
{
|
{
|
||||||
|
|
28
src/JobsJobsJobs/Shared/Api/SkillForm.cs
Normal file
28
src/JobsJobsJobs/Shared/Api/SkillForm.cs
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace JobsJobsJobs.Shared.Api
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The fields required for a skill
|
||||||
|
/// </summary>
|
||||||
|
public class SkillForm
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The ID of this skill
|
||||||
|
/// </summary>
|
||||||
|
[Required]
|
||||||
|
public string Id { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The description of the skill
|
||||||
|
/// </summary>
|
||||||
|
[StringLength(100)]
|
||||||
|
public string Description { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Notes regarding the skill
|
||||||
|
/// </summary>
|
||||||
|
[StringLength(100)]
|
||||||
|
public string? Notes { get; set; } = null;
|
||||||
|
}
|
||||||
|
}
|
|
@ -12,5 +12,15 @@ namespace JobsJobsJobs.Shared
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>A new skill ID</returns>
|
/// <returns>A new skill ID</returns>
|
||||||
public static async Task<SkillId> Create() => new SkillId(await ShortId.Create());
|
public static async Task<SkillId> Create() => new SkillId(await ShortId.Create());
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attempt to create a skill ID from a string
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">The prospective ID</param>
|
||||||
|
/// <returns>The skill ID</returns>
|
||||||
|
/// <exception cref="System.FormatException">If the string is not a valid skill ID</exception>
|
||||||
|
public static SkillId Parse(string id) => new SkillId(ShortId.Parse(id));
|
||||||
|
|
||||||
|
public override string ToString() => Id.ToString();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue
Block a user