From 7f88461f2fd29a052bcc2484ef4125f9ba05bcb7 Mon Sep 17 00:00:00 2001 From: Krystian Panek Date: Fri, 6 Jun 2025 08:28:50 +0200 Subject: [PATCH 01/25] File argument PoC --- .../vml/es/acm/core/code/ArgumentType.java | 3 +- .../dev/vml/es/acm/core/code/Arguments.java | 10 +++++ .../es/acm/core/code/arg/FileArgument.java | 13 ++++++ .../vml/es/acm/core/servlet/FileOutput.java | 17 ++++++++ .../vml/es/acm/core/servlet/FileServlet.java | 43 +++++++++++++++++++ .../jcr_root/apps/acm/api/file/.content.xml | 5 +++ 6 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 core/src/main/java/dev/vml/es/acm/core/code/arg/FileArgument.java create mode 100644 core/src/main/java/dev/vml/es/acm/core/servlet/FileOutput.java create mode 100644 core/src/main/java/dev/vml/es/acm/core/servlet/FileServlet.java create mode 100644 ui.apps/src/main/content/jcr_root/apps/acm/api/file/.content.xml diff --git a/core/src/main/java/dev/vml/es/acm/core/code/ArgumentType.java b/core/src/main/java/dev/vml/es/acm/core/code/ArgumentType.java index 3b2d26af..e9a42bf5 100644 --- a/core/src/main/java/dev/vml/es/acm/core/code/ArgumentType.java +++ b/core/src/main/java/dev/vml/es/acm/core/code/ArgumentType.java @@ -13,5 +13,6 @@ public enum ArgumentType { MULTISELECT, COLOR, NUMBER_RANGE, - PATH + PATH, + FILE } diff --git a/core/src/main/java/dev/vml/es/acm/core/code/Arguments.java b/core/src/main/java/dev/vml/es/acm/core/code/Arguments.java index 8a88ee08..c46b253d 100644 --- a/core/src/main/java/dev/vml/es/acm/core/code/Arguments.java +++ b/core/src/main/java/dev/vml/es/acm/core/code/Arguments.java @@ -231,4 +231,14 @@ public void path(String name, Closure options) { GroovyUtils.with(argument, options); add(argument); } + + public void file(String name) { + file(name, null); + } + + public void file(String name, Closure options) { + FileArgument argument = new FileArgument(name); + GroovyUtils.with(argument, options); + add(argument); + } } diff --git a/core/src/main/java/dev/vml/es/acm/core/code/arg/FileArgument.java b/core/src/main/java/dev/vml/es/acm/core/code/arg/FileArgument.java new file mode 100644 index 00000000..a8f5137a --- /dev/null +++ b/core/src/main/java/dev/vml/es/acm/core/code/arg/FileArgument.java @@ -0,0 +1,13 @@ +package dev.vml.es.acm.core.code.arg; + +import dev.vml.es.acm.core.code.Argument; +import dev.vml.es.acm.core.code.ArgumentType; + +import java.io.File; + +public class FileArgument extends Argument { + + public FileArgument(String name) { + super(name, ArgumentType.FILE, File.class); + } +} diff --git a/core/src/main/java/dev/vml/es/acm/core/servlet/FileOutput.java b/core/src/main/java/dev/vml/es/acm/core/servlet/FileOutput.java new file mode 100644 index 00000000..d1bef5ca --- /dev/null +++ b/core/src/main/java/dev/vml/es/acm/core/servlet/FileOutput.java @@ -0,0 +1,17 @@ +package dev.vml.es.acm.core.servlet; + +import java.io.File; +import java.io.Serializable; + +public class FileOutput implements Serializable { + + private File file; + + public FileOutput(File file) { + this.file = file; + } + + public File getFile() { + return file; + } +} diff --git a/core/src/main/java/dev/vml/es/acm/core/servlet/FileServlet.java b/core/src/main/java/dev/vml/es/acm/core/servlet/FileServlet.java new file mode 100644 index 00000000..964e05f5 --- /dev/null +++ b/core/src/main/java/dev/vml/es/acm/core/servlet/FileServlet.java @@ -0,0 +1,43 @@ +package dev.vml.es.acm.core.servlet; + +import org.apache.sling.api.SlingHttpServletRequest; +import org.apache.sling.api.SlingHttpServletResponse; +import org.apache.sling.api.servlets.ServletResolverConstants; +import org.apache.sling.api.servlets.SlingAllMethodsServlet; +import org.osgi.service.component.annotations.Component; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.servlet.Servlet; +import java.io.File; +import java.io.IOException; + +import static dev.vml.es.acm.core.util.ServletResult.error; +import static dev.vml.es.acm.core.util.ServletResult.ok; +import static dev.vml.es.acm.core.util.ServletUtils.respondJson; + +@Component( + service = {Servlet.class}, + property = { + ServletResolverConstants.SLING_SERVLET_RESOURCE_TYPES + "=" + FileServlet.RT, + ServletResolverConstants.SLING_SERVLET_METHODS + "=POST", + ServletResolverConstants.SLING_SERVLET_EXTENSIONS + "=json", + }) +public class FileServlet extends SlingAllMethodsServlet { + + public static final String RT = "acm/api/file"; + + private static final Logger LOG = LoggerFactory.getLogger(FileServlet.class); + + @Override + protected void doPost(SlingHttpServletRequest request, SlingHttpServletResponse response) throws IOException { + try { + File file = new File("/tmp/acm/executableId/number/originalFileName.txt"); + FileOutput output = new FileOutput(file); + respondJson(response, ok("File uploaded successfully", output)); + } catch (Exception e) { + LOG.error("File cannot be uploaded!", e); + respondJson(response, error(String.format("File cannot be uploaded! %s", e.getMessage().trim()))); + } + } +} diff --git a/ui.apps/src/main/content/jcr_root/apps/acm/api/file/.content.xml b/ui.apps/src/main/content/jcr_root/apps/acm/api/file/.content.xml new file mode 100644 index 00000000..57924282 --- /dev/null +++ b/ui.apps/src/main/content/jcr_root/apps/acm/api/file/.content.xml @@ -0,0 +1,5 @@ + + From 5501f5d02d6ab5996c5799b523a3041f328c2b1f Mon Sep 17 00:00:00 2001 From: Krystian Panek Date: Fri, 6 Jun 2025 08:42:53 +0200 Subject: [PATCH 02/25] Multi file PoC --- .../dev/vml/es/acm/core/code/Arguments.java | 10 ++++++++++ .../acm/core/code/arg/MultiFileArgument.java | 13 +++++++++++++ .../vml/es/acm/core/servlet/FileOutput.java | 11 ++++++----- .../vml/es/acm/core/servlet/FileServlet.java | 4 +++- .../src/components/CodeArgumentInput.tsx | 6 +++++- ui.frontend/src/utils/api.types.ts | 18 +++++++++++++++--- 6 files changed, 52 insertions(+), 10 deletions(-) create mode 100644 core/src/main/java/dev/vml/es/acm/core/code/arg/MultiFileArgument.java diff --git a/core/src/main/java/dev/vml/es/acm/core/code/Arguments.java b/core/src/main/java/dev/vml/es/acm/core/code/Arguments.java index c46b253d..150a7875 100644 --- a/core/src/main/java/dev/vml/es/acm/core/code/Arguments.java +++ b/core/src/main/java/dev/vml/es/acm/core/code/Arguments.java @@ -241,4 +241,14 @@ public void file(String name, Closure options) { GroovyUtils.with(argument, options); add(argument); } + + public void multiFile(String name) { + multiFile(name, null); + } + + public void multiFile(String name, Closure options) { + MultiFileArgument argument = new MultiFileArgument(name); + GroovyUtils.with(argument, options); + add(argument); + } } diff --git a/core/src/main/java/dev/vml/es/acm/core/code/arg/MultiFileArgument.java b/core/src/main/java/dev/vml/es/acm/core/code/arg/MultiFileArgument.java new file mode 100644 index 00000000..bcfdbf40 --- /dev/null +++ b/core/src/main/java/dev/vml/es/acm/core/code/arg/MultiFileArgument.java @@ -0,0 +1,13 @@ +package dev.vml.es.acm.core.code.arg; + +import dev.vml.es.acm.core.code.Argument; +import dev.vml.es.acm.core.code.ArgumentType; + +import java.io.File; + +public class MultiFileArgument extends Argument { + + public MultiFileArgument(String name) { + super(name, ArgumentType.FILE, File[].class); + } +} diff --git a/core/src/main/java/dev/vml/es/acm/core/servlet/FileOutput.java b/core/src/main/java/dev/vml/es/acm/core/servlet/FileOutput.java index d1bef5ca..ec9d1638 100644 --- a/core/src/main/java/dev/vml/es/acm/core/servlet/FileOutput.java +++ b/core/src/main/java/dev/vml/es/acm/core/servlet/FileOutput.java @@ -2,16 +2,17 @@ import java.io.File; import java.io.Serializable; +import java.util.List; public class FileOutput implements Serializable { - private File file; + private List files; - public FileOutput(File file) { - this.file = file; + public FileOutput(List files) { + this.files = files; } - public File getFile() { - return file; + public List getFiles() { + return files; } } diff --git a/core/src/main/java/dev/vml/es/acm/core/servlet/FileServlet.java b/core/src/main/java/dev/vml/es/acm/core/servlet/FileServlet.java index 964e05f5..4cdd2023 100644 --- a/core/src/main/java/dev/vml/es/acm/core/servlet/FileServlet.java +++ b/core/src/main/java/dev/vml/es/acm/core/servlet/FileServlet.java @@ -11,6 +11,7 @@ import javax.servlet.Servlet; import java.io.File; import java.io.IOException; +import java.util.Collections; import static dev.vml.es.acm.core.util.ServletResult.error; import static dev.vml.es.acm.core.util.ServletResult.ok; @@ -32,8 +33,9 @@ public class FileServlet extends SlingAllMethodsServlet { @Override protected void doPost(SlingHttpServletRequest request, SlingHttpServletResponse response) throws IOException { try { + // TODO support uploading multiple files File file = new File("/tmp/acm/executableId/number/originalFileName.txt"); - FileOutput output = new FileOutput(file); + FileOutput output = new FileOutput(Collections.singletonList(file)); respondJson(response, ok("File uploaded successfully", output)); } catch (Exception e) { LOG.error("File cannot be uploaded!", e); diff --git a/ui.frontend/src/components/CodeArgumentInput.tsx b/ui.frontend/src/components/CodeArgumentInput.tsx index 4cbe248c..02d80750 100644 --- a/ui.frontend/src/components/CodeArgumentInput.tsx +++ b/ui.frontend/src/components/CodeArgumentInput.tsx @@ -32,7 +32,7 @@ import { ArgumentValue, isBoolArgument, isColorArgument, - isDateTimeArgument, + isDateTimeArgument, isFileArgument, isMultiFileArgument, isMultiSelectArgument, isNumberArgument, isPathArgument, @@ -317,6 +317,10 @@ const CodeArgumentInput: React.FC = ({ arg }) => { validationState={fieldState.error ? 'invalid' : 'valid'} /> ); + } else if (isFileArgument(arg)) { + // TODO implement single-file argument input + } else if (isMultiFileArgument(arg)) { + // TODO implement multi-file argument input } else { throw new Error(`Unsupported argument type: ${arg.type}`); } diff --git a/ui.frontend/src/utils/api.types.ts b/ui.frontend/src/utils/api.types.ts index eafdd5eb..0881a0e9 100644 --- a/ui.frontend/src/utils/api.types.ts +++ b/ui.frontend/src/utils/api.types.ts @@ -27,7 +27,7 @@ export type Description = { }; }; -export type ArgumentType = 'BOOL' | 'STRING' | 'TEXT' | 'SELECT' | 'MULTISELECT' | 'INTEGER' | 'DECIMAL' | 'DATETIME' | 'DATE' | 'TIME' | 'COLOR' | 'NUMBER_RANGE' | 'PATH'; +export type ArgumentType = 'BOOL' | 'STRING' | 'TEXT' | 'SELECT' | 'MULTISELECT' | 'INTEGER' | 'DECIMAL' | 'DATETIME' | 'DATE' | 'TIME' | 'COLOR' | 'NUMBER_RANGE' | 'PATH' | 'FILE' | 'MULTIFILE'; export type ArgumentValue = string | string[] | number | number[] | boolean | null | undefined | RangeValue; export type ArgumentValues = Record; @@ -98,6 +98,10 @@ export type PathArgument = Argument & { rootInclusive: boolean; }; +export type FileArgument = Argument & { + mimeTypes: string[]; +}; + export function isStringArgument(arg: Argument): arg is StringArgument { return arg.type === 'STRING'; } @@ -118,6 +122,10 @@ export function isSelectArgument(arg: Argument): arg is SelectArg return arg.type === 'SELECT'; } +export function isMultiSelectArgument(arg: Argument): arg is MultiSelectArgument { + return arg.type === 'MULTISELECT'; +} + export function isNumberArgument(arg: Argument): arg is NumberArgument { return arg.type === 'INTEGER' || arg.type === 'DECIMAL'; } @@ -134,8 +142,12 @@ export function isPathArgument(arg: Argument): arg is PathArgumen return arg.type === 'PATH'; } -export function isMultiSelectArgument(arg: Argument): arg is MultiSelectArgument { - return arg.type === 'MULTISELECT'; +export function isFileArgument(arg: Argument): arg is FileArgument { + return arg.type === 'FILE'; +} + +export function isMultiFileArgument(arg: Argument): arg is FileArgument { + return arg.type === 'MULTIFILE'; } export type Execution = { From 96b3e394eabb75fa0a3c1d4e600d68cdfbc63592 Mon Sep 17 00:00:00 2001 From: Krystian Panek Date: Fri, 6 Jun 2025 14:41:06 +0200 Subject: [PATCH 03/25] Base UI --- .../core/code/arg/AbstractFileArgument.java | 45 ++++++ .../es/acm/core/code/arg/FileArgument.java | 3 +- .../acm/core/code/arg/MultiFileArgument.java | 5 +- .../src/components/CodeArgumentInput.tsx | 17 ++- ui.frontend/src/components/FileInput.tsx | 132 ++++++++++++++++++ ui.frontend/src/utils/api.types.ts | 4 + 6 files changed, 199 insertions(+), 7 deletions(-) create mode 100644 core/src/main/java/dev/vml/es/acm/core/code/arg/AbstractFileArgument.java create mode 100644 ui.frontend/src/components/FileInput.tsx diff --git a/core/src/main/java/dev/vml/es/acm/core/code/arg/AbstractFileArgument.java b/core/src/main/java/dev/vml/es/acm/core/code/arg/AbstractFileArgument.java new file mode 100644 index 00000000..4b9810fe --- /dev/null +++ b/core/src/main/java/dev/vml/es/acm/core/code/arg/AbstractFileArgument.java @@ -0,0 +1,45 @@ +package dev.vml.es.acm.core.code.arg; + +import dev.vml.es.acm.core.code.Argument; +import dev.vml.es.acm.core.code.ArgumentType; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +abstract class AbstractFileArgument extends Argument { + + private List mimeTypes; + + public AbstractFileArgument(String name, ArgumentType type, Class valueType) { + super(name, type, valueType); + } + + public List getMimeTypes() { + return mimeTypes; + } + + public void setMimeTypes(List mimeTypes) { + this.mimeTypes = mimeTypes; + } + + public void images() { + this.mimeTypes = Arrays.asList( + "image/png", "image/jpeg", "image/gif", "image/webp", "image/bmp", "image/tiff", "image/svg+xml" + ); + } + + public void videos() { + this.mimeTypes = Arrays.asList( + "video/mp4", "video/webm", "video/ogg", "video/avi", "video/mpeg", "video/quicktime" + ); + } + + public void audios() { + this.mimeTypes = Arrays.asList("audio/mpeg", "audio/wav", "audio/ogg", "audio/aac", "audio/flac", "audio/mp3"); + } + + public void pdfs() { + this.mimeTypes = Collections.singletonList("application/pdf"); + } +} diff --git a/core/src/main/java/dev/vml/es/acm/core/code/arg/FileArgument.java b/core/src/main/java/dev/vml/es/acm/core/code/arg/FileArgument.java index a8f5137a..43bb902b 100644 --- a/core/src/main/java/dev/vml/es/acm/core/code/arg/FileArgument.java +++ b/core/src/main/java/dev/vml/es/acm/core/code/arg/FileArgument.java @@ -1,11 +1,10 @@ package dev.vml.es.acm.core.code.arg; -import dev.vml.es.acm.core.code.Argument; import dev.vml.es.acm.core.code.ArgumentType; import java.io.File; -public class FileArgument extends Argument { +public class FileArgument extends AbstractFileArgument { public FileArgument(String name) { super(name, ArgumentType.FILE, File.class); diff --git a/core/src/main/java/dev/vml/es/acm/core/code/arg/MultiFileArgument.java b/core/src/main/java/dev/vml/es/acm/core/code/arg/MultiFileArgument.java index bcfdbf40..c004fdf4 100644 --- a/core/src/main/java/dev/vml/es/acm/core/code/arg/MultiFileArgument.java +++ b/core/src/main/java/dev/vml/es/acm/core/code/arg/MultiFileArgument.java @@ -1,13 +1,12 @@ package dev.vml.es.acm.core.code.arg; -import dev.vml.es.acm.core.code.Argument; import dev.vml.es.acm.core.code.ArgumentType; import java.io.File; -public class MultiFileArgument extends Argument { +public class MultiFileArgument extends AbstractFileArgument { public MultiFileArgument(String name) { super(name, ArgumentType.FILE, File[].class); } -} +} \ No newline at end of file diff --git a/ui.frontend/src/components/CodeArgumentInput.tsx b/ui.frontend/src/components/CodeArgumentInput.tsx index 02d80750..e696072a 100644 --- a/ui.frontend/src/components/CodeArgumentInput.tsx +++ b/ui.frontend/src/components/CodeArgumentInput.tsx @@ -45,6 +45,7 @@ import { Dates } from '../utils/dates'; import { Strings } from '../utils/strings'; import Markdown from './Markdown'; import PathField from './PathPicker'; +import FileField from './FileInput'; interface CodeArgumentInputProps { arg: Argument; @@ -318,9 +319,21 @@ const CodeArgumentInput: React.FC = ({ arg }) => { /> ); } else if (isFileArgument(arg)) { - // TODO implement single-file argument input + return ( + + ) } else if (isMultiFileArgument(arg)) { - // TODO implement multi-file argument input + return ( + + ) } else { throw new Error(`Unsupported argument type: ${arg.type}`); } diff --git a/ui.frontend/src/components/FileInput.tsx b/ui.frontend/src/components/FileInput.tsx new file mode 100644 index 00000000..befe341a --- /dev/null +++ b/ui.frontend/src/components/FileInput.tsx @@ -0,0 +1,132 @@ +import { FileTrigger, ProgressCircle, Button, ListView, Item, Text, Flex } from '@adobe/react-spectrum'; +import { useState } from 'react'; +import { isMultiFileArgument, FileOutput, FileArgument } from '../utils/api.types.ts'; +import { apiRequest } from '../utils/api'; +import Delete from '@spectrum-icons/workflow/Delete'; + +interface FileFieldProps { + arg: FileArgument; // TODO make it more reusable + value: string | string[]; + onChange: (name: string, value: string | string[]) => void; +} + +type FileItem = { + name: string; + path: string; + uploading: boolean; + deleting: boolean; + file?: File; +}; + +const uploadFiles = async (files: File[]): Promise => { + const formData = new FormData(); + files.forEach((file) => formData.append('file', file)); + const response = await apiRequest({ + operation: 'File upload', + url: '/apps/acm/api/file.json', + method: 'POST', + data: formData, + headers: {}, // Let browser set Content-Type for FormData + }); + return response.data.data.files; +}; + +const deleteFile = async (path: string): Promise => { + await apiRequest({ + operation: 'File delete', + url: '/apps/acm/api/file.json', + method: 'DELETE', + data: { path }, + headers: { 'Content-Type': 'application/json' }, + }); +}; + +const FileField: React.FC = ({ arg, value, onChange }) => { + const [files, setFiles] = useState( + (Array.isArray(value) ? value : value ? [value] : []).map((path) => ({ + name: path.split('/').pop() ?? path, + path, + uploading: false, + deleting: false, + })) + ); + + const handleFiles = async (selectedFiles: FileList | null) => { + if (!selectedFiles) { + return; + } + const newFiles: FileItem[] = Array.from(selectedFiles).map((file) => ({ + name: file.name, + path: '', + uploading: true, + deleting: false, + file, + })); + setFiles((prev) => [...prev, ...newFiles]); + try { + const uploadedPaths = await uploadFiles(newFiles.map(f => f.file!)); + setFiles((prev) => + prev.map((f, idx) => + newFiles.includes(f) + ? { ...f, path: uploadedPaths[newFiles.indexOf(f)], uploading: false } + : f + ) + ); + const updatedPaths = [ + ...files.filter((f) => f.path).map((f) => f.path), + ...uploadedPaths, + ]; + onChange(arg.name, isMultiFileArgument(arg) ? updatedPaths : uploadedPaths[0] || ''); + } catch { + setFiles((prev) => prev.filter((f) => !newFiles.includes(f))); + } + }; + + const handleDelete = async (fileObj: FileItem) => { + setFiles((prev) => + prev.map((f) => (f === fileObj ? { ...f, deleting: true } : f)) + ); + try { + await deleteFile(fileObj.path); + const updated = files.filter((f) => f !== fileObj); + setFiles(updated); + const updatedPaths = updated.filter((f) => f.path).map((f) => f.path); + onChange(arg.name, isMultiFileArgument(arg) ? updatedPaths : updatedPaths[0] || ''); + } catch { + setFiles((prev) => + prev.map((f) => (f === fileObj ? { ...f, deleting: false } : f)) + ); + } + }; + + return ( + + + + + + {(file) => ( + + {file.name} + {file.uploading ? ( + + ) : file.deleting ? ( + + ) : ( + + )} + + )} + + + ); +}; + +export default FileField; \ No newline at end of file diff --git a/ui.frontend/src/utils/api.types.ts b/ui.frontend/src/utils/api.types.ts index 0881a0e9..af064bfb 100644 --- a/ui.frontend/src/utils/api.types.ts +++ b/ui.frontend/src/utils/api.types.ts @@ -352,3 +352,7 @@ export enum NodeType { export enum JCR_CONSTANTS { JCR_CONTENT = 'jcr:content', } + +export type FileOutput = { + files: string[]; +} \ No newline at end of file From d53e4acd5d6b465fcea01cdee20f022f6bde3461 Mon Sep 17 00:00:00 2001 From: Krystian Panek Date: Fri, 6 Jun 2025 15:04:39 +0200 Subject: [PATCH 04/25] File field PoC --- .../vml/es/acm/core/servlet/FileServlet.java | 17 ++- .../src/components/CodeArgumentInput.tsx | 22 +-- ui.frontend/src/components/FileField.tsx | 117 ++++++++++++++++ ui.frontend/src/components/FileInput.tsx | 132 ------------------ ui.frontend/src/components/UserInfo.tsx | 1 - ui.frontend/src/utils/api.types.ts | 2 +- 6 files changed, 140 insertions(+), 151 deletions(-) create mode 100644 ui.frontend/src/components/FileField.tsx delete mode 100644 ui.frontend/src/components/FileInput.tsx diff --git a/core/src/main/java/dev/vml/es/acm/core/servlet/FileServlet.java b/core/src/main/java/dev/vml/es/acm/core/servlet/FileServlet.java index 4cdd2023..b615eeff 100644 --- a/core/src/main/java/dev/vml/es/acm/core/servlet/FileServlet.java +++ b/core/src/main/java/dev/vml/es/acm/core/servlet/FileServlet.java @@ -22,6 +22,7 @@ property = { ServletResolverConstants.SLING_SERVLET_RESOURCE_TYPES + "=" + FileServlet.RT, ServletResolverConstants.SLING_SERVLET_METHODS + "=POST", + ServletResolverConstants.SLING_SERVLET_METHODS + "=DELETE", ServletResolverConstants.SLING_SERVLET_EXTENSIONS + "=json", }) public class FileServlet extends SlingAllMethodsServlet { @@ -34,7 +35,7 @@ public class FileServlet extends SlingAllMethodsServlet { protected void doPost(SlingHttpServletRequest request, SlingHttpServletResponse response) throws IOException { try { // TODO support uploading multiple files - File file = new File("/tmp/acm/executableId/number/originalFileName.txt"); + File file = new File("/tmp/acm/file/yyyy/mm/dd/number/originalFileName.txt"); FileOutput output = new FileOutput(Collections.singletonList(file)); respondJson(response, ok("File uploaded successfully", output)); } catch (Exception e) { @@ -42,4 +43,18 @@ protected void doPost(SlingHttpServletRequest request, SlingHttpServletResponse respondJson(response, error(String.format("File cannot be uploaded! %s", e.getMessage().trim()))); } } + + @Override + protected void doDelete(SlingHttpServletRequest request, SlingHttpServletResponse response) throws IOException { + try { + // TODO support deleting multiple files + File file = new File("/tmp/acm/file/yyyy/mm/dd/number/originalFileName.txt"); + FileOutput output = new FileOutput(Collections.singletonList(file)); + respondJson(response, ok("File deleted successfully", output)); + } catch (Exception e) { + LOG.error("File cannot be deleted!", e); + respondJson(response, error(String.format("File cannot be deleted! %s", e.getMessage().trim()))); + } + } + } diff --git a/ui.frontend/src/components/CodeArgumentInput.tsx b/ui.frontend/src/components/CodeArgumentInput.tsx index e696072a..2f194ccf 100644 --- a/ui.frontend/src/components/CodeArgumentInput.tsx +++ b/ui.frontend/src/components/CodeArgumentInput.tsx @@ -32,7 +32,9 @@ import { ArgumentValue, isBoolArgument, isColorArgument, - isDateTimeArgument, isFileArgument, isMultiFileArgument, + isDateTimeArgument, + isFileArgument, + isMultiFileArgument, isMultiSelectArgument, isNumberArgument, isPathArgument, @@ -43,9 +45,9 @@ import { } from '../utils/api.types.ts'; import { Dates } from '../utils/dates'; import { Strings } from '../utils/strings'; +import FileField from './FileField'; import Markdown from './Markdown'; import PathField from './PathPicker'; -import FileField from './FileInput'; interface CodeArgumentInputProps { arg: Argument; @@ -319,21 +321,9 @@ const CodeArgumentInput: React.FC = ({ arg }) => { /> ); } else if (isFileArgument(arg)) { - return ( - - ) + return ; } else if (isMultiFileArgument(arg)) { - return ( - - ) + return ; } else { throw new Error(`Unsupported argument type: ${arg.type}`); } diff --git a/ui.frontend/src/components/FileField.tsx b/ui.frontend/src/components/FileField.tsx new file mode 100644 index 00000000..a2179f50 --- /dev/null +++ b/ui.frontend/src/components/FileField.tsx @@ -0,0 +1,117 @@ +import { Button, FileTrigger, Flex, Item, ListView, ProgressCircle, Text } from '@adobe/react-spectrum'; +import Delete from '@spectrum-icons/workflow/Delete'; +import { useState } from 'react'; +import { apiRequest } from '../utils/api'; +import { FileOutput } from '../utils/api.types.ts'; + +interface FileFieldProps { + value: string | string[]; + allowMultiple: boolean; + mimeTypes?: string[]; + onChange: (value: string | string[]) => void; +} + +type FileItem = { + name: string; + path: string; + uploading: boolean; + deleting: boolean; + file?: File; +}; + +const uploadFiles = async (files: File[]): Promise => { + const formData = new FormData(); + files.forEach((file) => formData.append('file', file)); + const response = await apiRequest({ + operation: 'File upload', + url: '/apps/acm/api/file.json', + method: 'POST', + data: formData, + headers: {}, + }); + return response.data.data.files; +}; + +const deleteFile = async (path: string): Promise => { + await apiRequest({ + operation: 'File delete', + url: '/apps/acm/api/file.json', + method: 'DELETE', + data: { path }, + headers: { 'Content-Type': 'application/json' }, + }); +}; + +const FileField: React.FC = ({ value, onChange, mimeTypes, allowMultiple }) => { + const [files, setFiles] = useState( + (Array.isArray(value) ? value : value ? [value] : []).map((path) => ({ + name: path.split('/').pop() ?? path, + path, + uploading: false, + deleting: false, + })), + ); + + const handleFiles = async (selectedFiles: FileList | null) => { + if (!selectedFiles) { + return; + } + const newFiles: FileItem[] = Array.from(selectedFiles).map((file) => ({ + name: file.name, + path: '', + uploading: true, + deleting: false, + file, + })); + setFiles((prev) => [...prev, ...newFiles]); + try { + const uploadedPaths = await uploadFiles(newFiles.map((f) => f.file!)); + setFiles((prev) => prev.map((f) => (newFiles.includes(f) ? { ...f, path: uploadedPaths[newFiles.indexOf(f)], uploading: false } : f))); + const updatedPaths = [...files.filter((f) => f.path).map((f) => f.path), ...uploadedPaths]; + onChange(allowMultiple ? updatedPaths : uploadedPaths[0] || ''); + } catch { + setFiles((prev) => prev.filter((f) => !newFiles.includes(f))); + } + }; + + const handleDelete = async (fileObj: FileItem) => { + setFiles((prev) => prev.map((f) => (f === fileObj ? { ...f, deleting: true } : f))); + try { + await deleteFile(fileObj.path); + const updated = files.filter((f) => f !== fileObj); + setFiles(updated); + const updatedPaths = updated.filter((f) => f.path).map((f) => f.path); + onChange(allowMultiple ? updatedPaths : updatedPaths[0] || ''); + } catch { + setFiles((prev) => prev.map((f) => (f === fileObj ? { ...f, deleting: false } : f))); + } + }; + + return ( + + + + + + {(file) => ( + + + {file.uploading ? ( + + ) : file.deleting ? ( + + ) : ( + + )} + {file.name} + + + )} + + + ); +}; + +export default FileField; diff --git a/ui.frontend/src/components/FileInput.tsx b/ui.frontend/src/components/FileInput.tsx deleted file mode 100644 index befe341a..00000000 --- a/ui.frontend/src/components/FileInput.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import { FileTrigger, ProgressCircle, Button, ListView, Item, Text, Flex } from '@adobe/react-spectrum'; -import { useState } from 'react'; -import { isMultiFileArgument, FileOutput, FileArgument } from '../utils/api.types.ts'; -import { apiRequest } from '../utils/api'; -import Delete from '@spectrum-icons/workflow/Delete'; - -interface FileFieldProps { - arg: FileArgument; // TODO make it more reusable - value: string | string[]; - onChange: (name: string, value: string | string[]) => void; -} - -type FileItem = { - name: string; - path: string; - uploading: boolean; - deleting: boolean; - file?: File; -}; - -const uploadFiles = async (files: File[]): Promise => { - const formData = new FormData(); - files.forEach((file) => formData.append('file', file)); - const response = await apiRequest({ - operation: 'File upload', - url: '/apps/acm/api/file.json', - method: 'POST', - data: formData, - headers: {}, // Let browser set Content-Type for FormData - }); - return response.data.data.files; -}; - -const deleteFile = async (path: string): Promise => { - await apiRequest({ - operation: 'File delete', - url: '/apps/acm/api/file.json', - method: 'DELETE', - data: { path }, - headers: { 'Content-Type': 'application/json' }, - }); -}; - -const FileField: React.FC = ({ arg, value, onChange }) => { - const [files, setFiles] = useState( - (Array.isArray(value) ? value : value ? [value] : []).map((path) => ({ - name: path.split('/').pop() ?? path, - path, - uploading: false, - deleting: false, - })) - ); - - const handleFiles = async (selectedFiles: FileList | null) => { - if (!selectedFiles) { - return; - } - const newFiles: FileItem[] = Array.from(selectedFiles).map((file) => ({ - name: file.name, - path: '', - uploading: true, - deleting: false, - file, - })); - setFiles((prev) => [...prev, ...newFiles]); - try { - const uploadedPaths = await uploadFiles(newFiles.map(f => f.file!)); - setFiles((prev) => - prev.map((f, idx) => - newFiles.includes(f) - ? { ...f, path: uploadedPaths[newFiles.indexOf(f)], uploading: false } - : f - ) - ); - const updatedPaths = [ - ...files.filter((f) => f.path).map((f) => f.path), - ...uploadedPaths, - ]; - onChange(arg.name, isMultiFileArgument(arg) ? updatedPaths : uploadedPaths[0] || ''); - } catch { - setFiles((prev) => prev.filter((f) => !newFiles.includes(f))); - } - }; - - const handleDelete = async (fileObj: FileItem) => { - setFiles((prev) => - prev.map((f) => (f === fileObj ? { ...f, deleting: true } : f)) - ); - try { - await deleteFile(fileObj.path); - const updated = files.filter((f) => f !== fileObj); - setFiles(updated); - const updatedPaths = updated.filter((f) => f.path).map((f) => f.path); - onChange(arg.name, isMultiFileArgument(arg) ? updatedPaths : updatedPaths[0] || ''); - } catch { - setFiles((prev) => - prev.map((f) => (f === fileObj ? { ...f, deleting: false } : f)) - ); - } - }; - - return ( - - - - - - {(file) => ( - - {file.name} - {file.uploading ? ( - - ) : file.deleting ? ( - - ) : ( - - )} - - )} - - - ); -}; - -export default FileField; \ No newline at end of file diff --git a/ui.frontend/src/components/UserInfo.tsx b/ui.frontend/src/components/UserInfo.tsx index 756e3ab1..e98a4a57 100644 --- a/ui.frontend/src/components/UserInfo.tsx +++ b/ui.frontend/src/components/UserInfo.tsx @@ -25,7 +25,6 @@ const extractUserFromEmail = (email: string): string | null => { }; const UserInfo: React.FC = ({ id }) => { - // ACM service IDs like 'acm-content-service' if (id.startsWith(UserIdServicePrefix)) { return ( diff --git a/ui.frontend/src/utils/api.types.ts b/ui.frontend/src/utils/api.types.ts index af064bfb..ec60495e 100644 --- a/ui.frontend/src/utils/api.types.ts +++ b/ui.frontend/src/utils/api.types.ts @@ -355,4 +355,4 @@ export enum JCR_CONSTANTS { export type FileOutput = { files: string[]; -} \ No newline at end of file +}; From 9eaf5f78ea359102ba7fe24ea3fcc537fa154a3b Mon Sep 17 00:00:00 2001 From: Krystian Panek Date: Fri, 6 Jun 2025 15:10:58 +0200 Subject: [PATCH 05/25] Min/max for multi --- .../acm/core/code/arg/MultiFileArgument.java | 20 +++++++++++++++++++ .../src/components/CodeArgumentInput.tsx | 2 +- ui.frontend/src/components/FileField.tsx | 6 ++++-- ui.frontend/src/utils/api.types.ts | 8 +++++++- 4 files changed, 32 insertions(+), 4 deletions(-) diff --git a/core/src/main/java/dev/vml/es/acm/core/code/arg/MultiFileArgument.java b/core/src/main/java/dev/vml/es/acm/core/code/arg/MultiFileArgument.java index c004fdf4..aaa45fd5 100644 --- a/core/src/main/java/dev/vml/es/acm/core/code/arg/MultiFileArgument.java +++ b/core/src/main/java/dev/vml/es/acm/core/code/arg/MultiFileArgument.java @@ -6,7 +6,27 @@ public class MultiFileArgument extends AbstractFileArgument { + private Integer min; + + private Integer max; + public MultiFileArgument(String name) { super(name, ArgumentType.FILE, File[].class); } + + public Integer getMin() { + return min; + } + + public void setMin(Integer min) { + this.min = min; + } + + public Integer getMax() { + return max; + } + + public void setMax(Integer max) { + this.max = max; + } } \ No newline at end of file diff --git a/ui.frontend/src/components/CodeArgumentInput.tsx b/ui.frontend/src/components/CodeArgumentInput.tsx index 2f194ccf..4b6e9b5f 100644 --- a/ui.frontend/src/components/CodeArgumentInput.tsx +++ b/ui.frontend/src/components/CodeArgumentInput.tsx @@ -323,7 +323,7 @@ const CodeArgumentInput: React.FC = ({ arg }) => { } else if (isFileArgument(arg)) { return ; } else if (isMultiFileArgument(arg)) { - return ; + return ; } else { throw new Error(`Unsupported argument type: ${arg.type}`); } diff --git a/ui.frontend/src/components/FileField.tsx b/ui.frontend/src/components/FileField.tsx index a2179f50..698be359 100644 --- a/ui.frontend/src/components/FileField.tsx +++ b/ui.frontend/src/components/FileField.tsx @@ -6,9 +6,11 @@ import { FileOutput } from '../utils/api.types.ts'; interface FileFieldProps { value: string | string[]; + onChange: (value: string | string[]) => void; allowMultiple: boolean; mimeTypes?: string[]; - onChange: (value: string | string[]) => void; + min?: number; + max?: number; } type FileItem = { @@ -42,7 +44,7 @@ const deleteFile = async (path: string): Promise => { }); }; -const FileField: React.FC = ({ value, onChange, mimeTypes, allowMultiple }) => { +const FileField: React.FC = ({ value, onChange, mimeTypes, allowMultiple, min, max }) => { const [files, setFiles] = useState( (Array.isArray(value) ? value : value ? [value] : []).map((path) => ({ name: path.split('/').pop() ?? path, diff --git a/ui.frontend/src/utils/api.types.ts b/ui.frontend/src/utils/api.types.ts index ec60495e..5d3dd407 100644 --- a/ui.frontend/src/utils/api.types.ts +++ b/ui.frontend/src/utils/api.types.ts @@ -102,6 +102,12 @@ export type FileArgument = Argument & { mimeTypes: string[]; }; +export type MultiFileArgument = Argument & { + mimeTypes: string[]; + min: number; + max: number; +}; + export function isStringArgument(arg: Argument): arg is StringArgument { return arg.type === 'STRING'; } @@ -146,7 +152,7 @@ export function isFileArgument(arg: Argument): arg is FileArgumen return arg.type === 'FILE'; } -export function isMultiFileArgument(arg: Argument): arg is FileArgument { +export function isMultiFileArgument(arg: Argument): arg is MultiFileArgument { return arg.type === 'MULTIFILE'; } From 8af2ac42821405d3fe3eedd56ff109ee61f95a92 Mon Sep 17 00:00:00 2001 From: Krystian Panek Date: Fri, 6 Jun 2025 15:18:52 +0200 Subject: [PATCH 06/25] PoC --- .../dev/vml/es/acm/core/code/FileManager.java | 23 +++++++++++++++++++ .../vml/es/acm/core/servlet/FileServlet.java | 5 ++++ 2 files changed, 28 insertions(+) create mode 100644 core/src/main/java/dev/vml/es/acm/core/code/FileManager.java diff --git a/core/src/main/java/dev/vml/es/acm/core/code/FileManager.java b/core/src/main/java/dev/vml/es/acm/core/code/FileManager.java new file mode 100644 index 00000000..badc0b8b --- /dev/null +++ b/core/src/main/java/dev/vml/es/acm/core/code/FileManager.java @@ -0,0 +1,23 @@ +package dev.vml.es.acm.core.code; + +import org.osgi.service.component.annotations.Component; + +import java.io.File; +import java.io.InputStream; + +@Component(immediate = true, service = FileManager.class) +public class FileManager { + + public File root() { + return new File("/tmp/acm/file"); // TODO make it configurable + } + + public File get(String path) { + return new File(root(), path); + } + + // write the file to the temporary directory to conventional path: /tmp/acm/file/yyyy/mm/dd/number/originalFileName.txt + public File save(InputStream stream, String fileName) { + return null; + } +} diff --git a/core/src/main/java/dev/vml/es/acm/core/servlet/FileServlet.java b/core/src/main/java/dev/vml/es/acm/core/servlet/FileServlet.java index b615eeff..8e550145 100644 --- a/core/src/main/java/dev/vml/es/acm/core/servlet/FileServlet.java +++ b/core/src/main/java/dev/vml/es/acm/core/servlet/FileServlet.java @@ -1,10 +1,12 @@ package dev.vml.es.acm.core.servlet; +import dev.vml.es.acm.core.code.FileManager; import org.apache.sling.api.SlingHttpServletRequest; import org.apache.sling.api.SlingHttpServletResponse; import org.apache.sling.api.servlets.ServletResolverConstants; import org.apache.sling.api.servlets.SlingAllMethodsServlet; import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -31,6 +33,9 @@ public class FileServlet extends SlingAllMethodsServlet { private static final Logger LOG = LoggerFactory.getLogger(FileServlet.class); + @Reference + private FileManager fileManager; + @Override protected void doPost(SlingHttpServletRequest request, SlingHttpServletResponse response) throws IOException { try { From c26d00bf47efa660ca62c298b329671cd0bed120 Mon Sep 17 00:00:00 2001 From: Krystian Panek Date: Fri, 6 Jun 2025 16:00:48 +0200 Subject: [PATCH 07/25] File arg / happy path works --- .../dev/vml/es/acm/core/code/FileManager.java | 58 ++++++++++++++++--- .../dev/vml/es/acm/core/code/OutputFile.java | 2 +- .../vml/es/acm/core/servlet/FileServlet.java | 38 +++++++----- .../dev/vml/es/acm/core/util/TypeUtils.java | 4 ++ ui.frontend/src/components/FileField.tsx | 24 ++++---- 5 files changed, 93 insertions(+), 33 deletions(-) diff --git a/core/src/main/java/dev/vml/es/acm/core/code/FileManager.java b/core/src/main/java/dev/vml/es/acm/core/code/FileManager.java index badc0b8b..783bc5d7 100644 --- a/core/src/main/java/dev/vml/es/acm/core/code/FileManager.java +++ b/core/src/main/java/dev/vml/es/acm/core/code/FileManager.java @@ -1,23 +1,67 @@ package dev.vml.es.acm.core.code; +import dev.vml.es.acm.core.AcmException; +import org.apache.commons.io.FileUtils; +import org.apache.commons.io.IOUtils; import org.osgi.service.component.annotations.Component; -import java.io.File; -import java.io.InputStream; +import java.io.*; +import java.nio.file.Files; +import java.text.SimpleDateFormat; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.stream.Collectors; @Component(immediate = true, service = FileManager.class) public class FileManager { public File root() { - return new File("/tmp/acm/file"); // TODO make it configurable + return FileUtils.getTempDirectory().toPath().resolve("acm/file").toFile(); } - public File get(String path) { - return new File(root(), path); + public File get(String relativePath) { + return new File(root(), relativePath); } - // write the file to the temporary directory to conventional path: /tmp/acm/file/yyyy/mm/dd/number/originalFileName.txt public File save(InputStream stream, String fileName) { - return null; + try { + String datePath = new SimpleDateFormat("yyyy/MM/dd").format(new Date()); + File dir = new File(root(), datePath); + if (!dir.exists() && !dir.mkdirs()) { + throw new AcmException("File directory cannot be created: " + dir.getAbsolutePath()); + } + + String baseName = System.currentTimeMillis() + "-" + (int)(Math.random() * 10000) + "-" + fileName; + File file = new File(dir, baseName); + + if (file.exists()) { + throw new AcmException("File already exists: " + file.getAbsolutePath()); + } + try (OutputStream out = Files.newOutputStream(file.toPath())) { + IOUtils.copy(stream, out); + } + return file; + } catch (IOException e) { + throw new AcmException("File cannot be saved: " + fileName, e); + } + } + + public File delete(String relativePath) { + File file = get(relativePath); + if (!file.exists()) { + throw new AcmException(String.format("File to be deleted does not exist '%s'!", relativePath)); + } + if (!file.delete()) { + throw new AcmException(String.format("File cannot be deleted '%s'!", relativePath)); + } + return file; + } + + public List deleteAll(List relativePaths) { + if (relativePaths == null || relativePaths.isEmpty()) { + return Collections.emptyList(); + } + return relativePaths.stream().map(this::delete).collect(Collectors.toList()); } } diff --git a/core/src/main/java/dev/vml/es/acm/core/code/OutputFile.java b/core/src/main/java/dev/vml/es/acm/core/code/OutputFile.java index 9889b36e..cbbef6b4 100644 --- a/core/src/main/java/dev/vml/es/acm/core/code/OutputFile.java +++ b/core/src/main/java/dev/vml/es/acm/core/code/OutputFile.java @@ -14,7 +14,7 @@ public class OutputFile implements Output { - public static final String TMP_DIR = "acm"; + public static final String TMP_DIR = "acm/output"; private final String executionId; diff --git a/core/src/main/java/dev/vml/es/acm/core/servlet/FileServlet.java b/core/src/main/java/dev/vml/es/acm/core/servlet/FileServlet.java index 8e550145..334d386e 100644 --- a/core/src/main/java/dev/vml/es/acm/core/servlet/FileServlet.java +++ b/core/src/main/java/dev/vml/es/acm/core/servlet/FileServlet.java @@ -11,13 +11,16 @@ import org.slf4j.LoggerFactory; import javax.servlet.Servlet; +import javax.servlet.http.Part; import java.io.File; import java.io.IOException; -import java.util.Collections; +import java.util.LinkedList; +import java.util.List; import static dev.vml.es.acm.core.util.ServletResult.error; import static dev.vml.es.acm.core.util.ServletResult.ok; import static dev.vml.es.acm.core.util.ServletUtils.respondJson; +import static dev.vml.es.acm.core.util.ServletUtils.stringsParam; @Component( service = {Servlet.class}, @@ -33,32 +36,39 @@ public class FileServlet extends SlingAllMethodsServlet { private static final Logger LOG = LoggerFactory.getLogger(FileServlet.class); + private static final String PATH_PARAM = "path"; + @Reference - private FileManager fileManager; + private FileManager manager; @Override protected void doPost(SlingHttpServletRequest request, SlingHttpServletResponse response) throws IOException { try { - // TODO support uploading multiple files - File file = new File("/tmp/acm/file/yyyy/mm/dd/number/originalFileName.txt"); - FileOutput output = new FileOutput(Collections.singletonList(file)); - respondJson(response, ok("File uploaded successfully", output)); + List filesUploaded = new LinkedList<>(); + for (Part part : request.getParts()) { + if (part.getSubmittedFileName() != null) { + File file = manager.save(part.getInputStream(), part.getSubmittedFileName()); + filesUploaded.add(file); + } + } + FileOutput output = new FileOutput(filesUploaded); + respondJson(response, ok("Files uploaded successfully", output)); } catch (Exception e) { - LOG.error("File cannot be uploaded!", e); - respondJson(response, error(String.format("File cannot be uploaded! %s", e.getMessage().trim()))); + LOG.error("Files cannot be uploaded!", e); + respondJson(response, error(String.format("Files cannot be uploaded! %s", e.getMessage().trim()))); } } @Override protected void doDelete(SlingHttpServletRequest request, SlingHttpServletResponse response) throws IOException { try { - // TODO support deleting multiple files - File file = new File("/tmp/acm/file/yyyy/mm/dd/number/originalFileName.txt"); - FileOutput output = new FileOutput(Collections.singletonList(file)); - respondJson(response, ok("File deleted successfully", output)); + List paths = stringsParam(request, PATH_PARAM); + List deleted = manager.deleteAll(paths); + FileOutput output = new FileOutput(deleted); + respondJson(response, ok("Files deleted successfully", output)); } catch (Exception e) { - LOG.error("File cannot be deleted!", e); - respondJson(response, error(String.format("File cannot be deleted! %s", e.getMessage().trim()))); + LOG.error("Files cannot be deleted!", e); + respondJson(response, error(String.format("Files cannot be deleted! %s", e.getMessage().trim()))); } } diff --git a/core/src/main/java/dev/vml/es/acm/core/util/TypeUtils.java b/core/src/main/java/dev/vml/es/acm/core/util/TypeUtils.java index f8d0dba8..5f22248d 100644 --- a/core/src/main/java/dev/vml/es/acm/core/util/TypeUtils.java +++ b/core/src/main/java/dev/vml/es/acm/core/util/TypeUtils.java @@ -1,5 +1,6 @@ package dev.vml.es.acm.core.util; +import java.io.File; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; @@ -18,6 +19,7 @@ private TypeUtils() { /** * Convert a value to the specified type. * Fallback uses implicitly {@link org.apache.sling.api.wrappers.impl.ObjectConverter}. + * TODO support converting to arrays and collections. */ public static Optional convert(Object value, Class type, boolean fallback) { if (value instanceof String) { @@ -27,6 +29,8 @@ public static Optional convert(Object value, Class type, boolean fallb return Optional.ofNullable((T) DateUtils.toLocalDate((String) value)); } else if (type == LocalTime.class) { return Optional.ofNullable((T) DateUtils.toLocalTime((String) value)); + } else if (type == File.class) { + return Optional.ofNullable((T) new File((String) value)); } } else if (value instanceof Date) { if (type == LocalDateTime.class) { diff --git a/ui.frontend/src/components/FileField.tsx b/ui.frontend/src/components/FileField.tsx index 698be359..87fc5029 100644 --- a/ui.frontend/src/components/FileField.tsx +++ b/ui.frontend/src/components/FileField.tsx @@ -34,14 +34,13 @@ const uploadFiles = async (files: File[]): Promise => { return response.data.data.files; }; -const deleteFile = async (path: string): Promise => { - await apiRequest({ +const deleteFile = async (path: string): Promise => { + const response = await apiRequest({ operation: 'File delete', - url: '/apps/acm/api/file.json', + url: `/apps/acm/api/file.json?path=${encodeURIComponent(path)}`, method: 'DELETE', - data: { path }, - headers: { 'Content-Type': 'application/json' }, }); + return response.data.data.files[0]; }; const FileField: React.FC = ({ value, onChange, mimeTypes, allowMultiple, min, max }) => { @@ -89,23 +88,26 @@ const FileField: React.FC = ({ value, onChange, mimeTypes, allow } }; + // TODO add to support validation, min/max, etc. return ( - + {(file) => ( {file.uploading ? ( - + ) : file.deleting ? ( - + ) : ( - + )} {file.name} From a4e92566a769f6d99b43fd58bcbbbf0d3055246f Mon Sep 17 00:00:00 2001 From: Krystian Panek Date: Mon, 9 Jun 2025 07:00:53 +0200 Subject: [PATCH 08/25] Uploader works --- .../dev/vml/es/acm/core/code/FileManager.java | 30 ++++++++++--------- .../src/components/CodeArgumentInput.tsx | 18 +++++++++-- .../{FileField.tsx => FilePicker.tsx} | 4 +-- 3 files changed, 33 insertions(+), 19 deletions(-) rename ui.frontend/src/components/{FileField.tsx => FilePicker.tsx} (96%) diff --git a/core/src/main/java/dev/vml/es/acm/core/code/FileManager.java b/core/src/main/java/dev/vml/es/acm/core/code/FileManager.java index 783bc5d7..8f017587 100644 --- a/core/src/main/java/dev/vml/es/acm/core/code/FileManager.java +++ b/core/src/main/java/dev/vml/es/acm/core/code/FileManager.java @@ -3,6 +3,7 @@ import dev.vml.es.acm.core.AcmException; import org.apache.commons.io.FileUtils; import org.apache.commons.io.IOUtils; +import org.apache.commons.lang3.StringUtils; import org.osgi.service.component.annotations.Component; import java.io.*; @@ -20,21 +21,22 @@ public File root() { return FileUtils.getTempDirectory().toPath().resolve("acm/file").toFile(); } - public File get(String relativePath) { - return new File(root(), relativePath); + public File get(String path) { + if (!StringUtils.startsWith(path, root().getAbsolutePath())) { + throw new AcmException(String.format("File path must start with the root directory'%s'!", root().getAbsolutePath())); + } + return new File(path); } public File save(InputStream stream, String fileName) { try { String datePath = new SimpleDateFormat("yyyy/MM/dd").format(new Date()); - File dir = new File(root(), datePath); + String randomDir = System.currentTimeMillis() + "-" + (int)(Math.random() * 10000); + File dir = new File(root(), datePath + "/" + randomDir); if (!dir.exists() && !dir.mkdirs()) { throw new AcmException("File directory cannot be created: " + dir.getAbsolutePath()); } - - String baseName = System.currentTimeMillis() + "-" + (int)(Math.random() * 10000) + "-" + fileName; - File file = new File(dir, baseName); - + File file = new File(dir, fileName); if (file.exists()) { throw new AcmException("File already exists: " + file.getAbsolutePath()); } @@ -47,21 +49,21 @@ public File save(InputStream stream, String fileName) { } } - public File delete(String relativePath) { - File file = get(relativePath); + public File delete(String path) { + File file = get(path); if (!file.exists()) { - throw new AcmException(String.format("File to be deleted does not exist '%s'!", relativePath)); + throw new AcmException(String.format("File to be deleted does not exist '%s'!", path)); } if (!file.delete()) { - throw new AcmException(String.format("File cannot be deleted '%s'!", relativePath)); + throw new AcmException(String.format("File cannot be deleted '%s'!", path)); } return file; } - public List deleteAll(List relativePaths) { - if (relativePaths == null || relativePaths.isEmpty()) { + public List deleteAll(List paths) { + if (paths == null || paths.isEmpty()) { return Collections.emptyList(); } - return relativePaths.stream().map(this::delete).collect(Collectors.toList()); + return paths.stream().map(this::delete).collect(Collectors.toList()); } } diff --git a/ui.frontend/src/components/CodeArgumentInput.tsx b/ui.frontend/src/components/CodeArgumentInput.tsx index 4b6e9b5f..761dbfad 100644 --- a/ui.frontend/src/components/CodeArgumentInput.tsx +++ b/ui.frontend/src/components/CodeArgumentInput.tsx @@ -45,7 +45,7 @@ import { } from '../utils/api.types.ts'; import { Dates } from '../utils/dates'; import { Strings } from '../utils/strings'; -import FileField from './FileField'; +import FilePicker from './FilePicker'; import Markdown from './Markdown'; import PathField from './PathPicker'; @@ -321,9 +321,21 @@ const CodeArgumentInput: React.FC = ({ arg }) => { /> ); } else if (isFileArgument(arg)) { - return ; + return ( + +
+ +
+
+ ); } else if (isMultiFileArgument(arg)) { - return ; + return ( + +
+ +
+
+ ); } else { throw new Error(`Unsupported argument type: ${arg.type}`); } diff --git a/ui.frontend/src/components/FileField.tsx b/ui.frontend/src/components/FilePicker.tsx similarity index 96% rename from ui.frontend/src/components/FileField.tsx rename to ui.frontend/src/components/FilePicker.tsx index 87fc5029..81913ded 100644 --- a/ui.frontend/src/components/FileField.tsx +++ b/ui.frontend/src/components/FilePicker.tsx @@ -43,7 +43,7 @@ const deleteFile = async (path: string): Promise => { return response.data.data.files[0]; }; -const FileField: React.FC = ({ value, onChange, mimeTypes, allowMultiple, min, max }) => { +const FilePicker: React.FC = ({ value, onChange, mimeTypes, allowMultiple, min, max }) => { const [files, setFiles] = useState( (Array.isArray(value) ? value : value ? [value] : []).map((path) => ({ name: path.split('/').pop() ?? path, @@ -118,4 +118,4 @@ const FileField: React.FC = ({ value, onChange, mimeTypes, allow ); }; -export default FileField; +export default FilePicker; From ab200395b9a62ce845b5b3b997b00943c7d2ddde Mon Sep 17 00:00:00 2001 From: Krystian Panek Date: Mon, 9 Jun 2025 08:39:13 +0200 Subject: [PATCH 09/25] Multifile almost work --- .../vml/es/acm/core/code/ArgumentType.java | 3 +- .../acm/core/code/arg/MultiFileArgument.java | 2 +- .../src/components/CodeArgumentInput.tsx | 6 +- .../{FilePicker.tsx => FileUploader.tsx} | 62 +++++++++++-------- ui.frontend/src/hooks/form.ts | 19 +++++- 5 files changed, 60 insertions(+), 32 deletions(-) rename ui.frontend/src/components/{FilePicker.tsx => FileUploader.tsx} (64%) diff --git a/core/src/main/java/dev/vml/es/acm/core/code/ArgumentType.java b/core/src/main/java/dev/vml/es/acm/core/code/ArgumentType.java index e9a42bf5..fbeca220 100644 --- a/core/src/main/java/dev/vml/es/acm/core/code/ArgumentType.java +++ b/core/src/main/java/dev/vml/es/acm/core/code/ArgumentType.java @@ -14,5 +14,6 @@ public enum ArgumentType { COLOR, NUMBER_RANGE, PATH, - FILE + FILE, + MULTIFILE, } diff --git a/core/src/main/java/dev/vml/es/acm/core/code/arg/MultiFileArgument.java b/core/src/main/java/dev/vml/es/acm/core/code/arg/MultiFileArgument.java index aaa45fd5..dfdeaafb 100644 --- a/core/src/main/java/dev/vml/es/acm/core/code/arg/MultiFileArgument.java +++ b/core/src/main/java/dev/vml/es/acm/core/code/arg/MultiFileArgument.java @@ -11,7 +11,7 @@ public class MultiFileArgument extends AbstractFileArgument { private Integer max; public MultiFileArgument(String name) { - super(name, ArgumentType.FILE, File[].class); + super(name, ArgumentType.MULTIFILE, File[].class); } public Integer getMin() { diff --git a/ui.frontend/src/components/CodeArgumentInput.tsx b/ui.frontend/src/components/CodeArgumentInput.tsx index 761dbfad..e60520d9 100644 --- a/ui.frontend/src/components/CodeArgumentInput.tsx +++ b/ui.frontend/src/components/CodeArgumentInput.tsx @@ -45,7 +45,7 @@ import { } from '../utils/api.types.ts'; import { Dates } from '../utils/dates'; import { Strings } from '../utils/strings'; -import FilePicker from './FilePicker'; +import FileUploader from './FileUploader'; import Markdown from './Markdown'; import PathField from './PathPicker'; @@ -324,7 +324,7 @@ const CodeArgumentInput: React.FC = ({ arg }) => { return (
- +
); @@ -332,7 +332,7 @@ const CodeArgumentInput: React.FC = ({ arg }) => { return (
- +
); diff --git a/ui.frontend/src/components/FilePicker.tsx b/ui.frontend/src/components/FileUploader.tsx similarity index 64% rename from ui.frontend/src/components/FilePicker.tsx rename to ui.frontend/src/components/FileUploader.tsx index 81913ded..9a53da80 100644 --- a/ui.frontend/src/components/FilePicker.tsx +++ b/ui.frontend/src/components/FileUploader.tsx @@ -3,6 +3,7 @@ import Delete from '@spectrum-icons/workflow/Delete'; import { useState } from 'react'; import { apiRequest } from '../utils/api'; import { FileOutput } from '../utils/api.types.ts'; +import FileAdd from '@spectrum-icons/workflow/FileAdd'; interface FileFieldProps { value: string | string[]; @@ -43,7 +44,7 @@ const deleteFile = async (path: string): Promise => { return response.data.data.files[0]; }; -const FilePicker: React.FC = ({ value, onChange, mimeTypes, allowMultiple, min, max }) => { +const FileUploader: React.FC = ({ value, onChange, mimeTypes, allowMultiple, max }) => { const [files, setFiles] = useState( (Array.isArray(value) ? value : value ? [value] : []).map((path) => ({ name: path.split('/').pop() ?? path, @@ -52,6 +53,8 @@ const FilePicker: React.FC = ({ value, onChange, mimeTypes, allo deleting: false, })), ); + const uploadedCount = files.filter((f) => f.path).length; + const atMax = typeof max === 'number' && uploadedCount >= max; const handleFiles = async (selectedFiles: FileList | null) => { if (!selectedFiles) { @@ -88,34 +91,43 @@ const FilePicker: React.FC = ({ value, onChange, mimeTypes, allo } }; - // TODO add to support validation, min/max, etc. return ( - - - - - {(file) => ( - - - {file.uploading ? ( - - ) : file.deleting ? ( - - ) : ( - - )} - {file.name} - - - )} - + ) : ( + + + + )} + {uploadedCount > 0 && ( + + {(file) => ( + + + {file.uploading ? ( + + ) : file.deleting ? ( + + ) : ( + + )} + {file.name} + + + )} + + )} ); }; -export default FilePicker; +export default FileUploader; diff --git a/ui.frontend/src/hooks/form.ts b/ui.frontend/src/hooks/form.ts index 7fd79dd5..e3e96489 100644 --- a/ui.frontend/src/hooks/form.ts +++ b/ui.frontend/src/hooks/form.ts @@ -1,5 +1,5 @@ import { useFormContext } from 'react-hook-form'; -import { Argument, ArgumentValue, isDateTimeArgument, isNumberArgument, isPathArgument } from '../utils/api.types.ts'; +import { Argument, ArgumentValue, isDateTimeArgument, isMultiFileArgument, isNumberArgument, isPathArgument } from '../utils/api.types.ts'; import { Dates } from '../utils/dates'; type ValidationResult = string | true | undefined; @@ -52,7 +52,22 @@ function validateCustom(arg: Argument, value: ArgumentValue, allV } function validateDefault(arg: Argument, value: ArgumentValue): ValidationResult { - if (isNumberArgument(arg) && typeof value === 'number') { + if (isMultiFileArgument(arg)) { + const files = Array.isArray(value) ? value : value ? [value] : []; + const min = typeof arg.min === 'number' ? arg.min : undefined; + const max = typeof arg.max === 'number' ? arg.max : undefined; + + if ((min && files.length < min) || (max && files.length > max)) { + let msg = 'Files count allowed:'; + if (min) { + msg += ` minimum ${min}`; + } + if (max) { + msg += `, maximum ${max}`; + } + return msg; + } + } else if (isNumberArgument(arg) && typeof value === 'number') { if (arg.min && value < arg.min) { return `Value must be greater than or equal to '${arg.min}'`; } From ff1c2330c00b6c835adeee8002f307027a937565 Mon Sep 17 00:00:00 2001 From: Krystian Panek Date: Mon, 9 Jun 2025 09:09:04 +0200 Subject: [PATCH 10/25] File arr works --- .../dev/vml/es/acm/core/util/TypeUtils.java | 46 +++++++++++++++++-- 1 file changed, 41 insertions(+), 5 deletions(-) diff --git a/core/src/main/java/dev/vml/es/acm/core/util/TypeUtils.java b/core/src/main/java/dev/vml/es/acm/core/util/TypeUtils.java index 5f22248d..17526d4e 100644 --- a/core/src/main/java/dev/vml/es/acm/core/util/TypeUtils.java +++ b/core/src/main/java/dev/vml/es/acm/core/util/TypeUtils.java @@ -1,13 +1,12 @@ package dev.vml.es.acm.core.util; import java.io.File; +import java.lang.reflect.Array; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; -import java.util.Calendar; -import java.util.Collections; -import java.util.Date; -import java.util.Optional; +import java.util.*; + import org.apache.sling.api.wrappers.ValueMapDecorator; public final class TypeUtils { @@ -19,9 +18,14 @@ private TypeUtils() { /** * Convert a value to the specified type. * Fallback uses implicitly {@link org.apache.sling.api.wrappers.impl.ObjectConverter}. - * TODO support converting to arrays and collections. */ + @SuppressWarnings("unchecked") public static Optional convert(Object value, Class type, boolean fallback) { + if (type.isArray()) { + Object array = convertToArray(value, type.getComponentType(), fallback); + return Optional.ofNullable((T) array); + } + if (value instanceof String) { if (type == LocalDateTime.class) { return Optional.ofNullable((T) DateUtils.toLocalDateTime((String) value)); @@ -63,4 +67,36 @@ public static Optional convert(Object value, Class type, boolean fallb return Optional.empty(); } + + @SuppressWarnings("unchecked") + private static T[] convertToArray(Object obj, Class type, boolean fallback) { + if (obj == null) { + return (T[]) Array.newInstance(type, 0); + } + if (obj.getClass().isArray()) { + List resultList = new ArrayList<>(); + for (int i = 0; i < Array.getLength(obj); ++i) { + Optional convertedValue = convert(Array.get(obj, i), type, fallback); + convertedValue.ifPresent(resultList::add); + } + return resultList.toArray((T[]) Array.newInstance(type, resultList.size())); + } else if (obj instanceof Collection) { + Collection collection = (Collection) obj; + List resultList = new ArrayList<>(collection.size()); + for (Object element : collection) { + Optional convertedValue = convert(element, type, fallback); + convertedValue.ifPresent(resultList::add); + } + return resultList.toArray((T[]) Array.newInstance(type, resultList.size())); + } else { + Optional convertedValue = convert(obj, type, fallback); + if (!convertedValue.isPresent()) { + return (T[]) Array.newInstance(type, 0); + } else { + T[] arrayResult = (T[]) Array.newInstance(type, 1); + arrayResult[0] = convertedValue.get(); + return arrayResult; + } + } + } } From d06eab25cbf719bb43bfb02abed631edd27aa2a1 Mon Sep 17 00:00:00 2001 From: Krystian Panek Date: Mon, 9 Jun 2025 10:08:11 +0200 Subject: [PATCH 11/25] Upload works --- ui.frontend/src/components/FileUploader.tsx | 89 +++++++++++---------- 1 file changed, 46 insertions(+), 43 deletions(-) diff --git a/ui.frontend/src/components/FileUploader.tsx b/ui.frontend/src/components/FileUploader.tsx index 9a53da80..ab0698dc 100644 --- a/ui.frontend/src/components/FileUploader.tsx +++ b/ui.frontend/src/components/FileUploader.tsx @@ -24,7 +24,7 @@ type FileItem = { const uploadFiles = async (files: File[]): Promise => { const formData = new FormData(); - files.forEach((file) => formData.append('file', file)); + files.forEach((file) => formData.append(file.name, file)); const response = await apiRequest({ operation: 'File upload', url: '/apps/acm/api/file.json', @@ -46,12 +46,12 @@ const deleteFile = async (path: string): Promise => { const FileUploader: React.FC = ({ value, onChange, mimeTypes, allowMultiple, max }) => { const [files, setFiles] = useState( - (Array.isArray(value) ? value : value ? [value] : []).map((path) => ({ - name: path.split('/').pop() ?? path, - path, - uploading: false, - deleting: false, - })), + (Array.isArray(value) ? value : value ? [value] : []).map((path) => ({ + name: path.split('/').pop() ?? path, + path, + uploading: false, + deleting: false, + })), ); const uploadedCount = files.filter((f) => f.path).length; const atMax = typeof max === 'number' && uploadedCount >= max; @@ -91,43 +91,46 @@ const FileUploader: React.FC = ({ value, onChange, mimeTypes, al } }; + // TODO remove it + console.debug('Files uploaded', files); + return ( - - {atMax ? ( - - ) : ( - - - - )} - {uploadedCount > 0 && ( - - {(file) => ( - - - {file.uploading ? ( - - ) : file.deleting ? ( - - ) : ( - - )} - {file.name} - - - )} - - )} - + + {atMax ? ( + + ) : ( + + + + )} + {uploadedCount > 0 && ( + + {(file) => ( + + + {file.uploading ? ( + + ) : file.deleting ? ( + + ) : ( + + )} + {file.name} + + + )} + + )} + ); }; -export default FileUploader; +export default FileUploader; \ No newline at end of file From 8ee7a7472bc57ac4082700688246e897b35d9ada Mon Sep 17 00:00:00 2001 From: Krystian Panek Date: Mon, 9 Jun 2025 10:12:45 +0200 Subject: [PATCH 12/25] Deletion fixed --- ui.frontend/src/components/FileUploader.tsx | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/ui.frontend/src/components/FileUploader.tsx b/ui.frontend/src/components/FileUploader.tsx index ab0698dc..0aea9271 100644 --- a/ui.frontend/src/components/FileUploader.tsx +++ b/ui.frontend/src/components/FileUploader.tsx @@ -79,21 +79,20 @@ const FileUploader: React.FC = ({ value, onChange, mimeTypes, al }; const handleDelete = async (fileObj: FileItem) => { - setFiles((prev) => prev.map((f) => (f === fileObj ? { ...f, deleting: true } : f))); + setFiles((prev) => prev.map((f) => (f.path === fileObj.path ? { ...f, deleting: true } : f))); try { await deleteFile(fileObj.path); - const updated = files.filter((f) => f !== fileObj); - setFiles(updated); - const updatedPaths = updated.filter((f) => f.path).map((f) => f.path); - onChange(allowMultiple ? updatedPaths : updatedPaths[0] || ''); + setFiles((prev) => { + const updated = prev.filter((f) => f.path !== fileObj.path); + const updatedPaths = updated.filter((f) => f.path).map((f) => f.path); + onChange(allowMultiple ? updatedPaths : updatedPaths[0] || ''); + return updated; + }); } catch { - setFiles((prev) => prev.map((f) => (f === fileObj ? { ...f, deleting: false } : f))); + setFiles((prev) => prev.map((f) => (f.path === fileObj.path ? { ...f, deleting: false } : f))); } }; - // TODO remove it - console.debug('Files uploaded', files); - return ( {atMax ? ( From 9a3a70b534ce27f6164699807ab21eb11f306ebb Mon Sep 17 00:00:00 2001 From: Krystian Panek Date: Mon, 9 Jun 2025 10:15:03 +0200 Subject: [PATCH 13/25] Hardening --- ui.frontend/src/components/FileUploader.tsx | 98 +++++++++++---------- 1 file changed, 53 insertions(+), 45 deletions(-) diff --git a/ui.frontend/src/components/FileUploader.tsx b/ui.frontend/src/components/FileUploader.tsx index 0aea9271..6ff3161d 100644 --- a/ui.frontend/src/components/FileUploader.tsx +++ b/ui.frontend/src/components/FileUploader.tsx @@ -1,9 +1,9 @@ import { Button, FileTrigger, Flex, Item, ListView, ProgressCircle, Text } from '@adobe/react-spectrum'; import Delete from '@spectrum-icons/workflow/Delete'; +import FileAdd from '@spectrum-icons/workflow/FileAdd'; import { useState } from 'react'; import { apiRequest } from '../utils/api'; import { FileOutput } from '../utils/api.types.ts'; -import FileAdd from '@spectrum-icons/workflow/FileAdd'; interface FileFieldProps { value: string | string[]; @@ -46,12 +46,12 @@ const deleteFile = async (path: string): Promise => { const FileUploader: React.FC = ({ value, onChange, mimeTypes, allowMultiple, max }) => { const [files, setFiles] = useState( - (Array.isArray(value) ? value : value ? [value] : []).map((path) => ({ - name: path.split('/').pop() ?? path, - path, - uploading: false, - deleting: false, - })), + (Array.isArray(value) ? value : value ? [value] : []).map((path) => ({ + name: path.split('/').pop() ?? path, + path, + uploading: false, + deleting: false, + })), ); const uploadedCount = files.filter((f) => f.path).length; const atMax = typeof max === 'number' && uploadedCount >= max; @@ -70,11 +70,19 @@ const FileUploader: React.FC = ({ value, onChange, mimeTypes, al setFiles((prev) => [...prev, ...newFiles]); try { const uploadedPaths = await uploadFiles(newFiles.map((f) => f.file!)); - setFiles((prev) => prev.map((f) => (newFiles.includes(f) ? { ...f, path: uploadedPaths[newFiles.indexOf(f)], uploading: false } : f))); + setFiles((prev) => + prev.map((f) => { + const idx = newFiles.findIndex((nf) => nf.name === f.name && f.uploading && !f.path); + if (idx !== -1) { + return { ...f, path: uploadedPaths[idx], uploading: false }; + } + return f; + }), + ); const updatedPaths = [...files.filter((f) => f.path).map((f) => f.path), ...uploadedPaths]; onChange(allowMultiple ? updatedPaths : uploadedPaths[0] || ''); } catch { - setFiles((prev) => prev.filter((f) => !newFiles.includes(f))); + setFiles((prev) => prev.filter((f) => !newFiles.some((nf) => nf.name === f.name && f.uploading && !f.path))); } }; @@ -94,42 +102,42 @@ const FileUploader: React.FC = ({ value, onChange, mimeTypes, al }; return ( - - {atMax ? ( - - ) : ( - - - - )} - {uploadedCount > 0 && ( - - {(file) => ( - - - {file.uploading ? ( - - ) : file.deleting ? ( - - ) : ( - - )} - {file.name} - - - )} - - )} - + + {atMax ? ( + + ) : ( + + + + )} + {uploadedCount > 0 && ( + + {(file) => ( + + + {file.uploading ? ( + + ) : file.deleting ? ( + + ) : ( + + )} + {file.name} + + + )} + + )} + ); }; -export default FileUploader; \ No newline at end of file +export default FileUploader; From be0441e70cde9773626347ca521f7a36ad47743f Mon Sep 17 00:00:00 2001 From: Krystian Panek Date: Mon, 9 Jun 2025 10:24:40 +0200 Subject: [PATCH 14/25] Upload one by one --- ui.frontend/src/components/FileUploader.tsx | 41 ++++++++++++--------- 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/ui.frontend/src/components/FileUploader.tsx b/ui.frontend/src/components/FileUploader.tsx index 6ff3161d..b0ac5be1 100644 --- a/ui.frontend/src/components/FileUploader.tsx +++ b/ui.frontend/src/components/FileUploader.tsx @@ -14,7 +14,10 @@ interface FileFieldProps { max?: number; } +const generateId = () => `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + type FileItem = { + id: string; name: string; path: string; uploading: boolean; @@ -47,6 +50,7 @@ const deleteFile = async (path: string): Promise => { const FileUploader: React.FC = ({ value, onChange, mimeTypes, allowMultiple, max }) => { const [files, setFiles] = useState( (Array.isArray(value) ? value : value ? [value] : []).map((path) => ({ + id: generateId(), name: path.split('/').pop() ?? path, path, uploading: false, @@ -61,29 +65,30 @@ const FileUploader: React.FC = ({ value, onChange, mimeTypes, al return; } const newFiles: FileItem[] = Array.from(selectedFiles).map((file) => ({ + id: generateId(), name: file.name, path: '', uploading: true, deleting: false, file, })); - setFiles((prev) => [...prev, ...newFiles]); - try { - const uploadedPaths = await uploadFiles(newFiles.map((f) => f.file!)); - setFiles((prev) => - prev.map((f) => { - const idx = newFiles.findIndex((nf) => nf.name === f.name && f.uploading && !f.path); - if (idx !== -1) { - return { ...f, path: uploadedPaths[idx], uploading: false }; - } - return f; - }), - ); - const updatedPaths = [...files.filter((f) => f.path).map((f) => f.path), ...uploadedPaths]; - onChange(allowMultiple ? updatedPaths : uploadedPaths[0] || ''); - } catch { - setFiles((prev) => prev.filter((f) => !newFiles.some((nf) => nf.name === f.name && f.uploading && !f.path))); + + setFiles((prev) => [...prev, ...newFiles]); // Pre-render all loading indicators + + for (const fileItem of newFiles) { + try { + const [uploadedPath] = await uploadFiles([fileItem.file!]); + setFiles((prev) => prev.map((f) => (f.id === fileItem.id ? { ...f, path: uploadedPath, uploading: false } : f))); + } catch { + setFiles((prev) => prev.filter((f) => f.id !== fileItem.id)); + } } + + setFiles((prev) => { + const updatedPaths = prev.filter((f) => f.path).map((f) => f.path); + onChange(allowMultiple ? updatedPaths : updatedPaths[0] || ''); + return prev; + }); }; const handleDelete = async (fileObj: FileItem) => { @@ -116,10 +121,10 @@ const FileUploader: React.FC = ({ value, onChange, mimeTypes, al )} - {uploadedCount > 0 && ( + {files.length > 0 && ( {(file) => ( - + {file.uploading ? ( From 29f9b1b931c628ec764c11b0187e9f1a9898c38b Mon Sep 17 00:00:00 2001 From: Krystian Panek Date: Mon, 9 Jun 2025 10:27:38 +0200 Subject: [PATCH 15/25] Better UI --- ui.frontend/src/components/FileUploader.tsx | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/ui.frontend/src/components/FileUploader.tsx b/ui.frontend/src/components/FileUploader.tsx index b0ac5be1..1cd85482 100644 --- a/ui.frontend/src/components/FileUploader.tsx +++ b/ui.frontend/src/components/FileUploader.tsx @@ -2,7 +2,7 @@ import { Button, FileTrigger, Flex, Item, ListView, ProgressCircle, Text } from import Delete from '@spectrum-icons/workflow/Delete'; import FileAdd from '@spectrum-icons/workflow/FileAdd'; import { useState } from 'react'; -import { apiRequest } from '../utils/api'; +import {apiRequest, toastRequest} from '../utils/api'; import { FileOutput } from '../utils/api.types.ts'; interface FileFieldProps { @@ -28,8 +28,9 @@ type FileItem = { const uploadFiles = async (files: File[]): Promise => { const formData = new FormData(); files.forEach((file) => formData.append(file.name, file)); - const response = await apiRequest({ + const response = await toastRequest({ operation: 'File upload', + positive: false, url: '/apps/acm/api/file.json', method: 'POST', data: formData, @@ -38,13 +39,15 @@ const uploadFiles = async (files: File[]): Promise => { return response.data.data.files; }; -const deleteFile = async (path: string): Promise => { - const response = await apiRequest({ +const deleteFiles = async (paths: string[]): Promise => { + const query = paths.map((p) => `path=${encodeURIComponent(p)}`).join('&'); + const response = await toastRequest({ operation: 'File delete', - url: `/apps/acm/api/file.json?path=${encodeURIComponent(path)}`, + positive: false, + url: `/apps/acm/api/file.json?${query}`, method: 'DELETE', }); - return response.data.data.files[0]; + return response.data.data.files; }; const FileUploader: React.FC = ({ value, onChange, mimeTypes, allowMultiple, max }) => { @@ -94,7 +97,7 @@ const FileUploader: React.FC = ({ value, onChange, mimeTypes, al const handleDelete = async (fileObj: FileItem) => { setFiles((prev) => prev.map((f) => (f.path === fileObj.path ? { ...f, deleting: true } : f))); try { - await deleteFile(fileObj.path); + await deleteFiles([fileObj.path]); setFiles((prev) => { const updated = prev.filter((f) => f.path !== fileObj.path); const updatedPaths = updated.filter((f) => f.path).map((f) => f.path); From d64f45d26266e142758f281412de101ee5c91d81 Mon Sep 17 00:00:00 2001 From: Krystian Panek Date: Mon, 9 Jun 2025 10:30:09 +0200 Subject: [PATCH 16/25] Truncate under below max --- ui.frontend/src/components/FileUploader.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ui.frontend/src/components/FileUploader.tsx b/ui.frontend/src/components/FileUploader.tsx index 1cd85482..6ed069ad 100644 --- a/ui.frontend/src/components/FileUploader.tsx +++ b/ui.frontend/src/components/FileUploader.tsx @@ -67,7 +67,11 @@ const FileUploader: React.FC = ({ value, onChange, mimeTypes, al if (!selectedFiles) { return; } - const newFiles: FileItem[] = Array.from(selectedFiles).map((file) => ({ + const uploadedCount = files.filter((f) => f.path).length; + const remaining = typeof max === 'number' ? Math.max(0, max - uploadedCount) : selectedFiles.length; + const filesToUpload = Array.from(selectedFiles).slice(0, remaining); + + const newFiles: FileItem[] = filesToUpload.map((file) => ({ id: generateId(), name: file.name, path: '', From 8298d1c965545e3d0977636a179bfbe53287b25c Mon Sep 17 00:00:00 2001 From: Krystian Panek Date: Mon, 9 Jun 2025 10:44:42 +0200 Subject: [PATCH 17/25] Upload btn hiding --- ui.frontend/src/components/FileUploader.tsx | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/ui.frontend/src/components/FileUploader.tsx b/ui.frontend/src/components/FileUploader.tsx index 6ed069ad..2bc76b53 100644 --- a/ui.frontend/src/components/FileUploader.tsx +++ b/ui.frontend/src/components/FileUploader.tsx @@ -2,7 +2,7 @@ import { Button, FileTrigger, Flex, Item, ListView, ProgressCircle, Text } from import Delete from '@spectrum-icons/workflow/Delete'; import FileAdd from '@spectrum-icons/workflow/FileAdd'; import { useState } from 'react'; -import {apiRequest, toastRequest} from '../utils/api'; +import { toastRequest } from '../utils/api'; import { FileOutput } from '../utils/api.types.ts'; interface FileFieldProps { @@ -51,6 +51,7 @@ const deleteFiles = async (paths: string[]): Promise => { }; const FileUploader: React.FC = ({ value, onChange, mimeTypes, allowMultiple, max }) => { + const effectiveMax = allowMultiple ? max : 1; const [files, setFiles] = useState( (Array.isArray(value) ? value : value ? [value] : []).map((path) => ({ id: generateId(), @@ -61,14 +62,14 @@ const FileUploader: React.FC = ({ value, onChange, mimeTypes, al })), ); const uploadedCount = files.filter((f) => f.path).length; - const atMax = typeof max === 'number' && uploadedCount >= max; + const atMax = typeof effectiveMax === 'number' && uploadedCount >= effectiveMax; const handleFiles = async (selectedFiles: FileList | null) => { if (!selectedFiles) { return; } const uploadedCount = files.filter((f) => f.path).length; - const remaining = typeof max === 'number' ? Math.max(0, max - uploadedCount) : selectedFiles.length; + const remaining = typeof effectiveMax === 'number' ? Math.max(0, effectiveMax - uploadedCount) : selectedFiles.length; const filesToUpload = Array.from(selectedFiles).slice(0, remaining); const newFiles: FileItem[] = filesToUpload.map((file) => ({ @@ -115,12 +116,7 @@ const FileUploader: React.FC = ({ value, onChange, mimeTypes, al return ( - {atMax ? ( - - ) : ( + {!atMax && (