From 3c59fb8b4d8a80077c2f352ccf1314fd32ff442c Mon Sep 17 00:00:00 2001 From: Faton Ramadani Date: Sat, 13 Jan 2024 08:35:00 +0100 Subject: [PATCH] feat(frontend): s3 file upload (#2976) * feat(frontend): wip * feat(frontend): s3 file working * feat(frontend): policy * feat(frontend): policy * feat(frontend): wip * feat(frontend): merge main * feat(frontend): update s3 upload logic * feat(frontend): wip * feat(frontend): wip * feat(frontend): path template * feat(frontend): done * feat(frontend): clean up * feat(frontend): fix dark mode * feat(frontend): fix outputs + add component control * feat(frontend): fix outputs + add component control * Update components.ts * Update components.ts --------- Co-authored-by: Ruben Fiszel --- .../apps/components/helpers/InputValue.svelte | 16 +- .../components/helpers/RunnableWrapper.svelte | 12 + .../apps/components/helpers/eval.ts | 11 +- .../components/inputs/AppS3FileInput.svelte | 461 ++++++++++++++++++ .../apps/editor/AppEditorHeader.svelte | 6 +- .../lib/components/apps/editor/appUtils.ts | 43 +- .../components/apps/editor/appUtilsInfer.ts | 1 + .../apps/editor/component/Component.svelte | 8 + .../apps/editor/component/components.ts | 82 +++- .../components/apps/editor/component/sets.ts | 1 + .../componentsPanel/componentControlUtils.ts | 9 + .../componentsPanel/quickStyleProperties.ts | 3 + .../settingsPanel/ComponentPanel.svelte | 2 + .../inputEditor/StaticInputEditor.svelte | 17 +- frontend/src/lib/components/apps/inputType.ts | 2 + frontend/src/lib/components/apps/types.ts | 3 +- frontend/src/lib/components/apps/utils.ts | 9 + .../components/common/FileProgressBar.svelte | 58 +++ 18 files changed, 723 insertions(+), 21 deletions(-) create mode 100644 frontend/src/lib/components/apps/components/inputs/AppS3FileInput.svelte create mode 100644 frontend/src/lib/components/common/FileProgressBar.svelte diff --git a/frontend/src/lib/components/apps/components/helpers/InputValue.svelte b/frontend/src/lib/components/apps/components/helpers/InputValue.svelte index a040d772e9..77c0cd604a 100644 --- a/frontend/src/lib/components/apps/components/helpers/InputValue.svelte +++ b/frontend/src/lib/components/apps/components/helpers/InputValue.svelte @@ -18,7 +18,7 @@ import { computeGlobalContext, eval_like } from './eval' import deepEqualWithOrderedArray from './deepEqualWithOrderedArray' import { deepEqual } from 'fast-equals' - import { isCodeInjection } from '$lib/utils' + import { deepMergeWithPriority, isCodeInjection } from '$lib/utils' type T = string | number | boolean | Record | undefined @@ -30,8 +30,8 @@ export let field: string = key const { componentControl, runnableComponents } = getContext('AppViewerContext') - const editorContext = getContext('AppEditorContext') + const editorContext = getContext('AppEditorContext') const iterContext = getContext('ListWrapperContext') const rowContext = getContext('RowWrapperContext') const groupContext = getContext('GroupContext') @@ -94,8 +94,8 @@ let firstDebounce = true const debounce_ms = 50 - export async function computeExpr() { - const nvalue = await evalExpr(lastInput as EvalAppInput) + export async function computeExpr(args?: Record) { + const nvalue = await evalExpr(lastInput as EvalAppInput, args) if (!deepEqual(nvalue, value)) { value = nvalue } @@ -146,6 +146,7 @@ const debounceEval = async () => { let nvalue = await evalExpr(lastInput as EvalAppInput) + if (field) { editorContext?.evalPreview.update((x) => { x[`${id}.${field}`] = nvalue @@ -250,12 +251,15 @@ } } - async function evalExpr(input: EvalAppInput | EvalV2AppInput): Promise { + async function evalExpr( + input: EvalAppInput | EvalV2AppInput, + args?: Record + ): Promise { if (iterContext && $iterContext.disabled) return try { const r = await eval_like( input.expr, - computeGlobalContext($worldStore, fullContext), + computeGlobalContext($worldStore, deepMergeWithPriority(fullContext, args ?? {})), true, $state, $mode == 'dnd', diff --git a/frontend/src/lib/components/apps/components/helpers/RunnableWrapper.svelte b/frontend/src/lib/components/apps/components/helpers/RunnableWrapper.svelte index 0e50eaacc6..dc85133b88 100644 --- a/frontend/src/lib/components/apps/components/helpers/RunnableWrapper.svelte +++ b/frontend/src/lib/components/apps/components/helpers/RunnableWrapper.svelte @@ -25,6 +25,7 @@ | 'closeModal' | 'open' | 'close' + | 'clearFiles' configuration: { gotoUrl: { url: string | undefined; newTab: boolean | undefined } setTab: { @@ -49,6 +50,9 @@ close?: { id: string | undefined } + clearFiles?: { + id: string | undefined + } } } | undefined @@ -203,6 +207,14 @@ $componentControl[id].close?.() break } + case 'clearFiles': { + const id = sideEffect?.configuration?.clearFiles?.id + + if (!id) return + + $componentControl[id].clearFiles?.() + break + } default: break } diff --git a/frontend/src/lib/components/apps/components/helpers/eval.ts b/frontend/src/lib/components/apps/components/helpers/eval.ts index 09e344e8e3..b59e05364c 100644 --- a/frontend/src/lib/components/apps/components/helpers/eval.ts +++ b/frontend/src/lib/components/apps/components/helpers/eval.ts @@ -20,7 +20,7 @@ export function computeGlobalContext(world: World | undefined, extraContext: any function create_context_function_template(eval_string: string, context, noReturn: boolean) { return ` -return async function (context, state, goto, setTab, recompute, getAgGrid, setValue, setSelectedIndex, openModal, closeModal, open, close, validate, invalidate, validateAll) { +return async function (context, state, goto, setTab, recompute, getAgGrid, setValue, setSelectedIndex, openModal, closeModal, open, close, validate, invalidate, validateAll, clearFiles) { "use strict"; ${ Object.keys(context).length > 0 @@ -55,10 +55,12 @@ function make_context_evaluator( close, validate, invalidate, - validateAll + validateAll, + clearFiles ) => Promise { let template = create_context_function_template(eval_string, context, noReturn) let functor = Function(template) + return functor() } @@ -114,6 +116,7 @@ export async function eval_like( validate?: (key: string) => void invalidate?: (key: string, error: string) => void validateAll?: () => void + clearFiles?: () => void } >, worldStore: World | undefined, @@ -135,6 +138,7 @@ export async function eval_like( } }) let evaluator = make_context_evaluator(text, context, noReturn) + return await evaluator( context, proxiedState, @@ -185,6 +189,9 @@ export async function eval_like( }, (id) => { controlComponents[id]?.validateAll?.() + }, + (id) => { + controlComponents[id]?.clearFiles?.() } ) } diff --git a/frontend/src/lib/components/apps/components/inputs/AppS3FileInput.svelte b/frontend/src/lib/components/apps/components/inputs/AppS3FileInput.svelte new file mode 100644 index 0000000000..1c98eab63a --- /dev/null +++ b/frontend/src/lib/components/apps/components/inputs/AppS3FileInput.svelte @@ -0,0 +1,461 @@ + + +{#each Object.keys(css ?? {}) as key (key)} + +{/each} + +{#each Object.keys(components['s3fileinputcomponent'].initialData.configuration) as key (key)} + +{/each} + +{#if configuration.type?.['configuration']?.s3.pathTemplate} + +{/if} + +{#if render} +
+ {#if $fileUploads.length > 0 && !forceDisplayUploads} +
+
+ {#each $fileUploads as fileUpload} +
+
+
+ {fileUpload.name} + {`${Math.round((fileUpload.size / 1024 / 1024) * 100) / 100} MB`} +
+
+ {#if fileUpload.errorMessage} + + {:else if fileUpload.cancelled} + + {:else if fileUpload.progress === 100} + + {/if} + + {#if fileUpload.cancelled || fileUpload.errorMessage !== undefined} + + + {/if} + {#if fileUpload.progress < 100 && !fileUpload.cancelled && !fileUpload.errorMessage} + + {/if} + + {#if fileUpload.progress === 100 && !fileUpload.cancelled} + + {/if} +
+
+ + {#if fileUpload.errorMessage} + {fileUpload.errorMessage} + {:else if fileUpload.cancelled} + Upload cancelled + {/if} + + {#if !(fileUpload.cancelled || fileUpload.errorMessage !== undefined)} + + {fileUpload.progress === 100 ? 'Upload finished' : `Uploading`} to path: {fileUpload.path} + + {/if} +
+ {/each} +
+
+ {#if !$fileUploads.every((fileUpload) => fileUpload.progress === 100 || fileUpload.cancelled)} + + {/if} + +
+
+ {:else} + { + forceDisplayUploads = false + handleChange(detail) + }} + class={twMerge('w-full h-full', css?.container?.class, 'wm-file-input')} + style={css?.container?.style} + > + {resolvedConfigS3.text} + + {/if} +
+{/if} diff --git a/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte b/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte index a14ab90c50..a22da3592e 100644 --- a/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte @@ -237,13 +237,16 @@ } r.push(...nr) } - return r + + const processed = r .filter((x) => x.input) .map(async (o) => { if (o.input?.type == 'runnable') { return await processRunnable(o.id, o.input.runnable, o.input.fields) } }) + + return processed }) .concat( Object.values($app.hiddenInlineScripts ?? {}).map(async (v, i) => { @@ -251,6 +254,7 @@ }) ) )) as ([string, Record] | undefined)[] + policy.triggerables = Object.fromEntries( allTriggers.filter(Boolean) as [string, Record][] ) diff --git a/frontend/src/lib/components/apps/editor/appUtils.ts b/frontend/src/lib/components/apps/editor/appUtils.ts index 8c9e55a9d2..f6bace868e 100644 --- a/frontend/src/lib/components/apps/editor/appUtils.ts +++ b/frontend/src/lib/components/apps/editor/appUtils.ts @@ -21,7 +21,12 @@ import { allItems } from '../utils' import type { Output, World } from '../rx' import gridHelp from '../svelte-grid/utils/helper' import type { FilledItem } from '../svelte-grid/types' -import type { EvalAppInput, StaticAppInput } from '../inputType' +import type { + StaticAppInput, + EvalAppInput, + EvalV2AppInput, + InputConnectionEval +} from '../inputType' import { get, type Writable } from 'svelte/store' import { deepMergeWithPriority } from '$lib/utils' import { sendUserToast } from '$lib/toast' @@ -251,19 +256,32 @@ export function getGridItems(app: App, focusedGrid: FocusedGrid | undefined): Gr } } -function cleanseValue(key: string, value: { type: 'eval' | 'static'; value?: any; expr?: string }) { +function cleanseValue( + key: string, + value: { + type: 'eval' | 'static' | 'evalv2' + value?: any + expr?: string + connections?: InputConnectionEval[] + } +) { if (!value) { return [key, undefined] } if (value.type === 'static') { return [key, { type: value.type, value: value.value }] - } else { + } else if (value.type === 'eval') { return [key, { type: value.type, expr: value.expr }] + } else { + return [key, { type: value.type, expr: value.expr, connections: value.connections }] } } export function cleanseOneOfConfiguration( - configuration: Record> + configuration: Record< + string, + Record + > ) { return Object.fromEntries( Object.entries(configuration).map(([key, val]) => [ @@ -602,10 +620,14 @@ export type InitConfig< string, | StaticAppInput | EvalAppInput + | EvalV2AppInput | { type: 'oneOf' selected: string - configuration: Record> + configuration: Record< + string, + Record + > } > > = { @@ -631,10 +653,14 @@ export function initConfig< string, | StaticAppInput | EvalAppInput + | EvalV2AppInput | { type: 'oneOf' selected: string - configuration: Record> + configuration: Record< + string, + Record + > } > >( @@ -645,7 +671,10 @@ export function initConfig< | { type: 'oneOf' selected: string - configuration: Record> + configuration: Record< + string, + Record + > } | any > diff --git a/frontend/src/lib/components/apps/editor/appUtilsInfer.ts b/frontend/src/lib/components/apps/editor/appUtilsInfer.ts index fa6e07f8a0..28f15e6c37 100644 --- a/frontend/src/lib/components/apps/editor/appUtilsInfer.ts +++ b/frontend/src/lib/components/apps/editor/appUtilsInfer.ts @@ -18,6 +18,7 @@ export async function inferDeps( componentId == 'row' || componentId == 'iter' || componentId == 'group' || + componentId == 'file' || id in (worldOutputs[componentId] ?? {}) ) .map(([componentId, id]) => ({ diff --git a/frontend/src/lib/components/apps/editor/component/Component.svelte b/frontend/src/lib/components/apps/editor/component/Component.svelte index 7dc695bd34..0dc081f239 100644 --- a/frontend/src/lib/components/apps/editor/component/Component.svelte +++ b/frontend/src/lib/components/apps/editor/component/Component.svelte @@ -68,6 +68,7 @@ import AppDecisionTree from '../../components/layout/AppDecisionTree.svelte' import AppAgCharts from '../../components/display/charts/AppAgCharts.svelte' import AppDbExplorer from '../../components/display/dbtable/AppDbExplorer.svelte' + import AppS3FileInput from '../../components/inputs/AppS3FileInput.svelte' export let component: AppComponent export let selected: boolean @@ -618,6 +619,13 @@ customCss={component.customCss} {render} /> + {:else if component.type === 's3fileinputcomponent'} + {:else if component.type === 'imagecomponent'} = { type: T @@ -186,6 +192,8 @@ export type DBExplorerComponent = BaseComponent<'dbexplorercomponent'> & { columns: RichConfiguration } +export type S3FileInputComponent = BaseComponent<'s3fileinputcomponent'> + export type DecisionTreeNode = { id: string label: string @@ -264,6 +272,7 @@ export type TypedComponent = | StatisticCardComponent | MenuComponent | DecisionTreeComponent + | S3FileInputComponent | AgChartsComponent | AgChartsComponentEe @@ -378,7 +387,8 @@ const labels = { open: 'Open a modal or a drawer', close: 'Close a modal or a drawer', openModal: 'Open a modal (deprecated)', - closeModal: 'Close a modal (deprecated)' + closeModal: 'Close a modal (deprecated)', + clearFiles: 'Clear files from a S3 file input' } const onSuccessClick = { @@ -454,6 +464,14 @@ const onSuccessClick = { type: 'static', value: '' } + }, + clearFiles: { + id: { + tooltip: 'The id of s3 file input to clear', + fieldType: 'text', + type: 'static', + value: '' + } } } } as const @@ -2985,6 +3003,64 @@ This is a paragraph. ] as DecisionTreeNode[] } }, + s3fileinputcomponent: { + name: 'S3 File Uploader', + icon: UploadCloud, + documentationLink: `${documentationBaseUrl}/s3fileinput`, + dims: '2:8-6:8' as AppComponentDimensions, + customCss: { + container: { class: '', style: '' } + }, + initialData: { + configuration: { + type: { + type: 'oneOf', + selected: 's3', + labels: { + s3: 'S3' + }, + configuration: { + s3: { + resource: { + type: 'static', + fieldType: 'resource', + value: '', + subFieldType: 's3' + } as StaticAppInput, + acceptedFileTypes: { + type: 'static', + value: ['image/*', 'application/pdf'] as string[], + fieldType: 'array' + }, + allowMultiple: { + type: 'static', + value: false, + fieldType: 'boolean', + tooltip: 'If allowed, the user will be able to select more than one file' + }, + text: { + type: 'static', + value: 'Drag and drop files or click to select them', + fieldType: 'text' + }, + /* + displayDirectLink: { + type: 'static', + value: false, + fieldType: 'boolean' + }, + */ + pathTemplate: { + type: 'eval', + expr: `\`\${file.name}\``, + fieldType: 'template', + } + } + } + } as const + } + } + }, dbexplorercomponent: { name: 'Database Studio', icon: Database, diff --git a/frontend/src/lib/components/apps/editor/component/sets.ts b/frontend/src/lib/components/apps/editor/component/sets.ts index 3ae09cef74..f39bcb2cb8 100644 --- a/frontend/src/lib/components/apps/editor/component/sets.ts +++ b/frontend/src/lib/components/apps/editor/component/sets.ts @@ -43,6 +43,7 @@ const inputs: ComponentSet = { 'rangecomponent', 'dateinputcomponent', 'fileinputcomponent', + 's3fileinputcomponent', 'checkboxcomponent', 'selectcomponent', 'resourceselectcomponent', diff --git a/frontend/src/lib/components/apps/editor/componentsPanel/componentControlUtils.ts b/frontend/src/lib/components/apps/editor/componentsPanel/componentControlUtils.ts index f3ccdfdd89..37b66e36dc 100644 --- a/frontend/src/lib/components/apps/editor/componentsPanel/componentControlUtils.ts +++ b/frontend/src/lib/components/apps/editor/componentsPanel/componentControlUtils.ts @@ -50,6 +50,13 @@ const open = { documentation: 'https://www.windmill.dev/docs/apps/app-runnable-panel#open' } +const clearFiles = { + title: 'clearFiles', + description: 'Clear the files of a file input component.', + example: 'clearFiles(id: string)', + documentation: 'https://www.windmill.dev/docs/apps/app-runnable-panel#clearFiles' +} + const close = { title: 'close', description: 'Use the close function to close a modal or a drawer.', @@ -94,6 +101,8 @@ export function getComponentControl(type: keyof typeof components): Array {:else if fieldType === 'tab-select'} - {:else if fieldType === 'resource'} + {:else if fieldType === 'resource' && subFieldType !== 's3'} { @@ -77,6 +77,21 @@ showSchemaExplorer resourceType="postgresql" /> + {:else if fieldType === 'resource' && subFieldType === 's3'} + { + let path = e.detail + if (componentInput) { + if (path) { + componentInput.value = `$res:${path}` + } else { + componentInput.value = undefined + } + } + }} + resourceType="s3" + /> {:else if fieldType === 'labeledresource'} {#if componentInput?.value && typeof componentInput?.value == 'object' && 'label' in componentInput?.value && (componentInput.value?.['value'] == undefined || typeof componentInput.value?.['value'] == 'string')}
diff --git a/frontend/src/lib/components/apps/inputType.ts b/frontend/src/lib/components/apps/inputType.ts index 56d3518a16..9a8f4af63f 100644 --- a/frontend/src/lib/components/apps/inputType.ts +++ b/frontend/src/lib/components/apps/inputType.ts @@ -31,6 +31,7 @@ export type InputType = | 'resource' | 'db-explorer' | 'db-table' + | 's3' | 'number-tuple' // Connection to an output of another component @@ -205,6 +206,7 @@ export type AppInput = | AppInputSpec<'array', DecisionTreeNode, 'DecisionTreeNode'> | AppInputSpec<'array', object[], 'ag-chart'> | AppInputSpec<'resource', string> + | AppInputSpec<'resource', string, 's3'> | AppInputSpec<'array', object[], 'number-tuple'> export type RowAppInput = Extract diff --git a/frontend/src/lib/components/apps/types.ts b/frontend/src/lib/components/apps/types.ts index 6cf2cbaf73..b683f0064b 100644 --- a/frontend/src/lib/components/apps/types.ts +++ b/frontend/src/lib/components/apps/types.ts @@ -75,7 +75,7 @@ export type RichConfigurations = Record export type StaticRichConfigurations = Record< string, - RichConfigurationT + RichConfigurationT > export interface BaseAppComponent extends Partial { @@ -248,6 +248,7 @@ export type AppViewerContext = { validate?: (key: string) => void invalidate?: (key: string, error: string) => void validateAll?: () => void + clearFiles?: () => void } > > diff --git a/frontend/src/lib/components/apps/utils.ts b/frontend/src/lib/components/apps/utils.ts index 23e8182d43..d91bf298c9 100644 --- a/frontend/src/lib/components/apps/utils.ts +++ b/frontend/src/lib/components/apps/utils.ts @@ -239,6 +239,12 @@ declare function invalidate(id: string, key: number, error: string): void; * @param id component's id */ declare function validateAll(id: string, key: number): void; + +/** Clear the files of a file input component + * @param id component's id + */ +declare function clearFiles(id: string): void; + ` : '' } @@ -249,6 +255,9 @@ declare const iter: {index: number, value: any}; /** The row within the context of a table */ declare const row: {index: number, value: Record, disabled: boolean}; +/** The file within the s3 file input */ +declare const file: File | undefined; + /** The group fields within the context of a container's group */ declare const group: Record; diff --git a/frontend/src/lib/components/common/FileProgressBar.svelte b/frontend/src/lib/components/common/FileProgressBar.svelte new file mode 100644 index 0000000000..6368813364 --- /dev/null +++ b/frontend/src/lib/components/common/FileProgressBar.svelte @@ -0,0 +1,58 @@ + + +{#key color} +
+ {#if ended} + + {:else} +
+
+
+ {Math.round($tweenedProgress)}% + {/if} +
+{/key} + +