Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 21 additions & 31 deletions client/packages/lowcoder/src/comps/comps/fileComp/draggerUpload.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -152,6 +151,7 @@ interface DraggerUploadProps {
minSize: number;
maxSize: number;
maxFiles: number;
fileNamePattern: string;
uploadType: "single" | "multiple" | "directory";
text: string;
dragHintText?: string;
Expand All @@ -162,25 +162,27 @@ interface DraggerUploadProps {

export const DraggerUpload = (props: DraggerUploadProps) => {
const { dispatch, files, style, autoHeight, animationStyle } = props;
const [fileList, setFileList] = useState<UploadFile[]>(
files.map((f) => ({ ...f, status: "done" })) as UploadFile[]
);
// Track only files currently being uploaded (not yet in props.files)
const [uploadingFiles, setUploadingFiles] = useState<UploadFile[]>([]);
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<UploadFile[]>(() => [
...(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;
Expand Down Expand Up @@ -240,8 +242,6 @@ export const DraggerUpload = (props: DraggerUploadProps) => {
props.onEvent("parse");
});
}

setFileList(uploadedFiles.slice(-maxFiles));
};

return (
Expand All @@ -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}
>
<p className="ant-upload-drag-icon">
Expand Down
132 changes: 103 additions & 29 deletions client/packages/lowcoder/src/comps/comps/fileComp/fileComp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -97,6 +97,7 @@ const validationChildren = {
minSize: FileSizeControl,
maxSize: FileSizeControl,
maxFiles: NumberControl,
fileNamePattern: StringControl,
};

const commonChildren = {
Expand Down Expand Up @@ -127,6 +128,11 @@ const commonValidationFields = (children: RecordConstructorToComp<typeof validat
placeholder: "10kb",
tooltip: trans("file.maxSizeTooltip"),
}),
children.fileNamePattern.propertyView({
label: trans("file.fileNamePattern"),
placeholder: trans("file.fileNamePatternPlaceholder"),
tooltip: trans("file.fileNamePatternTooltip"),
}),
];

export const commonProps = (
Expand All @@ -141,6 +147,49 @@ export const commonProps = (
customRequest: (options: UploadRequestOption) => 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 {
Expand Down Expand Up @@ -265,29 +314,32 @@ const Upload = (
},
) => {
const { dispatch, files, style } = props;
const [fileList, setFileList] = useState<UploadFile[]>(
files.map((f) => ({ ...f, status: "done" })) as UploadFile[]
);
// Track only files currently being uploaded (not yet in props.files)
const [uploadingFiles, setUploadingFiles] = useState<UploadFile[]>([]);
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<UploadFile[]>(() => [
...(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;
Expand Down Expand Up @@ -348,8 +400,6 @@ const Upload = (
props.onEvent("parse");
});
}

setFileList(uploadedFiles.slice(-maxFiles));
};

return (
Expand All @@ -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}

>
Expand Down Expand Up @@ -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, [
Expand Down
6 changes: 6 additions & 0 deletions client/packages/lowcoder/src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand All @@ -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",
Expand Down
Loading