Radish alpha
r
rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5
Radicle web interface
Radicle
Git
Add support for radicle-planning-boards
Archived did:key:z6MkpmPi...dmAZ opened 2 years ago

This patch adds support for radicle-planning-boards. The Board is hosted on a separate instance, and is embedded in the radicle-interface as an iframe.

Added a new “board” project page, where I have put all radicle-planning-boards related logic. In general, I tried to keep changes outside this file to a minimum, however to add the new page I had to touch a few files. Not sure if there’s a less invasive way to do this I missed. I would be happy to hear feedback on this.

What the Board page does

  • Renders an iframe with the radicle-planning-boards url (found in config.json)
  • Passes some initial information to the iframe through the url (theme, origin, whether the user is a delegate)
  • Establishes two-way communication with the radicle-planning-boards iframe through the postMessage API, and shares the auth token and theme

Integration

The design provided by Daniel suggest adding tabs on the Issues page header to switch between the Issues and the Board page. I have elected not to do this, and instead added links to the sidebar (and bottom bar on mobile). Here’s my reasoning:

  • As mentioned previously, I tried to keep interference with the existing code to a minimum. Adding tabs to the Issues page header requires modifying the Issues page, which adds yet another point of contact to maintain in the future
  • In the future, the Board could also show patches as well as issues in each column, which would make it a separate entity from the Issues page
  • I think the sidebar can serve as a good place for “plugins” like the Board to hook into

Config

I updated the config schema to accept a new plugins key where config for plugins can be added. The board accepts the following configuration:

  • enabled - Toggles the plugin on and off
  • origin - The origin of the radicle-planning-boards instance

Example:

{
  "plugins": {
    "radiclePlanningBoards": {
      "enabled": true,
      "origin": "https://radicle-planning-boards.radicle.xyz"
    }
  }
}

Happy to change the specifics here based on feedback.

Running radicle-planning-boards locally

  • Clone the repo
  • Update radicleInterfaceOrigin in constants/config.ts to http://localhost:3000
  • Install the dependencies (pnpm install)
  • Start the dev server on a different port than radicle-interface (pnpm dev --port 4000)
  • In radicle-interface, add the origin in the config to http://localhost:4000 and set enabled to true (as shown above)
  • Start radicle-interface
12 files changed +291 -6 8f43a579 541268ca
modified src/App.svelte
@@ -23,6 +23,7 @@
  import Patches from "@app/views/projects/Patches.svelte";
  import Session from "@app/views/session/Index.svelte";
  import Source from "@app/views/projects/Source.svelte";
+
  import Board from "@app/views/projects/Board.svelte";

  import Error from "@app/views/error/View.svelte";
  import Loading from "@app/components/Loading.svelte";
@@ -87,6 +88,8 @@
  <Patches {...$activeRouteStore.params} />
{:else if $activeRouteStore.resource === "project.patch"}
  <Patch {...$activeRouteStore.params} />
+
{:else if $activeRouteStore.resource === "project.board"}
+
  <Board {...$activeRouteStore.params} />
{:else if $activeRouteStore.resource === "error"}
  <Error {...$activeRouteStore.params} />
{:else if $activeRouteStore.resource === "notFound"}
modified src/App/Header/Breadcrumbs.svelte
@@ -26,7 +26,7 @@
  <div class="breadcrumbs">
    <NodeSegment baseUrl={$activeRouteStore.params.baseUrl} showLocalNode />
  </div>
-
{:else if $activeRouteStore.resource === "project.source" || $activeRouteStore.resource === "project.history" || $activeRouteStore.resource === "project.commit" || $activeRouteStore.resource === "project.issues" || $activeRouteStore.resource === "project.newIssue" || $activeRouteStore.resource === "project.issue" || $activeRouteStore.resource === "project.patches" || $activeRouteStore.resource === "project.patch"}
+
{:else if $activeRouteStore.resource === "project.source" || $activeRouteStore.resource === "project.history" || $activeRouteStore.resource === "project.commit" || $activeRouteStore.resource === "project.issues" || $activeRouteStore.resource === "project.newIssue" || $activeRouteStore.resource === "project.issue" || $activeRouteStore.resource === "project.patches" || $activeRouteStore.resource === "project.patch" || $activeRouteStore.resource === "project.board"}
  <div class="breadcrumbs">
    <NodeSegment baseUrl={$activeRouteStore.params.baseUrl} />

