Repository: temporalio/ui
Pull request #85 (2021-09-21 16:35:48)

Add basic date filtering
Initial ground work for selecting workflows in a given time range.

Also, this pull request adds `createStoreWithCallback`, which allows you to create a `writable` store that will trigger a function whenever `set` or `update` are called on the store.

feedmeapples: lg

Patch:
diff --git a/src/lib/components/filter-select.svelte b/src/lib/components/filter-select.svelte
index 7a9a477b..b97b957a 100644
--- a/src/lib/components/filter-select.svelte
+++ b/src/lib/components/filter-select.svelte
@@ -6,7 +6,7 @@
 
 <div>
   <label for={id} class="text-gray-600 text-xs block">{name}</label>
-  <select class="block border-2 text-base p-2" {id} bind:value>
+  <select class="block border-2 text-base p-2" {id} bind:value on:change>
     <slot />
   </select>
 </div>
diff --git a/src/lib/components/time-range-select.svelte b/src/lib/components/time-range-select.svelte
new file mode 100644
index 00000000..30ce6677
--- /dev/null
+++ b/src/lib/components/time-range-select.svelte
@@ -0,0 +1,38 @@
+<script lang="ts">
+  import type { Writable } from 'svelte/store';
+  import { toDuration, toString } from '$lib/utilities/to-duration';
+
+  import Select from '$lib/components/filter-select.svelte';
+
+  const updateRange = (event: Event) => {
+    const target = event.target as HTMLSelectElement;
+    const duration = toDuration(target.value);
+    $range = duration;
+  };
+
+  export let key: string;
+  export let range: Writable<Duration>;
+
+  const durations: { [key: string]: Duration } = {
+    '10 minutes': { minutes: 10 },
+    '60 minutes': { minutes: 60 },
+    '3 hours': { hours: 3 },
+    '24 hours': { hours: 24 },
+    '3 days': { days: 3 },
+    '7 days': { days: 7 },
+    '30 days': { days: 30 },
+    '90 days': { days: 3 },
+  };
+</script>
+
+<Select
+  id={`${key}-time-range`}
+  name="Time Range"
+  value={toString($range)}
+  on:change={updateRange}
+>
+  <option value={null} />
+  {#each Object.keys(durations) as duration}
+    <option value={duration}>Last {duration}</option>
+  {/each}
+</Select>
diff --git a/src/lib/models/workflow-execution.ts b/src/lib/models/workflow-execution.ts
index f46a421f..2e79cb0b 100644
--- a/src/lib/models/workflow-execution.ts
+++ b/src/lib/models/workflow-execution.ts
@@ -19,7 +19,7 @@ export const toWorkflowExecution = (
 export const toWorkflowExecutions = (
   executions: WorkflowExecutionInfo[],
 ): WorkflowExecution[] => {
-  return executions.map(
+  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 59a63a47..7007e7e1 100644
--- a/src/lib/services/workflow-execution-service.ts
+++ b/src/lib/services/workflow-execution-service.ts
@@ -1,3 +1,5 @@
+import { sub, formatISO } from 'date-fns';
+
 import { paginated } from '$lib/utilities/paginated';
 import { toURL } from '$lib/utilities/to-url';
 import type {
@@ -25,6 +27,7 @@ export type GetPollersResponse = {
 
 type FetchWorkflows<T> = NamespaceScopedRequest & {
   onUpdate?: (results: Omit<T, 'nextPageToken'>) => void;
+  startTime?: Duration;
   request?: typeof fetch;
 };
 
@@ -38,11 +41,15 @@ const fetchWorkflows =
   async ({
     namespace,
     onUpdate = (x) => x,
+    startTime = { days: 1 },
     request = fetch,
   }: FetchWorkflows<ListWorkflowExecutionsResponse>) => {
     const { executions } = await paginated(async (token: string) => {
       const url = toURL(`${base}/namespaces/${namespace}/workflows/${type}`, {
         next_page_token: token,
+        'start_time_filter.earliest_time': formatISO(
+          sub(new Date(), startTime),
+        ),
       });
 
       const response = await request(url);
diff --git a/src/lib/stores/data.ts b/src/lib/stores/data.ts
deleted file mode 100644
index 2f626c4e..00000000
--- a/src/lib/stores/data.ts
+++ /dev/null
@@ -1,57 +0,0 @@
-import { derived, get, Readable, writable } from 'svelte/store';
-
-export type Slice<T> = {
-  isLoading: boolean;
-  isError: boolean;
-  data: T | null;
-};
-
-export type SliceStore<T> = Readable<Slice<T>>;
-
-export type SliceRequestConfiguration<T, U> = {
-  initialData?: U;
-  format?: (response: T) => U;
-};
-
-const store = writable({});
-
-const update =
-  <T>(key: string) =>
-  (data: Slice<T>) =>
-    store.update((s) => ({ ...s, [key]: { ...data } }));
-
-const newSlice = <T>(initialState?: T): Slice<T> => ({
-  isLoading: true,
-  isError: false,
-  data: initialState || null,
-});
-
-function request<T, U>(
-  key: string,
-  request: () => Promise<T>,
-  configuration: SliceRequestConfiguration<T, U> = {},
-): SliceStore<U> {
-  const { initialData, format = (i) => i } = configuration;
-  const currentState: Slice<U> = get(store)[key] || newSlice(initialData);
-  const sliceStore = derived(store, (s) => s[key]);
-  const updateKey = update(key);
-
-  updateKey({ ...currentState, isLoading: true });
-
-  request().then((data) => {
-    updateKey({
-      isLoading: false,
-      isError: false,
-      data: format(data),
-    });
-  });
-
-  return {
-    subscribe: sliceStore.subscribe,
-  };
-}
-
-export const query = {
-  subscribe: store.subscribe,
-  request,
-};
diff --git a/src/lib/stores/workflows.ts b/src/lib/stores/workflows.ts
index 17bdf626..87b64c17 100644
--- a/src/lib/stores/workflows.ts
+++ b/src/lib/stores/workflows.ts
@@ -1,4 +1,4 @@
-import { derived, writable, Writable } from 'svelte/store';
+import { derived, writable, Writable, get } from 'svelte/store';
 import type { ListWorkflowExecutionsResponse } from '$types';
 import { unique } from '$lib/utilities/unique';
 
@@ -8,6 +8,7 @@ import {
 } from '$lib/models/workflow-execution';
 
 import { fetchAllWorkflows } from '$lib/services/workflow-execution-service';
+import { createStoreWithCallback } from '$lib/utilities/create-store-with-callback';
 
 type WorkflowStore = {
   loading: boolean;
@@ -39,6 +40,16 @@ const updateWorkflows =
   };
 
 export const createStore = (namespace: string) => {
+  const update = () => {
+    const startTime = get(range);
+
+    fetchAllWorkflows({
+      namespace,
+      onUpdate: updateWorkflows(store),
+      startTime,
+    });
+  };
+
   const store = writable<WorkflowStore>(
     {
       loading: true,
@@ -47,21 +58,24 @@ export const createStore = (namespace: string) => {
       workflows: {},
     },
     () => {
-      fetchAllWorkflows({ namespace, onUpdate: updateWorkflows(store) });
+      let idleCallback: number;
+
+      update();
 
       const interval = setInterval(() => {
-        fetchAllWorkflows({ namespace, onUpdate: updateWorkflows(store) });
+        idleCallback = requestIdleCallback(update);
       }, 30000);
 
       return () => {
+        if (idleCallback) cancelIdleCallback(idleCallback);
         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 range = createStoreWithCallback<Duration>({ hours: 24 }, update);
 
   const status = writable<WorkflowStatus>(null);
   const workflowType = writable<WorkflowType>(null);
@@ -91,10 +105,10 @@ export const createStore = (namespace: string) => {
 
   return {
     ids,
-    get,
     all,
     filtered,
     workflowTypes,
+    range,
     filters: {
       status,
       workflowType,
diff --git a/src/lib/utilities/create-store-with-callback.ts b/src/lib/utilities/create-store-with-callback.ts
new file mode 100644
index 00000000..c7a82cfa
--- /dev/null
+++ b/src/lib/utilities/create-store-with-callback.ts
@@ -0,0 +1,24 @@
+import { Updater, Writable, writable } from 'svelte/store';
+
+const call = (fn: () => void) => {
+  if (fn && typeof fn === 'function') fn();
+};
+
+export const createStoreWithCallback = <T>(
+  value: T,
+  callback: () => void,
+): Writable<T> => {
+  const store = writable(value);
+
+  return {
+    ...store,
+    set: (value: T) => {
+      store.set(value);
+      call(callback);
+    },
+    update: (updater: Updater<T>) => {
+      store.update(updater);
+      call(callback);
+    },
+  };
+};
diff --git a/src/lib/utilities/to-duration.ts b/src/lib/utilities/to-duration.ts
new file mode 100644
index 00000000..d540fa96
--- /dev/null
+++ b/src/lib/utilities/to-duration.ts
@@ -0,0 +1,13 @@
+const durationPattern =
+  /(\d+)\s(years|months|weeks|days|hours|minutes|seconds)/;
+
+export const toDuration = (value: string): Duration => {
+  const [, amount, units] = value.match(durationPattern);
+  return { [units]: parseInt(amount, 10) };
+};
+
+export const toString = (duration: Duration) => {
+  const units = Object.keys(duration)[0];
+  const amount: number = duration[units];
+  return `${amount} ${units}`;
+};
diff --git a/src/routes/namespaces/[namespace]/workflows/_workflow-filters.svelte b/src/routes/namespaces/[namespace]/workflows/_workflow-filters.svelte
index ffbe2494..43aba9be 100644
--- a/src/routes/namespaces/[namespace]/workflows/_workflow-filters.svelte
+++ b/src/routes/namespaces/[namespace]/workflows/_workflow-filters.svelte
@@ -4,12 +4,14 @@
 
   import Select from '$lib/components/filter-select.svelte';
   import Input from '$lib/components/filter-input.svelte';
+  import TimeRangeSelect from '$lib/components/time-range-select.svelte';
 
   export let timeFormat: string = 'relative';
 
   $: store = createWorkflowStore($namespace);
   $: workflowTypes = store.workflowTypes;
   $: filters = store.filters;
+  $: range = store.range;
 
   $: status = filters.status;
   $: workflowType = filters.workflowType;
@@ -26,6 +28,21 @@
 </script>
 
 <section class="p-4 flex gap-8">
+  <TimeRangeSelect {range} key="workflows" />
+  <Select
+    id="filter-by-workflow-status"
+    name="Workflow Status"
+    bind:value={$status}
+  >
+    <option value={null} />
+    <option value="Running">Running</option>
+    <option value="TimedOut">Timed Out</option>
+    <option value="Completed">Completed</option>
+    <option value="Failed">Failed</option>
+    <option value="ContinuedAsNew">Continued as New</option>
+    <option value="Canceled">Canceled</option>
+    <option value="Terminated">Terminated</option>
+  </Select>
   <Select
     id="filter-by-workflow-status"
     name="Workflow Status"


Test patch:
diff --git a/src/lib/utilities/create-store-with-callback.test.ts b/src/lib/utilities/create-store-with-callback.test.ts
new file mode 100644
index 00000000..99efd323
--- /dev/null
+++ b/src/lib/utilities/create-store-with-callback.test.ts
@@ -0,0 +1,17 @@
+import { createStoreWithCallback } from './create-store-with-callback';
+
+describe(createStoreWithCallback, () => {
+  it('should call the callback when a new value is set', () => {
+    const fn = jest.fn();
+    const store = createStoreWithCallback(0, fn);
+    store.set(1);
+    expect(fn).toBeCalled();
+  });
+
+  it('should call the callback when value is updated', () => {
+    const fn = jest.fn();
+    const store = createStoreWithCallback(0, fn);
+    store.update((count) => count + 1);
+    expect(fn).toBeCalled();
+  });
+});
diff --git a/src/lib/utilities/to-duration.test.ts b/src/lib/utilities/to-duration.test.ts
new file mode 100644
index 00000000..d4fe39f4
--- /dev/null
+++ b/src/lib/utilities/to-duration.test.ts
@@ -0,0 +1,35 @@
+import { toDuration } from './to-duration';
+
+describe(toDuration, () => {
+  it('should correctly parse "Last 10 minutes"', () => {
+    expect(toDuration('Last 10 minutes')).toEqual({ minutes: 10 });
+  });
+
+  it('should correctly parse "Last 60 minutes"', () => {
+    expect(toDuration('Last 60 minutes')).toEqual({ minutes: 60 });
+  });
+
+  it('should correctly parse "Last 3 hours"', () => {
+    expect(toDuration('Last 3 hours')).toEqual({ hours: 3 });
+  });
+
+  it('should correctly parse "Last 24 hours"', () => {
+    expect(toDuration('Last 24 hours')).toEqual({ hours: 24 });
+  });
+
+  it('should correctly parse "Last 3 days"', () => {
+    expect(toDuration('Last 3 days')).toEqual({ days: 3 });
+  });
+
+  it('should correctly parse "Last 7 days"', () => {
+    expect(toDuration('Last 7 days')).toEqual({ days: 7 });
+  });
+
+  it('should correctly parse "Last 30 days"', () => {
+    expect(toDuration('Last 30 days')).toEqual({ days: 30 });
+  });
+
+  it('should correctly parse "Last 3 months"', () => {
+    expect(toDuration('Last 3 months')).toEqual({ months: 3 });
+  });
+});