feat(frontend): pick image from workspace storage bucket (#5382)

* feat(frontend): pick image from workspace storage bucket

* also upload

* update policy for unauthed s3 download

* sqlx prep

* sqlx prep

* force policy

* no need for ee

* image picker

* Update frontend/src/lib/components/apps/editor/settingsPanel/InputsSpecEditor.svelte

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* field not needed

* feature flag

* filter for image files

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
This commit is contained in:
Alexander Petric
2025-03-06 11:53:53 +01:00
committed by GitHub
co-authored by ellipsis-dev[bot]
parent 4fabc2a825
commit 8dbe0fa644
15 changed files with 367 additions and 79 deletions
@@ -0,0 +1,24 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS (\n SELECT 1 FROM app\n WHERE path = $1\n AND workspace_id = $2\n AND policy @> jsonb_build_object('allowed_s3_keys', jsonb_build_array(jsonb_build_object('s3_path', $3::text)))::jsonb\n )",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "a06d8e9ac6e6a8d6b4b61bdf89ef2a5f75c195c7bf916a9b55be99e5cacdf44f"
}
+10 -1
View File
@@ -11398,7 +11398,7 @@ paths:
/w/{workspace}/job_helpers/download_s3_file:
get:
summary: Download file to S3 bucket
summary: Download file from S3 bucket
operationId: fileDownload
tags:
- helpers
@@ -14919,6 +14919,15 @@ components:
type: array
items:
type: object
allowed_s3_keys:
type: array
items:
type: object
properties:
s3_path:
type: string
resource:
type: string
execution_mode:
type: string
enum: [viewer, publisher, anonymous]
+84 -24
View File
@@ -225,6 +225,12 @@ pub struct S3Input {
file_key_regex: String,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct S3Key {
s3_path: String,
resource: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
pub struct Policy {
pub on_behalf_of: Option<String>,
@@ -239,6 +245,7 @@ pub struct Policy {
pub triggerables_v2: Option<HashMap<String, PolicyTriggerableInputs>>,
pub execution_mode: ExecutionMode,
pub s3_inputs: Option<Vec<S3Input>>,
pub allowed_s3_keys: Option<Vec<S3Key>>,
}
#[derive(Deserialize)]
@@ -1563,6 +1570,7 @@ async fn upload_s3_file_from_app(
.map(|s| s.split(',').map(|s| s.to_string()).collect())
.unwrap_or_default(),
}]),
allowed_s3_keys: None,
})
} else {
let policy_o = sqlx::query_scalar!(
@@ -1873,26 +1881,41 @@ async fn get_on_behalf_authed_from_app(
path: &str,
w_id: &str,
opt_authed: &Option<ApiAuthed>,
) -> Result<ApiAuthed> {
let policy_o = sqlx::query_scalar!(
"SELECT policy from app WHERE path = $1 AND workspace_id = $2",
path,
w_id
)
.fetch_optional(db)
.await?;
let policy = policy_o
.map(|p| serde_json::from_value::<Policy>(p).map_err(to_anyhow))
.transpose()?
.unwrap_or_else(|| Policy {
force_allowed_s3_keys: Option<Vec<S3Key>>,
) -> Result<(ApiAuthed, Policy)> {
let policy = if let Some(force_allowed_s3_keys) = force_allowed_s3_keys {
Policy {
execution_mode: ExecutionMode::Viewer,
triggerables: None,
triggerables_v2: None,
on_behalf_of: None,
on_behalf_of_email: None,
s3_inputs: None,
});
allowed_s3_keys: Some(force_allowed_s3_keys),
}
} else {
// TODO: improve db query to not return uneeded fields
let policy_o = sqlx::query_scalar!(
"SELECT policy from app WHERE path = $1 AND workspace_id = $2",
path,
w_id
)
.fetch_optional(db)
.await?;
policy_o
.map(|p| serde_json::from_value::<Policy>(p).map_err(to_anyhow))
.transpose()?
.unwrap_or_else(|| Policy {
execution_mode: ExecutionMode::Viewer,
triggerables: None,
triggerables_v2: None,
on_behalf_of: None,
on_behalf_of_email: None,
s3_inputs: None,
allowed_s3_keys: None,
})
};
let (username, permissioned_as, email) =
get_on_behalf_details_from_policy_and_authed(&policy, &opt_authed).await?;
@@ -1901,7 +1924,7 @@ async fn get_on_behalf_authed_from_app(
fetch_api_authed_from_permissioned_as(permissioned_as, email, &w_id, &db, Some(username))
.await?;
Ok(on_behalf_authed)
Ok((on_behalf_authed, policy))
}
#[cfg(feature = "parquet")]
@@ -1911,6 +1934,7 @@ async fn check_if_allowed_to_access_s3_file_from_app(
file_key: &str,
w_id: &str,
path: &str,
policy: &Policy,
) -> Result<()> {
// if anonymous, check that the file was the result of an app script ran by an anonymous user in the last 3 hours
// otherwise, if logged in, allow any file (TODO: change that when we implement better s3 policy)
@@ -1932,7 +1956,10 @@ async fn check_if_allowed_to_access_s3_file_from_app(
)
.fetch_one(db)
.await?
.unwrap_or(false);
.unwrap_or(false)
// check if the file is allowed by the allowed_s3_keys policy
|| policy.allowed_s3_keys.as_ref().unwrap().iter().any(|key| key.s3_path == file_key);
if !allowed {
Err(Error::BadRequest("File restricted".to_string()))
@@ -1941,21 +1968,46 @@ async fn check_if_allowed_to_access_s3_file_from_app(
}
}
#[cfg(feature = "parquet")]
#[derive(Deserialize)]
pub struct DownloadFileQueryWithForceViewerAllowedS3Keys {
#[serde(flatten)]
pub file_query: DownloadFileQuery,
pub force_viewer_allowed_s3_keys: Option<String>,
}
#[cfg(feature = "parquet")]
async fn download_s3_file_from_app(
OptAuthed(opt_authed): OptAuthed,
Extension(db): Extension<DB>,
Path((w_id, path)): Path<(String, StripPath)>,
Query(query): Query<DownloadFileQuery>,
Query(query): Query<DownloadFileQueryWithForceViewerAllowedS3Keys>,
) -> Result<Response> {
let path = path.to_path();
let on_behalf_authed = get_on_behalf_authed_from_app(&db, &path, &w_id, &opt_authed).await?;
let force_viewer_allowed_s3_keys = if let Some(force_viewer_allowed_s3_keys) =
query.force_viewer_allowed_s3_keys.clone()
{
Some(serde_json::from_str::<Vec<S3Key>>(&force_viewer_allowed_s3_keys).unwrap_or_default())
} else {
None
};
check_if_allowed_to_access_s3_file_from_app(&db, &opt_authed, &query.file_key, &w_id, &path)
.await?;
let (on_behalf_authed, policy) =
get_on_behalf_authed_from_app(&db, &path, &w_id, &opt_authed, force_viewer_allowed_s3_keys)
.await?;
download_s3_file_internal(on_behalf_authed, &db, None, "", &w_id, query).await
check_if_allowed_to_access_s3_file_from_app(
&db,
&opt_authed,
&query.file_query.file_key,
&w_id,
&path,
&policy,
)
.await?;
download_s3_file_internal(on_behalf_authed, &db, None, "", &w_id, query.file_query).await
}
#[cfg(not(feature = "parquet"))]
@@ -1974,10 +2026,18 @@ async fn load_s3_file_image_preview_from_app(
) -> Result<Response> {
let path = path.to_path();
let on_behalf_authed = get_on_behalf_authed_from_app(&db, &path, &w_id, &opt_authed).await?;
let (on_behalf_authed, policy) =
get_on_behalf_authed_from_app(&db, &path, &w_id, &opt_authed, None).await?;
check_if_allowed_to_access_s3_file_from_app(&db, &opt_authed, &query.file_key, &w_id, &path)
.await?;
check_if_allowed_to_access_s3_file_from_app(
&db,
&opt_authed,
&query.file_key,
&w_id,
&path,
&policy,
)
.await?;
load_image_preview_internal(on_behalf_authed, &db, "", &w_id, query).await
}
@@ -9,18 +9,30 @@
import ResolveConfig from '../helpers/ResolveConfig.svelte'
import InitializeComponent from '../helpers/InitializeComponent.svelte'
import ResolveStyle from '../helpers/ResolveStyle.svelte'
import { defaultIfEmptyString } from '$lib/utils'
import { userStore } from '$lib/stores'
import { computeS3ImageViewerPolicy } from '../../editor/appUtilsS3'
export let id: string
export let configuration: RichConfigurations
export let customCss: ComponentCustomCSS<'imagecomponent'> | undefined = undefined
export let render: boolean
function computeForceViewerPolicies() {
if (!isEditor) {
return undefined
}
const policy = computeS3ImageViewerPolicy(configuration, $app)
return policy
}
const resolvedConfig = initConfig(
components['imagecomponent'].initialData.configuration,
configuration
)
const { app, worldStore } = getContext<AppViewerContext>('AppViewerContext')
const { app, appPath, worldStore, workspace, isEditor } =
getContext<AppViewerContext>('AppViewerContext')
const fit: Record<string, string> = {
cover: 'object-cover',
contain: 'object-contain',
@@ -31,6 +43,44 @@
initOutput($worldStore, id, {})
let css = initCss($app.css?.imagecomponent, customCss)
let imageUrl: string | undefined = undefined
async function getS3Image(source: string | undefined) {
if (!source) return ''
const appPathOrUser = defaultIfEmptyString(
$appPath,
`u/${$userStore?.username ?? 'unknown'}/newapp`
)
const params = new URLSearchParams()
params.append('file_key', source)
const forceViewerPolicies = computeForceViewerPolicies()
if (forceViewerPolicies) {
params.append('force_viewer_allowed_s3_keys', JSON.stringify([forceViewerPolicies]))
}
return `/api/w/${workspace}/apps_u/download_s3_file/${appPathOrUser}?${params.toString()}`
}
async function loadImage() {
if (
resolvedConfig.sourceKind === 's3 (workspace storage)' ||
resolvedConfig.source?.startsWith('s3://')
) {
imageUrl = await getS3Image(resolvedConfig.source?.replace('s3://', ''))
} else if (resolvedConfig.sourceKind === 'png encoded as base64') {
imageUrl = 'data:image/png;base64,' + resolvedConfig.source
} else if (resolvedConfig.sourceKind === 'jpeg encoded as base64') {
imageUrl = 'data:image/jpeg;base64,' + resolvedConfig.source
} else if (resolvedConfig.sourceKind === 'svg encoded as base64') {
imageUrl = 'data:image/svg+xml;base64,' + resolvedConfig.source
} else {
imageUrl = resolvedConfig.source
}
}
$: resolvedConfig && loadImage()
</script>
<InitializeComponent {id} />
@@ -55,23 +105,19 @@
{/each}
{#if render}
<Loader loading={resolvedConfig.source == undefined}>
<img
on:pointerdown|preventDefault
src={resolvedConfig.sourceKind == 'png encoded as base64'
? 'data:image/png;base64,' + resolvedConfig.source
: resolvedConfig.sourceKind == 'jpeg encoded as base64'
? 'data:image/jpeg;base64,' + resolvedConfig.source
: resolvedConfig.sourceKind == 'svg encoded as base64'
? 'data:image/svg+xml;base64,' + resolvedConfig.source
: resolvedConfig.source}
alt={resolvedConfig.altText}
style={css?.image?.style ?? ''}
class={twMerge(
`w-full h-full ${fit[resolvedConfig.imageFit || 'cover']}`,
css?.image?.class,
'wm-image'
)}
/>
<Loader loading={imageUrl === undefined}>
{#if imageUrl}
<img
on:pointerdown|preventDefault
src={imageUrl}
alt={resolvedConfig.altText}
style={css?.image?.style ?? ''}
class={twMerge(
`w-full h-full ${fit[resolvedConfig.imageFit || 'cover']}`,
css?.image?.class,
'wm-image'
)}
/>
{/if}
</Loader>
{/if}
@@ -6,7 +6,8 @@
EvalAppInput,
EvalV2AppInput,
TemplateV2Input,
UploadAppInput
UploadAppInput,
UploadS3AppInput
} from '../../inputType'
import type {
AppEditorContext,
@@ -235,6 +236,8 @@
}
} else if (lastInput?.type == 'upload') {
value = (lastInput as UploadAppInput).value
} else if (lastInput?.type == 'uploadS3') {
value = (lastInput as UploadS3AppInput).value
} else {
value = undefined
}
@@ -3,6 +3,7 @@
import { initConfig, initOutput } from '../../editor/appUtils'
import type { AppViewerContext, ComponentCustomCSS, RichConfigurations } from '../../types'
import type { FileUploadData } from '../../inputType'
import { initCss } from '../../utils'
import ResolveStyle from '../helpers/ResolveStyle.svelte'
import ResolveConfig from '../helpers/ResolveConfig.svelte'
@@ -26,16 +27,6 @@
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, runnableComponents, workspace } =
getContext<AppViewerContext>('AppViewerContext')
@@ -85,7 +85,11 @@
import Summary from '$lib/components/Summary.svelte'
import HideButton from './settingsPanel/HideButton.svelte'
import DeployOverrideConfirmationModal from '$lib/components/common/confirmationModal/DeployOverrideConfirmationModal.svelte'
import { computeS3FileInputPolicy, computeWorkspaceS3FileInputPolicy } from './appUtilsS3'
import {
computeS3FileInputPolicy,
computeWorkspaceS3FileInputPolicy,
computeS3ImageViewerPolicy
} from './appUtilsS3'
import { isCloudHosted } from '$lib/cloud'
import { base } from '$lib/base'
import ClipboardPanel from '$lib/components/details/ClipboardPanel.svelte'
@@ -342,6 +346,17 @@
}
policy.s3_inputs = s3_inputs
const s3FileKeys = items
.filter((x) => (x.data as AppComponent).type === 'imagecomponent')
.map((x) => {
const c = x.data as AppComponent
const config = c.configuration as any
return computeS3ImageViewerPolicy(config, $app)
})
.filter(Boolean) as { s3_path?: string | undefined; resource?: string | undefined }[]
policy.allowed_s3_keys = s3FileKeys
}
async function processRunnable(
@@ -80,3 +80,14 @@ export function computeS3FileInputPolicy(s3Config: any, app: App) {
file_key_regex
}
}
export function computeS3ImageViewerPolicy(config: any, app: App) {
if (
config.sourceKind.value === 's3 (workspace storage)' ||
config.source.value.startsWith('s3://')
) {
return { s3_path: config.source.value.replace('s3://', ''), resource: 'default' }
} else {
return undefined
}
}
@@ -485,6 +485,7 @@ export const selectOptions = {
prose: ['sm', 'Default', 'lg', 'xl', '2xl'],
imageSourceKind: [
'url',
's3 (workspace storage)',
'png encoded as base64',
'jpeg encoded as base64',
'svg encoded as base64'
@@ -3073,6 +3074,10 @@ See date-fns format for more information. By default, it is 'dd.MM.yyyy HH:mm'
fileUpload: {
accept: 'image/*',
convertTo: 'base64'
},
fileUploadS3: {
accept: 'image/*',
convertTo: 'base64'
}
},
sourceKind: {
@@ -5,13 +5,24 @@
import EvalInputEditor from './inputEditor/EvalInputEditor.svelte'
import RowInputEditor from './inputEditor/RowInputEditor.svelte'
import StaticInputEditor from './inputEditor/StaticInputEditor.svelte'
import { Button } from '$lib/components/common'
import UploadInputEditor from './inputEditor/UploadInputEditor.svelte'
import { getContext, createEventDispatcher } from 'svelte'
import type { AppViewerContext, RichConfiguration } from '../../types'
import type { InputConnection, InputType, UploadAppInput } from '../../inputType'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import S3FilePicker from '$lib/components/S3FilePicker.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import { FunctionSquare, Loader2, Pen, Plug2, Upload, User } from 'lucide-svelte'
import {
FunctionSquare,
Loader2,
Pen,
Plug2,
Upload,
UploadCloud,
User,
Pipette
} from 'lucide-svelte'
import { fieldTypeToTsType } from '../../utils'
import EvalV2InputEditor from './inputEditor/EvalV2InputEditor.svelte'
import ConnectionButton from '$lib/components/common/button/ConnectionButton.svelte'
@@ -30,6 +41,7 @@
export let format: string | undefined
export let selectOptions: string[] | undefined
export let fileUpload: UploadAppInput['fileUpload'] | undefined = undefined
export let fileUploadS3: UploadAppInput['fileUploadS3'] | undefined = undefined
export let placeholder: string | undefined
export let customTitle: string | undefined = undefined
export let displayType: boolean = false
@@ -44,11 +56,22 @@
export let markdownTooltip: string | undefined = undefined
export let securedContext = false
const { connectingInput, app } = getContext<AppViewerContext>('AppViewerContext')
const { connectingInput, app, workspace } = getContext<AppViewerContext>('AppViewerContext')
const dispatch = createEventDispatcher()
let evalV2editor: EvalV2InputEditor
let s3FilePicker: S3FilePicker | undefined
let s3PickerSelection: { s3: string; storage?: string } | undefined = undefined
let s3FolderPrefix: string = ''
$: s3PickerSelection && updateSelectedS3File()
function updateSelectedS3File() {
if (s3PickerSelection) {
componentInput['value'] = `s3://${s3PickerSelection.s3}`
}
}
function applyConnection(connection: InputConnection) {
const expr = `${connection.componentId}.${connection.path}`
//@ts-ignore
@@ -150,6 +173,15 @@
{#if fileUpload}
<ToggleButton value="upload" icon={Upload} iconOnly tooltip="Upload" {item} />
{/if}
{#if fileUploadS3}
<ToggleButton
value="uploadS3"
icon={UploadCloud}
iconOnly
tooltip="Upload S3"
{item}
/>
{/if}
{#if componentInput?.type === 'connected'}
<ToggleButton value="connected" icon={Plug2} iconOnly tooltip="Connect" {item} />
{/if}
@@ -201,6 +233,38 @@
/>
{:else if componentInput?.type === 'upload'}
<UploadInputEditor bind:componentInput {fileUpload} />
{:else if componentInput?.type === 'uploadS3'}
<div class="w-12/12 pb-2 flex flex-row mb-1 gap-1">
<input type="text" placeholder="S3 Folder prefix" bind:value={s3FolderPrefix} aria-label="S3 Folder prefix" />
</div>
<UploadInputEditor
bind:componentInput
fileUpload={fileUploadS3}
s3={true}
{workspace}
prefix={s3FolderPrefix}
/>
<Button
variant="border"
color="light"
size="xs"
btnClasses="mt-1"
on:click={() => {
s3PickerSelection = undefined
s3FilePicker?.open?.()
}}
startIcon={{ icon: Pipette }}
>
Choose an existing file
</Button>
<S3FilePicker
bind:this={s3FilePicker}
folderOnly={false}
fromWorkspaceSettings={true}
bind:selectedFileKey={s3PickerSelection}
readOnlyMode={false}
regexFilter={/\.(png|jpg|jpeg|svg|webp)$/i}
/>
{:else if componentInput?.type === 'user'}
<span class="text-2xs italic text-tertiary">Field's value is set by the user</span>
{/if}
@@ -71,6 +71,7 @@
selectOptions={meta?.['selectOptions']}
tooltip={meta?.['tooltip']}
fileUpload={meta?.['fileUpload']}
fileUploadS3={meta?.['fileUploadS3']}
placeholder={meta?.['placeholder']}
customTitle={meta?.['customTitle']}
loading={meta?.['loading']}
@@ -1,25 +1,59 @@
<script lang="ts">
import { FileInput } from '../../../../common'
import type { UploadAppInput } from '../../../inputType'
import type { UploadAppInput, UploadS3AppInput, FileUploadData } from '../../../inputType'
import type { ReadFileAs } from '../../../../common/fileInput/model'
import FileUpload from '$lib/components/common/fileUpload/FileUpload.svelte'
import { writable, type Writable } from 'svelte/store'
export let componentInput: UploadAppInput | undefined
export let fileUpload: UploadAppInput['fileUpload'] | undefined
export let componentInput: UploadAppInput | UploadS3AppInput | undefined
export let fileUpload: UploadAppInput['fileUpload'] | UploadS3AppInput['fileUploadS3'] | undefined
export let s3: boolean | undefined = false
export let prefix: string | undefined = undefined
export let workspace: string | undefined = undefined
let fileUploads: Writable<FileUploadData[]> = writable([])
function hasConvertTo(upload: any): upload is { convertTo: ReadFileAs } {
return upload && 'convertTo' in upload
}
</script>
<FileInput
accept={fileUpload?.accept}
multiple={fileUpload?.multiple}
convertTo={fileUpload?.convertTo}
iconSize={24}
class="text-sm py-4"
on:change={({ detail }) => {
if (componentInput) {
componentInput.value = fileUpload?.multiple ? detail : detail?.[0]
}
}}
>
<svelte:fragment slot="selected-title">
<!-- Removing the title when there is a selected file -->
<span />
</svelte:fragment>
</FileInput>
{#if s3}
<FileUpload
acceptedFileTypes={[fileUpload?.accept ?? '*']}
allowMultiple={fileUpload?.multiple}
containerText={'Drag and drop a file'}
customResourceType="s3"
iconSize={24}
customClass="text-sm py-4"
{fileUploads}
{workspace}
pathTransformer={({ file }) => {
const cleanPrefix = prefix ? `${prefix.replace(/^\/+|\/+$/g, '')}/` : ''
return `${cleanPrefix}${file.name}`
}}
on:addition={({ detail }) => {
if (componentInput) {
componentInput.value = `s3://${detail.path}`
}
}}
/>
{:else}
<FileInput
accept={fileUpload?.accept}
multiple={fileUpload?.multiple}
convertTo={hasConvertTo(fileUpload) ? fileUpload.convertTo : undefined}
iconSize={24}
class="text-sm py-4"
on:change={({ detail }) => {
if (componentInput) {
componentInput.value = fileUpload?.multiple ? detail : detail?.[0]
}
}}
>
<svelte:fragment slot="selected-title">
<!-- Removing the title when there is a selected file -->
<span />
</svelte:fragment>
</FileInput>
{/if}
@@ -76,6 +76,21 @@ export type UploadInput = {
value: string
}
export type UploadS3Input = {
type: 'uploadS3'
value: string
}
export type FileUploadData = {
name: string
size: number
progress: number
cancelled?: boolean
errorMessage?: string
path?: string
file?: File
}
export type EvalInput = {
type: 'eval'
expr: string
@@ -149,6 +164,7 @@ export type AppInputSpec<T extends InputType, U, V extends InputType = never> =
| EvalInput
| EvalInputV2
| UploadInput
| UploadS3Input
| ResultInput
| TemplateInput
| TemplateV2Input
@@ -174,6 +190,10 @@ type InputConfiguration<T extends InputType, V extends InputType> = {
*/
convertTo?: ReadFileAs
}
fileUploadS3?: {
accept: string
multiple?: boolean
}
noStatic?: boolean
onDemandOnly?: boolean
hideRefreshButton?: boolean
@@ -243,6 +263,7 @@ export type StaticAppInputOnDemand = Extract<StaticAppInput, { onDemandOnly: tru
export type TemplateV2AppInput = Extract<AppInput, { type: 'templatev2' }>
export type UploadAppInput = Extract<AppInput, { type: 'upload' }>
export type UploadS3AppInput = Extract<AppInput, { type: 'uploadS3' }>
export type RichAppInput =
| AppInput
+3 -1
View File
@@ -22,7 +22,8 @@ import type {
StaticAppInput,
TemplateV2AppInput,
UploadAppInput,
UserAppInput
UploadS3AppInput,
UserAppInput,
} from './inputType'
import type { World } from './rx'
import type { FilledItem } from './svelte-grid/types'
@@ -59,6 +60,7 @@ export type Configuration =
| EvalAppInput
| EvalV2AppInput
| UploadAppInput
| UploadS3AppInput
| ResultAppInput
| TemplateV2AppInput
@@ -33,6 +33,7 @@
export let fileUploads: Writable<FileUploadData[]> = writable([])
export let appPath: string | undefined = undefined
export let disabled = false
export let iconSize: number | undefined = undefined
export let initialValue:
| {
s3: string
@@ -534,6 +535,7 @@
accept={acceptedFileTypes?.join(',')}
multiple={allowMultiple}
returnFileNames
iconSize={iconSize}
on:change={({ detail }) => {
forceDisplayUploads = false
handleChange(detail)