Repository: temporalio/ui
Pull request #185 (2021-12-27 20:05:16)

Implement routeFor helper for deterministic routes
- Fixes issue with the new event table where the relative URLs were incorrect
- Removes `getWorkflowExecutionUrl`
- Implements `routeFor` helper for deterministic routing.

Eventually, we'll probably want to expand this to accept models and `$page.params` and do the appropriate routing, but this is a solid start to validate the concept.


Patch:
diff --git a/src/lib/components/event-table.svelte b/src/lib/components/event-table.svelte
index b10cc8be..90f2bd26 100644
--- a/src/lib/components/event-table.svelte
+++ b/src/lib/components/event-table.svelte
@@ -1,6 +1,4 @@
 <script lang="ts">
-  import { setContext } from 'svelte';
-
   import Event from './event.svelte';
   import VirtualList from '@sveltejs/svelte-virtual-list';
 
diff --git a/src/lib/components/event.svelte b/src/lib/components/event.svelte
index 5fa8e3b8..17b95fd9 100644
--- a/src/lib/components/event.svelte
+++ b/src/lib/components/event.svelte
@@ -5,16 +5,22 @@
   import { faCalendar } from '@fortawesome/free-solid-svg-icons';
 
   import { formatEvent } from '$lib/utilities/get-event-classification';
+  import { formatDate } from '$lib/utilities/format-date';
+  import { routeFor } from '$lib/utilities/route-for';
 
   import EventLabel from '$lib/components/event-label.svelte';
-  import { formatDate } from '$lib/utilities/format-date';
 
   export let event: HistoryEventWithId | PendingActivity;
 
   let { id, pending, timeStamp, name, tag, classification } =
     formatEvent(event);
 
-  let href = pending ? `pending-${id}` : `event-${id}`;
+  let { namespace, workflow: workflowId, run: runId } = $page.params;
+  let parameters = { namespace, workflowId, runId, eventId: id };
+
+  let href = pending
+    ? routeFor('workflow.events.full.pending', parameters)
+    : routeFor('workflow.events.full.event', parameters);
 </script>
 
 <a
