4 Commits
v0.9.1 ... v1

Author SHA1 Message Date
b98d28adb4 Update welcome page (#5) 2021-06-14 22:09:33 -04:00
60ed7e1e79 Complete public search (#5)
- Bump version
- Define nav between profile and continent/skills
- Remove redundant code
2021-06-14 21:49:20 -04:00
fb147888c5 Add public search page / initial API (#5) 2021-04-07 21:49:22 -04:00
4a73927e64 Update README 2021-03-16 20:53:09 -04:00
26 changed files with 443 additions and 98 deletions

View File

@@ -1,3 +1,5 @@
# jobs-jobs-jobs # Jobs, Jobs, Jobs <small>_(and Jobs - Let's Vote for Jobs!)_</small>
Repository for the development of No Agenda Jobs - currently parked [here](http://jobs.bitbadger.solutions) Source repository for **Jobs, Jobs, Jobs**, the jobs and career site for No Agenda nation.
What is No Agenda? [So glad you asked!](https://noagendashow.net)

2
src/.dockerignore Normal file
View File

@@ -0,0 +1,2 @@
**/bin
**/obj

10
src/Dockerfile Normal file
View File

@@ -0,0 +1,10 @@
FROM mcr.microsoft.com/dotnet/sdk:5.0 AS build
WORKDIR /jjj
COPY . ./
WORKDIR /jjj/JobsJobsJobs/Server
RUN dotnet publish JobsJobsJobs.Server.csproj -c Release /p:PublishProfile=Properties/PublishProfiles/FolderProfile.xml
FROM mcr.microsoft.com/dotnet/aspnet:5.0
WORKDIR /jjj
COPY --from=build /jjj/JobsJobsJobs/Server/bin/Release/net5.0/linux-x64/publish/ ./
ENTRYPOINT [ "/jjj/JobsJobsJobs.Server" ]

View File

@@ -11,8 +11,10 @@ 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
.dockerignore = .dockerignore
database\12-add-real-name.sql = database\12-add-real-name.sql database\12-add-real-name.sql = database\12-add-real-name.sql
JobsJobsJobs\Directory.Build.props = JobsJobsJobs\Directory.Build.props JobsJobsJobs\Directory.Build.props = JobsJobsJobs\Directory.Build.props
Dockerfile = Dockerfile
database\tables.sql = database\tables.sql database\tables.sql = database\tables.sql
EndProjectSection EndProjectSection
EndProject EndProject

View File

@@ -22,7 +22,7 @@ namespace JobsJobsJobs.Client
/// <summary> /// <summary>
/// The application version, as a nice display string /// The application version, as a nice display string
/// </summary> /// </summary>
public static Lazy<string> Version => new Lazy<string>(() => public static Lazy<string> Version => new(() =>
{ {
var version = Assembly.GetExecutingAssembly().GetName().Version!; var version = Assembly.GetExecutingAssembly().GetName().Version!;
var display = $"v{version.Major}.{version.Minor}"; var display = $"v{version.Major}.{version.Minor}";

View File

@@ -11,7 +11,7 @@
{ {
<p> <p>
Your employment profile was last updated <FullDateTime TheDate=@Profile.LastUpdatedOn />. Your profile currently Your employment profile was last updated <FullDateTime TheDate=@Profile.LastUpdatedOn />. Your profile currently
lists @Profile.Skills.Length skill@(Profile.Skills.Length != 1 ? "s" : ""). lists @Profile.Skills.Count skill@(Profile.Skills.Count != 1 ? "s" : "").
</p> </p>
<p><a href="/profile/view/@state.User.Id">View Your Employment Profile</a></p> <p><a href="/profile/view/@state.User.Id">View Your Employment Profile</a></p>
@if (Profile.SeekingEmployment) @if (Profile.SeekingEmployment)
@@ -41,7 +41,6 @@
</Loading> </Loading>
<hr> <hr>
<p> <p>
To see what is currently done, and how this application works, check out &ldquo;How It Works&rdquo; in the sidebar. To see how this application works, check out &ldquo;How It Works&rdquo; in the sidebar (last updated June
The application now has 4 of 5 phases complete towards version 1.0; the documentation was last updated January 14<sup>th</sup>, 2021).
31<sup>st</sup>, 2021.
</p> </p>

View File

@@ -4,18 +4,6 @@
<h3>How It Works</h3> <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> <h4>Completing Your Profile</h4>
<ul> <ul>
<li> <li>
@@ -44,11 +32,10 @@
would like it presented to fellow citizens. would like it presented to fellow citizens.
</li> </li>
<li> <li>
<a href="https://github.com/bit-badger/jobs-jobs-jobs/issues/5" target="_blank">Phase 5</a> includes allowing If you check the &ldquo;Allow my profile to be searched publicly&rdquo; checkbox, <strong>and</strong> you are
public access to the continent, region, and skills fields of Gitmo Nation citizens who indicate that they are both seeking employment, your continent, region, and skills fields will be searchable and displayed to public users of
seeking employment <strong>and</strong> want their information disclosed to public users. The &ldquo;Allow my the site. They will not be tied to your No Agenda Social handle or real name; they are there to let people peek
profile to be searched publicly&rdquo; checkbox, at the bottom of the page where you edit your employment profile, behind the curtain a bit, and hopefully inspire them to join us.
is how you opt your profile in to this list.
</li> </li>
</ul> </ul>
@@ -77,19 +64,18 @@
to read it; if you submitted the story, there will also be an &ldquo;Edit&rdquo; link. to read it; if you submitted the story, there will also be an &ldquo;Edit&rdquo; link.
</p> </p>
<h4> <h4>Publicly Available Information</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> <p>
The &ldquo;public&rdquo; page for profile information will display the following information: The &ldquo;Job Seekers&rdquo; page for profile information will allow users to search for and display the continent,
region, skills, and notes of users who are seeking employment <strong>and</strong> have opted in to their information
being publicly searchable. If you are a public user, this information is always the latest we have; check out the link
at the top of the search results for how you can learn more about these fine human resources!
</p> </p>
<ul>
<li>A count of profiles where the citizen is seeking employment</li> <h4>Help / Suggestions</h4>
<li>The citizen&rsquo;s continent and region</li>
<li>The citizen&rsquo;s skills and notes</li>
</ul>
<p> <p>
This information will be pullled only from profiles where citizens have said can it be publicly available This is open-source software
<strong>and</strong> are currently seeking employment. <a href="https://github.com/bit-badger/jobs-jobs-jobs" _target="_blank">developed on Github</a>; feel free to
<a href="https://github.com/bit-badger/jobs-jobs-jobs/issues" target="_blank">create an issue there</a>, or look up
@("@")danieljsummers on No Agenda Social.
</p> </p>

View File

@@ -4,8 +4,8 @@
<PageTitle Title="Welcome!" /> <PageTitle Title="Welcome!" />
<p> <p>
Future home of No Agenda Jobs, where citizens of Gitmo Nation can assist one another in finding or enhancing their Welcome to Jobs, Jobs, Jobs (AKA No Agenda Careers), where citizens of Gitmo Nation can assist one another in finding
employment. This will enable them to continue providing value for value to Adam and John, as they continue their work employment. This will enable them to continue providing value-for-value to Adam and John, as they continue their work
deconstructing the misinformation that passes for news on a day-to-day basis. deconstructing the misinformation that passes for news on a day-to-day basis.
</p> </p>
<p> <p>

View File

@@ -5,7 +5,6 @@ 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.Profiles namespace JobsJobsJobs.Client.Pages.Profiles
@@ -56,10 +55,10 @@ namespace JobsJobsJobs.Client.Pages.Profiles
{ {
if (query.TryGetValue(part, out var partValue)) func(partValue); if (query.TryGetValue(part, out var partValue)) func(partValue);
} }
setPart("ContinentId", x => Criteria.ContinentId = x); setPart(nameof(Criteria.ContinentId), x => Criteria.ContinentId = x);
setPart("Skill", x => Criteria.Skill = x); setPart(nameof(Criteria.Skill), x => Criteria.Skill = x);
setPart("BioExperience", x => Criteria.BioExperience = x); setPart(nameof(Criteria.BioExperience), x => Criteria.BioExperience = x);
setPart("RemoteWork", x => Criteria.RemoteWork = x); setPart(nameof(Criteria.RemoteWork), x => Criteria.RemoteWork = x);
await RetrieveProfiles(); await RetrieveProfiles();
} }
@@ -100,6 +99,11 @@ namespace JobsJobsJobs.Client.Pages.Profiles
Searching = false; Searching = false;
} }
/// <summary>
/// Return a CSS class if the user is actively seeking work
/// </summary>
/// <param name="profile">The result in question</param>
/// <returns>A string with the appropriate CSS class, if actively seeking work</returns>
private static string? IsSeeking(ProfileSearchResult profile) => private static string? IsSeeking(ProfileSearchResult profile) =>
profile.SeekingEmployment ? "font-weight-bold" : null; profile.SeekingEmployment ? "font-weight-bold" : null;
@@ -124,10 +128,10 @@ namespace JobsJobsJobs.Client.Pages.Profiles
if (!string.IsNullOrEmpty(func(Criteria))) dict.Add(name, func(Criteria)); if (!string.IsNullOrEmpty(func(Criteria))) dict.Add(name, func(Criteria));
} }
part("ContinentId", it => it.ContinentId); part(nameof(Criteria.ContinentId), it => it.ContinentId);
part("Skill", it => it.Skill); part(nameof(Criteria.Skill), it => it.Skill);
part("BioExperience", it => it.BioExperience); part(nameof(Criteria.BioExperience), it => it.BioExperience);
part("RemoteWork", it => it.RemoteWork); part(nameof(Criteria.RemoteWork), it => it.RemoteWork);
return dict; return dict;
} }

View File

@@ -0,0 +1,62 @@
@page "/profile/seeking"
@inject HttpClient http
@inject NavigationManager nav
@inject AppState state
<PageTitle Title="People Seeking Work" />
<h3>People Seeking Work</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 &ldquo;Search&rdquo; to list all profiles.</p>
}
<Collapsible HeaderText="Search Criteria" Collapsed=@(Searched && SearchResults.Any())>
<PublicSearchForm Criteria=@Criteria OnSearch=@DoSearch Continents=@Continents />
</Collapsible>
<br>
@if (SearchResults.Any())
{
<p>
These profiles match your search criteria. To learn more about these people, join the merry band of human
resources in the <a href="https://noagendashow.net" target="_blank">No Agenda</a> tribe!
</p>
<table class="table table-sm table-hover">
<thead>
<tr>
<th scope="col">Continent</th>
<th scope="col" class="text-center">Region</th>
<th scope="col" class="text-center">Remote?</th>
<th scope="col" class="text-center">Skills</th>
</tr>
</thead>
<tbody>
@foreach (var profile in SearchResults)
{
<tr>
<td>@profile.Continent</td>
<td>@profile.Region</td>
<td class="text-center">@YesOrNo(profile.RemoteWork)</td>
<td>
@foreach (var skill in profile.Skills)
{
@skill.Replace(" ()", "")<br>
}
</td>
</tr>
}
</tbody>
</table>
}
else if (Searched)
{
<p>No results found for the specified criteria</p>
}
}
</ErrorList>

View File

@@ -0,0 +1,134 @@
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.Threading.Tasks;
namespace JobsJobsJobs.Client.Pages.Profiles
{
public partial class Seeking : 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 PublicSearch Criteria { get; set; } = new PublicSearch();
/// <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<PublicSearchResult> SearchResults { get; set; } = Enumerable.Empty<PublicSearchResult>();
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(nameof(Criteria.ContinentId), x => Criteria.ContinentId = x);
setPart(nameof(Criteria.Region), x => Criteria.Region = x);
setPart(nameof(Criteria.Skill), x => Criteria.Skill = x);
setPart(nameof(Criteria.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/seeking", query));
await RetrieveProfiles();
}
/// <summary>
/// Retreive profiles matching the current search criteria
/// </summary>
private async Task RetrieveProfiles()
{
Searching = true;
var searchResult = await ServerApi.RetrieveMany<PublicSearchResult>(http,
QueryHelpers.AddQueryString("profile/public-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<PublicSearch, string?> func)
{
if (!string.IsNullOrEmpty(func(Criteria))) dict.Add(name, func(Criteria));
}
part(nameof(Criteria.ContinentId), it => it.ContinentId);
part(nameof(Criteria.Region), it => it.Region);
part(nameof(Criteria.Skill), it => it.Skill);
part(nameof(Criteria.RemoteWork), it => it.RemoteWork);
return dict;
}
}
}

View File

@@ -15,7 +15,7 @@
</div> </div>
@if (Profile.Skills.Length > 0) @if (Profile.Skills.Count > 0)
{ {
<hr> <hr>
<h4>Skills</h4> <h4>Skills</h4>

View File

@@ -2,7 +2,6 @@
using JobsJobsJobs.Shared.Api; using JobsJobsJobs.Shared.Api;
using NodaTime; using NodaTime;
using NodaTime.Serialization.SystemTextJson; using NodaTime.Serialization.SystemTextJson;
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Net; using System.Net;

View File

@@ -18,6 +18,11 @@
<span class="oi oi-home" aria-hidden="true"></span> Home <span class="oi oi-home" aria-hidden="true"></span> Home
</NavLink> </NavLink>
</li> </li>
<li class="nav-item px-3">
<NavLink class="nav-link" href="/profile/seeking">
<span class="oi oi-spreadsheet" aria-hidden="true"></span> Job Seekers
</NavLink>
</li>
<li class="nav-item px-3"> <li class="nav-item px-3">
<a class="nav-link" href="@AuthUrl"> <a class="nav-link" href="@AuthUrl">
<span class="oi oi-account-login" aria-hidden="true"></span> Log On <span class="oi oi-account-login" aria-hidden="true"></span> Log On

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 col-md-4 col-lg-3">
<label for="region" class="jjj-label">Region</label>
<InputText id="region" @bind-Value=@Criteria.Region class="form-control form-control-sm"
placeholder="(free-form text)" />
</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>
<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 PublicSearch Criteria { get; set; } = default!;
[Parameter]
public EventCallback OnSearch { get; set; } = default!;
[Parameter]
public IEnumerable<Continent> Continents { get; set; } = default!;
}

View File

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

View File

@@ -1,5 +1,4 @@
using JobsJobsJobs.Server.Data; using JobsJobsJobs.Server.Data;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using System.Threading.Tasks; using System.Threading.Tasks;
@@ -9,7 +8,6 @@ namespace JobsJobsJobs.Server.Areas.Api.Controllers
/// API endpoint for continent information /// API endpoint for continent information
/// </summary> /// </summary>
[Route("api/[controller]")] [Route("api/[controller]")]
[Authorize]
[ApiController] [ApiController]
public class ContinentController : ControllerBase public class ContinentController : ControllerBase
{ {

View File

@@ -97,10 +97,6 @@ namespace JobsJobsJobs.Server.Areas.Api.Controllers
return Ok(); return Ok();
} }
[HttpGet("skills")]
public async Task<IActionResult> GetSkills() =>
Ok(await _db.FindSkillsByCitizen(CurrentCitizenId));
[HttpGet("count")] [HttpGet("count")]
public async Task<IActionResult> GetProfileCount() => public async Task<IActionResult> GetProfileCount() =>
Ok(new Count(await _db.CountProfiles())); Ok(new Count(await _db.CountProfiles()));
@@ -120,6 +116,11 @@ namespace JobsJobsJobs.Server.Areas.Api.Controllers
public async Task<IActionResult> Search([FromQuery] ProfileSearch search) => public async Task<IActionResult> Search([FromQuery] ProfileSearch search) =>
Ok(await _db.SearchProfiles(search)); Ok(await _db.SearchProfiles(search));
[HttpGet("public-search")]
[AllowAnonymous]
public async Task<IActionResult> SearchPublic([FromQuery] PublicSearch search) =>
Ok(await _db.SearchPublicProfiles(search));
[HttpPatch("employment-found")] [HttpPatch("employment-found")]
public async Task<IActionResult> EmploymentFound() public async Task<IActionResult> EmploymentFound()
{ {

View File

@@ -12,38 +12,36 @@ namespace JobsJobsJobs.Server.Data
/// Citizen ID converter /// Citizen ID converter
/// </summary> /// </summary>
public static readonly ValueConverter<CitizenId, string> CitizenIdConverter = public static readonly ValueConverter<CitizenId, string> CitizenIdConverter =
new ValueConverter<CitizenId, string>(v => v.ToString(), v => CitizenId.Parse(v)); new(v => v.ToString(), v => CitizenId.Parse(v));
/// <summary> /// <summary>
/// Continent ID converter /// Continent ID converter
/// </summary> /// </summary>
public static readonly ValueConverter<ContinentId, string> ContinentIdConverter = public static readonly ValueConverter<ContinentId, string> ContinentIdConverter =
new ValueConverter<ContinentId, string>(v => v.ToString(), v => ContinentId.Parse(v)); new(v => v.ToString(), v => ContinentId.Parse(v));
/// <summary> /// <summary>
/// Markdown converter /// Markdown converter
/// </summary> /// </summary>
public static readonly ValueConverter<MarkdownString, string> MarkdownStringConverter = public static readonly ValueConverter<MarkdownString, string> MarkdownStringConverter =
new ValueConverter<MarkdownString, string>(v => v.Text, v => new MarkdownString(v)); new(v => v.Text, v => new MarkdownString(v));
/// <summary> /// <summary>
/// Markdown converter for possibly-null values /// Markdown converter for possibly-null values
/// </summary> /// </summary>
public static readonly ValueConverter<MarkdownString?, string?> OptionalMarkdownStringConverter = public static readonly ValueConverter<MarkdownString?, string?> OptionalMarkdownStringConverter =
new ValueConverter<MarkdownString?, string?>( new(v => v == null ? null : v.Text, v => v == null ? null : new MarkdownString(v));
v => v == null ? null : v.Text,
v => v == null ? null : new MarkdownString(v));
/// <summary> /// <summary>
/// Skill ID converter /// Skill ID converter
/// </summary> /// </summary>
public static readonly ValueConverter<SkillId, string> SkillIdConverter = public static readonly ValueConverter<SkillId, string> SkillIdConverter =
new ValueConverter<SkillId, string>(v => v.ToString(), v => SkillId.Parse(v)); new(v => v.ToString(), v => SkillId.Parse(v));
/// <summary> /// <summary>
/// Success ID converter /// Success ID converter
/// </summary> /// </summary>
public static readonly ValueConverter<SuccessId, string> SuccessIdConverter = public static readonly ValueConverter<SuccessId, string> SuccessIdConverter =
new ValueConverter<SuccessId, string>(v => v.ToString(), v => SuccessId.Parse(v)); new(v => v.ToString(), v => SuccessId.Parse(v));
} }
} }

View File

@@ -83,8 +83,12 @@ namespace JobsJobsJobs.Server.Data
m.Property(e => e.LastUpdatedOn).HasColumnName("last_updated_on").IsRequired(); m.Property(e => e.LastUpdatedOn).HasColumnName("last_updated_on").IsRequired();
m.Property(e => e.Experience).HasColumnName("experience") m.Property(e => e.Experience).HasColumnName("experience")
.HasConversion(Converters.OptionalMarkdownStringConverter); .HasConversion(Converters.OptionalMarkdownStringConverter);
m.Ignore(e => e.Continent); m.HasOne(e => e.Continent)
m.Ignore(e => e.Skills); .WithMany()
.HasForeignKey(e => e.ContinentId);
m.HasMany(e => e.Skills)
.WithOne()
.HasForeignKey(e => e.CitizenId);
}); });
modelBuilder.Entity<Skill>(m => modelBuilder.Entity<Skill>(m =>

View File

@@ -18,24 +18,13 @@ namespace JobsJobsJobs.Server.Data
/// </summary> /// </summary>
/// <param name="citizenId">The ID of the citizen whose profile should be retrieved</param> /// <param name="citizenId">The ID of the citizen whose profile should be retrieved</param>
/// <returns>The profile, or null if it does not exist</returns> /// <returns>The profile, or null if it does not exist</returns>
public static async Task<Profile?> FindProfileByCitizen(this JobsDbContext db, CitizenId citizenId) public static async Task<Profile?> FindProfileByCitizen(this JobsDbContext db, CitizenId citizenId) =>
{ await db.Profiles.AsNoTracking()
var profile = await db.Profiles.AsNoTracking() .Include(p => p.Continent)
.Include(p => p.Skills)
.SingleOrDefaultAsync(p => p.Id == citizenId) .SingleOrDefaultAsync(p => p.Id == citizenId)
.ConfigureAwait(false); .ConfigureAwait(false);
if (profile != null)
{
return profile with
{
Continent = await db.FindContinentById(profile.ContinentId).ConfigureAwait(false),
Skills = (await db.FindSkillsByCitizen(citizenId).ConfigureAwait(false)).ToArray()
};
}
return null;
}
/// <summary> /// <summary>
/// Save a profile /// Save a profile
/// </summary> /// </summary>
@@ -52,16 +41,6 @@ namespace JobsJobsJobs.Server.Data
} }
} }
/// <summary>
/// Retrieve all skills for the given citizen
/// </summary>
/// <param name="citizenId">The ID of the citizen whose skills should be retrieved</param>
/// <returns>The skills defined for this citizen</returns>
public static async Task<IEnumerable<Skill>> FindSkillsByCitizen(this JobsDbContext db, CitizenId citizenId) =>
await db.Skills.AsNoTracking()
.Where(s => s.CitizenId == citizenId)
.ToListAsync().ConfigureAwait(false);
/// <summary> /// <summary>
/// Save a skill /// Save a skill
/// </summary> /// </summary>
@@ -111,7 +90,7 @@ namespace JobsJobsJobs.Server.Data
/// <summary> /// <summary>
/// Search profiles by the given criteria /// Search profiles by the given criteria
/// </summary> /// </summary>
// TODO: A criteria parameter! /// <param name="search">The search parameters</param>
/// <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) ProfileSearch search)
@@ -161,6 +140,56 @@ namespace JobsJobsJobs.Server.Data
.ToListAsync().ConfigureAwait(false); .ToListAsync().ConfigureAwait(false);
} }
/// <summary>
/// Search public profiles by the given criteria
/// </summary>
/// <param name="search">The search parameters</param>
/// <returns>The information for profiles matching the criteria</returns>
public static async Task<IEnumerable<PublicSearchResult>> SearchPublicProfiles(this JobsDbContext db,
PublicSearch search)
{
var query = db.Profiles
.Include(it => it.Continent)
.Include(it => it.Skills)
.Where(it => it.IsPublic);
var useIds = false;
var citizenIds = new List<CitizenId>();
if (!string.IsNullOrEmpty(search.ContinentId))
{
query = query.Where(it => it.ContinentId == ContinentId.Parse(search.ContinentId));
}
if (!string.IsNullOrEmpty(search.Region))
{
query = query.Where(it => it.Region.ToLower().Contains(search.Region.ToLower()));
}
if (!string.IsNullOrEmpty(search.RemoteWork))
{
query = query.Where(it => it.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 (useIds)
{
query = query.Where(it => citizenIds.Contains(it.Id));
}
return await query.Select(x => new PublicSearchResult(x.Continent!.Name, x.Region, x.RemoteWork,
x.Skills.Select(sk => $"{sk.Description} ({sk.Notes})")))
.ToListAsync().ConfigureAwait(false);
}
/// <summary> /// <summary>
/// Delete skills and profile for the given citizen /// Delete skills and profile for the given citizen
/// </summary> /// </summary>

View File

@@ -6,7 +6,7 @@
<ActiveDebugProfile>JobsJobsJobs.Server</ActiveDebugProfile> <ActiveDebugProfile>JobsJobsJobs.Server</ActiveDebugProfile>
<Controller_SelectedScaffolderID>MvcControllerEmptyScaffolder</Controller_SelectedScaffolderID> <Controller_SelectedScaffolderID>MvcControllerEmptyScaffolder</Controller_SelectedScaffolderID>
<Controller_SelectedScaffolderCategoryPath>root/Common/MVC/Controller</Controller_SelectedScaffolderCategoryPath> <Controller_SelectedScaffolderCategoryPath>root/Common/MVC/Controller</Controller_SelectedScaffolderCategoryPath>
<NameOfLastUsedPublishProfile>FolderProfile</NameOfLastUsedPublishProfile> <NameOfLastUsedPublishProfile>C:\Users\danie\Documents\sandbox\jobs-jobs-jobs\src\JobsJobsJobs\Server\Properties\PublishProfiles\FolderProfile.pubxml</NameOfLastUsedPublishProfile>
</PropertyGroup> </PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'"> <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
<DebuggerFlavor>ProjectDebugger</DebuggerFlavor> <DebuggerFlavor>ProjectDebugger</DebuggerFlavor>

View File

@@ -5,6 +5,6 @@ https://go.microsoft.com/fwlink/?LinkID=208121.
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> <Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup> <PropertyGroup>
<_PublishTargetUrl>C:\Users\danie\Documents\sandbox\jobs-jobs-jobs\src\JobsJobsJobs\Server\bin\Release\net5.0\publish\</_PublishTargetUrl> <_PublishTargetUrl>C:\Users\danie\Documents\sandbox\jobs-jobs-jobs\src\JobsJobsJobs\Server\bin\Release\net5.0\publish\</_PublishTargetUrl>
<History>True|2021-03-16T23:34:57.2747439Z;</History> <History>True|2021-06-15T02:08:33.4261507Z;True|2021-06-14T21:58:04.2622487-04:00;True|2021-03-16T19:34:57.2747439-04:00;</History>
</PropertyGroup> </PropertyGroup>
</Project> </Project>

View File

@@ -0,0 +1,37 @@
namespace JobsJobsJobs.Shared.Api
{
/// <summary>
/// The parameters for a public job search
/// </summary>
public class PublicSearch
{
/// <summary>
/// Retrieve citizens from this continent
/// </summary>
public string? ContinentId { get; set; }
/// <summary>
/// Retrieve citizens from this region
/// </summary>
public string? Region { get; set; }
/// <summary>
/// Text for a search within a citizen's skills
/// </summary>
public string? Skill { 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(Region)
&& string.IsNullOrEmpty(Skill)
&& string.IsNullOrEmpty(RemoteWork);
}
}

View File

@@ -0,0 +1,13 @@
using System.Collections.Generic;
namespace JobsJobsJobs.Shared.Api
{
/// <summary>
/// A public profile search result
/// </summary>
public record PublicSearchResult(
string Continent,
string Region,
bool RemoteWork,
IEnumerable<string> Skills);
}

View File

@@ -1,5 +1,5 @@
using NodaTime; using NodaTime;
using System; using System.Collections.Generic;
namespace JobsJobsJobs.Shared namespace JobsJobsJobs.Shared
{ {
@@ -26,6 +26,6 @@ namespace JobsJobsJobs.Shared
/// <summary> /// <summary>
/// Convenience property for skills associated with a profile /// Convenience property for skills associated with a profile
/// </summary> /// </summary>
public Skill[] Skills { get; set; } = Array.Empty<Skill>(); public ICollection<Skill> Skills { get; set; } = new List<Skill>();
} }
} }