Repository: temporalio/ui
Pull request #217 (2022-01-11 21:07:08)

Implement JSON download
Add ability to download event history as JSON.


Patch:
diff --git a/package-lock.json b/package-lock.json
index e3c99ba1..e047ffe2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,6 +11,7 @@
         "@fortawesome/free-solid-svg-icons": "^5.15.4",
         "@sveltejs/svelte-virtual-list": "^3.0.1",
         "date-fns": "^2.23.0",
+        "date-fns-tz": "^1.2.2",
         "json-beautify": "^1.1.1",
         "just-debounce": "^1.1.0",
         "sanitize-html": "^2.6.1",
@@ -51,7 +52,6 @@
         "svelte-check": "^2.2.5",
         "svelte-highlight": "^3.3.0",
         "svelte-preprocess": "^4.8.0",
-        "svelte-time": "^0.4.2",
         "tailwindcss": "^2.2.9",
         "ts-jest": "^27.0.5",
         "ts-node": "^10.2.1",
@@ -3356,6 +3356,14 @@
         "url": "https://opencollective.com/date-fns"
       }
     },
+    "node_modules/date-fns-tz": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-1.2.2.tgz",
+      "integrity": "sha512-vWtn44eEqnLbkACb7T5G5gPgKR4nY8NkNMOCyoY49NsRGHrcDmY2aysCyzDeA+u+vcDBn/w6nQqEDyouRs4m8w==",
+      "peerDependencies": {
+        "date-fns": ">=2.0.0"
+      }
+    },
     "node_modules/dayjs": {
       "version": "1.10.7",
       "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.10.7.tgz",
@@ -10618,15 +10626,6 @@
         }
       }
     },
-    "node_modules/svelte-time": {
-      "version": "0.4.2",
-      "resolved": "https://registry.npmjs.org/svelte-time/-/svelte-time-0.4.2.tgz",
-      "integrity": "sha512-PDpSP51SWPdvqP18edB+vYokKg6gY8c0q5ZVHQAUXohMTbeyJ5Np6oltNH22iE7KQJcTakCwj36bDYT2eBA8QA==",
-      "dev": true,
-      "dependencies": {
-        "dayjs": "^1.10.6"
-      }
-    },
     "node_modules/svgo": {
       "version": "2.7.0",
       "resolved": "https://registry.npmjs.org/svgo/-/svgo-2.7.0.tgz",
@@ -14336,6 +14335,12 @@
       "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.25.0.tgz",
       "integrity": "sha512-ovYRFnTrbGPD4nqaEqescPEv1mNwvt+UTqI3Ay9SzNtey9NZnYu6E2qCcBBgJ6/2VF1zGGygpyTDITqpQQ5e+w=="
     },
+    "date-fns-tz": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-1.2.2.tgz",
+      "integrity": "sha512-vWtn44eEqnLbkACb7T5G5gPgKR4nY8NkNMOCyoY49NsRGHrcDmY2aysCyzDeA+u+vcDBn/w6nQqEDyouRs4m8w==",
+      "requires": {}
+    },
     "dayjs": {
       "version": "1.10.7",
       "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.10.7.tgz",
@@ -19705,15 +19710,6 @@
         "strip-indent": "^3.0.0"
       }
     },
