diff --git a/Cargo.lock b/Cargo.lock index e7014443..0b43c7cd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2530,6 +2530,7 @@ dependencies = [ "utoipa-swagger-ui", "xbuilder", "xsender", + "zip", ] [[package]] @@ -2542,6 +2543,7 @@ dependencies = [ "serde", "thiserror", "uuid", + "zip", ] [[package]] diff --git a/openubl/README.md b/openubl/README.md index 0ac4fedc..cee94f9a 100644 --- a/openubl/README.md +++ b/openubl/README.md @@ -7,5 +7,5 @@ docker-compose -f openubl/deploy/compose/compose.yaml up ``` ```shell -RUST_LOG=info cargo watch -x 'run -p openubl-cli -- server --db-user user --db-password password --oidc-auth-server-url http://localhost:9001/realms/openubl --storage-type minio --storage-minio-host http://localhost:9000 --storage-minio-access-key accesskey --storage-minio-secret-key secretkey' +RUST_LOG=info cargo watch -x 'run -p openubl-cli -- server --db-user user --db-password password --oidc-auth-server-url http://localhost:9001/realms/openubl --storage-type minio --storage-minio-host http://localhost:9002 --storage-minio-bucket openubl --storage-minio-access-key admin --storage-minio-secret-key password' ``` diff --git a/openubl/deploy/compose/compose.yaml b/openubl/deploy/compose/compose.yaml index 05796dcb..bf7f8191 100644 --- a/openubl/deploy/compose/compose.yaml +++ b/openubl/deploy/compose/compose.yaml @@ -45,14 +45,13 @@ services: minio: image: quay.io/minio/minio:latest - command: server /data + command: server --console-address ":9001" /data ports: - - "9000:9000" + - "9002:9000" + - "9003:9001" environment: MINIO_ROOT_USER: "admin" MINIO_ROOT_PASSWORD: "password" - MINIO_ACCESS_KEY: "accesskey" - MINIO_SECRET_KEY: "secretkey" MINIO_NOTIFY_NATS_ENABLE_OPENUBL: "on" MINIO_NOTIFY_NATS_ADDRESS_OPENUBL: "nats:4222" MINIO_NOTIFY_NATS_SUBJECT_OPENUBL: "openubl" diff --git a/openubl/server/Cargo.toml b/openubl/server/Cargo.toml index 07098e32..1b9a9542 100644 --- a/openubl/server/Cargo.toml +++ b/openubl/server/Cargo.toml @@ -28,3 +28,4 @@ actix-web-httpauth = "0.8.1" actix-4-jwt-auth = { version = "1.2.0" } actix-multipart = "0.6.1" minio = "0.1.0" +zip = "0.6.6" diff --git a/openubl/server/src/lib.rs b/openubl/server/src/lib.rs index 712a0528..2e419d1c 100644 --- a/openubl/server/src/lib.rs +++ b/openubl/server/src/lib.rs @@ -12,7 +12,7 @@ use openubl_api::system::InnerSystem; use openubl_common::config::Database; use openubl_storage::StorageSystem; -use crate::server::{health, project}; +use crate::server::{health, project, files}; pub mod server; @@ -95,8 +95,12 @@ pub struct AppState { pub fn configure(config: &mut web::ServiceConfig) { config.service(health::liveness); config.service(health::readiness); + config.service(project::list_projects); config.service(project::create_project); + config.service(project::get_project); config.service(project::update_project); config.service(project::delete_project); + + config.service(files::upload_file); } diff --git a/openubl/server/src/server/files.rs b/openubl/server/src/server/files.rs index 5b0538b5..4da01323 100644 --- a/openubl/server/src/server/files.rs +++ b/openubl/server/src/server/files.rs @@ -1,13 +1,13 @@ use actix_4_jwt_auth::AuthenticatedUser; -use actix_multipart::form::tempfile::TempFile; use actix_multipart::form::MultipartForm; -use actix_web::{post, web, Responder}; +use actix_multipart::form::tempfile::TempFile; +use actix_web::{post, Responder, web}; use openubl_oidc::UserClaims; use xsender::prelude::{FromPath, UblFile}; -use crate::server::Error; use crate::AppState; +use crate::server::Error; #[derive(Debug, MultipartForm)] struct UploadForm { @@ -16,18 +16,20 @@ struct UploadForm { } #[utoipa::path(responses((status = 200, description = "Upload a file")))] -#[post("/projects/files")] +#[post("/projects/{project_id}/files")] pub async fn upload_file( state: web::Data, + _path: web::Path, MultipartForm(form): MultipartForm, _user: AuthenticatedUser, ) -> Result { - for f in form.files { - let ubl_file = UblFile::from_path(f.file.path())?; + for temp_file in form.files { + let ubl_file = UblFile::from_path(temp_file.file.path())?; ubl_file.metadata()?; - let filename = f.file.path().to_str().expect("hello"); - state.storage.upload(filename).await?; + let file_path = temp_file.file.path().to_str().expect("Could not find filename"); + let filename = temp_file.file_name.unwrap_or("file.xml".to_string()); + state.storage.upload(file_path, &filename).await?; } Ok("Live") diff --git a/openubl/server/src/server/project.rs b/openubl/server/src/server/project.rs index 706e95f2..0e816876 100644 --- a/openubl/server/src/server/project.rs +++ b/openubl/server/src/server/project.rs @@ -56,6 +56,28 @@ pub async fn create_project( } } +#[utoipa::path(responses((status = 200, description = "Get project")))] +#[get("/projects/{project_id}")] +pub async fn get_project( + state: web::Data, + path: web::Path, + user: AuthenticatedUser, +) -> Result { + let project_id = path.into_inner(); + + match state + .system + .get_project(project_id, &user.claims.user_id(), Transactional::None) + .await + .map_err(Error::System)? + { + None => Ok(HttpResponse::NotFound().finish()), + Some(ctx) => { + Ok(HttpResponse::Ok().json(ctx.project)) + } + } +} + #[utoipa::path(responses((status = 204, description = "Update project")))] #[put("/projects/{project_id}")] pub async fn update_project( diff --git a/openubl/storage/Cargo.toml b/openubl/storage/Cargo.toml index 10d916da..e1ee360f 100644 --- a/openubl/storage/Cargo.toml +++ b/openubl/storage/Cargo.toml @@ -12,3 +12,4 @@ serde = { version = "1.0.193", features = ["derive"] } anyhow = "1.0.78" uuid = { version = "1.6.1", features = ["v4"] } thiserror = "1.0.53" +zip = "0.6.6" diff --git a/openubl/storage/src/config.rs b/openubl/storage/src/config.rs index 9624e6df..ba9da79b 100644 --- a/openubl/storage/src/config.rs +++ b/openubl/storage/src/config.rs @@ -24,7 +24,7 @@ pub struct MinioStorage { #[arg(id = "storage-minio-host", long, env = "STORAGE_MINIO_HOST")] pub host: String, #[arg( - id = "minio-bucket", + id = "storage-minio-bucket", long, env = "STORAGE_MINIO_BUCKET", default_value = "openubl" diff --git a/openubl/storage/src/lib.rs b/openubl/storage/src/lib.rs index 01e868ee..a25e0926 100644 --- a/openubl/storage/src/lib.rs +++ b/openubl/storage/src/lib.rs @@ -1,13 +1,18 @@ -use anyhow::anyhow; -use std::fs::rename; +use std::fs; +use std::fs::{File, rename}; +use std::io::{Read, Write}; use std::path::Path; use std::str::FromStr; +use anyhow::anyhow; use minio::s3::args::UploadObjectArgs; use minio::s3::client::Client; use minio::s3::creds::StaticProvider; use minio::s3::http::BaseUrl; use uuid::Uuid; +use zip::result::{ZipError, ZipResult}; +use zip::write::FileOptions; +use zip::ZipWriter; use crate::config::Storage; @@ -24,6 +29,8 @@ pub enum StorageSystemErr { Filesystem(std::io::Error), #[error(transparent)] Minio(minio::s3::error::Error), + #[error(transparent)] + Zip(zip::result::ZipError), } impl From for StorageSystemErr { @@ -38,6 +45,12 @@ impl From for StorageSystemErr { } } +impl From for StorageSystemErr { + fn from(e: zip::result::ZipError) -> Self { + Self::Zip(e) + } +} + impl StorageSystem { pub fn new(config: &Storage) -> anyhow::Result { match config.storage_type.as_str() { @@ -57,19 +70,52 @@ impl StorageSystem { } } - pub async fn upload(&self, filename: &str) -> Result { - let object_id = Uuid::new_v4().to_string(); + pub async fn upload(&self, file_path: &str, filename: &str) -> Result { + let zip_name = format!("{}.zip", Uuid::new_v4()); + let zip_path = zip_file(&zip_name, file_path, filename)?; + match self { StorageSystem::FileSystem(workspace) => { - let new_path = Path::new(workspace).join(&object_id); - rename(filename, new_path)?; - Ok(object_id.clone()) + let new_path = Path::new(workspace).join(&zip_name); + rename(zip_path, new_path)?; + Ok(zip_name.clone()) } StorageSystem::Minio(bucket, client) => { - let object = &UploadObjectArgs::new(bucket, &object_id, filename)?; + let object = &UploadObjectArgs::new(bucket, &zip_name, &zip_path)?; let response = client.upload_object(object).await?; + + // Clear temp files + fs::remove_file(file_path)?; + fs::remove_file(zip_path)?; + Ok(response.object_name) } } } } + + +pub fn zip_file(zip_filename: &str, full_path_of_file_to_be_zipped: &str, file_name_to_be_used_in_zip: &str) -> ZipResult { + let mut file = File::open(full_path_of_file_to_be_zipped)?; + let file_path = Path::new(full_path_of_file_to_be_zipped); + let file_directory = file_path.parent().ok_or(ZipError::InvalidArchive("Could not find the parent folder of given file"))?; + + let zip_path = file_directory.join(zip_filename); + let zip_file = File::create(zip_path.as_path())?; + let mut zip = ZipWriter::new(zip_file); + + let file_options = FileOptions::default() + .compression_method(zip::CompressionMethod::Bzip2) + .unix_permissions(0o755); + + zip.start_file(file_name_to_be_used_in_zip, file_options)?; + + let mut buff = Vec::new(); + file.read_to_end(&mut buff)?; + zip.write_all(&buff)?; + + zip.finish()?; + + let result = zip_path.to_str().ok_or(ZipError::InvalidArchive("Could not determine with zip filename"))?; + Ok(result.to_string()) +} \ No newline at end of file diff --git a/openubl/ui/client/src/app/api/rest.ts b/openubl/ui/client/src/app/api/rest.ts index bdaa0f49..2024cffe 100644 --- a/openubl/ui/client/src/app/api/rest.ts +++ b/openubl/ui/client/src/app/api/rest.ts @@ -1,4 +1,4 @@ -import axios from "axios"; +import axios, { AxiosRequestConfig } from "axios"; import { HubPaginatedResult, HubRequestParams, New, Project } from "./models"; import { serializeRequestParamsForHub } from "@app/hooks/table-controls"; @@ -43,3 +43,9 @@ export const updateProject = (obj: Project) => export const deleteProject = (id: number | string) => axios.delete(`${PROJECTS}/${id}`); + +export const uploadFile = ( + projectId: number | string, + formData: FormData, + config?: AxiosRequestConfig +) => axios.post(`${PROJECTS}/${projectId}/files`, formData, config); diff --git a/openubl/ui/client/src/app/components/PageDrawerContext.tsx b/openubl/ui/client/src/app/components/PageDrawerContext.tsx index 9da68c42..a7bb01da 100644 --- a/openubl/ui/client/src/app/components/PageDrawerContext.tsx +++ b/openubl/ui/client/src/app/components/PageDrawerContext.tsx @@ -6,6 +6,7 @@ import { DrawerContent, DrawerContentBody, DrawerHead, + DrawerPanelBody, DrawerPanelContent, DrawerPanelContentProps, } from "@patternfly/react-core"; @@ -13,7 +14,7 @@ import pageStyles from "@patternfly/react-styles/css/components/Page/page"; const usePageDrawerState = () => { const [isDrawerExpanded, setIsDrawerExpanded] = React.useState(false); - const [drawerChildren, setDrawerChildren] = + const [drawerPanelContent, setDrawerPanelContent] = React.useState(null); const [drawerPanelContentProps, setDrawerPanelContentProps] = React.useState< Partial @@ -23,8 +24,8 @@ const usePageDrawerState = () => { return { isDrawerExpanded, setIsDrawerExpanded, - drawerChildren, - setDrawerChildren, + drawerPanelContent, + setDrawerPanelContent, drawerPanelContentProps, setDrawerPanelContentProps, drawerPageKey, @@ -38,8 +39,8 @@ type PageDrawerState = ReturnType; const PageDrawerContext = React.createContext({ isDrawerExpanded: false, setIsDrawerExpanded: () => {}, - drawerChildren: null, - setDrawerChildren: () => {}, + drawerPanelContent: null, + setDrawerPanelContent: () => {}, drawerPanelContentProps: {}, setDrawerPanelContentProps: () => {}, drawerPageKey: "", @@ -58,7 +59,7 @@ export const PageContentWithDrawerProvider: React.FC< const { isDrawerExpanded, drawerFocusRef, - drawerChildren, + drawerPanelContent, drawerPanelContentProps, drawerPageKey, } = pageDrawerState; @@ -80,7 +81,7 @@ export const PageContentWithDrawerProvider: React.FC< key={drawerPageKey} {...drawerPanelContentProps} > - {drawerChildren} + {drawerPanelContent} } > @@ -98,6 +99,7 @@ let numPageDrawerContentInstances = 0; export interface IPageDrawerContentProps { isExpanded: boolean; onCloseClick: () => void; // Should be used to update local state such that `isExpanded` becomes false. + header?: React.ReactNode; children: React.ReactNode; // The content to show in the drawer when `isExpanded` is true. drawerPanelContentProps?: Partial; // Additional props for the DrawerPanelContent component. focusKey?: string | number; // A unique key representing the object being described in the drawer. When this changes, the drawer will regain focus. @@ -105,17 +107,18 @@ export interface IPageDrawerContentProps { } export const PageDrawerContent: React.FC = ({ - isExpanded: localIsExpandedProp, + isExpanded, onCloseClick, + header = null, children, - drawerPanelContentProps: localDrawerPanelContentProps, + drawerPanelContentProps, focusKey, pageKey: localPageKeyProp, }) => { const { setIsDrawerExpanded, drawerFocusRef, - setDrawerChildren, + setDrawerPanelContent, setDrawerPanelContentProps, setDrawerPageKey, } = React.useContext(PageDrawerContext); @@ -137,12 +140,12 @@ export const PageDrawerContent: React.FC = ({ // This is the ONLY place where `setIsDrawerExpanded` should be called. // To expand/collapse the drawer, use the `isExpanded` prop when rendering PageDrawerContent. React.useEffect(() => { - setIsDrawerExpanded(localIsExpandedProp); + setIsDrawerExpanded(isExpanded); return () => { setIsDrawerExpanded(false); - setDrawerChildren(null); + setDrawerPanelContent(null); }; - }, [localIsExpandedProp]); + }, [isExpanded, setDrawerPanelContent, setIsDrawerExpanded]); // Same with pageKey and drawerPanelContentProps, keep them in sync with the local prop on PageDrawerContent. React.useEffect(() => { @@ -150,33 +153,48 @@ export const PageDrawerContent: React.FC = ({ return () => { setDrawerPageKey(""); }; - }, [localPageKeyProp]); + }, [localPageKeyProp, setDrawerPageKey]); React.useEffect(() => { - setDrawerPanelContentProps(localDrawerPanelContentProps || {}); - }, [localDrawerPanelContentProps]); + setDrawerPanelContentProps(drawerPanelContentProps || {}); + }, [drawerPanelContentProps, setDrawerPanelContentProps]); // If the drawer is already expanded describing app A, then the user clicks app B, we want to send focus back to the drawer. - React.useEffect(() => { - drawerFocusRef?.current?.focus(); - }, [focusKey]); + + // TODO: This introduces a layout issue bug when clicking in between the columns of a table. + // React.useEffect(() => { + // drawerFocusRef?.current?.focus(); + // }, [drawerFocusRef, focusKey]); React.useEffect(() => { - setDrawerChildren( - - - {children} - - - - - + const drawerHead = header === null ? children : header; + const drawerPanelBody = header === null ? null : children; + + setDrawerPanelContent( + <> + + + {drawerHead} + + + + + + {drawerPanelBody} + ); - }, [children]); + }, [ + children, + drawerFocusRef, + header, + isExpanded, + onCloseClick, + setDrawerPanelContent, + ]); return null; }; diff --git a/openubl/ui/client/src/app/hooks/useUpload.ts b/openubl/ui/client/src/app/hooks/useUpload.ts new file mode 100644 index 00000000..35b2dd66 --- /dev/null +++ b/openubl/ui/client/src/app/hooks/useUpload.ts @@ -0,0 +1,250 @@ +import React from "react"; +import axios, { + AxiosError, + AxiosPromise, + AxiosRequestConfig, + AxiosResponse, + CancelTokenSource, +} from "axios"; + +const CANCEL_MESSAGE = "cancelled"; + +interface PromiseConfig { + formData: FormData; + config: AxiosRequestConfig; + + thenFn: (response: AxiosResponse) => void; + catchFn: (error: AxiosError) => void; +} + +interface Upload { + progress: number; + status: "queued" | "inProgress" | "complete"; + response?: AxiosResponse; + error?: AxiosError; + wasCancelled: boolean; + cancelFn?: CancelTokenSource; +} + +const defaultUpload = (): Upload => ({ + progress: 0, + status: "queued", + error: undefined, + response: undefined, + wasCancelled: false, + cancelFn: undefined, +}); + +interface Status { + uploads: Map>; +} + +type Action = + | { + type: "queueUpload"; + payload: { + file: File; + cancelFn: CancelTokenSource; + }; + } + | { + type: "updateUploadProgress"; + payload: { + file: File; + progress: number; + }; + } + | { + type: "finishUploadSuccessfully"; + payload: { + file: File; + response: AxiosResponse; + }; + } + | { + type: "finishUploadWithError"; + payload: { + file: File; + error: AxiosError; + }; + } + | { + type: "removeUpload"; + payload: { + file: File; + }; + }; + +const reducer = ( + state: Status, + action: Action +): Status => { + switch (action.type) { + case "queueUpload": + return { + ...state, + uploads: new Map(state.uploads).set(action.payload.file, { + ...(state.uploads.get(action.payload.file) || defaultUpload()), + status: "queued", + cancelFn: action.payload.cancelFn, + }), + }; + case "updateUploadProgress": + return { + ...state, + uploads: new Map(state.uploads).set(action.payload.file, { + ...(state.uploads.get(action.payload.file) || defaultUpload()), + progress: action.payload.progress || 0, + status: "inProgress", + }), + }; + case "finishUploadSuccessfully": + return { + ...state, + uploads: new Map(state.uploads).set(action.payload.file, { + ...state.uploads.get(action.payload.file)!, + status: "complete", + + response: action.payload.response, + }), + }; + case "finishUploadWithError": + return { + ...state, + uploads: new Map(state.uploads).set(action.payload.file, { + ...state.uploads.get(action.payload.file)!, + status: "complete", + error: action.payload.error, + wasCancelled: action.payload.error?.message === CANCEL_MESSAGE, + }), + }; + case "removeUpload": + const newUploads = new Map(state.uploads); + newUploads.delete(action.payload.file); + return { + ...state, + uploads: newUploads, + }; + default: + throw new Error(); + } +}; + +const initialState = (): Status => ({ + uploads: new Map(), +}); + +export const useUpload = ({ + parallel, + uploadFn, + onSuccess, + onError, +}: { + parallel: boolean; + uploadFn: ( + form: FormData, + requestConfig: AxiosRequestConfig + ) => AxiosPromise; + onSuccess?: (response: AxiosResponse, file: File) => void; + onError?: (error: AxiosError, file: File) => void; +}) => { + const [state, dispatch] = React.useReducer(reducer, initialState()); + + const handleUpload = (acceptedFiles: File[]) => { + const queue: PromiseConfig[] = []; + + for (let index = 0; index < acceptedFiles.length; index++) { + const file = acceptedFiles[index]; + + // Upload + const formData = new FormData(); + formData.set("file", file); + + const cancelFn = axios.CancelToken.source(); + + const config: AxiosRequestConfig = { + headers: { + "X-Requested-With": "XMLHttpRequest", + }, + onUploadProgress: (progressEvent: ProgressEvent) => { + const progress = (progressEvent.loaded / progressEvent.total) * 100; + dispatch({ + type: "updateUploadProgress", + payload: { file, progress: Math.round(progress) }, + }); + }, + cancelToken: cancelFn.token, + }; + + dispatch({ + type: "queueUpload", + payload: { + file, + cancelFn, + }, + }); + + const thenFn = (response: AxiosResponse) => { + dispatch({ + type: "finishUploadSuccessfully", + payload: { file, response }, + }); + + if (onSuccess) onSuccess(response, file); + }; + + const catchFn = (error: AxiosError) => { + dispatch({ + type: "finishUploadWithError", + payload: { file, error }, + }); + + if (error.message !== CANCEL_MESSAGE) { + if (onError) onError(error, file); + } + }; + + const promiseConfig: PromiseConfig = { + formData, + config, + thenFn, + catchFn, + }; + + queue.push(promiseConfig); + } + + if (parallel) { + queue.forEach((queue) => { + uploadFn(queue.formData, queue.config) + .then(queue.thenFn) + .catch(queue.catchFn); + }); + } else { + queue.reduce(async (prev, next) => { + await prev; + return uploadFn(next.formData, next.config) + .then(next.thenFn) + .catch(next.catchFn); + }, Promise.resolve()); + } + }; + + const handleCancelUpload = (file: File) => { + state.uploads.get(file)?.cancelFn?.cancel(CANCEL_MESSAGE); + }; + + const handleRemoveUpload = (file: File) => { + dispatch({ + type: "removeUpload", + payload: { file }, + }); + }; + + return { + uploads: state.uploads, + handleUpload, + handleCancelUpload, + handleRemoveUpload, + }; +}; diff --git a/openubl/ui/client/src/app/pages/documents/documents.tsx b/openubl/ui/client/src/app/pages/documents/documents.tsx index 5bda6a16..56e049b1 100644 --- a/openubl/ui/client/src/app/pages/documents/documents.tsx +++ b/openubl/ui/client/src/app/pages/documents/documents.tsx @@ -1,12 +1,10 @@ -import React, { useContext, useState } from "react"; -import { NavLink } from "react-router-dom"; -import { AxiosError } from "axios"; +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { NavLink, useParams } from "react-router-dom"; import { Button, ButtonVariant, - Modal, - ModalVariant, PageSection, PageSectionVariants, Text, @@ -26,11 +24,8 @@ import { Tr, } from "@patternfly/react-table"; +import { FilterToolbar, FilterType } from "@app/components/FilterToolbar"; import { SimplePagination } from "@app/components/SimplePagination"; -import { - FilterToolbar, - FilterType, -} from "@app/components/FilterToolbar"; import { ConditionalTableBody, TableHeaderContentWithControls, @@ -38,51 +33,20 @@ import { } from "@app/components/TableControls"; import { useLocalTableControls } from "@app/hooks/table-controls"; -import { - useFetchProjects, - useDeleteProjectMutation, -} from "@app/queries/projects"; +import { useFetchProjects } from "@app/queries/projects"; -import { Project } from "@app/api/models"; -import { ConfirmDialog } from "@app/components/ConfirmDialog"; -import { NotificationsContext } from "@app/components/NotificationsContext"; -import { getAxiosErrorMessage } from "@app/utils/utils"; +import { UploadFilesDrawer } from "./upload-files-drawer"; export const Projects: React.FC = () => { - const { pushNotification } = useContext(NotificationsContext); + const { t } = useTranslation(); + const { projectId } = useParams(); - const [isConfirmDialogOpen, setIsConfirmDialogOpen] = - useState(false); - const [projectIdToDelete, setProjectIdToDelete] = React.useState(); - - const [createUpdateModalState, setCreateUpdateModalState] = useState< - "create" | Project | null + const [uploadFilesToProjectId, setUploadFilesToProjectId] = useState< + string | number | null >(null); - const isCreateUpdateModalOpen = createUpdateModalState !== null; - const projectToUpdate = - createUpdateModalState !== "create" ? createUpdateModalState : null; - - const onDeleteOrgSuccess = () => { - pushNotification({ - title: "Proyecto eliminado", - variant: "success", - }); - }; - - const onDeleteOrgError = (error: AxiosError) => { - pushNotification({ - title: getAxiosErrorMessage(error), - variant: "danger", - }); - }; const { projects, isFetching, fetchError, refetch } = useFetchProjects(); - const { mutate: deleteOrg } = useDeleteProjectMutation( - onDeleteOrgSuccess, - onDeleteOrgError - ); - const tableControls = useLocalTableControls({ idProperty: "id", items: projects, @@ -121,21 +85,11 @@ export const Projects: React.FC = () => { }, } = tableControls; - const closeCreateUpdateModal = () => { - setCreateUpdateModalState(null); - refetch; - }; - - const deleteRow = (row: Project) => { - setProjectIdToDelete(row.id); - setIsConfirmDialogOpen(true); - }; - return ( <> - Proyectos + {t("terms.documents")} @@ -154,15 +108,26 @@ export const Projects: React.FC = () => { id="create-project" aria-label="Create new project" variant={ButtonVariant.primary} - onClick={() => setCreateUpdateModalState("create")} + // onClick={() => setCreateUpdateModalState("create")} > Crear proyecto + + + @@ -208,16 +173,18 @@ export const Projects: React.FC = () => { setCreateUpdateModalState(item), - }, - { - title: "Eliminar", - onClick: () => deleteRow(item), - }, - ]} + items={ + [ + // { + // title: "Editar", + // onClick: () => setCreateUpdateModalState(item), + // }, + // { + // title: "Eliminar", + // onClick: () => deleteRow(item), + // }, + ] + } /> @@ -229,11 +196,16 @@ export const Projects: React.FC = () => { + + setUploadFilesToProjectId(null)} + /> ); diff --git a/openubl/ui/client/src/app/pages/documents/upload-files-drawer.tsx b/openubl/ui/client/src/app/pages/documents/upload-files-drawer.tsx new file mode 100644 index 00000000..993b5881 --- /dev/null +++ b/openubl/ui/client/src/app/pages/documents/upload-files-drawer.tsx @@ -0,0 +1,160 @@ +import * as React from "react"; +import { FileRejection } from "react-dropzone"; +import { + DropEvent, + List, + ListItem, + Modal, + MultipleFileUpload, + MultipleFileUploadMain, + MultipleFileUploadStatus, + MultipleFileUploadStatusItem, + TextContent, + Title, +} from "@patternfly/react-core"; + +import spacing from "@patternfly/react-styles/css/utilities/Spacing/spacing"; +import UploadIcon from "@patternfly/react-icons/dist/esm/icons/upload-icon"; +import FileIcon from "@patternfly/react-icons/dist/esm/icons/file-code-icon"; + +import { + IPageDrawerContentProps, + PageDrawerContent, +} from "@app/components/PageDrawerContext"; + +import { useUpload } from "@app/hooks/useUpload"; +import { uploadFile } from "@app/api/rest"; + +export interface IUploadFilesDrawerProps + extends Pick { + projectId: string | number | null; +} + +export const UploadFilesDrawer: React.FC = ({ + projectId, + onCloseClick, +}) => { + const { uploads, handleUpload, handleRemoveUpload } = useUpload({ + parallel: true, + uploadFn: (formData, config) => { + return uploadFile(projectId!, formData, config); + }, + }); + + const [showStatus, setShowStatus] = React.useState(false); + const [statusIcon, setStatusIcon] = React.useState< + "danger" | "success" | "inProgress" + >("inProgress"); + const [rejectedFiles, setRejectedFiles] = React.useState([]); + + // only show the status component once a file has been uploaded, but keep the status list component itself even if all files are removed + if (!showStatus && uploads.size > 0) { + setShowStatus(true); + } + + // determine the icon that should be shown for the overall status list + React.useEffect(() => { + const currentUploads = Array.from(uploads.values()); + if (currentUploads.some((e) => e.status === "inProgress")) { + setStatusIcon("inProgress"); + } else if (currentUploads.every((e) => e.status === "complete")) { + setStatusIcon("success"); + } else { + setStatusIcon("danger"); + } + }, [uploads]); + + const removeFiles = (filesToRemove: File[]) => { + filesToRemove.forEach((e) => { + handleRemoveUpload(e); + }); + }; + + // callback that will be called by the react dropzone with the newly dropped file objects + const handleFileDrop = (_event: DropEvent, droppedFiles: File[]) => { + handleUpload(droppedFiles); + }; + + // dropzone prop that communicates to the user that files they've attempted to upload are not an appropriate type + const handleDropRejected = (fileRejections: FileRejection[]) => { + setRejectedFiles(fileRejections); + }; + + const successFileCount = Array.from(uploads.values()).filter( + (upload) => upload.response + ).length; + + return ( + + + Upload XML (UBL) files + + + } + > + + } + titleText="Drag and drop files here" + titleTextSeparator="or" + infoText="Accepted file types: XML" + /> + {showStatus && ( + + {Array.from(uploads.entries()).map(([file, upload]) => ( + } + file={file} + key={file.name} + onClearClick={() => removeFiles([file])} + progressValue={upload.progress} + progressVariant={ + upload.error + ? "danger" + : upload.response + ? "success" + : undefined + } + /> + ))} + + )} + + 0} + title="Unsupported files" + titleIconVariant="warning" + showClose + aria-label="unsupported file upload attempted" + onClose={() => setRejectedFiles([])} + variant="small" + > + + {rejectedFiles.map((e) => ( + {e.file.name} + ))} + + + + + ); +};