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/classes/IOClient.ts b/src/classes/IOClient.ts index d405d9f..27d93bf 100644 --- a/src/classes/IOClient.ts +++ b/src/classes/IOClient.ts @@ -421,6 +421,11 @@ export class IOClient { url: this.createIOMethod('INPUT_URL', { componentDef: urlInput, }), + 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', { @@ -453,13 +458,10 @@ 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 }), + file: this.createIOMethod('UPLOAD_FILE', { + componentDef: file(this.logger), + }), }, }, } diff --git a/src/classes/IOComponent.ts b/src/classes/IOComponent.ts index 420533e..3012565 100644 --- a/src/classes/IOComponent.ts +++ b/src/classes/IOComponent.ts @@ -185,10 +185,6 @@ export default class IOComponent { return this.instance } - setOptional(optional: boolean) { - this.instance.isOptional = optional - } - get label() { return this.instance.label } 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/components/upload.ts b/src/components/upload.ts index f182740..1ae71de 100644 --- a/src/components/upload.ts +++ b/src/components/upload.ts @@ -1,35 +1,8 @@ import path from 'path' import fetch, { Response } from 'node-fetch' import { IntervalError } from '..' -import { T_IO_PROPS, T_IO_RETURNS } from '../ioSchema' - -export function file(_: T_IO_PROPS<'UPLOAD_FILE'>) { - return { - 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)) - }, - } - }, - } -} +import { T_IO_PROPS, T_IO_RETURNS, T_IO_STATE } from '../ioSchema' +import Logger from '../classes/Logger' const MAX_RETRIES = 3 @@ -48,3 +21,61 @@ async function retryFetch(url: string): Promise { // 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'> + ) => Promise<{ uploadUrl: string; downloadUrl: string }> +} + +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 } + } + + 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' } + } + }, + } + } +} diff --git a/src/examples/basic/index.ts b/src/examples/basic/index.ts index fc0d0d1..c515ce3 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 { generateS3Urls } from '../utils/upload' const actionLinks: IntervalActionHandler = async () => { await io.group([ @@ -127,6 +128,70 @@ const interval = new Interval({ logLevel: 'debug', endpoint: 'ws://localhost:3000/websocket', actions: { + disabled_inputs: async io => { + await io.group([ + 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 [ + { + name: 'John Doe', + email: 'johndoe@example.com', + }, + ] + }, + }), + 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!' + }, 'long-return-string': async io => { return { date: new Date(), @@ -312,7 +377,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 +389,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 +399,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(), }), @@ -676,10 +741,27 @@ const interval = new Interval({ return { message: 'OK, notified!' } }, - upload: async io => { + upload: async (io, ctx) => { + const customDestinationFile = await io.experimental.input.file( + 'Upload an image!', + { + helpText: 'Will be uploaded to the custom destination.', + allowedExtensions: ['.gif', '.jpg', '.jpeg', '.png'], + generatePresignedUrls: async ({ name }) => { + const urlSafeName = name.replace(/ /g, '-') + const path = `custom-endpoint/${new Date().getTime()}-${urlSafeName}` + + return generateS3Urls(path) + }, + } + ) + + console.log(await customDestinationFile.url()) + const file = await io.experimental.input.file('Upload an image!', { - helpText: 'Can be any image, or a CSV (?).', - allowedExtensions: ['.gif', '.jpg', '.jpeg', 'csv'], + helpText: + 'Will be uploaded to Interval and expire after the action finishes running.', + allowedExtensions: ['.gif', '.jpg', '.jpeg', '.png'], }) console.log(file) diff --git a/src/examples/utils/upload.ts b/src/examples/utils/upload.ts new file mode 100644 index 0000000..9372934 --- /dev/null +++ b/src/examples/utils/upload.ts @@ -0,0 +1,41 @@ +import { + AWS_REGION, + AWS_KEY_ID, + AWS_KEY_SECRET, + AWS_S3_IO_BUCKET, +} from '../../env' +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 generateS3Urls(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, + }) + + const uploadUrl = await getSignedUrl(s3Client, command, { + expiresIn: 3600, // 1 hour + }) + + const url = new URL(uploadUrl) + const downloadUrl = url.origin + url.pathname + + return { uploadUrl, downloadUrl } +} diff --git a/src/ioSchema.ts b/src/ioSchema.ts index 0523ed1..e811fa8 100644 --- a/src/ioSchema.ts +++ b/src/ioSchema.ts @@ -250,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(), @@ -259,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(), @@ -273,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(), @@ -292,6 +295,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(), @@ -301,6 +305,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(), @@ -311,6 +316,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, @@ -321,6 +327,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, @@ -331,6 +338,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, @@ -348,8 +356,14 @@ export const ioSchema = { props: z.object({ helpText: z.string().optional(), allowedExtensions: z.array(z.string()).optional(), + uploadUrl: z.string().nullish().optional(), + downloadUrl: z.string().nullish().optional(), + disabled: z.optional(z.boolean().default(false)), + }), + state: z.object({ + name: z.string(), + type: z.string(), }), - state: z.null(), returns: z.object({ name: z.string(), type: z.string(), @@ -370,6 +384,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(), @@ -389,6 +404,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), @@ -399,6 +415,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, @@ -412,6 +429,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),