Radish alpha
r
rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5
Radicle web interface
Radicle
Git
Improve UX when a node is unreachable
Rūdolfs Ošiņš committed 1 month ago
commit 73408cd8265a81e23b8dd32902ee98347dbe9c2c
parent fa85596
11 files changed +155 -36
modified src/lib/router/definitions.ts
@@ -1,3 +1,4 @@
+
import type { BaseUrl } from "@http-client";
import type {
  ResponseError,
  ResponseParseError,
@@ -18,7 +19,7 @@ interface BootingRoute {

export interface NotFoundRoute {
  resource: "notFound";
-
  params: { title: string };
+
  params: { title: string; description?: string; baseUrl?: BaseUrl };
}

export type ErrorParam = Error | ResponseParseError | ResponseError | undefined;
modified src/views/NotFound.svelte
@@ -1,8 +1,13 @@
<script lang="ts">
+
  import type { BaseUrl } from "@http-client";
+

  import Layout from "@app/App/Layout.svelte";
  import IconLarge from "@app/components/IconLarge.svelte";
+
  import SeedSelector from "@app/views/nodes/SeedSelector.svelte";

  export let title: string;
+
  export let description: string | undefined = undefined;
+
  export let baseUrl: BaseUrl | undefined = undefined;
</script>

<style>
@@ -14,11 +19,32 @@
    gap: 1.5rem;
    height: 100%;
  }
+
  .title-row {
+
    display: flex;
+
    align-items: center;
+
    gap: 0.5rem;
+
    white-space: nowrap;
+
  }
</style>

<Layout>
  <div class="container">
    <IconLarge name="desert" />
-
    <div class="title txt-heading-m">{title}</div>
+
    <div class="title-row txt-heading-m">
+
      <span>{title}</span>
+
      {#if baseUrl}
+
        <SeedSelector {baseUrl} compact />
+
      {/if}
+
    </div>
+
    {#if description}
+
      <div
+
        class="txt-body-m-regular"
+
        style:color="var(--color-text-tertiary)"
+
        style:text-align="center">
+
        {#each description.split("\n") as line}
+
          <div>{line}</div>
+
        {/each}
+
      </div>
+
    {/if}
  </div>
</Layout>
modified src/views/nodes/ReposView.svelte
@@ -2,7 +2,6 @@
  import type { BaseUrl, NodeStats } from "@http-client";

  import * as router from "@app/lib/router";
-
  import { baseUrlToString } from "@app/lib/utils";
  import { fetchRepoInfos } from "@app/components/RepoCard";
  import { handleError } from "@app/views/nodes/error";

@@ -196,6 +195,6 @@
      </div>
    {/if}
  {:catch error}
-
    {router.push(handleError(error, baseUrlToString(baseUrl)))}
+
    {router.push(handleError(error, baseUrl))}
  {/await}
</div>
modified src/views/nodes/SeedSelector.svelte
@@ -23,6 +23,7 @@
  import TextInput from "@app/components/TextInput.svelte";

  export let baseUrl: BaseUrl;
+
  export let compact: boolean = false;

  let expanded: boolean = false;
  let loading = false;
@@ -108,14 +109,17 @@
  }
</style>

-
<div class="global-flex-item container" style:width="100%">
+
<div
+
  class="global-flex-item container"
+
  style:width="100%"
+
  style:justify-content={compact ? "center" : "flex-start"}>
  <Popover
    bind:expanded
    popoverPositionTop="2.5rem"
    popoverPadding="0.25rem"
    popoverBorderRadius="var(--border-radius-md)">
    <div class="target" slot="toggle" title="Switch preferred seed" let:toggle>
-
      <div class="txt-body-m-semibold txt-overflow">
+
      <div class:txt-body-m-semibold={!compact} class="txt-overflow">
        {baseUrl.hostname}
      </div>
      <IconButton on:click={toggle} ariaLabel="Toggle seed selector dropdown">
@@ -199,27 +203,29 @@
    </svelte:fragment>
  </Popover>

-
  <IconButton
-
    ariaLabel={some($bookmarkedSeeds, baseUrl) ||
-
    some(config.preferredSeeds, baseUrl)
-
      ? "Remove bookmark"
-
      : "Add bookmark"}
-
    stopPropagation
-
    disabled={some(config.preferredSeeds, baseUrl)}
-
    title={some(config.preferredSeeds, baseUrl)
-
      ? "Default seeds can't be removed"
-
      : undefined}
-
    on:click={() => {
-
      if (some($bookmarkedSeeds, baseUrl)) {
-
        removeBookmark(baseUrl);
-
      } else {
-
        addBookmark(baseUrl);
-
      }
-
    }}>
-
    {#if some($bookmarkedSeeds, baseUrl) || some(config.preferredSeeds, baseUrl)}
-
      <Icon name="bookmark-fill" />
-
    {:else}
-
      <Icon name="bookmark" />
-
    {/if}
-
  </IconButton>
+
  {#if !compact}
+
    <IconButton
+
      ariaLabel={some($bookmarkedSeeds, baseUrl) ||
+
      some(config.preferredSeeds, baseUrl)
+
        ? "Remove bookmark"
+
        : "Add bookmark"}
+
      stopPropagation
+
      disabled={some(config.preferredSeeds, baseUrl)}
+
      title={some(config.preferredSeeds, baseUrl)
+
        ? "Default seeds can't be removed"
+
        : undefined}
+
      on:click={() => {
+
        if (some($bookmarkedSeeds, baseUrl)) {
+
          removeBookmark(baseUrl);
+
        } else {
+
          addBookmark(baseUrl);
+
        }
+
      }}>
+
      {#if some($bookmarkedSeeds, baseUrl) || some(config.preferredSeeds, baseUrl)}
+
        <Icon name="bookmark-fill" />
+
      {:else}
+
        <Icon name="bookmark" />
+
      {/if}
+
    </IconButton>
+
  {/if}
</div>
modified src/views/nodes/error.ts
@@ -1,11 +1,15 @@
+
import type { BaseUrl } from "@http-client";
import type { ErrorRoute, NotFoundRoute } from "@app/lib/router/definitions";

import { ResponseParseError, ResponseError } from "@http-client/lib/fetcher";
+
import { baseUrlToString } from "@app/lib/utils";

export function handleError(
  error: Error | ResponseParseError | ResponseError,
-
  url: string,
+
  baseUrl: BaseUrl,
): NotFoundRoute | ErrorRoute {
+
  const url = baseUrlToString(baseUrl);
+

  if (error instanceof ResponseParseError) {
    return {
      resource: "error",
@@ -30,7 +34,12 @@ export function handleError(
  ) {
    return {
      resource: "notFound",
-
      params: { title: "Node not found" },
+
      params: {
+
        title: "Could not connect to",
+
        description:
+
          "The node may be offline or the address may be incorrect.\nSelect a different node to continue.",
+
        baseUrl,
+
      },
    };
  } else {
    return {
modified src/views/nodes/router.ts
@@ -4,7 +4,6 @@ import type { ErrorRoute, NotFoundRoute } from "@app/lib/router/definitions";
import config from "@app/lib/config";
import { HttpdClient } from "@http-client";
import { ResponseError, ResponseParseError } from "@http-client/lib/fetcher";
-
import { baseUrlToString } from "@app/lib/utils";
import { handleError } from "@app/views/nodes/error";
import { unreachableError } from "@app/views/repos/error";
import { determineSeed } from "./SeedSelector";
@@ -70,7 +69,7 @@ export async function loadNodeRoute(
      error instanceof ResponseError ||
      error instanceof ResponseParseError
    ) {
-
      return handleError(error, baseUrlToString(api.baseUrl));
+
      return handleError(error, baseUrl);
    } else {
      return unreachableError();
    }
modified src/views/repos/error.ts
@@ -44,6 +44,19 @@ export function handleError(
        description: error.description,
      },
    };
+
  } else if (
+
    error instanceof TypeError &&
+
    error.message === "Failed to fetch"
+
  ) {
+
    return {
+
      resource: "notFound",
+
      params: {
+
        title: "Could not connect to",
+
        description:
+
          "The node may be offline or the address may be incorrect.\nSelect a different node to continue.",
+
        baseUrl: route.node,
+
      },
+
    };
  } else {
    return {
      resource: "error",
modified src/views/users/UserReposView.svelte
@@ -77,5 +77,5 @@
    </div>
  {/if}
{:catch error}
-
  {router.push(handleError(error, utils.baseUrlToString(baseUrl)))}
+
  {router.push(handleError(error, baseUrl))}
{/await}
modified src/views/users/router.ts
@@ -67,7 +67,7 @@ export async function loadUserRoute({
      error instanceof ResponseError ||
      error instanceof ResponseParseError
    ) {
-
      return handleError(error, utils.baseUrlToString(api.baseUrl));
+
      return handleError(error, api.baseUrl);
    } else {
      return unreachableError();
    }
modified tests/build/smoke.spec.ts
@@ -5,5 +5,5 @@ test("exceptions in production build", async ({ page }) => {
  // Wait for scripts to finish executing, there might be exceptions that
  // happen after the page has been painted.
  await page.waitForTimeout(2000);
-
  await expect(page.getByText("Node not found")).toBeVisible();
+
  await expect(page.getByText("Could not connect to")).toBeVisible();
});
modified tests/e2e/node.spec.ts
@@ -2,6 +2,7 @@ import {
  defaultConfig,
  expect,
  shortNodeRemote,
+
  sourceBrowsingRid,
  test,
} from "@tests/support/fixtures.js";

@@ -51,6 +52,71 @@ test("show pinned repositories", async ({ page }) => {
  ).toBeVisible();
});

+
test("unreachable node shows error with seed selector", async ({ page }) => {
+
  await page.goto("/nodes/this.node.does.not.exist.xyz", {
+
    waitUntil: "networkidle",
+
  });
+

+
  // Shows the error title with the unreachable hostname.
+
  await expect(page.getByText("Could not connect to")).toBeVisible();
+
  await expect(page.getByText("this.node.does.not.exist.xyz")).toBeVisible();
+

+
  // Shows the description.
+
  await expect(
+
    page.getByText("The node may be offline or the address may be incorrect."),
+
  ).toBeVisible();
+
  await expect(
+
    page.getByText("Select a different node to continue."),
+
  ).toBeVisible();
+

+
  // Shows the seed selector dropdown toggle.
+
  await expect(
+
    page.getByRole("button", { name: "Toggle seed selector dropdown" }),
+
  ).toBeVisible();
+

+
  // Bookmark button is not shown in compact mode.
+
  await expect(
+
    page.getByRole("button", { name: "Add bookmark" }),
+
  ).not.toBeVisible();
+
});
+

+
test("seed selector on not-found page allows navigating to a working node", async ({
+
  page,
+
}) => {
+
  await page.goto("/nodes/this.node.does.not.exist.xyz", {
+
    waitUntil: "networkidle",
+
  });
+

+
  await expect(page.getByText("Could not connect to")).toBeVisible();
+

+
  // Open the seed selector and navigate to the working local node.
+
  await page
+
    .getByRole("button", { name: "Toggle seed selector dropdown" })
+
    .click();
+
  await page.getByPlaceholder("seed.radicle.example").fill("localhost");
+
  await page.getByPlaceholder("seed.radicle.example").press("Enter");
+

+
  // Should navigate to the working node.
+
  await expect(page).toHaveURL("/nodes/localhost");
+
  await expect(page.getByText("source-browsing")).toBeVisible();
+
});
+

+
test("unreachable seed on repo page shows error with seed selector", async ({
+
  page,
+
}) => {
+
  await page.goto(`/nodes/this.node.does.not.exist.xyz/${sourceBrowsingRid}`, {
+
    waitUntil: "networkidle",
+
  });
+

+
  await expect(page.getByText("Could not connect to")).toBeVisible();
+
  await expect(page.getByText("this.node.does.not.exist.xyz")).toBeVisible();
+

+
  // Seed selector is available to navigate away.
+
  await expect(
+
    page.getByRole("button", { name: "Toggle seed selector dropdown" }),
+
  ).toBeVisible();
+
});
+

test("edit seed bookmarks", async ({ page }) => {
  // Proxy requests to seed.example.tld to the local test api.
  await page.route(