Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4155072990 | |||
| feb3c5fd4a | |||
| 7839b8eb57 | |||
| 15c1a3ff2c |
@@ -11,6 +11,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "JobsJobsJobs.Shared", "Jobs
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{50B51580-9F09-41E2-BC78-DAD38C37B583}"
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{50B51580-9F09-41E2-BC78-DAD38C37B583}"
|
||||||
ProjectSection(SolutionItems) = preProject
|
ProjectSection(SolutionItems) = preProject
|
||||||
|
JobsJobsJobs\Directory.Build.props = JobsJobsJobs\Directory.Build.props
|
||||||
database\tables.sql = database\tables.sql
|
database\tables.sql = database\tables.sql
|
||||||
EndProjectSection
|
EndProjectSection
|
||||||
EndProject
|
EndProject
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
using JobsJobsJobs.Shared;
|
using JobsJobsJobs.Shared;
|
||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
namespace JobsJobsJobs.Client
|
namespace JobsJobsJobs.Client
|
||||||
{
|
{
|
||||||
@@ -45,6 +48,33 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
public AppState() { }
|
public AppState() { }
|
||||||
|
|
||||||
private void NotifyChanged() => OnChange.Invoke();
|
private void NotifyChanged() => OnChange.Invoke();
|
||||||
|
|||||||
@@ -1,12 +1,5 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
|
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net5.0</TargetFramework>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<AssemblyVersion>0.7.0.0</AssemblyVersion>
|
|
||||||
<FileVersion>0.7.0.0</FileVersion>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Blazored.Toast" Version="3.1.2" />
|
<PackageReference Include="Blazored.Toast" Version="3.1.2" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="5.0.1" />
|
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="5.0.1" />
|
||||||
|
|||||||
@@ -28,23 +28,30 @@ else
|
|||||||
started!
|
started!
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
@{
|
|
||||||
/**
|
|
||||||
This is phase 3 stuff...
|
|
||||||
<hr>
|
<hr>
|
||||||
<p>
|
<p>
|
||||||
There @(ProfileCount == 1 ? "is" : "are") @(ProfileCount == 0 ? "no" : ProfileCount) employment
|
There @(ProfileCount == 1 ? "is" : "are") @(ProfileCount == 0 ? "no" : ProfileCount) employment
|
||||||
profile@(ProfileCount != 1 ? "s" : "") from citizens of Gitmo Nation.
|
profile@(ProfileCount != 1 ? "s" : "") from citizens of Gitmo Nation.
|
||||||
@if (ProfileCount > 0)
|
@if (ProfileCount > 0)
|
||||||
{
|
{
|
||||||
<text>Take a look around and see if you can help them find work!</text> <em>(coming soon)</em>
|
<text>Take a look around and see if you can help them find work!</text>
|
||||||
}
|
|
||||||
</p> */
|
|
||||||
}
|
}
|
||||||
|
</p>
|
||||||
</ErrorList>
|
</ErrorList>
|
||||||
}
|
}
|
||||||
<hr>
|
<hr>
|
||||||
<h4>Phase 2 – What Works <small><em>(Last Updated January 8<sup>th</sup>, 2021)</em></small></h4>
|
<h4>
|
||||||
|
<a href="https://github.com/bit-badger/jobs-jobs-jobs/issues/3" target="_blank">Phase 3</a> – What Works
|
||||||
|
<small><em>(v0.8 – Last Updated January 19<sup>th</sup>, 2021)</em></small>
|
||||||
|
</h4>
|
||||||
|
<p>
|
||||||
|
The “View Profiles” link at the side now allows you to search for profiles by continent, the
|
||||||
|
citizen’s desire for remote work, a skill, or any text in their professional biography and experience. If you
|
||||||
|
find someone with whom you’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>
|
||||||
|
<hr>
|
||||||
|
<h4>Phase 2 – What Works <small><em>(v0.7 – Last Updated January 8<sup>th</sup>, 2021)</em></small></h4>
|
||||||
<p>
|
<p>
|
||||||
If you’ve gotten this far, you’ve already passed
|
If you’ve gotten this far, you’ve already passed
|
||||||
<a href="https://github.com/bit-badger/jobs-jobs-jobs/issues/1" target="_blank">Phase 1</a>, which enabled users of
|
<a href="https://github.com/bit-badger/jobs-jobs-jobs/issues/1" target="_blank">Phase 1</a>, which enabled users of
|
||||||
|
|||||||
@@ -46,19 +46,12 @@ namespace JobsJobsJobs.Client.Pages.Citizen
|
|||||||
protected override async Task OnInitializedAsync()
|
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)
|
||||||
{
|
{
|
||||||
|
|||||||
13
src/JobsJobsJobs/Client/Pages/Citizen/LogOff.razor
Normal file
13
src/JobsJobsJobs/Client/Pages/Citizen/LogOff.razor
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
@page "/citizen/log-off"
|
||||||
|
@inject NavigationManager nav
|
||||||
|
@inject AppState state
|
||||||
|
@inject IToastService toast
|
||||||
|
@code {
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
state.Jwt = "";
|
||||||
|
state.User = null;
|
||||||
|
toast.ShowSuccess("Have a Nice Day!", "Log Off Successful");
|
||||||
|
nav.NavigateTo("/");
|
||||||
|
}
|
||||||
|
}
|
||||||
57
src/JobsJobsJobs/Client/Pages/Profile/Search.razor
Normal file
57
src/JobsJobsJobs/Client/Pages/Profile/Search.razor
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
@page "/profile/search"
|
||||||
|
@inject HttpClient http
|
||||||
|
@inject NavigationManager nav
|
||||||
|
@inject AppState state
|
||||||
|
|
||||||
|
<PageTitle Title="Search Profiles" />
|
||||||
|
<h3>Search Profiles</h3>
|
||||||
|
|
||||||
|
<ErrorList Errors=@ErrorMessages>
|
||||||
|
@if (Searching)
|
||||||
|
{
|
||||||
|
<p>Searching profiles...</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (!Searched)
|
||||||
|
{
|
||||||
|
<p>Enter one or more criteria to filter results, or just click “Search” 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())
|
||||||
|
{
|
||||||
|
<table class="table table-sm table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Profile</th>
|
||||||
|
<th scope="col">Name</th>
|
||||||
|
<th scope="col" class="text-center">Seeking?</th>
|
||||||
|
<th scope="col" class="text-center">Remote?</th>
|
||||||
|
<th scope="col" class="text-center">Full-Time?</th>
|
||||||
|
<th scope="col">Last Updated</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var profile in SearchResults)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td><a href="/profile/view/@profile.CitizenId">View</a></td>
|
||||||
|
<td class=@IsSeeking(profile)>@profile.DisplayName</td>
|
||||||
|
<td class="text-center">@YesOrNo(profile.SeekingEmployment)</td>
|
||||||
|
<td class="text-center">@YesOrNo(profile.RemoteWork)</td>
|
||||||
|
<td class="text-center">@YesOrNo(profile.FullTime)</td>
|
||||||
|
<td><FullDate TheDate=@profile.LastUpdated /></td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
else if (Searched)
|
||||||
|
{
|
||||||
|
<p>No results found for the specified criteria</p>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</ErrorList>
|
||||||
135
src/JobsJobsJobs/Client/Pages/Profile/Search.razor.cs
Normal file
135
src/JobsJobsJobs/Client/Pages/Profile/Search.razor.cs
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
using JobsJobsJobs.Shared;
|
||||||
|
using JobsJobsJobs.Shared.Api;
|
||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
using Microsoft.AspNetCore.WebUtilities;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace JobsJobsJobs.Client.Pages.Profile
|
||||||
|
{
|
||||||
|
public partial class Search : ComponentBase
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Whether a search has been performed
|
||||||
|
/// </summary>
|
||||||
|
private bool Searched { get; set; } = false;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Indicates whether a request for matching profiles is in progress
|
||||||
|
/// </summary>
|
||||||
|
private bool Searching { get; set; } = false;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The search criteria
|
||||||
|
/// </summary>
|
||||||
|
private ProfileSearch Criteria { get; set; } = new ProfileSearch();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Error messages encountered while searching for profiles
|
||||||
|
/// </summary>
|
||||||
|
private IList<string> ErrorMessages { get; } = new List<string>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// All continents
|
||||||
|
/// </summary>
|
||||||
|
private IEnumerable<Continent> Continents { get; set; } = Enumerable.Empty<Continent>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The search results
|
||||||
|
/// </summary>
|
||||||
|
private IEnumerable<ProfileSearchResult> SearchResults { get; set; } = Enumerable.Empty<ProfileSearchResult>();
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
Continents = await state.GetContinents(http);
|
||||||
|
|
||||||
|
// Determine if we have searched before
|
||||||
|
var query = QueryHelpers.ParseQuery(nav.ToAbsoluteUri(nav.Uri).Query);
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retreive profiles matching the current search criteria
|
||||||
|
/// </summary>
|
||||||
|
private async Task RetrieveProfiles()
|
||||||
|
{
|
||||||
|
Searching = true;
|
||||||
|
|
||||||
|
var searchResult = await ServerApi.RetrieveMany<ProfileSearchResult>(http,
|
||||||
|
QueryHelpers.AddQueryString("profile/search", SearchQuery()));
|
||||||
|
|
||||||
|
if (searchResult.IsOk)
|
||||||
|
{
|
||||||
|
SearchResults = searchResult.Ok;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ErrorMessages.Add(searchResult.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
Searched = true;
|
||||||
|
Searching = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? IsSeeking(ProfileSearchResult profile) =>
|
||||||
|
profile.SeekingEmployment ? "font-weight-bold" : null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Return "Yes" for true and "No" for false
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="condition">The condition in question</param>
|
||||||
|
/// <returns>"Yes" for true, "No" for false</returns>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/JobsJobsJobs/Client/Shared/Collapsible.razor
Normal file
25
src/JobsJobsJobs/Client/Shared/Collapsible.razor
Normal 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;
|
||||||
|
}
|
||||||
16
src/JobsJobsJobs/Client/Shared/Collapsible.razor.css
Normal file
16
src/JobsJobsJobs/Client/Shared/Collapsible.razor.css
Normal 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';
|
||||||
|
}
|
||||||
23
src/JobsJobsJobs/Client/Shared/FullDate.razor
Normal file
23
src/JobsJobsJobs/Client/Shared/FullDate.razor
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
@using NodaTime
|
||||||
|
@using NodaTime.Text
|
||||||
|
@using System.Globalization
|
||||||
|
|
||||||
|
@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);
|
||||||
|
}
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
{
|
{
|
||||||
var version = Assembly.GetExecutingAssembly().GetName().Version!;
|
var version = Assembly.GetExecutingAssembly().GetName().Version!;
|
||||||
Version = $"v{version.Major}.{version.Minor}";
|
Version = $"v{version.Major}.{version.Minor}";
|
||||||
if (version.Revision > 0) Version += $".{version.Revision}";
|
if (version.Build > 0) Version += $".{version.Build}";
|
||||||
base.OnInitialized();
|
base.OnInitialized();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,11 +33,16 @@
|
|||||||
</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 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="counter">
|
<NavLink class="nav-link" href="/profile/search">
|
||||||
|
<span class="oi oi-spreadsheet" aria-hidden="true"></span> View Profiles
|
||||||
|
</NavLink>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item px-3">
|
||||||
|
<NavLink class="nav-link" href="/citizen/log-off">
|
||||||
<span class="oi oi-plus" aria-hidden="true"></span> Log Off
|
<span class="oi oi-plus" aria-hidden="true"></span> Log Off
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
[Parameter]
|
[Parameter]
|
||||||
public string Title { get; set; } = default!;
|
public string Title { get; set; } = default!;
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
{
|
{
|
||||||
await base.OnInitializedAsync();
|
await base.OnAfterRenderAsync(firstRender);
|
||||||
await js.InvokeVoidAsync("setPageTitle", $"{Title} ~ Jobs, Jobs, Jobs");
|
await js.InvokeVoidAsync("setPageTitle", $"{Title} ~ Jobs, Jobs, Jobs");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
60
src/JobsJobsJobs/Client/Shared/ProfileSearchForm.razor
Normal file
60
src/JobsJobsJobs/Client/Shared/ProfileSearchForm.razor
Normal 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="">– Any –</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!;
|
||||||
|
}
|
||||||
@@ -65,3 +65,7 @@ label.jjj-required::after {
|
|||||||
color: red;
|
color: red;
|
||||||
content: ' *';
|
content: ' *';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
label.jjj-label {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|||||||
8
src/JobsJobsJobs/Directory.Build.props
Normal file
8
src/JobsJobsJobs/Directory.Build.props
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net5.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<AssemblyVersion>0.8.0.0</AssemblyVersion>
|
||||||
|
<FileVersion>0.8.0.0</FileVersion>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
@@ -110,5 +110,9 @@ namespace JobsJobsJobs.Server.Areas.Api.Controllers
|
|||||||
var profile = await _db.FindProfileByCitizen(CitizenId.Parse(id));
|
var profile = await _db.FindProfileByCitizen(CitizenId.Parse(id));
|
||||||
return profile == null ? NotFound() : Ok(profile);
|
return profile == null ? NotFound() : Ok(profile);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("search")]
|
||||||
|
public async Task<IActionResult> Search([FromQuery] ProfileSearch search) =>
|
||||||
|
Ok(await _db.SearchProfiles(search));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using JobsJobsJobs.Shared;
|
using JobsJobsJobs.Shared;
|
||||||
|
using JobsJobsJobs.Shared.Api;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
using System;
|
using System;
|
||||||
@@ -108,5 +109,58 @@ namespace JobsJobsJobs.Server.Data
|
|||||||
/// <returns>The count of skills for the given citizen</returns>
|
/// <returns>The count of skills for the given citizen</returns>
|
||||||
public static async Task<int> CountSkillsByCitizen(this JobsDbContext db, CitizenId citizenId) =>
|
public static async Task<int> CountSkillsByCitizen(this JobsDbContext db, CitizenId citizenId) =>
|
||||||
await db.Skills.CountAsync(s => s.CitizenId == citizenId).ConfigureAwait(false);
|
await db.Skills.CountAsync(s => s.CitizenId == citizenId).ConfigureAwait(false);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Search profiles by the given criteria
|
||||||
|
/// </summary>
|
||||||
|
// TODO: A criteria parameter!
|
||||||
|
/// <returns>The information for profiles matching the criteria</returns>
|
||||||
|
public static async Task<IEnumerable<ProfileSearchResult>> SearchProfiles(this JobsDbContext db,
|
||||||
|
ProfileSearch search)
|
||||||
|
{
|
||||||
|
var query = db.Profiles
|
||||||
|
.Join(db.Citizens, p => p.Id, c => c.Id, (p, c) => new { Profile = p, Citizen = c });
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net5.0</TargetFramework>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<UserSecretsId>553960ef-0c79-47d4-98d8-9ca1708e558f</UserSecretsId>
|
<UserSecretsId>553960ef-0c79-47d4-98d8-9ca1708e558f</UserSecretsId>
|
||||||
<AssemblyVersion>0.7.0.0</AssemblyVersion>
|
|
||||||
<FileVersion>0.7.0.0</FileVersion>
|
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -28,5 +24,4 @@
|
|||||||
<Folder Include="Controllers\" />
|
<Folder Include="Controllers\" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -16,7 +16,9 @@
|
|||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
<div id="app">
|
||||||
<component type="typeof(JobsJobsJobs.Client.App)" render-mode="WebAssemblyPrerendered" />
|
<component type="typeof(JobsJobsJobs.Client.App)" render-mode="WebAssemblyPrerendered" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="blazor-error-ui">
|
<div id="blazor-error-ui">
|
||||||
An unhandled error has occurred.
|
An unhandled error has occurred.
|
||||||
@@ -24,6 +26,16 @@
|
|||||||
<a class="dismiss">🗙</a>
|
<a class="dismiss">🗙</a>
|
||||||
</div>
|
</div>
|
||||||
<script src="_framework/blazor.webassembly.js"></script>
|
<script src="_framework/blazor.webassembly.js"></script>
|
||||||
|
<script>
|
||||||
|
var Audio = {
|
||||||
|
play(audio) {
|
||||||
|
document.getElementById(audio).play()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function setPageTitle(theTitle) {
|
||||||
|
document.title = theTitle
|
||||||
|
}
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ using Microsoft.AspNetCore.Authentication.JwtBearer;
|
|||||||
using Microsoft.AspNetCore.Builder;
|
using Microsoft.AspNetCore.Builder;
|
||||||
using Microsoft.AspNetCore.Hosting;
|
using Microsoft.AspNetCore.Hosting;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.HttpsPolicy;
|
|
||||||
using Microsoft.AspNetCore.ResponseCompression;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
@@ -35,7 +33,9 @@ namespace JobsJobsJobs.Server
|
|||||||
services.AddDbContext<JobsDbContext>(options =>
|
services.AddDbContext<JobsDbContext>(options =>
|
||||||
{
|
{
|
||||||
options.UseNpgsql(Configuration.GetConnectionString("JobsDb"), o => o.UseNodaTime());
|
options.UseNpgsql(Configuration.GetConnectionString("JobsDb"), o => o.UseNodaTime());
|
||||||
// options.LogTo(System.Console.WriteLine, Microsoft.Extensions.Logging.LogLevel.Information);
|
#if DEBUG
|
||||||
|
options.LogTo(System.Console.WriteLine, Microsoft.Extensions.Logging.LogLevel.Information);
|
||||||
|
#endif
|
||||||
});
|
});
|
||||||
services.AddSingleton<IClock>(SystemClock.Instance);
|
services.AddSingleton<IClock>(SystemClock.Instance);
|
||||||
services.AddLogging();
|
services.AddLogging();
|
||||||
@@ -98,7 +98,7 @@ namespace JobsJobsJobs.Server
|
|||||||
endpoints.MapRazorPages();
|
endpoints.MapRazorPages();
|
||||||
endpoints.MapControllers();
|
endpoints.MapControllers();
|
||||||
endpoints.MapFallback("api/{**slug}", send404);
|
endpoints.MapFallback("api/{**slug}", send404);
|
||||||
endpoints.MapFallbackToFile("{**slug}", "index.html");
|
endpoints.MapFallbackToPage("{**slug}", "/_Host");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
37
src/JobsJobsJobs/Shared/Api/ProfileSearch.cs
Normal file
37
src/JobsJobsJobs/Shared/Api/ProfileSearch.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/JobsJobsJobs/Shared/Api/ProfileSearchResult.cs
Normal file
15
src/JobsJobsJobs/Shared/Api/ProfileSearchResult.cs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
using NodaTime;
|
||||||
|
|
||||||
|
namespace JobsJobsJobs.Shared.Api
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// A user matching the profile search
|
||||||
|
/// </summary>
|
||||||
|
public record ProfileSearchResult(
|
||||||
|
CitizenId CitizenId,
|
||||||
|
string DisplayName,
|
||||||
|
bool SeekingEmployment,
|
||||||
|
bool RemoteWork,
|
||||||
|
bool FullTime,
|
||||||
|
Instant LastUpdated);
|
||||||
|
}
|
||||||
@@ -1,12 +1,5 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net5.0</TargetFramework>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<AssemblyVersion>0.7.0.0</AssemblyVersion>
|
|
||||||
<FileVersion>0.7.0.0</FileVersion>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Markdig" Version="0.22.1" />
|
<PackageReference Include="Markdig" Version="0.22.1" />
|
||||||
<PackageReference Include="Nanoid" Version="2.1.0" />
|
<PackageReference Include="Nanoid" Version="2.1.0" />
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user