diff --git a/src/JobsJobsJobs/Client/JobsJobsJobs.Client.csproj b/src/JobsJobsJobs/Client/JobsJobsJobs.Client.csproj index ce2a994..cc14c46 100644 --- a/src/JobsJobsJobs/Client/JobsJobsJobs.Client.csproj +++ b/src/JobsJobsJobs/Client/JobsJobsJobs.Client.csproj @@ -6,6 +6,7 @@ + diff --git a/src/JobsJobsJobs/Client/Pages/Citizen/Dashboard.razor b/src/JobsJobsJobs/Client/Pages/Citizen/Dashboard.razor index bfa6ba2..59e4a78 100644 --- a/src/JobsJobsJobs/Client/Pages/Citizen/Dashboard.razor +++ b/src/JobsJobsJobs/Client/Pages/Citizen/Dashboard.razor @@ -1,47 +1,41 @@ @page "/citizen/dashboard" -@inject HttpClient http -@inject AppState state -

Welcome, @state.User!.Name!

+

Welcome, @State.User!.Name!

-@if (retrievingProfile) +@if (RetrievingData) {

Retrieving your employment profile...

} -else if (profile != null) -{ -

Your employment profile was last updated @profile.LastUpdatedOn

-} else { -

You do not have an employment profile established; click “Profile”* in the menu to get started!

-

* Once it's there...

-} - -@if (errorMessage != null) -{ -

@errorMessage

-} -@code { - - bool retrievingProfile = true; - Profile? profile = null; - string? errorMessage = null; - - protected override async Task OnInitializedAsync() + if (Profile != null) + { +

+ Your employment profile was last updated . Your profile currently + lists @SkillCount skill@(SkillCount != 1 ? "s" : ""). +

+ } + else + { +

You do not have an employment profile established; click “Profile”* in the menu to get started!

+ } +

+ There @(ProfileCount == 1 ? "is" : "are") @(ProfileCount == 0 ? "no" : ProfileCount) employment + profile@(ProfileCount != 1 ? "s" : "") from citizens of Gitmo Nation. + @if (ProfileCount > 0) { - if (state.User != null) - { - var profileResult = await ServerApi.RetrieveProfile(http, state); - if (profileResult.IsOk) - { - profile = profileResult.Ok; - } - else - { - errorMessage = profileResult.Error; - } - retrievingProfile = false; - } + Take a look around and see if you can help them find work! } +

+} + +@if (ErrorMessages.Count > 0) +{ +

The following error(s) occurred:

+

+ @foreach (var msg in ErrorMessages) + { + @msg
+ } +

} diff --git a/src/JobsJobsJobs/Client/Pages/Citizen/Dashboard.razor.cs b/src/JobsJobsJobs/Client/Pages/Citizen/Dashboard.razor.cs new file mode 100644 index 0000000..79ba0cc --- /dev/null +++ b/src/JobsJobsJobs/Client/Pages/Citizen/Dashboard.razor.cs @@ -0,0 +1,96 @@ +using JobsJobsJobs.Shared; +using JobsJobsJobs.Shared.Api; +using Microsoft.AspNetCore.Components; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading.Tasks; + +namespace JobsJobsJobs.Client.Pages.Citizen +{ + /// + /// The first page a user sees after signing in + /// + public partial class Dashboard : ComponentBase + { + /// + /// Whether the data is being retrieved + /// + private bool RetrievingData { get; set; } = true; + + /// + /// The user's profile + /// + private Profile? Profile { get; set; } = null; + + /// + /// The number of skills in the user's profile + /// + private long SkillCount { get; set; } = 0L; + + /// + /// The number of profiles + /// + private long ProfileCount { get; set; } = 0L; + + /// + /// Error messages from data access + /// + private IList ErrorMessages { get; } = new List(); + + /// + /// The HTTP client to use for API access + /// + [Inject] + public HttpClient Http { get; set; } = default!; + + /// + /// The current application state + /// + [Inject] + public AppState State { get; set; } = default!; + + + protected override async Task OnInitializedAsync() + { + if (State.User != null) + { + ServerApi.SetJwt(Http, State); + var profileTask = ServerApi.RetrieveProfile(Http, State); + var profileCountTask = ServerApi.RetrieveOne(Http, "profile/count"); + var skillCountTask = ServerApi.RetrieveOne(Http, "profile/skill-count"); + + await Task.WhenAll(profileTask, profileCountTask, skillCountTask); + + if (profileTask.Result.IsOk) + { + Profile = profileTask.Result.Ok; + } + else + { + ErrorMessages.Add(profileTask.Result.Error); + } + + if (profileCountTask.Result.IsOk) + { + ProfileCount = profileCountTask.Result.Ok?.Value ?? 0; + } + else + { + ErrorMessages.Add(profileCountTask.Result.Error); + } + + if (skillCountTask.Result.IsOk) + { + SkillCount = skillCountTask.Result.Ok?.Value ?? 0; + } + else + { + ErrorMessages.Add(skillCountTask.Result.Error); + } + + RetrievingData = false; + } + } + + } +} diff --git a/src/JobsJobsJobs/Client/Pages/Citizen/EditProfile.razor.cs b/src/JobsJobsJobs/Client/Pages/Citizen/EditProfile.razor.cs index 7efc578..251c5f6 100644 --- a/src/JobsJobsJobs/Client/Pages/Citizen/EditProfile.razor.cs +++ b/src/JobsJobsJobs/Client/Pages/Citizen/EditProfile.razor.cs @@ -1,4 +1,5 @@ -using JobsJobsJobs.Shared; +using Blazored.Toast.Services; +using JobsJobsJobs.Shared; using JobsJobsJobs.Shared.Api; using Microsoft.AspNetCore.Components; using System.Collections.Generic; @@ -51,6 +52,12 @@ namespace JobsJobsJobs.Client.Pages.Citizen [Inject] private AppState State { get; set; } = default!; + /// + /// Toast service + /// + [Inject] + private IToastService Toasts { get; set; } = default!; + protected override async Task OnInitializedAsync() { ServerApi.SetJwt(Http, State); @@ -128,12 +135,13 @@ namespace JobsJobsJobs.Client.Pages.Citizen var res = await Http.PostAsJsonAsync("/api/profile/save", ProfileForm); if (res.IsSuccessStatusCode) { - // TODO: success notification + Toasts.ShowSuccess("Profile Saved Successfully"); } else { - // TODO: probably not the best way to handle this... - ErrorMessages.Add(await res.Content.ReadAsStringAsync()); + var error = await res.Content.ReadAsStringAsync(); + if (!string.IsNullOrEmpty(error)) error = $"- {error}"; + Toasts.ShowError($"{(int)res.StatusCode} {error}"); } } diff --git a/src/JobsJobsJobs/Client/Program.cs b/src/JobsJobsJobs/Client/Program.cs index b7c2c9d..41c2573 100644 --- a/src/JobsJobsJobs/Client/Program.cs +++ b/src/JobsJobsJobs/Client/Program.cs @@ -1,13 +1,10 @@ +using Blazored.Toast; using Microsoft.AspNetCore.Components.WebAssembly.Hosting; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; using NodaTime; using NodaTime.Serialization.SystemTextJson; using System; -using System.Collections.Generic; using System.Net.Http; -using System.Text; using System.Text.Json; using System.Threading.Tasks; @@ -23,6 +20,7 @@ namespace JobsJobsJobs.Client builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) }); builder.Services.AddSingleton(new AppState()); builder.Services.AddSingleton(new JsonSerializerOptions().ConfigureForNodaTime(DateTimeZoneProviders.Tzdb)); + builder.Services.AddBlazoredToast(); await builder.Build().RunAsync(); } } diff --git a/src/JobsJobsJobs/Client/ServerApi.cs b/src/JobsJobsJobs/Client/ServerApi.cs index 3f69335..f395864 100644 --- a/src/JobsJobsJobs/Client/ServerApi.cs +++ b/src/JobsJobsJobs/Client/ServerApi.cs @@ -131,7 +131,7 @@ namespace JobsJobsJobs.Client /// /// The type of item expected /// The HTTP client to use for server communication - /// The API URL to use call + /// The API URL to call /// A result with the items, or an error if one occurs /// The caller is responsible for setting the JWT on the HTTP client public static async Task>> RetrieveMany(HttpClient http, string url) @@ -150,5 +150,29 @@ namespace JobsJobsJobs.Client return Result>.AsError($"Unable to parse result: {ex.Message}"); } } + + /// + /// Retrieve one item from the given URL + /// + /// The type of item expected + /// The HTTP client to use for server communication + /// The API URL to call + /// A result with the item (possibly null), or an error if one occurs + /// The caller is responsible for setting the JWT on the HTTP client + public static async Task> RetrieveOne(HttpClient http, string url) + { + try + { + return Result.AsOk(await http.GetFromJsonAsync($"/api/{url}", _serializerOptions)); + } + catch (HttpRequestException ex) + { + return Result.AsError(ex.Message); + } + catch (JsonException ex) + { + return Result.AsError($"Unable to parse result: {ex.Message}"); + } + } } } diff --git a/src/JobsJobsJobs/Client/Shared/FullDateTime.razor b/src/JobsJobsJobs/Client/Shared/FullDateTime.razor new file mode 100644 index 0000000..49d6ed3 --- /dev/null +++ b/src/JobsJobsJobs/Client/Shared/FullDateTime.razor @@ -0,0 +1,23 @@ +@using NodaTime +@using NodaTime.Text +@using System.Globalization + +@Translated + +@code { + /// + /// The pattern with which dates will be formatted + /// + private static InstantPattern pattern = InstantPattern.Create("ld ' at ' lt", CultureInfo.CurrentCulture); + + /// + /// The date to be formatted + /// + [Parameter] + public Instant TheDate { get; set; } + + /// + /// The formatted date + /// + private string Translated => pattern.Format(TheDate); +} diff --git a/src/JobsJobsJobs/Client/Shared/MainLayout.razor b/src/JobsJobsJobs/Client/Shared/MainLayout.razor index 025a1b7..3731c9b 100644 --- a/src/JobsJobsJobs/Client/Shared/MainLayout.razor +++ b/src/JobsJobsJobs/Client/Shared/MainLayout.razor @@ -1,5 +1,6 @@ @inherits LayoutComponentBase @inject IJSRuntime js +@using Blazored.Toast.Configuration
+ @code { async void PlayJobs() => await js.InvokeVoidAsync("Audio.play", "pelosijobs"); diff --git a/src/JobsJobsJobs/Client/_Imports.razor b/src/JobsJobsJobs/Client/_Imports.razor index 453a6ec..a3f00ca 100644 --- a/src/JobsJobsJobs/Client/_Imports.razor +++ b/src/JobsJobsJobs/Client/_Imports.razor @@ -1,4 +1,6 @@ -@using System.Net.Http +@using Blazored.Toast +@using Blazored.Toast.Services +@using System.Net.Http @using System.Net.Http.Json @using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Routing diff --git a/src/JobsJobsJobs/Client/wwwroot/index.html b/src/JobsJobsJobs/Client/wwwroot/index.html index b146463..0688532 100644 --- a/src/JobsJobsJobs/Client/wwwroot/index.html +++ b/src/JobsJobsJobs/Client/wwwroot/index.html @@ -2,13 +2,14 @@ - - - JobsJobsJobs - - - - + + + JobsJobsJobs + + + + + diff --git a/src/JobsJobsJobs/Server/Areas/Api/Controllers/ProfileController.cs b/src/JobsJobsJobs/Server/Areas/Api/Controllers/ProfileController.cs index 1f66fd5..c144bb4 100644 --- a/src/JobsJobsJobs/Server/Areas/Api/Controllers/ProfileController.cs +++ b/src/JobsJobsJobs/Server/Areas/Api/Controllers/ProfileController.cs @@ -103,5 +103,19 @@ namespace JobsJobsJobs.Server.Areas.Api.Controllers await _db.OpenAsync(); return Ok(await _db.FindSkillsByCitizen(CurrentCitizenId)); } + + [HttpGet("count")] + public async Task GetProfileCount() + { + await _db.OpenAsync(); + return Ok(new Count(await _db.CountProfiles())); + } + + [HttpGet("skill-count")] + public async Task GetSkillCount() + { + await _db.OpenAsync(); + return Ok(new Count(await _db.CountSkills(CurrentCitizenId))); + } } } diff --git a/src/JobsJobsJobs/Server/Data/CitizenExtensions.cs b/src/JobsJobsJobs/Server/Data/CitizenExtensions.cs index 9a58071..33db0d7 100644 --- a/src/JobsJobsJobs/Server/Data/CitizenExtensions.cs +++ b/src/JobsJobsJobs/Server/Data/CitizenExtensions.cs @@ -1,9 +1,5 @@ using JobsJobsJobs.Shared; using Npgsql; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Runtime.CompilerServices; using System.Threading.Tasks; namespace JobsJobsJobs.Server.Data @@ -31,13 +27,11 @@ namespace JobsJobsJobs.Server.Data { using var cmd = conn.CreateCommand(); cmd.CommandText = "SELECT * FROM citizen WHERE na_user = @na_user"; - cmd.Parameters.Add(new NpgsqlParameter("@na_user", naUser)); + cmd.AddString("na_user", naUser); using NpgsqlDataReader rdr = await cmd.ExecuteReaderAsync().ConfigureAwait(false); - if (await rdr.ReadAsync().ConfigureAwait(false)) - { - return ToCitizen(rdr); - } + if (await rdr.ReadAsync().ConfigureAwait(false)) return ToCitizen(rdr); + return null; } @@ -54,12 +48,12 @@ namespace JobsJobsJobs.Server.Data ) VALUES( @na_user, @display_name, @profile_url, @joined_on, @last_seen_on, @id )"; - cmd.Parameters.Add(new NpgsqlParameter("@id", citizen.Id.ToString())); - cmd.Parameters.Add(new NpgsqlParameter("@na_user", citizen.NaUser)); - cmd.Parameters.Add(new NpgsqlParameter("@display_name", citizen.DisplayName)); - cmd.Parameters.Add(new NpgsqlParameter("@profile_url", citizen.ProfileUrl)); - cmd.Parameters.Add(new NpgsqlParameter("@joined_on", citizen.JoinedOn)); - cmd.Parameters.Add(new NpgsqlParameter("@last_seen_on", citizen.LastSeenOn)); + cmd.AddString("id", citizen.Id); + cmd.AddString("na_user", citizen.NaUser); + cmd.AddString("display_name", citizen.DisplayName); + cmd.AddString("profile_url", citizen.ProfileUrl); + cmd.AddInstant("joined_on", citizen.JoinedOn); + cmd.AddInstant("last_seen_on", citizen.LastSeenOn); await cmd.ExecuteNonQueryAsync().ConfigureAwait(false); } @@ -76,9 +70,9 @@ namespace JobsJobsJobs.Server.Data SET display_name = @display_name, last_seen_on = @last_seen_on WHERE id = @id"; - cmd.Parameters.Add(new NpgsqlParameter("@id", citizen.Id.ToString())); - cmd.Parameters.Add(new NpgsqlParameter("@display_name", citizen.DisplayName)); - cmd.Parameters.Add(new NpgsqlParameter("@last_seen_on", citizen.LastSeenOn)); + cmd.AddString("id", citizen.Id); + cmd.AddString("display_name", citizen.DisplayName); + cmd.AddInstant("last_seen_on", citizen.LastSeenOn); await cmd.ExecuteNonQueryAsync().ConfigureAwait(false); } diff --git a/src/JobsJobsJobs/Server/Data/NpgsqlDataReaderExtensions.cs b/src/JobsJobsJobs/Server/Data/NpgsqlExtensions.cs similarity index 54% rename from src/JobsJobsJobs/Server/Data/NpgsqlDataReaderExtensions.cs rename to src/JobsJobsJobs/Server/Data/NpgsqlExtensions.cs index feaf76d..a93fd67 100644 --- a/src/JobsJobsJobs/Server/Data/NpgsqlDataReaderExtensions.cs +++ b/src/JobsJobsJobs/Server/Data/NpgsqlExtensions.cs @@ -11,8 +11,10 @@ namespace JobsJobsJobs.Server.Data /// /// Extensions to the Npgsql data reader /// - public static class NpgsqlDataReaderExtensions + public static class NpgsqlExtensions { + #region Data Reader + /// /// Get a boolean by its name /// @@ -48,5 +50,44 @@ namespace JobsJobsJobs.Server.Data /// The name of the column to check /// True if the column is null, false if not public static bool IsDBNull(this NpgsqlDataReader rdr, string name) => rdr.IsDBNull(rdr.GetOrdinal(name)); + + #endregion + + #region Command + + /// + /// Add a string parameter + /// + /// The name of the parameter + /// The value of the parameter + public static void AddString(this NpgsqlCommand cmd, string name, object value) => + cmd.Parameters.Add( + new NpgsqlParameter($"@{name}", value is string @val ? @val : value.ToString()!)); + + /// + /// Add a boolean parameter + /// + /// The name of the parameter + /// The value of the parameter + public static void AddBool(this NpgsqlCommand cmd, string name, bool value) => + cmd.Parameters.Add(new NpgsqlParameter($"@{name}", value)); + + /// + /// Add an Instant parameter + /// + /// The name of the parameter + /// The value of the parameter + public static void AddInstant(this NpgsqlCommand cmd, string name, Instant value) => + cmd.Parameters.Add(new NpgsqlParameter($"@{name}", value)); + + /// + /// Add a parameter that may be null + /// + /// The name of the parameter + /// The value of the parameter + public static void AddMaybeNull(this NpgsqlCommand cmd, string name, object? value) => + cmd.Parameters.Add(new NpgsqlParameter($"@{name}", value == null ? DBNull.Value : value)); + + #endregion } } diff --git a/src/JobsJobsJobs/Server/Data/ProfileExtensions.cs b/src/JobsJobsJobs/Server/Data/ProfileExtensions.cs index 76c1b04..461e498 100644 --- a/src/JobsJobsJobs/Server/Data/ProfileExtensions.cs +++ b/src/JobsJobsJobs/Server/Data/ProfileExtensions.cs @@ -1,6 +1,5 @@ using JobsJobsJobs.Shared; using Npgsql; -using System; using System.Collections.Generic; using System.Linq; using System.Text; @@ -9,7 +8,7 @@ using System.Threading.Tasks; namespace JobsJobsJobs.Server.Data { /// - /// Extensions to the NpgsqlConnection type to support manipulation of profiles + /// Extensions to the Connection type to support manipulation of profiles /// public static class ProfileExtensions { @@ -53,7 +52,7 @@ namespace JobsJobsJobs.Server.Data FROM profile p INNER JOIN continent c ON p.continent_id = c.id WHERE citizen_id = @id"; - cmd.Parameters.Add(new NpgsqlParameter("@id", citizen.Id.ToString())); + cmd.AddString("id", citizen.Id); using var rdr = await cmd.ExecuteReaderAsync().ConfigureAwait(false); return await rdr.ReadAsync().ConfigureAwait(false) ? ToProfile(rdr) : null; @@ -84,17 +83,16 @@ namespace JobsJobsJobs.Server.Data 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)); + cmd.AddString("citizen_id", profile.Id); + cmd.AddBool("seeking_employment", profile.SeekingEmployment); + cmd.AddBool("is_public", profile.IsPublic); + cmd.AddString("continent_id", profile.ContinentId); + cmd.AddString("region", profile.Region); + cmd.AddBool("remote_work", profile.RemoteWork); + cmd.AddBool("full_time", profile.FullTime); + cmd.AddString("biography", profile.Biography.Text); + cmd.AddInstant("last_updated_on", profile.LastUpdatedOn); + cmd.AddMaybeNull("experience", profile.Experience); await cmd.ExecuteNonQueryAsync().ConfigureAwait(false); } @@ -109,7 +107,7 @@ namespace JobsJobsJobs.Server.Data { using var cmd = conn.CreateCommand(); cmd.CommandText = "SELECT * FROM skill WHERE citizen_id = @citizen_id"; - cmd.Parameters.Add(new NpgsqlParameter("citizen_id", citizenId.ToString())); + cmd.AddString("citizen_id", citizenId); var result = new List(); using var rdr = await cmd.ExecuteReaderAsync().ConfigureAwait(false); @@ -137,10 +135,10 @@ namespace JobsJobsJobs.Server.Data 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)); + cmd.AddString("id", skill.Id); + cmd.AddString("citizen_id", skill.CitizenId); + cmd.AddString("skill", skill.Description); + cmd.AddMaybeNull("notes", skill.Notes); await cmd.ExecuteNonQueryAsync().ConfigureAwait(false); } @@ -161,11 +159,39 @@ namespace JobsJobsJobs.Server.Data .Append(string.Join(", ", ids.Select(_ => $"@id{count++}").ToArray())) .Append(')') .ToString(); - cmd.Parameters.Add(new NpgsqlParameter("citizen_id", citizenId.ToString())); + cmd.AddString("citizen_id", citizenId); count = 0; - foreach (var id in ids) cmd.Parameters.Add(new NpgsqlParameter($"id{count++}", id.ToString())); + foreach (var id in ids) cmd.AddString($"id{count++}", id); await cmd.ExecuteNonQueryAsync().ConfigureAwait(false); } + + /// + /// Get a count of the citizens with profiles + /// + /// The number of citizens with profiles + public static async Task CountProfiles(this NpgsqlConnection conn) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT COUNT(citizen_id) FROM profile"; + + var result = await cmd.ExecuteScalarAsync().ConfigureAwait(false); + return result == null ? 0L : (long)result; + } + + /// + /// Count the skills for the given citizen + /// + /// The ID of the citizen whose skills should be counted + /// The count of skills for the given citizen + public static async Task CountSkills(this NpgsqlConnection conn, CitizenId citizenId) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT COUNT(id) FROM skill WHERE citizen_id = @citizen_id"; + cmd.AddString("citizen_id", citizenId); + + var result = await cmd.ExecuteScalarAsync().ConfigureAwait(false); + return result == null ? 0L : (long)result; + } } } diff --git a/src/JobsJobsJobs/Shared/Api/Count.cs b/src/JobsJobsJobs/Shared/Api/Count.cs new file mode 100644 index 0000000..88f39f6 --- /dev/null +++ b/src/JobsJobsJobs/Shared/Api/Count.cs @@ -0,0 +1,7 @@ +namespace JobsJobsJobs.Shared.Api +{ + /// + /// A transport mechanism to send counts across the wire via JSON + /// + public record Count(long Value); +}