8 Commits
v0.7.1 ... v0.9

Author SHA1 Message Date
a5c88a1034 Move docs to new page (#4)
Also verify citizen ID on  story edit page
2021-01-31 21:57:48 -05:00
46882bdfc6 Add view story page (#4) 2021-01-31 20:21:17 -05:00
a6fd891cc5 Add common loading component
This has no specific issue associated, but reduces boilerplate in most page components
2021-01-26 22:26:36 -05:00
7f7eb191fb Add success story add/edit and list (#4)
still a work in progress
2021-01-21 23:05:27 -05:00
340b93c6d7 Display dates/times in local TZ (#7) 2021-01-20 21:28:55 -05:00
4155072990 "Back" now works for search results (#3)
- Made the application only retrieve the list of continents once per visit
- Update the verbiage for phase 3 completion
2021-01-19 22:42:15 -05:00
feb3c5fd4a Search UI complete (#3)
"Back" doesn't preserve search results; need to fix that before this is done
2021-01-18 14:52:24 -05:00
7839b8eb57 Search works (#3)
Still need to clean it up a bit, and make the UI have a collapsed section once the search is completed
2021-01-17 23:28:01 -05:00
40 changed files with 1344 additions and 388 deletions

View File

@@ -1,5 +1,11 @@
using JobsJobsJobs.Shared; using JobsJobsJobs.Shared;
using Microsoft.JSInterop;
using NodaTime;
using System; using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Reflection;
using System.Threading.Tasks;
namespace JobsJobsJobs.Client namespace JobsJobsJobs.Client
{ {
@@ -13,6 +19,17 @@ namespace JobsJobsJobs.Client
/// </summary> /// </summary>
public class AppState public class AppState
{ {
/// <summary>
/// The application version, as a nice display string
/// </summary>
public static Lazy<string> Version => new Lazy<string>(() =>
{
var version = Assembly.GetExecutingAssembly().GetName().Version!;
var display = $"v{version.Major}.{version.Minor}";
if (version.Build > 0) display += $".{version.Build}";
return display;
});
public event Action OnChange = () => { }; public event Action OnChange = () => { };
private UserInfo? _user = null; private UserInfo? _user = null;
@@ -45,6 +62,59 @@ namespace JobsJobsJobs.Client
} }
} }
private IEnumerable<Continent>? _continents = null;
/// <summary>
/// Get a list of continents (only retrieves once per application load)
/// </summary>
/// <param name="http">The HTTP client to use to obtain continents the first time</param>
/// <returns>The list of continents</returns>
/// <exception cref="ApplicationException">If the continents cannot be loaded</exception>
public async Task<IEnumerable<Continent>> GetContinents(HttpClient http)
{
if (_continents == null)
{
ServerApi.SetJwt(http, this);
var continentResult = await ServerApi.RetrieveMany<Continent>(http, "continent/all");
if (continentResult.IsOk)
{
_continents = continentResult.Ok;
}
else
{
throw new ApplicationException($"Could not load continents - {continentResult.Error}");
}
}
return _continents;
}
private DateTimeZone? _tz = null;
/// <summary>
/// Get the time zone for the current user's browser
/// </summary>
/// <param name="js">The JS interop runtime for the application</param>
/// <returns>The time zone based on the user's browser</returns>
public async Task<DateTimeZone> GetTimeZone(IJSRuntime js)
{
if (_tz == null)
{
try
{
_tz = DateTimeZoneProviders.Tzdb.GetZoneOrNull(await js.InvokeAsync<string>("getTimeZone"));
}
catch (Exception) { }
}
if (_tz == null)
{
// Either the zone wasn't found, or the user's browser denied us access to it; there's not much to do
// here but set it to UTC and move on
_tz = DateTimeZoneProviders.Tzdb.GetZoneOrNull("Etc/UTC")!;
}
return _tz;
}
public AppState() { } public AppState() { }
private void NotifyChanged() => OnChange.Invoke(); private void NotifyChanged() => OnChange.Invoke();

View File

@@ -6,96 +6,42 @@
<h3>Welcome, @state.User!.Name!</h3> <h3>Welcome, @state.User!.Name!</h3>
@if (RetrievingData) <Loading OnLoad=@LoadProfile Message=@(new MarkupString("Retrieving your employment profile&hellip;"))>
{ @if (Profile != null)
<p>Retrieving your employment profile...</p> {
}
else
{
<ErrorList Errors=@ErrorMessages>
@if (Profile != null)
{
<p>
Your employment profile was last updated <FullDateTime TheDate=@Profile.LastUpdatedOn />. Your profile currently
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>
}
else
{
<p>
You do not have an employment profile established; click &ldquo;Edit Profile&rdquo; in the menu to get
started!
</p>
}
@{
/**
This is phase 3 stuff...
<hr>
<p> <p>
There @(ProfileCount == 1 ? "is" : "are") @(ProfileCount == 0 ? "no" : ProfileCount) employment Your employment profile was last updated <FullDateTime TheDate=@Profile.LastUpdatedOn />. Your profile currently
profile@(ProfileCount != 1 ? "s" : "") from citizens of Gitmo Nation. lists @Profile.Skills.Length skill@(Profile.Skills.Length != 1 ? "s" : "").
@if (ProfileCount > 0) </p>
{ <p><a href="/profile/view/@state.User.Id">View Your Employment Profile</a></p>
<text>Take a look around and see if you can help them find work!</text> <em>(coming soon)</em> @if (Profile.SeekingEmployment)
} {
</p> */ <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>
} }
</ErrorList> }
} else
{
<p>
You do not have an employment profile established; click &ldquo;Edit Profile&rdquo; in the menu to get
started!
</p>
}
<hr>
<p>
There @(ProfileCount == 1 ? "is" : "are") @(ProfileCount == 0 ? "no" : ProfileCount) employment
profile@(ProfileCount != 1 ? "s" : "") from citizens of Gitmo Nation.
@if (ProfileCount > 0)
{
<text>Take a look around and see if you can help them find work!</text>
}
</p>
</Loading>
<hr> <hr>
<h4>Phase 3 &ndash; What Works <small><em>(<span class="text-uppercase">In Progress</span> ~~ Last Updated January 10<sup>th</sup>, 2021)</em></small></h4>
<p> <p>
The &ldquo;View Profiles&rdquo; link at the side does not have any search capabilities, but it does provide a list of To see what is currently done, and how this application works, check out &ldquo;How It Works&rdquo; in the sidebar.
citizens who have filled out profiles, along with a way to view those profiles. The application now has 4 of 5 phases complete towards version 1.0; the documentation was last updated January
31<sup>st</sup>, 2021.
</p> </p>
<hr>
<h4>Phase 2 &ndash; What Works <small><em>(Last Updated January 8<sup>th</sup>, 2021)</em></small></h4>
<p>
If you&rsquo;ve gotten this far, you&rsquo;ve already passed
<a href="https://github.com/bit-badger/jobs-jobs-jobs/issues/1" target="_blank">Phase 1</a>, which enabled users of
No Agenda Social to create accounts here, simply by allowing the application read access to their profiles. Unless
there are requests for tighter integration with that site, this is the only access to your NAS information that this
application will require.
</p>
<p>
<a href="https://github.com/bit-badger/jobs-jobs-jobs/issues/2" target="_blank">Phase 2</a> allows you to complete
your employment profile. Much of this is straightforward, but there are a few things you might want to know before
you fill it out:
</p>
<ul>
<li>
The &ldquo;View Your Employment Profile&rdquo; link (which you&rdquo;ll see on this page, once your profile is
established) shows your profile the way all other validated users will be able to see it (once
<a href="https://github.com/bit-badger/jobs-jobs-jobs/issues/3" target="_blank">Phase 3</a> is complete). While
this site does not perform communication with others over No Agenda Social, the name on employment profiles is a
link to that user&rsquo;s profile; from there, others can communicate further with you using the tools Mastodon
provides.
</li>
<li>
The &ldquo;Professional Biography&rdquo; and &ldquo;Experience&rdquo; sections support Markdown, a plain-text way
to specify formatting quite similar to that provided by word processors. The
<a href="https://daringfireball.net/projects/markdown/" target="_blank">original page</a> for the project is a good
overview of its capabilities, and the pages at <a href="https://www.markdownguide.org/" target="_blank">Markdown
Guide</a> give in-depth lessons to make the most of this language. The version of Markdown employed here supports
many popular extensions, include smart quotes (turning "a quote" into &ldquo;a quote&rdquo;), tables,
super/subscripts, and more.
</li>
<li>
Skills are optional, but they are the place to record skills you have. Along with the skill, there is a
&ldquo;Notes&rdquo; section, which can be used to indicate the time you&rsquo;ve practiced a particular skill, the
mastery you have of that skill, etc. It is free-form text, so it is all up to you how you utilize the field.
</li>
<li>
The &ldquo;Experience&rdquo; field is intended to capture a chronological or topical employment history; with this
&ldquo;quick-n-dirty&rdquo; implementation, this Markdown box can be used to capture that information however you
would like it presented to fellow citizens.
</li>
<li>
<a href="https://github.com/bit-badger/jobs-jobs-jobs/issues/5" target="_blank">Phase 5</a> includes allowing
public access to the continent, region, and skills fields of Gitmo Nation citizens who indicate that they are both
seeking employment <strong>and</strong> want their information disclosed to public users. The &ldquo;Allow my
profile to be searched publicly&rdquo; checkbox, at the bottom of the page where you edit your employment profile,
is how you opt your profile in to this list.
</li>
</ul>

View File

@@ -11,11 +11,6 @@ namespace JobsJobsJobs.Client.Pages.Citizen
/// </summary> /// </summary>
public partial class Dashboard : ComponentBase public partial class Dashboard : ComponentBase
{ {
/// <summary>
/// Whether the data is being retrieved
/// </summary>
private bool RetrievingData { get; set; } = true;
/// <summary> /// <summary>
/// The user's profile /// The user's profile
/// </summary> /// </summary>
@@ -27,11 +22,10 @@ namespace JobsJobsJobs.Client.Pages.Citizen
private int ProfileCount { get; set; } private int ProfileCount { get; set; }
/// <summary> /// <summary>
/// Error messages from data access /// Load the user's profile information
/// </summary> /// </summary>
private IList<string> ErrorMessages { get; } = new List<string>(); /// <param name="errors">A collection to report errors that may be encountered</param>
public async Task LoadProfile(ICollection<string> errors)
protected override async Task OnInitializedAsync()
{ {
if (state.User != null) if (state.User != null)
{ {
@@ -47,7 +41,7 @@ namespace JobsJobsJobs.Client.Pages.Citizen
} }
else else
{ {
ErrorMessages.Add(profileTask.Result.Error); errors.Add(profileTask.Result.Error);
} }
if (profileCountTask.Result.IsOk) if (profileCountTask.Result.IsOk)
@@ -56,10 +50,8 @@ namespace JobsJobsJobs.Client.Pages.Citizen
} }
else else
{ {
ErrorMessages.Add(profileCountTask.Result.Error); errors.Add(profileCountTask.Result.Error);
} }
RetrievingData = false;
} }
} }
} }

View File

@@ -8,112 +8,111 @@
<h3>Employment Profile</h3> <h3>Employment Profile</h3>
@if (AllLoaded) <Loading OnLoad=@SetUpProfile Message=@(new MarkupString("Loading Your Profile&hellip;"))>
{ <EditForm Model=@ProfileForm OnValidSubmit=@SaveProfile>
<ErrorList Errors=@ErrorMessages> <DataAnnotationsValidator />
<EditForm Model=@ProfileForm OnValidSubmit=@SaveProfile> <div class="form-row">
<DataAnnotationsValidator /> <div class="col">
<div class="form-row"> <div class="form-check">
<div class="col"> <InputCheckbox id="seeking" class="form-check-input" @bind-Value=@ProfileForm.IsSeekingEmployment />
<div class="form-check"> <label for="seeking" class="form-check-label">I am currently seeking employment</label>
<InputCheckbox id="seeking" class="form-check-input" @bind-Value=@ProfileForm.IsSeekingEmployment /> @if (IsSeeking)
<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> <em>&nbsp; &nbsp; If you have found employment, consider
} <a href="/success-story/add">telling your fellow citizens about it</a>
</InputSelect> </em>
<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"
placeholder="Country, state, geographic area, etc." />
<ValidationMessage For=@(() => ProfileForm.Region) />
</div>
</div> </div>
</div> </div>
<div class="form-row"> </div>
<div class="col"> <div class="form-row">
<div class="form-group"> <div class="col col-xs-12 col-sm-6 col-md-4">
<label for="bio" class="jjj-required">Professional Biography</label> <div class="form-group">
<MarkdownEditor Id="bio" @bind-Text=@ProfileForm.Biography /> <label for="continentId" class="jjj-required">Continent</label>
<ValidationMessage For=@(() => ProfileForm.Biography) /> <InputSelect id="continentId" @bind-Value=@ProfileForm.ContinentId class="form-control">
</div> <option>&ndash; Select &ndash;</option>
@foreach (var (id, name) in Continents)
{
<option value="@id">@name</option>
}
</InputSelect>
<ValidationMessage For=@(() => ProfileForm.ContinentId) />
</div> </div>
</div> </div>
<div class="form-row"> <div class="col col-xs-12 col-sm-6 col-md-8">
<div class="col col-xs-12 col-sm-12 offset-md-2 col-md-4"> <div class="form-group">
<div class="form-check"> <label for="region" class="jjj-required">Region</label>
<InputCheckbox id="isRemote" class="form-check-input" @bind-Value=@ProfileForm.RemoteWork /> <InputText id="region" @bind-Value=@ProfileForm.Region class="form-control"
<label for="isRemote" class="form-check-label">I am looking for remote work</label> placeholder="Country, state, geographic area, etc." />
</div> <ValidationMessage For=@(() => ProfileForm.Region) />
</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> </div>
<hr> </div>
<h4> <div class="form-row">
Skills &nbsp; <div class="col">
<button type="button" class="btn btn-outline-primary" @onclick=@AddNewSkill>Add a Skill</button> <div class="form-group">
</h4> <label for="bio" class="jjj-required">Professional Biography</label>
@foreach (var skill in ProfileForm.Skills) <MarkdownEditor Id="bio" @bind-Text=@ProfileForm.Biography />
{ <ValidationMessage For=@(() => ProfileForm.Biography) />
<SkillEdit Skill=@skill OnRemove=@RemoveSkill />
}
<hr>
<h4>Experience</h4>
<p>
This application does not have a place to individually list your chronological job history; however, you can
use this area to list prior jobs, their dates, and anything else you want to include that&rsquo;s not already a
part of your Professional Biography above.
</p>
<div class="form-row">
<div class="col">
<MarkdownEditor Id="experience" @bind-Text=@ProfileForm.Experience />
</div> </div>
</div> </div>
<div class="form-row"> </div>
<div class="col"> <div class="form-row">
<div class="form-check"> <div class="col col-xs-12 col-sm-12 offset-md-2 col-md-4">
<InputCheckbox id="isPublic" class="form-check-input" @bind-Value=@ProfileForm.IsPublic /> <div class="form-check">
<label for="isPublic" class="form-check-label"> <InputCheckbox id="isRemote" class="form-check-input" @bind-Value=@ProfileForm.RemoteWork />
Allow my profile to be searched publicly (outside NA Social) <label for="isRemote" class="form-check-label">I am looking for remote work</label>
</label>
</div>
</div> </div>
</div> </div>
<div class="form-row"> <div class="col col-xs-12 col-sm-12 col-md-4">
<div class="col"> <div class="form-check">
<br> <InputCheckbox id="isFull" class="form-check-input" @bind-Value=@ProfileForm.FullTime />
<button type="submit" class="btn btn-outline-primary">Save</button> <label for="isFull" class="form-check-label">I am looking for full-time work</label>
</div> </div>
</div> </div>
</EditForm> </div>
@if (!IsNew) <hr>
<h4>
Skills &nbsp;
<button type="button" class="btn btn-outline-primary" @onclick=@AddNewSkill>Add a Skill</button>
</h4>
@foreach (var skill in ProfileForm.Skills)
{ {
<p> <SkillEdit Skill=@skill OnRemove=@RemoveSkill />
<br><a href="/profile/view/@state.User!.Id"><span class="oi oi-file"></span> View Your User Profile</a>
</p>
} }
</ErrorList> <hr>
} <h4>Experience</h4>
else <p>
{ This application does not have a place to individually list your chronological job history; however, you can
<p>Loading your profile...</p> use this area to list prior jobs, their dates, and anything else you want to include that&rsquo;s not already a
} part of your Professional Biography above.
</p>
<div class="form-row">
<div class="col">
<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>
@if (!IsNew)
{
<p>
<br><a href="/profile/view/@state.User!.Id"><span class="oi oi-file"></span> View Your User Profile</a>
</p>
}
</Loading>

View File

@@ -19,9 +19,10 @@ namespace JobsJobsJobs.Client.Pages.Citizen
private int _newSkillCounter = 0; private int _newSkillCounter = 0;
/// <summary> /// <summary>
/// A flag that indicates all the required API calls have completed, and the form is ready to be displayed /// Whether the citizen is seeking employment at the time the profile is loaded (used to show success story
/// link)
/// </summary> /// </summary>
private bool AllLoaded { get; set; } = false; private bool IsSeeking { get; set; } = false;
/// <summary> /// <summary>
/// The form for this page /// The form for this page
@@ -39,26 +40,18 @@ namespace JobsJobsJobs.Client.Pages.Citizen
private bool IsNew { get; set; } = false; private bool IsNew { get; set; } = false;
/// <summary> /// <summary>
/// Error messages from API access /// Set up the data needed to add or edit the user's profile
/// </summary> /// </summary>
private IList<string> ErrorMessages { get; } = new List<string>(); /// <param name="errors">The collection where errors can be reported</param>
public async Task SetUpProfile(ICollection<string> errors)
protected override async Task OnInitializedAsync()
{ {
ServerApi.SetJwt(http, state); ServerApi.SetJwt(http, state);
var continentTask = ServerApi.RetrieveMany<Continent>(http, "continent/all"); var continentTask = state.GetContinents(http);
var profileTask = ServerApi.RetrieveProfile(http, state); var profileTask = ServerApi.RetrieveProfile(http, state);
await Task.WhenAll(continentTask, profileTask); await Task.WhenAll(continentTask, profileTask);
if (continentTask.Result.IsOk) Continents = continentTask.Result;
{
Continents = continentTask.Result.Ok;
}
else
{
ErrorMessages.Add(continentTask.Result.Error);
}
if (profileTask.Result.IsOk) if (profileTask.Result.IsOk)
{ {
@@ -70,15 +63,14 @@ namespace JobsJobsJobs.Client.Pages.Citizen
else else
{ {
ProfileForm = ProfileForm.FromProfile(profileTask.Result.Ok); ProfileForm = ProfileForm.FromProfile(profileTask.Result.Ok);
IsSeeking = profileTask.Result.Ok.SeekingEmployment;
} }
if (ProfileForm.Skills.Count == 0) AddNewSkill(); if (ProfileForm.Skills.Count == 0) AddNewSkill();
} }
else else
{ {
ErrorMessages.Add(profileTask.Result.Error); errors.Add(profileTask.Result.Error);
} }
AllLoaded = true;
} }
/// <summary> /// <summary>
@@ -118,6 +110,5 @@ namespace JobsJobsJobs.Client.Pages.Citizen
toast.ShowError($"{(int)res.StatusCode} {error}"); toast.ShowError($"{(int)res.StatusCode} {error}");
} }
} }
} }
} }