modified src/App/Header/Breadcrumbs/ProjectSegment.svelte
@@ -81,6 +81,16 @@
      }}>
      Patches
    </Link>
+
  {:else if activeRoute.resource === "project.board"}
+
    <Separator />
+
    <Link
+
      route={{
+
        resource: "project.board",
+
        project: activeRoute.params.project.id,
+
        node: activeRoute.params.baseUrl,
+
      }}>
+
      Planning Board
+
    </Link>
  {:else if activeRoute.resource === "project.source"}
    {#if activeRoute.params.path !== "/"}
      <Separator />
modified src/components/IconSmall.svelte
@@ -20,6 +20,7 @@
    | "chevron-up"
    | "clipboard"
    | "collapse"
+
    | "columns"
    | "commit"
    | "cross"
    | "cursor"
@@ -201,6 +202,19 @@
      fill-rule="evenodd"
      clip-rule="evenodd"
      d="M4.31307 13.3536C4.11781 13.1583 4.11781 12.8417 4.31307 12.6464L7.175 9.78452C7.63061 9.32891 8.36931 9.32891 8.82492 9.78452L11.6868 12.6464C11.8821 12.8417 11.8821 13.1583 11.6868 13.3536C11.4916 13.5488 11.175 13.5488 10.9797 13.3536L8.11781 10.4916C8.05272 10.4265 7.9472 10.4265 7.88211 10.4916L5.02018 13.3536C4.82492 13.5488 4.50833 13.5488 4.31307 13.3536ZM4.31307 3.64645C4.50833 3.45118 4.82492 3.45118 5.02018 3.64645L7.88211 6.50838C7.9472 6.57346 8.05272 6.57346 8.11781 6.50838L10.9797 3.64645C11.175 3.45118 11.4916 3.45118 11.6868 3.64645C11.8821 3.84171 11.8821 4.15829 11.6868 4.35355L8.82492 7.21548C8.3693 7.6711 7.63061 7.67109 7.175 7.21548L4.31307 4.35355C4.11781 4.15829 4.11781 3.84171 4.31307 3.64645Z" />
+
  {:else if name === "columns"}
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M14 13L14 3L12 3L12 13L14 13ZM15 3C15 2.44772 14.5523 2 14 2L12 2C11.4477 2 11 2.44771 11 3L11 13C11 13.5523 11.4477 14 12 14L14 14C14.5523 14 15 13.5523 15 13L15 3Z" />
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M9 13L9 3L7 3L7 13L9 13ZM10 3C10 2.44772 9.55228 2 9 2L7 2C6.44772 2 6 2.44771 6 3L6 13C6 13.5523 6.44771 14 7 14L9 14C9.55228 14 10 13.5523 10 13L10 3Z" />
+
    <path
+
      fill-rule="evenodd"
+
      clip-rule="evenodd"
+
      d="M4 13L4 3L2 3L2 13L4 13ZM5 3C5 2.44771 4.55228 2 4 2L2 2C1.44772 2 1 2.44771 1 3L1 13C0.999999 13.5523 1.44771 14 2 14L4 14C4.55228 14 5 13.5523 5 13L5 3Z" />
  {:else if name === "commit"}
    <path
      fill-rule="evenodd"
modified src/lib/config.ts
@@ -14,6 +14,13 @@ export interface Config {
  };
  supportWebsite: string;
  fallbackPreferredSeed: BaseUrl;
+
  plugins?: Record<
+
    string,
+
    {
+
      enabled: boolean;
+
      [key: string]: unknown;
+
    }
+
  >;
}

