Refactor search logic

- type events that are handed between components
- simplify the `resolve` function interface
  - before we had return values and exceptions, now just a single union
    type as return value
  - move navigation to the Search component to reduce its
    responsibilities
  - flatten and simplify multi-level branching
- shuffle around code
  - move the `resolve` function to the `Search` component module,
    because it's only used there

Signed-off-by: Rūdolfs Ošiņš <rudolfs@osins.org>
This commit is contained in:
Rūdolfs Ošiņš
2022-09-29 14:50:13 +02:00
parent 01e0116029
commit d0edc52831
6 changed files with 198 additions and 165 deletions
+10 -11
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import type { Config } from "@app/config";
import type { ResolvedSearch } from "@app/resolver";
import type { ProjectsAndProfiles } from "@app/Search.svelte";
import type { Session } from "@app/session";
import { link } from "svelte-routing";
@@ -27,14 +27,9 @@
export let config: Config;
let query: string;
let results: ResolvedSearch;
let results: ProjectsAndProfiles | null = null;
let sessionButtonHover = false;
let searchResultsDisplayed = false;
function toggleSearchResults() {
searchResultsDisplayed = !searchResultsDisplayed;
}
$: address = session && session.address;
$: tokenBalance = session && session.tokenBalance;
@@ -177,7 +172,6 @@
{config}
on:search={e => {
({ query, results } = e.detail);
toggleSearchResults();
}} />
</div>
<div class="nav">
@@ -259,7 +253,6 @@
}}
on:search={e => {
({ query, results } = e.detail);
toggleSearchResults();
}} />
</div>
<a
@@ -276,7 +269,13 @@
</div>
</div>
{#if searchResultsDisplayed}
<SearchResults {config} {results} {query} on:close={toggleSearchResults} />
{#if results}
<SearchResults
{config}
{results}
{query}
on:close={() => {
results = null;
}} />
{/if}
</header>
+176 -22
View File
@@ -1,42 +1,196 @@
<script lang="ts">
<script lang="ts" context="module">
import type { Host } from "@app/api";
import type { ProjectInfo } from "@app/project";
import { ethers } from "ethers";
import * as utils from "@app/utils";
import { Profile } from "@app/profile";
import { Project } from "@app/project";
export interface ProjectsAndProfiles {
projects: { info: ProjectInfo; seed: Host }[];
profiles: Profile[];
}
type SearchResult =
| { type: "nothing" }
| { type: "error"; message: string }
| { type: "singleProfile"; id: string }
| { type: "singleProject"; seedHost: string; id: string }
| { type: "projectsAndProfiles"; projectsAndProfiles: ProjectsAndProfiles };
async function searchProjectsAndProfiles(
query: string,
config: Config,
): Promise<SearchResult> {
try {
// The query is a plain Ethereum address.
if (ethers.utils.isAddress(query)) {
return { type: "singleProfile", id: query };
}
const projectOnSeeds = Object.keys(config.seeds.pinned).map(seed => ({
nameOrUrn: query,
seed,
}));
// The query is a radicle project URN.
if (utils.isRadicleId(query)) {
const projects = await Project.getMulti(projectOnSeeds);
if (projects.length === 1) {
return {
type: "singleProject",
seedHost: projects[0].seed.host,
id: query,
};
} else {
return {
type: "projectsAndProfiles",
projectsAndProfiles: { projects, profiles: [] },
};
}
}
// The query is either a project or a profile name.
const normalizedQuery = query.toLowerCase();
const projectsAndProfiles: ProjectsAndProfiles = {
projects: [],
profiles: [],
};
try {
const projects = await Project.getMulti(projectOnSeeds);
projectsAndProfiles.projects.push(...projects);
} catch {
// TODO: collect errors and forward to user.
}
try {
let params: string[];
if (utils.isENSName(normalizedQuery, config)) {
params = [normalizedQuery];
} else {
params = [
`${normalizedQuery}.${config.registrar.domain}`,
`${normalizedQuery}.eth`,
];
}
const profiles = await Profile.getMulti(params, config);
projectsAndProfiles.profiles.push(...profiles);
} catch {
// TODO: collect errors and forward to user.
}
const projectCount = projectsAndProfiles.projects.length;
const profileCount = projectsAndProfiles.profiles.length;
if (profileCount === 1 && projectCount === 0) {
return {
type: "singleProfile",
id: projectsAndProfiles.profiles[0].address,
};
}
if (profileCount === 0 && projectCount === 1) {
return {
type: "singleProject",
seedHost: projectsAndProfiles.projects[0].seed.host,
id: query,
};
}
if (profileCount > 0 || projectCount > 0) {
return {
type: "projectsAndProfiles",
projectsAndProfiles,
};
}
return { type: "nothing" };
} catch (error) {
let message = "An unknown error occoured while searching.";
if (error instanceof Error) {
message = error.message;
}
return { type: "error", message };
}
}
</script>
<script lang="ts" strictEvents>
import type { Config } from "@app/config";
import { createEventDispatcher } from "svelte";
import debounce from "lodash/debounce";
import { createEventDispatcher } from "svelte";
import { navigate } from "svelte-routing";
import { resolve } from "@app/resolver";
import Loading from "@app/Loading.svelte";
import TextInput from "@app/TextInput.svelte";
import { unreachable } from "@app/utils";
export let config: Config;
const dispatch = createEventDispatcher<{
finished: boolean;
search: { query: string; results: ProjectsAndProfiles };
}>();
let input = "";
let searching = false;
let shake = false;
let shaking = false;
const dispatch = createEventDispatcher();
const handleKeydown = async (event: KeyboardEvent) => {
function shake() {
shaking = true;
debounce(() => (shaking = false), 500)();
}
async function search(event: KeyboardEvent) {
if (event.key === "Enter") {
if (input === "") {
return;
}
searching = true;
resolve(input, config)
.then(results => {
const query = input;
input = "";
searching = false;
if (results) dispatch("search", { query, results });
})
.catch(() => {
searching = false;
shake = true;
debounce(() => (shake = false), 500)();
dispatch("finished");
const query = input;
const searchResult = await searchProjectsAndProfiles(input, config);
if (searchResult.type === "nothing") {
searching = false;
shake();
} else if (searchResult.type === "error") {
// TODO: show some kind of notification to the user.
shake();
} else if (searchResult.type === "singleProfile") {
input = "";
navigate(`/${searchResult.id}`, { replace: true });
} else if (searchResult.type === "singleProject") {
input = "";
navigate(`/seeds/${searchResult.seedHost}/${searchResult.id}`, {
replace: true,
});
} else if (searchResult.type === "projectsAndProfiles") {
// TODO: show some kind of notification about any errors to the user.
input = "";
dispatch("search", {
query,
results: searchResult.projectsAndProfiles,
});
} else {
unreachable(searchResult);
}
searching = false;
dispatch("finished");
}
};
}
</script>
<style>
.horizontal-shake {
.shaking {
animation: horizontal-shaking 0.35s;
}
@keyframes horizontal-shaking {
@@ -58,12 +212,12 @@
}
</style>
<div class:horizontal-shake={shake}>
<div class:shaking>
<TextInput
variant="dashed"
disabled={searching}
bind:value={input}
on:keydown={handleKeydown}
on:keydown={search}
placeholder="Search a name or address…">
<svelte:fragment slot="right">
{#if searching}
+6 -1
View File
@@ -19,7 +19,12 @@
const getProjects =
config.projects.pinned.length > 0
? Project.getMulti(config.projects.pinned)
? Project.getMulti(
config.projects.pinned.map(project => ({
nameOrUrn: project.urn,
seed: project.seed,
})),
)
: Promise.resolve([]);
const onClick = (project: ProjectInfo, seed: Host) => {
+4 -4
View File
@@ -6,10 +6,10 @@
import Address from "@app/Address.svelte";
import Button from "@app/Button.svelte";
import { createEventDispatcher } from "svelte";
import type { ResolvedSearch } from "@app/resolver";
import type { ProjectsAndProfiles } from "@app/Search.svelte";
export let query: string;
export let results: ResolvedSearch;
export let results: ProjectsAndProfiles;
export let config: Config;
const dispatch = createEventDispatcher();
@@ -63,10 +63,10 @@
{/each}
</ul>
{/if}
{#if results.ens.length > 0}
{#if results.profiles.length > 0}
<p class="highlight txt-medium">ENS names</p>
<ul>
{#each results.ens as profile}
{#each results.profiles as profile}
<li>
<Address address={profile.address} {profile} {config} resolve />
</li>
+2 -2
View File
@@ -485,14 +485,14 @@ export class Project implements ProjectInfo {
}
static async getMulti(
projs: { urn: Urn; seed: string }[],
projs: { nameOrUrn: Urn; seed: string }[],
): Promise<{ info: ProjectInfo; seed: Host }[]> {
const promises = [];
for (const proj of projs) {
const seed = { host: proj.seed, port: null };
promises.push(
Project.getInfo(proj.urn, seed).then(info => {
Project.getInfo(proj.nameOrUrn, seed).then(info => {
return { info, seed };
}),
);
-125
View File
@@ -1,125 +0,0 @@
import type { Host } from "@app/api";
import type { Config } from "@app/config";
import type { ProjectInfo } from "@app/project";
import { navigate } from "svelte-routing";
import { ethers } from "ethers";
import * as utils from "@app/utils";
import { Project } from "@app/project";
import { NotFoundError } from "@app/error";
import { Profile, ProfileType } from "@app/profile";
export interface IProject {
info: ProjectInfo;
seed: Host;
}
export interface ResolvedSearch {
projects: IProject[];
ens: Profile[];
}
export async function resolve(
q: string | null,
config: Config,
): Promise<ResolvedSearch | null> {
const results: ResolvedSearch = {
projects: [],
ens: [],
};
try {
if (q) {
const seeds = Object.keys(config.seeds.pinned).map(seed => ({
urn: q as string,
seed,
}));
if (ethers.utils.isAddress(q)) {
navigate(`/${q}`, { replace: true });
return null;
// ========= Projects =========
} else if (utils.isRadicleId(q)) {
const projects = await Project.getMulti(seeds);
if (projects.length === 1) {
navigate(`/seeds/${projects[0].seed.host}/${q}`, {
replace: true,
});
return null;
} else {
results.projects.push(...projects);
}
} else {
const projects = await Project.getMulti(seeds);
results.projects.push(...projects);
// ========= ENS Names =========
const normalizedQuery = q.toLowerCase();
let profile: Profile | null;
try {
profile = await Profile.get(
normalizedQuery,
ProfileType.Minimal,
config,
);
} catch (e) {
profile = null;
}
if (profile) {
if (results.projects.length === 0) {
navigate(`/${profile.address}`, { replace: true });
return null;
} else {
results.ens.push(profile);
}
} else {
let profiles: Profile[];
try {
profiles = await Profile.getMulti(
[
`${normalizedQuery}.${config.registrar.domain}`,
`${normalizedQuery}.eth`,
],
config,
);
} catch (e) {
profiles = [];
}
if (profiles.length > 1) {
results.ens.push(...profiles);
} else if (profiles.length === 1) {
if (results.projects.length === 0) {
navigate(`/${profiles[0].address}`, { replace: true });
return null;
} else {
results.ens.push(...profiles);
}
} else {
if (results.projects.length === 1) {
navigate(`/seeds/${projects[0].seed.host}/${q}`, {
replace: true,
});
return null;
}
}
}
}
}
if (results.projects.length > 0 || results.ens.length > 0) {
return results;
}
throw new NotFoundError("No search results found");
} catch (e) {
if (e instanceof NotFoundError) {
throw new NotFoundError(e.message);
} else {
throw Error("Not able to resolve search query");
}
}
}