View File

@@ -0,0 +1,95 @@
@page "/how-it-works"
<PageTitle Title="How It Works" />
<h3>How It Works</h3>
<p>
Phase 4 is complete, which means that the entire logged-in version of the application is now available. There are
GitHub issues for each one
(<a href="https://github.com/bit-badger/jobs-jobs-jobs/issues/1" target="_blank">Phase 1</a> &bull;
<a href="https://github.com/bit-badger/jobs-jobs-jobs/issues/2" target="_blank">Phase 2</a> &bull;
<a href="https://github.com/bit-badger/jobs-jobs-jobs/issues/3" target="_blank">Phase 3</a> &bull;
<a href="https://github.com/bit-badger/jobs-jobs-jobs/issues/4" target="_blank">Phase 4</a>), and if you run into any
issues with it, feel free to
<a href="https://github.com/bit-badger/jobs-jobs-jobs/issues" target="_blank">let us know on GitHub</a>, or look up
@("@")danieljsummers on No Agenda Social.
</p>
<h4>Completing Your Profile</h4>
<ul>
<li>
The &ldquo;View Your Employment Profile&rdquo; link (which you&rdquo;ll see on this page, once your profile is
established) shows your profile the way all other validated users will be able to see it. While this site does not
perform communication with others over No Agenda Social, the name on employment profiles is a link to that
user&rsquo;s profile; from there, others can communicate further with you using the tools Mastodon provides.
</li>
<li>
The &ldquo;Professional Biography&rdquo; and &ldquo;Experience&rdquo; sections support Markdown, a plain-text way
to specify formatting quite similar to that provided by word processors. The
<a href="https://daringfireball.net/projects/markdown/" target="_blank">original page</a> for the project is a good
overview of its capabilities, and the pages at
<a href="https://www.markdownguide.org/" target="_blank">Markdown Guide</a> give in-depth lessons to make the most
of this language. The version of Markdown employed here supports many popular extensions, include smart quotes
(turning "a quote" into &ldquo;a quote&rdquo;), tables, super/subscripts, and more.
</li>
<li>
Skills are optional, but they are the place to record skills you have. Along with the skill, there is a
&ldquo;Notes&rdquo; section, which can be used to indicate the time you&rsquo;ve practiced a particular skill, the
mastery you have of that skill, etc. It is free-form text, so it is all up to you how you utilize the field.
</li>
<li>
The &ldquo;Experience&rdquo; field is intended to capture a chronological or topical employment history; with this
&ldquo;quick-n-dirty&rdquo; implementation, this Markdown box can be used to capture that information however you
would like it presented to fellow citizens.
</li>
<li>
<a href="https://github.com/bit-badger/jobs-jobs-jobs/issues/5" target="_blank">Phase 5</a> includes allowing
public access to the continent, region, and skills fields of Gitmo Nation citizens who indicate that they are both
seeking employment <strong>and</strong> want their information disclosed to public users. The &ldquo;Allow my
profile to be searched publicly&rdquo; checkbox, at the bottom of the page where you edit your employment profile,
is how you opt your profile in to this list.
</li>
</ul>
<h4>Searching Profiles</h4>
<p>
The &ldquo;View Profiles&rdquo; link at the side allows you to search for profiles by continent, the citizen&rsquo;s
desire for remote work, a skill, or any text in their professional biography and experience. If you find someone with
whom you&rsquo;d like to discuss potential opportunities, the name at the top of the profile links to their No Agenda
Social account, where you can use its features to get in touch.
</p>
<h4>Finding Employment</h4>
<p>
If your profile indicates that you are seeking employment, and you secure employment, that is something you will
want to update (and &ndash; congratulations!). From both the Dashboard and the Edit Profile pages, you will see a
link that encourages you to tell us about it. Click either of those links, and you will be brought to a page that
allows you to indicate whether your employment actually came from someone finding your profile on Jobs, Jobs, Jobs,
and gives you a place to write about the experience. These stories are only viewable by validated users, so feel free
to use as much (or as little) identifying information as you&rsquo;d like. You can also submit this page with all the
fields blank; in that case, your &ldquo;Seeking Employment&rdquo; flag is cleared, and the &ldquo;story&rdquo; is
recorded.
</p>
<p>
As a validated user, you can also view others success stories. Clicking &ldquo;Success Stories&rdquo; in the sidebar
will display a list of all the stories that have been recorded. If there is a story to be read, there will be a link
to read it; if you submitted the story, there will also be an &ldquo;Edit&rdquo; link.
</p>
<h4>
Publicly Available Information
<em><small>(coming in <a href="https://github.com/bit-badger/jobs-jobs-jobs/issues/5" target="_blank">Phase 5</a>)</small></em>
</h4>
<p>
The &ldquo;public&rdquo; page for profile information will display the following information:
</p>
<ul>
<li>A count of profiles where the citizen is seeking employment</li>
<li>The citizen&rsquo;s continent and region</li>
<li>The citizen&rsquo;s skills and notes</li>
</ul>
<p>
This information will be pullled only from profiles where citizens have said can it be publicly available
<strong>and</strong> are currently seeking employment.
</p>

