Repository: temporalio/ui
Pull request #179 (2021-12-22 20:09:01)

Add filtering for workflow ID and type
Wires up logic for filtering the workflow execution list by ID or type.

- Syncs filters with query params.
- Adds debouncing to filter inputs.
- Adds tests for utility functions.



Patch:
diff --git a/.vscode/settings.json b/.vscode/settings.json
index dde673e0..6d55db32 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -4,5 +4,6 @@
     "**/.svelte-kit": true,
     "**/build-local": true,
     "**/build-cloud": true
-  }
+  },
+  "jest.nodeEnv": { "TZ": "UTC" }
 }
diff --git a/jest.config.js b/jest.config.js
index c2a0a116..dea24647 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -1,3 +1,5 @@
+process.env.TZ = 'UTC';
+
 export default {
   preset: 'ts-jest',
   testEnvironment: 'node',
diff --git a/package-lock.json b/package-lock.json
index e7e847b4..e10fda91 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,10 +8,11 @@
       "name": "temporal-ui",
       "version": "0.0.1",
       "dependencies": {
-        "@sveltejs/svelte-virtual-list": "^3.0.1",
         "@fortawesome/free-solid-svg-icons": "^5.15.4",
+        "@sveltejs/svelte-virtual-list": "^3.0.1",
         "date-fns": "^2.23.0",
         "json-beautify": "^1.1.1",
+        "just-debounce": "^1.1.0",
         "svelte-fa": "^2.4.0",
         "svelte-hero-icons": "^3.0.0",
         "url-pattern": "^1.0.3",
@@ -6274,6 +6275,11 @@
         "node": ">=0.6.0"
       }
     },
+    "node_modules/just-debounce": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/just-debounce/-/just-debounce-1.1.0.tgz",
+      "integrity": "sha512-qpcRocdkUmf+UTNBYx5w6dexX5J31AKK1OmPwH630a83DdVVUIngk55RSAiIGpQyoH0dlr872VHfPjnQnK1qDQ=="
+    },
     "node_modules/kleur": {
       "version": "4.1.4",
       "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.4.tgz",
@@ -14387,6 +14393,11 @@
         "verror": "1.10.0"
       }
     },
