Add success story add/edit and list (#4)

still a work in progress
This commit is contained in:
Daniel J. Summers 2021-01-21 23:05:27 -05:00
parent 340b93c6d7
commit 7f7eb191fb
15 changed files with 484 additions and 29 deletions

View File

@ -20,6 +20,13 @@ else
lists @Profile.Skills.Length skill@(Profile.Skills.Length != 1 ? "s" : "").
</p>
<p><a href="/profile/view/@state.User.Id">View Your Employment Profile</a></p>
@if (Profile.SeekingEmployment)
{
<p>
Your profile indicates that you are seeking employment. Once you find it,
<a href="/success-story/add">tell your fellow citizens about it!</a>
</p>
}
}
else
{

View File

@ -18,6 +18,12 @@
<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>
@if (IsSeeking)
{
<em>&nbsp; &nbsp; If you have found employment, consider
<a href="/success-story/add">telling your fellow citizens about it</a>
</em>
}
</div>
</div>
</div>

View File

@ -23,6 +23,12 @@ namespace JobsJobsJobs.Client.Pages.Citizen
/// </summary>
private bool AllLoaded { get; set; } = false;
/// <summary>
/// Whether the citizen is seeking employment at the time the profile is loaded (used to show success story
/// link)
/// </summary>
private bool IsSeeking { get; set; } = false;
/// <summary>
/// The form for this page
/// </summary>
@ -63,6 +69,7 @@ namespace JobsJobsJobs.Client.Pages.Citizen
else
{
ProfileForm = ProfileForm.FromProfile(profileTask.Result.Ok);
IsSeeking = profileTask.Result.Ok.SeekingEmployment;
}
if (ProfileForm.Skills.Count == 0) AddNewSkill();
}

View File

@ -0,0 +1,57 @@
@page "/success-story/add"
@page "/success-story/edit/{Id}"
@inject HttpClient http
@inject AppState state
@inject NavigationManager nav
@inject IToastService toast
<PageTitle Title=@Title />
<h3>@Title</h3>
<ErrorList Errors=@ErrorMessages>
@if (Loading)
{
<p>Loading...</p>
}
else
{
@if (IsNew)
{
<p>
Congratulations on your employment! Your fellow citizens would enjoy hearing how it all came about; tell us
about it below! <em>(These will be visible to other users, but not to the general public.)</em>
</p>
}
<EditForm Model=@Form OnValidSubmit=@SaveStory>
<div class="form-row">
<div class="col">
<div class="form-check">
<InputCheckbox id="fromHere" class="form-check-input" @bind-Value=@Form.FromHere />
<label for="fromHere" class="form-check-label">I found my employment here</label>
</div>
</div>
</div>
<div class="form-row">
<div class="col">
<div class="form-group">
<label for="story" class="jjj-label">The Success Story</label>
<MarkdownEditor Id="story" @bind-Text=@Form.Story />
</div>
</div>
</div>
<div class="form-row">
<div class="col">
<br>
<button type="submit" class="btn btn-outline-primary">Save</button>
@if (IsNew)
{
<p>
<em>(Saving this will set &ldquo;Seeking Employment&rdquo; to &ldquo;No&rdquo; on your profile.)</em>
</p>
}
</div>
</div>
</EditForm>
}
</ErrorList>

View File

@ -0,0 +1,124 @@
using JobsJobsJobs.Shared;
using JobsJobsJobs.Shared.Api;
using Microsoft.AspNetCore.Components;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Json;
using System.Threading.Tasks;
namespace JobsJobsJobs.Client.Pages.SuccessStory
{
public partial class EditStory : ComponentBase
{
/// <summary>
/// The ID of the success story being edited
/// </summary>
[Parameter]
public string? Id { get; set; }
/// <summary>
/// Whether we are loading information
/// </summary>
private bool Loading { get; set; } = true;
/// <summary>
/// The page title / header
/// </summary>
public string Title => IsNew ? "Tell Your Success Story" : "Edit Success Story";
/// <summary>
/// The form with information for the success story
/// </summary>
private StoryForm Form { get; set; } = new StoryForm();
/// <summary>
/// Convenience property for showing new
/// </summary>
private bool IsNew => Form.Id == "new";
/// <summary>
/// Error messages from API access
/// </summary>
private IList<string> ErrorMessages { get; } = new List<string>();
protected override async Task OnInitializedAsync()
{
if (Id != null)
{
ServerApi.SetJwt(http, state);
var story = await ServerApi.RetrieveOne<Success>(http, $"success/{Id}");
if (story.IsOk && story.Ok != null)
{
Form = new StoryForm
{
Id = story.Ok.Id.ToString(),
FromHere = story.Ok.FromHere,
Story = story.Ok.Story?.Text ?? ""
};
}
else if (story.IsOk)
{
ErrorMessages.Add($"The success story {Id} does not exist");
}
else
{
ErrorMessages.Add(story.Error);
}
}
Loading = false;
}
/// <summary>
/// Save the success story
/// </summary>
private async Task SaveStory()
{
ServerApi.SetJwt(http, state);
var res = await http.PostAsJsonAsync("/api/success/save", Form);
if (res.IsSuccessStatusCode)
{
if (IsNew)
{
res = await http.PatchAsync("/api/profile/employment-found", new StringContent(""));
if (res.IsSuccessStatusCode)
{
SaveSuccessful();
}
else
{
await SaveFailed(res);
}
}
else
{
SaveSuccessful();
}
}
else
{
await SaveFailed(res);
}
}
/// <summary>
/// Handle success notifications if saving succeeded
/// </summary>
private void SaveSuccessful()
{
toast.ShowSuccess("Story Saved Successfully");
nav.NavigateTo("/success-story/list");
}
/// <summary>
/// Handle failure notifications is saving was not successful
/// </summary>
/// <param name="res">The HTTP response</param>
private async Task SaveFailed(HttpResponseMessage res)
{
var error = await res.Content.ReadAsStringAsync();
if (!string.IsNullOrEmpty(error)) error = $"- {error}";
toast.ShowError($"{(int)res.StatusCode} {error}");
}
}
}

