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 @@
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.)
+
+ }
+
+
+
+
+
+ }
+
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())
+ {
+
+
+
+ Story |
+ From |
+ Recorded On |
+
+
+
+ @foreach (var story in Stories)
+ {
+
+
+ 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)
{
-
-
- Log On
-
-
+
+
+ Log On
+
+
}
else
{
-
-
- Dashboard
-
-
-
-
- Edit Your Profile
-
-
-
-
- View Profiles
-
-
-
-
- Log Off
-
-
+
+
+ Dashboard
+
+
+
+
+ Edit Your Profile
+
+
+
+
+ View Profiles
+
+
+
+
+ Success Stories
+
+
+
+
+ Log Off
+
+
}
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; } = "";
+ }
+}