View File

@@ -1,5 +1,6 @@
@page "/profile/search" @page "/profile/search"
@inject HttpClient http @inject HttpClient http
@inject NavigationManager nav
@inject AppState state @inject AppState state
<PageTitle Title="Search Profiles" /> <PageTitle Title="Search Profiles" />
@@ -12,6 +13,14 @@
} }
else else
{ {
if (!Searched)
{
<p>Enter one or more criteria to filter results, or just click &ldquo;Search&rdquo; to list all profiles.</p>
}
<Collapsible HeaderText="Search Criteria" Collapsed=@(Searched && SearchResults.Any())>
<ProfileSearchForm Criteria=@Criteria OnSearch=@DoSearch Continents=@Continents />
</Collapsible>
<br>
@if (SearchResults.Any()) @if (SearchResults.Any())
{ {
<table class="table table-sm table-hover"> <table class="table table-sm table-hover">
@@ -40,5 +49,9 @@
</tbody> </tbody>
</table> </table>
} }
else if (Searched)
{
<p>No results found for the specified criteria</p>
}
} }
</ErrorList> </ErrorList>

View File

@@ -1,9 +1,11 @@
using JobsJobsJobs.Shared; using JobsJobsJobs.Shared;
using JobsJobsJobs.Shared.Api; using JobsJobsJobs.Shared.Api;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.WebUtilities;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Net;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace JobsJobsJobs.Client.Pages.Profile namespace JobsJobsJobs.Client.Pages.Profile
@@ -20,6 +22,11 @@ namespace JobsJobsJobs.Client.Pages.Profile
/// </summary> /// </summary>
private bool Searching { get; set; } = false; private bool Searching { get; set; } = false;
/// <summary>
/// The search criteria
/// </summary>
private ProfileSearch Criteria { get; set; } = new ProfileSearch();
/// <summary> /// <summary>
/// Error messages encountered while searching for profiles /// Error messages encountered while searching for profiles
/// </summary> /// </summary>
@@ -37,19 +44,36 @@ namespace JobsJobsJobs.Client.Pages.Profile
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
ServerApi.SetJwt(http, state); Continents = await state.GetContinents(http);
var continentResult = await ServerApi.RetrieveMany<Continent>(http, "continent/all");
if (continentResult.IsOk) // Determine if we have searched before
{ var query = QueryHelpers.ParseQuery(nav.ToAbsoluteUri(nav.Uri).Query);
Continents = continentResult.Ok;
}
else
{
ErrorMessages.Add(continentResult.Error);
}
// TODO: remove this call once the filter is ready if (query.TryGetValue("Searched", out var searched))
{
Searched = Convert.ToBoolean(searched);
void setPart(string part, Action<string> func)
{
if (query.TryGetValue(part, out var partValue)) func(partValue);
}
setPart("ContinentId", x => Criteria.ContinentId = x);
setPart("Skill", x => Criteria.Skill = x);
setPart("BioExperience", x => Criteria.BioExperience = x);
setPart("RemoteWork", x => Criteria.RemoteWork = x);
await RetrieveProfiles();
}
}
/// <summary>
/// Do a search
/// </summary>
/// <remarks>This navigates with the parameters in the URL; this should trigger a search</remarks>
private async Task DoSearch()
{
var query = SearchQuery();
query.Add("Searched", "True");
nav.NavigateTo(QueryHelpers.AddQueryString("/profile/search", query));
await RetrieveProfiles(); await RetrieveProfiles();
} }
@@ -60,8 +84,8 @@ namespace JobsJobsJobs.Client.Pages.Profile
{ {
Searching = true; Searching = true;
// TODO: send a filter with this request var searchResult = await ServerApi.RetrieveMany<ProfileSearchResult>(http,
var searchResult = await ServerApi.RetrieveMany<ProfileSearchResult>(http, "profile/search"); QueryHelpers.AddQueryString("profile/search", SearchQuery()));
if (searchResult.IsOk) if (searchResult.IsOk)
{ {
@@ -85,5 +109,27 @@ namespace JobsJobsJobs.Client.Pages.Profile
/// <param name="condition">The condition in question</param> /// <param name="condition">The condition in question</param>
/// <returns>"Yes" for true, "No" for false</returns> /// <returns>"Yes" for true, "No" for false</returns>
private static string YesOrNo(bool condition) => condition ? "Yes" : "No"; private static string YesOrNo(bool condition) => condition ? "Yes" : "No";
/// <summary>
/// Create a search query string from the currently-entered criteria
/// </summary>
/// <returns>The query string for the currently-entered criteria</returns>
private IDictionary<string, string?> SearchQuery()
{
var dict = new Dictionary<string, string?>();
if (Criteria.IsEmptySearch) return dict;
void part(string name, Func<ProfileSearch, string?> func)
{
if (!string.IsNullOrEmpty(func(Criteria))) dict.Add(name, func(Criteria));
}
part("ContinentId", it => it.ContinentId);
part("Skill", it => it.Skill);
part("BioExperience", it => it.BioExperience);
part("RemoteWork", it => it.RemoteWork);
return dict;
}
} }
} }

