Repository: temporalio/ui
Pull request #364 (2022-03-22 17:35:37)

Add modal with reason input to terminate workflow
## What was changed
Added a modal that allows a user to input a termination reason and confirm the termination.


<img width="1535" alt="Screen Shot 2022-03-22 at 1 51 17 PM" src="https://user-images.githubusercontent.com/7967403/159554482-d71530d0-ef95-451b-9b81-9933d4dbf7ff.png">
<img width="1536" alt="Screen Shot 2022-03-22 at 1 51 28 PM" src="https://user-images.githubusercontent.com/7967403/159554495-9de73b2c-81b5-4cca-bac2-4af3290e714f.png">

## Why?
Terminating a workflow is a permanent change and so a user should need to confirm the termination as well as get the opportunity to add an option reason for termination.
<!-- Tell your future self why have you made these changes -->

## Checklist

1. Closes 325

2. How was this tested:
<!--- Please describe how you tested your changes/how we can test them -->

3. Any docs updates needed?
<!--- update README if applicable
      or point out where to update docs.temporal.io -->



Patch:
diff --git a/.vscode/launch.json b/.vscode/launch.json
index ee86ef11..2f9f8aee 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -11,6 +11,17 @@
       "skipFiles": ["<node_internals>/**"],
       "runtimeExecutable": "npm",
       "runtimeArgs": ["run-script", "start"]
+    },
+    {
+      "type": "node",
+      "name": "vscode-jest-tests",
+      "request": "launch",
+      "console": "integratedTerminal",
+      "internalConsoleOptions": "neverOpen",
+      "disableOptimisticBPs": true,
+      "cwd": "${workspaceFolder}",
+      "runtimeExecutable": "npm",
+      "args": ["test", "--", "--runInBand", "--watchAll=false"]
     }
   ]
 }
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 6d55db32..b53c02fa 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -5,5 +5,8 @@
     "**/build-local": true,
     "**/build-cloud": true
   },
-  "jest.nodeEnv": { "TZ": "UTC" }
+  "jest.nodeEnv": {
+    "TZ": "UTC"
+  },
+  "jest.jestCommandLine": "npm test --"
 }
diff --git a/src/events.d.ts b/src/events.d.ts
index 21af9ff8..922fd49b 100644
--- a/src/events.d.ts
+++ b/src/events.d.ts
@@ -165,3 +165,6 @@ type FetchEventsResponse = {
   events: HistoryEventWithId[];
   eventGroups: CompactEventGroups;
 };
+
+type IterableEvent = HistoryEventWithId | CompactEventGroup;
+type IterableEvents = IterableEvent[];
diff --git a/src/lib/components/button.svelte b/src/lib/components/button.svelte
index 82ad35dd..e3f59e85 100644
--- a/src/lib/components/button.svelte
+++ b/src/lib/components/button.svelte
@@ -8,6 +8,7 @@
   export let destroy: boolean = false;
   export let loading: boolean = false;
   export let login: boolean = false;
+  export let thin: boolean = false;
   export let as: 'button' | 'anchor' = 'button';
   export let active: boolean = false;
   export let large: boolean = false;
@@ -25,6 +26,7 @@
     class:secondary
     class:destroy
     class:login
