Repository: temporalio/ui
Pull request #79 (2021-09-08 22:22:11)

Pagination
Recursively paginates through the API. The is currently _not_ optimized for view-layer performance.

**Nota bene**: This pull request also addresses some of the outstanding lint issues.


Patch:
diff --git a/src/lib/components/workflow-time.svelte b/src/lib/components/workflow-time.svelte
index 80e8083a..05ec0c24 100644
--- a/src/lib/components/workflow-time.svelte
+++ b/src/lib/components/workflow-time.svelte
@@ -18,3 +18,9 @@
 {#if time && timeFormat === 'UTC'}
   <p>{utc}</p>
 {/if}
+
+<style lang="postcss">
+  p {
+    @apply font-mono;
+  }
+</style>
diff --git a/src/lib/models/workflow-execution.ts b/src/lib/models/workflow-execution.ts
index 896259c7..f46a421f 100644
--- a/src/lib/models/workflow-execution.ts
+++ b/src/lib/models/workflow-execution.ts
@@ -10,14 +10,16 @@ import { formatDate } from '$lib/utilities/format-date';
 type Optional<T extends unknown, K extends keyof T = keyof T> = Omit<T, K> &
   Partial<Pick<T, K>>;
 
-export const toWorkflowExecution = (response: WorkflowExecutionAPIResponse) => {
+export const toWorkflowExecution = (
+  response: WorkflowExecutionAPIResponse,
+): WorkflowExecution => {
   return new WorkflowExecution(response);
 };
 
 export const toWorkflowExecutions = (
-  workflowExecutions: WorkflowExecutionInfo[],
-) => {
-  return workflowExecutions.map(
+  executions: WorkflowExecutionInfo[],
+): WorkflowExecution[] => {
+  return executions.map(
     (workflowExecutionInfo) => new WorkflowExecution({ workflowExecutionInfo }),
   );
 };
diff --git a/src/lib/services/workflow-execution-service.ts b/src/lib/services/workflow-execution-service.ts
index 14d41769..79efa96a 100644
--- a/src/lib/services/workflow-execution-service.ts
+++ b/src/lib/services/workflow-execution-service.ts
@@ -1,3 +1,5 @@
+import { paginated } from '$lib/utilities/paginated';
+import { toURL } from '$lib/utilities/to-url';
 import type {
   WorkflowExecutionInfo,
   PollerInfo,
@@ -9,33 +11,63 @@ import type {
 
 const base = 'http://localhost:8080/api/v1';
 
-export type GetAllWorkflowExecutionsRequest = { namespace: string };
-export type GetWorkflowExecutionRequest = GetAllWorkflowExecutionsRequest & {
+export type NamespaceScopedRequest = { namespace: string };
+export type GetWorkflowExecutionRequest = NamespaceScopedRequest & {
   executionId: string;
   runId: string;
 };
 
-export type GetAllPollersRequest = { namespace: string; queue: string };
+export type GetAllPollersRequest = NamespaceScopedRequest & { queue: string };
 export type GetPollersResponse = {
   pollers: PollerInfo[];
   taskQueueStatus: TaskQueueStatus;
 };
 
+type FetchWorkflows<T> = {
+  onUpdate?: (results: Omit<T, 'nextPageToken'>) => void;
+  request?: typeof fetch;
+};
+
+const fetchWorkflows =
+  (type: 'open' | 'closed') =>
+  async ({
+    namespace,
+    onUpdate = (x) => x,
+    request = fetch,
+  }: NamespaceScopedRequest &
+    FetchWorkflows<ListWorkflowExecutionsResponse>) => {
+    const { executions } = await paginated(async (token: string) => {
+      const url = toURL(`${base}/namespaces/${namespace}/workflows/${type}`, {
+        next_page_token: token,
+      });
+
+      const response = await request(url);
+      return await response.json();
+    }, onUpdate);
+
+    return executions;
+  };
+
+export const fetchOpenWorkflows = fetchWorkflows('open');
+export const fetchClosedWorkflows = fetchWorkflows('closed');
+export const fetchAllWorkflows = (
+  options:
+    | Parameters<typeof fetchOpenWorkflows>[0]
+    | Parameters<typeof fetchClosedWorkflows>[0],
+) => {
+  fetchOpenWorkflows(options);
+  fetchClosedWorkflows(options);
+};
+
 export const WorkflowExecutionAPI = {
   async getAll(
-    { namespace }: GetAllWorkflowExecutionsRequest,
+    { namespace }: NamespaceScopedRequest,
     request = fetch,
   ): Promise<WorkflowExecutionInfo[]> {
-    const { executions: open }: ListWorkflowExecutionsResponse = await request(
-      `${base}/namespaces/${namespace}/workflows/open`,
-    ).then((response) => response.json());
-
-    const { executions: closed }: ListWorkflowExecutionsResponse =
-      await request(`${base}/namespaces/${namespace}/workflows/closed`).then(
-        (response) => response.json(),
-      );
+    const open = await fetchOpenWorkflows({ namespace, request });
+    const closed = await fetchClosedWorkflows({ namespace, request });
 
-    return [...open, ...closed];
+    return [].concat(open).concat(closed);
   },
 
   async get(
diff --git a/src/lib/stores/data.ts b/src/lib/stores/data.ts
index 9f491fac..2f626c4e 100644
--- a/src/lib/stores/data.ts
+++ b/src/lib/stores/data.ts
@@ -15,8 +15,10 @@ export type SliceRequestConfiguration<T, U> = {
 
 const store = writable({});
 
-const update = (key: string) => (data: Slice<any>) =>
-  store.update((s) => ({ ...s, [key]: { ...data } }));
+const update =
+  <T>(key: string) =>
+  (data: Slice<T>) =>
+    store.update((s) => ({ ...s, [key]: { ...data } }));
 
 const newSlice = <T>(initialState?: T): Slice<T> => ({
   isLoading: true,
diff --git a/src/lib/stores/workflows.ts b/src/lib/stores/workflows.ts
new file mode 100644
index 00000000..226efba1
--- /dev/null
+++ b/src/lib/stores/workflows.ts
@@ -0,0 +1,113 @@
+import { derived, writable, Writable } from 'svelte/store';
+import type { ListWorkflowExecutionsResponse } from '$types';
+import { unique } from '$lib/utilities/unique';
+
+import {
+  toWorkflowExecutions,
+  WorkflowExecution,
+} from '$lib/models/workflow-execution';
+
+import { fetchAllWorkflows } from '$lib/services/workflow-execution-service';
+
+type WorkflowStore = {
+  loading: boolean;
+  updating: boolean;
+  ids: { [key: string]: boolean };
+  workflows: { [key: string]: WorkflowExecution };
+};
+
+const stores: { [key: string]: ReturnType<typeof createStore> } = {};
+
+const updateWorkflows =
+  (store: Writable<WorkflowStore>) =>
+  (payload: ListWorkflowExecutionsResponse) => {
+    const workflowExecutions = toWorkflowExecutions(payload.executions);
+    const ids = {};
+    const workflows = {};
+
+    for (const execution of workflowExecutions) {
+      const id = execution.id;
+      ids[id] = true;
+      workflows[id] = execution;
+    }
+
+    store.update(($store) => ({
+      ...$store,
+      ids: { ...$store.ids, ...ids },
+      workflows: { ...$store.workflows, ...workflows },
+    }));
+  };
+
+export const createStore = (namespace: string) => {
+  const store = writable<WorkflowStore>(
+    {
+      loading: true,
+      updating: false,
+      ids: {},
+      workflows: {},
+    },
+    () => {
+      fetchAllWorkflows({ namespace, onUpdate: updateWorkflows(store) });
+
+      const interval = setInterval(() => {
+        requestIdleCallback(
+          () => {
+            fetchAllWorkflows({ namespace, onUpdate: updateWorkflows(store) });
+          },
+          { timeout: 30000 },
+        );
+      }, 30000);
+
+      return () => clearInterval(interval);
+    },
+  );
+
+  const get = (id: string) => derived(store, ($store) => $store.workflows[id]);
+  const all = derived(store, ($store) => Object.values($store.workflows));
+  const ids = derived(store, ($store) => Object.keys($store.ids));
+
+  const status = writable<WorkflowStatus>(null);
+  const workflowType = writable<WorkflowType>(null);
+  const executionId = writable<string>(null);
+  const runId = writable<string>(null);
+  const workflowTypes = derived(all, ($all) =>
+    $all.map((execution) => execution.name).filter(unique),
+  );
+
+  const filtered = derived(
+    [all, status, workflowType, executionId, runId],
+    ([$all, $status, $workflowType, $executionId, $runId]) => {
+      return $all.filter((execution) => {
+        // Right now, the type generated does not match the actual API response.
+        // This is a temporary fix.
+        const executionStatus = execution.status as unknown as WorkflowStatus;
+
+        if ($status && executionStatus !== $status) return false;
+        if ($workflowType && execution.name !== $workflowType) return false;
+        if ($executionId && !execution.id.startsWith($executionId))
+          return false;
+        if ($runId && !execution.runId.startsWith($runId)) return false;
+        return true;
+      });
+    },
+  );
+
+  return {
+    ids,
+    get,
+    all,
+    filtered,
+    workflowTypes,
+    filters: {
+      status,
+      workflowType,
+      executionId,
+      runId,
+    },
+  };
+};
+
+export const createWorkflowStore = (namespace: string) => {
+  if (!stores[namespace]) stores[namespace] = createStore(namespace);
+  return stores[namespace];
+};
diff --git a/src/lib/utilities/convert-to-json.ts b/src/lib/utilities/convert-to-json.ts
index 7c81e96f..db126028 100644
--- a/src/lib/utilities/convert-to-json.ts
+++ b/src/lib/utilities/convert-to-json.ts
@@ -1,4 +1,6 @@
-export const convertToJSON = (events: any) => {
+export const convertToJSON = (
+  events: Parameters<typeof JSON.stringify>[0],
+): string => {
   const data = JSON.stringify(events);
   const dataUri =
     'data:application/json;charset=utf-8,' + encodeURIComponent(data);
diff --git a/src/lib/utilities/decode-payload.ts b/src/lib/utilities/decode-payload.ts
index de53ba92..fcb6d95b 100644
--- a/src/lib/utilities/decode-payload.ts
+++ b/src/lib/utilities/decode-payload.ts
@@ -1,7 +1,7 @@
 import type {
   WorkflowExecutionStartedEventAttributes,
   WorkflowExecutionCompletedEventAttributes,
-} from '$types/definitions';
+} from '$types';
 import base64 from 'base-64';
 
 type DecodeEvent = BaseEvent & {
@@ -9,7 +9,7 @@ type DecodeEvent = BaseEvent & {
   workflowExecutionCompletedEventAttributes?: WorkflowExecutionCompletedEventAttributes;
 };
 
-export const convertPayloadToJson = (event: DecodeEvent) => {
+export const convertPayloadToJson = (event: DecodeEvent): string => {
   if (!event) return;
 
   if (event.workflowExecutionStartedEventAttributes) {
diff --git a/src/lib/utilities/format-date.ts b/src/lib/utilities/format-date.ts
index e5532e13..1cb51156 100644
--- a/src/lib/utilities/format-date.ts
+++ b/src/lib/utilities/format-date.ts
@@ -6,13 +6,13 @@ export function formatDate(date: Date | Timestamp | string | null): string {
   if (!date) return '';
 
   if (isTimestamp(date)) {
-    date = tsToDate(date);
+    date = timestampToDate(date);
   }
 
   return format(parseJSON(date), 'MMMM dd, yyyy — h:mm a');
 }
 
-function tsToDate(ts: Timestamp): Date {
+function timestampToDate(ts: Timestamp): Date {
   if (!isTimestamp(ts)) {
     throw new TypeError('provided value is not a timestamp');
   }
@@ -23,6 +23,9 @@ function tsToDate(ts: Timestamp): Date {
   return d;
 }
 
-function isTimestamp(arg: any): arg is Timestamp {
-  return arg.seconds !== undefined && arg.nanos !== undefined;
+function isTimestamp(arg: unknown): arg is Timestamp {
+  if (typeof arg === 'object') {
+    return arg['seconds'] !== undefined && arg['nanos'] !== undefined;
+  }
+  return false;
 }
diff --git a/src/lib/utilities/get-started-and-completed-events.ts b/src/lib/utilities/get-started-and-completed-events.ts
index 1cc1e984..ec3f92e5 100644
--- a/src/lib/utilities/get-started-and-completed-events.ts
+++ b/src/lib/utilities/get-started-and-completed-events.ts
@@ -1,9 +1,14 @@
 import type { GetWorkflowExecutionHistoryResponse } from '$types';
 import { convertPayloadToJson } from './decode-payload';
 
+type WorkflowEvents = {
+  input: string;
+  result: string;
+};
+
 export const getWorkflowStartedAndCompletedEvents = (
   events: GetWorkflowExecutionHistoryResponse,
-) => {
+): WorkflowEvents => {
   const workflowStartedEvent: WorkflowExecutionStartedEvent =
     events.history.events.find((event) => {
       return !!event.workflowExecutionStartedEventAttributes;
diff --git a/src/lib/utilities/get-task-queue-url.ts b/src/lib/utilities/get-task-queue-url.ts
index ee2e9975..de9f09d5 100644
--- a/src/lib/utilities/get-task-queue-url.ts
+++ b/src/lib/utilities/get-task-queue-url.ts
@@ -1,4 +1,7 @@
-export const getTaskQueueUrl = (namespace: string, taskQueue: string) => {
+export const getTaskQueueUrl = (
+  namespace: string,
+  taskQueue: string,
+): string => {
   const url = `/namespaces/${namespace}/queues/${taskQueue}`;
 
   return url;
diff --git a/src/lib/utilities/get-workflow-execution-url.ts b/src/lib/utilities/get-workflow-execution-url.ts
index 272f496e..24951dc0 100644
--- a/src/lib/utilities/get-workflow-execution-url.ts
+++ b/src/lib/utilities/get-workflow-execution-url.ts
@@ -11,7 +11,7 @@ export const getWorkflowExecutionUrl = (
   workflow: WorkflowExecution,
   query?: URLSearchParamLike,
   queryOverrides?: URLSearchParamLike,
-) => {
+): string => {
   const url = `/namespaces/${namespace}/workflows/${workflow.id}/${workflow.runId}`;
 
   const search = queryOverrides
diff --git a/src/lib/utilities/merge.ts b/src/lib/utilities/merge.ts
new file mode 100644
index 00000000..a2f0ea7d
--- /dev/null
+++ b/src/lib/utilities/merge.ts
@@ -0,0 +1,22 @@
+type KeyValue = { [key: string]: string | number | boolean };
+
+export const merge = <T = KeyValue>(
+  first: T = {} as T,
+  second: T = {} as T,
+): T => {
+  const result = { ...first };
+
+  for (const key of Object.keys(second)) {
+    const value = result[key];
+
+    if (Array.isArray(value)) {
+      result[key] = result[key].concat(second[key]);
+    } else if (typeof value === 'object' && !Array.isArray(value)) {
+      result[key] = merge(result[key], second[key]);
+    } else {
+      result[key] = second[key];
+    }
+  }
+
+  return result;
+};
diff --git a/src/lib/utilities/paginated.ts b/src/lib/utilities/paginated.ts
new file mode 100644
index 00000000..9865c593
--- /dev/null
+++ b/src/lib/utilities/paginated.ts
@@ -0,0 +1,22 @@
+import { merge } from './merge';
+
+type NextPageToken = Uint8Array | string;
+type WithNextPageToken = { nextPageToken?: NextPageToken };
+
+export const paginated = async <T extends WithNextPageToken>(
+  fn: (token?: NextPageToken) => Promise<T>,
+  onUpdate?: (
+    current: Omit<T, keyof WithNextPageToken>,
+    full: Omit<T, keyof WithNextPageToken>,
+  ) => void,
+  token?: NextPageToken,
+  previousProps?: Omit<T, keyof WithNextPageToken>,
+): Promise<Omit<T, keyof WithNextPageToken>> => {
+  const { nextPageToken, ...props } = await fn(token);
+  const mergedProps = merge(previousProps, props);
+
+  if (!nextPageToken) return mergedProps;
+  if (onUpdate) onUpdate(props, mergedProps);
+
+  return paginated(fn, onUpdate, nextPageToken, mergedProps);
+};
diff --git a/src/lib/utilities/to-url.ts b/src/lib/utilities/to-url.ts
new file mode 100644
index 00000000..e35eb51f
--- /dev/null
+++ b/src/lib/utilities/to-url.ts
@@ -0,0 +1,10 @@
+import { toSearchParams, URLSearchParamLike } from './url-search-params';
+
+export const toURL = (url: string, params?: URLSearchParamLike): string => {
+  let result = url;
+  const paramsAsString = toSearchParams(params).toString();
+
+  if (paramsAsString) result += '?' + paramsAsString;
+
+  return result;
+};
diff --git a/src/lib/utilities/url-search-params.ts b/src/lib/utilities/url-search-params.ts
index 39a0a12f..2bc7c7c2 100644
--- a/src/lib/utilities/url-search-params.ts
+++ b/src/lib/utilities/url-search-params.ts
@@ -1,13 +1,23 @@
-export type URLSearchParamLike =
-  | { [key: string]: string | number | boolean }
-  | URLSearchParams;
+type KeyValue = { [key: string]: string | number | boolean };
+
+export type URLSearchParamLike = KeyValue | URLSearchParams;
+
+const removeUndefinedValues = (obj: KeyValue): KeyValue => {
+  const result = { ...obj };
+  Object.keys(result).forEach(
+    (key) => result[key] === undefined && delete result[key],
+  );
+  return result;
+};
 
 export const toSearchParams = (query: URLSearchParamLike): URLSearchParams => {
   if (query instanceof URLSearchParams) return query;
-  return new URLSearchParams(query as Record<string, string>);
+  return new URLSearchParams(
+    removeUndefinedValues(query) as Record<string, string>,
+  );
 };
 
-export const urlSearchParamsToObject = (search: URLSearchParams) => {
+export const urlSearchParamsToObject = (search: URLSearchParams): KeyValue => {
   return search
     .toString()
     .split('&')
diff --git a/src/routes/__layout.svelte b/src/routes/__layout.svelte
index 43ad93ff..5eea8f46 100644
--- a/src/routes/__layout.svelte
+++ b/src/routes/__layout.svelte
@@ -5,7 +5,7 @@
     ListNamespacesResponse,
   } from '$types';
 
-  export async function load({ fetch, page }: LoadInput) {
+  export async function load({ fetch }: LoadInput) {
     const { namespaces }: ListNamespacesResponse = await fetch(
       'http://localhost:8080/api/v1/namespaces',
     ).then((response) => response.json());
diff --git a/src/routes/namespaces/[namespace]/queues/[queue]/__layout.svelte b/src/routes/namespaces/[namespace]/queues/[queue]/__layout.svelte
index a3de8237..3bb8838e 100644
--- a/src/routes/namespaces/[namespace]/queues/[queue]/__layout.svelte
+++ b/src/routes/namespaces/[namespace]/queues/[queue]/__layout.svelte
@@ -1,6 +1,9 @@
 <script context="module" lang="ts">
   import type { LoadInput } from '@sveltejs/kit';
-  import { WorkflowExecutionAPI } from '$lib/services/workflow-execution-service';
+  import {
+    GetPollersResponse,
+    WorkflowExecutionAPI,
+  } from '$lib/services/workflow-execution-service';
 
   export async function load({ fetch, page }: LoadInput) {
     const { namespace, queue } = page.params;
@@ -17,7 +20,7 @@
   import QueuePollersTable from './_queue-pollers-table.svelte';
   import QueuePollersRow from './_queue-pollers-row.svelte';
 
-  export let pollers;
+  export let pollers: GetPollersResponse;
 </script>
 
 <section class="flex flex-col items-start">
diff --git a/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/_header.svelte b/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/_header.svelte
index 5b8b6625..daa494ff 100644
--- a/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/_header.svelte
+++ b/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/_header.svelte
@@ -16,25 +16,21 @@
 </script>
 
 <header class="flex flex-col justify-between">
-  <main class="px-6">
+  <div class="py-4 px-2 bg-gray-50 border-b-2 border-gray-100 flex justify-end">
     {#if $isFullScreen}
       <a href={workflowUrl}>
-        <Icon
-          src={ArrowRight}
-          class="absolute right-10 top-2 w-8 h-8 text-gray-400"
-        />
+        <Icon src={ArrowRight} class="w-8 h-8 text-gray-500" />
       </a>
     {:else}
       <a href={workflowUrl + '/summary'}>
-        <Icon
-          src={ArrowLeft}
-          class="absolute right-10 top-2 w-8 h-8 text-gray-400"
-        />
+        <Icon src={ArrowLeft} class="w-8 h-8 text-gray-500" />
       </a>
     {/if}
     <a href={`/namespaces/${$namespace}/workflows`}>
-      <Icon src={X} class="absolute right-2 top-2 w-8 h-8 text-gray-400" />
+      <Icon src={X} class="w-8 h-8 text-gray-500" />
     </a>
+  </div>
+  <main class="px-6">
     <div class="flex m-0 mt-6 justify-between items-center">
       <h1 class="text-lg">
         {workflow.name}
@@ -43,12 +39,12 @@
         <WorkflowStatus status={workflow.status} />
       </span>
     </div>
-    <p class="text-gray-500 text-sm">
-      <span class=" uppercase text-gray-400">Workflow ID</span>
+    <p class="text-gray-500 text-xs">
+      <span class="uppercase text-gray-400 mr-2">Workflow ID</span>
       {workflow.id}
     </p>
     <p class="text-gray-500 text-xs">
-      <span class=" uppercase text-gray-400">Run ID</span>
+      <span class="uppercase text-gray-400 mr-2">Run ID</span>
       {workflow.runId}
     </p>
   </main>
diff --git a/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/events/_event.svelte b/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/events/_event.svelte
index de4ae701..00d9eee7 100644
--- a/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/events/_event.svelte
+++ b/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/events/_event.svelte
@@ -10,20 +10,55 @@
 
   let even = !(index % 2);
   $: isJSONView = false;
+  $: isIdJSONView = false;
+  $: isEventTypeJSONView = false;
+  $: isTimeJSONView = false;
+  $: isDetailsJSONView = false;
 </script>
 
 <tr class:even>
-  <td>{event.eventId}</td>
-  <td>{event.eventType}</td>
-  <td>{formatDate(event.eventTime)}</td>
-  <td class="w-1/2">
-    <div class="flex">
-      <ExecutionInformation title="Version" value={event.version} />
-      <ExecutionInformation title="Task ID" value={event.taskId} />
-    </div>
-
-    <button on:click={() => (isJSONView = !isJSONView)}
-      >{!isJSONView ? 'GRID' : 'JSON'} VIEW</button
+  <td on:click={() => (isIdJSONView = !isIdJSONView)}>
+    {#if isIdJSONView}
+      <CodeBlock heading={``} content={JSON.stringify(event.eventId)} />
+    {:else}
+      {event.eventId}
+    {/if}
+  </td>
+  <td on:click={() => (isEventTypeJSONView = !isEventTypeJSONView)}>
+    {#if isEventTypeJSONView}
+      <CodeBlock heading={``} content={JSON.stringify(event.eventType)} />
+    {:else}
+      {event.eventType}
+    {/if}
+  </td>
+  <td on:click={() => (isTimeJSONView = !isTimeJSONView)}>
+    {#if isTimeJSONView}
+      <CodeBlock heading={``} content={JSON.stringify(event.eventTime)} />
+    {:else}
+      {formatDate(event.eventTime)}
+    {/if}</td
+  >
+  <td on:click={() => (isDetailsJSONView = !isDetailsJSONView)} class="w-1/2">
+    {#if isDetailsJSONView}
+      <CodeBlock
+        heading={``}
+        content={JSON.stringify({
+          verison: event.version,
+          TaskId: event.taskId,
+        })}
+      />
+    {:else}
+      <div class="flex">
+        <ExecutionInformation title="Version" value={event.version} />
+        <ExecutionInformation title="Task ID" value={event.taskId} />
+      </div>
+    {/if}
+
+    <button
+      on:click={(e) => {
+        e.stopPropagation();
+        isJSONView = !isJSONView;
+      }}>{!isJSONView ? 'GRID' : 'JSON'} VIEW</button
     >
 
     {#if isJSONView}
@@ -37,6 +72,7 @@
 <style lang="postcss">
   td {
     vertical-align: top;
+    cursor: pointer;
     @apply p-4 overflow-x-scroll;
   }
 
diff --git a/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/events/_events-filters.svelte b/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/events/_events-filters.svelte
index c3441b7d..eb641ea6 100644
--- a/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/events/_events-filters.svelte
+++ b/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/events/_events-filters.svelte
@@ -2,15 +2,21 @@
   import type { History, DescribeWorkflowExecutionResponse } from '$types';
   import { convertToJSON } from '$lib/utilities/convert-to-json';
   import Icon, { Download } from 'svelte-hero-icons';
+  import Input from '$lib/components/filter-input.svelte';
 
   export let eventFormat: string = 'grid';
   export let history: History;
   export let execution: DescribeWorkflowExecutionResponse;
+  export let eventType: string = null;
 
   function setFormat(format: EventFormat) {
     eventFormat = format;
   }
 
+  function clear() {
+    eventType = null;
+  }
+
   $: dataUri = convertToJSON(history.events);
 </script>
 
@@ -26,6 +32,14 @@
       on:click={() => setFormat('json')}>JSON</button
     >
   </div>
+  <div class="flex justify-center items-end">
+    <Input
+      name="Event History"
+      id="filter-by-event-history"
+      bind:value={eventType}
+    />
+    <button class="ml-2" on:click={clear}>Clear</button>
+  </div>
   <div>
     <a
       class="text-black-500 font-bold uppercase px-3 py-1 text-xs flex"
diff --git a/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/events/_events.svelte b/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/events/_events.svelte
index 5796a023..2ec8220f 100644
--- a/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/events/_events.svelte
+++ b/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/events/_events.svelte
@@ -5,6 +5,13 @@
 
   export let history: History;
   export let eventFormat: EventFormat;
+  export let eventType;
+
+  $: events = history.events.filter((event) => {
+    if (eventType && !String(event.eventType).startsWith(eventType))
+      return false;
+    return true;
+  });
 </script>
 
 <section>
@@ -19,7 +26,7 @@
         </tr>
       </thead>
       <tbody>
-        {#each history.events as event, index}
+        {#each events as event, index}
           <Event {event} {index} />
         {/each}
       </tbody>
@@ -27,7 +34,7 @@
   {/if}
 
   {#if eventFormat === 'json'}
-    {#each history.events as event}
+    {#each events as event}
       <CodeBlock
         heading={`Event ID: ${event.eventId}`}
         content={JSON.stringify(event)}
diff --git a/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/events/index.svelte b/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/events/index.svelte
index 942cdf77..c01736c1 100644
--- a/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/events/index.svelte
+++ b/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/events/index.svelte
@@ -21,12 +21,13 @@
   import EventsFilters from './_events-filters.svelte';
   export let events: GetWorkflowExecutionHistoryResponse;
   export let execution: DescribeWorkflowExecutionResponse;
+  export let eventType;
 
   let { history } = events;
   let eventFormat: EventFormat = 'grid';
 </script>
 
 <div class="px-6 py-6">
-  <EventsFilters bind:eventFormat {history} {execution} />
-  <Events {history} {eventFormat} />
+  <EventsFilters bind:eventFormat bind:eventType {history} {execution} />
+  <Events {history} {eventFormat} {eventType} />
 </div>
diff --git a/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/index.svelte b/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/index.svelte
index 7dace41f..9d0ad67f 100644
--- a/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/index.svelte
+++ b/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/index.svelte
@@ -21,7 +21,7 @@
     DescribeWorkflowExecutionResponse,
     GetWorkflowExecutionHistoryResponse,
   } from '$types';
-  import { getContext } from 'svelte';
+
   import { toWorkflowExecution } from '$lib/models/workflow-execution';
   import CodeBlock from '$lib/components/code-block.svelte';
   import { getTaskQueueUrl } from '$lib/utilities/get-task-queue-url';
diff --git a/src/routes/namespaces/[namespace]/workflows/__layout.svelte b/src/routes/namespaces/[namespace]/workflows/__layout.svelte
index 79b7d0e1..8057a0b9 100644
--- a/src/routes/namespaces/[namespace]/workflows/__layout.svelte
+++ b/src/routes/namespaces/[namespace]/workflows/__layout.svelte
@@ -1,76 +1,59 @@
-<script context="module" lang="ts">
-  import type { LoadInput } from '@sveltejs/kit';
-  import { WorkflowExecutionAPI } from '$lib/services/workflow-execution-service';
-  import {
-    toWorkflowExecutions,
-    WorkflowExecution,
-  } from '$lib/models/workflow-execution';
-
-  export async function load({ fetch, page }: LoadInput) {
-    const { namespace } = page.params;
-    return await WorkflowExecutionAPI.getAll({ namespace }, fetch)
-      .then(toWorkflowExecutions)
-      .then((executions) => ({
-        props: { executions },
-      }));
-  }
-</script>
-
 <script lang="ts">
   import { isFullScreen } from '$lib/stores/full-screen';
+  import { namespace } from '$lib/stores/namespace';
+  import { createWorkflowStore } from '$lib/stores/workflows';
 
   import WorkflowsSummaryTable from './_workflows-summary-table.svelte';
   import WorkflowsSummaryRow from './_workflows-summary-row.svelte';
   import WorkflowFilters from './_workflow-filters.svelte';
-  import { unique } from '$lib/utilities/unique';
 
-  export let executions: WorkflowExecution[];
+  $: store = createWorkflowStore($namespace);
+  $: workflows = store.filtered;
 
-  let status = null;
-  let workflowType = null;
-  let executionId = null;
-  let runId = null;
   let timeFormat = 'relative';
 
-  let workflowTypes = executions
-    .map((execution) => execution.name)
-    .filter(unique);
-
-  $: workflows = executions.filter((execution) => {
-    // Right now, the type generated does not match the actual API response.
-    // This is a temporary fix.
-    const executionStatus = execution.status as unknown as WorkflowStatus;
+  let currentPage = 0;
+  let executionsPerPage = 50;
+  $: maximumPage = Math.ceil($workflows.length / executionsPerPage);
 
-    if (status && executionStatus !== status) return false;
-    if (workflowType && execution.name !== workflowType) return false;
-    if (executionId && !execution.id.startsWith(executionId)) return false;
-    if (runId && !execution.runId.startsWith(runId)) return false;
-    return true;
-  });
+  $: visibleWorkflows = $workflows.slice(
+    currentPage * executionsPerPage,
+    currentPage * executionsPerPage + executionsPerPage,
+  );
 </script>
 
 <section class="flex items-start">
   {#if !$isFullScreen}
-    <div class="w-full  h-screen overflow-scroll">
-      <WorkflowFilters
-        bind:status
-        bind:workflowType
-        bind:runId
-        bind:executionId
-        bind:timeFormat
-        {workflowTypes}
-      />
+    <div class="w-full h-screen overflow-scroll">
+      <header>
+        <WorkflowFilters {timeFormat} />
+        <section class="bg-gray-100 p-4 flex gap-4">
+          <button on:click={() => currentPage--} disabled={currentPage <= 0}>
+            Previous
+          </button>
+          <p>
+            Page {currentPage + 1} of {maximumPage}
+          </p>
+          <button
+            on:click={() => currentPage++}
+            disabled={currentPage >= maximumPage - 1}
+          >
+            Next
+          </button>
+        </section>
+      </header>
       <WorkflowsSummaryTable>
         <tbody slot="rows">
-          {#each workflows as workflow}
+          {#each visibleWorkflows as workflow}
             <WorkflowsSummaryRow {workflow} {timeFormat} />
           {:else}
             <tr>
               <td
-                colspan="4"
+                colspan="5"
                 class="m-auto p-12 text-center font-extralight text-2xl"
-                >No Results</td
               >
+                No Results
+              </td>
             </tr>
           {/each}
         </tbody>
@@ -80,3 +63,13 @@
 
   <slot />
 </section>
+
+<style lang="postcss">
+  button {
+    @apply rounded-lg border-purple-600 border-2 bg-white text-purple-600 px-2 text-sm;
+  }
+
+  button:disabled {
+    @apply text-purple-400 border-purple-400;
+  }
+</style>
diff --git a/src/routes/namespaces/[namespace]/workflows/_workflow-filters.svelte b/src/routes/namespaces/[namespace]/workflows/_workflow-filters.svelte
index ea4c9e7c..ffbe2494 100644
--- a/src/routes/namespaces/[namespace]/workflows/_workflow-filters.svelte
+++ b/src/routes/namespaces/[namespace]/workflows/_workflow-filters.svelte
@@ -1,19 +1,26 @@
 <script lang="ts">
+  import { namespace } from '$lib/stores/namespace';
+  import { createWorkflowStore } from '$lib/stores/workflows';
+
   import Select from '$lib/components/filter-select.svelte';
   import Input from '$lib/components/filter-input.svelte';
 
-  export let status: WorkflowStatus = null;
-  export let workflowType: WorkflowType = null;
-  export let executionId: string = null;
-  export let runId: string = null;
   export let timeFormat: string = 'relative';
-  export let workflowTypes: string[];
+
+  $: store = createWorkflowStore($namespace);
+  $: workflowTypes = store.workflowTypes;
+  $: filters = store.filters;
+
+  $: status = filters.status;
+  $: workflowType = filters.workflowType;
+  $: executionId = filters.executionId;
+  $: runId = filters.runId;
 
   function clear() {
-    status = null;
-    workflowType = null;
-    executionId = null;
-    runId = null;
+    $status = null;
+    $workflowType = null;
+    $executionId = null;
+    $runId = null;
     timeFormat = 'relative';
   }
 </script>
@@ -22,7 +29,7 @@
   <Select
     id="filter-by-workflow-status"
     name="Workflow Status"
-    bind:value={status}
+    bind:value={$status}
   >
     <option value={null} />
     <option value="Running">Running</option>
@@ -36,10 +43,10 @@
   <Select
     id="filter-by-workflow-type"
     name="Workflow Type"
-    bind:value={workflowType}
+    bind:value={$workflowType}
   >
     <option value={null} />
-    {#each workflowTypes as name}
+    {#each $workflowTypes as name}
       <option>{name}</option>
     {/each}
   </Select>
@@ -55,9 +62,9 @@
   <Input
     name="Execution ID"
     id="filter-by-execution-id"
-    bind:value={executionId}
+    bind:value={$executionId}
   />
-  <Input name="Run ID" id="filter-by-run-id" bind:value={runId} />
+  <Input name="Run ID" id="filter-by-run-id" bind:value={$runId} />
   <button on:click={clear}>Clear</button>
 </section>
 
diff --git a/src/routes/namespaces/[namespace]/workflows/_workflows-summary-row.svelte b/src/routes/namespaces/[namespace]/workflows/_workflows-summary-row.svelte
index 41a2f9f8..3c419c42 100644
--- a/src/routes/namespaces/[namespace]/workflows/_workflows-summary-row.svelte
+++ b/src/routes/namespaces/[namespace]/workflows/_workflows-summary-row.svelte
@@ -1,12 +1,12 @@
 <script lang="ts">
+  import type { WorkflowExecution } from '$lib/models/workflow-execution';
+
   import { namespace } from '$lib/stores/namespace';
   import { page } from '$app/stores';
-  import type { WorkflowExecution } from '$lib/models/workflow-execution';
   import { getWorkflowExecutionUrl } from '$lib/utilities/get-workflow-execution-url';
   import { pathMatches } from '$lib/utilities/path-matches';
   import Time from '$lib/components/workflow-time.svelte';
   import WorkflowStatus from '$lib/components/workflow-status.svelte';
-  import { getContext } from 'svelte';
 
   export let workflow: WorkflowExecution;
   export let timeFormat: string;


Test patch:
diff --git a/src/lib/utilities/merge.test.ts b/src/lib/utilities/merge.test.ts
new file mode 100644
index 00000000..0824b9fb
--- /dev/null
+++ b/src/lib/utilities/merge.test.ts
@@ -0,0 +1,33 @@
+import { merge } from './merge';
+
+describe(merge, () => {
+  it('should overwrite keys if they are primitive values', () => {
+    const first = { a: 1 };
+    const second = { a: 2 };
+    expect(merge(first, second)).toEqual({ a: 2 });
+  });
+
+  it('should concat values if they are arrays', () => {
+    const first = { a: [1, 2] };
+    const second = { a: [3, 4] };
+    expect(merge(first, second)).toEqual({ a: [1, 2, 3, 4] });
+  });
+
+  it('should recursively traverse down objects', () => {
+    const first = { a: { b: [1, 2] } };
+    const second = { a: { b: [3, 4] } };
+    expect(merge(first, second)).toEqual({ a: { b: [1, 2, 3, 4] } });
+  });
+
+  it('should retain keys from the first object that are not found on the second', () => {
+    const first = { a: 1, b: 3 };
+    const second = { a: 2 };
+    expect(merge(first, second)).toEqual({ a: 2, b: 3 });
+  });
+
+  it('should apply keys from the second that are not found on the first', () => {
+    const first = { a: 1 };
+    const second = { a: 2, b: 3 };
+    expect(merge(first, second)).toEqual({ a: 2, b: 3 });
+  });
+});
diff --git a/src/lib/utilities/paginated.test.ts b/src/lib/utilities/paginated.test.ts
new file mode 100644
index 00000000..ec515ad9
--- /dev/null
+++ b/src/lib/utilities/paginated.test.ts
@@ -0,0 +1,48 @@
+import { paginated } from './paginated';
+
+const fetchMock = async (token: string | Uint8Array = null) => {
+  if (!token)
+    return {
+      items: [1, 2, 3],
+      nextPageToken: '1',
+    };
+
+  if (token === '1')
+    return {
+      items: [4, 5, 6],
+      nextPageToken: '2',
+    };
+
+  if (token === '2')
+    return {
+      items: [7, 8, 9],
+      nextPageToken: '3',
+    };
+
+  if (token === '3')
+    return {
+      items: [10, 11, 12],
+      nextPageToken: null,
+    };
+};
+
+describe(paginated, () => {
+  it('should be a function', () => {
+    expect(typeof paginated).toBe('function');
+  });
+
+  it('should return a promise with the first page of data', () => {
+    return paginated((token) => fetchMock(token)).then((result) =>
+      expect(result).toEqual({
+        items: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
+      }),
+    );
+  });
+
+  it('should collect incremental updates with an onUpdate callback', () => {
+    const mockCallback = jest.fn((x) => x);
+    return paginated((token) => fetchMock(token), mockCallback).then(() => {
+      expect(mockCallback.mock.calls.length).toBe(3);
+    });
+  });
+});
diff --git a/src/lib/utilities/to-url.test.ts b/src/lib/utilities/to-url.test.ts
new file mode 100644
index 00000000..32bab178
--- /dev/null
+++ b/src/lib/utilities/to-url.test.ts
@@ -0,0 +1,15 @@
+import { toURL } from './to-url';
+
+describe(toURL, () => {
+  it('should take a string for the URL and return it', () => {
+    expect(toURL('/workflows')).toBe('/workflows');
+  });
+
+  it('should turn the query params into a query string', () => {
+    expect(toURL('/workflows', { a: 'hello' })).toBe('/workflows?a=hello');
+  });
+
+  it('should drop undefined query parameters', () => {
+    expect(toURL('/workflows', { a: undefined })).toBe('/workflows');
+  });
+});
diff --git a/src/lib/utilities/url-search-params.test.ts b/src/lib/utilities/url-search-params.test.ts
index a1112778..5e76cae4 100644
--- a/src/lib/utilities/url-search-params.test.ts
+++ b/src/lib/utilities/url-search-params.test.ts
@@ -33,6 +33,17 @@ describe(toSearchParams, () => {
     expect(result.get('a')).toBe(expected.get('a'));
     expect(result.get('b')).toBe(expected.get('b'));
   });
+
+  it('should drop undefined parameters', () => {
+    const expected = new URLSearchParams();
+
+    expected.set('a', '1');
+
+    const result = toSearchParams({ a: 1, b: undefined });
+
+    expect(result.get('a')).toBe(expected.get('a'));
+    expect(result.get('b')).toBe(null);
+  });
 });
 
 describe(mergeSearchParams, () => {