From 285ab1410cd45464c6058d4c9c38b9c6b7293286 Mon Sep 17 00:00:00 2001 From: Dan Philibin Date: Mon, 25 Jul 2022 19:35:00 -0700 Subject: [PATCH 01/17] add support for custom upload URLs --- envoy.config.ts | 8 +++++++- package.json | 2 ++ src/components/upload.ts | 23 +++++++++++++++++++++-- src/examples/basic/index.ts | 17 ++++++++++++++--- src/examples/utils/upload.ts | 33 +++++++++++++++++++++++++++++++++ src/ioSchema.ts | 8 +++++++- 6 files changed, 84 insertions(+), 7 deletions(-) create mode 100644 src/examples/utils/upload.ts diff --git a/envoy.config.ts b/envoy.config.ts index 0eacba5..fb96fd4 100644 --- a/envoy.config.ts +++ b/envoy.config.ts @@ -1,5 +1,11 @@ import { EnvoyVariableSpec } from '@interval/envoy' -const vars: EnvoyVariableSpec[] = ['DEMO_API_KEY'] +const vars: EnvoyVariableSpec[] = [ + 'DEMO_API_KEY', + { name: 'AWS_KEY_ID', isRequired: false }, + { name: 'AWS_KEY_SECRET', isRequired: false }, + { name: 'AWS_S3_IO_BUCKET', isRequired: false }, + { name: 'AWS_REGION', isRequired: false }, +] export default vars diff --git a/package.json b/package.json index 30b5a8b..5614bfc 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,8 @@ "zod": "^3.13.3" }, "devDependencies": { + "@aws-sdk/client-s3": "^3.135.0", + "@aws-sdk/s3-request-presigner": "^3.135.0", "@faker-js/faker": "^7.3.0", "@interval/envoy": "^1.0.1", "@types/dedent": "^0.7.0", diff --git a/src/components/upload.ts b/src/components/upload.ts index f182740..702c2b2 100644 --- a/src/components/upload.ts +++ b/src/components/upload.ts @@ -1,10 +1,20 @@ import path from 'path' import fetch, { Response } from 'node-fetch' import { IntervalError } from '..' -import { T_IO_PROPS, T_IO_RETURNS } from '../ioSchema' +import { T_IO_PROPS, T_IO_RETURNS, T_IO_STATE } from '../ioSchema' + +export function file({ + generatePreSignedUrl, + ...props +}: Omit, 'isProvidingUrls'> & { + generatePreSignedUrl?: ( + state: T_IO_STATE<'UPLOAD_FILE'> + ) => Promise<{ uploadUrl: string; downloadUrl: string }> +}) { + const isProvidingUrls = !!generatePreSignedUrl -export function file(_: T_IO_PROPS<'UPLOAD_FILE'>) { return { + props: { ...props, isProvidingUrls }, getValue({ url, ...response }: T_IO_RETURNS<'UPLOAD_FILE'>) { return { ...response, @@ -28,6 +38,15 @@ export function file(_: T_IO_PROPS<'UPLOAD_FILE'>) { }, } }, + async onStateChange(newState: T_IO_STATE<'UPLOAD_FILE'>) { + if (!generatePreSignedUrl) { + return { isProvidingUrls } + } + + const urls = await generatePreSignedUrl(newState) + + return { ...urls, isProvidingUrls } + }, } } diff --git a/src/examples/basic/index.ts b/src/examples/basic/index.ts index c2a3493..5592ed9 100644 --- a/src/examples/basic/index.ts +++ b/src/examples/basic/index.ts @@ -12,6 +12,7 @@ import { } from './selectFromTable' import unauthorized from './unauthorized' import './ghostHost' +import { generateDownloadUrl, generateUploadUrl } from '../utils/upload' const actionLinks: IntervalActionHandler = async () => { await io.group([ @@ -676,10 +677,20 @@ const interval = new Interval({ return { message: 'OK, notified!' } }, - upload: async io => { + upload: async (io, ctx) => { const file = await io.experimental.input.file('Upload an image!', { - helpText: 'Can be any image, or a CSV (?).', - allowedExtensions: ['.gif', '.jpg', '.jpeg', 'csv'], + helpText: 'Can be any image.', + allowedExtensions: ['.gif', '.jpg', '.jpeg', '.png'], + generatePreSignedUrl: async ({ name }) => { + const path = encodeURIComponent( + `custom-url/${ctx.action.slug}/${name}` + ) + + const uploadUrl = await generateUploadUrl(path) + const downloadUrl = generateDownloadUrl(path) + + return { uploadUrl, downloadUrl } + }, }) console.log(file) diff --git a/src/examples/utils/upload.ts b/src/examples/utils/upload.ts new file mode 100644 index 0000000..8861d94 --- /dev/null +++ b/src/examples/utils/upload.ts @@ -0,0 +1,33 @@ +import { + AWS_REGION, + AWS_KEY_ID, + AWS_KEY_SECRET, + AWS_S3_IO_BUCKET, +} from '../../env' +import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3' +import { getSignedUrl } from '@aws-sdk/s3-request-presigner' + +export const s3Client = new S3Client({ + region: AWS_REGION ?? 'us-west-1', + credentials: { + accessKeyId: AWS_KEY_ID, + secretAccessKey: AWS_KEY_SECRET, + }, +}) + +export async function generateUploadUrl(key: string) { + const command = new PutObjectCommand({ + Bucket: AWS_S3_IO_BUCKET, + Key: key, + }) + + const signedUrl = await getSignedUrl(s3Client, command, { + expiresIn: 3600, // 1 hour + }) + + return signedUrl +} + +export function generateDownloadUrl(path: string) { + return `https://${AWS_S3_IO_BUCKET}.s3.${AWS_REGION}.amazonaws.com/${path}` +} diff --git a/src/ioSchema.ts b/src/ioSchema.ts index 12fb362..3801de9 100644 --- a/src/ioSchema.ts +++ b/src/ioSchema.ts @@ -337,8 +337,14 @@ export const ioSchema = { props: z.object({ helpText: z.string().optional(), allowedExtensions: z.array(z.string()).optional(), + uploadUrl: z.string().nullish(), + downloadUrl: z.string().nullish(), + isProvidingUrls: z.boolean(), + }), + state: z.object({ + name: z.string(), + type: z.string(), }), - state: z.null(), returns: z.object({ name: z.string(), type: z.string(), From b032b566d402b506e0b32cb95ce5460f94350171 Mon Sep 17 00:00:00 2001 From: Dan Philibin Date: Mon, 25 Jul 2022 19:45:07 -0700 Subject: [PATCH 02/17] clean up type def --- src/components/upload.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/components/upload.ts b/src/components/upload.ts index 702c2b2..06c9e1c 100644 --- a/src/components/upload.ts +++ b/src/components/upload.ts @@ -3,14 +3,13 @@ import fetch, { Response } from 'node-fetch' import { IntervalError } from '..' import { T_IO_PROPS, T_IO_RETURNS, T_IO_STATE } from '../ioSchema' -export function file({ - generatePreSignedUrl, - ...props -}: Omit, 'isProvidingUrls'> & { +type UploaderProps = Omit, 'isProvidingUrls'> & { generatePreSignedUrl?: ( state: T_IO_STATE<'UPLOAD_FILE'> ) => Promise<{ uploadUrl: string; downloadUrl: string }> -}) { +} + +export function file({ generatePreSignedUrl, ...props }: UploaderProps) { const isProvidingUrls = !!generatePreSignedUrl return { From b99c01301cb0c0cc3dc00dd351bda66b2c7b9f79 Mon Sep 17 00:00:00 2001 From: Dan Philibin Date: Mon, 25 Jul 2022 20:18:36 -0700 Subject: [PATCH 03/17] remove isProvidingUrls prop we need to know whether to expect host-generated URLs when the component mounts, but we can infer that from `uploadUrl` and `downloadUrl` being null or undefined. --- src/components/upload.ts | 12 ++++++++---- src/examples/basic/index.ts | 30 ++++++++++++++++++++---------- src/ioSchema.ts | 5 ++--- 3 files changed, 30 insertions(+), 17 deletions(-) diff --git a/src/components/upload.ts b/src/components/upload.ts index 06c9e1c..ddc108b 100644 --- a/src/components/upload.ts +++ b/src/components/upload.ts @@ -3,7 +3,7 @@ import fetch, { Response } from 'node-fetch' import { IntervalError } from '..' import { T_IO_PROPS, T_IO_RETURNS, T_IO_STATE } from '../ioSchema' -type UploaderProps = Omit, 'isProvidingUrls'> & { +type UploaderProps = T_IO_PROPS<'UPLOAD_FILE'> & { generatePreSignedUrl?: ( state: T_IO_STATE<'UPLOAD_FILE'> ) => Promise<{ uploadUrl: string; downloadUrl: string }> @@ -13,7 +13,11 @@ export function file({ generatePreSignedUrl, ...props }: UploaderProps) { const isProvidingUrls = !!generatePreSignedUrl return { - props: { ...props, isProvidingUrls }, + props: { + ...props, + uploadUrl: isProvidingUrls ? null : undefined, + downloadUrl: isProvidingUrls ? null : undefined, + }, getValue({ url, ...response }: T_IO_RETURNS<'UPLOAD_FILE'>) { return { ...response, @@ -39,12 +43,12 @@ export function file({ generatePreSignedUrl, ...props }: UploaderProps) { }, async onStateChange(newState: T_IO_STATE<'UPLOAD_FILE'>) { if (!generatePreSignedUrl) { - return { isProvidingUrls } + return { uploadUrl: undefined, downloadUrl: undefined } } const urls = await generatePreSignedUrl(newState) - return { ...urls, isProvidingUrls } + return urls }, } } diff --git a/src/examples/basic/index.ts b/src/examples/basic/index.ts index 5592ed9..02a8f98 100644 --- a/src/examples/basic/index.ts +++ b/src/examples/basic/index.ts @@ -678,19 +678,29 @@ const interval = new Interval({ return { message: 'OK, notified!' } }, upload: async (io, ctx) => { + const customDestinationFile = await io.experimental.input.file( + 'Upload an image!', + { + helpText: 'Can be any image.', + allowedExtensions: ['.gif', '.jpg', '.jpeg', '.png'], + generatePreSignedUrl: async ({ name }) => { + const path = encodeURIComponent( + `custom-url/${ctx.action.slug}/${name}` + ) + + const uploadUrl = await generateUploadUrl(path) + const downloadUrl = generateDownloadUrl(path) + + return { uploadUrl, downloadUrl } + }, + } + ) + + console.log(await customDestinationFile.url()) + const file = await io.experimental.input.file('Upload an image!', { helpText: 'Can be any image.', allowedExtensions: ['.gif', '.jpg', '.jpeg', '.png'], - generatePreSignedUrl: async ({ name }) => { - const path = encodeURIComponent( - `custom-url/${ctx.action.slug}/${name}` - ) - - const uploadUrl = await generateUploadUrl(path) - const downloadUrl = generateDownloadUrl(path) - - return { uploadUrl, downloadUrl } - }, }) console.log(file) diff --git a/src/ioSchema.ts b/src/ioSchema.ts index 3801de9..2454c70 100644 --- a/src/ioSchema.ts +++ b/src/ioSchema.ts @@ -337,9 +337,8 @@ export const ioSchema = { props: z.object({ helpText: z.string().optional(), allowedExtensions: z.array(z.string()).optional(), - uploadUrl: z.string().nullish(), - downloadUrl: z.string().nullish(), - isProvidingUrls: z.boolean(), + uploadUrl: z.string().nullish().optional(), + downloadUrl: z.string().nullish().optional(), }), state: z.object({ name: z.string(), From 1e64859565b5f076611015ef1d732247b3b5ba98 Mon Sep 17 00:00:00 2001 From: Dan Philibin Date: Mon, 25 Jul 2022 21:44:17 -0700 Subject: [PATCH 04/17] fix SDK build without AWS credentials --- src/examples/utils/upload.ts | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/src/examples/utils/upload.ts b/src/examples/utils/upload.ts index 8861d94..8dfed74 100644 --- a/src/examples/utils/upload.ts +++ b/src/examples/utils/upload.ts @@ -7,15 +7,19 @@ import { import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3' import { getSignedUrl } from '@aws-sdk/s3-request-presigner' -export const s3Client = new S3Client({ - region: AWS_REGION ?? 'us-west-1', - credentials: { - accessKeyId: AWS_KEY_ID, - secretAccessKey: AWS_KEY_SECRET, - }, -}) - export async function generateUploadUrl(key: string) { + if (!AWS_KEY_ID || !AWS_KEY_SECRET || !AWS_S3_IO_BUCKET) { + throw new Error('Missing AWS credentials') + } + + const s3Client = new S3Client({ + region: AWS_REGION ?? 'us-west-1', + credentials: { + accessKeyId: AWS_KEY_ID, + secretAccessKey: AWS_KEY_SECRET, + }, + }) + const command = new PutObjectCommand({ Bucket: AWS_S3_IO_BUCKET, Key: key, @@ -29,5 +33,9 @@ export async function generateUploadUrl(key: string) { } export function generateDownloadUrl(path: string) { + if (!AWS_S3_IO_BUCKET || !AWS_REGION) { + throw new Error('Missing AWS credentials') + } + return `https://${AWS_S3_IO_BUCKET}.s3.${AWS_REGION}.amazonaws.com/${path}` } From d1f1b3c05de44085dca3a805d4b0ba5f74bff5d9 Mon Sep 17 00:00:00 2001 From: Dan Philibin Date: Mon, 25 Jul 2022 22:38:57 -0700 Subject: [PATCH 05/17] fix header column row height in vertical tables --- src/examples/basic/selectFromTable.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/examples/basic/selectFromTable.ts b/src/examples/basic/selectFromTable.ts index 14306d7..f2e56d9 100644 --- a/src/examples/basic/selectFromTable.ts +++ b/src/examples/basic/selectFromTable.ts @@ -47,17 +47,20 @@ function formatCurrency(amount: number) { export const table_basic: IntervalActionHandler = async io => { const simpleCharges = charges.map(ch => ({ name: ch.name, + longText: faker.lorem.paragraph(), email: faker.internet.email(), + 'short text longer label': faker.lorem.word(), amount: ch.amount, })) const [name, phone, selections] = await io.group([ io.input.text('Full name'), io.input.text('Phone number'), - io.select.table('Select a person', { + io.display.table('Select a person', { data: [...simpleCharges, ...simpleCharges], - minSelections: 1, - maxSelections: 3, + orientation: 'vertical', + // minSelections: 1, + // maxSelections: 3, }), ]) From e00b6c50ab635bdf7e3bfa323f2e8ab84f2186fe Mon Sep 17 00:00:00 2001 From: Dan Philibin Date: Mon, 25 Jul 2022 22:41:07 -0700 Subject: [PATCH 06/17] undo examples change --- src/examples/basic/selectFromTable.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/examples/basic/selectFromTable.ts b/src/examples/basic/selectFromTable.ts index f2e56d9..14306d7 100644 --- a/src/examples/basic/selectFromTable.ts +++ b/src/examples/basic/selectFromTable.ts @@ -47,20 +47,17 @@ function formatCurrency(amount: number) { export const table_basic: IntervalActionHandler = async io => { const simpleCharges = charges.map(ch => ({ name: ch.name, - longText: faker.lorem.paragraph(), email: faker.internet.email(), - 'short text longer label': faker.lorem.word(), amount: ch.amount, })) const [name, phone, selections] = await io.group([ io.input.text('Full name'), io.input.text('Phone number'), - io.display.table('Select a person', { + io.select.table('Select a person', { data: [...simpleCharges, ...simpleCharges], - orientation: 'vertical', - // minSelections: 1, - // maxSelections: 3, + minSelections: 1, + maxSelections: 3, }), ]) From ed4233531c8a1cde21887fb5a513d84f75e52437 Mon Sep 17 00:00:00 2001 From: Dan Philibin Date: Tue, 26 Jul 2022 14:51:55 -0700 Subject: [PATCH 07/17] rename to generatePresignedUrl --- src/components/upload.ts | 10 +++++----- src/examples/basic/index.ts | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/components/upload.ts b/src/components/upload.ts index ddc108b..f621180 100644 --- a/src/components/upload.ts +++ b/src/components/upload.ts @@ -4,13 +4,13 @@ import { IntervalError } from '..' import { T_IO_PROPS, T_IO_RETURNS, T_IO_STATE } from '../ioSchema' type UploaderProps = T_IO_PROPS<'UPLOAD_FILE'> & { - generatePreSignedUrl?: ( + generatePresignedUrl?: ( state: T_IO_STATE<'UPLOAD_FILE'> ) => Promise<{ uploadUrl: string; downloadUrl: string }> } -export function file({ generatePreSignedUrl, ...props }: UploaderProps) { - const isProvidingUrls = !!generatePreSignedUrl +export function file({ generatePresignedUrl, ...props }: UploaderProps) { + const isProvidingUrls = !!generatePresignedUrl return { props: { @@ -42,11 +42,11 @@ export function file({ generatePreSignedUrl, ...props }: UploaderProps) { } }, async onStateChange(newState: T_IO_STATE<'UPLOAD_FILE'>) { - if (!generatePreSignedUrl) { + if (!generatePresignedUrl) { return { uploadUrl: undefined, downloadUrl: undefined } } - const urls = await generatePreSignedUrl(newState) + const urls = await generatePresignedUrl(newState) return urls }, diff --git a/src/examples/basic/index.ts b/src/examples/basic/index.ts index 02a8f98..f4e49a9 100644 --- a/src/examples/basic/index.ts +++ b/src/examples/basic/index.ts @@ -683,10 +683,10 @@ const interval = new Interval({ { helpText: 'Can be any image.', allowedExtensions: ['.gif', '.jpg', '.jpeg', '.png'], - generatePreSignedUrl: async ({ name }) => { const path = encodeURIComponent( `custom-url/${ctx.action.slug}/${name}` ) + generatePresignedUrl: async ({ name }) => { const uploadUrl = await generateUploadUrl(path) const downloadUrl = generateDownloadUrl(path) From c274ffbf7f0d443756bb8982f438cafe699bc70f Mon Sep 17 00:00:00 2001 From: Dan Philibin Date: Tue, 26 Jul 2022 14:52:10 -0700 Subject: [PATCH 08/17] add test for custom upload endpoints --- src/examples/basic/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/examples/basic/index.ts b/src/examples/basic/index.ts index f4e49a9..4e3fc8f 100644 --- a/src/examples/basic/index.ts +++ b/src/examples/basic/index.ts @@ -683,10 +683,10 @@ const interval = new Interval({ { helpText: 'Can be any image.', allowedExtensions: ['.gif', '.jpg', '.jpeg', '.png'], - const path = encodeURIComponent( - `custom-url/${ctx.action.slug}/${name}` - ) generatePresignedUrl: async ({ name }) => { + const path = `custom-endpoint/${ + ctx.action.slug + }/${new Date().getTime()}-${encodeURIComponent(name)}` const uploadUrl = await generateUploadUrl(path) const downloadUrl = generateDownloadUrl(path) From c7b669eea32fcb06c6ccc5cfe40f29d343b4168b Mon Sep 17 00:00:00 2001 From: Dan Philibin Date: Tue, 26 Jul 2022 17:04:17 -0700 Subject: [PATCH 09/17] clear custom URLs when selecting a new file --- src/components/upload.ts | 9 ++++++--- src/examples/basic/index.ts | 7 ++++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/components/upload.ts b/src/components/upload.ts index f621180..cf25434 100644 --- a/src/components/upload.ts +++ b/src/components/upload.ts @@ -46,9 +46,12 @@ export function file({ generatePresignedUrl, ...props }: UploaderProps) { return { uploadUrl: undefined, downloadUrl: undefined } } - const urls = await generatePresignedUrl(newState) - - return urls + try { + const urls = await generatePresignedUrl(newState) + return urls + } catch (error) { + return { uploadUrl: 'error', downloadUrl: 'error' } + } }, } } diff --git a/src/examples/basic/index.ts b/src/examples/basic/index.ts index 4e3fc8f..ba49a51 100644 --- a/src/examples/basic/index.ts +++ b/src/examples/basic/index.ts @@ -684,9 +684,10 @@ const interval = new Interval({ helpText: 'Can be any image.', allowedExtensions: ['.gif', '.jpg', '.jpeg', '.png'], generatePresignedUrl: async ({ name }) => { - const path = `custom-endpoint/${ - ctx.action.slug - }/${new Date().getTime()}-${encodeURIComponent(name)}` + // TODO: S3 double-encodes the filename, converting % into %25. the resulting URL doesn't work + const path = `custom-endpoint/${new Date().getTime()}-${encodeURIComponent( + name + )}` const uploadUrl = await generateUploadUrl(path) const downloadUrl = generateDownloadUrl(path) From 2844894fd04674c0f006511ae765501160d5e9d9 Mon Sep 17 00:00:00 2001 From: Dan Philibin Date: Wed, 27 Jul 2022 13:08:07 -0700 Subject: [PATCH 10/17] use single function for generating upload/download --- src/components/upload.ts | 10 +++++----- src/examples/basic/index.ts | 20 ++++++++------------ src/examples/utils/upload.ts | 22 +++++++++++----------- 3 files changed, 24 insertions(+), 28 deletions(-) diff --git a/src/components/upload.ts b/src/components/upload.ts index cf25434..5ad4c63 100644 --- a/src/components/upload.ts +++ b/src/components/upload.ts @@ -4,13 +4,13 @@ import { IntervalError } from '..' import { T_IO_PROPS, T_IO_RETURNS, T_IO_STATE } from '../ioSchema' type UploaderProps = T_IO_PROPS<'UPLOAD_FILE'> & { - generatePresignedUrl?: ( + generatePresignedUrls?: ( state: T_IO_STATE<'UPLOAD_FILE'> ) => Promise<{ uploadUrl: string; downloadUrl: string }> } -export function file({ generatePresignedUrl, ...props }: UploaderProps) { - const isProvidingUrls = !!generatePresignedUrl +export function file({ generatePresignedUrls, ...props }: UploaderProps) { + const isProvidingUrls = !!generatePresignedUrls return { props: { @@ -42,12 +42,12 @@ export function file({ generatePresignedUrl, ...props }: UploaderProps) { } }, async onStateChange(newState: T_IO_STATE<'UPLOAD_FILE'>) { - if (!generatePresignedUrl) { + if (!generatePresignedUrls) { return { uploadUrl: undefined, downloadUrl: undefined } } try { - const urls = await generatePresignedUrl(newState) + const urls = await generatePresignedUrls(newState) return urls } catch (error) { return { uploadUrl: 'error', downloadUrl: 'error' } diff --git a/src/examples/basic/index.ts b/src/examples/basic/index.ts index ba49a51..7b32c35 100644 --- a/src/examples/basic/index.ts +++ b/src/examples/basic/index.ts @@ -12,7 +12,7 @@ import { } from './selectFromTable' import unauthorized from './unauthorized' import './ghostHost' -import { generateDownloadUrl, generateUploadUrl } from '../utils/upload' +import { generateS3Urls } from '../utils/upload' const actionLinks: IntervalActionHandler = async () => { await io.group([ @@ -681,18 +681,13 @@ const interval = new Interval({ const customDestinationFile = await io.experimental.input.file( 'Upload an image!', { - helpText: 'Can be any image.', + helpText: 'Will be uploaded to the custom destination.', allowedExtensions: ['.gif', '.jpg', '.jpeg', '.png'], - generatePresignedUrl: async ({ name }) => { - // TODO: S3 double-encodes the filename, converting % into %25. the resulting URL doesn't work - const path = `custom-endpoint/${new Date().getTime()}-${encodeURIComponent( - name - )}` + generatePresignedUrls: async ({ name }) => { + const urlSafeName = name.replace(/ /g, '-') + const path = `custom-endpoint/${new Date().getTime()}-${urlSafeName}` - const uploadUrl = await generateUploadUrl(path) - const downloadUrl = generateDownloadUrl(path) - - return { uploadUrl, downloadUrl } + return generateS3Urls(path) }, } ) @@ -700,7 +695,8 @@ const interval = new Interval({ console.log(await customDestinationFile.url()) const file = await io.experimental.input.file('Upload an image!', { - helpText: 'Can be any image.', + helpText: + 'Will be uploaded to Interval and expire after the action finishes running.', allowedExtensions: ['.gif', '.jpg', '.jpeg', '.png'], }) diff --git a/src/examples/utils/upload.ts b/src/examples/utils/upload.ts index 8dfed74..9372934 100644 --- a/src/examples/utils/upload.ts +++ b/src/examples/utils/upload.ts @@ -4,10 +4,15 @@ import { AWS_KEY_SECRET, AWS_S3_IO_BUCKET, } from '../../env' -import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3' +import { + GetObjectCommand, + PutObjectCommand, + S3Client, +} from '@aws-sdk/client-s3' import { getSignedUrl } from '@aws-sdk/s3-request-presigner' +import * as urlParser from '@aws-sdk/url-parser' -export async function generateUploadUrl(key: string) { +export async function generateS3Urls(key: string) { if (!AWS_KEY_ID || !AWS_KEY_SECRET || !AWS_S3_IO_BUCKET) { throw new Error('Missing AWS credentials') } @@ -25,17 +30,12 @@ export async function generateUploadUrl(key: string) { Key: key, }) - const signedUrl = await getSignedUrl(s3Client, command, { + const uploadUrl = await getSignedUrl(s3Client, command, { expiresIn: 3600, // 1 hour }) - return signedUrl -} - -export function generateDownloadUrl(path: string) { - if (!AWS_S3_IO_BUCKET || !AWS_REGION) { - throw new Error('Missing AWS credentials') - } + const url = new URL(uploadUrl) + const downloadUrl = url.origin + url.pathname - return `https://${AWS_S3_IO_BUCKET}.s3.${AWS_REGION}.amazonaws.com/${path}` + return { uploadUrl, downloadUrl } } From 9312b90896ad7eb49102fdc43294a16cd17e115c Mon Sep 17 00:00:00 2001 From: Dan Philibin Date: Wed, 27 Jul 2022 16:09:56 -0700 Subject: [PATCH 11/17] promote date components out of experimental namespace --- src/classes/IOClient.ts | 10 +++++----- src/examples/basic/index.ts | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/classes/IOClient.ts b/src/classes/IOClient.ts index e1a0452..e8e4fd9 100644 --- a/src/classes/IOClient.ts +++ b/src/classes/IOClient.ts @@ -417,6 +417,11 @@ export class IOClient { number: this.createIOMethod('INPUT_NUMBER'), email: this.createIOMethod('INPUT_EMAIL'), richText: this.createIOMethod('INPUT_RICH_TEXT'), + date: this.createIOMethod('INPUT_DATE', { componentDef: date }), + time: this.createIOMethod('INPUT_TIME'), + datetime: this.createIOMethod('INPUT_DATETIME', { + componentDef: datetime, + }), }, select: { single: this.createIOMethod('SELECT_SINGLE', { @@ -449,11 +454,6 @@ export class IOClient { propsRequired: true, componentDef: spreadsheet, }), - date: this.createIOMethod('INPUT_DATE', { componentDef: date }), - time: this.createIOMethod('INPUT_TIME'), - datetime: this.createIOMethod('INPUT_DATETIME', { - componentDef: datetime, - }), input: { file: this.createIOMethod('UPLOAD_FILE', { componentDef: file }), }, diff --git a/src/examples/basic/index.ts b/src/examples/basic/index.ts index c2a3493..b71f810 100644 --- a/src/examples/basic/index.ts +++ b/src/examples/basic/index.ts @@ -312,7 +312,7 @@ const interval = new Interval({ }, dates: async io => { const [date, time, datetime] = await io.group([ - io.experimental.date('Enter a date', { + io.input.date('Enter a date', { min: { year: 2020, month: 1, @@ -324,7 +324,7 @@ const interval = new Interval({ day: 30, }, }), - io.experimental.time('Enter a time', { + io.input.time('Enter a time', { min: { hour: 8, minute: 30, @@ -334,7 +334,7 @@ const interval = new Interval({ minute: 0, }, }), - io.experimental.datetime('Enter a datetime', { + io.input.datetime('Enter a datetime', { defaultValue: new Date(), min: new Date(), }), From fbc895d280b233536118ec90aee976d24887bc1c Mon Sep 17 00:00:00 2001 From: Ryan Coppolo Date: Thu, 28 Jul 2022 11:01:15 -0400 Subject: [PATCH 12/17] One component working --- src/classes/IOComponent.ts | 5 +++ src/classes/IOPromise.ts | 77 +++++++++++++++++++++++++++++++++++++ src/examples/basic/index.ts | 4 ++ src/ioSchema.ts | 1 + 4 files changed, 87 insertions(+) diff --git a/src/classes/IOComponent.ts b/src/classes/IOComponent.ts index 420533e..2d4e19c 100644 --- a/src/classes/IOComponent.ts +++ b/src/classes/IOComponent.ts @@ -16,6 +16,7 @@ export interface ComponentInstance { state: z.infer isStateful?: boolean isOptional?: boolean + isDisabled?: boolean validationErrorMessage?: string | undefined } @@ -74,6 +75,7 @@ export default class IOComponent { incomingState: z.infer ) => Promise>>, isOptional: boolean = false, + isDisabled: boolean = false, validator?: IOPromiseValidator | undefined> ) { this.handleStateChange = handleStateChange @@ -95,6 +97,7 @@ export default class IOComponent { state: null, isStateful: !!handleStateChange, isOptional: isOptional, + isDisabled: isDisabled, } this.returnValue = new Promise< @@ -185,6 +188,7 @@ export default class IOComponent { return this.instance } + // this isn't called anywhere, can we delete? setOptional(optional: boolean) { this.instance.isOptional = optional } @@ -204,6 +208,7 @@ export default class IOComponent { props: this.instance.props, isStateful: this.instance.isStateful, isOptional: this.instance.isOptional, + isDisabled: this.instance.isDisabled, validationErrorMessage: this.instance.validationErrorMessage, } } diff --git a/src/classes/IOPromise.ts b/src/classes/IOPromise.ts index fc379d5..8798ec1 100644 --- a/src/classes/IOPromise.ts +++ b/src/classes/IOPromise.ts @@ -94,6 +94,7 @@ export class IOPromise< this.label, this.props, this.onStateChange, + false, false ) } @@ -121,6 +122,7 @@ export class InputIOPromise< this.props, this.onStateChange, false, + false, this.validator ? this.#handleValidation.bind(this) : undefined ) } @@ -168,6 +170,30 @@ export class InputIOPromise< : this } + disabled(isDisabled?: true): DisabledIOPromise + disabled(isDisabled?: false): IOPromise + disabled( + isDisabled?: boolean + ): + | DisabledIOPromise + | IOPromise + disabled( + isDisabled = true + ): + | DisabledIOPromise + | IOPromise { + return isDisabled + ? new DisabledIOPromise({ + renderer: this.renderer, + methodName: this.methodName, + label: this.label, + props: this.props, + valueGetter: this.valueGetter, + onStateChange: this.onStateChange, + }) + : this + } + exclusive(): ExclusiveIOPromise { return new ExclusiveIOPromise({ renderer: this.renderer, @@ -210,6 +236,7 @@ export class OptionalIOPromise< this.props, this.onStateChange, true, + false, this.validator ? this.#handleValidation.bind(this) : undefined ) } @@ -233,6 +260,56 @@ export class OptionalIOPromise< } } +/** + * A subclass of IOPromise that marks its inner component as disabled + */ +export class DisabledIOPromise< + MethodName extends T_IO_INPUT_METHOD_NAMES, + Props = T_IO_PROPS, + Output = ComponentReturnValue +> extends InputIOPromise { + then(resolve: (output: Output) => void, reject?: (err: IOError) => void) { + this.renderer([this.component]) + .then(([result]) => { + resolve(this.getValue(result)) + }) + .catch(err => { + if (reject) reject(err) + }) + } + + get component() { + return new IOComponent( + this.methodName, + this.label, + this.props, + this.onStateChange, + false, + true, + this.validator ? this.#handleValidation.bind(this) : undefined + ) + } + + async #handleValidation( + returnValue: ComponentReturnValue | undefined + ): Promise { + if (returnValue === undefined) { + // This should be caught already, primarily here for types + return 'This field is required.' + } + + if (this.validator) { + return this.validator(this.getValue(returnValue)) + } + } + + validate(validator: IOPromiseValidator): this { + this.validator = validator + + return this + } +} + /** * A thin subclass of IOPromise that does nothing but mark the component * as "exclusive" for components that cannot be rendered in a group. diff --git a/src/examples/basic/index.ts b/src/examples/basic/index.ts index c2a3493..34c8e95 100644 --- a/src/examples/basic/index.ts +++ b/src/examples/basic/index.ts @@ -127,6 +127,10 @@ const interval = new Interval({ logLevel: 'debug', endpoint: 'ws://localhost:3000/websocket', actions: { + disabled_inputs: async io => { + await io.input.text('Text you can’t edit').disabled(true) + return 'Done!' + }, 'long-return-string': async io => { return { date: new Date(), diff --git a/src/ioSchema.ts b/src/ioSchema.ts index 12fb362..185b827 100644 --- a/src/ioSchema.ts +++ b/src/ioSchema.ts @@ -11,6 +11,7 @@ export const IO_RENDER = z.object({ propsMeta: z.any().optional(), isStateful: z.boolean().optional().default(false), isOptional: z.boolean().optional().default(false), + isDisabled: z.boolean().optional().default(false), validationErrorMessage: z.string().optional(), }) ), From b2ae246e5b7f91016fd8191ab83b756d30db4b87 Mon Sep 17 00:00:00 2001 From: Ryan Coppolo Date: Thu, 28 Jul 2022 12:28:26 -0400 Subject: [PATCH 13/17] Add all the other inputs and update example action --- src/examples/basic/index.ts | 65 ++++++++++++++++++++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/src/examples/basic/index.ts b/src/examples/basic/index.ts index 34c8e95..fafae3a 100644 --- a/src/examples/basic/index.ts +++ b/src/examples/basic/index.ts @@ -128,7 +128,70 @@ const interval = new Interval({ endpoint: 'ws://localhost:3000/websocket', actions: { disabled_inputs: async io => { - await io.input.text('Text you can’t edit').disabled(true) + // TODO + // should we make this work? + // await io.confirm("Can't edit this").disabled(true) + + await io.group([ + io.display.heading('This is a bunch of disabled inputs'), + io.input.text('Text input').disabled(true), + io.input.boolean('Boolean input').disabled(true), + io.input.number('Number input').disabled(true), + io.input.email('Email input').disabled(true), + io.input.richText('Rich text input').disabled(true), + io + .search('Search for a user', { + renderResult: user => ({ + label: user.name, + description: user.email, + }), + onSearch: async query => { + return [ + { + name: 'John Doe', + email: 'johndoe@example.com', + }, + ] + }, + }) + .disabled(true), + io.select + .single('Select something', { + options: [1, 2, 3], + }) + .disabled(true), + io.select + .multiple('Select multiple of something', { + options: [1, 2, 3], + }) + .disabled(true), + io.select + .table('Select from table', { + data: [ + { + album: 'Exile on Main Street', + artist: 'The Rolling Stones', + year: 1972, + }, + { + artist: 'Michael Jackson', + album: 'Thriller', + year: 1982, + }, + { + album: 'Enter the Wu-Tang (36 Chambers)', + artist: 'Wu-Tang Clan', + year: 1993, + }, + ], + }) + .disabled(true), + io.experimental.date('Date input').disabled(true), + io.experimental.time('Time input').disabled(true), + io.experimental.datetime('Date & time').disabled(true), + io.experimental.input.file('File input').disabled(true), + ]) + return 'Done!' }, 'long-return-string': async io => { From ad109bdb2b942243e97284f1f2a0f7b356e8893b Mon Sep 17 00:00:00 2001 From: Ryan Coppolo Date: Thu, 28 Jul 2022 16:20:34 -0400 Subject: [PATCH 14/17] Consistent styling, switch to disabled prop --- src/classes/IOComponent.ts | 9 --- src/classes/IOPromise.ts | 77 ------------------------- src/components/search.ts | 3 + src/examples/basic/index.ts | 111 ++++++++++++++++++------------------ src/ioSchema.ts | 14 ++++- 5 files changed, 72 insertions(+), 142 deletions(-) diff --git a/src/classes/IOComponent.ts b/src/classes/IOComponent.ts index 2d4e19c..3012565 100644 --- a/src/classes/IOComponent.ts +++ b/src/classes/IOComponent.ts @@ -16,7 +16,6 @@ export interface ComponentInstance { state: z.infer isStateful?: boolean isOptional?: boolean - isDisabled?: boolean validationErrorMessage?: string | undefined } @@ -75,7 +74,6 @@ export default class IOComponent { incomingState: z.infer ) => Promise>>, isOptional: boolean = false, - isDisabled: boolean = false, validator?: IOPromiseValidator | undefined> ) { this.handleStateChange = handleStateChange @@ -97,7 +95,6 @@ export default class IOComponent { state: null, isStateful: !!handleStateChange, isOptional: isOptional, - isDisabled: isDisabled, } this.returnValue = new Promise< @@ -188,11 +185,6 @@ export default class IOComponent { return this.instance } - // this isn't called anywhere, can we delete? - setOptional(optional: boolean) { - this.instance.isOptional = optional - } - get label() { return this.instance.label } @@ -208,7 +200,6 @@ export default class IOComponent { props: this.instance.props, isStateful: this.instance.isStateful, isOptional: this.instance.isOptional, - isDisabled: this.instance.isDisabled, validationErrorMessage: this.instance.validationErrorMessage, } } diff --git a/src/classes/IOPromise.ts b/src/classes/IOPromise.ts index 8798ec1..fc379d5 100644 --- a/src/classes/IOPromise.ts +++ b/src/classes/IOPromise.ts @@ -94,7 +94,6 @@ export class IOPromise< this.label, this.props, this.onStateChange, - false, false ) } @@ -122,7 +121,6 @@ export class InputIOPromise< this.props, this.onStateChange, false, - false, this.validator ? this.#handleValidation.bind(this) : undefined ) } @@ -170,30 +168,6 @@ export class InputIOPromise< : this } - disabled(isDisabled?: true): DisabledIOPromise - disabled(isDisabled?: false): IOPromise - disabled( - isDisabled?: boolean - ): - | DisabledIOPromise - | IOPromise - disabled( - isDisabled = true - ): - | DisabledIOPromise - | IOPromise { - return isDisabled - ? new DisabledIOPromise({ - renderer: this.renderer, - methodName: this.methodName, - label: this.label, - props: this.props, - valueGetter: this.valueGetter, - onStateChange: this.onStateChange, - }) - : this - } - exclusive(): ExclusiveIOPromise { return new ExclusiveIOPromise({ renderer: this.renderer, @@ -236,7 +210,6 @@ export class OptionalIOPromise< this.props, this.onStateChange, true, - false, this.validator ? this.#handleValidation.bind(this) : undefined ) } @@ -260,56 +233,6 @@ export class OptionalIOPromise< } } -/** - * A subclass of IOPromise that marks its inner component as disabled - */ -export class DisabledIOPromise< - MethodName extends T_IO_INPUT_METHOD_NAMES, - Props = T_IO_PROPS, - Output = ComponentReturnValue -> extends InputIOPromise { - then(resolve: (output: Output) => void, reject?: (err: IOError) => void) { - this.renderer([this.component]) - .then(([result]) => { - resolve(this.getValue(result)) - }) - .catch(err => { - if (reject) reject(err) - }) - } - - get component() { - return new IOComponent( - this.methodName, - this.label, - this.props, - this.onStateChange, - false, - true, - this.validator ? this.#handleValidation.bind(this) : undefined - ) - } - - async #handleValidation( - returnValue: ComponentReturnValue | undefined - ): Promise { - if (returnValue === undefined) { - // This should be caught already, primarily here for types - return 'This field is required.' - } - - if (this.validator) { - return this.validator(this.getValue(returnValue)) - } - } - - validate(validator: IOPromiseValidator): this { - this.validator = validator - - return this - } -} - /** * A thin subclass of IOPromise that does nothing but mark the component * as "exclusive" for components that cannot be rendered in a group. diff --git a/src/components/search.ts b/src/components/search.ts index e8a9c3a..d47ab0a 100644 --- a/src/components/search.ts +++ b/src/components/search.ts @@ -18,10 +18,12 @@ export default function search({ onSearch, initialResults = [], renderResult, + disabled = false, ...rest }: { placeholder?: string helpText?: string + disabled?: boolean initialResults?: Result[] renderResult: (result: Result) => RenderResultDef onSearch: (query: string) => Promise @@ -58,6 +60,7 @@ export default function search({ const props: T_IO_PROPS<'SEARCH'> = { ...rest, results: renderResults(initialResults), + disabled, } return { diff --git a/src/examples/basic/index.ts b/src/examples/basic/index.ts index fafae3a..9b30e2b 100644 --- a/src/examples/basic/index.ts +++ b/src/examples/basic/index.ts @@ -133,63 +133,64 @@ const interval = new Interval({ // await io.confirm("Can't edit this").disabled(true) await io.group([ - io.display.heading('This is a bunch of disabled inputs'), - io.input.text('Text input').disabled(true), - io.input.boolean('Boolean input').disabled(true), - io.input.number('Number input').disabled(true), - io.input.email('Email input').disabled(true), - io.input.richText('Rich text input').disabled(true), - io - .search('Search for a user', { - renderResult: user => ({ - label: user.name, - description: user.email, - }), - onSearch: async query => { - return [ - { - name: 'John Doe', - email: 'johndoe@example.com', - }, - ] - }, - }) - .disabled(true), - io.select - .single('Select something', { - options: [1, 2, 3], - }) - .disabled(true), - io.select - .multiple('Select multiple of something', { - options: [1, 2, 3], - }) - .disabled(true), - io.select - .table('Select from table', { - data: [ - { - album: 'Exile on Main Street', - artist: 'The Rolling Stones', - year: 1972, - }, - { - artist: 'Michael Jackson', - album: 'Thriller', - year: 1982, - }, + io.display.heading('Here are a bunch of disabled inputs'), + io.input.text('Text input', { + disabled: true, + placeholder: 'Text goes here', + }), + io.experimental.datetime('Date & time', { disabled: true }), + io.input.boolean('Boolean input', { disabled: true }), + io.select.single('Select something', { + options: [1, 2, 3], + disabled: true, + }), + io.input.number('Number input', { + disabled: true, + }), + io.input.email('Email input', { disabled: true }), + io.input.richText('Rich text input', { disabled: true }), + io.search('Search for a user', { + disabled: true, + renderResult: user => ({ + label: user.name, + description: user.email, + }), + onSearch: async query => { + return [ { - album: 'Enter the Wu-Tang (36 Chambers)', - artist: 'Wu-Tang Clan', - year: 1993, + name: 'John Doe', + email: 'johndoe@example.com', }, - ], - }) - .disabled(true), - io.experimental.date('Date input').disabled(true), - io.experimental.time('Time input').disabled(true), - io.experimental.datetime('Date & time').disabled(true), - io.experimental.input.file('File input').disabled(true), + ] + }, + }), + io.select.multiple('Select multiple of something', { + options: [1, 2, 3], + disabled: true, + }), + io.select.table('Select from table', { + data: [ + { + album: 'Exile on Main Street', + artist: 'The Rolling Stones', + year: 1972, + }, + { + artist: 'Michael Jackson', + album: 'Thriller', + year: 1982, + }, + { + album: 'Enter the Wu-Tang (36 Chambers)', + artist: 'Wu-Tang Clan', + year: 1993, + }, + ], + disabled: true, + }), + io.experimental.date('Date input', { disabled: true }), + io.experimental.time('Time input', { disabled: true }), + io.experimental.input.file('File input', { disabled: true }), ]) return 'Done!' diff --git a/src/ioSchema.ts b/src/ioSchema.ts index 185b827..9e04420 100644 --- a/src/ioSchema.ts +++ b/src/ioSchema.ts @@ -11,7 +11,6 @@ export const IO_RENDER = z.object({ propsMeta: z.any().optional(), isStateful: z.boolean().optional().default(false), isOptional: z.boolean().optional().default(false), - isDisabled: z.boolean().optional().default(false), validationErrorMessage: z.string().optional(), }) ), @@ -251,6 +250,7 @@ export const ioSchema = { lines: z.optional(z.number()), minLength: z.optional(z.number().int().positive()), maxLength: z.optional(z.number().int().positive()), + disabled: z.optional(z.boolean().default(false)), }), state: z.null(), returns: z.string(), @@ -260,6 +260,7 @@ export const ioSchema = { helpText: z.optional(z.string()), placeholder: z.optional(z.string()), defaultValue: z.optional(z.string()), + disabled: z.optional(z.boolean().default(false)), }), state: z.null(), returns: z.string(), @@ -274,6 +275,7 @@ export const ioSchema = { defaultValue: z.optional(z.number()), decimals: z.optional(z.number().positive().int()), currency: z.optional(currencyCode), + disabled: z.optional(z.boolean().default(false)), }), state: z.null(), returns: z.number(), @@ -282,6 +284,7 @@ export const ioSchema = { props: z.object({ helpText: z.optional(z.string()), defaultValue: z.boolean().default(false), + disabled: z.optional(z.boolean().default(false)), }), state: z.null(), returns: z.boolean(), @@ -291,6 +294,7 @@ export const ioSchema = { helpText: z.optional(z.string()), placeholder: z.optional(z.string()), defaultValue: z.optional(z.string()), + disabled: z.optional(z.boolean().default(false)), }), state: z.null(), returns: z.string(), @@ -301,6 +305,7 @@ export const ioSchema = { defaultValue: z.optional(dateObject), min: z.optional(dateObject), max: z.optional(dateObject), + disabled: z.optional(z.boolean().default(false)), }), state: z.null(), returns: dateObject, @@ -311,6 +316,7 @@ export const ioSchema = { defaultValue: z.optional(timeObject), min: z.optional(timeObject), max: z.optional(timeObject), + disabled: z.optional(z.boolean().default(false)), }), state: z.null(), returns: timeObject, @@ -321,6 +327,7 @@ export const ioSchema = { defaultValue: z.optional(dateTimeObject), min: z.optional(dateTimeObject), max: z.optional(dateTimeObject), + disabled: z.optional(z.boolean().default(false)), }), state: z.null(), returns: dateTimeObject, @@ -338,6 +345,7 @@ export const ioSchema = { props: z.object({ helpText: z.string().optional(), allowedExtensions: z.array(z.string()).optional(), + disabled: z.optional(z.boolean().default(false)), }), state: z.null(), returns: z.object({ @@ -360,6 +368,7 @@ export const ioSchema = { ), placeholder: z.optional(z.string()), helpText: z.optional(z.string()), + disabled: z.optional(z.boolean().default(false)), }), state: z.object({ queryTerm: z.string() }), returns: z.string(), @@ -379,6 +388,7 @@ export const ioSchema = { data: z.array(internalTableRow), minSelections: z.optional(z.number().int().min(0)), maxSelections: z.optional(z.number().positive().int()), + disabled: z.optional(z.boolean().default(false)), }), state: z.null(), returns: z.array(internalTableRow), @@ -389,6 +399,7 @@ export const ioSchema = { helpText: z.optional(z.string()), defaultValue: z.optional(richSelectOption), searchable: z.optional(z.boolean()), + disabled: z.optional(z.boolean().default(false)), }), state: z.object({ queryTerm: z.string() }), returns: richSelectOption, @@ -402,6 +413,7 @@ export const ioSchema = { .default([] as z.infer[]), minSelections: z.optional(z.number().int().min(0)), maxSelections: z.optional(z.number().positive().int()), + disabled: z.optional(z.boolean().default(false)), }), state: z.null(), returns: z.array(labelValue), From 2bfbcef6f77292d989223e0679da87cc750cc2c1 Mon Sep 17 00:00:00 2001 From: Ryan Coppolo Date: Fri, 29 Jul 2022 15:01:54 -0400 Subject: [PATCH 15/17] Update sdk-js/src/examples/basic/index.ts Co-authored-by: Dan Philibin --- src/examples/basic/index.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/examples/basic/index.ts b/src/examples/basic/index.ts index 9b30e2b..7e37c44 100644 --- a/src/examples/basic/index.ts +++ b/src/examples/basic/index.ts @@ -128,10 +128,6 @@ const interval = new Interval({ endpoint: 'ws://localhost:3000/websocket', actions: { disabled_inputs: async io => { - // TODO - // should we make this work? - // await io.confirm("Can't edit this").disabled(true) - await io.group([ io.display.heading('Here are a bunch of disabled inputs'), io.input.text('Text input', { From 6d18055f528095ce5fa38580fbeaea297ac28f77 Mon Sep 17 00:00:00 2001 From: Alex Arena Date: Fri, 29 Jul 2022 12:10:36 -0700 Subject: [PATCH 16/17] consistent file order --- src/components/upload.ts | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/src/components/upload.ts b/src/components/upload.ts index 5ad4c63..5a7684a 100644 --- a/src/components/upload.ts +++ b/src/components/upload.ts @@ -3,6 +3,24 @@ import fetch, { Response } from 'node-fetch' import { IntervalError } from '..' import { T_IO_PROPS, T_IO_RETURNS, T_IO_STATE } from '../ioSchema' +const MAX_RETRIES = 3 + +async function retryFetch(url: string): Promise { + for (let i = 1; i <= MAX_RETRIES; i++) { + try { + const r = await fetch(url) + return r + } catch (err) { + if (i === MAX_RETRIES) { + throw err + } + } + } + + // This should never happen, final failing response err would be thrown above + throw new IntervalError('Failed to fetch file.') +} + type UploaderProps = T_IO_PROPS<'UPLOAD_FILE'> & { generatePresignedUrls?: ( state: T_IO_STATE<'UPLOAD_FILE'> @@ -55,21 +73,3 @@ export function file({ generatePresignedUrls, ...props }: UploaderProps) { }, } } - -const MAX_RETRIES = 3 - -async function retryFetch(url: string): Promise { - for (let i = 1; i <= MAX_RETRIES; i++) { - try { - const r = await fetch(url) - return r - } catch (err) { - if (i === MAX_RETRIES) { - throw err - } - } - } - - // This should never happen, final failing response err would be thrown above - throw new IntervalError('Failed to fetch file.') -} From 2969330ec3d9d83bc894ec4ce860bb8b4362595b Mon Sep 17 00:00:00 2001 From: Alex Arena Date: Fri, 29 Jul 2022 12:25:51 -0700 Subject: [PATCH 17/17] logs errs from generatePresignedUrls vs swallowing --- src/classes/IOClient.ts | 4 +- src/components/upload.ts | 92 +++++++++++++++++++++------------------- 2 files changed, 52 insertions(+), 44 deletions(-) diff --git a/src/classes/IOClient.ts b/src/classes/IOClient.ts index e1a0452..9d3549f 100644 --- a/src/classes/IOClient.ts +++ b/src/classes/IOClient.ts @@ -455,7 +455,9 @@ export class IOClient { componentDef: datetime, }), input: { - file: this.createIOMethod('UPLOAD_FILE', { componentDef: file }), + file: this.createIOMethod('UPLOAD_FILE', { + componentDef: file(this.logger), + }), }, }, } diff --git a/src/components/upload.ts b/src/components/upload.ts index 5a7684a..1ae71de 100644 --- a/src/components/upload.ts +++ b/src/components/upload.ts @@ -2,6 +2,7 @@ import path from 'path' import fetch, { Response } from 'node-fetch' import { IntervalError } from '..' import { T_IO_PROPS, T_IO_RETURNS, T_IO_STATE } from '../ioSchema' +import Logger from '../classes/Logger' const MAX_RETRIES = 3 @@ -27,49 +28,54 @@ type UploaderProps = T_IO_PROPS<'UPLOAD_FILE'> & { ) => Promise<{ uploadUrl: string; downloadUrl: string }> } -export function file({ generatePresignedUrls, ...props }: UploaderProps) { - const isProvidingUrls = !!generatePresignedUrls +export function file(logger: Logger) { + return function ({ generatePresignedUrls, ...props }: UploaderProps) { + const isProvidingUrls = !!generatePresignedUrls + return { + props: { + ...props, + uploadUrl: isProvidingUrls ? null : undefined, + downloadUrl: isProvidingUrls ? null : undefined, + }, + getValue({ url, ...response }: T_IO_RETURNS<'UPLOAD_FILE'>) { + return { + ...response, + lastModified: new Date(response.lastModified), + get extension(): string { + return path.extname(response.name) + }, + async url(): Promise { + return url + }, + async text(): Promise { + return retryFetch(url).then(r => r.text()) + }, + async json(): Promise { + return retryFetch(url).then(r => r.json()) + }, + async buffer(): Promise { + return retryFetch(url) + .then(r => r.arrayBuffer()) + .then(arrayBuffer => Buffer.from(arrayBuffer)) + }, + } + }, + async onStateChange(newState: T_IO_STATE<'UPLOAD_FILE'>) { + if (!generatePresignedUrls) { + return { uploadUrl: undefined, downloadUrl: undefined } + } - return { - props: { - ...props, - uploadUrl: isProvidingUrls ? null : undefined, - downloadUrl: isProvidingUrls ? null : undefined, - }, - getValue({ url, ...response }: T_IO_RETURNS<'UPLOAD_FILE'>) { - return { - ...response, - lastModified: new Date(response.lastModified), - get extension(): string { - return path.extname(response.name) - }, - async url(): Promise { - return url - }, - async text(): Promise { - return retryFetch(url).then(r => r.text()) - }, - async json(): Promise { - return retryFetch(url).then(r => r.json()) - }, - async buffer(): Promise { - return retryFetch(url) - .then(r => r.arrayBuffer()) - .then(arrayBuffer => Buffer.from(arrayBuffer)) - }, - } - }, - async onStateChange(newState: T_IO_STATE<'UPLOAD_FILE'>) { - if (!generatePresignedUrls) { - return { uploadUrl: undefined, downloadUrl: undefined } - } - - try { - const urls = await generatePresignedUrls(newState) - return urls - } catch (error) { - return { uploadUrl: 'error', downloadUrl: 'error' } - } - }, + try { + const urls = await generatePresignedUrls(newState) + return urls + } catch (error) { + logger.error( + 'An error was unexpectedly thrown from the `generatePresignedUrls` function:' + ) + logger.error(error) + return { uploadUrl: 'error', downloadUrl: 'error' } + } + }, + } } }