diff --git a/src/assets/images/close.svg b/src/assets/images/close.svg new file mode 100644 index 00000000..39bbe50b --- /dev/null +++ b/src/assets/images/close.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/framework/processing/py/port/api/props.py b/src/framework/processing/py/port/api/props.py index f4e7b56d..bc47d077 100644 --- a/src/framework/processing/py/port/api/props.py +++ b/src/framework/processing/py/port/api/props.py @@ -171,6 +171,26 @@ def toDict(self): return dict +@dataclass +class PropsUIPromptFileInputMultiple: + """Prompt the user to submit multiple files + + Attributes: + description: text with an explanation + extensions: accepted mime types, example: "application/zip, text/plain" + """ + + description: Translatable + extensions: str + + def toDict(self): + dict = {} + dict["__type__"] = "PropsUIPromptFileInputMultiple" + dict["description"] = self.description.toDict() + dict["extensions"] = self.extensions + return dict + + @dataclass class PropsUIPromptProgress: """Prompt the user information during the extraction @@ -315,6 +335,7 @@ class PropsUIPageDonation: PropsUIPromptRadioInput | PropsUIPromptConsentForm | PropsUIPromptFileInput + | PropsUIPromptFileInputMultiple | PropsUIPromptConfirm | PropsUIPromptQuestionnaire ) diff --git a/src/framework/processing/py_worker.js b/src/framework/processing/py_worker.js index 7624af32..27eec298 100755 --- a/src/framework/processing/py_worker.js +++ b/src/framework/processing/py_worker.js @@ -47,10 +47,23 @@ function runCycle(payload) { function unwrap(response) { console.log('[ProcessingWorker] unwrap response: ' + JSON.stringify(response.payload)) + const directoryName = "/file-input" return new Promise((resolve) => { switch (response.payload.__type__) { case 'PayloadFile': - copyFileToPyFS(response.payload.value, resolve) + const file = response.payload.value + copyFileToPyFS([file], directoryName) + resolve({ __type__: 'PayloadString', value: `${directoryName}/${file.name}`}) + break + + case 'PayloadFileArray': + const filePaths = [] + const files = response.payload.value + for (const file of files) { + filePaths.push(`${directoryName}/${file.name}`) + } + copyFileToPyFS(files, directoryName) + resolve({ __type__: 'PayloadStringArray', value: filePaths }) break default: @@ -59,9 +72,8 @@ function unwrap(response) { }) } -function copyFileToPyFS(file, resolve) { - directoryName = `/file-input` - pathStats = self.pyodide.FS.analyzePath(directoryName) +function copyFileToPyFS(files, directoryName) { + const pathStats = self.pyodide.FS.analyzePath(directoryName) if (!pathStats.exists) { self.pyodide.FS.mkdir(directoryName) } else { @@ -70,11 +82,10 @@ function copyFileToPyFS(file, resolve) { self.pyodide.FS.mount( self.pyodide.FS.filesystems.WORKERFS, { - files: [file] + files: files }, directoryName ) - resolve({ __type__: 'PayloadString', value: directoryName + '/' + file.name }) } function initialise() { diff --git a/src/framework/types/commands.ts b/src/framework/types/commands.ts index c2b7ae26..8404085a 100644 --- a/src/framework/types/commands.ts +++ b/src/framework/types/commands.ts @@ -43,6 +43,7 @@ export type PayloadResolved = PayloadTrue | PayloadString | PayloadFile | + PayloadFileArray | PayloadJSON export interface PayloadVoid { @@ -65,6 +66,11 @@ export interface PayloadFile { value: File } +export interface PayloadFileArray { + __type__: 'PayloadFileArray' + value: File[] +} + export interface PayloadJSON { __type__: 'PayloadJSON' value: string diff --git a/src/framework/types/pages.ts b/src/framework/types/pages.ts index bcf44e23..84dc2e49 100644 --- a/src/framework/types/pages.ts +++ b/src/framework/types/pages.ts @@ -5,6 +5,7 @@ import { } from './elements' import { PropsUIPromptFileInput, + PropsUIPromptFileInputMultiple, PropsUIPromptConfirm, PropsUIPromptConsentForm, PropsUIPromptRadioInput, @@ -34,7 +35,7 @@ export interface PropsUIPageDonation { __type__: 'PropsUIPageDonation' platform: string header: PropsUIHeader - body: PropsUIPromptFileInput | PropsUIPromptConfirm | PropsUIPromptProgress | PropsUIPromptConsentForm | PropsUIPromptRadioInput | PropsUIPromptQuestionnaire + body: PropsUIPromptFileInput | PropsUIPromptConfirm | PropsUIPromptProgress | PropsUIPromptConsentForm | PropsUIPromptRadioInput | PropsUIPromptQuestionnaire | PropsUIPromptFileInputMultiple footer: PropsUIFooter } export function isPropsUIPageDonation (arg: any): arg is PropsUIPageDonation { diff --git a/src/framework/types/prompts.ts b/src/framework/types/prompts.ts index e9494e8f..766fd063 100644 --- a/src/framework/types/prompts.ts +++ b/src/framework/types/prompts.ts @@ -39,6 +39,15 @@ export function isPropsUIPromptFileInput (arg: any): arg is PropsUIPromptFileInp return isInstanceOf(arg, 'PropsUIPromptFileInput', ['description', 'extensions']) } +export interface PropsUIPromptFileInputMultiple { + __type__: "PropsUIPromptFileInputMultiple" + description: Text + extensions: string +} +export function isPropsUIPromptFileInputMultiple (arg: any): arg is PropsUIPromptFileInputMultiple { + return isInstanceOf(arg, 'PropsUIPromptFileInputMultiple', ['description', 'extensions']) +} + export interface PropsUIPromptProgress { __type__: 'PropsUIPromptProgress' description: Text diff --git a/src/framework/visualisation/react/ui/pages/donation_page.tsx b/src/framework/visualisation/react/ui/pages/donation_page.tsx index 7751e05a..af977193 100644 --- a/src/framework/visualisation/react/ui/pages/donation_page.tsx +++ b/src/framework/visualisation/react/ui/pages/donation_page.tsx @@ -8,6 +8,7 @@ import { isPropsUIPromptConfirm, isPropsUIPromptConsentForm, isPropsUIPromptFileInput, + isPropsUIPromptFileInputMultiple, isPropsUIPromptRadioInput, isPropsUIPromptQuestionnaire } from '../../../../types/prompts' @@ -17,6 +18,7 @@ import { Title1 } from '../elements/text' import { Confirm } from '../prompts/confirm' import { ConsentForm } from '../prompts/consent_form' import { FileInput } from '../prompts/file_input' +import { FileInputMultiple } from '../prompts/file_input_multiple' import { Progress } from '../prompts/progress' import { Questionnaire } from '../prompts/questionnaire' import { RadioInput } from '../prompts/radio_input' @@ -35,6 +37,9 @@ export const DonationPage = (props: Props): JSX.Element => { if (isPropsUIPromptFileInput(body)) { return } + if (isPropsUIPromptFileInputMultiple(body)) { + return + } if (isPropsUIPromptProgress(body)) { return } diff --git a/src/framework/visualisation/react/ui/prompts/file_input_multiple.tsx b/src/framework/visualisation/react/ui/prompts/file_input_multiple.tsx new file mode 100644 index 00000000..e8ff8605 --- /dev/null +++ b/src/framework/visualisation/react/ui/prompts/file_input_multiple.tsx @@ -0,0 +1,137 @@ +import { Weak } from '../../../../helpers' +import * as React from 'react' +import { Translatable } from '../../../../types/elements' +import TextBundle from '../../../../text_bundle' +import { Translator } from '../../../../translator' +import { ReactFactoryContext } from '../../factory' +import { PropsUIPromptFileInputMultiple } from '../../../../types/prompts' +import { PrimaryButton } from '../elements/button' +import CloseSvg from '../../../../../assets/images/close.svg' +import { BodyLarge, BodySmall } from '../elements/text' + +type Props = Weak & ReactFactoryContext + +export const FileInputMultiple = (props: Props): JSX.Element => { + const [waiting, setWaiting] = React.useState(false) + const [files, setFiles] = React.useState([]) + const input = React.useRef(null) + + const { resolve } = props + const { description, note, extensions, selectButton, continueButton } = prepareCopy(props) + + function handleClick (): void { + input.current?.click() + } + + function addFile(file: File): void { + const fileExists = files.some(f => f.name === file.name && f.size === file.size); + if (!fileExists) { + setFiles(prevFiles => [...prevFiles, file]); + } + }; + + function removeFile(index: number): void { + setFiles(prevFiles => prevFiles.filter((_, i) => i !== index)); + }; + + function handleSelect (event: React.ChangeEvent): void { + const selectedFiles = event.target.files + if (selectedFiles != null && selectedFiles.length > 0) { + for (let i = 0; i < selectedFiles.length; i++) { + addFile(selectedFiles[i]) + } + } else { + console.log('[FileInput] Error selecting file: ' + JSON.stringify(selectedFiles)) + } + } + + function handleConfirm (): void { + if (files !== undefined && !waiting) { + setWaiting(true) + resolve?.({ __type__: 'PayloadFileArray', value: files }) + } + } + + return ( + <> +
+
+ {description} +
+
+
+ +
+ +
+
+
+ {files.map((file, index) => ( +
+
+ {file.name} + +
+
+
+
+
+ ))} +
+
+
+ +
+
+ +
+
+
+ + ) +} + +interface Copy { + description: string + note: string + extensions: string + selectButton: string + continueButton: string +} + +function prepareCopy ({ description, extensions, locale }: Props): Copy { + return { + description: Translator.translate(description, locale), + note: Translator.translate(note(), locale), + extensions: extensions, + selectButton: Translator.translate(selectButtonLabel(), locale), + continueButton: Translator.translate(continueButtonLabel(), locale) + } +} + +const continueButtonLabel = (): Translatable => { + return new TextBundle() + .add('en', 'Continue') + .add('de', 'Weiter') + .add('nl', 'Verder') +} + +const selectButtonLabel = (): Translatable => { + return new TextBundle() + .add('en', 'Choose file(s)') + .add('de', 'Datei(en) auswählen') + .add('nl', 'Kies bestand(en)') +} + +const note = (): Translatable => { + return new TextBundle() + .add('en', 'Note: The process to extract the correct data from the file is done on your own computer. No data is stored or sent yet.') + .add('de', 'Anmerkung: Die weitere Verarbeitung der Datei erfolgt auf Ihrem eigenen Endgerät. Es werden noch keine Daten gespeichert oder weiter gesendet.') + .add('nl', 'NB: Het proces om de juiste gegevens uit het bestand te halen gebeurt op uw eigen computer. Er worden nog geen gegevens opgeslagen of verstuurd.') +} +