View File

@ -0,0 +1,46 @@
@page "/success-story/list"
@inject HttpClient http
@inject AppState state
<PageTitle Title="Success Stories" />
<h3>Success Stories</h3>
<ErrorList Errors=@ErrorMessages>
@if (Loading)
{
<p>Loading...</p>
}
else if (Stories.Any())
{
<table class="table table-sm table-hover">
<thead>
<tr>
<th scope="col">Story</th>
<th scope="col">From</th>
<th scope="col">Recorded On</th>
</tr>
</thead>
<tbody>
@foreach (var story in Stories)
{
<tr>
<td>
<a href="/success-story/view/@story.Id">View</a>
@if (story.CitizenId == state.User!.Id)
{
<text> ~ <a href="/success-story/edit/@story.Id">Edit</a></text>
}
</td>
<td>@story.CitizenName</td>
<td><FullDate TheDate=@story.RecordedOn /></td>
</tr>
}
</tbody>
</table>
}
else
{
<p>There are no success stories recorded <em>(yet)</em></p>
}
</ErrorList>

View File

@ -0,0 +1,44 @@
using JobsJobsJobs.Shared.Api;
using Microsoft.AspNetCore.Components;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace JobsJobsJobs.Client.Pages.SuccessStory
{
public partial class ListStories : ComponentBase
{
/// <summary>
/// Whether we are still loading data
/// </summary>
private bool Loading { get; set; } = true;
/// <summary>
/// The story entries
/// </summary>
private IEnumerable<StoryEntry> Stories { get; set; } = default!;
/// <summary>
/// Error messages encountered
/// </summary>
private IList<string> ErrorMessages { get; set; } = new List<string>();
protected override async Task OnInitializedAsync()
{
ServerApi.SetJwt(http, state);
var stories = await ServerApi.RetrieveMany<StoryEntry>(http, "success/list");
if (stories.IsOk)
{
Stories = stories.Ok;
}
else
{
ErrorMessages.Add(stories.Error);
}
Loading = false;
}
}
}

View File

@ -41,6 +41,11 @@
<span class="oi oi-spreadsheet" aria-hidden="true"></span> View Profiles
</NavLink>
</li>
<li class="nav-item px-3">
<NavLink class="nav-link" href="/success-story/list">
<span class="oi oi-graph" aria-hidden="true"></span> Success Stories
</NavLink>
</li>
<li class="nav-item px-3">
<NavLink class="nav-link" href="/citizen/log-off">
<span class="oi oi-plus" aria-hidden="true"></span> Log Off

View File

@ -2,7 +2,7 @@
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
<Nullable>enable</Nullable>
<AssemblyVersion>0.8.0.0</AssemblyVersion>
<FileVersion>0.8.0.0</FileVersion>
<AssemblyVersion>0.9.0.0</AssemblyVersion>
<FileVersion>0.9.0.0</FileVersion>
</PropertyGroup>
</Project>

View File

@ -33,6 +33,7 @@ namespace JobsJobsJobs.Server.Areas.Api.Controllers
/// Constructor
/// </summary>
/// <param name="db">The data context to use for this request</param>
/// <param name="clock">The clock instance to use for this request</param>
public ProfileController(JobsDbContext db, IClock clock)
{
_db = db;
@ -114,5 +115,20 @@ namespace JobsJobsJobs.Server.Areas.Api.Controllers
[HttpGet("search")]
public async Task<IActionResult> Search([FromQuery] ProfileSearch search) =>
Ok(await _db.SearchProfiles(search));
[HttpPatch("employment-found")]
public async Task<IActionResult> EmploymentFound()
{
var profile = await _db.FindProfileByCitizen(CurrentCitizenId);
if (profile == null) return NotFound();
var updated = profile with { SeekingEmployment = false };
_db.Update(updated);
await _db.SaveChangesAsync();
return Ok();
}
}
}