+    "just-debounce": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/just-debounce/-/just-debounce-1.1.0.tgz",
+      "integrity": "sha512-qpcRocdkUmf+UTNBYx5w6dexX5J31AKK1OmPwH630a83DdVVUIngk55RSAiIGpQyoH0dlr872VHfPjnQnK1qDQ=="
+    },
     "kleur": {
       "version": "4.1.4",
       "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.4.tgz",
diff --git a/package.json b/package.json
index d8dbc15f..7d6000a7 100644
--- a/package.json
+++ b/package.json
@@ -8,7 +8,7 @@
     "dev:cloud": "TEMPORAL_UI_BUILD_TARGET=cloud svelte-kit dev",
     "build:local": "TEMPORAL_UI_BUILD_TARGET=local svelte-kit build",
     "build:cloud": "TEMPORAL_UI_BUILD_TARGET=cloud svelte-kit build",
-    "test": "jest",
+    "test": "TZ=UTC jest",
     "preview:local": "TEMPORAL_UI_BUILD_TARGET=local svelte-kit preview",
     "preview:cloud": "TEMPORAL_UI_BUILD_TARGET=cloud svelte-kit preview",
     "check": "TEMPORAL_UI_BUILD_TARGET=local svelte-check --tsconfig ./tsconfig.json",
@@ -22,10 +22,11 @@
     "cypress:open": "TEMPORAL_API_HOST=http://localhost:3000 cypress open"
   },
   "dependencies": {
-    "@sveltejs/svelte-virtual-list": "^3.0.1",
     "@fortawesome/free-solid-svg-icons": "^5.15.4",
+    "@sveltejs/svelte-virtual-list": "^3.0.1",
     "date-fns": "^2.23.0",
     "json-beautify": "^1.1.1",
+    "just-debounce": "^1.1.0",
     "svelte-fa": "^2.4.0",
     "svelte-hero-icons": "^3.0.0",
     "url-pattern": "^1.0.3",
diff --git a/src/global.d.ts b/src/global.d.ts
index 85f6ac41..3fe725cc 100644
--- a/src/global.d.ts
+++ b/src/global.d.ts
@@ -74,3 +74,10 @@ type Activity = {
   activityTaskCancelRequestedEvent?: ActivityTaskCancelRequestedEvent;
   activityTaskCanceledEvent?: ActivityTaskCanceledEvent;
 };
+
+type FilterParameters = {
+  workflowId?: string;
+  workflowType?: string;
+  status?: WorkflowStatus;
+  timeRange?: Duration | string;
+};
diff --git a/src/lib/components/filter-input.svelte b/src/lib/components/filter-input.svelte
index 397a81c1..f602eb97 100644
--- a/src/lib/components/filter-input.svelte
+++ b/src/lib/components/filter-input.svelte
@@ -1,7 +1,27 @@
 <script lang="ts">
-  export let id: string;
+  import { goto } from '$app/navigation';
+  import { page } from '$app/stores';
+  import { updateQueryParameters } from '$lib/utilities/update-query-parameters';
+  import debounce from 'just-debounce';
+
+  export let parameter: string;
   export let name: string;
   export let value: string;
+
+  let _value = (parameter && $page.query.get(parameter)) || value;
+
+  const id = `${parameter || name}-filter`;
+  const update = debounce(updateQueryParameters, 300);
+
+  $: {
+    update({
+      parameter,
+      value: _value,
+      query: $page.query,
+      path: $page.path,
+      goto,
+    });
+  }
 </script>
 
 <div class="flex flex-col items-start justify-center">
@@ -10,6 +30,6 @@
     class="block border-2 text-base p-2 w-full h-10 rounded-lg "
     placeholder={name}
     {id}
-    bind:value
+    bind:value={_value}
   />
 </div>
diff --git a/src/lib/services/workflow-service.ts b/src/lib/services/workflow-service.ts
index 400c9e62..6a4f7ada 100644
--- a/src/lib/services/workflow-service.ts
+++ b/src/lib/services/workflow-service.ts
@@ -1,20 +1,23 @@
 import { sub, formatISO } from 'date-fns';
+import { get } from 'svelte/store';
+
+import type { ListWorkflowExecutionsResponse } from '$types';
+import type { WorkflowExecution } from '$lib/models/workflow-execution';
+
 import { requestFromAPI } from '$lib/utilities/request-from-api';
+import { toDuration } from '$lib/utilities/to-duration';
+
 import {
   toWorkflowExecution,
   toWorkflowExecutions,
 } from '$lib/models/workflow-execution';
-import { toDuration } from '$lib/utilities/to-duration';
-
-import { fetchEvents } from './events-service';
 import { toEventHistory } from '$lib/models/event-history';
 
-import type { ListWorkflowExecutionsResponse } from '$types';
-import type { WorkflowExecution } from '$lib/models/workflow-execution';
-import { getStatusFilterCode } from '$lib/utilities/get-workflow-status-filter-code';
+import { fetchEvents } from './events-service';
 import { convertEventPayloadFromDataConverter } from './data-converter';
-import { get } from 'svelte/store';
+
 import { dataConverterPort } from '$lib/stores/data-converter-config';
+import { getWorkflowFilterParameters } from '$lib/utilities/get-workflow-filter-parameters';
 
 export type GetWorkflowExecutionRequest = NamespaceScopedRequest & {
   executionId: string;
@@ -26,20 +29,6 @@ type CombinedWorkflowExecutionsResponse = {
   nextPageTokens: NextPageTokens;
 };
 
-type TimeRangeParameter = {
-  timeRange?: Duration | string;
-};
-
-type StatusParameters = {
-  status?: WorkflowStatus;
-};
-
-const createDate = (timeRange: Duration | string) => {
-  const duration =
-    typeof timeRange === 'string' ? toDuration(timeRange) : timeRange;
-  return formatISO(sub(new Date(), duration));
-};
-
 const emptyWorkflowRequest = (): Promise<ListWorkflowExecutionsResponse> => {
   return Promise.resolve({
     executions: [],
@@ -48,18 +37,16 @@ const emptyWorkflowRequest = (): Promise<ListWorkflowExecutionsResponse> => {
 
 export const fetchOpenWorkflows = (
   namespace: string,
-  { timeRange, status }: TimeRangeParameter & StatusParameters,
+  parameters: FilterParameters,
   request = fetch,
 ): Promise<ListWorkflowExecutionsResponse> => {
   if (status && status !== 'Running') return emptyWorkflowRequest();
+  const params = getWorkflowFilterParameters(parameters);
+
   return requestFromAPI<ListWorkflowExecutionsResponse>(
     `/namespaces/${namespace}/workflows/open`,
     {
-      params: {
-        'start_time_filter.earliest_time': createDate(
-          timeRange || { hours: 24 },
-        ),
-      },
+      params,
       request,
     },
   );
@@ -67,18 +54,12 @@ export const fetchOpenWorkflows = (
 
 export const fetchClosedWorkflows = (
   namespace: string,
-  { timeRange, status }: TimeRangeParameter & StatusParameters,
+  parameters: FilterParameters,
   request = fetch,
 ): Promise<ListWorkflowExecutionsResponse> => {
   if (status === 'Running') return emptyWorkflowRequest();
 
-  const params: Record<string, string> = {
-    'start_time_filter.earliest_time': createDate(timeRange || { hours: 24 }),
-  };
-
-  const statusFilter = getStatusFilterCode(status);
-
-  if (statusFilter) params['status_filter.status'] = statusFilter;
+  const params = getWorkflowFilterParameters(parameters);
 
   return requestFromAPI<ListWorkflowExecutionsResponse>(
     `/namespaces/${namespace}/workflows/closed`,
@@ -91,13 +72,14 @@ export const fetchClosedWorkflows = (
 
 export const fetchAllWorkflows = async (
   namespace: string,
-  { timeRange, status }: TimeRangeParameter & StatusParameters,
+  parameters: FilterParameters,
   request = fetch,
 ): Promise<CombinedWorkflowExecutionsResponse> => {
   const [open, closed] = await Promise.all([
-    fetchOpenWorkflows(namespace, { timeRange, status }, request),
-    fetchClosedWorkflows(namespace, { timeRange, status }, request),
+    fetchOpenWorkflows(namespace, parameters, request),
+    fetchClosedWorkflows(namespace, parameters, request),
   ]);
+
   const executions = [...open?.executions, ...closed?.executions];
 
   return {
diff --git a/src/lib/utilities/get-workflow-filter-parameters.ts b/src/lib/utilities/get-workflow-filter-parameters.ts
new file mode 100644
index 00000000..7fe0e70a
--- /dev/null
+++ b/src/lib/utilities/get-workflow-filter-parameters.ts
@@ -0,0 +1,31 @@
+import { formatISO, sub } from 'date-fns';
+
+import { toDuration } from './to-duration';
+import { getStatusFilterCode } from './get-workflow-status-filter-code';
+
+const createDate = (timeRange: Duration | string) => {
+  const duration =
+    typeof timeRange === 'string' ? toDuration(timeRange) : timeRange;
+  return formatISO(sub(new Date(), duration));
+};
+
+export const getWorkflowFilterParameters = ({
+  workflowId,
+  workflowType,
+  timeRange,
+  status,
+}: FilterParameters = {}): Record<string, string> => {
+  const params: Record<string, string> = {};
+  const statusFilter = getStatusFilterCode(status);
+
+  params['start_time_filter.earliest_time'] = createDate(
+    timeRange || { hours: 24 },
+  );
+
+  if (workflowId) params['execution_filter.workflow_id'] = workflowId;
+  if (workflowType) params['type_filter.name'] = workflowType;
+
+  if (statusFilter) params['status_filter.status'] = statusFilter;
+
+  return params;
+};
diff --git a/src/lib/utilities/update-query-parameters.ts b/src/lib/utilities/update-query-parameters.ts
index 66387855..dbb68176 100644
--- a/src/lib/utilities/update-query-parameters.ts
+++ b/src/lib/utilities/update-query-parameters.ts
@@ -2,13 +2,15 @@ import type { goto, invalidate } from '$app/navigation';
 
 type UpdateQueryParams = {
   parameter: string;
-  value: string;
+  value?: string;
   query: URLSearchParams;
   path: string;
   goto: typeof goto;
   invalidate?: typeof invalidate;
 };
 
+const options = { replaceState: true, keepfocus: true };
+
 export const updateQueryParameters = ({
   parameter,
   value,
@@ -21,5 +23,10 @@ export const updateQueryParameters = ({
   } else {
     query.delete(parameter);
   }
-  goto(`${path}?${query}`, { replaceState: true, keepfocus: true });
+
+  if (query.toString()) {
+    goto(`${path}?${query}`, options);
+  } else {
+    goto(path, options);
+  }
 };
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
deleted file mode 100644
index 037c4a7a..00000000
--- a/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/events/_events-filters.svelte
+++ /dev/null
@@ -1,54 +0,0 @@
-<script lang="ts">
-  import Icon from 'svelte-hero-icons/Icon.svelte';
-  import { Download } from 'svelte-hero-icons';
-  import type { Writable } from 'svelte/store';
-
-  import { convertToJSON } from '$lib/utilities/convert-to-json';
-  import Input from '$lib/components/filter-input.svelte';
-  import Button from '$lib/components/button.svelte';
-  export let events: BaseEvent[];
-  export let eventFormat: Writable<EventFormat>;
-  export let eventType: Writable<string>;
-  export let execution: string;
-
-  function setFormat(format: EventFormat) {
-    $eventFormat = format;
-  }
-
-  function clear() {
-    $eventType = null;
-  }
-
-  $: dataUri = convertToJSON(events);
-</script>
-
-<section class="p-4 flex gap-2 items-center justify-between">
-  <div>
-    <label for="format">View Format</label>
-    <Button on:click={() => setFormat('grid')}>GRID</Button>
-    <Button 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 on:click={clear}>Clear</Button>
-  </div>
-  <div>
-    <a
-      class="text-black-500 font-bold uppercase px-3 py-1 text-xs flex"
-      href={dataUri}
-      download={`${execution}.json`}
-    >
-      <Icon src={Download} class="text-black w-4 h-4" />export</a
-    >
-  </div>
-</section>
-
-<style lang="postcss">
-  label {
-    font-size: 14px;
-  }
-</style>
diff --git a/src/routes/namespaces/[namespace]/workflows/_workflow-filters.svelte b/src/routes/namespaces/[namespace]/workflows/_workflow-filters.svelte
index bfb87d15..0ad0d049 100644
--- a/src/routes/namespaces/[namespace]/workflows/_workflow-filters.svelte
+++ b/src/routes/namespaces/[namespace]/workflows/_workflow-filters.svelte
@@ -6,6 +6,9 @@
 
   export let timeFormat: string = 'relative';
 
+  let workflowIdFilter = '';
+  let workflowTypeFilter = '';
+
   const durations = [
     '10 minutes',
     '60 minutes',
@@ -32,8 +35,16 @@
 <section class="flex flex-col my-8">
   <h2 class="text-3xl mb-4">Workflows</h2>
   <div class="grid grid-cols-5 gap-4">
-    <FilterInput id="workflow-id-filter" name="Workflow ID" value="" />
-    <FilterInput id="workflow-type-filter" name="Workflow Type" value="" />
+    <FilterInput
+      parameter="workflow-id"
+      name="Workflow ID"
+      value={workflowIdFilter}
+    />
+    <FilterInput
+      parameter="workflow-type"
+      name="Workflow Type"
+      value={workflowTypeFilter}
+    />
     <FilterSelect label="Time Range" parameter="time-range" value="24 hours">
       {#each durations as value}
         <Option {value}>{value}</Option>
diff --git a/src/routes/namespaces/[namespace]/workflows/_workflows-summary-row.svelte b/src/routes/namespaces/[namespace]/workflows/_workflows-summary-row.svelte
index b4a69485..87262984 100644
--- a/src/routes/namespaces/[namespace]/workflows/_workflows-summary-row.svelte
+++ b/src/routes/namespaces/[namespace]/workflows/_workflows-summary-row.svelte
@@ -1,8 +1,8 @@
 <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';
 
diff --git a/src/routes/namespaces/[namespace]/workflows/index.svelte b/src/routes/namespaces/[namespace]/workflows/index.svelte
index 664d64b9..27b4ea9d 100644
--- a/src/routes/namespaces/[namespace]/workflows/index.svelte
+++ b/src/routes/namespaces/[namespace]/workflows/index.svelte
@@ -5,12 +5,14 @@
     if (!page.query.has('time-range')) page.query.set('time-range', '24 hours');
 
     const namespace = page.params.namespace;
+    const workflowId = page.query.get('workflow-id');
+    const workflowType = page.query.get('workflow-type');
     const timeRange = page.query.get('time-range');
     const status = page.query.get('status') as WorkflowStatus;
 
     const initialData = await fetchAllWorkflows(
-      page.params.namespace,
-      { timeRange, status },
+      namespace,
+      { workflowId, workflowType, timeRange, status },
       fetch,
     );
 


Test patch:
diff --git a/src/lib/utilities/get-workflow-filter-parameters.test.ts b/src/lib/utilities/get-workflow-filter-parameters.test.ts
new file mode 100644
index 00000000..1fcc8329
--- /dev/null
+++ b/src/lib/utilities/get-workflow-filter-parameters.test.ts
@@ -0,0 +1,75 @@
+import { getWorkflowFilterParameters } from './get-workflow-filter-parameters';
+
+describe(getWorkflowFilterParameters, () => {
+  afterEach(() => {
+    jest.useRealTimers();
+  });
+
+  it('should always have an earliest_time set', () => {
+    const result = getWorkflowFilterParameters();
+    expect(result['start_time_filter.earliest_time']).toBeDefined();
+  });
+
+  it('should always have a default earliest_time of 24 hours', () => {
+    jest.useFakeTimers().setSystemTime(new Date('2020-01-01').getTime());
+    const OneDayEarlier = '2019-12-31T00:00:00Z';
+
+    const result = getWorkflowFilterParameters();
+    expect(result['start_time_filter.earliest_time']).toBe(OneDayEarlier);
+  });
+
+  it('should adjust the start time based on a duration', () => {
+    jest.useFakeTimers().setSystemTime(new Date('2020-01-01').getTime());
+    const NinetyDaysEarlier = '2019-10-03T00:00:00Z';
+
+    const result = getWorkflowFilterParameters({ timeRange: { days: 90 } });
+    expect(result['start_time_filter.earliest_time']).toBe(NinetyDaysEarlier);
+  });
+
+  it('should pass through a workflow ID', () => {
+    const result = getWorkflowFilterParameters({ workflowId: 'string value' });
+    expect(result['execution_filter.workflow_id']).toBe('string value');
+  });
+
+  it('should pass through a workflow type', () => {
+    const result = getWorkflowFilterParameters({
+      workflowType: 'string value',
+    });
+    expect(result['type_filter.name']).toBe('string value');
+  });
+
+  it('should not include a workflow ID filter if none is provided', () => {
+    const result = getWorkflowFilterParameters();
+    expect(result['execution_filter.workflow_id']).toBeUndefined();
+  });
+
+  it('should not include a workflow ID filter if null is provided', () => {
+    const result = getWorkflowFilterParameters({ workflowId: null });
+    expect(result['execution_filter.workflow_id']).toBeUndefined();
+  });
+
+  it('should not include a workflow ID filter if an empty string is provided', () => {
+    const result = getWorkflowFilterParameters({ workflowId: '' });
+    expect(result['execution_filter.workflow_id']).toBeUndefined();
+  });
+
+  it('should not include a workflow type filter if none is provided', () => {
+    const result = getWorkflowFilterParameters();
+    expect(result['type_filter.name']).toBeUndefined();
+  });
+
+  it('should not include a workflow type filter if null is provided', () => {
+    const result = getWorkflowFilterParameters({ workflowType: null });
+    expect(result['type_filter.name']).toBeUndefined();
+  });
+
+  it('should not include a workflow type filter if an empty string is provided', () => {
+    const result = getWorkflowFilterParameters({ workflowType: '' });
+    expect(result['type_filter.name']).toBeUndefined();
+  });
+
+  it('should return a status filter if one is provided', () => {
+    const result = getWorkflowFilterParameters({ status: 'Running' });
+    expect(result['status_filter.status']).toBe('1');
+  });
+});
diff --git a/src/lib/utilities/get-workflow-status-filter-code.test.ts b/src/lib/utilities/get-workflow-status-filter-code.test.ts
new file mode 100644
index 00000000..72db878d
--- /dev/null
+++ b/src/lib/utilities/get-workflow-status-filter-code.test.ts
@@ -0,0 +1,31 @@
+import { getStatusFilterCode } from './get-workflow-status-filter-code';
+
+describe(getStatusFilterCode, () => {
+  it('should return "1" if the status is "Running"', () => {
+    expect(getStatusFilterCode('Running')).toBe('1');
+  });
+
+  it('should return "2" if the status is "Completed"', () => {
+    expect(getStatusFilterCode('Completed')).toBe('2');
+  });
+
+  it('should return "3" if the status is "Failed"', () => {
+    expect(getStatusFilterCode('Failed')).toBe('3');
+  });
+
+  it('should return "4" if the status is "Canceled"', () => {
+    expect(getStatusFilterCode('Canceled')).toBe('4');
+  });
+
+  it('should return "5" if the status is "Terminated"', () => {
+    expect(getStatusFilterCode('Terminated')).toBe('5');
+  });
+
+  it('should return "6" if the status is "ContinuedAsNew"', () => {
+    expect(getStatusFilterCode('ContinuedAsNew')).toBe('6');
+  });
+
+  it('should return "7" if the status is "TimedOut"', () => {
+    expect(getStatusFilterCode('TimedOut')).toBe('7');
+  });
+});
diff --git a/src/lib/utilities/update-query-parameters.test.ts b/src/lib/utilities/update-query-parameters.test.ts
new file mode 100644
index 00000000..085efafb
--- /dev/null
+++ b/src/lib/utilities/update-query-parameters.test.ts
@@ -0,0 +1,106 @@
+import { updateQueryParameters } from './update-query-parameters';
+
+const gotoOptions = { replaceState: true, keepfocus: true };
+
+describe(updateQueryParameters, () => {
+  it('should call the set method on the query when a value is provided', () => {
+    const parameter = 'parameter';
+    const value = 'value';
+    const query = new URLSearchParams();
+    const path = '/some/path';
+    const goto = () => Promise.resolve(true);
+
+    const spy = jest.spyOn(query, 'set');
+
+    updateQueryParameters({ parameter, value, query, path, goto });
+
+    expect(spy).toHaveBeenCalled();
+    expect(spy).toHaveBeenCalledWith(parameter, value);
+  });
+
+  it('should call the delete method on the query when no value is provided', () => {
+    const parameter = 'parameter';
+    const query = new URLSearchParams();
+    const path = '/some/path';
+    const goto = () => Promise.resolve(true);
+
+    const spy = jest.spyOn(query, 'delete');
+
+    updateQueryParameters({ parameter, query, path, goto });
+
+    expect(spy).toHaveBeenCalled();
+    expect(spy).toHaveBeenCalledWith(parameter);
+  });
+
+  it('should call the delete method on the query when an empty string is provided', () => {
+    const parameter = 'parameter';
+    const value = '';
+    const query = new URLSearchParams();
+    const path = '/some/path';
+    const goto = () => Promise.resolve(true);
+
+    const spy = jest.spyOn(query, 'delete');
+
+    updateQueryParameters({ parameter, value, query, path, goto });
+
+    expect(spy).toHaveBeenCalled();
+    expect(spy).toHaveBeenCalledWith(parameter);
+  });
+
+  it('should call the delete method on the query when null is provided', () => {
+    const parameter = 'parameter';
+    const value = null;
+    const query = new URLSearchParams();
+    const path = '/some/path';
+    const goto = () => Promise.resolve(true);
+
+    const spy = jest.spyOn(query, 'delete');
+
+    updateQueryParameters({ parameter, value, query, path, goto });
+
+    expect(spy).toHaveBeenCalled();
+    expect(spy).toHaveBeenCalledWith(parameter);
+  });
+
+  it('should call `goto` with the correct path', () => {
+    const parameter = 'parameter';
+    const value = 'value';
+    const query = new URLSearchParams();
+    const path = '/some/path';
+    const goto = jest.fn().mockReturnValue(Promise.resolve(null));
+
+    updateQueryParameters({ parameter, value, query, path, goto });
+
+    expect(goto).toHaveBeenCalledWith(
+      '/some/path?parameter=value',
+      gotoOptions,
+    );
+  });
+
+  it('should call `goto` with the correct path when query paramters already exist', () => {
+    const parameter = 'parameter';
+    const value = 'value';
+    const query = new URLSearchParams({ otherParameter: 'otherValue' });
+    const path = '/some/path';
+    const goto = jest.fn().mockReturnValue(Promise.resolve(null));
+
+    updateQueryParameters({ parameter, value, query, path, goto });
+
+    expect(goto).toHaveBeenCalledWith(
+      '/some/path?otherParameter=otherValue&parameter=value',
+      gotoOptions,
+    );
+  });
+
+  it('should call `goto` with without the "?" if the query params are empty', () => {
+    const parameter = 'parameter';
+    const value = null;
+    const query = new URLSearchParams();
+    const path = '/some/path';
+    const goto = jest.fn().mockReturnValue(Promise.resolve(null));
+
+    updateQueryParameters({ parameter, value, query, path, goto });
+
+    expect(goto).toHaveBeenCalledWith('/some/path', gotoOptions);
+  });
+});