Repository: temporalio/ui
Pull request #226 (2022-01-12 22:33:23)

Add Signal and Marker events to compact view
<!--- Note to EXTERNAL Contributors -->
<!-- Thanks for opening a PR! 
If it is a significant code change, please **make sure there is an open issue** for this. 
We work best with you when we have accepted the idea first before you code. -->

<!--- For ALL Contributors 👇 -->

## What was changed
<!-- Describe what has changed in this PR -->

Added Signals and Markers to history compact view:

![image](https://user-images.githubusercontent.com/11838981/149233632-c9c54607-86d1-48ba-9391-f8ee83d55ea2.png)


## Why?
<!-- Tell your future self why have you made these changes -->

Important events for compact view

## Checklist
<!--- add/delete as needed --->

1. Closes <!-- add issue number here -->

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/src/events.d.ts b/src/events.d.ts
index 4b6be0de..6b4df02c 100644
--- a/src/events.d.ts
+++ b/src/events.d.ts
@@ -319,6 +319,8 @@ interface PendingActivity extends PendingActivityInfo {
 type EventType =
   | ActivityType
   | TimerType
+  | SignalType
+  | MarkerType
   | 'ChildWorkflowExecutionCanceled'
   | 'ChildWorkflowExecutionCompleted'
   | 'ChildWorkflowExecutionFailed'
@@ -334,7 +336,6 @@ type EventType =
   | 'WorkflowExecutionCompleted'
   | 'WorkflowExecutionContinuedAsNew'
   | 'WorkflowExecutionFailed'
-  | 'WorkflowExecutionSignaled'
   | 'WorkflowExecutionStarted'
   | 'WorkflowExecutionTerminated'
   | 'WorkflowExecutionTimedOut'
@@ -344,10 +345,8 @@ type EventType =
   | 'WorkflowTaskStarted'
   | 'WorkflowTaskTimedOut'
   | 'ExternalWorkflowExecutionCancelRequested'
-  | 'ExternalWorkflowExecutionSignaled'
   | 'RequestCancelExternalWorkflowExecutionFailed'
   | 'RequestCancelExternalWorkflowExecutionInitiated'
-  | 'MarkerRecorded'
   | 'UpsertWorkflowSearchAttributes';
 
 type ActivityType =
@@ -361,6 +360,12 @@ type ActivityType =
 
 type TimerType = 'TimerCanceled' | 'TimerFired' | 'TimerStarted';
 
+type SignalType =
+  | 'WorkflowExecutionSignaled'
+  | 'ExternalWorkflowExecutionSignaled';
+
+type MarkerType = 'MarkerRecorded';
+
 type EventTypeCategory =
   | 'activity'
   | 'child-workflow'
diff --git a/src/global.d.ts b/src/global.d.ts
index ac6f035b..d5474fc2 100644
--- a/src/global.d.ts
+++ b/src/global.d.ts
@@ -57,7 +57,7 @@ type TimeFormat = 'UTC' | 'relative' | 'local';
 type FilterParameters = {
   workflowId?: string;
   workflowType?: string;
-  status?: WorkflowStatus;
+  executionStatus?: WorkflowStatus;
   timeRange?: Duration | string;
 };
 
diff --git a/src/lib/models/events-group.ts b/src/lib/models/events-group.ts
index 65fa1c31..5658eada 100644
--- a/src/lib/models/events-group.ts
+++ b/src/lib/models/events-group.ts
@@ -14,14 +14,22 @@ const activityTypes = [
 
 const timerTypes = ['TimerStarted', 'TimerCanceled', 'TimerFired'];
 
+const signalType = ['WorkflowExecutionSignaled'];
+
+const markerType = ['MarkerRecorded'];
+
+type CompactEventType = ActivityType | TimerType | SignalType | MarkerType;
+
 type CompactEvent = HistoryEventWithId & {
-  eventType: ActivityType | TimerType;
+  eventType: CompactEventType;
 };
 
 const isCompactEvent = (event: HistoryEventWithId): event is CompactEvent => {
   if (
     activityTypes.includes(event.eventType) ||
-    timerTypes.includes(event.eventType)
+    isTimerEvent(event) ||
+    isSignalEvent(event) ||
+    isMarkerEvent(event)
   )
     return true;
   return false;
@@ -33,13 +41,23 @@ const isActivityScheduledEvent = (
   return event.eventType === 'ActivityTaskScheduled';
 };
 
-const isTimerStartedEvent = (
+const isTimerEvent = (
   event: HistoryEventWithId,
 ): event is ScheduledActivityEvent => {
-  return event.eventType === 'TimerStarted';
+  return timerTypes.includes(event.eventType);
 };
 
-type CompactEventType = ActivityType | TimerType;
+const isSignalEvent = (
+  event: HistoryEventWithId,
+): event is ScheduledActivityEvent => {
+  return signalType.includes(event.eventType);
+};
+
+const isMarkerEvent = (
+  event: HistoryEventWithId,
+): event is ScheduledActivityEvent => {
+  return markerType.includes(event.eventType);
+};
 
 export class EventsGroup {
   id: string;
@@ -53,11 +71,21 @@ export class EventsGroup {
         event?.activityTaskScheduledEventAttributes?.activityType?.name;
     }
 
-    if (isTimerStartedEvent(event)) {
+    if (isTimerEvent(event)) {
       this.id = event?.timerStartedEventAttributes?.timerId;
       this.name = `Timer ${event?.timerStartedEventAttributes?.timerId}`;
     }
 
+    if (isSignalEvent(event)) {
+      this.id = event.id;
+      this.name = `Signal ${event?.workflowExecutionSignaledEventAttributes?.signalName}`;
+    }
+
+    if (isMarkerEvent(event)) {
+      this.id = event.id;
+      this.name = `Marker ${event?.markerRecordedEventAttributes?.markerName}`;
+    }
+
     this.set(event.eventType, event);
   }
 
@@ -128,7 +156,12 @@ export class EventGroups {
   }
 
   add(event: CompactEvent): void {
-    if (isActivityScheduledEvent(event)) {
+    if (
+      isActivityScheduledEvent(event) ||
+      isTimerEvent(event) ||
+      isSignalEvent(event) ||
+      isMarkerEvent(event)
+    ) {
       const id = String(event.id);
       this._groups.set(id, new EventsGroup(event));
     }
@@ -163,11 +196,6 @@ export class EventGroups {
       const { scheduledEventId } = event.activityTaskCompletedEventAttributes;
       this.get(scheduledEventId).set(event.eventType, event);
     }
-
-    if (isTimerStartedEvent(event)) {
-      const id = String(event.id);
-      this._groups.set(id, new EventsGroup(event));
-    }
   }
 
   get length(): number {
diff --git a/src/lib/models/workflow-execution.ts b/src/lib/models/workflow-execution.ts
index 1b825066..22c52728 100644
--- a/src/lib/models/workflow-execution.ts
+++ b/src/lib/models/workflow-execution.ts
@@ -1,4 +1,3 @@
-import { fetchWorkflow } from '$lib/services/workflow-service';
 import type {
   WorkflowExecutionStatus,
   DescribeWorkflowExecutionResponse,
diff --git a/src/lib/services/workflow-service.ts b/src/lib/services/workflow-service.ts
index 6e94af3d..d69cae96 100644
--- a/src/lib/services/workflow-service.ts
+++ b/src/lib/services/workflow-service.ts
@@ -9,7 +9,7 @@ import {
 } from '$lib/models/workflow-execution';
 
 import { routeForApi } from '$lib/utilities/route-for-api';
-import { getWorkflowFilterParameters } from '$lib/utilities/get-workflow-filter-parameters';
+import { toListWorkflowQuery } from '$lib/utilities/list-workflow-query';
 
 export type GetWorkflowExecutionRequest = NamespaceScopedRequest & {
   executionId: string;
@@ -18,60 +18,7 @@ export type GetWorkflowExecutionRequest = NamespaceScopedRequest & {
 
 type CombinedWorkflowExecutionsResponse = {
   workflows: WorkflowExecution[];
-  nextPageTokens: NextPageTokens;
-};
-
-const emptyWorkflowRequest = (): Promise<ListWorkflowExecutionsResponse> => {
-  return Promise.resolve({
-    executions: [],
-  });
-};
-
-const checkForStatus =
-  (workflowStatus: WorkflowStatus, value: boolean) =>
-  ({ status }: FilterParameters): boolean => {
-    if (!status) return false;
-    if (status === workflowStatus) return value;
-    return !value;
-  };
-
-const filterIsSetToRunning = checkForStatus('Running', true);
-const filterIsNotSetToRunning = checkForStatus('Running', false);
-
-export const fetchOpenWorkflows = (
-  namespace: string,
-  parameters: FilterParameters,
-  request = fetch,
-): Promise<ListWorkflowExecutionsResponse> => {
-  if (filterIsNotSetToRunning(parameters)) return emptyWorkflowRequest();
-
-  const params = getWorkflowFilterParameters(parameters);
-
-  return requestFromAPI<ListWorkflowExecutionsResponse>(
-    routeForApi('workflows.open', { namespace }),
-    {
-      params,
-      request,
-    },
-  );
-};
-
-export const fetchClosedWorkflows = (
-  namespace: string,
-  parameters: FilterParameters,
-  request = fetch,
-): Promise<ListWorkflowExecutionsResponse> => {
-  if (filterIsSetToRunning(parameters)) return emptyWorkflowRequest();
-
-  const params = getWorkflowFilterParameters(parameters);
-
-  return requestFromAPI<ListWorkflowExecutionsResponse>(
-    routeForApi('workflows.closed', { namespace }),
-    {
-      params,
-      request,
-    },
-  );
+  nextPageToken: string;
 };
 
 export const fetchAllWorkflows = async (
@@ -79,19 +26,20 @@ export const fetchAllWorkflows = async (
   parameters: FilterParameters,
   request = fetch,
 ): Promise<CombinedWorkflowExecutionsResponse> => {
-  const [open, closed] = await Promise.all([
-    fetchOpenWorkflows(namespace, parameters, request),
-    fetchClosedWorkflows(namespace, parameters, request),
-  ]);
+  const query = toListWorkflowQuery(parameters);
 
-  const executions = [...open?.executions, ...closed?.executions];
+  const { executions, nextPageToken } =
+    await requestFromAPI<ListWorkflowExecutionsResponse>(
+      routeForApi('workflows', { namespace }),
+      {
+        params: { query },
+        request,
+      },
+    );
 
   return {
     workflows: toWorkflowExecutions({ executions }),
-    nextPageTokens: {
-      open: open.nextPageToken,
-      closed: closed.nextPageToken,
-    },
+    nextPageToken: String(nextPageToken),
   };
 };
 
diff --git a/src/lib/utilities/get-event-classification.ts b/src/lib/utilities/get-event-classification.ts
index 40ff9e1f..66ba5feb 100644
--- a/src/lib/utilities/get-event-classification.ts
+++ b/src/lib/utilities/get-event-classification.ts
@@ -69,7 +69,11 @@ export const getEventClassification = (
 ): EventClassification => {
   if (isPendingActivity(event)) return event.state;
   if (isEventsGroup(event)) {
-    event = event.get('ActivityTaskScheduled') ?? event.get('TimerStarted');
+    event =
+      event.get('ActivityTaskScheduled') ??
+      event.get('TimerStarted') ??
+      event.get('WorkflowExecutionSignaled') ??
+      event.get('MarkerRecorded');
   }
 
   const eventType = event.eventType;
diff --git a/src/lib/utilities/get-workflow-filter-parameters.ts b/src/lib/utilities/get-workflow-filter-parameters.ts
deleted file mode 100644
index 7fe0e70a..00000000
--- a/src/lib/utilities/get-workflow-filter-parameters.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-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/list-workflow-query.ts b/src/lib/utilities/list-workflow-query.ts
new file mode 100644
index 00000000..7ded76b5
--- /dev/null
+++ b/src/lib/utilities/list-workflow-query.ts
@@ -0,0 +1,72 @@
+import { isDuration, isDurationString, toDate } from './to-duration';
+
+type QueryKey =
+  | 'WorkflowId'
+  | 'WorkflowType'
+  | 'StartTime'
+  | 'CloseTime'
+  | 'ExecutionTime'
+  | 'ExecutionStatus';
+
+type FilterKey =
+  | 'workflowId'
+  | 'workflowType'
+  | 'timeRange'
+  | 'executionStatus';
+
+type FilterValue = string | Duration;
+
+const queryKeys: Readonly<Record<string, QueryKey>> = {
+  workflowId: 'WorkflowId',
+  workflowType: 'WorkflowType',
+  timeRange: 'StartTime',
+  executionStatus: 'ExecutionStatus',
+} as const;
+
+const filterKeys: Readonly<FilterKey[]> = [
+  'workflowId',
+  'workflowType',
+  'timeRange',
+  'executionStatus',
+] as const;
+
+const isValid = (value: unknown): boolean => {
+  if (value === null) return false;
+  if (value === undefined) return false;
+  if (typeof value === 'string' && value === 'undefined') return false;
+
+  return true;
+};
+
+const isFilterKey = (key: unknown): key is FilterKey => {
+  if (typeof key !== 'string') return false;
+
+  for (const filterKey of filterKeys) {
+    if (filterKey === key) return true;
+  }
+
+  return false;
+};
+
+const toQueryStatement = (key: FilterKey, value: FilterValue): string => {
+  const queryKey = queryKeys[key];
+
+  if (isDuration(value) || isDurationString(value)) {
+    return `${queryKey} > "${toDate(value)}"`;
+  }
+
+  return `${queryKey}="${value}"`;
+};
+
+const toQueryStatements = (parameters: FilterParameters): string[] => {
+  return Object.entries(parameters)
+    .map(([key, value]) => {
+      if (isFilterKey(key) && isValid(value))
+        return toQueryStatement(key, value);
+    })
+    .filter(Boolean);
+};
+
+export const toListWorkflowQuery = (parameters: FilterParameters): string => {
+  return toQueryStatements(parameters).join(' and ');
+};
diff --git a/src/lib/utilities/route-for-api.ts b/src/lib/utilities/route-for-api.ts
index b64fa61a..7ad482b8 100644
--- a/src/lib/utilities/route-for-api.ts
+++ b/src/lib/utilities/route-for-api.ts
@@ -1,4 +1,7 @@
-type WorkflowsAPIRoutePath = 'workflows.open' | 'workflows.closed';
+type WorkflowsAPIRoutePath =
+  | 'workflows'
+  | 'workflows.open'
+  | 'workflows.closed';
 type WorkflowAPIRoutePath =
   | 'workflow'
   | 'workflow.terminate'
@@ -67,6 +70,7 @@ export function routeForApi(
     user: '/me',
     namespaces: '/namespaces',
     'task-queue': `/namespaces/${parameters?.namespace}/task-queues/${parameters?.queue}`,
+    workflows: `/namespaces/${parameters?.namespace}/workflows`,
     'workflows.open': `/namespaces/${parameters?.namespace}/workflows/open`,
     'workflows.closed': `/namespaces/${parameters?.namespace}/workflows/closed`,
     workflow: `/namespaces/${parameters?.namespace}/workflows/${parameters?.executionId}/executions/${parameters?.runId}`,
diff --git a/src/lib/utilities/to-duration.ts b/src/lib/utilities/to-duration.ts
index 518d10d0..bc26fe73 100644
--- a/src/lib/utilities/to-duration.ts
+++ b/src/lib/utilities/to-duration.ts
@@ -1,5 +1,69 @@
-const durationPattern =
-  /(\d+)\s(years|months|weeks|days|hours|minutes|seconds)/;
+import { formatISO, sub } from 'date-fns';
+
+type DurationKey = typeof durationKeys;
+
+const millisecond = 1;
+const second = millisecond * 1000;
+const minute = second * 60;
+const hour = minute * 60;
+const day = hour * 24;
+const year = day * 365;
+
+const durationKeys = [
+  'years',
+  'months',
+  'weeks',
+  'days',
+  'hours',
+  'minutes',
+  'seconds',
+] as const;
+
+export const durations = [
+  '10 minutes',
+  '60 minutes',
+  '3 hours',
+  '24 hours',
+  '3 days',
+  '7 days',
+  '30 days',
+  '90 days',
+];
+
+const durationPattern = new RegExp(`(\\d+)\\s(${durationKeys.join('|')})`);
+
+const isDurationKey = (key: unknown): key is DurationKey => {
+  if (typeof key !== 'string') return false;
+
+  for (const durationKey of durationKeys) {
+    if (durationKey === key) return true;
+  }
+
+  return false;
+};
+
+export const isDuration = (value: unknown): value is Duration => {
+  if (value === null) return false;
+  if (typeof value !== 'object') return false;
+
+  for (const key of Object.keys(value)) {
+    if (!isDurationKey(key)) return false;
+  }
+
+  return true;
+};
+
+export const isDurationString = (value: unknown): value is string => {
+  if (value === null) return false;
+  if (typeof value !== 'string') return false;
+
+  const match = value.match(durationPattern);
+
+  if (!match) return false;
+
+  const [, amount, units] = match;
+  return !!amount && !!units;
+};
 
 export const toDuration = (value: string): Duration => {
   const [, amount, units] = value.match(durationPattern);
@@ -11,3 +75,29 @@ export const toString = (duration: Duration): string => {
   const amount: number = duration[units];
   return `${amount} ${units}`;
 };
+
+export const toDate = (timeRange: Duration | string): string => {
+  const duration =
+    typeof timeRange === 'string' ? toDuration(timeRange) : timeRange;
+
+  return formatISO(sub(new Date(), duration));
+};
+
+export const fromDate = (targetDate: string | Date): Duration => {
+  if (typeof targetDate === 'string') targetDate = new Date(targetDate);
+
+  const currentDate = new Date();
+  const difference = Number(currentDate) - Number(targetDate);
+
+  const years = Math.floor(difference / year);
+  const days = Math.floor(difference / day);
+  const hours = Math.floor(difference / hour);
+  const minutes = Math.floor(difference / minute);
+
+  if (years >= 1) return { years };
+  if (days >= 1) return { days };
+  if (hours >= 1) return { hours };
+  if (minutes >= 1) return { minutes };
+
+  return { seconds: difference / 1000 };
+};
diff --git a/src/routes/namespaces/[namespace]/workflows/_workflow-filters.svelte b/src/routes/namespaces/[namespace]/workflows/_workflow-filters.svelte
index d7aa588c..89831026 100644
--- a/src/routes/namespaces/[namespace]/workflows/_workflow-filters.svelte
+++ b/src/routes/namespaces/[namespace]/workflows/_workflow-filters.svelte
@@ -1,4 +1,6 @@
 <script lang="ts">
+  import { durations } from '$lib/utilities/to-duration';
+
   import Select from '$lib/components/select/select.svelte';
   import FilterSelect from '$lib/components/select/filter-select.svelte';
   import Option from '$lib/components/select/option.svelte';
@@ -9,17 +11,6 @@
   let workflowIdFilter = '';
   let workflowTypeFilter = '';
 
-  const durations = [
-    '10 minutes',
-    '60 minutes',
-    '3 hours',
-    '24 hours',
-    '3 days',
-    '7 days',
-    '30 days',
-    '90 days',
-  ];
-
   const statuses = {
     All: null,
     Running: 'Running',
diff --git a/src/routes/namespaces/[namespace]/workflows/index.svelte b/src/routes/namespaces/[namespace]/workflows/index.svelte
index 655f5762..d6d66e47 100644
--- a/src/routes/namespaces/[namespace]/workflows/index.svelte
+++ b/src/routes/namespaces/[namespace]/workflows/index.svelte
@@ -8,22 +8,24 @@
     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 executionStatus = page.query.get('status') as WorkflowStatus;
 
-    const initialData = await fetchAllWorkflows(
-      namespace,
-      { workflowId, workflowType, timeRange, status },
-      fetch,
-    );
+    const parameters: FilterParameters = {
+      workflowId,
+      workflowType,
+      timeRange,
+      executionStatus,
+    };
+    const initialData = await fetchAllWorkflows(namespace, parameters, fetch);
 
     return {
-      props: { initialData, namespace },
+      props: { initialData, namespace, parameters },
     };
   }
 </script>
 
 <script lang="ts">
-  import { page } from '$app/stores';
+  import VirtualList from '@sveltejs/svelte-virtual-list';
 
   import { fetchAllWorkflows } from '$lib/services/workflow-service';
   import { refreshable } from '$lib/stores/refreshable';
@@ -33,17 +35,15 @@
   import WorkflowFilters from './_workflow-filters.svelte';
   import EmptyState from '$lib/components/empty-state.svelte';
   import WorkflowsLoadingState from './_workflows-loading.svelte';
-  import VirtualList from '@sveltejs/svelte-virtual-list';
 
   export let namespace: string;
   export let initialData: ReturnType<typeof fetchAllWorkflows>;
-
-  $: timeRange = $page.query.get('time-range');
+  export let parameters: FilterParameters;
 
   let timeFormat: TimeFormat = 'UTC';
 
   $: data = refreshable(
-    () => fetchAllWorkflows(namespace, { timeRange }),
+    () => fetchAllWorkflows(namespace, parameters),
     initialData,
   );
 </script>


Test patch:
diff --git a/src/lib/utilities/get-workflow-filter-parameters.test.ts b/src/lib/utilities/get-workflow-filter-parameters.test.ts
deleted file mode 100644
index 1fcc8329..00000000
--- a/src/lib/utilities/get-workflow-filter-parameters.test.ts
+++ /dev/null
@@ -1,75 +0,0 @@
-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/list-workflow-query.test.ts b/src/lib/utilities/list-workflow-query.test.ts
new file mode 100644
index 00000000..24b87a99
--- /dev/null
+++ b/src/lib/utilities/list-workflow-query.test.ts
@@ -0,0 +1,62 @@
+import { toListWorkflowQuery } from './list-workflow-query';
+
+describe(toListWorkflowQuery, () => {
+  afterEach(() => {
+    jest.useRealTimers();
+  });
+
+  it('should convert an executionStatus', () => {
+    const query = toListWorkflowQuery({ executionStatus: 'Running' });
+    expect(query).toBe('ExecutionStatus="Running"');
+  });
+
+  it('should convert an timeRange with a Duration as a value', () => {
+    jest.useFakeTimers().setSystemTime(new Date('2020-01-01').getTime());
+    const twentyFourHoursEarlier = '2019-12-31T00:00:00Z';
+
+    const query = toListWorkflowQuery({ timeRange: { hours: 24 } });
+
+    expect(query).toBe(`StartTime > "${twentyFourHoursEarlier}"`);
+  });
+
+  it('should convert an two values using an "and"', () => {
+    const query = toListWorkflowQuery({
+      executionStatus: 'Running',
+      workflowId: 'abcdef123',
+    });
+
+    expect(query).toBe('ExecutionStatus="Running" and WorkflowId="abcdef123"');
+  });
+
+  it('should convert an three values using an "and"', () => {
+    const query = toListWorkflowQuery({
+      executionStatus: 'Running',
+      workflowId: 'abcdef123',
+      workflowType: 'ImportantBusinessTransaction',
+    });
+
+    expect(query).toBe(
+      'ExecutionStatus="Running" and WorkflowId="abcdef123" and WorkflowType="ImportantBusinessTransaction"',
+    );
+  });
+
+  it('should drop null values', () => {
+    const query = toListWorkflowQuery({
+      executionStatus: 'Running',
+      workflowId: 'abcdef123',
+      workflowType: null,
+    });
+
+    expect(query).toBe('ExecutionStatus="Running" and WorkflowId="abcdef123"');
+  });
+
+  it('should drop "undefined" which can be returned from inputs', () => {
+    const query = toListWorkflowQuery({
+      executionStatus: 'Running',
+      workflowId: 'abcdef123',
+      workflowType: 'undefined',
+    });
+
+    expect(query).toBe('ExecutionStatus="Running" and WorkflowId="abcdef123"');
+  });
+});
diff --git a/src/lib/utilities/to-duration.test.ts b/src/lib/utilities/to-duration.test.ts
index bafedf32..ea9b8fd7 100644
--- a/src/lib/utilities/to-duration.test.ts
+++ b/src/lib/utilities/to-duration.test.ts
@@ -1,4 +1,4 @@
-import { toDuration } from './to-duration';
+import { toDuration, toDate, fromDate, isDurationString } from './to-duration';
 
 describe(toDuration, () => {
   it('should correctly parse "Last 10 minutes"', () => {
@@ -65,3 +65,65 @@ describe(toDuration, () => {
     expect(toDuration('3 months')).toEqual({ months: 3 });
   });
 });
+
+describe(toDate, () => {
+  afterEach(() => {
+    jest.useRealTimers();
+  });
+
+  it('should produce a date based on a duration', () => {
+    jest.useFakeTimers().setSystemTime(new Date('2020-01-01').getTime());
+    const ninetyDaysEarlier = '2019-10-03T00:00:00Z';
+
+    const result = toDate({ days: 90 });
+    expect(result).toBe(ninetyDaysEarlier);
+  });
+
+  it('should produce a date based on a duration string', () => {
+    jest.useFakeTimers().setSystemTime(new Date('2020-01-01').getTime());
+    const ninetyDaysEarlier = '2019-10-03T00:00:00Z';
+
+    const result = toDate('90 days');
+    expect(result).toBe(ninetyDaysEarlier);
+  });
+});
+
+describe(fromDate, () => {
+  afterEach(() => {
+    jest.useRealTimers();
+  });
+
+  it('should produce a duration based on a 90 days in the past', () => {
+    jest.useFakeTimers().setSystemTime(new Date('2020-01-01').getTime());
+    const ninetyDaysEarlier = '2019-10-03T00:00:00Z';
+
+    const result = fromDate(ninetyDaysEarlier);
+    expect(result).toEqual({ days: 90 });
+  });
+
+  it('should produce a duration based on a 60 days in the past', () => {
+    jest.useFakeTimers().setSystemTime(new Date('2020-01-01').getTime());
+    const sixtyDaysEarlier = '2019-11-02T00:00:00Z';
+
+    const result = fromDate(sixtyDaysEarlier);
+    expect(result).toEqual({ days: 60 });
+  });
+});
+
+describe(isDurationString, () => {
+  it('should return true for "24 hours"', () => {
+    expect(isDurationString('24 hours')).toBe(true);
+  });
+
+  it('should return false for null', () => {
+    expect(isDurationString(null)).toBe(false);
+  });
+
+  it('should return false for undefined', () => {
+    expect(isDurationString(undefined)).toBe(false);
+  });
+
+  it('should return false for "undefined"', () => {
+    expect(isDurationString('undefined')).toBe(false);
+  });
+});