diff --git a/src/lib/utilities/get-workflow-execution-url.ts b/src/lib/utilities/get-workflow-execution-url.ts
deleted file mode 100644
index 1ef865c2..00000000
--- a/src/lib/utilities/get-workflow-execution-url.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import type { WorkflowExecution } from '$lib/models/workflow-execution';
-
-type WorkflowIdentifier = Pick<WorkflowExecution, 'id' | 'runId'>;
-
-export const getWorkflowExecutionUrl = (
-  namespace: string,
-  workflow: WorkflowIdentifier,
-): string => {
-  if (!workflow) return;
-  return `/namespaces/${namespace}/workflows/${workflow.id}/${workflow.runId}`;
-};
diff --git a/src/lib/utilities/route-for.ts b/src/lib/utilities/route-for.ts
new file mode 100644
index 00000000..5a20edd3
--- /dev/null
+++ b/src/lib/utilities/route-for.ts
@@ -0,0 +1,167 @@
+type RoutePath =
+  | 'workflows'
+  | 'workflow'
+  | 'workflow.events'
+  | 'workflow.events.full'
+  | 'workflow.events.full.event'
+  | 'workflow.events.full.pending'
+  | 'workflow.events.compact'
+  | 'workflow.events.compact.activity'
+  | 'workflow.events.json'
+  | 'workflow.stack-trace'
+  | 'workflow.query'
+  | 'workers';
+
+type EventView = 'event' | 'pending' | 'activity';
+type EventHistoryView = 'full' | 'compact' | 'json';
+
+export type NamespaceParameter = { namespace: string };
+export type WorkflowParameters = {
+  workflowId: string;
+  runId: string;
+} & NamespaceParameter;
+export type EventParameter = {
+  eventId: string;
+} & WorkflowParameters;
+export type TaskQueueParameter = {
+  queue: string;
+} & NamespaceParameter;
+
+type CompositeParameters = NamespaceParameter &
+  WorkflowParameters &
+  EventParameter &
+  TaskQueueParameter;
+
+const routeForNamespace = ({ namespace }: NamespaceParameter): string => {
+  return `/namespaces/${namespace}`;
+};
+
+const routeForWorkflows = (parameters: NamespaceParameter): string => {
+  return `${routeForNamespace(parameters)}/workflows`;
+};
+
+const routeForWorkflow = ({
+  workflowId,
+  runId,
+  ...parameters
+}: WorkflowParameters): string => {
+  return `${routeForWorkflows(parameters)}/${workflowId}/${runId}`;
+};
+
+const routeForEventHistory = (
+  parameters: WorkflowParameters,
+  view?: EventHistoryView,
+): string => {
+  if (!view) {
+    return `${routeForWorkflow(parameters)}/history`;
+  } else {
+    return `${routeForWorkflow(parameters)}/history/${view}`;
+  }
+};
+
+const routeForEventHistoryItem = (
+  parameters: WorkflowParameters,
+  view: EventHistoryView,
+  eventType: EventView,
+  eventId: string,
+): string => {
+  return `${routeForEventHistory(parameters, view)}/${eventType}-${eventId}`;
+};
+
+const routeForWorkers = ({ queue, ...parameters }: TaskQueueParameter) => {
+  return `${routeForNamespace(parameters)}/workers/${queue}`;
+};
+
+export function routeFor(
+  path: 'workflows',
+  parameters: NamespaceParameter,
+): string;
+export function routeFor(
+  path:
+    | 'workflow'
+    | 'workflow.events'
+    | 'workflow.events.full'
+    | 'workflow.events.compact'
+    | 'workflow.events.json'
+    | 'workflow.stack-trace'
+    | 'workflow.query',
+  parameters: WorkflowParameters,
+): string;
+export function routeFor(
+  path:
+    | 'workflow.events.full.event'
+    | 'workflow.events.full.pending'
+    | 'workflow.events.compact.activity',
+  parameters: EventParameter,
+): string;
+export function routeFor(
+  path: 'workers',
+  parameters: TaskQueueParameter,
+): string;
+export function routeFor(
+  path: RoutePath,
+  parameters: CompositeParameters,
+): string {
+  if (path === 'workflows') {
+    return routeForWorkflows(parameters);
+  }
+
+  if (path === 'workflow') {
+    return routeForWorkflow(parameters);
+  }
+
+  if (path === 'workflow.events') {
+    return routeForEventHistory(parameters);
+  }
+
+  if (path === 'workflow.events.full') {
+    return routeForEventHistory(parameters, 'full');
+  }
+
+  if (path === 'workflow.events.compact') {
+    return routeForEventHistory(parameters, 'compact');
+  }
+
+  if (path === 'workflow.events.json') {
+    return routeForEventHistory(parameters, 'json');
+  }
+
+  if (path === 'workflow.events.full.event') {
+    return routeForEventHistoryItem(
+      parameters,
+      'full',
+      'event',
+      parameters.eventId,
+    );
+  }
+
+  if (path === 'workflow.events.full.pending') {
+    return routeForEventHistoryItem(
+      parameters,
+      'full',
+      'pending',
+      parameters.eventId,
+    );
+  }
+
+  if (path === 'workflow.events.compact.activity') {
+    return routeForEventHistoryItem(
+      parameters,
+      'compact',
+      'activity',
+      parameters.eventId,
+    );
+  }
+
+  if (path === 'workflow.query') {
+    return routeForWorkflow(parameters) + '/query';
+  }
+
+  if (path === 'workflow.stack-trace') {
+    return routeForWorkflow(parameters) + '/stack-trace';
+  }
+
+  if (path === 'workers') {
+    return routeForWorkers(parameters);
+  }
+}
diff --git a/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/_tabs.svelte b/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/_tabs.svelte
index 15998bed..a214ae5d 100644
--- a/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/_tabs.svelte
+++ b/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/_tabs.svelte
@@ -2,23 +2,29 @@
   import type { WorkflowExecution } from '$lib/models/workflow-execution';
 
   import { page } from '$app/stores';
-
-  import { getWorkflowExecutionUrl } from '$lib/utilities/get-workflow-execution-url';
+  import { routeFor } from '$lib/utilities/route-for';
 
   import Tab from '$lib/components/tab.svelte';
 
   export let workflow: WorkflowExecution;
 
-  const { namespace, workflow: id, run: runId } = $page.params;
+  const { namespace, workflow: workflowId, run: runId } = $page.params;
   const historyEvents = workflow?.historyEvents;
-  const workflowUrl = getWorkflowExecutionUrl(namespace, { id, runId });
-
-  const urlFor = (path: string): string => `${workflowUrl}/${path}`;
 </script>
 
 <nav class="flex gap-6">
