diff --git a/frontend/src/api/cubicweb.ts b/frontend/src/api/cubicweb.ts index d8620eb1106f86a39cea57fd8ba7be2a4f8909ef_ZnJvbnRlbmQvc3JjL2FwaS9jdWJpY3dlYi50cw==..6ad5b6834f8e431d991fcc6609b6a5ab88b74bcc_ZnJvbnRlbmQvc3JjL2FwaS9jdWJpY3dlYi50cw== 100644 --- a/frontend/src/api/cubicweb.ts +++ b/frontend/src/api/cubicweb.ts @@ -9,6 +9,9 @@ } from "@cubicweb/client"; import { useRouter } from "next/navigation"; +const INSERT_FILE_RQL = + "INSERT File X: X data %(data)s, X data_format %(data_format)s, X data_name %(data_name)s"; + export function useApiLogin() { const client = useClient(); return (login: string, password: string) => client.login(login, password); @@ -42,8 +45,35 @@ return (eid: number) => client.execute(rql, { eid }); } +export function useApiUploadDataServiceFile() { + const client = useClient(); + + return async (file: File): Promise<string> => { + const transaction = new Transaction(); + transaction.setBinaries({ file }); + const insertQuery = transaction.push(INSERT_FILE_RQL, { + data: { + type: "binary_reference", + ref: "file", + }, + data_format: file.type, + data_name: file.name, + }); + const downloadUrlQuery = transaction.push( + "Any X.download_url() WHERE X eid %(eid)s", + { + eid: insertQuery.ref().row(0).column(0), + }, + ); + const result = await client.executeTransaction(transaction); + return result.resolveScalar( + downloadUrlQuery.ref().row(0).column(0), + ) as string; + }; +} + function pushSetProjectFiles( transaction: Transaction, eid: number | TransactionQueryScalarRef, data: Omit<Project, "import_recipes">, ) { @@ -45,10 +75,8 @@ function pushSetProjectFiles( transaction: Transaction, eid: number | TransactionQueryScalarRef, data: Omit<Project, "import_recipes">, ) { - const insertFileRql = - "INSERT File X: X data %(data)s, X data_format %(data_format)s, X data_name %(data_name)s"; const setOntologyRql = "Set X ontology_file %(ontology_file)s WHERE X eid %(eid)s"; const setShaclRql = "Set X shacl_files %(shacl_files)s WHERE X eid %(eid)s"; @@ -57,7 +85,7 @@ if (data.ontology_file?.data) { const file = data.ontology_file.data; binaries["ontology_file"] = file; - const insertQuery = transaction.push(insertFileRql, { + const insertQuery = transaction.push(INSERT_FILE_RQL, { data: { type: "binary_reference", ref: "ontology_file", @@ -73,7 +101,7 @@ if (data.shacl_files?.data) { const file = data.shacl_files.data; binaries["shacl_files"] = file; - const insertQuery = transaction.push(insertFileRql, { + const insertQuery = transaction.push(INSERT_FILE_RQL, { data: { type: "binary_reference", ref: "shacl_files", diff --git a/frontend/src/components/DataServiceForm.tsx b/frontend/src/components/DataServiceForm.tsx index d8620eb1106f86a39cea57fd8ba7be2a4f8909ef_ZnJvbnRlbmQvc3JjL2NvbXBvbmVudHMvRGF0YVNlcnZpY2VGb3JtLnRzeA==..6ad5b6834f8e431d991fcc6609b6a5ab88b74bcc_ZnJvbnRlbmQvc3JjL2NvbXBvbmVudHMvRGF0YVNlcnZpY2VGb3JtLnRzeA== 100644 --- a/frontend/src/components/DataServiceForm.tsx +++ b/frontend/src/components/DataServiceForm.tsx @@ -7,6 +7,13 @@ Select, MenuItem, FormHelperText, + Button, + Typography, + CircularProgress, + Dialog, + DialogTitle, + DialogContent, + DialogActions, } from "@mui/material"; import SaveIcon from "@mui/icons-material/Save"; import { DataService } from "@/types"; @@ -10,8 +17,14 @@ } from "@mui/material"; import SaveIcon from "@mui/icons-material/Save"; import { DataService } from "@/types"; -import { Controller, SubmitHandler, useForm } from "react-hook-form"; -import { useState } from "react"; +import { + Controller, + FormProvider, + SubmitHandler, + useForm, + useFormContext, +} from "react-hook-form"; +import { ChangeEventHandler, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { LoadingButton } from "@mui/lab"; import { useHandleAuthErrors } from "@/hooks/useHandleAuthErrors"; @@ -19,6 +32,9 @@ useApiCreateDataService, useApiUpdateDataService, } from "@/api/cubicweb"; +import FileUploadIcon from "@mui/icons-material/FileUpload"; +import CancelIcon from "@mui/icons-material/Cancel"; +import { useUploadDataServiceFile } from "@/hooks/useUploadDataServiceFile"; export interface DataServiceFormProps { dataService?: DataService; @@ -26,18 +42,14 @@ export function DataServiceForm({ dataService }: DataServiceFormProps) { const [loading, setLoading] = useState(false); - const { - handleSubmit, - control, - formState: { isDirty }, - reset, - } = useForm<DataService>({ - defaultValues: dataService - ? dataService - : { - refresh_period: "weekly", - }, - }); + const { handleSubmit, formState, reset, ...useFormProps } = + useForm<DataService>({ + defaultValues: dataService + ? dataService + : { + refresh_period: "weekly", + }, + }); const handleAuthErrors = useHandleAuthErrors(); const createDataService = useApiCreateDataService(); const updateDataService = useApiUpdateDataService(); @@ -63,7 +75,54 @@ }; return ( - <form onSubmit={handleSubmit(onSubmit)}> - <Stack spacing={2}> - {!dataService ? ( + <FormProvider + handleSubmit={handleSubmit} + formState={formState} + reset={reset} + {...useFormProps} + > + <form onSubmit={handleSubmit(onSubmit)}> + <Stack spacing={2}> + {!dataService ? ( + <Controller + name="name" + rules={{ required: true }} + render={({ field, fieldState: { error } }) => ( + <TextField + label="Nom" + required={true} + disabled={loading} + {...field} + error={error !== undefined} + helperText={ + error?.type === "required" ? "Champ requis" : error?.message + } + /> + )} + /> + ) : null} + <Stack direction={"row"} spacing={2}> + <Box flex={1}> + <Controller + name="data_url" + rules={{ required: true }} + render={({ field, fieldState: { error } }) => ( + <TextField + label="Data URL" + required={true} + disabled={loading} + {...field} + error={error !== undefined} + helperText={ + error?.type === "required" + ? "Champ requis" + : error?.message + } + fullWidth + /> + )} + /> + </Box> + <GenerateLinkButton /> + </Stack> <Controller @@ -69,5 +128,4 @@ <Controller - name="name" - control={control} + name="refresh_period" rules={{ required: true }} render={({ field, fieldState: { error } }) => ( @@ -72,3 +130,27 @@ rules={{ required: true }} render={({ field, fieldState: { error } }) => ( + <FormControl fullWidth> + <InputLabel id="update-freq-label"> + Fréquence de mise à jour + </InputLabel> + <Select + labelId="update-freq-label" + label="Fréquence de mise à jour" + {...field} + error={error !== undefined} + disabled={loading} + > + <MenuItem value={"daily"}>Journalier</MenuItem> + <MenuItem value={"weekly"}>Hebdomadaire</MenuItem> + <MenuItem value={"monthly"}>Mensuel</MenuItem> + </Select> + {error ? ( + <FormHelperText error={true}>{error?.message}</FormHelperText> + ) : null} + </FormControl> + )} + /> + <Controller + name="description" + render={({ field, fieldState: { error } }) => ( <TextField @@ -74,6 +156,5 @@ <TextField - label="Nom" - required={true} + label="Description" disabled={loading} {...field} error={error !== undefined} @@ -77,29 +158,8 @@ disabled={loading} {...field} error={error !== undefined} - helperText={ - error?.type === "required" ? "Champ requis" : error?.message - } - /> - )} - /> - ) : null} - <Stack direction={"row"} spacing={2}> - <Controller - name="data_url" - control={control} - rules={{ required: true }} - render={({ field, fieldState: { error } }) => ( - <TextField - label="Data URL" - required={true} - disabled={loading} - {...field} - error={error !== undefined} - helperText={ - error?.type === "required" ? "Champ requis" : error?.message - } + helperText={error?.message} fullWidth /> )} /> @@ -102,35 +162,16 @@ fullWidth /> )} /> - <Box minWidth={200}> - <Controller - name="refresh_period" - control={control} - rules={{ required: true }} - render={({ field, fieldState: { error } }) => ( - <FormControl fullWidth> - <InputLabel id="update-freq-label"> - Fréquence de mise à jour - </InputLabel> - <Select - labelId="update-freq-label" - label="Fréquence de mise à jour" - {...field} - error={error !== undefined} - disabled={loading} - > - <MenuItem value={"daily"}>Journalier</MenuItem> - <MenuItem value={"weekly"}>Hebdomadaire</MenuItem> - <MenuItem value={"monthly"}>Mensuel</MenuItem> - </Select> - {error ? ( - <FormHelperText error={true}> - {error?.message} - </FormHelperText> - ) : null} - </FormControl> - )} - /> + <Box display={"flex"} flexDirection={"row-reverse"}> + <LoadingButton + variant="contained" + startIcon={<SaveIcon />} + loading={loading} + type="submit" + disabled={!formState.isDirty} + > + {dataService ? "Sauvegarder" : "Ajouter"} + </LoadingButton> </Box> </Stack> @@ -135,31 +176,6 @@ </Box> </Stack> - <Controller - name="description" - control={control} - render={({ field, fieldState: { error } }) => ( - <TextField - label="Description" - disabled={loading} - {...field} - error={error !== undefined} - helperText={error?.message} - fullWidth - /> - )} - /> - <Box display={"flex"} flexDirection={"row-reverse"}> - <LoadingButton - variant="contained" - startIcon={<SaveIcon />} - loading={loading} - type="submit" - disabled={!isDirty} - > - {dataService ? "Sauvegarder" : "Ajouter"} - </LoadingButton> - </Box> - </Stack> - </form> + </form> + </FormProvider> ); } @@ -164,2 +180,69 @@ ); } + +function GenerateLinkButton() { + const { setValue } = useFormContext(); + const ref = useRef<HTMLInputElement>(null); + const { uploadFile, error, loading } = useUploadDataServiceFile(); + const [errorModalOpen, setErrorModalOpen] = useState(false); + + const chooseFile = () => { + ref.current?.click(); + }; + + const onFileChange: ChangeEventHandler<HTMLInputElement> = async (e) => { + if (e.target.files && e.target.files.length > 0) { + const file = e.target.files[0]; + const url = await uploadFile(file); + if (url) { + setValue("data_url", url, { shouldDirty: true }); + } else { + setErrorModalOpen(true); + } + } + }; + + function closeErrorDialog() { + setErrorModalOpen(false); + } + + return ( + <> + <input + type="file" + style={{ display: "none" }} + ref={ref} + onChange={onFileChange} + /> + <Button + variant="outlined" + startIcon={<FileUploadIcon />} + onClick={chooseFile} + > + à partir d'un fichier + </Button> + <Dialog open={loading}> + <DialogTitle>Mise en ligne du fichier...</DialogTitle> + <DialogContent> + <Box display={"flex"} alignItems={"center"} justifyContent={"center"}> + <CircularProgress /> + </Box> + </DialogContent> + </Dialog> + <Dialog open={errorModalOpen} onClose={closeErrorDialog}> + <DialogTitle>Erreur de mise en ligne</DialogTitle> + <DialogContent> + <Typography>{error}</Typography> + <Box display={"flex"} alignItems={"center"} justifyContent={"center"}> + <CancelIcon color="error" fontSize="large" /> + </Box> + </DialogContent> + <DialogActions> + <Button onClick={closeErrorDialog} autoFocus> + Close + </Button> + </DialogActions> + </Dialog> + </> + ); +} diff --git a/frontend/src/hooks/useUploadDataServiceFile.tsx b/frontend/src/hooks/useUploadDataServiceFile.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6ad5b6834f8e431d991fcc6609b6a5ab88b74bcc_ZnJvbnRlbmQvc3JjL2hvb2tzL3VzZVVwbG9hZERhdGFTZXJ2aWNlRmlsZS50c3g= --- /dev/null +++ b/frontend/src/hooks/useUploadDataServiceFile.tsx @@ -0,0 +1,29 @@ +import { useApiUploadDataServiceFile } from "@/api/cubicweb"; +import { useState } from "react"; +import { useHandleAuthErrors } from "./useHandleAuthErrors"; + +export function useUploadDataServiceFile() { + const [loading, setLoading] = useState(false); + const [error, setError] = useState<string | undefined>(); + const handleAuthErrors = useHandleAuthErrors(); + const uploadDataServiceFile = useApiUploadDataServiceFile(); + + async function uploadFile(file: File): Promise<string | null> { + setLoading(true); + setError(undefined); + try { + const downloadUrl = await uploadDataServiceFile(file); + setLoading(false); + return downloadUrl; + } catch (e) { + setLoading(false); + try { + handleAuthErrors(e); + } catch { + setError(`Une erreur inconnue est survenue`); + } + } + return null; + } + return { loading, uploadFile, error }; +}