# 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[];
 };