-  <Tab label="History" href={urlFor('history')} amount={historyEvents} />
-  <Tab label="Workers" href={urlFor('workers')} />
-  <Tab label="Stack Trace" href={urlFor('stack-trace')} />
-  <Tab label="Queries" href={urlFor('queries')} />
+  <Tab
+    label="History"
+    href={routeFor('workflow.events.full', { namespace, workflowId, runId })}
+    amount={historyEvents}
+  />
+  <Tab label="Workers" href="#not-implemented" />
+  <Tab
+    label="Stack Trace"
+    href={routeFor('workflow.stack-trace', { namespace, workflowId, runId })}
+  />
+  <Tab
+    label="Query"
+    href={routeFor('workflow.query', { namespace, workflowId, runId })}
+  />
 </nav>
diff --git a/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/history/__layout.svelte b/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/history/__layout.svelte
index 6b528390..66f457b4 100644
--- a/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/history/__layout.svelte
+++ b/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/history/__layout.svelte
@@ -1,16 +1,13 @@
 <script context="module" lang="ts">
-  import { getWorkflowExecutionUrl } from '$lib/utilities/get-workflow-execution-url';
   import type { LoadInput } from '@sveltejs/kit';
+  import type { WorkflowParameters } from '$lib/utilities/route-for';
 