View File

@@ -2,51 +2,44 @@
@inject HttpClient http @inject HttpClient http
@inject AppState state @inject AppState state
@if (IsLoading) <Loading OnLoad=@RetrieveProfile>
{
<p>Loading profile...</p>
}
else
{
<PageTitle Title=@($"Employment Profile for {Citizen.DisplayName}") /> <PageTitle Title=@($"Employment Profile for {Citizen.DisplayName}") />
<ErrorList Errors=@ErrorMessages> <h2><a href="@Citizen.ProfileUrl" target="_blank">@Citizen.DisplayName</a></h2>
<h2><a href="@Citizen.ProfileUrl" target="_blank">@Citizen.DisplayName</a></h2> <h4>@Profile.Continent!.Name, @Profile.Region</h4>
<h4>@Profile.Continent!.Name, @Profile.Region</h4> <p>@WorkTypes</p>
<p>@WorkTypes</p>
<hr> <hr>
<div> <div>
@(new MarkupString(Profile.Biography.ToHtml())) @(new MarkupString(Profile.Biography.ToHtml()))
</div> </div>
@if (Profile.Skills.Length > 0) @if (Profile.Skills.Length > 0)
{ {
<hr> <hr>
<h4>Skills</h4> <h4>Skills</h4>
<ul> <ul>
@foreach (var skill in Profile.Skills) @foreach (var skill in Profile.Skills)
{ {
var notes = skill.Notes == null ? "" : $" ({skill.Notes})"; var notes = skill.Notes == null ? "" : $" ({skill.Notes})";
<li>@skill.Description@notes</li> <li>@skill.Description@notes</li>
} }
</ul> </ul>
} }
@if (Profile.Experience != null) @if (Profile.Experience != null)
{ {
<hr> <hr>
<h4>Experience / Employment History</h4> <h4>Experience / Employment History</h4>
<div> <div>
@(new MarkupString(Profile.Experience.ToHtml())) @(new MarkupString(Profile.Experience.ToHtml()))
</div> </div>
} }
@if (Id == state.User!.Id.ToString()) @if (Id == state.User!.Id.ToString())
{ {
<hr> <hr>
<p><a href="/citizen/profile"><span class="oi oi-pencil"></span> Edit Your Profile</a></p> <p><a href="/citizen/profile"><span class="oi oi-pencil"></span> Edit Your Profile</a></p>
} }
</ErrorList> </Loading>
}