function getConfig(): Config {
modified src/lib/router.ts
@@ -136,7 +136,8 @@ function setTitle(loadedRoute: LoadedRoute) {
    loadedRoute.resource === "project.issues" ||
    loadedRoute.resource === "project.newIssue" ||
    loadedRoute.resource === "project.patches" ||
-
    loadedRoute.resource === "project.patch"
+
    loadedRoute.resource === "project.patch" ||
+
    loadedRoute.resource === "project.board"
  ) {
    title.push(...projectTitle(loadedRoute));
  } else if (loadedRoute.resource === "nodes") {
@@ -257,7 +258,8 @@ export function routeToPath(route: Route): string {
    route.resource === "project.newIssue" ||
    route.resource === "project.issue" ||
    route.resource === "project.patches" ||
-
    route.resource === "project.patch"
+
    route.resource === "project.patch" ||
+
    route.resource === "project.board"
  ) {
    return projectRouteToPath(route);
  } else if (
modified src/lib/router/definitions.ts
@@ -75,7 +75,8 @@ export async function loadRoute(route: Route): Promise<LoadedRoute> {
    route.resource === "project.newIssue" ||
    route.resource === "project.issue" ||
    route.resource === "project.patches" ||
-
    route.resource === "project.patch"
+
    route.resource === "project.patch" ||
+
    route.resource === "project.board"
  ) {
    return await loadProjectRoute(route);
  } else {
added src/views/projects/Board.svelte
@@ -0,0 +1,158 @@
+
<script lang="ts">
+
  import { onMount } from "svelte";
+
  import { z, string, literal, object } from "zod";
+

+
  import type { BaseUrl, Project } from "@httpd-client";
+

+
  import { config } from "@app/lib/config";
+
  import { httpdStore } from "@app/lib/httpd";
+
  import * as utils from "@app/lib/utils";
+
  import * as role from "@app/lib/roles";
+
  import { theme, type Theme } from "@app/lib/appearance";
+

+
  import ErrorMessage from "@app/components/ErrorMessage.svelte";
+
  import Loading from "@app/components/Loading.svelte";
+
  import Layout from "./Layout.svelte";
+

+
  export let baseUrl: BaseUrl;
+
  export let project: Project;
+

+
  const RpbBoardsConfigSchema = object({
+
    enabled: literal(true),
+
    origin: string().url(),
+
  });
+
  type RpbConfig = z.infer<typeof RpbBoardsConfigSchema>;
+

+
  const incomingMessageSchema = object({
+
    type: literal("request-auth-token"),
+
  });
+

+
  type OutgoingMessage =
+
    | {
+
        type: "theme";
+
        theme: Theme;
+
      }
+
    | {
+
        type: "set-auth-token";
+
        authToken: string;
+
      }
+
    | {
+
        type: "remove-auth-token";
+
      };
+

+
  let loading = true;
+
  let error: any;
+
  let rpbConfig: RpbConfig | undefined;
+
  let iFrameSrc: string;
+
  let iFrame: HTMLIFrameElement;
+

+
  $: session =
+
    $httpdStore.state === "authenticated" && utils.isLocal(baseUrl.hostname)
+
      ? $httpdStore.session
+
      : undefined;
+

+
  function postMessage(message: OutgoingMessage) {
+
    if (!rpbConfig) {
+
      return;
+
    }
+

+
    iFrame?.contentWindow?.postMessage(message, rpbConfig.origin);
+
  }
+

+
  function handleIncomingMessage(event: MessageEvent) {
+
    if (!rpbConfig || event.origin !== rpbConfig.origin) {
+
      return;
+
    }
+

+
    const result = incomingMessageSchema.safeParse(event.data);
+
    if (!result.success) {
+
      return;
+
    }
+

+
    switch (result.data.type) {
+
      case "request-auth-token":
+
        if (session?.id) {
+
          postMessage({ type: "set-auth-token", authToken: session.id });
+
        }
+
        break;
+
    }
+
  }
+

+
  onMount(() => {
+
    try {
+
      rpbConfig = RpbBoardsConfigSchema.parse(
+
        config.plugins?.radiclePlanningBoards,
+
      );
+

+
      const url = new URL(rpbConfig.origin);
+
      url.pathname = `${baseUrl.hostname}:${baseUrl.port}/${project.id}`;
+
      url.searchParams.set("initialTheme", $theme);
+
      url.searchParams.set("baseUrl", window.location.origin);
+
      url.searchParams.set(
+
        "canEditLabels",
+
        (!!role.isDelegate(session?.publicKey, project.delegates)).toString(),
+
      );
+
      iFrameSrc = url.toString();
+
    } catch (e) {
+
      error = e;
+
    }
+
  });
+

+
  $: postMessage({ type: "theme", theme: $theme });
+
  $: {
+
    if (!loading) {
+
      if (session?.id) {
+
        postMessage({ type: "set-auth-token", authToken: session.id });
+
      } else {
+
        postMessage({ type: "remove-auth-token" });
+
      }
+
    }
+
  }
+
</script>
+

+
<style>
+
  iframe {
+
    display: block;
+
    width: 100%;
+
    height: 100%;
+
  }
+

+
  .hidden {
+
    position: absolute;
+
    width: 1px;
+
    height: 1px;
+
    padding: 0;
+
    margin: -1px;
+
    overflow: hidden;
+
    clip: rect(0, 0, 0, 0);
+
    white-space: nowrap;
+
    border-width: 0;
+
  }
+
</style>
+

+
<svelte:window on:message={handleIncomingMessage} />
+

+
<Layout {baseUrl} {project} activeTab="board">
+
  {#if error}
+
    <ErrorMessage
+
      title="Invalid configuration"
+
      description="Check the configuration for radicle-planning-boards"
+
      {error} />
+
  {:else}
+
    <iframe
+
      bind:this={iFrame}
+
      title="Planning Board"
+
      src={iFrameSrc}
+
      frameborder="0"
+
      class:hidden={loading}
+
      on:load={() => {
+
        loading = false;
+
      }}
+
      allow="clipboard-read; clipboard-write">
+
    </iframe>
+

+
    {#if loading}
+
      <Loading center />
+
    {/if}
+
  {/if}
+
</Layout>
modified src/views/projects/Header.svelte
@@ -1,5 +1,5 @@
<script lang="ts" context="module">
-
  export type ActiveTab = "source" | "issues" | "patches" | undefined;
+
  export type ActiveTab = "source" | "issues" | "patches" | "board" | undefined;
</script>

<script lang="ts">
modified src/views/projects/Layout.svelte
@@ -2,6 +2,8 @@
  import type { ActiveTab } from "./Header.svelte";
  import type { BaseUrl, Project } from "@httpd-client";

+
  import { config } from "@app/lib/config";
+

  import AppHeader from "@app/App/Header.svelte";

  import Button from "@app/components/Button.svelte";
@@ -126,6 +128,24 @@
          </Button>
        </Link>
      </div>
+

+
      {#if config?.plugins?.radiclePlanningBoards?.enabled}
+
        <div style:width="100%">
+
          <Link
+
            title="Planning Board"
+
            route={{
+
              resource: "project.board",
+
              project: project.id,
+
              node: baseUrl,
+
            }}>
+
            <Button
+
              variant={activeTab === "board" ? "secondary" : "secondary-mobile"}
+
              styleWidth="100%">
+
              <IconSmall name="columns" />
+
            </Button>
+
          </Link>
+
        </div>
+
      {/if}
    </MobileFooter>
  </div>
</div>
modified src/views/projects/Sidebar.svelte
@@ -5,6 +5,7 @@
  import { queryProject } from "@app/lib/projects";
  import { httpdStore, api } from "@app/lib/httpd";
  import { isLocal } from "@app/lib/utils";
+
  import { config } from "@app/lib/config";
  import { onMount } from "svelte";

  import Button from "@app/components/Button.svelte";
@@ -247,6 +248,26 @@
        </div>
      </Button>
    </Link>
+

+
    {#if config?.plugins?.radiclePlanningBoards?.enabled}
+
      <Link
+
        title="Planning Board"
+
        route={{
+
          resource: "project.board",
+
          project: project.id,
+
          node: baseUrl,
+
        }}>
+
        <Button
+
          stylePadding="0.5rem 0.75rem"
+
          size="large"
+
          styleWidth="100%"
+
          styleJustifyContent={"flex-start"}
+
          variant={activeTab === "board" ? "gray" : "background"}>
+
          <IconSmall name="columns" />
+
          <div class="title-counter" class:expanded>Planning Board</div>
+
        </Button>
+
      </Link>
+
    {/if}
  </div>
  <div class="bottom">
    <div class="help" class:expanded>
modified src/views/projects/router.ts
@@ -47,7 +47,8 @@ export type ProjectRoute =
      issue: string;
    }
  | ProjectPatchesRoute
-
  | ProjectPatchRoute;
+
  | ProjectPatchRoute
+
  | ProjectBoardRoute;

interface ProjectIssuesRoute {
  resource: "project.issues";
@@ -101,6 +102,12 @@ interface ProjectPatchesRoute {
  search?: string;
}

+
interface ProjectBoardRoute {
+
  resource: "project.board";
+
  node: BaseUrl;
+
  project: string;
+
}
+

export type ProjectLoadedRoute =
  | {
      resource: "project.source";
@@ -199,6 +206,13 @@ export type ProjectLoadedRoute =
        preferredSeeds: string[];
        publicExplorer: string;
      };
+
    }
+
  | {
+
      resource: "project.board";
+
      params: {
+
        baseUrl: BaseUrl;
+
        project: Project;
+
      };
    };

export type BlobResult =
@@ -335,6 +349,15 @@ export async function loadProjectRoute(
      };
    } else if (route.resource === "project.patches") {
      return await loadPatchesView(route);
+
    } else if (route.resource === "project.board") {
+
      if (!config?.plugins?.radiclePlanningBoards?.enabled) {
+
        return {
+
          resource: "notFound",
+
          params: { title: "Page not found" },
+
        };
+
      }
+

+
      return await loadBoardView(route);
    } else {
      return unreachable(route);
    }
@@ -343,6 +366,21 @@ export async function loadProjectRoute(
  }
}

+
async function loadBoardView(
+
  route: ProjectBoardRoute,
+
): Promise<ProjectLoadedRoute> {
+
  const api = new HttpdClient(route.node);
+
  const project = await api.project.getById(route.project);
+

+
  return {
+
    resource: "project.board",
+
    params: {
+
      baseUrl: route.node,
+
      project,
+
    },
+
  };
+
}
+

async function loadPatchesView(
  route: ProjectPatchesRoute,
): Promise<ProjectLoadedRoute> {
@@ -749,6 +787,12 @@ export function resolveProjectRoute(
    }
  } else if (content === "patches") {
    return resolvePatchesRoute(node, project, segments, urlSearch);
+
  } else if (content === "board") {
+
    return {
+
      resource: "project.board",
+
      node,
+
      project,
+
    };
  } else {
    return null;
  }
@@ -872,6 +916,8 @@ export function projectRouteToPath(route: ProjectRoute): string {
    return url;
  } else if (route.resource === "project.patch") {
    return patchRouteToPath(route);
+
  } else if (route.resource === "project.board") {
+
    return [...pathSegments, "board"].join("/");
  } else {
    return unreachable(route);
  }
@@ -936,6 +982,9 @@ export function projectTitle(loadedRoute: ProjectLoadedRoute) {
  } else if (loadedRoute.resource === "project.patches") {
    title.push(loadedRoute.params.project.name);
    title.push("patches");
+
  } else if (loadedRoute.resource === "project.board") {
+
    title.push(loadedRoute.params.project.name);
+
    title.push("planning board");
  } else {
    return unreachable(loadedRoute);
  }