View File

@ -0,0 +1,79 @@
using JobsJobsJobs.Server.Data;
using JobsJobsJobs.Shared;
using JobsJobsJobs.Shared.Api;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using NodaTime;
using System.Security.Claims;
using System.Threading.Tasks;
namespace JobsJobsJobs.Server.Areas.Api.Controllers
{
/// <summary>
/// API controller for success stories
/// </summary>
[Route("api/[controller]")]
[Authorize]
[ApiController]
public class SuccessController : Controller
{
/// <summary>
/// The data context
/// </summary>
private readonly JobsDbContext _db;
/// <summary>
/// The NodaTime clock instance
/// </summary>
private readonly IClock _clock;
/// <summary>
/// Constructor
/// </summary>
/// <param name="db">The data context to use for this request</param>
/// <param name="clock">The clock instance to use for this request</param>
public SuccessController(JobsDbContext db, IClock clock)
{
_db = db;
_clock = clock;
}
/// <summary>
/// The current citizen ID
/// </summary>
private CitizenId CurrentCitizenId => CitizenId.Parse(User.FindFirst(ClaimTypes.NameIdentifier)!.Value);
[HttpGet("{id}")]
public async Task<IActionResult> Retrieve(string id) =>
Ok(await _db.FindSuccessById(SuccessId.Parse(id)));
[HttpPost("save")]
public async Task<IActionResult> Save([FromBody] StoryForm form)
{
if (form.Id == "new")
{
var story = new Success(await SuccessId.Create(), CurrentCitizenId, _clock.GetCurrentInstant(),
form.FromHere, string.IsNullOrWhiteSpace(form.Story) ? null : new MarkdownString(form.Story));
await _db.AddAsync(story);
}
else
{
var story = await _db.FindSuccessById(SuccessId.Parse(form.Id));
if (story == null) return NotFound();
var updated = story with
{
FromHere = form.FromHere,
Story = string.IsNullOrWhiteSpace(form.Story) ? null : new MarkdownString(form.Story)
};
_db.Update(updated);
}
await _db.SaveChangesAsync();
return Ok();
}
[HttpGet("list")]
public async Task<IActionResult> List() =>
Ok(await _db.AllStories());
}
}

View File

@ -1,11 +1,9 @@
using JobsJobsJobs.Shared;
using JobsJobsJobs.Shared.Api;
using Microsoft.EntityFrameworkCore;
using Npgsql;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace JobsJobsJobs.Server.Data

View File

@ -0,0 +1,34 @@
using JobsJobsJobs.Shared;
using JobsJobsJobs.Shared.Api;
using Microsoft.EntityFrameworkCore;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace JobsJobsJobs.Server.Data
{
/// <summary>
/// Extensions to JobsDbContext to support manipulation of success stories
/// </summary>
public static class SuccessExtensions
{
/// <summary>
/// Get a success story by its ID
/// </summary>
/// <param name="id">The ID of the story to retrieve</param>
/// <returns>The success story, if found</returns>
public static async Task<Success?> FindSuccessById(this JobsDbContext db, SuccessId id) =>
await db.Successes.AsNoTracking().SingleOrDefaultAsync(s => s.Id == id).ConfigureAwait(false);
/// <summary>
/// Get a list of success stories, with the information needed for the list page
/// </summary>
/// <returns>A list of success stories, citizen names, and dates</returns>
public static async Task<IEnumerable<StoryEntry>> AllStories(this JobsDbContext db) =>
await db.Successes
.Join(db.Citizens, s => s.CitizenId, c => c.Id, (s, c) => new { Success = s, Citizen = c })
.OrderByDescending(it => it.Success.RecordedOn)
.Select(it => new StoryEntry(it.Success.Id, it.Citizen.Id, it.Citizen.DisplayName, it.Success.RecordedOn))
.ToListAsync().ConfigureAwait(false);
}
}

View File

@ -0,0 +1,9 @@
using NodaTime;
namespace JobsJobsJobs.Shared.Api
{
/// <summary>
/// An entry in the list of success stories
/// </summary>
public record StoryEntry(SuccessId Id, CitizenId CitizenId, string CitizenName, Instant RecordedOn);
}

View File

@ -0,0 +1,23 @@
namespace JobsJobsJobs.Shared.Api
{
/// <summary>
/// The data required to provide a success story
/// </summary>
public class StoryForm
{
/// <summary>
/// The ID of this story
/// </summary>
public string Id { get; set; } = "new";
/// <summary>
/// Whether the employment was obtained from Jobs, Jobs, Jobs
/// </summary>
public bool FromHere { get; set; } = false;
/// <summary>
/// The success story
/// </summary>
public string Story { get; set; } = "";
}
}