View File

@@ -1,6 +1,5 @@
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Domain = JobsJobsJobs.Shared; using Domain = JobsJobsJobs.Shared;
@@ -8,11 +7,6 @@ namespace JobsJobsJobs.Client.Pages.Profile
{ {
public partial class View : ComponentBase public partial class View : ComponentBase
{ {
/// <summary>
/// Whether data for this component is loading
/// </summary>
private bool IsLoading { get; set; } = true;
/// <summary> /// <summary>
/// The citizen whose profile is being displayed /// The citizen whose profile is being displayed
/// </summary> /// </summary>
@@ -48,18 +42,17 @@ namespace JobsJobsJobs.Client.Pages.Profile
} }
} }
/// <summary>
/// Error messages from data retrieval
/// </summary>
private IList<string> ErrorMessages { get; } = new List<string>();
/// <summary> /// <summary>
/// The ID of the citizen whose profile should be displayed /// The ID of the citizen whose profile should be displayed
/// </summary> /// </summary>
[Parameter] [Parameter]
public string Id { get; set; } = default!; public string Id { get; set; } = default!;
protected override async Task OnInitializedAsync() /// <summary>
/// Retrieve the requested profile
/// </summary>
/// <param name="errors">A collection to report errors that may occur</param>
public async Task RetrieveProfile(ICollection<string> errors)
{ {
ServerApi.SetJwt(http, state); ServerApi.SetJwt(http, state);
var citizenTask = ServerApi.RetrieveOne<Domain.Citizen>(http, $"citizen/get/{Id}"); var citizenTask = ServerApi.RetrieveOne<Domain.Citizen>(http, $"citizen/get/{Id}");
@@ -73,11 +66,11 @@ namespace JobsJobsJobs.Client.Pages.Profile
} }
else if (citizenTask.Result.IsOk) else if (citizenTask.Result.IsOk)
{ {
ErrorMessages.Add("Citizen not found"); errors.Add("Citizen not found");
} }
else else
{ {
ErrorMessages.Add(citizenTask.Result.Error); errors.Add(citizenTask.Result.Error);
} }
if (profileTask.Result.IsOk && profileTask.Result.Ok != null) if (profileTask.Result.IsOk && profileTask.Result.Ok != null)
@@ -86,14 +79,12 @@ namespace JobsJobsJobs.Client.Pages.Profile
} }
else if (profileTask.Result.IsOk) else if (profileTask.Result.IsOk)
{ {
ErrorMessages.Add("Profile not found"); errors.Add("Profile not found");
} }
else else
{ {
ErrorMessages.Add(profileTask.Result.Error); errors.Add(profileTask.Result.Error);
} }
IsLoading = false;
} }
} }
} }

View File

@@ -0,0 +1,49 @@
@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>
<Loading OnLoad=@RetrieveStory>
@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>
</Loading>

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>
/// 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>
/// Retrieve the story
/// </summary>
/// <param name="errors">A collection to use in reporting errors that may occur</param>
public async Task RetrieveStory(ICollection<string> errors)
{
if (Id != null)
{
ServerApi.SetJwt(http, state);
var story = await ServerApi.RetrieveOne<Success>(http, $"success/{Id}");
if (story.IsOk && story.Ok != null)
{
if (story.Ok.CitizenId == state.User!.Id)
{
Form = new StoryForm
{
Id = story.Ok.Id.ToString(),
FromHere = story.Ok.FromHere,
Story = story.Ok.Story?.Text ?? ""
};
}
else
{
errors.Add("Stop messing with the URL");
}
}
else if (story.IsOk)
{
errors.Add($"The success story {Id} does not exist");
}
else
{
errors.Add(story.Error);
}
}
}
/// <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,59 @@
@page "/success-story/list"
@inject HttpClient http
@inject AppState state
<PageTitle Title="Success Stories" />
<h3>Success Stories</h3>
<Loading OnLoad=@LoadStories>
@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">Found Here?</th>
<th scope="col">Recorded On</th>
</tr>
</thead>
<tbody>
@foreach (var story in Stories)
{
<tr>
<td>
@if (story.HasStory)
{
<a href="/success-story/view/@story.Id">View</a>
}
else
{
<em>None</em>
}
@if (story.CitizenId == state.User!.Id)
{
<text> ~ </text><a href="/success-story/edit/@story.Id">Edit</a>
}
</td>
<td>@story.CitizenName</td>
<td>
@if (story.FromHere)
{
<strong>Yes</strong>
}
else
{
<text>No</text>
}
</td>
<td><FullDate TheDate=@story.RecordedOn /></td>
</tr>
}
</tbody>
</table>
}
else
{
<p>There are no success stories recorded <em>(yet)</em></p>
}
</Loading>

View File

@@ -0,0 +1,34 @@
using JobsJobsJobs.Shared.Api;
using Microsoft.AspNetCore.Components;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace JobsJobsJobs.Client.Pages.SuccessStory
{
public partial class ListStories : ComponentBase
{
/// <summary>
/// The story entries
/// </summary>
private IEnumerable<StoryEntry> Stories { get; set; } = default!;
/// <summary>
/// Load all success stories
/// </summary>
/// <param name="errors">The collection into which errors can be reported</param>
public async Task LoadStories(ICollection<string> errors)
{
ServerApi.SetJwt(http, state);
var stories = await ServerApi.RetrieveMany<StoryEntry>(http, "success/list");
if (stories.IsOk)
{
Stories = stories.Ok;
}
else
{
errors.Add(stories.Error);
}
}
}
}

View File

