# HG changeset patch # User Arnaud Vergnet <arnaud.vergnet@logilab.fr> # Date 1708528102 -3600 # Wed Feb 21 16:08:22 2024 +0100 # Node ID e85846b1f5b4de7fbfc9fc49299c9a112035d858 # Parent b4fd61fcacbb8db6a3969d2b0ed9f2a85c5c61ad feat(frontend): add ability to fetch and upload files diff --git a/frontend/package-lock.json b/frontend/package-lock.json --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,7 +8,7 @@ "name": "frontend", "version": "0.1.0", "dependencies": { - "@cubicweb/client": "^3.0.0-alpha.8", + "@cubicweb/client": "^3.0.0-alpha.10", "@emotion/cache": "^11.11.0", "@emotion/react": "^11.11.3", "@emotion/styled": "^11.11.0", @@ -250,9 +250,9 @@ } }, "node_modules/@cubicweb/client": { - "version": "3.0.0-alpha.8", - "resolved": "https://registry.npmjs.org/@cubicweb/client/-/client-3.0.0-alpha.8.tgz", - "integrity": "sha512-yitDoCo8ygRoUzVJtq2sUUY0XYhjO8iCzT3N4MvJjPHWPpHe0Hzwu0IyES8feri53Q72Yq+2tp/2Ym75fc5NiQ==" + "version": "3.0.0-alpha.10", + "resolved": "https://registry.npmjs.org/@cubicweb/client/-/client-3.0.0-alpha.10.tgz", + "integrity": "sha512-v8mmhaLUslas9LV+AHpOBUzacRz/xyngUeIGLYpqp5jSqmUO9hh3Z56G0ep9XK648ghUfSKptGXspIgTr0B/uA==" }, "node_modules/@emotion/babel-plugin": { "version": "11.11.0", diff --git a/frontend/package.json b/frontend/package.json --- a/frontend/package.json +++ b/frontend/package.json @@ -9,7 +9,7 @@ "lint": "next lint" }, "dependencies": { - "@cubicweb/client": "^3.0.0-alpha.8", + "@cubicweb/client": "^3.0.0-alpha.10", "@emotion/cache": "^11.11.0", "@emotion/react": "^11.11.3", "@emotion/styled": "^11.11.0", diff --git a/frontend/src/api/cubicweb.ts b/frontend/src/api/cubicweb.ts --- a/frontend/src/api/cubicweb.ts +++ b/frontend/src/api/cubicweb.ts @@ -1,6 +1,11 @@ import { useClient } from "@/context/ClientContext"; -import { DataService, ImportProcess, Project, Recipe } from "@/types"; -import { ResultSet, Transaction } from "@cubicweb/client"; +import { CWFile, DataService, ImportProcess, Project, Recipe } from "@/types"; +import { + BinariesParam, + ResultSet, + Transaction, + TransactionQueryScalarRef, +} from "@cubicweb/client"; import { useRouter } from "next/navigation"; export function useApiLogin() { @@ -36,16 +41,71 @@ return (eid: number) => client.execute(rql, { eid }); } +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"; + + const binaries: BinariesParam = {}; + if (data.ontology_file?.data) { + const file = data.ontology_file.data; + binaries["ontology_file"] = file; + const insertQuery = transaction.push(insertFileRql, { + data: { + type: "binary_reference", + ref: "ontology_file", + }, + data_format: file.type, + data_name: "ontologie.owl", + }); + transaction.push(setOntologyRql, { + eid, + ontology_file: insertQuery.ref().row(0).column(0), + }); + } + if (data.shacl_files?.data) { + const file = data.shacl_files.data; + binaries["shacl_files"] = file; + const insertQuery = transaction.push(insertFileRql, { + data: { + type: "binary_reference", + ref: "shacl_files", + }, + data_format: file.type, + data_name: "shacl.shacl", + }); + transaction.push(setShaclRql, { + eid, + shacl_files: insertQuery.ref().row(0).column(0), + }); + } + transaction.setBinaries(binaries); +} + export function useApiCreateProject() { const client = useClient(); const rql = "INSERT ImportProcedure X: X name %(name)s, X sparql_endpoint %(sparql_endpoint)s, X activated %(activated)s"; - return (data: Omit<Project, "import_recipes">) => - client.execute(rql, { + + return async (data: Omit<Project, "import_recipes">) => { + const transaction = new Transaction(); + const query = transaction.push(rql, { name: data.name, sparql_endpoint: data.sparql_endpoint, activated: data.activated, }); + const eidRef = query.ref().row(0).column(0); + pushSetProjectFiles(transaction, eidRef, data); + + const result = await client.executeTransaction(transaction); + return result.resolveScalar(eidRef) as number; + }; } export function useApiUpdateProject() { @@ -53,13 +113,17 @@ const rql = "SET X name %(name)s, X sparql_endpoint %(sparql_endpoint)s, X activated %(activated)s" + "WHERE X is ImportProcedure, X eid %(eid)s"; - return (data: Omit<Project, "import_recipes">) => - client.execute(rql, { + return (data: Omit<Project, "import_recipes">) => { + const transaction = new Transaction(); + transaction.push(rql, { eid: data.eid, name: data.name, sparql_endpoint: data.sparql_endpoint, activated: data.activated, }); + pushSetProjectFiles(transaction, data.eid, data); + return client.executeTransaction(transaction); + }; } export function useApiUpdateProjectState() { @@ -136,8 +200,8 @@ export function useApiGetProject(): (eid: number) => Promise<Project> { const client = useClient(); const projectRql = - "Any X, ATTR_NAME, ATTR_SPARQL_ENDPOINT, ATTR_ACTIVATED " + - "WHERE X is ImportProcedure, X eid %(eid)s, X name ATTR_NAME, X sparql_endpoint ATTR_SPARQL_ENDPOINT, X activated ATTR_ACTIVATED"; + "Any X, ATTR_NAME, ATTR_SPARQL_ENDPOINT, ATTR_ACTIVATED, REL_ONTOLOGY.download_url(), REL_SHACL.download_url() " + + "WHERE X is ImportProcedure, X eid %(eid)s, X name ATTR_NAME, X sparql_endpoint ATTR_SPARQL_ENDPOINT, X activated ATTR_ACTIVATED, X ontology_file REL_ONTOLOGY?, X shacl_files REL_SHACL?"; const recipeListRql = "Any X, ATTR_NAME, ATTR_PROCESS_TYPE , REL_DATASERVICE " + @@ -196,6 +260,8 @@ import_processes: importProcessListResultSetToObject( importProcessListResult, ), + ontology_file: { downloadUrl: r[4] } as CWFile, + shacl_files: { downloadUrl: r[5] } as CWFile, } as Project; } catch (e) { if (e && typeof e === "object" && "title" in e) { diff --git a/frontend/src/components/FileField.tsx b/frontend/src/components/FileField.tsx new file mode 100644 --- /dev/null +++ b/frontend/src/components/FileField.tsx @@ -0,0 +1,97 @@ +import { Box, Button, ButtonGroup, Tooltip } from "@mui/material"; +import FileUploadIcon from "@mui/icons-material/FileUpload"; +import Visibility from "@mui/icons-material/Visibility"; +import { ChangeEventHandler, useRef } from "react"; +import { CWFile } from "@/types"; + +interface FileFieldProps { + label: string; + value?: CWFile; + disabled?: boolean; + onChange?: (file: CWFile) => void; +} + +export function FileField({ + label, + value, + disabled = false, + onChange, +}: FileFieldProps) { + const ref = useRef<HTMLInputElement>(null); + + const onPressOpen = async () => { + let fileToOpen = value?.data; + // The file is available on the server, fetch it + if (!fileToOpen && value?.downloadUrl) { + const response = await fetch(value.downloadUrl, { + credentials: "include", + }); + fileToOpen = await response.blob(); + } + if (fileToOpen) { + const url = URL.createObjectURL(fileToOpen); + window.open(url); + } else { + console.warn("No file available either locally or on the server"); + } + }; + + const chooseFile = () => { + ref.current?.click(); + }; + + const onFileChange: ChangeEventHandler<HTMLInputElement> = (e) => { + if (e.target.files && e.target.files.length > 0 && onChange) { + const file = e.target.files[0]; + if (value) { + onChange({ + ...value, + data: file, + }); + } else { + onChange({ + data: file, + }); + } + } + }; + + const filePresent = value?.downloadUrl || value?.data; + + return ( + <> + <input + type="file" + style={{ display: "none" }} + ref={ref} + onChange={onFileChange} + /> + <ButtonGroup variant={filePresent ? "outlined" : "text"}> + <Button + fullWidth + startIcon={<FileUploadIcon />} + disabled={disabled} + onClick={chooseFile} + > + {label}: {filePresent ? "Modifier le fichier" : "Choisir un fichier"} + </Button> + <Tooltip + title={ + filePresent ? "TÊlÊcharger le fichier" : "Aucun fichier en ligne" + } + > + {/* This box is necessary for the tooltip to show */} + <Box> + <Button + startIcon={<Visibility />} + disabled={disabled || !filePresent} + onClick={onPressOpen} + > + Voir + </Button> + </Box> + </Tooltip> + </ButtonGroup> + </> + ); +} diff --git a/frontend/src/components/ProjectForm.tsx b/frontend/src/components/ProjectForm.tsx --- a/frontend/src/components/ProjectForm.tsx +++ b/frontend/src/components/ProjectForm.tsx @@ -1,13 +1,5 @@ -import { - Stack, - TextField, - Box, - Button, - Switch, - FormControlLabel, -} from "@mui/material"; +import { Stack, TextField, Box, Switch, FormControlLabel } from "@mui/material"; import SaveIcon from "@mui/icons-material/Save"; -import CloudUploadIcon from "@mui/icons-material/CloudUpload"; import { Project } from "@/types"; import { Controller, SubmitHandler, useForm } from "react-hook-form"; import { useState } from "react"; @@ -15,6 +7,7 @@ import { LoadingButton } from "@mui/lab"; import { useHandleAuthErrors } from "@/hooks/useHandleAuthErrors"; import { useApiCreateProject, useApiUpdateProject } from "@/api/cubicweb"; +import { FileField } from "./FileField"; export interface ProjectFormProps { project?: Project; @@ -23,7 +16,7 @@ export function ProjectForm({ project }: ProjectFormProps) { const [loading, setLoading] = useState(false); const { handleSubmit, control } = useForm<Project>({ - defaultValues: project, + defaultValues: project ?? { activated: false }, }); const handleAuthErrors = useHandleAuthErrors(); const createProject = useApiCreateProject(); @@ -35,8 +28,7 @@ setLoading(true); try { if (data.eid === undefined) { - const result = await createProject(data); - const eid = result[0][0]; + const eid = await createProject(data); setLoading(false); router.push(`/project/${eid}`); } else { @@ -108,12 +100,20 @@ )} /> </Stack> - <Button fullWidth startIcon={<CloudUploadIcon />}> - Ontologie - </Button> - <Button fullWidth startIcon={<CloudUploadIcon />}> - SHACL - </Button> + <Controller + name="ontology_file" + control={control} + render={({ field }) => ( + <FileField label="Ontologie" {...field} disabled={loading} /> + )} + /> + <Controller + name="shacl_files" + control={control} + render={({ field }) => ( + <FileField label="SHACL" {...field} disabled={loading} /> + )} + /> <Box display={"flex"} flexDirection={"row-reverse"}> <LoadingButton variant="contained" diff --git a/frontend/src/types.ts b/frontend/src/types.ts --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -1,8 +1,15 @@ +export type CWFile = { + downloadUrl?: string; + data?: Blob; +}; + export type Project = { eid: number; name: string; sparql_endpoint: string; activated: boolean; + ontology_file?: CWFile; + shacl_files?: CWFile; import_recipes?: Recipe[]; import_processes?: ImportProcess[]; };