-  export async function load({ stuff, page }: LoadInput) {
-    const { workflow, run: runId, namespace } = page.params;
-
-    const path =
-      getWorkflowExecutionUrl(namespace, { id: workflow, runId }) + '/history';
+  export async function load({ page }: LoadInput) {
+    const { workflow: workflowId, run: runId, namespace } = page.params;
 
     return {
       props: {
-        path,
+        workflow: { workflowId, runId, namespace },
       },
     };
   }
@@ -26,8 +23,9 @@
 
   import ToggleButton from '$lib/components/toggle-button.svelte';
   import ToggleButtons from '$lib/components/toggle-buttons.svelte';
+  import { routeFor } from '$lib/utilities/route-for';
 
-  export let path: string;
+  export let workflow: WorkflowParameters;
 </script>
 
 <section class="flex flex-col gap-4">
@@ -35,9 +33,18 @@
     <h3 class="text-lg font-semibold">Event History</h3>
     <div class="flex gap-4">
       <ToggleButtons>
-        <ToggleButton icon={faStream} href="{path}/full" />
-        <ToggleButton icon={faLayerGroup} href="{path}/compact" />
-        <ToggleButton icon={faCode} href="{path}/json" />
+        <ToggleButton
+          icon={faStream}
+          href={routeFor('workflow.events.full', workflow)}
+        />
+        <ToggleButton
+          icon={faLayerGroup}
+          href={routeFor('workflow.events.compact', workflow)}
+        />
+        <ToggleButton
+          icon={faCode}
+          href={routeFor('workflow.events.json', workflow)}
+        />
       </ToggleButtons>
       <ToggleButton icon={faDownload} href="#no-implemented" />
     </div>
diff --git a/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/history/json/index.svelte b/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/history/json/index.svelte
index 49d43ca2..c6a74701 100644
--- a/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/history/json/index.svelte
+++ b/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/history/json/index.svelte
@@ -1,7 +1,7 @@
 <script context="module" lang="ts">
   import type { LoadInput } from '@sveltejs/kit';
 
-  export async function load({ stuff, page }: LoadInput) {
+  export async function load({ stuff }: LoadInput) {
     const { events } = stuff as {
       events: HistoryEventWithId[];
     };
diff --git a/src/routes/namespaces/[namespace]/workflows/_workflows-summary-row.svelte b/src/routes/namespaces/[namespace]/workflows/_workflows-summary-row.svelte
index 87262984..0cb83e3d 100644
--- a/src/routes/namespaces/[namespace]/workflows/_workflows-summary-row.svelte
+++ b/src/routes/namespaces/[namespace]/workflows/_workflows-summary-row.svelte
@@ -1,15 +1,19 @@
 <script lang="ts">
   import type { WorkflowExecution } from '$lib/models/workflow-execution';
   import { namespace } from '$lib/stores/namespace';
-  import { getWorkflowExecutionUrl } from '$lib/utilities/get-workflow-execution-url';
 
   import Time from '$lib/components/workflow-time.svelte';
   import WorkflowStatus from '$lib/components/workflow-status.svelte';
+  import { routeFor } from '$lib/utilities/route-for';
 
   export let workflow: WorkflowExecution;
   export let timeFormat: string;
 
-  $: href = getWorkflowExecutionUrl($namespace, workflow);
+  $: href = routeFor('workflow', {
+    namespace: $namespace,
+    workflowId: workflow.id,
+    runId: workflow.runId,
+  });
 </script>
 
 <a sveltekit:noscroll {href}>


Test patch:
diff --git a/src/lib/utilities/get-workflow-execution-url.test.ts b/src/lib/utilities/get-workflow-execution-url.test.ts
deleted file mode 100644
index 0f3c918e..00000000
--- a/src/lib/utilities/get-workflow-execution-url.test.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import type { WorkflowExecution } from '../models/workflow-execution';
-import { getWorkflowExecutionUrl } from './get-workflow-execution-url';
-
-describe(getWorkflowExecutionUrl, () => {
-  it('should display a workflow URL with just the workflow information', () => {
-    const workflow = { id: 'id', runId: 'run' } as WorkflowExecution;
-
-    expect(getWorkflowExecutionUrl('default', workflow)).toBe(
-      '/namespaces/default/workflows/id/run',
-    );
-  });
-});
diff --git a/src/lib/utilities/route-for.test.ts b/src/lib/utilities/route-for.test.ts
new file mode 100644
index 00000000..69587b65
--- /dev/null
+++ b/src/lib/utilities/route-for.test.ts
@@ -0,0 +1,115 @@
+import { routeFor } from './route-for';
+
+describe(routeFor, () => {
+  it('should route to "workflows"', () => {
+    const path = routeFor('workflows', { namespace: 'default' });
+    expect(path).toBe('/namespaces/default/workflows');
+  });
+
+  it('should route to "workflow"', () => {
+    const path = routeFor('workflow', {
+      namespace: 'default',
+      workflowId: 'abc',
+      runId: 'def',
+    });
+    expect(path).toBe('/namespaces/default/workflows/abc/def');
+  });
+
+  it('should route to "workflow.events"', () => {
+    const path = routeFor('workflow.events', {
+      namespace: 'default',
+      workflowId: 'abc',
+      runId: 'def',
+    });
+    expect(path).toBe('/namespaces/default/workflows/abc/def/history');
+  });
+
+  it('should route to "workflow".events.full', () => {
+    const path = routeFor('workflow.events.full', {
+      namespace: 'default',
+      workflowId: 'abc',
+      runId: 'def',
+    });
+    expect(path).toBe('/namespaces/default/workflows/abc/def/history/full');
+  });
+
+  it('should route to "workflow".events.full.event', () => {
+    const path = routeFor('workflow.events.full.event', {
+      namespace: 'default',
+      workflowId: 'abc',
+      runId: 'def',
+      eventId: '1',
+    });
+    expect(path).toBe(
+      '/namespaces/default/workflows/abc/def/history/full/event-1',
+    );
+  });
+
+  it('should route to "workflow".events.full.pending', () => {
+    const path = routeFor('workflow.events.full.pending', {
+      namespace: 'default',
+      workflowId: 'abc',
+      runId: 'def',
+      eventId: '1',
+    });
+    expect(path).toBe(
+      '/namespaces/default/workflows/abc/def/history/full/pending-1',
+    );
+  });
+
+  it('should route to "workflow".events.compact', () => {
+    const path = routeFor('workflow.events.compact', {
+      namespace: 'default',
+      workflowId: 'abc',
+      runId: 'def',
+    });
+    expect(path).toBe('/namespaces/default/workflows/abc/def/history/compact');
+  });
+
+  it('should route to "workflow".events.compact.activity', () => {
+    const path = routeFor('workflow.events.compact.activity', {
+      namespace: 'default',
+      workflowId: 'abc',
+      runId: 'def',
+      eventId: '1',
+    });
+    expect(path).toBe(
+      '/namespaces/default/workflows/abc/def/history/compact/activity-1',
+    );
+  });
+
+  it('should route to "workflow".events.json', () => {
+    const path = routeFor('workflow.events.json', {
+      namespace: 'default',
+      workflowId: 'abc',
+      runId: 'def',
+    });
+    expect(path).toBe('/namespaces/default/workflows/abc/def/history/json');
+  });
+
+  it('should route to "workflow".stack-trace', () => {
+    const path = routeFor('workflow.stack-trace', {
+      namespace: 'default',
+      workflowId: 'abc',
+      runId: 'def',
+    });
+    expect(path).toBe('/namespaces/default/workflows/abc/def/stack-trace');
+  });
+
+  it('should route to "workflow".query', () => {
+    const path = routeFor('workflow.query', {
+      namespace: 'default',
+      workflowId: 'abc',
+      runId: 'def',
+    });
+    expect(path).toBe('/namespaces/default/workflows/abc/def/query');
+  });
+
+  it('should route to "workers"', () => {
+    const path = routeFor('workers', {
+      namespace: 'default',
+      queue: 'rainbow-statuses',
+    });
+    expect(path).toBe('/namespaces/default/workers/rainbow-statuses');
+  });
+});