@@ -0,0 +1,19 @@
@page "/success-story/view/{Id}"
@inject HttpClient http
@inject AppState state
<PageTitle Title="Success Story" />
<Loading OnLoad=@RetrieveStory>
<h3>@Citizen.DisplayName&rsquo;s Success Story</h3>
<h4><FullDateTime TheDate=@Story.RecordedOn /></h4>
@if (Story.FromHere)
{
<p><em><strong>Found via Jobs, Jobs, Jobs</strong></em></p>
}
<hr>
@if (Story.Story != null)
{
<div>@(new MarkupString(Story.Story.ToHtml()))</div>
}
</Loading>

View File

@@ -0,0 +1,69 @@
using Microsoft.AspNetCore.Components;
using System.Collections.Generic;
using System.Threading.Tasks;
using Domain = JobsJobsJobs.Shared;
namespace JobsJobsJobs.Client.Pages.SuccessStory
{
public partial class ViewStory : ComponentBase
{
/// <summary>
/// The ID of the success story to display
/// </summary>
[Parameter]
public string Id { get; set; } = default!;
/// <summary>
/// The success story to be displayed
/// </summary>
private Domain.Success Story { get; set; } = default!;
/// <summary>
/// The citizen who authorized this success story
/// </summary>
private Domain.Citizen Citizen { get; set; } = default!;
/// <summary>
/// Retrieve the success story
/// </summary>
/// <param name="errors">The error collection via which errors will be reported</param>
public async Task RetrieveStory(ICollection<string> errors)
{
ServerApi.SetJwt(http, state);
var story = await ServerApi.RetrieveOne<Domain.Success>(http, $"success/{Id}");
if (story.IsOk)
{
if (story.Ok == null)
{
errors.Add($"Success story {Id} not found");
}
else
{
Story = story.Ok;
var citizen = await ServerApi.RetrieveOne<Domain.Citizen>(http, $"citizen/get/{Story.CitizenId}");
if (citizen.IsOk)
{
if (citizen.Ok == null)
{
errors.Add($"Citizen ID {Story.CitizenId} not found");
}
else
{
Citizen = citizen.Ok;
}
}
else
{
errors.Add(citizen.Error);
}
}
}
else
{
errors.Add(story.Error);
}
}
}
}

View File

