From 7f7eb191fbf996bce188f1b412e4624a72a3a9c0 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Thu, 21 Jan 2021 23:05:27 -0500 Subject: [PATCH] Add success story add/edit and list (#4) still a work in progress --- .../Client/Pages/Citizen/Dashboard.razor | 7 + .../Client/Pages/Citizen/EditProfile.razor | 6 + .../Client/Pages/Citizen/EditProfile.razor.cs | 7 + .../Client/Pages/SuccessStory/EditStory.razor | 57 ++++++++ .../Pages/SuccessStory/EditStory.razor.cs | 124 ++++++++++++++++++ .../Pages/SuccessStory/ListStories.razor | 46 +++++++ .../Pages/SuccessStory/ListStories.razor.cs | 44 +++++++ src/JobsJobsJobs/Client/Shared/NavMenu.razor | 55 ++++---- src/JobsJobsJobs/Directory.Build.props | 4 +- .../Api/Controllers/ProfileController.cs | 16 +++ .../Api/Controllers/SuccessController.cs | 79 +++++++++++ .../Server/Data/ProfileExtensions.cs | 2 - .../Server/Data/SuccessExtensions.cs | 34 +++++ src/JobsJobsJobs/Shared/Api/StoryEntry.cs | 9 ++ src/JobsJobsJobs/Shared/Api/StoryForm.cs | 23 ++++ 15 files changed, 484 insertions(+), 29 deletions(-) create mode 100644 src/JobsJobsJobs/Client/Pages/SuccessStory/EditStory.razor create mode 100644 src/JobsJobsJobs/Client/Pages/SuccessStory/EditStory.razor.cs create mode 100644 src/JobsJobsJobs/Client/Pages/SuccessStory/ListStories.razor create mode 100644 src/JobsJobsJobs/Client/Pages/SuccessStory/ListStories.razor.cs create mode 100644 src/JobsJobsJobs/Server/Areas/Api/Controllers/SuccessController.cs create mode 100644 src/JobsJobsJobs/Server/Data/SuccessExtensions.cs create mode 100644 src/JobsJobsJobs/Shared/Api/StoryEntry.cs create mode 100644 src/JobsJobsJobs/Shared/Api/StoryForm.cs diff --git a/src/JobsJobsJobs/Client/Pages/Citizen/Dashboard.razor b/src/JobsJobsJobs/Client/Pages/Citizen/Dashboard.razor index 30f6e93..51a70ee 100644 --- a/src/JobsJobsJobs/Client/Pages/Citizen/Dashboard.razor +++ b/src/JobsJobsJobs/Client/Pages/Citizen/Dashboard.razor @@ -20,6 +20,13 @@ else lists @Profile.Skills.Length skill@(Profile.Skills.Length != 1 ? "s" : "").

View Your Employment Profile

+ @if (Profile.SeekingEmployment) + { +

+ Your profile indicates that you are seeking employment. Once you find it, + tell your fellow citizens about it! +

+ } } else { diff --git a/src/JobsJobsJobs/Client/Pages/Citizen/EditProfile.razor b/src/JobsJobsJobs/Client/Pages/Citizen/EditProfile.razor index d735359..2ba8578 100644 --- a/src/JobsJobsJobs/Client/Pages/Citizen/EditProfile.razor +++ b/src/JobsJobsJobs/Client/Pages/Citizen/EditProfile.razor @@ -18,6 +18,12 @@
+ @if (IsSeeking) + { +     If you have found employment, consider + telling your fellow citizens about it + + }
diff --git a/src/JobsJobsJobs/Client/Pages/Citizen/EditProfile.razor.cs b/src/JobsJobsJobs/Client/Pages/Citizen/EditProfile.razor.cs index 583cb1d..c62af77 100644 --- a/src/JobsJobsJobs/Client/Pages/Citizen/EditProfile.razor.cs +++ b/src/JobsJobsJobs/Client/Pages/Citizen/EditProfile.razor.cs @@ -23,6 +23,12 @@ namespace JobsJobsJobs.Client.Pages.Citizen /// private bool AllLoaded { get; set; } = false; + /// + /// Whether the citizen is seeking employment at the time the profile is loaded (used to show success story + /// link) + /// + private bool IsSeeking { get; set; } = false; + /// /// The form for this page /// @@ -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(); } diff --git a/src/JobsJobsJobs/Client/Pages/SuccessStory/EditStory.razor b/src/JobsJobsJobs/Client/Pages/SuccessStory/EditStory.razor new file mode 100644 index 0000000..9cb884a --- /dev/null +++ b/src/JobsJobsJobs/Client/Pages/SuccessStory/EditStory.razor @@ -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 + + + +

@Title

+ + + @if (Loading) + { +

Loading...

+ } + else + { + @if (IsNew) + { +

+ Congratulations on your employment! Your fellow citizens would enjoy hearing how it all came about; tell us + about it below! (These will be visible to other users, but not to the general public.) +

+ } + +
+
+
+ + +
+
+
+
+
+
+ + +
+
+
+
+
+
+ + @if (IsNew) + { +

+ (Saving this will set “Seeking Employment” to “No” on your profile.) +

+ } +
+
+
+ } +
diff --git a/src/JobsJobsJobs/Client/Pages/SuccessStory/EditStory.razor.cs b/src/JobsJobsJobs/Client/Pages/SuccessStory/EditStory.razor.cs new file mode 100644 index 0000000..5b9c536 --- /dev/null +++ b/src/JobsJobsJobs/Client/Pages/SuccessStory/EditStory.razor.cs @@ -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 + { + /// + /// The ID of the success story being edited + /// + [Parameter] + public string? Id { get; set; } + + /// + /// Whether we are loading information + /// + private bool Loading { get; set; } = true; + + /// + /// The page title / header + /// + public string Title => IsNew ? "Tell Your Success Story" : "Edit Success Story"; + + /// + /// The form with information for the success story + /// + private StoryForm Form { get; set; } = new StoryForm(); + + /// + /// Convenience property for showing new + /// + private bool IsNew => Form.Id == "new"; + + /// + /// Error messages from API access + /// + private IList ErrorMessages { get; } = new List(); + + protected override async Task OnInitializedAsync() + { + if (Id != null) + { + ServerApi.SetJwt(http, state); + var story = await ServerApi.RetrieveOne(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; + } + + /// + /// Save the success story + /// + 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); + } + } + + /// + /// Handle success notifications if saving succeeded + /// + private void SaveSuccessful() + { + toast.ShowSuccess("Story Saved Successfully"); + nav.NavigateTo("/success-story/list"); + } + + /// + /// Handle failure notifications is saving was not successful + /// + /// The HTTP response + private async Task SaveFailed(HttpResponseMessage res) + { + var error = await res.Content.ReadAsStringAsync(); + if (!string.IsNullOrEmpty(error)) error = $"- {error}"; + toast.ShowError($"{(int)res.StatusCode} {error}"); + } + } +} diff --git a/src/JobsJobsJobs/Client/Pages/SuccessStory/ListStories.razor b/src/JobsJobsJobs/Client/Pages/SuccessStory/ListStories.razor new file mode 100644 index 0000000..bb960ad --- /dev/null +++ b/src/JobsJobsJobs/Client/Pages/SuccessStory/ListStories.razor @@ -0,0 +1,46 @@ +@page "/success-story/list" +@inject HttpClient http +@inject AppState state + + + +

Success Stories

+ + + @if (Loading) + { +

Loading...

+ } + else if (Stories.Any()) + { + + + + + + + + + + @foreach (var story in Stories) + { + + + + + + } + +
StoryFromRecorded On
+ View + @if (story.CitizenId == state.User!.Id) + { + ~ Edit + } + @story.CitizenName
+ } + else + { +

There are no success stories recorded (yet)

+ } +
diff --git a/src/JobsJobsJobs/Client/Pages/SuccessStory/ListStories.razor.cs b/src/JobsJobsJobs/Client/Pages/SuccessStory/ListStories.razor.cs new file mode 100644 index 0000000..ec33402 --- /dev/null +++ b/src/JobsJobsJobs/Client/Pages/SuccessStory/ListStories.razor.cs @@ -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 + { + /// + /// Whether we are still loading data + /// + private bool Loading { get; set; } = true; + + /// + /// The story entries + /// + private IEnumerable Stories { get; set; } = default!; + + /// + /// Error messages encountered + /// + private IList ErrorMessages { get; set; } = new List(); + + protected override async Task OnInitializedAsync() + { + ServerApi.SetJwt(http, state); + var stories = await ServerApi.RetrieveMany(http, "success/list"); + + if (stories.IsOk) + { + Stories = stories.Ok; + } + else + { + ErrorMessages.Add(stories.Error); + } + + Loading = false; + } + } +} diff --git a/src/JobsJobsJobs/Client/Shared/NavMenu.razor b/src/JobsJobsJobs/Client/Shared/NavMenu.razor index 6de9041..b2eda0d 100644 --- a/src/JobsJobsJobs/Client/Shared/NavMenu.razor +++ b/src/JobsJobsJobs/Client/Shared/NavMenu.razor @@ -18,34 +18,39 @@ @if (state.User == null) { - + } else { - - - - + + + + + } diff --git a/src/JobsJobsJobs/Directory.Build.props b/src/JobsJobsJobs/Directory.Build.props index 8868ec2..a043cca 100644 --- a/src/JobsJobsJobs/Directory.Build.props +++ b/src/JobsJobsJobs/Directory.Build.props @@ -2,7 +2,7 @@ net5.0 enable - 0.8.0.0 - 0.8.0.0 + 0.9.0.0 + 0.9.0.0 diff --git a/src/JobsJobsJobs/Server/Areas/Api/Controllers/ProfileController.cs b/src/JobsJobsJobs/Server/Areas/Api/Controllers/ProfileController.cs index a1bff06..83f398b 100644 --- a/src/JobsJobsJobs/Server/Areas/Api/Controllers/ProfileController.cs +++ b/src/JobsJobsJobs/Server/Areas/Api/Controllers/ProfileController.cs @@ -33,6 +33,7 @@ namespace JobsJobsJobs.Server.Areas.Api.Controllers /// Constructor /// /// The data context to use for this request + /// The clock instance to use for this request public ProfileController(JobsDbContext db, IClock clock) { _db = db; @@ -114,5 +115,20 @@ namespace JobsJobsJobs.Server.Areas.Api.Controllers [HttpGet("search")] public async Task Search([FromQuery] ProfileSearch search) => Ok(await _db.SearchProfiles(search)); + + [HttpPatch("employment-found")] + public async Task 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(); + } + } } diff --git a/src/JobsJobsJobs/Server/Areas/Api/Controllers/SuccessController.cs b/src/JobsJobsJobs/Server/Areas/Api/Controllers/SuccessController.cs new file mode 100644 index 0000000..55eeff2 --- /dev/null +++ b/src/JobsJobsJobs/Server/Areas/Api/Controllers/SuccessController.cs @@ -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 +{ + /// + /// API controller for success stories + /// + [Route("api/[controller]")] + [Authorize] + [ApiController] + public class SuccessController : Controller + { + /// + /// The data context + /// + private readonly JobsDbContext _db; + + /// + /// The NodaTime clock instance + /// + private readonly IClock _clock; + + /// + /// Constructor + /// + /// The data context to use for this request + /// The clock instance to use for this request + public SuccessController(JobsDbContext db, IClock clock) + { + _db = db; + _clock = clock; + } + + /// + /// The current citizen ID + /// + private CitizenId CurrentCitizenId => CitizenId.Parse(User.FindFirst(ClaimTypes.NameIdentifier)!.Value); + + [HttpGet("{id}")] + public async Task Retrieve(string id) => + Ok(await _db.FindSuccessById(SuccessId.Parse(id))); + + [HttpPost("save")] + public async Task 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 List() => + Ok(await _db.AllStories()); + } +} diff --git a/src/JobsJobsJobs/Server/Data/ProfileExtensions.cs b/src/JobsJobsJobs/Server/Data/ProfileExtensions.cs index 4b59d88..5d2503d 100644 --- a/src/JobsJobsJobs/Server/Data/ProfileExtensions.cs +++ b/src/JobsJobsJobs/Server/Data/ProfileExtensions.cs @@ -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 diff --git a/src/JobsJobsJobs/Server/Data/SuccessExtensions.cs b/src/JobsJobsJobs/Server/Data/SuccessExtensions.cs new file mode 100644 index 0000000..3b97b2e --- /dev/null +++ b/src/JobsJobsJobs/Server/Data/SuccessExtensions.cs @@ -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 +{ + /// + /// Extensions to JobsDbContext to support manipulation of success stories + /// + public static class SuccessExtensions + { + /// + /// Get a success story by its ID + /// + /// The ID of the story to retrieve + /// The success story, if found + public static async Task FindSuccessById(this JobsDbContext db, SuccessId id) => + await db.Successes.AsNoTracking().SingleOrDefaultAsync(s => s.Id == id).ConfigureAwait(false); + + /// + /// Get a list of success stories, with the information needed for the list page + /// + /// A list of success stories, citizen names, and dates + public static async Task> 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); + } +} diff --git a/src/JobsJobsJobs/Shared/Api/StoryEntry.cs b/src/JobsJobsJobs/Shared/Api/StoryEntry.cs new file mode 100644 index 0000000..1f80eb3 --- /dev/null +++ b/src/JobsJobsJobs/Shared/Api/StoryEntry.cs @@ -0,0 +1,9 @@ +using NodaTime; + +namespace JobsJobsJobs.Shared.Api +{ + /// + /// An entry in the list of success stories + /// + public record StoryEntry(SuccessId Id, CitizenId CitizenId, string CitizenName, Instant RecordedOn); +} diff --git a/src/JobsJobsJobs/Shared/Api/StoryForm.cs b/src/JobsJobsJobs/Shared/Api/StoryForm.cs new file mode 100644 index 0000000..1d85016 --- /dev/null +++ b/src/JobsJobsJobs/Shared/Api/StoryForm.cs @@ -0,0 +1,23 @@ +namespace JobsJobsJobs.Shared.Api +{ + /// + /// The data required to provide a success story + /// + public class StoryForm + { + /// + /// The ID of this story + /// + public string Id { get; set; } = "new"; + + /// + /// Whether the employment was obtained from Jobs, Jobs, Jobs + /// + public bool FromHere { get; set; } = false; + + /// + /// The success story + /// + public string Story { get; set; } = ""; + } +}