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