@@ -29,7 +29,6 @@ namespace JobsJobsJobs.Client
/// </summary> /// </summary>
static ServerApi() static ServerApi()
{ {
var options = new JsonSerializerOptions var options = new JsonSerializerOptions
{ {
PropertyNamingPolicy = JsonNamingPolicy.CamelCase PropertyNamingPolicy = JsonNamingPolicy.CamelCase

View File

@@ -0,0 +1,25 @@
<div class="card">
<div class="card-header">
<a href="#" class="@(Collapsed ? "jjj-c-collapsed" : "jjj-c-open")"
@onclick=@Toggle @onclick:preventDefault>
@HeaderText
</a>
</div>
@if (!Collapsed)
{
<div class="card-body">@ChildContent</div>
}
</div>
@code {
[Parameter]
public RenderFragment ChildContent { get; set; } = default!;
[Parameter]
public bool Collapsed { get; set; } = false;
[Parameter]
public string HeaderText { get; set; } = "Toggle";
private void Toggle() => Collapsed = !Collapsed;
}

View File

@@ -0,0 +1,16 @@
a.jjj-c-collapsed,
a.jjj-c-open {
text-decoration: none;
font-weight: bold;
color: black;
}
a.jjj-c-collapsed:hover,
a.jjj-c-open:hover {
cursor: pointer;
}
.jjj-c-collapsed::before {
content: '\2b9e \00a0';
}
.jjj-c-open::before {
content: '\2b9f \00a0';
}

View File

@@ -1,23 +1,4 @@
@using NodaTime @inject IJSRuntime js
@using NodaTime.Text @inject AppState state
@using System.Globalization
@Translated @Translated
@code {
/// <summary>
/// The pattern with which dates will be formatted
/// </summary>
private static InstantPattern pattern = InstantPattern.Create("ld<MMMM d, yyyy>", CultureInfo.CurrentCulture);
/// <summary>
/// The date to be formatted
/// </summary>
[Parameter]
public Instant TheDate { get; set; }
/// <summary>
/// The formatted date
/// </summary>
private string Translated => pattern.Format(TheDate);
}

View File

@@ -0,0 +1,30 @@
using Microsoft.AspNetCore.Components;
using NodaTime;
using NodaTime.Text;
using System.Threading.Tasks;
namespace JobsJobsJobs.Client.Shared
{
public partial class FullDate : ComponentBase
{
/// <summary>
/// The pattern with which dates will be formatted
/// </summary>
private static readonly ZonedDateTimePattern Pattern =
ZonedDateTimePattern.CreateWithCurrentCulture("ld<MMMM d, yyyy>", DateTimeZoneProviders.Tzdb);
/// <summary>
/// The date to be formatted
/// </summary>
[Parameter]
public Instant TheDate { get; set; }
/// <summary>
/// The formatted date
/// </summary>
private string Translated { get; set; } = "";
protected override async Task OnInitializedAsync() =>
Translated = Pattern.Format(TheDate.InZone(await state.GetTimeZone(js)));
}
}

View File

@@ -1,23 +1,4 @@
@using NodaTime @inject IJSRuntime js
@using NodaTime.Text @inject AppState state
@using System.Globalization
@Translated @Translated
@code {
/// <summary>
/// The pattern with which dates will be formatted
/// </summary>
private static InstantPattern pattern = InstantPattern.Create("ld<D> ' at ' lt<t>", CultureInfo.CurrentCulture);
/// <summary>
/// The date to be formatted
/// </summary>
[Parameter]
public Instant TheDate { get; set; }
/// <summary>
/// The formatted date
/// </summary>
private string Translated => pattern.Format(TheDate);
}

View File

@@ -0,0 +1,30 @@
using Microsoft.AspNetCore.Components;
using NodaTime;
using NodaTime.Text;
using System.Threading.Tasks;
namespace JobsJobsJobs.Client.Shared
{
public partial class FullDateTime : ComponentBase
{
/// <summary>
/// The pattern with which dates will be formatted
/// </summary>
private static readonly ZonedDateTimePattern Pattern =
ZonedDateTimePattern.CreateWithCurrentCulture("ld<D> ' at ' lt<t>", DateTimeZoneProviders.Tzdb);
/// <summary>
/// The date to be formatted
/// </summary>
[Parameter]
public Instant TheDate { get; set; }
/// <summary>
/// The formatted date
/// </summary>
private string Translated { get; set; } = "";
protected override async Task OnInitializedAsync() =>
Translated = Pattern.Format(TheDate.InZone(await state.GetTimeZone(js)));
}
}

View File

@@ -0,0 +1,18 @@
@if (IsLoading)
{
<p>@Message</p>
}
else if (ErrorMessages.Count > 0)
{
<p>The following error@(ErrorMessages.Count == 1 ? "" : "s") occurred:</p>
<ul>
@foreach (var msg in ErrorMessages)
{
<li><pre>@msg</pre></li>
}
</ul>
}
else
{
@ChildContent
}

View File

@@ -0,0 +1,56 @@
using Microsoft.AspNetCore.Components;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace JobsJobsJobs.Client.Shared
{
public partial class Loading : ComponentBase
{
/// <summary>
/// The delegate to call to load the data for this page
/// </summary>
[Parameter]
public EventCallback<ICollection<string>> OnLoad { get; set; }
/// <summary>
/// The message to display when the page is loading (optional)
/// </summary>
[Parameter]
public MarkupString Message { get; set; } = new MarkupString("Loading&hellip;");
/// <summary>
/// The content to be displayed once the data has been loaded
/// </summary>
[Parameter]
public RenderFragment ChildContent { get; set; } = default!;
/// <summary>
/// Error messages that may arise from the data loading delegate
/// </summary>
private ICollection<string> ErrorMessages { get; set; } = new List<string>();
/// <summary>
/// Whether we are currently loading data
/// </summary>
private bool IsLoading { get; set; } = true;
protected override async Task OnInitializedAsync()
{
if (OnLoad.HasDelegate)
{
try
{
await OnLoad.InvokeAsync(ErrorMessages);
}
finally
{
IsLoading = false;
}
}
else
{
IsLoading = false;
}
}
}
}

View File

@@ -1,7 +1,6 @@
@inherits LayoutComponentBase @inherits LayoutComponentBase
@using System.Reflection
@inject IJSRuntime js
@using Blazored.Toast.Configuration @using Blazored.Toast.Configuration
@inject IJSRuntime js
<div class="page"> <div class="page">
<div class="sidebar"> <div class="sidebar">
@@ -10,7 +9,7 @@
<div class="main"> <div class="main">
<div class="top-row px-4"> <div class="top-row px-4">
<em>(...and Jobs - <a class="audio" @onclick="PlayJobs">Let's Vote for Jobs!</a>)</em> <em>(&hellip;and Jobs - <a class="audio" @onclick="PlayJobs">Let's Vote for Jobs!</a>)</em>
</div> </div>
<div class="content px-4"> <div class="content px-4">
@@ -20,22 +19,12 @@
<source src="/audio/pelosi-jobs.mp3"> <source src="/audio/pelosi-jobs.mp3">
</audio> </audio>
<div class="app-version">Jobs, Jobs, Jobs @Version</div> <div class="app-version">Jobs, Jobs, Jobs @AppState.Version.Value</div>
</div> </div>
</div> </div>
<BlazoredToasts Position="ToastPosition.BottomRight" <BlazoredToasts Position="ToastPosition.BottomRight"
ShowProgressBar="true" /> ShowProgressBar="true" />
@code { @code {
async void PlayJobs() => await js.InvokeVoidAsync("Audio.play", "pelosijobs"); async void PlayJobs() => await js.InvokeVoidAsync("Audio.play", "pelosijobs");
private string Version { get; set; } = "";
protected override void OnInitialized()
{
var version = Assembly.GetExecutingAssembly().GetName().Version!;
Version = $"v{version.Major}.{version.Minor}";
if (version.Build > 0) Version += $".{version.Build}";
base.OnInitialized();
}
} }

View File

@@ -4,49 +4,59 @@
<div class="top-row pl-4 navbar navbar-dark"> <div class="top-row pl-4 navbar navbar-dark">
<a class="navbar-brand" href="">Jobs, Jobs, Jobs</a> <a class="navbar-brand" href="">Jobs, Jobs, Jobs</a>
<button class="navbar-toggler" @onclick="ToggleNavMenu"> <button class="navbar-toggler" @onclick=@ToggleNavMenu>
<span class="navbar-toggler-icon"></span> <span class="navbar-toggler-icon"></span>
</button> </button>
</div> </div>
<div class="@NavMenuCssClass" @onclick="ToggleNavMenu"> <div class="@NavMenuCssClass" @onclick=@ToggleNavMenu>
<ul class="nav flex-column"> <ul class="nav flex-column">
<li class="nav-item px-3">
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
<span class="oi oi-home" aria-hidden="true"></span> Home
</NavLink>
</li>
@if (state.User == null) @if (state.User == null)
{ {
<li class="nav-item px-3"> <li class="nav-item px-3">
<a class="nav-link" href="@AuthUrl"> <NavLink class="nav-link" href="" Match=@NavLinkMatch.All>
<span class="oi oi-account-login" aria-hidden="true"></span> Log On <span class="oi oi-home" aria-hidden="true"></span> Home
</a> </NavLink>
</li> </li>
<li class="nav-item px-3">
<a class="nav-link" href="@AuthUrl">
<span class="oi oi-account-login" aria-hidden="true"></span> Log On
</a>
</li>
} }
else else
{ {
<li class="nav-item px-3"> <li class="nav-item px-3">
<NavLink class="nav-link" href="/citizen/dashboard"> <NavLink class="nav-link" href="/citizen/dashboard">
<span class="oi oi-dashboard" aria-hidden="true"></span> Dashboard <span class="oi oi-dashboard" aria-hidden="true"></span> Dashboard
</NavLink> </NavLink>
</li> </li>
<li class="nav-item px-3"> <li class="nav-item px-3">
<NavLink class="nav-link" href="/citizen/profile"> <NavLink class="nav-link" href="/citizen/profile">
<span class="oi oi-pencil" aria-hidden="true"></span> Edit Your Profile <span class="oi oi-pencil" aria-hidden="true"></span> Edit Your Profile
</NavLink> </NavLink>
</li> </li>
<li class="nav-item px-3"> <li class="nav-item px-3">
<NavLink class="nav-link" href="/profile/search"> <NavLink class="nav-link" href="/profile/search">
<span class="oi oi-spreadsheet" aria-hidden="true"></span> View Profiles <span class="oi oi-spreadsheet" aria-hidden="true"></span> View Profiles
</NavLink> </NavLink>
</li> </li>
<li class="nav-item px-3"> <li class="nav-item px-3">
<NavLink class="nav-link" href="/citizen/log-off"> <NavLink class="nav-link" href="/success-story/list">
<span class="oi oi-plus" aria-hidden="true"></span> Log Off <span class="oi oi-graph" aria-hidden="true"></span> Success Stories
</NavLink> </NavLink>
</li> </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
</NavLink>
</li>
} }
<li class="nav-item px-3">
<NavLink class="nav-link" href="/how-it-works">
<span class="oi oi-question-mark" aria-hidden="true"></span> How It Works
</NavLink>
</li>
</ul> </ul>
</div> </div>

View File

@@ -0,0 +1,60 @@
@using JobsJobsJobs.Shared.Api
<EditForm Model=@Criteria OnValidSubmit=@OnSearch>
<div class="form-row">
<div class="col col-12 col-sm-6 col-md-4 col-lg-3">
<label for="continentId" class="jjj-label">Continent</label>
<InputSelect id="continentId" @bind-Value=@Criteria.ContinentId class="form-control form-control-sm">
<option value="">&ndash; Any &ndash;</option>
@foreach (var (id, name) in Continents)
{
<option value="@id">@name</option>
}
</InputSelect>
</div>
<div class="col col-12 col-sm-6 offset-md-2 col-lg-3 offset-lg-0">
<label class="jjj-label">Seeking Remote Work?</label><br>
<InputRadioGroup @bind-Value=@Criteria.RemoteWork>
<div class="form-check form-check-inline">
<InputRadio id="remoteNull" Value=@("") class="form-check-input" />
<label for="remoteNull" class="form-check-label">No Selection</label>
</div>
<div class="form-check form-check-inline">
<InputRadio id="remoteYes" Value=@("yes") class="form-check-input" />
<label for="remoteYes" class="form-check-label">Yes</label>
</div>
<div class="form-check form-check-inline">
<InputRadio id="remoteNo" Value=@("no") class="form-check-input" />
<label for="remoteNo" class="form-check-label">No</label>
</div>
</InputRadioGroup>
</div>
<div class="col col-12 col-sm-6 col-lg-3">
<label for="skillSearch" class="jjj-label">Skill</label>
<InputText id="skillSearch" @bind-Value=@Criteria.Skill class="form-control form-control-sm"
placeholder="(free-form text)" />
</div>
<div class="col col-12 col-sm-6 col-lg-3">
<label for="bioSearch" class="jjj-label">Bio / Experience</label>
<InputText id="bioSearch" @bind-Value=@Criteria.BioExperience class="form-control form-control-sm"
placeholder="(free-form text)" />
</div>
</div>
<div class="form-row">
<div class="col">
<br>
<button type="submit" class="btn btn-sm btn-outline-primary">Search</button>
</div>
</div>
</EditForm>
@code {
[Parameter]
public ProfileSearch Criteria { get; set; } = default!;
[Parameter]
public EventCallback OnSearch { get; set; } = default!;
[Parameter]
public IEnumerable<Continent> Continents { get; set; } = default!;
}

View File

@@ -65,3 +65,7 @@ label.jjj-required::after {
color: red; color: red;
content: ' *'; content: ' *';
} }
label.jjj-label {
font-style: italic;
}

View File

@@ -43,6 +43,9 @@
function setPageTitle(theTitle) { function setPageTitle(theTitle) {
document.title = theTitle document.title = theTitle
} }
function getTimeZone() {
return Intl.DateTimeFormat().resolvedOptions().timeZone
}
</script> </script>
</body> </body>

View File

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

View File

@@ -33,6 +33,7 @@ namespace JobsJobsJobs.Server.Areas.Api.Controllers
/// Constructor /// Constructor
/// </summary> /// </summary>
/// <param name="db">The data context to use for this request</param> /// <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) public ProfileController(JobsDbContext db, IClock clock)
{ {
_db = db; _db = db;
@@ -112,9 +113,22 @@ namespace JobsJobsJobs.Server.Areas.Api.Controllers
} }
[HttpGet("search")] [HttpGet("search")]
public async Task<IActionResult> Search() public async Task<IActionResult> Search([FromQuery] ProfileSearch search) =>
Ok(await _db.SearchProfiles(search));
[HttpPatch("employment-found")]
public async Task<IActionResult> EmploymentFound()
{ {
return Ok(await _db.SearchProfiles()); 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;
using JobsJobsJobs.Shared.Api; using JobsJobsJobs.Shared.Api;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Npgsql;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Text;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace JobsJobsJobs.Server.Data namespace JobsJobsJobs.Server.Data
@@ -115,12 +113,51 @@ namespace JobsJobsJobs.Server.Data
/// </summary> /// </summary>
// TODO: A criteria parameter! // TODO: A criteria parameter!
/// <returns>The information for profiles matching the criteria</returns> /// <returns>The information for profiles matching the criteria</returns>
public static async Task<IEnumerable<ProfileSearchResult>> SearchProfiles(this JobsDbContext db) public static async Task<IEnumerable<ProfileSearchResult>> SearchProfiles(this JobsDbContext db,
ProfileSearch search)
{ {
return await db.Profiles var query = db.Profiles
.Join(db.Citizens, p => p.Id, c => c.Id, (p, c) => new { Profile = p, Citizen = c }) .Join(db.Citizens, p => p.Id, c => c.Id, (p, c) => new { Profile = p, Citizen = c });
.Select(x => new ProfileSearchResult(x.Citizen.Id, x.Citizen.DisplayName, x.Profile.SeekingEmployment,
x.Profile.RemoteWork, x.Profile.FullTime, x.Profile.LastUpdatedOn)) var useIds = false;
var citizenIds = new List<CitizenId>();
if (!string.IsNullOrEmpty(search.ContinentId))
{
query = query.Where(it => it.Profile.ContinentId == ContinentId.Parse(search.ContinentId));
}
if (!string.IsNullOrEmpty(search.RemoteWork))
{
query = query.Where(it => it.Profile.RemoteWork == (search.RemoteWork == "yes"));
}
if (!string.IsNullOrEmpty(search.Skill))
{
useIds = true;
citizenIds.AddRange(await db.Skills
.Where(s => s.Description.ToLower().Contains(search.Skill.ToLower()))
.Select(s => s.CitizenId)
.ToListAsync().ConfigureAwait(false));
}
if (!string.IsNullOrEmpty(search.BioExperience))
{
useIds = true;
citizenIds.AddRange(await db.Profiles
.FromSqlRaw("SELECT citizen_id FROM profile WHERE biography ILIKE {0} OR experience ILIKE {0}",
$"%{search.BioExperience}%")
.Select(p => p.Id)
.ToListAsync().ConfigureAwait(false));
}
if (useIds)
{
query = query.Where(it => citizenIds.Contains(it.Citizen.Id));
}
return await query.Select(x => new ProfileSearchResult(x.Citizen.Id, x.Citizen.DisplayName,
x.Profile.SeekingEmployment, x.Profile.RemoteWork, x.Profile.FullTime, x.Profile.LastUpdatedOn))
.ToListAsync().ConfigureAwait(false); .ToListAsync().ConfigureAwait(false);
} }
} }

View File

@@ -0,0 +1,35 @@
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, it.Success.FromHere, it.Success.Story != null))
.ToListAsync().ConfigureAwait(false);
}
}

