4 Commits
v0.7 ... v0.8

Author SHA1 Message Date
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
15c1a3ff2c Return all users with profiles (#3)
Also fixed server pre-rendering and added log off functionality
2021-01-10 22:06:26 -05:00
27 changed files with 573 additions and 72 deletions

View File

@@ -11,6 +11,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "JobsJobsJobs.Shared", "Jobs
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{50B51580-9F09-41E2-BC78-DAD38C37B583}"
ProjectSection(SolutionItems) = preProject
JobsJobsJobs\Directory.Build.props = JobsJobsJobs\Directory.Build.props
database\tables.sql = database\tables.sql
EndProjectSection
EndProject

View File

@@ -1,5 +1,8 @@
using JobsJobsJobs.Shared;
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading.Tasks;
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() { }
private void NotifyChanged() => OnChange.Invoke();

View File

@@ -1,12 +1,5 @@
<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>
<PackageReference Include="Blazored.Toast" Version="3.1.2" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="5.0.1" />

View File

@@ -28,23 +28,30 @@ else
started!
</p>
}
@{
/**
This is phase 3 stuff...
<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> <em>(coming soon)</em>
}
</p> */
<text>Take a look around and see if you can help them find work!</text>
}
</p>
</ErrorList>
}
<hr>
<h4>Phase 2 &ndash; 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> &ndash; What Works
<small><em>(v0.8 &ndash; Last Updated January 19<sup>th</sup>, 2021)</em></small>
</h4>
<p>
The &ldquo;View Profiles&rdquo; link at the side now 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>
<hr>
<h4>Phase 2 &ndash; What Works <small><em>(v0.7 &ndash; 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

View File

@@ -46,19 +46,12 @@ namespace JobsJobsJobs.Client.Pages.Citizen
protected override async Task OnInitializedAsync()
{
ServerApi.SetJwt(http, state);
var continentTask = ServerApi.RetrieveMany<Continent>(http, "continent/all");
var continentTask = state.GetContinents(http);
var profileTask = ServerApi.RetrieveProfile(http, state);
await Task.WhenAll(continentTask, profileTask);
if (continentTask.Result.IsOk)
{
Continents = continentTask.Result.Ok;
}
else
{
ErrorMessages.Add(continentTask.Result.Error);
}
Continents = continentTask.Result;
if (profileTask.Result.IsOk)
{

View 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("/");
}
}

View 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 &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())
{
<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>

View 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;
}
}
}

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

@@ -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);
}

View File

@@ -35,7 +35,7 @@
{
var version = Assembly.GetExecutingAssembly().GetName().Version!;
Version = $"v{version.Major}.{version.Minor}";
if (version.Revision > 0) Version += $".{version.Revision}";
if (version.Build > 0) Version += $".{version.Build}";
base.OnInitialized();
}
}

View File

@@ -33,11 +33,16 @@
</li>
<li class="nav-item px-3">
<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>
</li>
<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
</NavLink>
</li>

View File

@@ -3,9 +3,9 @@
[Parameter]
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");
}
}

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;
content: ' *';
}
label.jjj-label {
font-style: italic;
}

View 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>

View File

@@ -110,5 +110,9 @@ namespace JobsJobsJobs.Server.Areas.Api.Controllers
var profile = await _db.FindProfileByCitizen(CitizenId.Parse(id));
return profile == null ? NotFound() : Ok(profile);
}
[HttpGet("search")]
public async Task<IActionResult> Search([FromQuery] ProfileSearch search) =>
Ok(await _db.SearchProfiles(search));
}
}

View File

@@ -1,4 +1,5 @@
using JobsJobsJobs.Shared;
using JobsJobsJobs.Shared.Api;
using Microsoft.EntityFrameworkCore;
using Npgsql;
using System;
@@ -108,5 +109,58 @@ namespace JobsJobsJobs.Server.Data
/// <returns>The count of skills for the given citizen</returns>
public static async Task<int> CountSkillsByCitizen(this JobsDbContext db, CitizenId citizenId) =>
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);
}
}
}

View File

@@ -1,11 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
<Nullable>enable</Nullable>
<UserSecretsId>553960ef-0c79-47d4-98d8-9ca1708e558f</UserSecretsId>
<AssemblyVersion>0.7.0.0</AssemblyVersion>
<FileVersion>0.7.0.0</FileVersion>
</PropertyGroup>
<ItemGroup>
@@ -28,5 +24,4 @@
<Folder Include="Controllers\" />
</ItemGroup>
</Project>

View File

@@ -16,7 +16,9 @@
</head>
<body>
<div id="app">
<component type="typeof(JobsJobsJobs.Client.App)" render-mode="WebAssemblyPrerendered" />
</div>
<div id="blazor-error-ui">
An unhandled error has occurred.
@@ -24,6 +26,16 @@
<a class="dismiss">🗙</a>
</div>
<script src="_framework/blazor.webassembly.js"></script>
<script>
var Audio = {
play(audio) {
document.getElementById(audio).play()
}
}
function setPageTitle(theTitle) {
document.title = theTitle
}
</script>
</body>
</html>

View File

@@ -3,8 +3,6 @@ using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.HttpsPolicy;
using Microsoft.AspNetCore.ResponseCompression;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
@@ -35,7 +33,9 @@ namespace JobsJobsJobs.Server
services.AddDbContext<JobsDbContext>(options =>
{
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.AddLogging();
@@ -98,7 +98,7 @@ namespace JobsJobsJobs.Server
endpoints.MapRazorPages();
endpoints.MapControllers();
endpoints.MapFallback("api/{**slug}", send404);
endpoints.MapFallbackToFile("{**slug}", "index.html");
endpoints.MapFallbackToPage("{**slug}", "/_Host");
});
}
}

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>
/// A user matching the profile search
/// </summary>
public record ProfileSearchResult(
CitizenId CitizenId,
string DisplayName,
bool SeekingEmployment,
bool RemoteWork,
bool FullTime,
Instant LastUpdated);
}

View File

@@ -1,12 +1,5 @@
<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>
<PackageReference Include="Markdig" Version="0.22.1" />
<PackageReference Include="Nanoid" Version="2.1.0" />

View File

@@ -1,4 +1,6 @@
namespace JobsJobsJobs.Shared
using System;
namespace JobsJobsJobs.Shared
{
/// <summary>
/// A result with two different possibilities
@@ -60,5 +62,24 @@
/// <param name="error">The error message</param>
/// <returns>The Error result</returns>
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);
}
}