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:
Faton Ramadani
2024-01-13 08:35:00 +01:00
committed by GitHub
parent aafd7d9003
commit 3c59fb8b4d
18 changed files with 723 additions and 21 deletions
@@ -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'}
@@ -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' }>
+2 -1
View File
@@ -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>