Profile save/load works

Still need to add skills, but #2 is getting there...
This commit is contained in:
Daniel J. Summers 2020-12-27 15:38:24 -05:00
parent 1e474395a9
commit a48af190fa
10 changed files with 297 additions and 139 deletions

View File

@ -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()

View File

@ -0,0 +1,89 @@
@page "/citizen/profile"
<h3>Employment Profile</h3>
@if (ErrorMessage != "")
{
<p>@ErrorMessage</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>&ndash; Select &ndash;</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>
}

View File

@ -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
{
/// <summary>
/// Profile edit page (called EditProfile so as not to create naming conflicts)
/// </summary>
public partial class EditProfile : ComponentBase
{
/// <summary>
/// The form for this page
/// </summary>
private ProfileForm ProfileForm { get; set; } = new ProfileForm();
/// <summary>
/// All continents
/// </summary>
private IEnumerable<Continent> Continents { get; set; } = Enumerable.Empty<Continent>();
/// <summary>
/// Error message from API access
/// </summary>
private string ErrorMessage { get; set; } = "";
/// <summary>
/// HTTP client instance to use for API access
/// </summary>
[Inject]
private HttpClient Http { get; set; } = default!;
/// <summary>
/// Application state
/// </summary>
[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();
}
}
}
}

View File

@ -1,130 +0,0 @@
@page "/citizen/profile"
@using JobsJobsJobs.Client.ViewModels
@inject HttpClient http
@inject AppState state
<h3>Employment Profile</h3>
@if (errorMessage != "")
{
<p>@errorMessage</p>
}
else if (profileForm != null)
{
<EditForm Model="@profileForm" OnValidSubmit="@SaveProfile">
<DataAnnotationsValidator />
<div class="form-row">
<div class="col col-xs-12 col-sm-12 col-md-4">
<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 class="col col-xs-12 col-sm-12 col-md-8">
<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 col-xs-12 col-sm-12 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-8">
<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 col-xs-12 col-sm-6 col-md-4">
<div class="form-group">
<label for="continentId">Continent</label>
<InputSelect id="continentId" @bind-Value="@profileForm.ContinentId" class="form-control">
<option>&ndash; Select &ndash;</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">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">Professional Biography</label>
<MarkdownEditor Id="bio" @bind-Text="@profileForm.Biography" />
<ValidationMessage For="@(() => profileForm.Biography)" />
</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">
<br>
<button type="submit" class="btn btn-outline-primary">Save</button>
</div>
</div>
</EditForm>
}
@code {
public JobsJobsJobs.Shared.Profile? profile = null;
public ProfileForm? profileForm = null;
private IEnumerable<Continent> continents = Enumerable.Empty<Continent>();
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
}
}

View File

@ -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
/// </summary>
public static class ServerApi
{
/// <summary>
/// System.Text.Json options configured for NodaTime
/// </summary>
private static readonly JsonSerializerOptions _serializerOptions;
/// <summary>
/// Static constructor
/// </summary>
static ServerApi()
{
var options = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
options.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb);
_serializerOptions = options;
}
/// <summary>
/// Create an API URL
/// </summary>
@ -37,6 +59,14 @@ namespace JobsJobsJobs.Client
return req;
}
/// <summary>
/// Set the JSON Web Token (JWT) bearer header for the given HTTP client
/// </summary>
/// <param name="http">The HTTP client whose authentication header should be set</param>
/// <param name="state">The current application state</param>
public static void SetJwt(HttpClient http, AppState state) =>
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", state.Jwt);
/// <summary>
/// Log on a user with the authorization code received from No Agenda Social
/// </summary>
@ -73,7 +103,8 @@ namespace JobsJobsJobs.Client
return true switch
{
_ when res.StatusCode == HttpStatusCode.NoContent => Result<Profile?>.AsOk(null),
_ when res.IsSuccessStatusCode => Result<Profile?>.AsOk(await res.Content.ReadFromJsonAsync<Profile>()),
_ when res.IsSuccessStatusCode => Result<Profile?>.AsOk(
await res.Content.ReadFromJsonAsync<Profile>(_serializerOptions)),
_ => Result<Profile?>.AsError(await res.Content.ReadAsStringAsync()),
};
}

View File

@ -1,4 +1,5 @@
section.preview {
border: solid 1px darkgray;
border-radius: 2rem;
border-radius: .5rem;
padding: .25rem;
}

View File

@ -56,3 +56,12 @@ a.audio {
a.audio:hover {
cursor: pointer;
}
label.jjj-required {
font-weight:bold;
}
label.jjj-required::after {
color: red;
content: ' *';
}

View File

@ -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
/// </summary>
[Route("api/[controller]")]
[Authorize]
[ApiController]
public class ProfileController : ControllerBase
{
@ -25,16 +28,21 @@ namespace JobsJobsJobs.Server.Areas.Api.Controllers
/// </summary>
private readonly NpgsqlConnection _db;
/// <summary>
/// The NodaTime clock instance
/// </summary>
private readonly IClock _clock;
/// <summary>
/// Constructor
/// </summary>
/// <param name="db">The database connection to use for this request</param>
public ProfileController(NpgsqlConnection db)
public ProfileController(NpgsqlConnection db, IClock clock)
{
_db = db;
_clock = clock;
}
[Authorize]
[HttpGet("")]
public async Task<IActionResult> 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<IActionResult> 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();
}
}
}

View File

@ -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;
}
/// <summary>
/// Save a profile
/// </summary>
/// <param name="profile">The profile to be saved</param>
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);
}
}
}

View File

@ -1,7 +1,6 @@
using JobsJobsJobs.Shared;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations;
namespace JobsJobsJobs.Client.ViewModels
namespace JobsJobsJobs.Shared.Api
{
/// <summary>
/// The data required to update a profile