+    class:thin
     {disabled}
   >
     {#if icon}
@@ -45,6 +47,7 @@
     class:destroy
     class:disabled
     class:login
+    class:thin
     {disabled}
   >
     {#if icon}
@@ -74,7 +77,7 @@
   }
 
   .secondary {
-    @apply text-white bg-secondary border-2 rounded-lg px-2 transition-colors;
+    @apply text-gray-800 bg-white border-2 rounded-lg py-2 px-4 transition-colors;
   }
 
   .secondary:disabled {
@@ -86,7 +89,7 @@
   }
 
   .destroy {
-    @apply text-white bg-danger border-2 rounded-lg py-1 px-5 transition-colors;
+    @apply text-white bg-danger border-2 rounded-lg px-5 transition-colors;
   }
 
   .destroy:disabled {
@@ -104,4 +107,8 @@
   .login {
     @apply bg-gray-900 mx-auto py-4;
   }
+
+  .thin {
+    @apply py-1;
+  }
 </style>
diff --git a/src/lib/components/modal.svelte b/src/lib/components/modal.svelte
new file mode 100644
index 00000000..5a85cbb8
--- /dev/null
+++ b/src/lib/components/modal.svelte
@@ -0,0 +1,65 @@
+<script lang="ts">
+  import Icon from 'svelte-fa';
+  import { faTimes } from '@fortawesome/free-solid-svg-icons';
+  import { createEventDispatcher } from 'svelte';
+  import Button from './button.svelte';
+
+  export let open = false;
+  export let confirmText = 'Confirm';
+
+  const dispatch = createEventDispatcher();
+</script>
+
+{#if open}
+  <div class="modal">
+    <div class="overlay" />
+    <div class="body">
+      <div
+        class="float-right cursor-pointer p-6"
+        on:click={() => dispatch('cancelModal', {})}
+      >
+        <Icon icon={faTimes} />
+      </div>
+      <div class="title">
+        <slot name="title">
+          <h3>Title</h3>
+        </slot>
+      </div>
+      <div class="content">
+        <slot name="content">
+          <span>Content</span>
+        </slot>
+      </div>
+      <div class="flex justify-end items-center p-6 space-x-2">
+        <Button secondary on:click={() => dispatch('cancelModal', {})}
+          >Cancel</Button
+        >
+        <Button destroy on:click={() => dispatch('confirmModal', {})}
+          >{confirmText}</Button
+        >
+      </div>
+    </div>
+  </div>
+{/if}
+
+<style lang="postcss">
+  .modal {
+    @apply z-50 fixed w-full h-full top-0 left-0 flex items-center justify-center p-8 lg:p-0;
+  }
+
+  .overlay {
+    @apply fixed w-full h-full bg-gray-900 opacity-50;
+  }
+
+  .body {
+    @apply bg-white w-full md:h-max lg:w-1/3  mx-auto rounded-lg shadow-xl z-50 overflow-y-auto;
+  }
+
+  .title {
+    @apply bg-white pt-8 pb-0 px-8 text-2xl;
+  }
+
+  .content {
+    @apply p-8;
+  }
+</style>
diff --git a/src/lib/components/pagination.svelte b/src/lib/components/pagination.svelte
index 5d3bc53f..61a7d20d 100644
--- a/src/lib/components/pagination.svelte
+++ b/src/lib/components/pagination.svelte
@@ -3,39 +3,20 @@
   import { faAngleLeft, faAngleRight } from '@fortawesome/free-solid-svg-icons';
 
   import { page } from '$app/stores';
+  import { pagination, perPageOptions } from '$lib/stores/pagination';
 
   import FilterSelect from './select/filter-select.svelte';
 
-  type T = $$Generic<Array>;
+  type T = $$Generic;
 
-  export let key: string = null;
-  export let options = ['25', '50', '100'];
-  export let items: T;
+  export let key = 'per-page';
+  export let items: T[];
+  export let startingIndex: string | number = null;
 
-  const queryKey = key || 'per-page';
-
-  let pageNumber = 1;
-  let visibleItems: T = items.slice(0, 0);
-
-  $: queryKeyValue = $page.url.searchParams.get(queryKey);
-  $: perPage = queryKeyValue || '25';
-  $: pageRange = !isNaN(Number(perPage)) ? Math.abs(Number(perPage)) : 25;
-
-  $: previousEnabled = pageNumber > 1;
-  $: nextEnabled = pageNumber * pageRange < items.length;
-
-  $: startingIndex = pageRange * (pageNumber - 1);
-  $: endingIndex = Math.min(startingIndex + pageRange, items.length);
-
-  $: {
-    visibleItems = items.slice(startingIndex, endingIndex);
-  }
-
-  $: {
-    if (!options.includes(pageRange.toString())) {
-      options = [pageRange.toString(), ...options];
-    }
-  }
+  $: perPage = $page.url.searchParams.get(key) || '100';
+  $: store = pagination(items, perPage);
+  $: store.adjustPageSize(perPage);
+  $: store.jumpToIndex(startingIndex);
 </script>
 
 <div class="flex flex-col gap-4">
@@ -44,30 +25,32 @@
       <p class="w-fit text-right">Per Page</p>
       <FilterSelect
         label="Per Page"
-        parameter={queryKey}
-        value={String(pageRange)}
-        {options}
+        parameter={key}
+        value={perPage}
+        options={perPageOptions(perPage)}
       />
     </div>
     <div class="flex gap-6 items-center justify-center">
       <button
         class="caret"
-        disabled={!previousEnabled}
-        on:click={() => pageNumber--}
+        disabled={!$store.hasPrevious}
+        on:click={() => store.previous()}
       >
         <Icon icon={faAngleLeft} />
       </button>
-      <p>{startingIndex + 1}–{endingIndex} of {items.length}</p>
+      <p>
+        {$store.startingIndex + 1}–{$store.endingIndex + 1} of {$store.length}
+      </p>
       <button
         class="caret"
-        disabled={!nextEnabled}
-        on:click={() => pageNumber++}
+        disabled={!$store.hasNext}
+        on:click={() => store.next()}
       >
         <Icon icon={faAngleRight} />
       </button>
     </div>
   </nav>
-  <slot {visibleItems} />
+  <slot visibleItems={$store.items} />
 </div>
 
 <style lang="postcss">
diff --git a/src/lib/components/terminate-workflow.svelte b/src/lib/components/terminate-workflow.svelte
index 09d8b4a5..44c2315a 100644
--- a/src/lib/components/terminate-workflow.svelte
+++ b/src/lib/components/terminate-workflow.svelte
@@ -1,31 +1,28 @@
 <script lang="ts">
-  import { goto } from '$app/navigation';
-
-  import { routeForWorkflow } from '$lib/utilities/route-for';
   import { handleError } from '$lib/utilities/handle-error';
   import { terminateWorkflow } from '$lib/services/terminate-service';
   import { notifications } from '$lib/stores/notifications';
 
   import Button from '$lib/components/button.svelte';
+  import Modal from '$lib/components/modal.svelte';
 
   export let workflow: WorkflowExecution;
   export let namespace: string;
 
   let reason = '';
+  let showConfirmation = false;
+
+  const show = () => (showConfirmation = true);
+  const cancel = () => (showConfirmation = false);
 
   const isEligibleForTermination = (workflow: WorkflowExecution) =>
     String(workflow.status) === 'Running';
 
   const handleSuccessfulTermination = () => {
+    showConfirmation = false;
     reason = '';
     notifications.add('success', 'Workflow Terminated');
-    goto(
-      routeForWorkflow({
-        namespace,
-        workflow: workflow.id,
-        run: workflow.runId,
-      }),
-    );
+    window.location.reload();
   };
 
   const terminate = () => {
@@ -40,5 +37,24 @@
 </script>
 
 {#if isEligibleForTermination(workflow)}
-  <Button destroy on:click={terminate}>Terminate</Button>
+  <Button destroy thin on:click={show}>Terminate</Button>
+  <Modal
+    open={showConfirmation}
+    confirmText="Terminate"
+    on:cancelModal={cancel}
+    on:confirmModal={terminate}
+  >
+    <h3 slot="title">Terminate Workflow</h3>
+    <div slot="content">
+      <p>
+        Are you sure you want to terminate this workflow? This action cannot be
+        undone.
+      </p>
+      <input
+        class="block w-full border border-gray-200 rounded-md p-2 mt-4"
+        placeholder="Enter a reason"
+        bind:value={reason}
+      />
+    </div>
+  </Modal>
 {/if}
diff --git a/src/lib/models/event-history/get-event-categorization.ts b/src/lib/models/event-history/get-event-categorization.ts
index b5a32a90..099c17fa 100644
--- a/src/lib/models/event-history/get-event-categorization.ts
+++ b/src/lib/models/event-history/get-event-categorization.ts
@@ -70,12 +70,9 @@ export const isCategoryType = (value: string): value is EventTypeCategory => {
 };
 
 export const getEventsInCategory = (
-  events: HistoryEventWithId[] | CompactEventGroups,
+  events: HistoryEventWithId[],
   category: string,
 ) => {
   if (!isCategoryType(category)) return events;
-  return events.filter(
-    (event: HistoryEventWithId | CompactEventGroup) =>
-      event.category === category,
-  );
+  return events.filter((event: IterableEvent) => event.category === category);
 };
diff --git a/src/lib/stores/pagination.ts b/src/lib/stores/pagination.ts
new file mode 100644
index 00000000..1aa94d99
--- /dev/null
+++ b/src/lib/stores/pagination.ts
@@ -0,0 +1,154 @@
+import { derived, writable, get } from 'svelte/store';
+
+const options: string[] = ['100', '250', '500'];
+
+export const perPageOptions = (perPage: number | string) => {
+  const itemsPerPage = String(perPage);
+  return options.includes(itemsPerPage) ? options : [perPage, ...options];
+};
+
+export const getPageForIndex = (i: number, pageSize: number): number => {
+  return Math.floor(i / pageSize) + 1;
+};
+
+export const getStartingIndexForPage = (
+  page: number,
+  itemsPerPage: number,
+  items: ArrayLike<unknown>,
+) => {
+  if (isNaN(page)) return 0;
+
+  if (page <= 1) return 0;
+  if (page > getTotalPages(itemsPerPage, items))
+    return items.length - itemsPerPage;
+
+  return Math.floor(itemsPerPage * (page - 1));
+};
+
+export const getNearestStartingIndex = (
+  index: number,
+  itemsPerPage: number,
+  items: ArrayLike<unknown>,
+) => {
+  const page = getPageForIndex(index, itemsPerPage);
+  return getStartingIndexForPage(page, itemsPerPage, items);
+};
+
+export const getValidPage = (
+  page: number,
+  itemsPerPage: number,
+  items: ArrayLike<unknown>,
+) => {
+  if (isNaN(page)) return 0;
+  const lastPage = getTotalPages(itemsPerPage, items);
+
+  if (page <= 0) return 1;
+  if (page > lastPage) return lastPage;
+
+  return page;
+};
+
+export const getTotalPages = (
+  pageSize: number,
+  items: ArrayLike<unknown>,
+): number => {
+  return Math.ceil(items.length / pageSize);
+};
+
+export const getIndex = (index: number, things: ArrayLike<unknown>): number => {
+  if (isNaN(index)) return 0;
+  if (index < 0) return 0;
+  if (index < things.length) return index;
+  return things.length - 1;
+};
+
+export const outOfBounds = (index: number, things: ArrayLike<unknown>) => {
+  if (index >= things.length) return true;
+  if (index < 0) return true;
+  return false;
+};
+
+/**
+ * Creates a Svelte store for viewing pages of a larger data set.
+ */
+export const pagination = <T>(
+  items: Readonly<T[]> = [],
+  perPage: number | string = 100,
+  startingIndex: string | number = 0,
+) => {
+  const start = getNearestStartingIndex(
+    Number(startingIndex),
+    Number(perPage),
+    items,
+  );
+
+  const pageSize = writable(Number(perPage));
+  const index = writable(start);
+
+  const adjustPageSize = (n: number | string) => {
+    pageSize.set(Number(n));
+  };
+
+  const next = () => {
+    index.update((index) => {
+      const nextIndex = index + get(pageSize);
+      if (outOfBounds(nextIndex, items)) return index;
+      return getIndex(nextIndex, items);
+    });
+  };
+
+  const previous = () => {
+    index.update((index) => {
+      const nextStart = index - get(pageSize);
+      return getIndex(nextStart, items);
+    });
+  };
+
+  const jumpToPage = (page: number | string) => {
+    const itemsPerPage = get(pageSize);
+    return index.set(
+      getStartingIndexForPage(Number(page), itemsPerPage, items),
+    );
+  };
+
+  const jumpToIndex = (i: number | string) => {
+    const page = getPageForIndex(Number(i), get(pageSize));
+    jumpToPage(page);
+  };
+
+  const findIndex = (fn: (item: T) => boolean): number => {
+    for (let i = 0; i < items.length; i++) {
+      if (fn(items[i])) return i;
+    }
+  };
+
+  const findPage = (fn: (item: T) => boolean): number => {
+    const i = findIndex(fn);
+    return getPageForIndex(i, get(pageSize));
+  };
+
+  const { subscribe } = derived([index, pageSize], ([$index, $pageSize]) => {
+    return {
+      items: items.slice($index, $index + $pageSize),
+      hasPrevious: !outOfBounds($index - $pageSize, items),
+      hasNext: !outOfBounds($index + $pageSize, items),
+      startingIndex: $index,
+      endingIndex: getIndex($index + $pageSize - 1, items),
+      length: items.length,
+      pageSize: $pageSize,
+      currentPage: getPageForIndex($index, $pageSize),
+      totalPages: getTotalPages($pageSize, items),
+    };
+  });
+
+  return {
+    subscribe,
+    adjustPageSize,
+    next,
+    previous,
+    jumpToPage,
+    jumpToIndex,
+    findIndex,
+    findPage,
+  };
+};
diff --git a/src/lib/utilities/update-query-parameters.ts b/src/lib/utilities/update-query-parameters.ts
index 741f2991..0675ca6b 100644
--- a/src/lib/utilities/update-query-parameters.ts
+++ b/src/lib/utilities/update-query-parameters.ts
@@ -1,6 +1,5 @@
 import { browser } from '$app/env';
 import type { goto, invalidate } from '$app/navigation';
-import { appendQueryParameters } from './append-query-parameters';
 
 type UpdateQueryParams = {
   parameter: string;
@@ -18,8 +17,13 @@ export const updateQueryParameters = async ({
   url,
   goto,
 }: UpdateQueryParams): Promise<typeof value> => {
+  const current = url.searchParams.get(parameter);
+  const next = String(value);
+
+  if (current === next) return;
+
   if (value) {
-    url.searchParams.set(parameter, value.toString());
+    url.searchParams.set(parameter, next);
   } else {
     url.searchParams.delete(parameter);
   }
diff --git a/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/history/[view]/__layout.svelte b/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/history/[view]/__layout.svelte
index 46954af8..6b4b26a0 100644
--- a/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/history/[view]/__layout.svelte
+++ b/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/history/[view]/__layout.svelte
@@ -31,59 +31,70 @@
 </script>
 
 <script lang="ts">
+  import { page } from '$app/stores';
+
   import EmptyState from '$lib/components/empty-state.svelte';
+  import Pagination from '$lib/components/pagination.svelte';
   import FilterSelect from '$lib/components/select/filter-select.svelte';
   import Option from '$lib/components/select/option.svelte';
   import EventListItem from './_event-list-item.svelte';
 
-  export let items: HistoryEventWithId[] | CompactEventGroups;
+  export let items: IterableEvents;
   export let category: EventTypeCategory;
+
+  const startingIndex = items.findIndex(
+    ({ id }) => $page.params.eventId === id,
+  );
 </script>
 
-<section class="flex flex-col border-2 border-gray-300 rounded-lg w-full mb-6">
-  <div class="flex w-full">
-    <header class="table-header border-r-2 rounded-tl-lg w-1/3">
-      <h3>Summary</h3>
-      <div class="flex items-center gap-4">
-        <FilterSelect parameter="category" bind:value={category}>
-          <Option value={undefined}>All</Option>
-          <Option value="activity">Activity</Option>
-          <Option value="command">Command</Option>
-          <Option value="signal">Signal</Option>
-          <Option value="timer">Timer</Option>
-          <Option value="child-workflow">Child Workflow</Option>
-          <Option value="workflow">Workflow</Option>
-        </FilterSelect>
-      </div>
-    </header>
-    <header class="table-header rounded-tr-lg w-2/3">
-      <h3>Details</h3>
-    </header>
-  </div>
-  <div class="flex">
-    <div class="flex flex-col w-1/3 border-r-2 border-gray-300 rounded-bl-lg">
-      <div class="rounded-bl-lg overflow-y-scroll h-full">
-        {#each items as event (event.id)}
-          <EventListItem {event} />
-        {:else}
-          <div class="p-2">
-            <EmptyState
-              title="No Events Match"
-              content="There are no events that match your filters. Adjust your filters to see your events."
-            />
-          </div>
-        {/each}
-      </div>
+<Pagination {items} {startingIndex} let:visibleItems>
+  <section
+    class="flex flex-col border-2 border-gray-300 rounded-lg w-full mb-6"
+  >
+    <div class="flex w-full">
+      <header class="table-header border-r-2 rounded-tl-lg w-1/3">
+        <h3>Summary</h3>
+        <div class="flex items-center gap-4">
+          <FilterSelect parameter="category" bind:value={category}>
+            <Option value={undefined}>All</Option>
+            <Option value="activity">Activity</Option>
+            <Option value="command">Command</Option>
+            <Option value="signal">Signal</Option>
+            <Option value="timer">Timer</Option>
+            <Option value="child-workflow">Child Workflow</Option>
+            <Option value="workflow">Workflow</Option>
+          </FilterSelect>
+        </div>
+      </header>
+      <header class="table-header rounded-tr-lg w-2/3">
+        <h3>Details</h3>
+      </header>
     </div>
-    <div class="flex flex-col w-2/3">
-      <div
-        class="overflow-y-scroll overflow-x-hidden rounded-br-lg px-4 w-full py-4"
-      >
-        <slot />
+    <div class="flex">
+      <div class="flex flex-col w-1/3 border-r-2 border-gray-300 rounded-bl-lg">
+        <div class="rounded-bl-lg overflow-y-scroll h-full">
+          {#each visibleItems as event (event.id)}
+            <EventListItem {event} />
+          {:else}
+            <div class="p-2">
+              <EmptyState
+                title="No Events Match"
+                content="There are no events that match your filters. Adjust your filters to see your events."
+              />
+            </div>
+          {/each}
+        </div>
+      </div>
+      <div class="flex flex-col w-2/3">
+        <div
+          class="overflow-y-scroll overflow-x-hidden rounded-br-lg px-4 w-full py-4"
+        >
+          <slot />
+        </div>
       </div>
     </div>
-  </div>
-</section>
+  </section>
+</Pagination>
 
 <style lang="postcss">
   .table-header {
diff --git a/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/history/[view]/_event-list-item.svelte b/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/history/[view]/_event-list-item.svelte
index 7a1cb149..c0a026e0 100644
--- a/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/history/[view]/_event-list-item.svelte
+++ b/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/history/[view]/_event-list-item.svelte
@@ -7,7 +7,7 @@
   import { isEventGroup } from '$lib/models/group-events';
   import { appendQueryParameters } from '$lib/utilities/append-query-parameters';
 
-  export let event: HistoryEventWithId | CompactEventGroup;
+  export let event: IterableEvent;
 
   const isActive = (currentId: string): boolean => {
     if (isEvent(event)) {
diff --git a/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/history/[view]/index.svelte b/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/history/[view]/index.svelte
index 50362417..c4fc127d 100644
--- a/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/history/[view]/index.svelte
+++ b/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/history/[view]/index.svelte
@@ -4,7 +4,7 @@
   export const load: Load = async function ({ url, params, stuff }) {
     const { matchingEvents, matchingEventGroups } = stuff;
 
-    let items: HistoryEventWithId[] | CompactEventGroups;
+    let items: IterableEvents;
 
     if (params.view === 'summary') items = matchingEvents;
     if (params.view === 'compact') items = matchingEventGroups;
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 f309c5cd..7a14ac14 100644
--- a/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/history/__layout.svelte
+++ b/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/history/__layout.svelte
@@ -38,7 +38,6 @@
     faBars,
     faCode,
     faLayerGroup,
-    faStream,
     faTable,
   } from '@fortawesome/free-solid-svg-icons';
 
diff --git a/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/history/full.svelte b/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/history/full.svelte
index 0055a29a..2d274408 100644
--- a/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/history/full.svelte
+++ b/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/history/full.svelte
@@ -1,7 +1,7 @@
 <script context="module" lang="ts">
   import type { Load } from '@sveltejs/kit';
 
-  export const load: Load = async function ({ stuff, url, params }) {
+  export const load: Load = async function ({ stuff }) {
     const { events } = stuff;
 
     return {
@@ -16,26 +16,29 @@
   import { formatDate } from '$lib/utilities/format-date';
 
   import EventDetails from './_event-details.svelte';
+  import Pagination from '$lib/components/pagination.svelte';
 
   export let events: HistoryEventWithId[];
 </script>
 
-<section class="border-2 border-gray-300 rounded-lg w-full mb-6">
-  <header class="flex table-header rounded-t-lg">
-    <h3 class="w-1/12">Workflow Events</h3>
-    <h3 class="w-2/12">Date & Time</h3>
-    <h3 class="w-3/4">Event Details</h3>
-  </header>
-  {#each events as event}
-    <article class="table-row" id={event.id}>
-      <p class="w-1/12"><a href="#{event.id}">{event.id}</a></p>
-      <p class="w-2/12">{formatDate(event.eventTime)}</p>
-      <div class="w-3/4">
-        <EventDetails {event} />
-      </div>
-    </article>
-  {/each}
-</section>
+<Pagination items={events} let:visibleItems>
+  <section class="border-2 border-gray-300 rounded-lg w-full mb-6">
+    <header class="flex table-header rounded-t-lg">
+      <h3 class="w-1/12">Workflow Events</h3>
+      <h3 class="w-2/12">Date & Time</h3>
+      <h3 class="w-3/4">Event Details</h3>
+    </header>
+    {#each visibleItems as event (event.id)}
+      <article class="table-row" id={event.id}>
+        <p class="w-1/12"><a href="#{event.id}">{event.id}</a></p>
+        <p class="w-2/12">{formatDate(event.eventTime)}</p>
+        <div class="w-3/4">
+          <EventDetails {event} />
+        </div>
+      </article>
+    {/each}
+  </section>
+</Pagination>
 
 <style lang="postcss">
   .table-header {


Test patch:
diff --git a/src/lib/stores/pagination.test.ts b/src/lib/stores/pagination.test.ts
new file mode 100644
index 00000000..fb679623
--- /dev/null
+++ b/src/lib/stores/pagination.test.ts
@@ -0,0 +1,332 @@
+import { get } from 'svelte/store';
+import {
+  getIndex,
+  getPageForIndex,
+  getStartingIndexForPage,
+  getTotalPages,
+  getValidPage,
+  pagination,
+} from './pagination';
+
+const oneHundredResolutions = new Array(100).fill(null).map((_, i) => i);
+
+describe(pagination, () => {
+  it('should have a pageSize', () => {
+    const store = pagination(oneHundredResolutions, 50);
+    const { pageSize } = get(store);
+
+    expect(pageSize).toBe(50);
+  });
+
+  it('should be able to adjust the pageSize', () => {
+    const store = pagination(oneHundredResolutions, 50);
+    store.adjustPageSize(75);
+    const { pageSize } = get(store);
+
+    expect(pageSize).toBe(75);
+  });
+
+  it('should set hasPrevious to false by default', () => {
+    const store = pagination(oneHundredResolutions, 50);
+    const { hasPrevious } = get(store);
+
+    expect(hasPrevious).toBe(false);
+  });
+
+  it('should set hasPrevious if it is not at the beginning', () => {
+    const store = pagination(oneHundredResolutions, 25);
+    store.next();
+    const { hasPrevious } = get(store);
+
+    expect(hasPrevious).toBe(true);
+  });
+
+  it('should set hasNext to true if the pageSize is less than the items in the array', () => {
+    const store = pagination(oneHundredResolutions, 50);
+    const { hasNext } = get(store);
+
+    expect(hasNext).toBe(true);
+  });
+
+  it('should set hasNext to false if there are no more items', () => {
+    const store = pagination(oneHundredResolutions, 75);
+    store.next();
+    const { hasNext } = get(store);
+
+    expect(hasNext).toBe(false);
+  });
+
+  it('should set the startingIndex to zero by default', () => {
+    const store = pagination(oneHundredResolutions, 75);
+    const { startingIndex } = get(store);
+
+    expect(startingIndex).toBe(0);
+  });
+
+  it('should set the endingIndex to pageSize by default', () => {
+    const store = pagination(oneHundredResolutions, 5);
+    const { endingIndex } = get(store);
+
+    expect(endingIndex).toBe(4);
+  });
+
+  it('should increment the startingIndex by the pageSize', () => {
+    const store = pagination(oneHundredResolutions, 5);
+    store.next();
+    const { startingIndex } = get(store);
+
+    expect(startingIndex).toBe(5);
+  });
+
+  it('should increment the endingIndex by the pageSize', () => {
+    const store = pagination(oneHundredResolutions, 5);
+    store.next();
+    const { endingIndex } = get(store);
+
+    expect(endingIndex).toBe(9);
+  });
+
+  it('should decrement the startingIndex by the page size', () => {
+    const store = pagination(oneHundredResolutions, 5);
+
+    store.next();
+    store.next();
+    store.previous();
+
+    const { startingIndex } = get(store);
+
+    expect(startingIndex).toBe(5);
+  });
+
+  it('should decrement the endingIndex by the page size', () => {
+    const store = pagination(oneHundredResolutions, 5);
+
+    store.next();
+    store.next();
+    store.previous();
+
+    const { endingIndex } = get(store);
+
+    expect(endingIndex).toBe(9);
+  });
+
+  it('should not decrement the starting position if at the beginning', () => {
+    const store = pagination(oneHundredResolutions, 5);
+
+    store.previous();
+
+    const { startingIndex } = get(store);
+
+    expect(startingIndex).toBe(0);
+  });
+
+  it('should not increment the starting position if at end', () => {
+    const store = pagination(oneHundredResolutions, 200);
+
+    store.next();
+
+    const { startingIndex } = get(store);
+
+    expect(startingIndex).toBe(0);
+  });
+
+  it('should show the first page worth of items by default', () => {
+    const store = pagination(oneHundredResolutions, 5);
+
+    const { items: result } = get(store);
+
+    expect(result).toEqual([0, 1, 2, 3, 4]);
+  });
+
+  it('should show the second page of items once incremented', () => {
+    const store = pagination(oneHundredResolutions, 5);
+
+    store.next();
+
+    const { items: result } = get(store);
+
+    expect(result).toEqual([5, 6, 7, 8, 9]);
+  });
+
+  it('should default to a startingPage of 1', () => {
+    const store = pagination(oneHundredResolutions, 5);
+
+    const { currentPage } = get(store);
+
+    expect(currentPage).toBe(1);
+  });
+
+  it('should increment the starting page when incremented', () => {
+    const store = pagination(oneHundredResolutions, 5);
+
+    store.next();
+
+    const { currentPage } = get(store);
+
+    expect(currentPage).toBe(2);
+  });
+
+  it('should have the correct number of total pages', () => {
+    const store = pagination(oneHundredResolutions, 5);
+
+    const { totalPages } = get(store);
+
+    expect(totalPages).toBe(20);
+  });
+
+  it('should have the correct page when jumping to a page', () => {
+    const store = pagination(oneHundredResolutions, 5);
+
+    store.jumpToPage(10);
+
+    const { currentPage } = get(store);
+
+    expect(currentPage).toBe(10);
+  });
+
+  it('should have the correct starting index when jumping to a page', () => {
+    const store = pagination(oneHundredResolutions, 5);
+
+    store.jumpToPage(2);
+
+    const { startingIndex } = get(store);
+
+    expect(startingIndex).toBe(5);
+  });
+
+  it('should default to zero when jumping to a negative number', () => {
+    const store = pagination(oneHundredResolutions, 5);
+
+    store.jumpToPage(-20);
+
+    const { currentPage } = get(store);
+
+    expect(currentPage).toBe(1);
+  });
+
+  it('should default to the last page when jumping to a page out of bounds', () => {
+    const store = pagination(oneHundredResolutions, 5);
+
+    store.jumpToPage(200);
+
+    const { currentPage } = get(store);
+
+    expect(currentPage).toBe(20);
+  });
+
+  it('should allow you to find the index of an item', () => {
+    const store = pagination(oneHundredResolutions, 5);
+
+    const index = store.findIndex((i) => i === 5);
+
+    expect(index).toBe(5);
+  });
+
+  it('should allow you to find the page of an item', () => {
+    const store = pagination(oneHundredResolutions, 5);
+
+    const page = store.findPage((i) => i === 6);
+
+    expect(page).toBe(2);
+  });
+
+  it('should be able to jump to a page for a given index', () => {
+    const store = pagination(oneHundredResolutions, 5);
+
+    store.jumpToIndex(6);
+
+    const { currentPage } = get(store);
+
+    expect(currentPage).toBe(2);
+  });
+
+  it('should allow you set a custom starting index', () => {
+    const store = pagination(oneHundredResolutions, 5, 5);
+
+    const { startingIndex } = get(store);
+
+    expect(startingIndex).toBe(5);
+  });
+
+  it('should fall back to the nearest page', () => {
+    const store = pagination(oneHundredResolutions, 5, 6);
+
+    const { startingIndex } = get(store);
+
+    expect(startingIndex).toBe(5);
+  });
+});
+
+describe(getPageForIndex, () => {
+  it('should correctly get the page for a given index', () => {
+    expect(getPageForIndex(6, 5)).toBe(2);
+  });
+});
+
+describe(getTotalPages, () => {
+  it('should correctly calculate the total number of pages', () => {
+    expect(getTotalPages(20, oneHundredResolutions)).toBe(5);
+  });
+});
+
+describe(getIndex, () => {
+  it('should get the index if it is in the array', () => {
+    const things = ['first', 'second', 'third'];
+    expect(getIndex(2, things)).toBe(2);
+  });
+
+  it('should get the index of the last item of the last item in the array', () => {
+    const things = ['first', 'second', 'third'];
+    expect(getIndex(3, things)).toBe(2);
+  });
+
+  it('should return 0 if you give it an index less than 0', () => {
+    const things = ['first', 'second', 'third'];
+    expect(getIndex(-10, things)).toBe(0);
+  });
+
+  it('should return 0 if given NaN', () => {
+    const things = ['first', 'second', 'third'];
+    expect(getIndex(NaN, things)).toBe(0);
+  });
+});
+
+describe(getValidPage, () => {
+  it('should return 1 if given the first index of an array', () => {
+    expect(getValidPage(0, 20, oneHundredResolutions)).toBe(1);
+  });
+
+  it('should return 2 if given an index from the second page', () => {
+    expect(getValidPage(2, 20, oneHundredResolutions)).toBe(2);
+  });
+
+  it('should return the last page if given a page out of bounds', () => {
+    expect(getValidPage(10000, 20, oneHundredResolutions)).toBe(5);
+  });
+
+  it('should return 0 if given NaN', () => {
+    expect(getValidPage(NaN, 20, oneHundredResolutions)).toBe(0);
+  });
+});
+
+describe(getStartingIndexForPage, () => {
+  it('should return 0 for the first page', () => {
+    expect(getStartingIndexForPage(1, 20, oneHundredResolutions)).toBe(0);
+  });
+
+  it('should return the first index of the second page for the something on the second page', () => {
+    expect(getStartingIndexForPage(2, 20, oneHundredResolutions)).toBe(20);
+  });
+
+  it('should return the first index of the last page for the something out of bounds', () => {
+    expect(getStartingIndexForPage(100, 20, oneHundredResolutions)).toBe(80);
+  });
+
+  it('should return 0 if given a negative number for the page', () => {
+    expect(getStartingIndexForPage(-10, 20, oneHundredResolutions)).toBe(0);
+  });
+
+  it('should return 0 if given NaN', () => {
+    expect(getStartingIndexForPage(NaN, 20, oneHundredResolutions)).toBe(0);
+  });
+});
diff --git a/src/lib/utilities/update-query-parameters.test.ts b/src/lib/utilities/update-query-parameters.test.ts
index da655e27..88201c6f 100644
--- a/src/lib/utilities/update-query-parameters.test.ts
+++ b/src/lib/utilities/update-query-parameters.test.ts
@@ -7,7 +7,7 @@ describe(updateQueryParameters, () => {
   it('should call the set method on the query when a value is provided', () => {
     const parameter = 'parameter';
     const value = 'value';
-    const goto = () => Promise.resolve(true);
+    const goto = () => Promise.resolve();
 
     const spy = jest.spyOn(url.searchParams, 'set');
 
@@ -19,7 +19,7 @@ describe(updateQueryParameters, () => {
 
   it('should call the delete method on the query when no value is provided', () => {
     const parameter = 'parameter';
-    const goto = () => Promise.resolve(true);
+    const goto = () => Promise.resolve();
 
     const spy = jest.spyOn(url.searchParams, 'delete');
 
@@ -32,7 +32,7 @@ describe(updateQueryParameters, () => {
   it('should call the delete method on the query when an empty string is provided', () => {
     const parameter = 'parameter';
     const value = '';
-    const goto = () => Promise.resolve(true);
+    const goto = () => Promise.resolve();
 
     const spy = jest.spyOn(url.searchParams, 'delete');
 
@@ -45,7 +45,7 @@ describe(updateQueryParameters, () => {
   it('should call the delete method on the query when null is provided', () => {
     const parameter = 'parameter';
     const value = null;
-    const goto = () => Promise.resolve(true);
+    const goto = () => Promise.resolve();
 
     const spy = jest.spyOn(url.searchParams, 'delete');
 
@@ -67,13 +67,13 @@ describe(updateQueryParameters, () => {
 
   it('should call `goto` with the correct path when query paramters already exist', () => {
     const parameter = 'parameter';
-    const value = 'value';
+    const value = 'newvalue';
     const goto = jest.fn().mockReturnValue(Promise.resolve(null));
 
     updateQueryParameters({ parameter, value, url, goto });
 
     expect(goto).toHaveBeenCalledWith(
-      'https://temporal.io/?parameter=value',
+      'https://temporal.io/?parameter=newvalue',
       gotoOptions,
     );
   });