From 2f6abf80e4a8dc17b2683861d4f4b91efa23e955 Mon Sep 17 00:00:00 2001 From: Wilson Chua <wwc4618@ic.ac.uk> Date: Tue, 8 Sep 2020 00:55:37 +0800 Subject: [PATCH] ModuleResource staff view: Implement drag to rearrange and per resource actionable menus --- package.json | 1 + src/components/atoms/FileListItem/index.tsx | 71 ++++++-- .../atoms/FileListItem/style.module.scss | 1 + src/components/atoms/IconButton/index.tsx | 25 ++- .../atoms/IconButton/style.module.scss | 15 +- .../molecules/CategoryList/index.tsx | 122 ++++++++++---- .../molecules/ResourceDetailForm/index.tsx | 104 +++++++++--- .../molecules/SelectionView/index.tsx | 3 +- src/components/organisms/EditModal/index.tsx | 98 +++++++++++ .../UploadModal/index.tsx | 87 ++++------ .../UploadModal/style.module.scss | 4 - .../pages/ModuleDashboard/index.tsx | 4 +- src/components/pages/ModuleOverview/index.tsx | 4 +- .../ModuleResources/components/StaffView.tsx | 152 ++++++++++++------ .../pages/ModuleResources/index.tsx | 78 ++++----- src/components/pages/ModuleResources/utils.ts | 8 +- src/components/pages/StandardView/index.tsx | 12 ++ src/utils/types.tsx | 1 + 18 files changed, 568 insertions(+), 222 deletions(-) create mode 100644 src/components/organisms/EditModal/index.tsx rename src/components/{pages/ModuleResources/components => organisms}/UploadModal/index.tsx (75%) rename src/components/{pages/ModuleResources/components => organisms}/UploadModal/style.module.scss (90%) create mode 100644 src/utils/types.tsx diff --git a/package.json b/package.json index e29ae82d7..3af9365cb 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "react-router-dom": "^5.2.0", "react-scripts": "3.4.1", "react-select": "^3.1.0", + "react-sortable-hoc": "1.11.0", "react-transition-group": "^4.4.1", "react-use-localstorage": "^3.5.3", "types": "^0.1.1", diff --git a/src/components/atoms/FileListItem/index.tsx b/src/components/atoms/FileListItem/index.tsx index 4b37b91f9..48646aff8 100644 --- a/src/components/atoms/FileListItem/index.tsx +++ b/src/components/atoms/FileListItem/index.tsx @@ -1,40 +1,91 @@ -import React from "react"; +import React, { useEffect, useState } from "react"; import styles from "./style.module.scss"; import classNames from "classnames"; import Row from "react-bootstrap/esm/Row"; import Badge from "react-bootstrap/Badge"; import { IconDefinition } from "@fortawesome/free-regular-svg-icons"; +import { faBars } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { SortableHandle } from "react-sortable-hoc"; export interface FileListItemProps { title: string; icon: IconDefinition; tags: string[]; + resourceActions?: any + showMenu?: boolean, + setShowMenu?: (show: boolean) => void, onIconClick?: (event: React.MouseEvent) => void; onClick?: (event: React.MouseEvent) => void; onMouseOver?: (event: React.MouseEvent) => void; onMouseOut?: (event: React.MouseEvent) => void; } +const DragHandle = SortableHandle(() => <FontAwesomeIcon icon={faBars}/>); + const FileListItem: React.FC<FileListItemProps> = ({ title, icon, tags, + resourceActions, + showMenu, + setShowMenu, onIconClick, onClick, onMouseOver, onMouseOut }) => { + const [xPos, setXPos] = useState("0px"); + const [yPos, setYPos] = useState("0px"); + + const handleClick = () => { + setShowMenu && showMenu && setShowMenu(false); + } + + const handleContextMenu = (e: React.MouseEvent) => { + e.preventDefault(); + setXPos(`${e.pageX - 5}px`); + setYPos(`${e.pageY - 5}px`); + setShowMenu && setShowMenu(true); + } + + useEffect(() => { + document.addEventListener("click", handleClick); + }); + return ( - <div - className={styles.listItem} - onClick={onClick} - onMouseOut={onMouseOut} - onMouseOver={onMouseOver} - > - <Row className={styles.listRow}> - <div className={styles.listItemTitle}>{title}</div> + <> + {showMenu && resourceActions && + <div + style={{ + top: yPos, + left: xPos, + position: "absolute", + boxShadow: "0px 8px 10px #999999", + borderRadius: "8px", + zIndex: 10000 + }} + > + { resourceActions } + </div> + } + <Row + className={styles.listRow} + onClick={onClick} + onMouseOver={onMouseOver} + onMouseOut={onMouseOut} + onContextMenu={handleContextMenu} + > + <div className={styles.listItemTitle}> + { resourceActions ? + <div style={{ padding: 0, display: "flex", alignItems: "center" }}> + <DragHandle /> + { title } + </div> : + title + } + </div> <div style={{ padding: 0, display: "flex", alignItems: "center" }}> {tags.map(tag => ( <Badge @@ -59,7 +110,7 @@ const FileListItem: React.FC<FileListItemProps> = ({ /> </div> </Row> - </div> + </> ); }; diff --git a/src/components/atoms/FileListItem/style.module.scss b/src/components/atoms/FileListItem/style.module.scss index 3c1168748..30ac6758f 100644 --- a/src/components/atoms/FileListItem/style.module.scss +++ b/src/components/atoms/FileListItem/style.module.scss @@ -24,6 +24,7 @@ justify-content: space-between; flex-wrap: nowrap; background: transparent; + cursor: pointer; } .listItemTitle { diff --git a/src/components/atoms/IconButton/index.tsx b/src/components/atoms/IconButton/index.tsx index 120a6baa5..d2697618c 100644 --- a/src/components/atoms/IconButton/index.tsx +++ b/src/components/atoms/IconButton/index.tsx @@ -1,25 +1,31 @@ import React from "react"; import styles from "./style.module.scss"; +import classNames from "classnames"; + import Button from "react-bootstrap/Button"; +import OverlayTrigger from "react-bootstrap/OverlayTrigger"; +import Tooltip from "react-bootstrap/Tooltip"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { IconDefinition } from "@fortawesome/free-solid-svg-icons"; interface IconButtonProps { - buttonProps: any; + buttonProps?: any; + tooltip?: string; onClick: any; icon: IconDefinition; } const IconButton: React.FC<IconButtonProps> = ({ buttonProps, + tooltip, onClick, icon }) => { - return ( + const button = () => ( <Button {...buttonProps} variant="secondary" - className={styles.sectionHeaderButton} + className={classNames(styles.sectionHeaderButton, styles.iconButton)} onClick={onClick} > <FontAwesomeIcon @@ -28,6 +34,19 @@ const IconButton: React.FC<IconButtonProps> = ({ /> </Button> ); + + if (tooltip) { + return ( + <OverlayTrigger overlay={ + <Tooltip id={`tooltip-${tooltip}`}> + {tooltip} + </Tooltip> + }> + { button() } + </OverlayTrigger> + ); + } + return button(); }; diff --git a/src/components/atoms/IconButton/style.module.scss b/src/components/atoms/IconButton/style.module.scss index 5ae0ba0d1..a993515ce 100644 --- a/src/components/atoms/IconButton/style.module.scss +++ b/src/components/atoms/IconButton/style.module.scss @@ -1,2 +1,15 @@ @import "assets/scss/custom"; -@import "assets/scss/global"; \ No newline at end of file +@import "assets/scss/global"; + + +.iconButton.sectionHeaderButton { + background-color: $gray-800; + color: $white; + margin-left: 0px; +} + +.iconButton.sectionHeaderButton:hover, .iconButton.sectionHeaderButton:focus { + background-color: $white; + color: $gray-700 !important; + box-shadow: none !important; +} \ No newline at end of file diff --git a/src/components/molecules/CategoryList/index.tsx b/src/components/molecules/CategoryList/index.tsx index 9884daf02..1d24405a3 100644 --- a/src/components/molecules/CategoryList/index.tsx +++ b/src/components/molecules/CategoryList/index.tsx @@ -1,53 +1,119 @@ -import React from "react"; +import React, { useState, useEffect } from "react"; + +import { SortableContainer, SortableElement, arrayMove } from 'react-sortable-hoc'; import { faSquare, faCheckSquare } from "@fortawesome/free-regular-svg-icons"; -import { Resource, resourceTypeToIcon } from "../../pages/ModuleResources/utils"; -import { SelectionProps } from "components/molecules/SelectionView"; + import FileListItem from "components/atoms/FileListItem"; +import { SelectionProps } from "components/molecules/SelectionView"; +import { Resource, resourceTypeToIcon } from "../../pages/ModuleResources/utils"; +import { idBooleanMap } from "utils/types"; +import { staffRequest } from "utils/api"; +import { api, methods } from "constants/routes"; export interface CategoryListProps { categoryItems: Resource[]; select?: SelectionProps; - fileDropdown?: (id: number, filename: string) => any; + showMenus?: idBooleanMap, + setShowMenus?: (id: number) => (show: boolean) => void, + resourceActions?: (id: number, filename: string) => any; handleRowClick: (id: number) => void; handleIconClick: (id: number) => void; handleMouseOver: (id: number) => void; handleMouseOut: (id: number) => void; } +const SortableItem = SortableElement(({children}: {children: any}) => + <div>{children}</div> +); + +const SortableList = SortableContainer(({items}: {items: any[]}) => + <div> + {items.map((item, index) => ( + <SortableItem + key={`item-${index}`} + index={index} + children={item} + /> + ))} + </div> +); + const CategoryList: React.FC<CategoryListProps> = ({ categoryItems, select, - fileDropdown, + showMenus, + setShowMenus, + resourceActions, handleRowClick, handleIconClick, handleMouseOver, handleMouseOut, }) => { + let oldIndexes: number[] = categoryItems.map(resource => resource.index); + + const initListItems = (items: Resource[]) => items.map(({ title, type, tags, id, index }) => { + if (type === undefined || tags === undefined) return null; + + let icon = + select && (select.isAnySelected() || select.state.isHoveringOver[id]) + ? select.state.isSelected[id] + ? faCheckSquare + : faSquare + : resourceTypeToIcon(type); + + return ( + <FileListItem + onClick={() => handleRowClick(id)} + onMouseOver={() => handleMouseOver(id)} + onMouseOut={() => handleMouseOut(id)} + onIconClick={() => handleIconClick(id)} + showMenu={showMenus && showMenus[id]} + setShowMenu={setShowMenus && setShowMenus(id)} + icon={icon} + tags={tags} + title={title} + resourceActions={resourceActions ? resourceActions(id, title) : null} + key={index} + /> + ); + }); + + const [listItems, setListItems] = useState(initListItems(categoryItems)); + const [resources, setResources] = useState(categoryItems); + + useEffect(() => { + setListItems(initListItems(resources)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [showMenus, setShowMenus]); + + const onSortEnd = ({ oldIndex, newIndex }: { oldIndex : number, newIndex: number }) => { + setListItems(arrayMove(listItems, oldIndex, newIndex)); + // Save reordered array as local var since state will not update immediately + let newResources = arrayMove(resources, oldIndex, newIndex); + setResources(newResources); + // Only need to modify array subset between target indices + let minIndex = Math.min(oldIndex, newIndex); + let maxIndex = Math.max(oldIndex, newIndex) + 1; + let indexes = oldIndexes.slice(minIndex, maxIndex); + + newResources.slice(minIndex, maxIndex).forEach((resource, i) => { + staffRequest(api.MATERIALS_RESOURCES_ID(resource.id), methods.PUT, () => {}, () => {}, { + index: indexes[i] + }); + }) + } + return ( <div style={{marginLeft: ".25rem"}}> - {categoryItems.map(({ title, type, tags, id }) => { - if (type === undefined || tags === undefined) return null; - - let icon = - select && (select.isAnySelected() || select.state.isHoveringOver[id]) - ? select.state.isSelected[id] - ? faCheckSquare - : faSquare - : resourceTypeToIcon(type); - - return ( - <FileListItem - onClick={() => handleRowClick(id)} - onMouseOver={() => handleMouseOver(id)} - onMouseOut={() => handleMouseOut(id)} - onIconClick={() => handleIconClick(id)} - icon={icon} - tags={tags} - title={title} - key={id} - /> - ); - })} + { resourceActions ? + <SortableList + items={listItems} + onSortEnd={onSortEnd} + onSortStart={(_, event) => event.preventDefault()} + useDragHandle + /> + : listItems + } </div> ); }; diff --git a/src/components/molecules/ResourceDetailForm/index.tsx b/src/components/molecules/ResourceDetailForm/index.tsx index 06f01e48f..6949c4f8d 100644 --- a/src/components/molecules/ResourceDetailForm/index.tsx +++ b/src/components/molecules/ResourceDetailForm/index.tsx @@ -4,20 +4,28 @@ import Row from "react-bootstrap/Row"; import Col from "react-bootstrap/Col"; import Form from "react-bootstrap/Form"; import CreatableSelect from "react-select/creatable"; -import DatePicker, { registerLocale } from "react-datepicker"; -import { ResourceDetails } from "../../pages/ModuleResources/components/UploadModal" +import DatePicker from "react-datepicker"; import "react-datepicker/dist/react-datepicker.css"; -import enGB from "date-fns/locale/en-GB"; -registerLocale("en-GB", enGB); - interface ResourceDetailFormProps { id: number; - categories: string[]; + categories?: string[]; tagList: string[]; + isLink: boolean; defaultTitle?: string; - setResourceDetails: (id: number, details: ResourceDetails) => void; + defaultURL?: string; + defaultTags?: string[]; + defaultVisibleAfter?: Date; + setResourceDetails: (details: ResourceDetails) => void; +} + +export interface ResourceDetails { + title: string; + category: string; + tags: string[]; + visibleAfter?: Date; + url: string; } interface Option { @@ -29,27 +37,59 @@ const ResourceDetailForm: React.FC<ResourceDetailFormProps> = ({ id, categories, tagList, + isLink, defaultTitle, + defaultURL, + defaultTags, + defaultVisibleAfter, setResourceDetails, }) => { const [showPicker, setShowPicker] = useState(false); - const [startDate, setStartDate] = useState(new Date()); + const [startDate, setStartDate] = useState(defaultVisibleAfter || new Date()); const [title, setTitle] = useState<string>(defaultTitle || ""); - const [category, setCategory] = useState(categories[0] || ""); - const [tags, setTags] = useState<string[]>([]); - const [visibleAfter, setVisibleAfter] = useState(""); + const [category, setCategory] = useState((categories && categories[0]) || ""); + const [tags, setTags] = useState<string[]>(defaultTags || []); + const [visibleAfter, setVisibleAfter] = useState<Date>(); + const [url, setURL] = useState(defaultURL || ""); useEffect(() => { - setResourceDetails(id, { + setResourceDetails({ title, category, tags, - visibleAfter + visibleAfter, + url }) - }, [id, title, category, tags, visibleAfter, setResourceDetails]) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [title, category, tags, visibleAfter, url]) + + const datepicker = ( + <DatePicker + selected={startDate} + onChange={(date: Date) => { + setStartDate(date); + setVisibleAfter(date); + }} + showTimeInput + timeFormat="HH:mm" + dateFormat="MMMM d, yyyy HH:mm 'UTC'" + /> + ); return (<> + {isLink && + <Form.Group style={{ paddingTop: "20px" }}> + <Form.Label>URL</Form.Label> + <Form.Control + type="text" + placeholder="Paste link here." + defaultValue={defaultURL} + onChange={event => setURL(event.target.value)} + /> + </Form.Group> + } + <Form.Group> <Form.Label>Title</Form.Label> <Form.Control @@ -60,6 +100,7 @@ const ResourceDetailForm: React.FC<ResourceDetailFormProps> = ({ /> </Form.Group> + { categories && <Form.Group> <Form.Label>Category</Form.Label> <CreatableSelect @@ -73,11 +114,19 @@ const ResourceDetailForm: React.FC<ResourceDetailFormProps> = ({ }))} onChange={selectedCategory => setCategory(selectedCategory ? (selectedCategory as Option).value : "")} /> + <Form.Text muted> + Category cannot be changed afterwards. + </Form.Text> </Form.Group> + } <Form.Group> <Form.Label>Tags</Form.Label> <CreatableSelect + defaultValue={defaultTags ? defaultTags.map(tag => ({ + value: tag, + label: tag, + })) : []} isClearable isMulti menuPortalTarget={document.body} @@ -91,6 +140,16 @@ const ResourceDetailForm: React.FC<ResourceDetailFormProps> = ({ </Form.Group> <Form.Group> + {defaultVisibleAfter ? + <Row> + <Col md="auto"> + <Form.Label>Visible after</Form.Label> + </Col> + <Col> + { datepicker } + </Col> + </Row> + : <Row> <Col md="auto"> <Form.Switch @@ -102,16 +161,13 @@ const ResourceDetailForm: React.FC<ResourceDetailFormProps> = ({ </Col> {showPicker && <Col> - <DatePicker - selected={startDate} - onChange={(date: Date) => setStartDate(date)} - onChangeRaw={event => setVisibleAfter(event.target.value)} - locale="en-GB" - showTimeInput - dateFormat="dd/MM/yyyy hh:mm" - /> - </Col>} - </Row> + { datepicker } + <Form.Text muted> + Course managers will still be able to view all "invisible" resources. + </Form.Text> + </Col> + } + </Row>} </Form.Group> </>); }; diff --git a/src/components/molecules/SelectionView/index.tsx b/src/components/molecules/SelectionView/index.tsx index 82550be76..e13c50b95 100644 --- a/src/components/molecules/SelectionView/index.tsx +++ b/src/components/molecules/SelectionView/index.tsx @@ -1,6 +1,7 @@ import React from "react"; import ResourceSectionHeader from "./components/SectionHeader"; import { faSquare, faCheckSquare } from "@fortawesome/free-regular-svg-icons"; +import { idBooleanMap } from "utils/types" export interface SelectionItem { title: string; @@ -11,8 +12,6 @@ export interface SelectionItem { thumbnail?: string; } -type idBooleanMap = { [key: number]: boolean }; - export interface SelectionProps { selectionItems: SelectionItem[]; state: MyState; diff --git a/src/components/organisms/EditModal/index.tsx b/src/components/organisms/EditModal/index.tsx new file mode 100644 index 000000000..95c1bb166 --- /dev/null +++ b/src/components/organisms/EditModal/index.tsx @@ -0,0 +1,98 @@ +import React, { useState } from "react"; +import Modal from "react-bootstrap/Modal"; +import Form from "react-bootstrap/Form"; +import Button from "react-bootstrap/Button"; +import ButtonGroup from "react-bootstrap/ButtonGroup"; + +import ResourceDetailForm, { ResourceDetails } from "components/molecules/ResourceDetailForm" +import { Resource } from "components/pages/ModuleResources/utils"; +import { staffRequest } from "utils/api" +import { api, methods } from "constants/routes" + +interface EditModalProps { + show: boolean; + onHide: any; + hideAndReload: () => void; + tags: string[]; + resource: Resource; +} + +const EditModal: React.FC<EditModalProps> = ({ + show, + onHide, + hideAndReload, + tags, + resource +}) => { + const [details, setDetails] = useState<ResourceDetails>(); + + const updateResourceDetails = (details: ResourceDetails) => { + setDetails(details); + }; + + const handleSubmit = async (event: any) => { + if (details) { + event.preventDefault(); + let payload: {[key: string]: any} = { + title: details.title, + tags: details.tags, + }; + if (resource.type === "link") { + payload.path = details.url; + } + if (details.visibleAfter) { + payload.visible_after = details.visibleAfter; + } + staffRequest(api.MATERIALS_RESOURCES_ID(resource.id), methods.PUT, hideAndReload, () => {}, payload); + } + } + + return ( + <Modal + style={{ zIndex: "10000" }} + size="lg" + show={show} + onHide={onHide} + centered + > + <Modal.Header closeButton> + <Modal.Title>Edit Resource</Modal.Title> + </Modal.Header> + + <Form onSubmit={handleSubmit}> + <Modal.Body> + <ResourceDetailForm + id={resource.id} + key={resource.id} + isLink={resource.type === "link"} + tagList={tags} + defaultTitle={resource.title} + defaultURL={resource.path} + defaultTags={resource.tags.filter(tag => tag !== "new")} + defaultVisibleAfter={resource.visible_after} + setResourceDetails={updateResourceDetails} + /> + </Modal.Body> + + <Modal.Footer> + <ButtonGroup className="btn-block"> + <Button + onClick={onHide} + variant="secondary" + > + Cancel + </Button> + <Button + type="submit" + variant="info" + > + Submit + </Button> + </ButtonGroup> + </Modal.Footer> + </Form> + </Modal> + ); +}; + +export default EditModal; \ No newline at end of file diff --git a/src/components/pages/ModuleResources/components/UploadModal/index.tsx b/src/components/organisms/UploadModal/index.tsx similarity index 75% rename from src/components/pages/ModuleResources/components/UploadModal/index.tsx rename to src/components/organisms/UploadModal/index.tsx index e6dfdb98e..75c71ba17 100644 --- a/src/components/pages/ModuleResources/components/UploadModal/index.tsx +++ b/src/components/organisms/UploadModal/index.tsx @@ -21,9 +21,9 @@ import { } from "@fortawesome/free-solid-svg-icons"; import styles from "./style.module.scss"; -import ResourceDetailForm from "../../../../molecules/ResourceDetailForm" -import { staffRequest } from "../../../../../utils/api" -import { api, methods } from "../../../../../constants/routes" +import ResourceDetailForm, { ResourceDetails } from "components/molecules/ResourceDetailForm" +import { staffRequest } from "utils/api" +import { api, methods } from "constants/routes" interface UploadModalProps { show: boolean; @@ -35,13 +35,6 @@ interface UploadModalProps { tags: string[]; } -export interface ResourceDetails { - title: string; - category: string; - tags: string[]; - visibleAfter: string; -} - const UploadModal: React.FC<UploadModalProps> = ({ show, onHide, @@ -52,7 +45,6 @@ const UploadModal: React.FC<UploadModalProps> = ({ tags, }) => { const [tab, setTab] = useState("file"); - const [url, setURL] = useState(""); const [rejectedFiles, setRejectedFiles] = useState<File[]>([]); const [resourceDetails, setResourceDetails] = useState<{[id: number] : ResourceDetails}>({}); const maxSize = 26214400; // 25mb, TODO: lift to constants @@ -75,10 +67,12 @@ const UploadModal: React.FC<UploadModalProps> = ({ setRejectedFiles(newFiles); } - const updateResourceDetails = (id: number, details: ResourceDetails) => { - resourceDetails[id] = details; - setResourceDetails({...resourceDetails}); - } + const updateResourceDetails = (id: number) => { + return (details: ResourceDetails) => { + resourceDetails[id] = details; + setResourceDetails({...resourceDetails}); + }; + }; const submitFileForResource = (file: File) => { let formData = new FormData() @@ -92,6 +86,23 @@ const UploadModal: React.FC<UploadModalProps> = ({ const handleSubmit = async (event: any) => { event.preventDefault(); + + const makePayload = (details: ResourceDetails) => { + let payload: {[key: string]: any} = { + year: year, + course: course, + type: tab, + title: details.title, + category: details.category, + tags: details.tags, + path: details.url, + }; + if (details.visibleAfter) { + payload.visible_after = details.visibleAfter; + } + return payload; + } + switch (tab) { case "file": { await Promise.all(acceptedFiles.map((file, index) => { @@ -99,20 +110,7 @@ const UploadModal: React.FC<UploadModalProps> = ({ // Empty promise i.e. do nothing return Promise.resolve(); } - - let details: ResourceDetails = resourceDetails[index]; - let payload: {[key: string]: any} = { - type: "file", - category: details.category, - course: course, - title: details.title, - year: year, - tags: details.tags, - path: "", - }; - if (details.visibleAfter !== "") { - payload.visible_after = details.visibleAfter; - } + let payload = makePayload(resourceDetails[index]); return staffRequest(api.MATERIALS_RESOURCES, methods.POST, submitFileForResource(file), () => {}, payload); })); @@ -120,20 +118,8 @@ const UploadModal: React.FC<UploadModalProps> = ({ break; } case "link": { - let details: ResourceDetails = resourceDetails[-1]; - let payload: {[key: string]: any} = { - type: "link", - category: details.category, - course: course, - title: details.title, - year: year, - tags: details.tags, - path: url, - }; - if (details.visibleAfter !== "") { - payload.visible_after = details.visibleAfter; - } - staffRequest(api.MATERIALS_RESOURCES, methods.POST, hideAndReload, () => {}, payload); + let payload = makePayload(resourceDetails[-1]); + await staffRequest(api.MATERIALS_RESOURCES, methods.POST, hideAndReload, () => {}, payload); break; } } @@ -203,8 +189,9 @@ const UploadModal: React.FC<UploadModalProps> = ({ key={index} categories={categories} tagList={tags} + isLink={false} defaultTitle={file.name} - setResourceDetails={updateResourceDetails} + setResourceDetails={updateResourceDetails(index)} /> </Card.Body> </Accordion.Collapse> @@ -214,20 +201,12 @@ const UploadModal: React.FC<UploadModalProps> = ({ </Tab> <Tab eventKey="link" title="Link"> - <Form.Group className={styles.tabFirstFormGroup}> - <Form.Label>URL</Form.Label> - <Form.Control - type="text" - placeholder="Paste link here." - onChange={e => setURL(e.target.value)} - /> - </Form.Group> - <ResourceDetailForm id={-1} categories={categories} tagList={tags} - setResourceDetails={updateResourceDetails} + isLink={true} + setResourceDetails={updateResourceDetails(-1)} /> </Tab> </Tabs> diff --git a/src/components/pages/ModuleResources/components/UploadModal/style.module.scss b/src/components/organisms/UploadModal/style.module.scss similarity index 90% rename from src/components/pages/ModuleResources/components/UploadModal/style.module.scss rename to src/components/organisms/UploadModal/style.module.scss index 138767799..5d32041d2 100644 --- a/src/components/pages/ModuleResources/components/UploadModal/style.module.scss +++ b/src/components/organisms/UploadModal/style.module.scss @@ -26,7 +26,3 @@ .clickable { cursor: pointer; } - -.tabFirstFormGroup { - padding-top: 20px; -} diff --git a/src/components/pages/ModuleDashboard/index.tsx b/src/components/pages/ModuleDashboard/index.tsx index 72769368c..e3d49f1e4 100644 --- a/src/components/pages/ModuleDashboard/index.tsx +++ b/src/components/pages/ModuleDashboard/index.tsx @@ -6,8 +6,8 @@ import classNames from "classnames"; import { faGlobe, faLink } from "@fortawesome/free-solid-svg-icons"; import PageButtonGroup from "components/molecules/PageButtonGroup"; -import { request } from "../../../utils/api"; -import { api, methods } from "../../../constants/routes"; +import { request } from "utils/api"; +import { api, methods } from "constants/routes"; import tutorImage1 from "assets/images/tutor-1.png"; import tutorImage2 from "assets/images/tutor-2.png"; diff --git a/src/components/pages/ModuleOverview/index.tsx b/src/components/pages/ModuleOverview/index.tsx index 572ab3a34..4aefffb80 100644 --- a/src/components/pages/ModuleOverview/index.tsx +++ b/src/components/pages/ModuleOverview/index.tsx @@ -1,8 +1,8 @@ import React from "react"; import styles from "./style.module.scss"; -import { request } from "../../../utils/api"; -import { api, methods } from "../../../constants/routes"; +import { request } from "utils/api"; +import { api, methods } from "constants/routes"; import Accordion from "react-bootstrap/Accordion"; import Card from "react-bootstrap/Card"; diff --git a/src/components/pages/ModuleResources/components/StaffView.tsx b/src/components/pages/ModuleResources/components/StaffView.tsx index 937041e55..b3a2c5e65 100644 --- a/src/components/pages/ModuleResources/components/StaffView.tsx +++ b/src/components/pages/ModuleResources/components/StaffView.tsx @@ -1,18 +1,21 @@ import React, { useState } from "react"; import Button from "react-bootstrap/Button"; +import ButtonGroup from "react-bootstrap/ButtonGroup"; import Col from "react-bootstrap/esm/Col"; import Row from "react-bootstrap/esm/Row"; import { faEdit } from "@fortawesome/free-regular-svg-icons"; import { faDownload, faTrash, faUpload } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import UploadModal from "./UploadModal" -import AlertModal from "../../../atoms/AlertModal" +import AlertModal from "components/atoms/AlertModal" +import IconButton from "components/atoms/IconButton" +import EditModal from "components/organisms/EditModal" +import UploadModal from "components/organisms/UploadModal" import { Resource, Folder } from "../utils"; import CategoryList from "components/molecules/CategoryList"; import CategoryHeader from "components/molecules/CategoryHeader"; +import { idBooleanMap } from "utils/types" import { staffRequest, download } from "utils/api" import { api, methods } from "constants/routes" @@ -21,9 +24,10 @@ export interface StaffViewProps { course: string; folders: Folder[]; reload: () => void; - resources: Resource[]; - searchText: string; - includeInSearchResult: (item: Resource, searchText: string) => boolean; + resources: Resource[]; + searchText: string; + includeInSearchResult: (item: Resource, searchText: string) => boolean; + onRowClick: (id: number) => void; } const StaffView: React.FC<StaffViewProps> = ({ @@ -31,12 +35,21 @@ const StaffView: React.FC<StaffViewProps> = ({ course, folders, reload, - resources, - searchText, - includeInSearchResult + resources, + searchText, + includeInSearchResult, + onRowClick }) => { const [modal, setModal] = useState(""); const [resourceID, setResourceID] = useState(-1); + const [editResource, setEditResource] = useState<Resource>(resources[0]); + const allClosed = () => resources.reduce((map, resource) => { + return { + ...map, + [resource.id]: false + }; + }, {}); + const [showMenus, setShowMenus] = useState<idBooleanMap>(allClosed()); const closeModal = () => setModal(""); let filesContent: Resource[] = resources; @@ -49,8 +62,8 @@ const StaffView: React.FC<StaffViewProps> = ({ // Get existing tags for selection upon new resource creation let tags: string[] = resources.flatMap(resource => resource.tags) tags = Array.from(new Set(tags)) - // "new" tag is determined by backend, remove it from selection pool - tags = tags.filter(tag => tag !== "new" && tag !== ""); + // Remove reserved tag `new` from selection pool, then arrange alphabetically + tags = tags.filter(tag => tag !== "new").sort(); const hiddenFileInput = React.createRef<HTMLInputElement>() const handleReuploadClick = (id: number) => { @@ -59,28 +72,48 @@ const StaffView: React.FC<StaffViewProps> = ({ hiddenFileInput.current.click(); } }; - const reuploadFile = (event: any) => { + const reuploadFile = async (event: any) => { const fileUploaded = event.target.files[0]; let formData = new FormData(); formData.append("file", fileUploaded); - staffRequest(api.MATERIALS_RESOURCES_FILE(resourceID), methods.PUT, + await staffRequest(api.MATERIALS_RESOURCES_FILE(resourceID), methods.PUT, () => {}, () => {}, formData, true - ) + ); + reload(); }; - const fileDropdown = (id: number, filename: string) => { - return ( - <> - <FontAwesomeIcon onClick={() => {}} icon={faEdit} /> - <FontAwesomeIcon onClick={() => staffRequest(api.MATERIALS_RESOURCES_ID(id), methods.DELETE, () => {}, () => {})} icon={faTrash}/> - {filename && <> - <FontAwesomeIcon onClick={() => download(api.MATERIALS_RESOURCES_FILE(id), methods.GET, filename)} icon={faDownload} /> - <FontAwesomeIcon onClick={() => handleReuploadClick(id)} icon={faUpload}/> - </>} - </> - ); - } + const resourceActions = (id: number, filename: string) => ( + <ButtonGroup> + <IconButton + tooltip="Edit" + onClick={() => { + setEditResource(resources.find(res => res.id === id) || resources[0]); + setModal("edit"); + }} + icon={faEdit} + /> + <IconButton + tooltip="Delete" + onClick={async () => { + await staffRequest(api.MATERIALS_RESOURCES_ID(id), methods.DELETE, () => {}, () => {}); + reload(); + }} + icon={faTrash} + /> + {filename && <> + <IconButton + tooltip="Download" + onClick={() => download(api.MATERIALS_RESOURCES_FILE(id), methods.GET, filename)} + icon={faDownload} /> + <IconButton + tooltip="Reupload" + onClick={() => handleReuploadClick(id)} + icon={faUpload} + /> + </>} + </ButtonGroup> + ) return ( <> @@ -100,7 +133,39 @@ const StaffView: React.FC<StaffViewProps> = ({ year={year} course={course} categories={folders.map(folder => folder.title).sort() || ["Lecture notes"]} - tags={tags.sort()} + tags={tags} + /> + + <EditModal + show={modal === "edit"} + onHide={closeModal} + hideAndReload={() => { + closeModal(); + reload(); + }} + tags={tags} + resource={editResource} + /> + + <AlertModal + show={modal === "alert"} + onHide={closeModal} + title="Remove All Warning" + message="This will irreversibly delete all course resources and associated files." + confirmLabel="Delete All Resources" + confirmOnClick={() => staffRequest( + api.MATERIALS_RESOURCES, + methods.DELETE, + () => { + closeModal(); + reload(); + }, + () => {}, + { + year: year, + course: course + } + )} /> {folders.map(({ title, id }) => { @@ -110,8 +175,16 @@ const StaffView: React.FC<StaffViewProps> = ({ /> <CategoryList categoryItems={filesContent.filter(res => res.folder === title)} - fileDropdown={fileDropdown} - handleRowClick={(id) => {}} + resourceActions={resourceActions} + showMenus={showMenus} + setShowMenus={(id) => { + return (show: boolean) => { + let newShowMenus: idBooleanMap = allClosed(); + newShowMenus[id] = show; + setShowMenus(newShowMenus); + }; + }} + handleRowClick={onRowClick} handleIconClick={(id) => {}} handleMouseOver={(id) => {}} handleMouseOut={(id) => {}} @@ -136,27 +209,6 @@ const StaffView: React.FC<StaffViewProps> = ({ </Button> </Col> </Row> - - <AlertModal - show={modal === "alert"} - onHide={closeModal} - title="Remove All Warning" - message="This will irreversibly delete all course resources and associated files." - confirmLabel="Delete All Resources" - confirmOnClick={() => staffRequest( - api.MATERIALS_RESOURCES, - methods.DELETE, - () => { - closeModal(); - reload(); - }, - () => {}, - { - year: year, - course: course - } - )} - /> </> ); }; diff --git a/src/components/pages/ModuleResources/index.tsx b/src/components/pages/ModuleResources/index.tsx index 690e9dfbd..b96584b5f 100644 --- a/src/components/pages/ModuleResources/index.tsx +++ b/src/components/pages/ModuleResources/index.tsx @@ -1,7 +1,7 @@ import React from "react"; -import { request, download } from "../../../utils/api"; -import { api, methods } from "../../../constants/routes"; +import { request, download } from "utils/api"; +import { api, methods } from "constants/routes"; import SearchBox from "components/molecules/SearchBox"; import QuickAccessView from "./components/QuickAccessView"; import CurrentDirectoryView from "./components/CurrentDirectoryView"; @@ -26,7 +26,6 @@ export interface ResourceState { isLoaded: Boolean; resources: Resource[]; searchText: string; - isStaff: boolean; } class ModuleResources extends React.Component<ResourcesProps, ResourceState> { @@ -41,8 +40,6 @@ class ModuleResources extends React.Component<ResourcesProps, ResourceState> { isLoaded: false, resources: [], searchText: "", - // change this value to switch between staff and student for testing - isStaff: false, }; } @@ -72,13 +69,18 @@ class ModuleResources extends React.Component<ResourcesProps, ResourceState> { thumbnail: thumbnail, id: resource.id, path: resource.path, + index: resource.index, + visible_after: new Date(resource.visible_after) } as Resource); } + + resourceArr = resourceArr.sort((a, b) => a.index > b.index ? 1 : -1); this.setState({ resources: resourceArr, isLoaded: true }); }); }; - const onFailure = (error: { text: () => Promise<any> }) => { - error.text().then((errorText) => { + const onFailure = (error: any) => { + console.log(error); + error.text().then((errorText: any) => { this.setState({ error: errorText, isLoaded: true }); }); }; @@ -171,8 +173,35 @@ class ModuleResources extends React.Component<ResourcesProps, ResourceState> { let scope = this.props.scope || ""; const view = () => { - if (this.state.isStaff) { - return ( + switch (this.props.view) { + case "card": return ( + <> + <FoldersView + folders={folders(this.state.resources)} + scope={scope} + searchText={this.state.searchText} + handleFolderDownload={(ids) => this.handleFolderDownload(ids)} + /> + + <CurrentDirectoryView + resources={this.state.resources} + scope={scope} + searchText={this.state.searchText} + onDownloadClick={(ids) => this.handleFileDownload(ids)} + onItemClick={(id) => this.handleResourceClick(id)} + includeInSearchResult={this.includeInSearchResult} + /> + + <QuickAccessView + resources={this.state.resources} + scope={scope} + searchText={this.state.searchText} + onDownloadClick={(ids) => this.handleFileDownload(ids)} + onItemClick={(id) => this.handleResourceClick(id)} + /> + </> + ); + case "staff": return ( <StaffView year={this.props.year} course={this.moduleCode} @@ -181,38 +210,9 @@ class ModuleResources extends React.Component<ResourcesProps, ResourceState> { resources={this.state.resources} searchText={this.state.searchText} includeInSearchResult={this.includeInSearchResult} + onRowClick={(id) => this.handleResourceClick(id)} /> ); - } - switch (this.props.view) { - case "card": - return ( - <> - <FoldersView - folders={folders(this.state.resources)} - scope={scope} - searchText={this.state.searchText} - handleFolderDownload={(ids) => this.handleFolderDownload(ids)} - /> - - <CurrentDirectoryView - resources={this.state.resources} - scope={scope} - searchText={this.state.searchText} - onDownloadClick={(ids) => this.handleFileDownload(ids)} - onItemClick={(id) => this.handleResourceClick(id)} - includeInSearchResult={this.includeInSearchResult} - /> - - <QuickAccessView - resources={this.state.resources} - scope={scope} - searchText={this.state.searchText} - onDownloadClick={(ids) => this.handleFileDownload(ids)} - onItemClick={(id) => this.handleResourceClick(id)} - /> - </> - ); default: return ( <ListView diff --git a/src/components/pages/ModuleResources/utils.ts b/src/components/pages/ModuleResources/utils.ts index b650b0ede..c14db7745 100644 --- a/src/components/pages/ModuleResources/utils.ts +++ b/src/components/pages/ModuleResources/utils.ts @@ -5,8 +5,8 @@ import { faLink, IconDefinition, } from "@fortawesome/free-solid-svg-icons"; -import { request } from "../../../utils/api"; -import { api, methods } from "../../../constants/routes"; +import { request } from "utils/api"; +import { api, methods } from "constants/routes"; export interface Folder { title: string; @@ -19,7 +19,9 @@ export interface Resource { tags: string[]; folder: string; id: number; - path?: string; + index: number; + path: string; + visible_after: Date; thumbnail?: string; } diff --git a/src/components/pages/StandardView/index.tsx b/src/components/pages/StandardView/index.tsx index 807ab1be0..6545f28c1 100644 --- a/src/components/pages/StandardView/index.tsx +++ b/src/components/pages/StandardView/index.tsx @@ -102,6 +102,18 @@ const StandardView: React.FC<StandardViewProps> = ({ )} /> + <Route + path="/modules/:id/resources-staff" + render={(props) => ( + <ModuleResources + year="2021" + moduleID={props.match.params.id} + scope={props.match.params.scope} + view="staff" + /> + )} + /> + <Route path="/modules/:id/feedback" component={ModuleFeedback} /> <Route path="/timeline"> diff --git a/src/utils/types.tsx b/src/utils/types.tsx new file mode 100644 index 000000000..567e570a1 --- /dev/null +++ b/src/utils/types.tsx @@ -0,0 +1 @@ +export type idBooleanMap = { [key: number]: boolean }; \ No newline at end of file -- GitLab