mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-19 08:01:25 +00:00
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 <ruben@windmill.dev>
This commit is contained in:
@@ -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<string | number, any> | undefined
|
||||
|
||||
@@ -30,8 +30,8 @@
|
||||
export let field: string = key
|
||||
|
||||
const { componentControl, runnableComponents } = getContext<AppViewerContext>('AppViewerContext')
|
||||
const editorContext = getContext<AppEditorContext>('AppEditorContext')
|
||||
|
||||
const editorContext = getContext<AppEditorContext>('AppEditorContext')
|
||||
const iterContext = getContext<ListContext>('ListWrapperContext')
|
||||
const rowContext = getContext<ListContext>('RowWrapperContext')
|
||||
const groupContext = getContext<GroupContext>('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<string, any>) {
|
||||
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<any> {
|
||||
async function evalExpr(
|
||||
input: EvalAppInput | EvalV2AppInput,
|
||||
args?: Record<string, any>
|
||||
): Promise<any> {
|
||||
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',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<any> {
|
||||
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?.()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,461 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from 'svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { FileInput } from '../../../common'
|
||||
import FileProgressBar from '../../../common/FileProgressBar.svelte'
|
||||
|
||||
import { initConfig, initOutput } from '../../editor/appUtils'
|
||||
import type { AppViewerContext, ComponentCustomCSS, RichConfigurations } from '../../types'
|
||||
import { initCss } from '../../utils'
|
||||
import ResolveStyle from '../helpers/ResolveStyle.svelte'
|
||||
import ResolveConfig from '../helpers/ResolveConfig.svelte'
|
||||
import { components } from '../../editor/component'
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { HelpersService, type UploadFilePart } from '$lib/gen'
|
||||
import { writable, type Writable } from 'svelte/store'
|
||||
import { Ban, CheckCheck, FileWarning, Files, RefreshCcw, Trash } from 'lucide-svelte'
|
||||
import InputValue from '../helpers/InputValue.svelte'
|
||||
|
||||
export let id: string
|
||||
export let configuration: RichConfigurations
|
||||
export let customCss: ComponentCustomCSS<'fileinputcomponent'> | undefined = undefined
|
||||
export let render: boolean
|
||||
export let extraKey: string | undefined = undefined
|
||||
|
||||
let resolvedConfig = initConfig(
|
||||
components['s3fileinputcomponent'].initialData.configuration,
|
||||
configuration
|
||||
)
|
||||
|
||||
type FileUploadData = {
|
||||
name: string
|
||||
size: number
|
||||
progress: number
|
||||
cancelled?: boolean
|
||||
errorMessage?: string
|
||||
path?: string
|
||||
file?: File
|
||||
}
|
||||
|
||||
let fileUploads: Writable<FileUploadData[]> = writable([])
|
||||
const { app, worldStore, componentControl } = getContext<AppViewerContext>('AppViewerContext')
|
||||
|
||||
$componentControl[id] = {
|
||||
clearFiles: () => {
|
||||
outputs.result.set([])
|
||||
$fileUploads = []
|
||||
}
|
||||
}
|
||||
|
||||
const outputs = initOutput($worldStore, id, {
|
||||
result: [] as { path: string }[] | undefined,
|
||||
loading: false,
|
||||
jobId: undefined
|
||||
})
|
||||
|
||||
async function handleChange(files: File[] | undefined) {
|
||||
for (const file of files ?? []) {
|
||||
uploadFileToS3(file, file.name)
|
||||
}
|
||||
}
|
||||
|
||||
$: resolvedConfigS3 = resolvedConfig.type.configuration.s3
|
||||
|
||||
let css = initCss($app.css?.fileinputcomponent, customCss)
|
||||
|
||||
let allFilesByKey: Record<
|
||||
string,
|
||||
{
|
||||
type: 'folder' | 'leaf'
|
||||
full_key: string
|
||||
display_name: string
|
||||
collapsed: boolean
|
||||
parentPath: string | undefined
|
||||
nestingLevel: number
|
||||
}
|
||||
> = {}
|
||||
|
||||
async function uploadFileToS3(fileToUpload: File, fileToUploadKey: string) {
|
||||
if (fileToUpload === undefined || fileToUploadKey === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
const path = (await inputValue?.computeExpr({ file: fileToUpload })) ?? fileToUploadKey
|
||||
|
||||
$fileUploads = $fileUploads.filter((fileUpload) => fileUpload.name !== fileToUpload.name)
|
||||
|
||||
const uploadData: FileUploadData = {
|
||||
name: fileToUpload.name,
|
||||
size: fileToUpload.size,
|
||||
progress: 1, // We set it to 1 so that the progress bar is visible
|
||||
cancelled: false,
|
||||
path: path,
|
||||
file: fileToUpload
|
||||
}
|
||||
|
||||
if (allFilesByKey[fileToUploadKey] !== undefined) {
|
||||
uploadData.errorMessage =
|
||||
'A file with this name already exists in the S3 bucket. If you want to replace it, delete it first.'
|
||||
$fileUploads = [...$fileUploads, uploadData]
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
$fileUploads = [...$fileUploads, uploadData]
|
||||
|
||||
let upload_id: string | undefined = undefined
|
||||
let parts: UploadFilePart[] = []
|
||||
|
||||
let reader = fileToUpload?.stream().getReader()
|
||||
let { value: chunk, done: readerDone } = await reader.read()
|
||||
if (chunk === undefined || readerDone) {
|
||||
sendUserToast('Error reading file, no data read', true)
|
||||
return
|
||||
}
|
||||
|
||||
let fileUploadProgress = 0
|
||||
while (true) {
|
||||
const currentFileUpload = $fileUploads.find(
|
||||
(fileUpload) => fileUpload.name === uploadData.name
|
||||
)!
|
||||
|
||||
if (currentFileUpload.cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
let { value: chunk_2, done: readerDone } = await reader.read()
|
||||
if (!readerDone && chunk_2 !== undefined && chunk.length <= 5 * 1024 * 1024) {
|
||||
// AWS enforces part to be bigger than 5MB, so we accumulate bytes until we reach that limit before triggering the request to the BE
|
||||
chunk = new Uint8Array([...chunk, ...chunk_2])
|
||||
continue
|
||||
}
|
||||
|
||||
fileUploadProgress += (chunk.length * 100) / fileToUpload.size
|
||||
uploadData.progress = fileUploadProgress
|
||||
|
||||
$fileUploads = $fileUploads.map((fileUpload) => {
|
||||
if (fileUpload.name === uploadData.name) {
|
||||
return uploadData
|
||||
}
|
||||
return fileUpload
|
||||
})
|
||||
|
||||
try {
|
||||
let response = await HelpersService.multipartFileUpload({
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: {
|
||||
file_key: path ?? fileToUploadKey,
|
||||
part_content: Array.from(chunk),
|
||||
upload_id: upload_id,
|
||||
parts: parts,
|
||||
is_final: readerDone,
|
||||
cancel_upload: currentFileUpload.cancelled ?? false,
|
||||
s3_resource_path: resolvedConfigS3 ? resolvedConfigS3.resource.split(':')[1] : undefined
|
||||
}
|
||||
})
|
||||
upload_id = response.upload_id
|
||||
parts = response.parts
|
||||
if (response.is_done) {
|
||||
if (currentFileUpload.cancelled) {
|
||||
sendUserToast('File upload cancelled!')
|
||||
} else {
|
||||
const curr = outputs.result.peak()
|
||||
|
||||
outputs.result.set(
|
||||
curr.concat({
|
||||
path: path ?? fileToUploadKey
|
||||
})
|
||||
)
|
||||
|
||||
sendUserToast('File upload finished!')
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
if (chunk_2 === undefined) {
|
||||
sendUserToast(
|
||||
'File upload is not finished, yet there is no more data to stream. This is unexpected',
|
||||
true
|
||||
)
|
||||
return
|
||||
}
|
||||
chunk = chunk_2
|
||||
} catch (e) {
|
||||
sendUserToast(e, true)
|
||||
$fileUploads = $fileUploads.map((fileUpload) => {
|
||||
if (fileUpload.name === uploadData.name) {
|
||||
fileUpload.errorMessage = e
|
||||
return fileUpload
|
||||
}
|
||||
return fileUpload
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let inputValue: InputValue | undefined = undefined
|
||||
|
||||
async function deleteFile(fileKey: string) {
|
||||
await HelpersService.deleteS3File({
|
||||
workspace: $workspaceStore!,
|
||||
fileKey: fileKey
|
||||
})
|
||||
|
||||
const curr = outputs.result.peak()
|
||||
|
||||
outputs.result.set(curr.filter((file) => file.path !== fileKey))
|
||||
|
||||
sendUserToast('File deleted!')
|
||||
}
|
||||
/*
|
||||
|
||||
{#if resolvedConfig.displayDirectLink && fileUpload.progress === 100}
|
||||
<Button
|
||||
color="light"
|
||||
on:click={() => {
|
||||
if (!fileUpload.path) {
|
||||
return
|
||||
}
|
||||
|
||||
loadFileMetadata(fileUpload.path)
|
||||
copyToClipboard(
|
||||
`https://resolvedConfig.bucketName.s3.amazonaws.com/${fileUpload.name}`
|
||||
)
|
||||
}}
|
||||
size="xs2"
|
||||
variant="border"
|
||||
>
|
||||
Copy Direct Link
|
||||
</Button>
|
||||
{/if}
|
||||
*/
|
||||
let forceDisplayUploads: boolean = false
|
||||
</script>
|
||||
|
||||
{#each Object.keys(css ?? {}) as key (key)}
|
||||
<ResolveStyle
|
||||
{id}
|
||||
{customCss}
|
||||
{key}
|
||||
bind:css={css[key]}
|
||||
componentStyle={$app.css?.fileinputcomponent}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
{#each Object.keys(components['s3fileinputcomponent'].initialData.configuration) as key (key)}
|
||||
<ResolveConfig
|
||||
{id}
|
||||
{extraKey}
|
||||
{key}
|
||||
bind:resolvedConfig={resolvedConfig[key]}
|
||||
configuration={configuration[key]}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
{#if configuration.type?.['configuration']?.s3.pathTemplate}
|
||||
<InputValue
|
||||
input={configuration.type?.['configuration']?.s3.pathTemplate}
|
||||
{id}
|
||||
field="pathTemplate"
|
||||
value=""
|
||||
bind:this={inputValue}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if render}
|
||||
<div class="w-full h-full p-2 flex">
|
||||
{#if $fileUploads.length > 0 && !forceDisplayUploads}
|
||||
<div class="border rounded-md flex flex-col gap-1 divide-y h-full w-full p-1">
|
||||
<div class="flex h-full overflow-y-auto flex-col">
|
||||
{#each $fileUploads as fileUpload}
|
||||
<div class="w-full flex flex-col gap-1 p-2">
|
||||
<div class="flex flex-row items-center justify-between">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-xs font-bold">{fileUpload.name}</span>
|
||||
<span class="text-xs"
|
||||
>{`${Math.round((fileUpload.size / 1024 / 1024) * 100) / 100} MB`}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex flex-row gap-1 items-center">
|
||||
{#if fileUpload.errorMessage}
|
||||
<FileWarning class="w-4 h-4 text-red-500" />
|
||||
{:else if fileUpload.cancelled}
|
||||
<FileWarning class="w-4 h-4 text-yellow-500" />
|
||||
{:else if fileUpload.progress === 100}
|
||||
<CheckCheck class="w-4 h-4 text-green-500" />
|
||||
{/if}
|
||||
|
||||
{#if fileUpload.cancelled || fileUpload.errorMessage !== undefined}
|
||||
<Button
|
||||
size="xs2"
|
||||
color="light"
|
||||
variant="border"
|
||||
on:click={() => {
|
||||
const file = fileUpload.file
|
||||
|
||||
if (!file) {
|
||||
return
|
||||
}
|
||||
|
||||
$fileUploads = $fileUploads.filter(
|
||||
(_fileUpload) => _fileUpload.name !== fileUpload.name
|
||||
)
|
||||
|
||||
uploadFileToS3(file, file.name)
|
||||
}}
|
||||
startIcon={{
|
||||
icon: RefreshCcw
|
||||
}}
|
||||
>
|
||||
Retry Upload
|
||||
</Button>
|
||||
<Button
|
||||
size="xs2"
|
||||
color="light"
|
||||
variant="border"
|
||||
on:click={() => {
|
||||
const file = fileUpload.file
|
||||
|
||||
if (!file) {
|
||||
return
|
||||
}
|
||||
|
||||
$fileUploads = $fileUploads.filter(
|
||||
(_fileUpload) => _fileUpload.name !== fileUpload.name
|
||||
)
|
||||
}}
|
||||
startIcon={{
|
||||
icon: RefreshCcw
|
||||
}}
|
||||
>
|
||||
Remove from list
|
||||
</Button>
|
||||
{/if}
|
||||
{#if fileUpload.progress < 100 && !fileUpload.cancelled && !fileUpload.errorMessage}
|
||||
<Button
|
||||
size="xs2"
|
||||
color="light"
|
||||
variant="border"
|
||||
on:click={() => {
|
||||
fileUpload.cancelled = true
|
||||
fileUpload.progress = 0
|
||||
}}
|
||||
startIcon={{
|
||||
icon: Ban
|
||||
}}
|
||||
>
|
||||
Cancel Upload
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
{#if fileUpload.progress === 100 && !fileUpload.cancelled}
|
||||
<Button
|
||||
size="xs2"
|
||||
color="red"
|
||||
variant="border"
|
||||
on:click={() => {
|
||||
$fileUploads = $fileUploads.filter(
|
||||
(_fileUpload) => _fileUpload.name !== fileUpload.name
|
||||
)
|
||||
|
||||
if (fileUpload.path) {
|
||||
deleteFile(fileUpload.path)
|
||||
}
|
||||
}}
|
||||
startIcon={{
|
||||
icon: Trash
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<FileProgressBar
|
||||
progress={fileUpload.progress}
|
||||
color={fileUpload.errorMessage
|
||||
? '#ef4444'
|
||||
: fileUpload.cancelled
|
||||
? '#eab308'
|
||||
: fileUpload.progress === 100
|
||||
? '#22c55e'
|
||||
: '#3b82f6'}
|
||||
ended={fileUpload.cancelled || fileUpload.errorMessage !== undefined}
|
||||
>
|
||||
{#if fileUpload.errorMessage}
|
||||
<span class="text-xs text-red-600">{fileUpload.errorMessage}</span>
|
||||
{:else if fileUpload.cancelled}
|
||||
<span class="text-xs text-yellow-600">Upload cancelled</span>
|
||||
{/if}
|
||||
</FileProgressBar>
|
||||
{#if !(fileUpload.cancelled || fileUpload.errorMessage !== undefined)}
|
||||
<span class="text-xs text-gray-500 dark:text-gray-200">
|
||||
{fileUpload.progress === 100 ? 'Upload finished' : `Uploading`} to path: {fileUpload.path}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="flex flex-row gap-1 items-center justify-end p-1">
|
||||
{#if !$fileUploads.every((fileUpload) => fileUpload.progress === 100 || fileUpload.cancelled)}
|
||||
<Button
|
||||
size="xs2"
|
||||
color="light"
|
||||
on:click={() => {
|
||||
$fileUploads = $fileUploads.map((fileUpload) => {
|
||||
if (fileUpload.progress === 100 || fileUpload.cancelled) {
|
||||
return fileUpload
|
||||
}
|
||||
|
||||
fileUpload.cancelled = true
|
||||
fileUpload.progress = 0
|
||||
return fileUpload
|
||||
})
|
||||
}}
|
||||
startIcon={{
|
||||
icon: Ban
|
||||
}}
|
||||
>
|
||||
Cancel All Uploads
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
size="xs2"
|
||||
color="light"
|
||||
on:click={() => {
|
||||
forceDisplayUploads = true
|
||||
}}
|
||||
startIcon={{
|
||||
icon: Files
|
||||
}}
|
||||
disabled={$fileUploads.some(
|
||||
(fileUpload) => fileUpload.progress !== 100 && !fileUpload.cancelled
|
||||
)}
|
||||
>
|
||||
Upload more files
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<FileInput
|
||||
accept={resolvedConfigS3.acceptedFileTypes?.length
|
||||
? resolvedConfigS3.acceptedFileTypes?.join(', ')
|
||||
: undefined}
|
||||
multiple={resolvedConfigS3.allowMultiple}
|
||||
returnFileNames
|
||||
includeMimeType
|
||||
on:change={({ detail }) => {
|
||||
forceDisplayUploads = false
|
||||
handleChange(detail)
|
||||
}}
|
||||
class={twMerge('w-full h-full', css?.container?.class, 'wm-file-input')}
|
||||
style={css?.container?.style}
|
||||
>
|
||||
{resolvedConfigS3.text}
|
||||
</FileInput>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -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<string, any>] | undefined)[]
|
||||
|
||||
policy.triggerables = Object.fromEntries(
|
||||
allTriggers.filter(Boolean) as [string, Record<string, any>][]
|
||||
)
|
||||
|
||||
@@ -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<string, Record<string, GeneralAppInput & (StaticAppInput | EvalAppInput)>>
|
||||
configuration: Record<
|
||||
string,
|
||||
Record<string, GeneralAppInput & (StaticAppInput | EvalAppInput | EvalV2AppInput)>
|
||||
>
|
||||
) {
|
||||
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<string, Record<string, StaticAppInput | EvalAppInput>>
|
||||
configuration: Record<
|
||||
string,
|
||||
Record<string, StaticAppInput | EvalAppInput | EvalV2AppInput>
|
||||
>
|
||||
}
|
||||
>
|
||||
> = {
|
||||
@@ -631,10 +653,14 @@ export function initConfig<
|
||||
string,
|
||||
| StaticAppInput
|
||||
| EvalAppInput
|
||||
| EvalV2AppInput
|
||||
| {
|
||||
type: 'oneOf'
|
||||
selected: string
|
||||
configuration: Record<string, Record<string, StaticAppInput | EvalAppInput>>
|
||||
configuration: Record<
|
||||
string,
|
||||
Record<string, StaticAppInput | EvalAppInput | EvalV2AppInput>
|
||||
>
|
||||
}
|
||||
>
|
||||
>(
|
||||
@@ -645,7 +671,10 @@ export function initConfig<
|
||||
| {
|
||||
type: 'oneOf'
|
||||
selected: string
|
||||
configuration: Record<string, Record<string, StaticAppInput | EvalAppInput | boolean>>
|
||||
configuration: Record<
|
||||
string,
|
||||
Record<string, StaticAppInput | EvalAppInput | EvalV2AppInput | boolean>
|
||||
>
|
||||
}
|
||||
| any
|
||||
>
|
||||
|
||||
@@ -18,6 +18,7 @@ export async function inferDeps(
|
||||
componentId == 'row' ||
|
||||
componentId == 'iter' ||
|
||||
componentId == 'group' ||
|
||||
componentId == 'file' ||
|
||||
id in (worldOutputs[componentId] ?? {})
|
||||
)
|
||||
.map(([componentId, id]) => ({
|
||||
|
||||
@@ -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'}
|
||||
<AppS3FileInput
|
||||
configuration={component.configuration}
|
||||
id={component.id}
|
||||
customCss={component.customCss}
|
||||
{render}
|
||||
/>
|
||||
{:else if component.type === 'imagecomponent'}
|
||||
<AppImage
|
||||
configuration={component.configuration}
|
||||
|
||||
@@ -44,7 +44,8 @@ import {
|
||||
FileBarChart,
|
||||
Menu,
|
||||
Network,
|
||||
Database
|
||||
Database,
|
||||
UploadCloud
|
||||
} from 'lucide-svelte'
|
||||
import type {
|
||||
Aligned,
|
||||
@@ -57,7 +58,12 @@ import type {
|
||||
} from '../../types'
|
||||
import type { Size } from '../../svelte-grid/types'
|
||||
|
||||
import type { AppInputSpec, EvalV2AppInput, ResultAppInput, StaticAppInput } from '../../inputType'
|
||||
import type {
|
||||
AppInputSpec,
|
||||
EvalV2AppInput,
|
||||
ResultAppInput,
|
||||
StaticAppInput
|
||||
} from '../../inputType'
|
||||
|
||||
export type BaseComponent<T extends string> = {
|
||||
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,
|
||||
|
||||
@@ -43,6 +43,7 @@ const inputs: ComponentSet = {
|
||||
'rangecomponent',
|
||||
'dateinputcomponent',
|
||||
'fileinputcomponent',
|
||||
's3fileinputcomponent',
|
||||
'checkboxcomponent',
|
||||
'selectcomponent',
|
||||
'resourceselectcomponent',
|
||||
|
||||
@@ -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<Compon
|
||||
return [getAgGrid, setSelectedIndex]
|
||||
case 'aggridcomponentee':
|
||||
return [getAgGrid, setSelectedIndex]
|
||||
case 's3fileinputcomponent':
|
||||
return [clearFiles]
|
||||
case 'displaycomponent':
|
||||
case 'dateinputcomponent':
|
||||
case 'textinputcomponent':
|
||||
|
||||
@@ -693,6 +693,9 @@ export const quickStyleProperties: Record<
|
||||
fileinputcomponent: {
|
||||
container: containerDefaultProps
|
||||
},
|
||||
s3fileinputcomponent: {
|
||||
container: containerDefaultProps
|
||||
},
|
||||
textinputcomponent: {
|
||||
input: inputDefaultProps
|
||||
},
|
||||
|
||||
@@ -196,6 +196,8 @@
|
||||
<PanelSection
|
||||
title={componentSettings?.item.data.type == 'steppercomponent'
|
||||
? 'Validations'
|
||||
: componentSettings?.item.data.type == 's3fileinputcomponent'
|
||||
? 'Path template'
|
||||
: hasInteraction
|
||||
? 'Event handler'
|
||||
: 'Data source'}
|
||||
|
||||
+16
-1
@@ -61,7 +61,7 @@
|
||||
<IconSelectInput bind:componentInput />
|
||||
{:else if fieldType === 'tab-select'}
|
||||
<TabSelectInput bind:componentInput />
|
||||
{:else if fieldType === 'resource'}
|
||||
{:else if fieldType === 'resource' && subFieldType !== 's3'}
|
||||
<ResourcePicker
|
||||
initialValue={componentInput.value?.split('$res:')?.[1] || ''}
|
||||
on:change={(e) => {
|
||||
@@ -77,6 +77,21 @@
|
||||
showSchemaExplorer
|
||||
resourceType="postgresql"
|
||||
/>
|
||||
{:else if fieldType === 'resource' && subFieldType === 's3'}
|
||||
<ResourcePicker
|
||||
initialValue={componentInput.value?.split('$res:')?.[1] || ''}
|
||||
on:change={(e) => {
|
||||
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')}
|
||||
<div class="flex flex-col gap-1 w-full">
|
||||
|
||||
@@ -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<AppInput, { type: 'row' }>
|
||||
|
||||
@@ -75,7 +75,7 @@ export type RichConfigurations = Record<string, RichConfiguration>
|
||||
|
||||
export type StaticRichConfigurations = Record<
|
||||
string,
|
||||
RichConfigurationT<GeneralAppInput & (StaticAppInput | EvalAppInput)>
|
||||
RichConfigurationT<GeneralAppInput & (StaticAppInput | EvalAppInput | EvalV2AppInput)>
|
||||
>
|
||||
|
||||
export interface BaseAppComponent extends Partial<Aligned> {
|
||||
@@ -248,6 +248,7 @@ export type AppViewerContext = {
|
||||
validate?: (key: string) => void
|
||||
invalidate?: (key: string, error: string) => void
|
||||
validateAll?: () => void
|
||||
clearFiles?: () => void
|
||||
}
|
||||
>
|
||||
>
|
||||
|
||||
@@ -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<string, any>, 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<string, any>;
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<script lang="ts">
|
||||
import { tweened } from 'svelte/motion'
|
||||
import { cubicOut } from 'svelte/easing'
|
||||
|
||||
export let color = 'blue'
|
||||
export let progress = 0
|
||||
export let ended: boolean = false
|
||||
|
||||
const tweenedProgress = tweened(progress, {
|
||||
duration: 400,
|
||||
easing: cubicOut
|
||||
})
|
||||
|
||||
$: tweenedProgress.set(progress)
|
||||
</script>
|
||||
|
||||
{#key color}
|
||||
<div class="flex flex-row gap-1 items-center justify-between w-full">
|
||||
{#if ended}
|
||||
<slot />
|
||||
{:else}
|
||||
<div class="progress-bar">
|
||||
<div
|
||||
class={`progress ${!ended ? 'blinking' : ''}`}
|
||||
style={`--color: ${color}; width: ${$tweenedProgress}%`}
|
||||
/>
|
||||
</div>
|
||||
<span class="text-xs p-1">{Math.round($tweenedProgress)}%</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/key}
|
||||
|
||||
<style>
|
||||
.progress-bar {
|
||||
height: 10px;
|
||||
background-color: lightgray;
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
.progress {
|
||||
height: 100%;
|
||||
background-color: var(--color);
|
||||
transition: width 0.4s ease-out;
|
||||
}
|
||||
.blinking {
|
||||
animation: blink 1s linear infinite;
|
||||
}
|
||||
@keyframes blink {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user