diff --git a/client/packages/lowcoder/src/comps/comps/fileComp/draggerUpload.tsx b/client/packages/lowcoder/src/comps/comps/fileComp/draggerUpload.tsx index 2f230ad38..22f08989d 100644 --- a/client/packages/lowcoder/src/comps/comps/fileComp/draggerUpload.tsx +++ b/client/packages/lowcoder/src/comps/comps/fileComp/draggerUpload.tsx @@ -1,7 +1,7 @@ import { default as AntdUpload } from "antd/es/upload"; import { default as Button } from "antd/es/button"; import { UploadFile, UploadChangeParam, UploadFileStatus, RcFile } from "antd/es/upload/interface"; -import { useState, useEffect } from "react"; +import { useState, useMemo } from "react"; import styled, { css } from "styled-components"; import { trans } from "i18n"; import _ from "lodash"; @@ -11,8 +11,7 @@ import { multiChangeAction, } from "lowcoder-core"; import { hasIcon } from "comps/utils"; -import { messageInstance } from "lowcoder-design/src/components/GlobalInstances"; -import { resolveValue, resolveParsedValue, commonProps } from "./fileComp"; +import { resolveValue, resolveParsedValue, commonProps, validateFile } from "./fileComp"; import { FileStyleType, AnimationStyleType, heightCalculator, widthCalculator } from "comps/controls/styleControlConstants"; import { ImageCaptureModal } from "./ImageCaptureModal"; import { v4 as uuidv4 } from "uuid"; @@ -152,6 +151,7 @@ interface DraggerUploadProps { minSize: number; maxSize: number; maxFiles: number; + fileNamePattern: string; uploadType: "single" | "multiple" | "directory"; text: string; dragHintText?: string; @@ -162,25 +162,27 @@ interface DraggerUploadProps { export const DraggerUpload = (props: DraggerUploadProps) => { const { dispatch, files, style, autoHeight, animationStyle } = props; - const [fileList, setFileList] = useState( - files.map((f) => ({ ...f, status: "done" })) as UploadFile[] - ); + // Track only files currently being uploaded (not yet in props.files) + const [uploadingFiles, setUploadingFiles] = useState([]); const [showModal, setShowModal] = useState(false); const isMobile = checkIsMobile(window.innerWidth); - useEffect(() => { - if (files.length === 0 && fileList.length !== 0) { - setFileList([]); - } - }, [files]); + // Derive fileList from props.files (source of truth) + currently uploading files + const fileList = useMemo(() => [ + ...(files.map((f) => ({ ...f, status: "done" as const })) as UploadFile[]), + ...uploadingFiles, + ], [files, uploadingFiles]); const handleOnChange = (param: UploadChangeParam) => { - const uploadingFiles = param.fileList.filter((f) => f.status === "uploading"); - if (uploadingFiles.length !== 0) { - setFileList(param.fileList); + const currentlyUploading = param.fileList.filter((f) => f.status === "uploading"); + if (currentlyUploading.length !== 0) { + setUploadingFiles(currentlyUploading); return; } + // Clear uploading state when all uploads complete + setUploadingFiles([]); + let maxFiles = props.maxFiles; if (props.uploadType === "single") { maxFiles = 1; @@ -240,8 +242,6 @@ export const DraggerUpload = (props: DraggerUploadProps) => { props.onEvent("parse"); }); } - - setFileList(uploadedFiles.slice(-maxFiles)); }; return ( @@ -254,21 +254,11 @@ export const DraggerUpload = (props: DraggerUploadProps) => { $auto={autoHeight} capture={props.forceCapture} openFileDialogOnClick={!(props.forceCapture && !isMobile)} - beforeUpload={(file) => { - if (!file.size || file.size <= 0) { - messageInstance.error(`${file.name} ` + trans("file.fileEmptyErrorMsg")); - return AntdUpload.LIST_IGNORE; - } - - if ( - (!!props.minSize && file.size < props.minSize) || - (!!props.maxSize && file.size > props.maxSize) - ) { - messageInstance.error(`${file.name} ` + trans("file.fileSizeExceedErrorMsg")); - return AntdUpload.LIST_IGNORE; - } - return true; - }} + beforeUpload={(file) => validateFile(file, { + minSize: props.minSize, + maxSize: props.maxSize, + fileNamePattern: props.fileNamePattern, + })} onChange={handleOnChange} >

diff --git a/client/packages/lowcoder/src/comps/comps/fileComp/fileComp.tsx b/client/packages/lowcoder/src/comps/comps/fileComp/fileComp.tsx index 360a81556..d18f7c822 100644 --- a/client/packages/lowcoder/src/comps/comps/fileComp/fileComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/fileComp/fileComp.tsx @@ -24,7 +24,7 @@ import { RecordConstructorToView, } from "lowcoder-core"; import { UploadRequestOption } from "rc-upload/lib/interface"; -import { Suspense, useCallback, useEffect, useRef, useState } from "react"; +import { Suspense, useCallback, useMemo, useRef, useState } from "react"; import styled, { css } from "styled-components"; import { JSONObject, JSONValue } from "../../../util/jsonTypes"; import { BoolControl, BoolPureControl } from "../../controls/boolControl"; @@ -97,6 +97,7 @@ const validationChildren = { minSize: FileSizeControl, maxSize: FileSizeControl, maxFiles: NumberControl, + fileNamePattern: StringControl, }; const commonChildren = { @@ -127,6 +128,11 @@ const commonValidationFields = (children: RecordConstructorToComp options.onSuccess && options.onSuccess({}), // Override the default upload logic and do not upload to the specified server }); +export interface FileValidationOptions { + minSize?: number; + maxSize?: number; + fileNamePattern?: string; +} + + +export const validateFile = ( + file: { name: string; size?: number }, + options: FileValidationOptions +): boolean | typeof AntdUpload.LIST_IGNORE => { + // Empty file validation + if (!file.size || file.size <= 0) { + messageInstance.error(`${file.name} ` + trans("file.fileEmptyErrorMsg")); + return AntdUpload.LIST_IGNORE; + } + + // File size validation + if ( + (!!options.minSize && file.size < options.minSize) || + (!!options.maxSize && file.size > options.maxSize) + ) { + messageInstance.error(`${file.name} ` + trans("file.fileSizeExceedErrorMsg")); + return AntdUpload.LIST_IGNORE; + } + + // File name pattern validation + if (options.fileNamePattern) { + try { + const pattern = new RegExp(options.fileNamePattern); + if (!pattern.test(file.name)) { + messageInstance.error(`${file.name} ` + trans("file.fileNamePatternErrorMsg")); + return AntdUpload.LIST_IGNORE; + } + } catch (e) { + messageInstance.error(trans("file.invalidFileNamePatternMsg", { error: String(e) })); + return AntdUpload.LIST_IGNORE; + } + } + + return true; +}; + const getStyle = (style: FileStyleType) => { return css` .ant-btn { @@ -265,29 +314,32 @@ const Upload = ( }, ) => { const { dispatch, files, style } = props; - const [fileList, setFileList] = useState( - files.map((f) => ({ ...f, status: "done" })) as UploadFile[] - ); + // Track only files currently being uploaded (not yet in props.files) + const [uploadingFiles, setUploadingFiles] = useState([]); const [showModal, setShowModal] = useState(false); const isMobile = checkIsMobile(window.innerWidth); - useEffect(() => { - if (files.length === 0 && fileList.length !== 0) { - setFileList([]); - } - }, [files]); + // Derive fileList from props.files (source of truth) + currently uploading files + const fileList = useMemo(() => [ + ...(files.map((f) => ({ ...f, status: "done" as const })) as UploadFile[]), + ...uploadingFiles, + ], [files, uploadingFiles]); + // chrome86 bug: button children should not contain only empty span const hasChildren = hasIcon(props.prefixIcon) || !!props.text || hasIcon(props.suffixIcon); const handleOnChange = (param: UploadChangeParam) => { - const uploadingFiles = param.fileList.filter((f) => f.status === "uploading"); + const currentlyUploading = param.fileList.filter((f) => f.status === "uploading"); // the onChange callback will be executed when the state of the antd upload file changes. // so make a trick logic: the file list with loading will not be processed - if (uploadingFiles.length !== 0) { - setFileList(param.fileList); + if (currentlyUploading.length !== 0) { + setUploadingFiles(currentlyUploading); return; } + // Clear uploading state when all uploads complete + setUploadingFiles([]); + let maxFiles = props.maxFiles; if (props.uploadType === "single") { maxFiles = 1; @@ -348,8 +400,6 @@ const Upload = ( props.onEvent("parse"); }); } - - setFileList(uploadedFiles.slice(-maxFiles)); }; return ( @@ -360,21 +410,11 @@ const Upload = ( {...commonProps(props)} $style={style} fileList={fileList} - beforeUpload={(file) => { - if (!file.size || file.size <= 0) { - messageInstance.error(`${file.name} ` + trans("file.fileEmptyErrorMsg")); - return AntdUpload.LIST_IGNORE; - } - - if ( - (!!props.minSize && file.size < props.minSize) || - (!!props.maxSize && file.size > props.maxSize) - ) { - messageInstance.error(`${file.name} ` + trans("file.fileSizeExceedErrorMsg")); - return AntdUpload.LIST_IGNORE; - } - return true; - }} + beforeUpload={(file) => validateFile(file, { + minSize: props.minSize, + maxSize: props.maxSize, + fileNamePattern: props.fileNamePattern, + })} onChange={handleOnChange} > @@ -552,6 +592,40 @@ const FileWithMethods = withMethodExposing(FileImplComp, [ }) ), }, + { + method: { + name: "clearValueAt", + description: trans("file.clearValueAtDesc"), + params: [{ name: "index", type: "number" }], + }, + execute: (comp, params) => { + const index = params[0] as number; + const value = comp.children.value.getView(); + const files = comp.children.files.getView(); + const parsedValue = comp.children.parsedValue.getView(); + + if (index < 0 || index >= files.length) { + return; + } + + comp.dispatch( + multiChangeAction({ + value: changeValueAction( + [...value.slice(0, index), ...value.slice(index + 1)], + false + ), + files: changeValueAction( + [...files.slice(0, index), ...files.slice(index + 1)], + false + ), + parsedValue: changeValueAction( + [...parsedValue.slice(0, index), ...parsedValue.slice(index + 1)], + false + ), + }) + ); + }, + }, ]); export const FileComp = withExposingConfigs(FileWithMethods, [ diff --git a/client/packages/lowcoder/src/i18n/locales/en.ts b/client/packages/lowcoder/src/i18n/locales/en.ts index 8cbc26404..7bccf6aad 100644 --- a/client/packages/lowcoder/src/i18n/locales/en.ts +++ b/client/packages/lowcoder/src/i18n/locales/en.ts @@ -1934,6 +1934,7 @@ export const en = { "filesValueDesc": "The Contents of the Currently Uploaded File Are Base64 Encoded", "filesDesc": "List of the Current Uploaded Files. For Details, Refer to", "clearValueDesc": "Clear All Files", + "clearValueAtDesc": "Clear File at Index", "parseFiles": "Parse Files", "parsedValueTooltip1": "If parseFiles Is True, Upload Files Will Parse to Object, Array, or String. Parsed Data Can Be Accessed via the parsedValue Array.", "parsedValueTooltip2": "Supports Excel, JSON, CSV, and Text Files. Other Formats Will Return Null.", @@ -1948,6 +1949,11 @@ export const en = { "dragAreaText": "Click or drag file to this area to upload", "dragAreaHint": "Support for a single or bulk upload. Strictly prohibited from uploading company data or other banned files.", "dragHintText": "Hint Text", + "fileNamePattern": "File Name Pattern", + "fileNamePatternTooltip": "A regular expression pattern to validate file names (e.g., '^[a-zA-Z0-9_-]+\\.[a-z]+$' for alphanumeric names). Leave empty to allow all file names.", + "fileNamePatternPlaceholder": "^[a-zA-Z0-9_-]+\\.[a-z]+$", + "fileNamePatternErrorMsg": "Upload Failed. The File Name Does Not Match the Required Pattern.", + "invalidFileNamePatternMsg": "Invalid File Name Pattern: {error}", }, "date": { "format": "Format",