From 00babbf71b7313d011ba4479771ec0f1f8bf2435 Mon Sep 17 00:00:00 2001 From: Barrett Falk Date: Tue, 5 Dec 2023 16:33:16 -0800 Subject: [PATCH] feature/CE-104 upload an attachment to an existing complaint (#210) Co-authored-by: afwilcox --- .../db/migrations/R__Configuration-values.sql | 19 ++ .../cypress/e2e/complaint-attachments.cy.ts | 18 +- frontend/package-lock.json | 34 +++- frontend/package.json | 2 + frontend/src/app/common/api.ts | 81 +++++++++ frontend/src/app/common/methods.tsx | 27 +++ .../components/common/attachment-slide.tsx | 31 +++- .../components/common/attachment-upload.tsx | 53 ++++++ .../common/attachments-carousel.tsx | 123 ++++++++++--- .../details/complaint-details-edit.tsx | 46 ++++- frontend/src/app/constants/configurations.ts | 1 + frontend/src/app/store/reducers.ts | 2 +- frontend/src/app/store/reducers/app.ts | 15 +- .../src/app/store/reducers/attachments.ts | 165 ++++++++++++++++++ .../src/app/store/reducers/objectstore.ts | 65 ------- frontend/src/app/types/coms/object.ts | 20 ++- .../src/app/types/state/attachments-state.ts | 2 +- frontend/src/assets/sass/carousel.scss | 30 +++- 18 files changed, 617 insertions(+), 117 deletions(-) create mode 100644 frontend/src/app/components/common/attachment-upload.tsx create mode 100644 frontend/src/app/store/reducers/attachments.ts delete mode 100644 frontend/src/app/store/reducers/objectstore.ts diff --git a/backend/db/migrations/R__Configuration-values.sql b/backend/db/migrations/R__Configuration-values.sql index fc69d807b..d74cff328 100644 --- a/backend/db/migrations/R__Configuration-values.sql +++ b/backend/db/migrations/R__Configuration-values.sql @@ -15,4 +15,23 @@ true, CURRENT_USER, CURRENT_TIMESTAMP, CURRENT_USER, +CURRENT_TIMESTAMP) ON CONFLICT DO NOTHING; + +insert + into + configuration(configuration_code, + configuration_value, + long_description, + active_ind, + create_user_id, + create_utc_timestamp, + update_user_id, + update_utc_timestamp) +values ('MAXFILESZ', +'5000', +'The maximum file size (in Megabytes) supported for upload.', +true, +CURRENT_USER, +CURRENT_TIMESTAMP, +CURRENT_USER, CURRENT_TIMESTAMP) ON CONFLICT DO NOTHING; \ No newline at end of file diff --git a/frontend/cypress/e2e/complaint-attachments.cy.ts b/frontend/cypress/e2e/complaint-attachments.cy.ts index 74b9516b9..ccaaf049a 100644 --- a/frontend/cypress/e2e/complaint-attachments.cy.ts +++ b/frontend/cypress/e2e/complaint-attachments.cy.ts @@ -36,10 +36,26 @@ describe("Complaint Attachments", () => { .should('exist') .and('not.be.visible'); + // should not be able to upload on details view + cy.get("button.coms-carousel-upload-container").should("not.exist"); + cy.get(".coms-carousel-actions").first().invoke('attr', 'style', 'display: block'); - + // cypress can't verify things that happen in other tabs, so don't open attachments in another tab cy.get(".download-icon").should("exist"); + }); + }); + + Cypress._.times(complaintTypes.length, (index) => { + it("Verifies that upload option exists ", () => { + if ("#hwcr-tab".includes(complaintTypes[index])) { + cy.navigateToEditScreen(COMPLAINT_TYPES.HWCR, "23-000076"); + } else { + cy.navigateToEditScreen(COMPLAINT_TYPES.ERS, "23-006888"); + } + + // should be able to upload on details view + cy.get("button.coms-carousel-upload-container").should("exist"); }); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 888bd407f..4caffa26e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -35,6 +35,7 @@ "@types/react-phone-number-input": "^3.0.14", "@types/react-transition-group": "^4.4.6", "@types/redux-persist": "^4.3.1", + "@types/uuid": "^9.0.7", "@types/warning": "^3.0.0", "@typescript-eslint/parser": "5.56.0", "axios": "^1.4.0", @@ -88,6 +89,7 @@ "typescript": "*", "uncontrollable": "^8.0.2", "urlpattern-polyfill": "^9.0.0", + "uuid": "^9.0.1", "warning": "^4.0.3", "web-vitals": "^3.0.0" }, @@ -2335,6 +2337,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/@cypress/request/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@cypress/xvfb": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/@cypress/xvfb/-/xvfb-1.2.4.tgz", @@ -4728,6 +4739,11 @@ "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz", "integrity": "sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==" }, + "node_modules/@types/uuid": { + "version": "9.0.7", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.7.tgz", + "integrity": "sha512-WUtIVRUZ9i5dYXefDEAI7sh9/O7jGvHg7Df/5O/gtH3Yabe5odI3UWopVR1qbPXQtvOxWu3mM4XxlYeZtMWF4g==" + }, "node_modules/@types/warning": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.0.tgz", @@ -22481,6 +22497,14 @@ "websocket-driver": "^0.7.4" } }, + "node_modules/sockjs/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/socks": { "version": "2.7.1", "resolved": "https://registry.npmjs.org/socks/-/socks-2.7.1.tgz", @@ -23984,9 +24008,13 @@ } }, "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], "bin": { "uuid": "dist/bin/uuid" } diff --git a/frontend/package.json b/frontend/package.json index 865e6daf9..dc2acba9f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -29,6 +29,7 @@ "@types/react-phone-number-input": "^3.0.14", "@types/react-transition-group": "^4.4.6", "@types/redux-persist": "^4.3.1", + "@types/uuid": "^9.0.7", "@types/warning": "^3.0.0", "@typescript-eslint/parser": "5.56.0", "axios": "^1.4.0", @@ -82,6 +83,7 @@ "typescript": "*", "uncontrollable": "^8.0.2", "urlpattern-polyfill": "^9.0.0", + "uuid": "^9.0.1", "warning": "^4.0.3", "web-vitals": "^3.0.0" }, diff --git a/frontend/src/app/common/api.ts b/frontend/src/app/common/api.ts index 0b45e937c..69b804be4 100644 --- a/frontend/src/app/common/api.ts +++ b/frontend/src/app/common/api.ts @@ -110,6 +110,46 @@ export const get = ( }); }; +export const deleteMethod = ( + dispatch: Dispatch, + parameters: ApiRequestParameters, + headers?: {} +): Promise => { + let config: AxiosRequestConfig = { headers: headers }; + return new Promise((resolve, reject) => { + const { url, requiresAuthentication, params } = parameters; + + if (requiresAuthentication) { + axios.defaults.headers.common[ + "Authorization" + ] = `Bearer ${localStorage.getItem(AUTH_TOKEN)}`; + } + + if (params) { + config.params = params; + } + + axios + .delete(url, config) + .then((response: AxiosResponse) => { + const { data, status } = response; + + if (status === STATUS_CODES.Unauthorized) { + window.location = KEYCLOAK_URL; + } + + resolve(data as T); + }) + .catch((error: AxiosError) => { + if (parameters.enableNotification) { + const { message } = error; + dispatch(toggleNotification("error", message)); + } + reject(error); + }); + }); +}; + export const post = ( dispatch: Dispatch, parameters: ApiRequestParameters, @@ -205,3 +245,44 @@ export const put = ( }); }); }; + +export const putFile = ( + dispatch: Dispatch, + parameters: ApiRequestParameters, + headers: {}, + file: File, +): Promise => { + let config: AxiosRequestConfig = { headers: headers }; + + const formData = new FormData(); + if (file) + formData.append('file', file); + + return new Promise((resolve, reject) => { + const { url, requiresAuthentication } = parameters; + + if (requiresAuthentication) { + axios.defaults.headers.common[ + "Authorization" + ] = `Bearer ${localStorage.getItem(AUTH_TOKEN)}`; + } + + axios + .put(url, file, config) + .then((response: AxiosResponse) => { + const { status } = response; + + if (status === STATUS_CODES.Unauthorized) { + window.location = KEYCLOAK_URL; + } + + resolve(response.data as T); + }) + .catch((error: AxiosError) => { + if (parameters.enableNotification) { + dispatch(toggleNotification("error", error.message)); + } + reject(error); + }); + }); +}; diff --git a/frontend/src/app/common/methods.tsx b/frontend/src/app/common/methods.tsx index d420228b5..e67b74498 100644 --- a/frontend/src/app/common/methods.tsx +++ b/frontend/src/app/common/methods.tsx @@ -68,6 +68,23 @@ export const formatDateTime = (input: string | undefined): string => { return format(Date.parse(input), "yyyy-MM-dd HH:mm:ss"); }; +// given a filename and complaint identifier, inject the complaint identifier inbetween the file name and extension +export const injectComplaintIdentifierToFilename = (filename: string, complaintIdentifier: string): string => { + // Find the last dot in the filename to separate the extension + const lastDotIndex = filename.lastIndexOf('.'); + + // If there's no dot, just append the complaintId at the end + if (lastDotIndex === -1) { + return (`${filename} ${complaintIdentifier}`); + } + + const fileNameWithoutExtension = filename.substring(0, lastDotIndex); + const fileExtension = filename.substring(lastDotIndex); + + // Otherwise, insert the complaintId before the extension + return (`${fileNameWithoutExtension} ${complaintIdentifier}${fileExtension}`); +} + // Used to retrieve the coordinates in the decimal format export const parseDecimalDegreesCoordinates = ( coordinates: Coordinate, @@ -154,3 +171,13 @@ export const truncateString = (str: string, maxLength: number): string=> { return str; } } + +export const removeFile = (fileList: FileList, fileToRemove: File): File[] => { + // Convert the FileList to an array + const filesArray = Array.from(fileList); + + // Filter out the file you want to remove + const updatedFilesArray = filesArray.filter(file => file !== fileToRemove); + + return updatedFilesArray; +} \ No newline at end of file diff --git a/frontend/src/app/components/common/attachment-slide.tsx b/frontend/src/app/components/common/attachment-slide.tsx index df6f80782..5f0c2d7d6 100644 --- a/frontend/src/app/components/common/attachment-slide.tsx +++ b/frontend/src/app/components/common/attachment-slide.tsx @@ -12,6 +12,7 @@ import AttachmentIcon from "./attachment-icon"; type Props = { index: number; attachment: COMSObject; + onFileRemove: (attachment: COMSObject) => void; allowDelete?: boolean; }; @@ -19,6 +20,7 @@ export const AttachmentSlide: FC = ({ index, attachment, allowDelete, + onFileRemove, }) => { const dispatch = useAppDispatch(); @@ -39,27 +41,46 @@ export const AttachmentSlide: FC = ({ a.click(); }; + const getSlideClass = () => { + let className = ""; + + if (attachment.errorMesage) { + className = "error-slide"; + } else if (attachment.pendingUpload) { + className = "pending-slide"; + } + + return className; + } + return ( -
+
- {allowDelete && } + {!attachment.pendingUpload && ( handleAttachmentClick(`${attachment.id}`, `${attachment.name}`) } - /> + />)} + {allowDelete && onFileRemove(attachment)} />}
-
{attachment.name}
+
{decodeURIComponent(attachment.name)}
+ {attachment?.pendingUpload && attachment?.errorMesage ? ( +
+ {attachment?.errorMesage} +
+ ) : (
- {formatDateTime(attachment.createdAt.toString())} + {attachment?.pendingUpload ? 'Pending upload...' : formatDateTime(attachment.createdAt?.toString())}
+ )}
diff --git a/frontend/src/app/components/common/attachment-upload.tsx b/frontend/src/app/components/common/attachment-upload.tsx new file mode 100644 index 000000000..9fc9cfb24 --- /dev/null +++ b/frontend/src/app/components/common/attachment-upload.tsx @@ -0,0 +1,53 @@ +import { FC, useRef } from "react"; +import { BsPlus } from "react-icons/bs"; + +type Props = { + onFileSelect: (selectedFile: FileList) => void; +}; + +export const AttachmentUpload: FC = ({ + onFileSelect, +}) => { + const handleFileChange = (event: React.ChangeEvent) => { + if (event.target.files) { + onFileSelect(event.target.files); + } + }; + + // Without this, I'm unable to re-add the same file twice. + const handleFileClick = () => { + // Clear the current file input value + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + }; + + const fileInputRef = useRef(null); + + const handleDivClick = () => { + fileInputRef.current?.click(); + }; + + return ( +
+ + +
+ ); +}; diff --git a/frontend/src/app/components/common/attachments-carousel.tsx b/frontend/src/app/components/common/attachments-carousel.tsx index b1a29618a..4119c1b67 100644 --- a/frontend/src/app/components/common/attachments-carousel.tsx +++ b/frontend/src/app/components/common/attachments-carousel.tsx @@ -6,32 +6,40 @@ import { ButtonNext, } from "pure-react-carousel"; import "pure-react-carousel/dist/react-carousel.es.css"; -import { useAppDispatch } from "../../hooks/hooks"; +import { useAppDispatch, useAppSelector } from "../../hooks/hooks"; import { useSelector } from "react-redux"; import { RootState } from "../../store/store"; import { getAttachments, setAttachments, -} from "../../store/reducers/objectstore"; -import { - BsArrowLeftShort, - BsArrowRightShort, - BsPlus, -} from "react-icons/bs"; +} from "../../store/reducers/attachments"; +import { BsArrowLeftShort, BsArrowRightShort } from "react-icons/bs"; import { AttachmentSlide } from "./attachment-slide"; +import { AttachmentUpload } from "./attachment-upload"; +import { COMSObject } from "../../types/coms/object"; +import { selectMaxFileSize } from "../../store/reducers/app"; +import { v4 as uuidv4 } from 'uuid'; type Props = { complaintIdentifier: string; allowUpload?: boolean; allowDelete?: boolean; + onFilesSelected?: (attachments: File[]) => void; + onFileDeleted?: (attachments: COMSObject) => void; }; export const AttachmentsCarousel: FC = ({ complaintIdentifier, allowUpload, allowDelete, + onFilesSelected, + onFileDeleted, }) => { const dispatch = useAppDispatch(); + + // max file size for uploads + const maxFileSize = useAppSelector(selectMaxFileSize) + const carouselData = useSelector( (state: RootState) => state.attachments.attachments ); @@ -40,32 +48,92 @@ export const AttachmentsCarousel: FC = ({ const [visibleSlides, setVisibleSlides] = useState(4); // Adjust the initial number of visible slides as needed const carouselContainerRef = useRef(null); // ref to the carousel's container, used to determine how many slides can fit in the container + const [slides, setSlides] = useState([]); + + // when the carousel data updates (from the selector, on load), populate the carousel slides + useEffect(() => { + if (carouselData) { + setSlides(carouselData); + } else { + setSlides([]) + } + }, [carouselData]); // get the attachments when the complaint loads useEffect(() => { dispatch(getAttachments(complaintIdentifier)); }, [complaintIdentifier, dispatch]); - //-- when the component unmounts clear the complaint from redux + //-- when the component unmounts clear the attachments from redux useEffect(() => { return () => { - dispatch(setAttachments({})); + dispatch(setAttachments([])); }; - }, []); + }, [dispatch]); + // when a user selects files (via the file browser that pops up when clicking the upload slide) then add them to the carousel + const onFileSelect = (newFiles: FileList) => { + const selectedFilesArray = Array.from(newFiles); + let newSlides: COMSObject[] = []; + selectedFilesArray.forEach((file) => { + newSlides.push(createSlideFromFile(file)); + }); + + removeInvalidFiles(selectedFilesArray); - useEffect(() => { + setSlides([...newSlides, ...slides]); + }; + + // don't upload files that are invalid + const removeInvalidFiles = (files: File[]) => { + if (onFilesSelected) { + // remove any of the selected files that fail validation so that they aren't uploaded + const validFiles = files.filter(file => file.size <= maxFileSize * 1_000_000); + onFilesSelected(validFiles); + } + } + + // given a file, create a carousel slide + const createSlideFromFile = (file: File) => { + const newSlide: COMSObject = { + name: encodeURIComponent(file.name), + id: uuidv4(), // generate a unique identifier in case the user uploads non-unique file names. This allows us to know which one the user wants to delete + path: "", + public: false, + active: false, + bucketId: "", + createdBy: "", + updatedBy: "", + pendingUpload: true + }; + + // check for large file sizes + if (file.size > (maxFileSize * 1_000_000)) { // convert MB to Bytes + newSlide.errorMesage = `File exceeds ${maxFileSize} MB`; + } + + return newSlide; + } + + // fired when user wants to remove a slide from the carousel + const onFileRemove = (attachment: COMSObject) => { + setSlides(slides => slides.filter(slide => slide.id !== attachment.id)); + if (onFileDeleted) { + onFileDeleted(attachment); + } + } + // calculates how many slides will fit on the page + useEffect(() => { const calcualteSlidesToDisplay = (containerWidth: number): number => { const SLIDE_WIDTH = 299; // width of a slide if 289, plus 10 for gap const slidesToDisplay = Math.floor(containerWidth / SLIDE_WIDTH); - if (allowUpload) { + if (allowUpload) { // account for the upload slide (which adds another slide to the carousel) return slidesToDisplay <= 1 ? 1 : slidesToDisplay - 1; } else { return slidesToDisplay <= 1 ? 1 : slidesToDisplay; } - } - + }; // Function to update the number of visible slides based on the parent container width const updateVisibleSlides = () => { if (carouselContainerRef.current) { @@ -85,16 +153,18 @@ export const AttachmentsCarousel: FC = ({ return () => { window.removeEventListener("resize", updateVisibleSlides); }; + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return (
-
Attachments ({carouselData?.length ? carouselData.length : 0})
- {carouselData && carouselData?.length > 0 && ( +
Attachments ({slides?.length ? slides.length : 0})
+ + {(allowUpload || (slides && slides?.length > 0)) && ( @@ -105,16 +175,19 @@ export const AttachmentsCarousel: FC = ({ {allowUpload && ( -
-
- -
-
Upload
-
+ )} - {carouselData?.map((item, index) => ( - + {slides?.map((item, index) => ( + onFileRemove(item)} + /> ))}
diff --git a/frontend/src/app/components/containers/complaints/details/complaint-details-edit.tsx b/frontend/src/app/components/containers/complaints/details/complaint-details-edit.tsx index e04f91e2b..caba5dd74 100644 --- a/frontend/src/app/components/containers/complaints/details/complaint-details-edit.tsx +++ b/frontend/src/app/components/containers/complaints/details/complaint-details-edit.tsx @@ -60,6 +60,8 @@ import { CallDetails } from "./call-details"; import { CallerInformation } from "./caller-information"; import { SuspectWitnessDetails } from "./suspect-witness-details"; import { AttachmentsCarousel } from "../../../common/attachments-carousel"; +import { deleteAttachments, saveAttachments } from "../../../../store/reducers/attachments"; +import { COMSObject } from "../../../../types/coms/object"; type ComplaintParams = { id: string; @@ -106,6 +108,8 @@ export const ComplaintDetailsEdit: FC = () => { setPrimaryPhoneMsg(""); setSecondaryPhoneMsg(""); setAlternatePhoneMsg(""); + setAttachmentsToAdd(null); + setAttachmentsToDelete(null); }; const cancelButtonClick = () => { @@ -122,6 +126,25 @@ export const ComplaintDetailsEdit: FC = () => { ); }; + // files to add to COMS when complaint is saved + const [attachmentsToAdd, setAttachmentsToAdd] = useState(null); + + // files to remove from COMS when complaint is saved + const [attachmentsToDelete, setAttachmentsToDelete] = useState(null); + + const handleAddAttachments = (selectedFiles: File[]) => { + setAttachmentsToAdd(prevFiles => prevFiles ? [...prevFiles, ...selectedFiles] : selectedFiles); + }; + + const handleDeleteAttachment = (fileToDelete: COMSObject) => { + if (!fileToDelete.pendingUpload) { + setAttachmentsToDelete(prevFiles => prevFiles ? [...prevFiles, fileToDelete] : [fileToDelete]); + } else if (attachmentsToAdd) { // we're deleting an attachment that wasn't uploaded, so remove the attachment from the "attachmentsToDelete" state + setAttachmentsToAdd(prevAttachments => prevAttachments ? prevAttachments.filter(file => file.name !== fileToDelete.name) : null); + } + }; + + const [errorNotificationClass, setErrorNotificationClass] = useState( "comp-complaint-error display-none" ); @@ -154,7 +177,18 @@ export const ComplaintDetailsEdit: FC = () => { } else { ToggleError("Errors in form"); setErrorNotificationClass("comp-complaint-error"); + }; + if (attachmentsToAdd) { + dispatch(saveAttachments(attachmentsToAdd, id)); + } + + if (attachmentsToDelete) { + dispatch(deleteAttachments(attachmentsToDelete)) } + + // clear the attachments since they've been added or saved. If they couldn't be added or saved then an error would have appeared + setAttachmentsToAdd(null); + setAttachmentsToDelete(null); }; useEffect(() => { @@ -1616,7 +1650,13 @@ export const ComplaintDetailsEdit: FC = () => {
)} - + { /> )} - {readOnly && ( - - )} + {readOnly && } {readOnly && ( { return 50; // if there is no default in the configuration table, use 50 is the fallback }; +// get the maximum file size for uploading to COMS (in MB) +export const selectMaxFileSize = (state: RootState): any => { + const { app } = state; + const configuration = app.configurations?.configurations?.find( + (record) => Configurations.MAX_FILES_SIZE === record.configurationCode + ); + if (configuration?.configurationValue) { + return +configuration.configurationValue; + } + return 5000000; // if there is no default in the configuration table, use 5000000 as the default +}; + export const selectNotification = (state: RootState): NotificationState => { const { app: { notifications }, @@ -338,7 +351,7 @@ export const getConfigurations = (): AppThunk => async (dispatch) => { ); } } catch (error) { - console.log(error); + ToggleError("Unable to get configurations"); } }; diff --git a/frontend/src/app/store/reducers/attachments.ts b/frontend/src/app/store/reducers/attachments.ts new file mode 100644 index 000000000..1a1b07f55 --- /dev/null +++ b/frontend/src/app/store/reducers/attachments.ts @@ -0,0 +1,165 @@ +import { createSlice } from "@reduxjs/toolkit"; +import { RootState, AppThunk } from "../store"; +import { + deleteMethod, + generateApiParameters, + get, + putFile, +} from "../../common/api"; +import { from } from "linq-to-typescript"; +import { COMSObject } from "../../types/coms/object"; +import { AttachmentsState } from "../../types/state/attachments-state"; +import config from "../../../config"; +import { injectComplaintIdentifierToFilename } from "../../common/methods"; +import { ToggleError, ToggleSuccess } from "../../common/toast"; +import axios from "axios"; + +const initialState: AttachmentsState = { + attachments: [], +}; + +/** + * Attachments for each complaint + */ +export const attachmentsSlice = createSlice({ + name: "attachments", + initialState, + // The `reducers` field lets us define reducers and generate associated actions + reducers: { + + // used when retrieving attachments from objectstore + setAttachments: (state, action) => { + const { + payload: { attachments }, + } = action; + return { ...state, attachments: attachments ?? [] }; + }, + + // used when removing an attachment from a complaint + removeAttachment: (state, action) => { + return { + ...state, + attachments: state.attachments.filter(attachment => attachment.id !== action.payload), + }; + }, + + // used when adding an attachment to a complaint + addAttachment: (state, action) => { + const { name, type, size, id } = action.payload; // Extract relevant info + const serializedFile = { name, type, size, id }; + + return { + ...state, + attachments: [...state.attachments, serializedFile], + }; + }, + + }, + + // The `extraReducers` field lets the slice handle actions defined elsewhere, + // including actions generated by createAsyncThunk or in other slices. + extraReducers: (builder) => {}, +}); + +// export the actions/reducers +export const { setAttachments, removeAttachment , addAttachment} = attachmentsSlice.actions; + +// Get list of the attachments and update store +export const getAttachments = + (complaint_identifier: string): AppThunk => + async (dispatch) => { + try { + const parameters = generateApiParameters( + `${config.COMS_URL}/object?bucketId=${config.COMS_BUCKET}` + ); + const response = await get>(dispatch, parameters, { + "x-amz-meta-complaint-id": complaint_identifier, + }); + if (response && from(response).any()) { + + dispatch( + setAttachments({ + attachments: response ?? [], + }) + ); + } + } catch (error) { + ToggleError(`Error retrieving attachments`); + } + }; + +// delete attachments from objectstore +export const deleteAttachments = + (attachments: COMSObject[]): AppThunk => + async (dispatch) => { + if (attachments) { + for (const attachment of attachments) { + try { + const parameters = generateApiParameters( + `${config.COMS_URL}/object/${attachment.id}` + ); + + await deleteMethod(dispatch, parameters); + dispatch(removeAttachment(attachment.id)); // delete from store + ToggleSuccess(`Attachment ${decodeURIComponent(attachment.name)} has been removed`); + } catch (error) { + ToggleError(`Attachment ${decodeURIComponent(attachment.name)} could not be deleted`); + } + } + } + + }; + +// save new attachment(s) to object store +export const saveAttachments = + (attachments: File[], complaint_identifier: string): AppThunk => + async (dispatch) => { + if (attachments) { + for (const attachment of attachments) { + const header = { + "x-amz-meta-complaint-id": complaint_identifier, + "Content-Disposition": `attachment; filename="${encodeURIComponent(injectComplaintIdentifierToFilename( + attachment.name, + complaint_identifier + ))}"`, + "Content-Type": attachment?.type, + }; + + try { + const parameters = generateApiParameters( + `${config.COMS_URL}/object?bucketId=${config.COMS_BUCKET}` + ); + + const response = await putFile( + dispatch, + parameters, + header, + attachment + ); + + dispatch(addAttachment(response)); // dispatch with serializable payload + + if (response) { + ToggleSuccess(`Attachment "${attachment.name}" saved`); + } + + } catch (error) { + if (axios.isAxiosError(error) && error.response?.status === 409) { + ToggleError(`Attachment "${attachment.name}" could not be saved. Duplicate file.`); + } else { + ToggleError(`Attachment "${attachment.name}" could not be saved.`); + } + } + } + } + }; + +//-- selectors +export const selectAttachments = (state: RootState): COMSObject[] => { + const { attachments: attachmentsRoot } = state; + const { attachments } = attachmentsRoot; + + return attachments ?? []; +}; + +export default attachmentsSlice.reducer; diff --git a/frontend/src/app/store/reducers/objectstore.ts b/frontend/src/app/store/reducers/objectstore.ts deleted file mode 100644 index f4cf8cdac..000000000 --- a/frontend/src/app/store/reducers/objectstore.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { createSlice } from "@reduxjs/toolkit"; -import { RootState, AppThunk } from "../store"; -import { Officer } from "../../types/person/person"; -import { generateApiParameters, get } from "../../common/api"; -import { from } from "linq-to-typescript"; -import { COMSObject } from "../../types/coms/object"; -import { AttachmentsState } from "../../types/state/attachments-state"; -import config from "../../../config"; - - -const initialState: AttachmentsState = { - attachments: [], -}; - -export const attachmentsSlice = createSlice({ - name: "attachments", - initialState, - // The `reducers` field lets us define reducers and generate associated actions - reducers: { - setAttachments: (state, action) => { - const { - payload: { attachments }, - } = action; - return { ...state, attachments }; - }, - }, - - // The `extraReducers` field lets the slice handle actions defined elsewhere, - // including actions generated by createAsyncThunk or in other slices. - extraReducers: (builder) => {}, -}); - -// export the actions/reducers -export const { setAttachments } = attachmentsSlice.actions; - -// Get list of the officers and update store -export const getAttachments = (complaint_identifier: string): AppThunk => async (dispatch) => { - try { - const parameters = generateApiParameters( - `${config.COMS_URL}/object?bucketId=${config.COMS_BUCKET}` - ); - const response = await get>(dispatch, parameters,{'x-amz-meta-complaint-id':complaint_identifier}); - if (response && from(response).any()) { - dispatch( - setAttachments({ - attachments: response, - }), - ); - } - } catch (error) { - //-- handle errors - } - }; - -//-- selectors - -export const selectAttachments = (state: RootState): COMSObject[] | null => { - const { attachments: attachmentsRoot } = state; - const { attachments } = attachmentsRoot; - - return attachments ?? null; -}; - - -export default attachmentsSlice.reducer; diff --git a/frontend/src/app/types/coms/object.ts b/frontend/src/app/types/coms/object.ts index fe4f33477..c16179b95 100644 --- a/frontend/src/app/types/coms/object.ts +++ b/frontend/src/app/types/coms/object.ts @@ -1,12 +1,14 @@ export interface COMSObject { - id: string; - path: string; - public: boolean; - active: boolean; - bucketId: string; + id?: string; + path?: string; + public?: boolean; + active?: boolean; + bucketId?: string; name: string; - createdBy: string; - createdAt: Date; - updatedBy: string; - updatedAt: Date; + createdBy?: string; + createdAt?: Date; + updatedBy?: string; + updatedAt?: Date; + pendingUpload?: boolean + errorMesage?: string } diff --git a/frontend/src/app/types/state/attachments-state.ts b/frontend/src/app/types/state/attachments-state.ts index 839158aed..3e8aac22a 100644 --- a/frontend/src/app/types/state/attachments-state.ts +++ b/frontend/src/app/types/state/attachments-state.ts @@ -1,5 +1,5 @@ import { COMSObject } from "../coms/object"; export interface AttachmentsState { - attachments?: COMSObject[]; + attachments: COMSObject[]; } diff --git a/frontend/src/assets/sass/carousel.scss b/frontend/src/assets/sass/carousel.scss index c8fec0f04..8948c002e 100644 --- a/frontend/src/assets/sass/carousel.scss +++ b/frontend/src/assets/sass/carousel.scss @@ -85,7 +85,7 @@ font-size: 32px; } -.coms-carousel-slide:hover > .top-section { +.coms-carousel-slide:hover .top-section { background-color: $gray-400; } @@ -103,13 +103,13 @@ padding-left: 16px; font-size: 16px; background-color: white; + height: 60px; } .coms-carousel-slide .slide_text { font-size: 16px; margin-bottom: 5px; text-align: left; - } .coms-carousel-slide .slide_file_name { @@ -120,6 +120,23 @@ width: 250px; } +.error-slide { + border-color: $bc-gov-danger; +} + +.pending-slide .top-section { + color: $gray-500; + background-color: $gray-200; +} + +.pending-slide:hover .top-section { + color: black; +} + +.error-slide .top-section, .error-slide .bottom-section { + color: $bc-gov-danger; +} + .coms-carousel-upload-container { float: left; margin-right: 10px; @@ -133,6 +150,11 @@ border-radius: 3px; background-color: $gray-110; color: $gray-400; + cursor: pointer; +} + +.coms-carousel-upload-container:hover { + color: black; } .upload-icon { @@ -148,4 +170,8 @@ position: absolute; top: 10px; right: 10px; +} + +.coms-carousel-error { + border-color: red; } \ No newline at end of file