-    "svelte-time": {
-      "version": "0.4.2",
-      "resolved": "https://registry.npmjs.org/svelte-time/-/svelte-time-0.4.2.tgz",
-      "integrity": "sha512-PDpSP51SWPdvqP18edB+vYokKg6gY8c0q5ZVHQAUXohMTbeyJ5Np6oltNH22iE7KQJcTakCwj36bDYT2eBA8QA==",
-      "dev": true,
-      "requires": {
-        "dayjs": "^1.10.6"
-      }
-    },
     "svgo": {
       "version": "2.7.0",
       "resolved": "https://registry.npmjs.org/svgo/-/svgo-2.7.0.tgz",
diff --git a/package.json b/package.json
index dff00af4..4c618245 100644
--- a/package.json
+++ b/package.json
@@ -25,6 +25,7 @@
     "@fortawesome/free-solid-svg-icons": "^5.15.4",
     "@sveltejs/svelte-virtual-list": "^3.0.1",
     "date-fns": "^2.23.0",
+    "date-fns-tz": "^1.2.2",
     "json-beautify": "^1.1.1",
     "just-debounce": "^1.1.0",
     "sanitize-html": "^2.6.1",
@@ -65,7 +66,6 @@
     "svelte-check": "^2.2.5",
     "svelte-highlight": "^3.3.0",
     "svelte-preprocess": "^4.8.0",
-    "svelte-time": "^0.4.2",
     "tailwindcss": "^2.2.9",
     "ts-jest": "^27.0.5",
     "ts-node": "^10.2.1",
diff --git a/src/global.d.ts b/src/global.d.ts
index 357eef32..c54b7128 100644
--- a/src/global.d.ts
+++ b/src/global.d.ts
@@ -52,7 +52,7 @@ type WorkflowExecutionFilters = {
   status: WorkflowStatus;
 };
 
-type EventFormat = 'grid' | 'json';
+type TimeFormat = 'UTC' | 'relative' | 'local';
 
 type ActivityStatus =
   | 'Started'
diff --git a/src/lib/components/workers-row.svelte b/src/lib/components/workers-row.svelte
index 210f63d4..53944000 100644
--- a/src/lib/components/workers-row.svelte
+++ b/src/lib/components/workers-row.svelte
@@ -1,7 +1,7 @@
 <script lang="ts">
+  import { formatDate } from '$lib/utilities/format-date';
   import Icon from 'svelte-fa';
   import { faTimes, faCheck } from '@fortawesome/free-solid-svg-icons';
-  import Time from '$lib/components/workflow-time.svelte';
 
   export let poller;
 </script>
@@ -12,7 +12,7 @@
   </div>
   <div class="links w-3/12 text-left">
     <h3>
-      <Time time={poller.lastAccessTime} timeFormat={'current'} />
+      <p>{formatDate(poller.lastAccessTime)}</p>
     </h3>
   </div>
   <div class="w-3/12 text-left">
diff --git a/src/lib/components/workflow-time.svelte b/src/lib/components/workflow-time.svelte
deleted file mode 100644
index ae70c4ff..00000000
--- a/src/lib/components/workflow-time.svelte
+++ /dev/null
@@ -1,24 +0,0 @@
-<script lang="ts">
-  import Time from 'svelte-time';
-  export let time: string;
-  export let timeFormat: string;
-
-  $: utc = new Date(time).toUTCString();
-  $: relative = new Date(time).getTime();
-</script>
-
-{#if time === 'null'}
-  <p />
-{:else if time && timeFormat === 'relative'}
-  <Time live relative timestamp={relative} />
-{:else if time && timeFormat === 'current'}
-  <p>{time}</p>
-{:else if time && timeFormat === 'UTC'}
-  <p>{utc}</p>
-{/if}
-
-<style lang="postcss">
-  p {
-    @apply font-mono;
-  }
-</style>
diff --git a/src/lib/utilities/convert-to-json.ts b/src/lib/utilities/create-data-url.ts
similarity index 87%
rename from src/lib/utilities/convert-to-json.ts
rename to src/lib/utilities/create-data-url.ts
index db126028..b506a4b7 100644
--- a/src/lib/utilities/convert-to-json.ts
+++ b/src/lib/utilities/create-data-url.ts
@@ -1,4 +1,4 @@
-export const convertToJSON = (
+export const createDataUrl = (
   events: Parameters<typeof JSON.stringify>[0],
 ): string => {
   const data = JSON.stringify(events);
diff --git a/src/lib/utilities/format-date.ts b/src/lib/utilities/format-date.ts
index a0a25723..d51b7608 100644
--- a/src/lib/utilities/format-date.ts
+++ b/src/lib/utilities/format-date.ts
@@ -1,15 +1,28 @@
-import { format, parseJSON } from 'date-fns';
+import { formatDistanceToNow, parseJSON } from 'date-fns';
+import { format, formatInTimeZone } from 'date-fns-tz';
 
 import type { Timestamp } from '$types';
 
-export function formatDate(date: Date | Timestamp | string | null): string {
+type ValidTime = Parameters<typeof parseJSON>[0] | Timestamp;
+
+const pattern = 'H:mm:ss:SS a O ccc, LLL do';
+
+export function formatDate(
+  date: ValidTime,
+  timeFormat: TimeFormat = 'UTC',
+): string {
   if (!date) return '';
 
   if (isTimestamp(date)) {
     date = timestampToDate(date);
   }
 
-  return format(parseJSON(date), 'h:mm:ss a O, E, MMM dd, yyyy');
+  const parsed = parseJSON(date);
+
+  if (timeFormat === 'local') return format(parsed, pattern);
+  if (timeFormat === 'relative') return formatDistanceToNow(parsed) + ' ago';
+
+  return formatInTimeZone(parsed, 'UTC', pattern);
 }
 
 function timestampToDate(ts: Timestamp): Date {
@@ -18,6 +31,7 @@ function timestampToDate(ts: Timestamp): Date {
   }
 
   const d = new Date(null);
+
   d.setTime(Number(ts.seconds) * 1000 + ts.nanos / 1000);
 
   return d;
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 e8afa144..67a32f1e 100644
--- a/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/history/__layout.svelte
+++ b/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/history/__layout.svelte
@@ -26,12 +26,13 @@
     faStream,
   } from '@fortawesome/free-solid-svg-icons';
 
+  import { routeFor } from '$lib/utilities/route-for';
+  import { createDataUrl } from '$lib/utilities/create-data-url';
+  import { fetchEvents } from '$lib/services/events-service';
   import { refreshable } from '$lib/stores/refreshable';
 
   import ToggleButton from '$lib/components/toggle-button.svelte';
   import ToggleButtons from '$lib/components/toggle-buttons.svelte';
-  import { routeFor } from '$lib/utilities/route-for';
-  import { fetchEvents } from '$lib/services/events-service';
 
   export let workflow: WorkflowParameters;
   export let parameters: Parameters<typeof fetchEvents>[0];
@@ -58,7 +59,7 @@
           href={routeFor('workflow.events.json', workflow)}
         />
       </ToggleButtons>
-      <ToggleButton icon={faDownload} href="#no-implemented" />
+      <ToggleButton icon={faDownload} href={createDataUrl(events)} />
     </div>
   </nav>
 </section>
diff --git a/src/routes/namespaces/[namespace]/workflows/_workflow-filters.svelte b/src/routes/namespaces/[namespace]/workflows/_workflow-filters.svelte
index e959582f..d7aa588c 100644
--- a/src/routes/namespaces/[namespace]/workflows/_workflow-filters.svelte
+++ b/src/routes/namespaces/[namespace]/workflows/_workflow-filters.svelte
@@ -4,7 +4,7 @@
   import Option from '$lib/components/select/option.svelte';
   import FilterInput from '$lib/components/filter-input.svelte';
 
-  export let timeFormat: string = 'relative';
+  export let timeFormat: TimeFormat = 'UTC';
 
   let workflowIdFilter = '';
   let workflowTypeFilter = '';
@@ -56,6 +56,6 @@
   <Select id="filter-by-relative-time" bind:value={timeFormat}>
     <Option value={'relative'}>Relative</Option>
     <Option value={'UTC'}>UTC</Option>
-    <Option value={'current'}>Current</Option>
+    <Option value={'local'}>Local</Option>
   </Select>
 </div>
diff --git a/src/routes/namespaces/[namespace]/workflows/_workflows-summary-row.svelte b/src/routes/namespaces/[namespace]/workflows/_workflows-summary-row.svelte
index 33110a03..05858eac 100644
--- a/src/routes/namespaces/[namespace]/workflows/_workflows-summary-row.svelte
+++ b/src/routes/namespaces/[namespace]/workflows/_workflows-summary-row.svelte
@@ -1,13 +1,14 @@
 <script lang="ts">
-  import type { WorkflowExecution } from '$lib/models/workflow-execution';
+  import { formatDate } from '$lib/utilities/format-date';
   import { namespace } from '$lib/stores/namespace';
 
-  import Time from '$lib/components/workflow-time.svelte';
+  import type { WorkflowExecution } from '$lib/models/workflow-execution';
+
   import WorkflowStatus from '$lib/components/workflow-status.svelte';
   import { routeFor } from '$lib/utilities/route-for';
 
   export let workflow: WorkflowExecution;
-  export let timeFormat: string;
+  export let timeFormat: TimeFormat;
 
   $: href = routeFor('workflow', {
     namespace: $namespace,
@@ -17,7 +18,7 @@
 </script>
 
 <a sveltekit:noscroll {href}>
-  <article class="row flex flex-row border-b-2">
+  <article class="row flex flex-row border-b-2 items-center">
     <div class="links w-3/12 text-left">
       {workflow.id}
     </div>
@@ -32,10 +33,14 @@
       </div>
     </div>
     <div class="w-2/12 text-left">
-      <Time time={workflow.startTime} {timeFormat} />
+      <p class="text-xs">
+        {formatDate(workflow.startTime, timeFormat)}
+      </p>
     </div>
     <div class="w-2/12 text-left">
-      <Time time={workflow.endTime} {timeFormat} />
+      <p class="text-xs">
+        {formatDate(workflow.endTime, timeFormat)}
+      </p>
     </div>
   </article>
 </a>
diff --git a/src/routes/namespaces/[namespace]/workflows/index.svelte b/src/routes/namespaces/[namespace]/workflows/index.svelte
index 1eb4ed64..c46a3f16 100644
--- a/src/routes/namespaces/[namespace]/workflows/index.svelte
+++ b/src/routes/namespaces/[namespace]/workflows/index.svelte
@@ -40,7 +40,8 @@
 
   $: timeRange = $page.query.get('time-range');
 
-  let timeFormat = 'relative';
+  let timeFormat: TimeFormat = 'UTC';
+
   let data = refreshable(
     () => fetchAllWorkflows(namespace, { timeRange }),
     initialData,


Test patch:
diff --git a/src/lib/utilities/convert-to-json.test.ts b/src/lib/utilities/create-data-url.test.ts
similarity index 60%
rename from src/lib/utilities/convert-to-json.test.ts
rename to src/lib/utilities/create-data-url.test.ts
index f8f42135..52b17437 100644
--- a/src/lib/utilities/convert-to-json.test.ts
+++ b/src/lib/utilities/create-data-url.test.ts
@@ -1,9 +1,9 @@
-import { convertToJSON } from './convert-to-json';
+import { createDataUrl } from './create-data-url';
 
-describe(convertToJSON, () => {
+describe(createDataUrl, () => {
   it('should return a data-encoded URI based on an object', () => {
     const example = { hello: 123 };
-    expect(convertToJSON(example)).toBe(
+    expect(createDataUrl(example)).toBe(
       'data:application/json;charset=utf-8,%7B%22hello%22%3A123%7D',
     );
   });