View File

@@ -35,6 +35,9 @@
function setPageTitle(theTitle) { function setPageTitle(theTitle) {
document.title = theTitle document.title = theTitle
} }
function getTimeZone() {
return Intl.DateTimeFormat().resolvedOptions().timeZone
}
</script> </script>
</body> </body>

View File

@@ -0,0 +1,37 @@
namespace JobsJobsJobs.Shared.Api
{
/// <summary>
/// The various ways profiles can be searched
/// </summary>
public class ProfileSearch
{
/// <summary>
/// Retrieve citizens from this continent
/// </summary>
public string? ContinentId { get; set; }
/// <summary>
/// Text for a search within a citizen's skills
/// </summary>
public string? Skill { get; set; }
/// <summary>
/// Text for a search with a citizen's professional biography and experience fields
/// </summary>
public string? BioExperience { get; set; }
/// <summary>
/// Whether to retrieve citizens who do or do not want remote work
/// </summary>
public string RemoteWork { get; set; } = "";
/// <summary>
/// Is the search empty?
/// </summary>
public bool IsEmptySearch =>
string.IsNullOrEmpty(ContinentId)
&& string.IsNullOrEmpty(Skill)
&& string.IsNullOrEmpty(BioExperience)
&& string.IsNullOrEmpty(RemoteWork);
}
}

View File

@@ -0,0 +1,15 @@
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,
bool FromHere,
bool HasStory);
}

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; } = "";
}
}

View File

@@ -1,4 +1,6 @@
namespace JobsJobsJobs.Shared using System;
namespace JobsJobsJobs.Shared
{ {
/// <summary> /// <summary>
/// A result with two different possibilities /// A result with two different possibilities
@@ -60,5 +62,24 @@
/// <param name="error">The error message</param> /// <param name="error">The error message</param>
/// <returns>The Error result</returns> /// <returns>The Error result</returns>
public static Result<TOk> AsError(string error) => new Result<TOk>(false) { Error = error }; public static Result<TOk> AsError(string error) => new Result<TOk>(false) { Error = error };
/// <summary>
/// Transform a result if it is OK, passing the error along if it is an error
/// </summary>
/// <param name="f">The transforming function</param>
/// <param name="result">The existing result</param>
/// <returns>The resultant result</returns>
public static Result<TOk> Bind(Func<TOk, Result<TOk>> f, Result<TOk> result) =>
result.IsOk ? f(result.Ok) : result;
/// <summary>
/// Transform a result to a different type if it is OK, passing the error along if it is an error
/// </summary>
/// <typeparam name="TOther">The type to which the result is transformed</typeparam>
/// <param name="f">The transforming function</param>
/// <param name="result">The existing result</param>
/// <returns>The resultant result</returns>
public static Result<TOther> Map<TOther>(Func<TOk, Result<TOther>> f, Result<TOk> result) =>
result.IsOk ? f(result.Ok) : Result<TOther>.AsError(result.Error);
} }
} }