diff --git a/README.md b/README.md index b87cb004..e95163fe 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,9 @@ This project was bootstrapped with [Create React App](https://github.com/faceboo In the project directory, you can run: +### `npm install` +Installs the dependencies required + ### `npm start` Runs the app in the development mode.\ diff --git a/package-lock.json b/package-lock.json index 3ef4561e..aa257d7f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,8 +24,8 @@ "@types/react-router-dom": "^5.3.3", "axios": "^1.4.0", "bootstrap": "^5.3.3", - "chart.js": "^3.7.0", - "formik": "^2.2.9", + "chart.js": "^4.1.1", + "formik": "^2.4.6", "jquery": "^3.7.1", "jwt-decode": "^3.1.2", "react": "^18.2.0", @@ -38,7 +38,8 @@ "react-redux": "^8.0.5", "react-router-dom": "^6.11.1", "react-scripts": "^5.0.1", - "recharts": "^2.12.3", + "react-select": "^5.8.3", + "recharts": "^2.0.0", "redux-persist": "^6.0.0", "sass": "^1.62.1", "save": "^2.9.0", @@ -2393,6 +2394,147 @@ "postcss-selector-parser": "^6.0.10" } }, + "node_modules/@emotion/babel-plugin": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", + "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.3.3", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/babel-plugin/node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "license": "MIT" + }, + "node_modules/@emotion/babel-plugin/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@emotion/babel-plugin/node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@emotion/cache": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.13.5.tgz", + "integrity": "sha512-Z3xbtJ+UcK76eWkagZ1onvn/wAVb1GOMuR15s30Fm2wrMgC7jzpnO2JZXr4eujTTqoQFUrZIw/rT0c6Zzjca1g==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", + "license": "MIT" + }, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", + "license": "MIT" + }, + "node_modules/@emotion/react": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.13.5.tgz", + "integrity": "sha512-6zeCUxUH+EPF1s+YF/2hPVODeV/7V07YU5x+2tfuRL8MdW6rv5vb2+CBEGTGwBdux0OIERcOS+RzxeK80k2DsQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.13.5", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.1.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "hoist-non-react-statics": "^3.3.1" + }, + "peerDependencies": { + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/serialize": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", + "license": "MIT", + "dependencies": { + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", + "csstype": "^3.0.2" + } + }, + "node_modules/@emotion/sheet": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", + "license": "MIT" + }, + "node_modules/@emotion/unitless": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", + "license": "MIT" + }, + "node_modules/@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.1.0.tgz", + "integrity": "sha512-+wBOcIV5snwGgI2ya3u99D7/FJquOIniQT1IKyDsBmEgwvpxMNeS65Oib7OnE2d2aY+3BU4OiH+0Wchf8yk3Hw==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emotion/utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", + "license": "MIT" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", + "license": "MIT" + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", @@ -2486,6 +2628,31 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.6.8", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.8.tgz", + "integrity": "sha512-7XJ9cPU+yI2QeLS+FCSlqNFZJq8arvswefkZrYI1yQBbftw6FyrZOxYSh+9S7z7TpeWlRt9zJ5IhM1WIL334jA==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.8" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.6.12", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.12.tgz", + "integrity": "sha512-NP83c0HjokcGVEMeoStg317VD9W7eDlGK7457dMBANbKA6GJZdc7rjujdgqzTaz93jkGgc5P/jeWbaCHnMNc+w==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.6.0", + "@floating-ui/utils": "^0.2.8" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.8.tgz", + "integrity": "sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==", + "license": "MIT" + }, "node_modules/@fortawesome/fontawesome-common-types": { "version": "6.5.2", "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.5.2.tgz", @@ -3045,6 +3212,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" + }, "node_modules/@leichtgewicht/ip-codec": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", @@ -5760,9 +5933,16 @@ } }, "node_modules/chart.js": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-3.7.0.tgz", - "integrity": "sha512-31gVuqqKp3lDIFmzpKIrBeum4OpZsQjSIAqlOpgjosHDJZlULtvwLEZKtEhIAZc7JMPaHlYMys40Qy9Mf+1AAg==" + "version": "4.4.6", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.6.tgz", + "integrity": "sha512-8Y406zevUPbbIBA/HRk33khEmQPk5+cxeflWE/2rx1NJsjVWMPw/9mSP9rxHP5eqi6LNoPBVMfZHxbwLSgldYA==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } }, "node_modules/check-types": { "version": "11.2.3", @@ -8363,6 +8543,12 @@ "url": "https://github.com/avajs/find-cache-dir?sponsor=1" } }, + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", + "license": "MIT" + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -8563,15 +8749,16 @@ } }, "node_modules/formik": { - "version": "2.4.5", - "resolved": "https://registry.npmjs.org/formik/-/formik-2.4.5.tgz", - "integrity": "sha512-Gxlht0TD3vVdzMDHwkiNZqJ7Mvg77xQNfmBRrNtvzcHZs72TJppSTDKHpImCMJZwcWPBJ8jSQQ95GJzXFf1nAQ==", + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/formik/-/formik-2.4.6.tgz", + "integrity": "sha512-A+2EI7U7aG296q2TLGvNapDNTZp1khVt5Vk0Q/fyfSROss0V/V6+txt2aJnwEos44IxTCW/LYAi/zgWzlevj+g==", "funding": [ { "type": "individual", "url": "https://opencollective.com/formik" } ], + "license": "Apache-2.0", "dependencies": { "@types/hoist-non-react-statics": "^3.3.1", "deepmerge": "^2.1.1", @@ -11372,6 +11559,12 @@ "node": ">= 4.0.0" } }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, "node_modules/merge-descriptors": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", @@ -14168,6 +14361,27 @@ } } }, + "node_modules/react-select": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/react-select/-/react-select-5.8.3.tgz", + "integrity": "sha512-lVswnIq8/iTj1db7XCG74M/3fbGB6ZaluCzvwPGT5ZOjCdL/k0CLWhEK0vCBLuU5bHTEf6Gj8jtSvi+3v+tO1w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.0", + "@emotion/cache": "^11.4.0", + "@emotion/react": "^11.8.1", + "@floating-ui/dom": "^1.0.1", + "@types/react-transition-group": "^4.4.0", + "memoize-one": "^6.0.0", + "prop-types": "^15.6.0", + "react-transition-group": "^4.3.0", + "use-isomorphic-layout-effect": "^1.1.2" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/react-smooth": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.1.tgz", @@ -15628,6 +15842,12 @@ "postcss": "^8.2.15" } }, + "node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", + "license": "MIT" + }, "node_modules/sucrase": { "version": "3.35.0", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", @@ -16529,6 +16749,20 @@ "requires-port": "^1.0.0" } }, + "node_modules/use-isomorphic-layout-effect": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz", + "integrity": "sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/use-sync-external-store": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", @@ -16603,6 +16837,28 @@ "node": ">= 0.8" } }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/void-elements": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", diff --git a/package.json b/package.json index a1ed9d64..668f6cb0 100644 --- a/package.json +++ b/package.json @@ -20,8 +20,7 @@ "axios": "^1.4.0", "bootstrap": "^5.3.3", "chart.js": "^4.1.1", - "recharts": "^2.0.0", - "formik": "^2.2.9", + "formik": "^2.4.6", "jquery": "^3.7.1", "jwt-decode": "^3.1.2", "react": "^18.2.0", @@ -34,6 +33,8 @@ "react-redux": "^8.0.5", "react-router-dom": "^6.11.1", "react-scripts": "^5.0.1", + "react-select": "^5.8.3", + "recharts": "^2.0.0", "redux-persist": "^6.0.0", "sass": "^1.62.1", "save": "^2.9.0", diff --git a/src/components/ColumnButton.test.tsx b/src/components/ColumnButton.test.tsx new file mode 100644 index 00000000..20e22b42 --- /dev/null +++ b/src/components/ColumnButton.test.tsx @@ -0,0 +1,57 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import ColumnButton from "./ColumnButton"; + +// Mock React-Bootstrap components +jest.mock("react-bootstrap", () => ({ + Button: ({ children, ...props }: any) => , + Tooltip: ({ id, children }: any) =>
{children}
, + OverlayTrigger: ({ children, overlay }: any) => ( +
+ {overlay} + {children} +
+ ), +})); + +describe("ColumnButton", () => { + const mockOnClick = jest.fn(); + + const baseProps = { + id: "test-button", + label: "Test Button", + tooltip: "This is a test tooltip", + variant: "primary", + size: "sm" as const, + className: "custom-class", + onClick: mockOnClick, + icon: Icon, + }; + + test("should call onClick when the button is clicked", async () => { + render(); + const button = screen.getByRole("button", { name: "Test Button" }); + + userEvent.click(button); + expect(mockOnClick).toHaveBeenCalledTimes(1); + }); + + test("should render a tooltip when tooltip is provided", () => { + render(); + const tooltip = screen.getByText("This is a test tooltip"); + + // Vanilla assertion to check if tooltip is rendered + expect(tooltip).not.toBeNull(); + const button = screen.getByRole("button", { name: "Test Button" }); + expect(button).not.toBeNull(); // Ensure the button is still present + }); + + test("should not render a tooltip when tooltip is not provided", () => { + render(); + const tooltip = screen.queryByText("This is a test tooltip"); + + // Vanilla assertion to check if tooltip is not rendered + expect(tooltip).toBeNull(); + }); +}); diff --git a/src/components/ColumnButton.tsx b/src/components/ColumnButton.tsx new file mode 100644 index 00000000..25eea86f --- /dev/null +++ b/src/components/ColumnButton.tsx @@ -0,0 +1,57 @@ +import React from "react"; +import { Button, OverlayTrigger, Tooltip } from "react-bootstrap"; + +/** + * @author Rutvik Kulkarni on Nov, 2024 + */ + +interface ColumnButtonProps { + id: string; + label?: string; + tooltip?: string; + variant: string; + size?: "sm" | "lg"; // Matches React-Bootstrap Button prop + className?: string; + onClick: () => void; + icon: React.ReactNode; + } + + const ColumnButton: React.FC = (props) => { + const { + id, + label, + tooltip, + variant, + size, + className, + onClick, + icon, + } = props; + + const displayButton = ( + + ); + + if (tooltip) { + return ( + {tooltip}} + > + {displayButton} + + ); + } + + return displayButton; + }; + + export default ColumnButton; \ No newline at end of file diff --git a/src/pages/Courses/Course.tsx b/src/pages/Courses/Course.tsx index d1e4db04..53ef2ccd 100644 --- a/src/pages/Courses/Course.tsx +++ b/src/pages/Courses/Course.tsx @@ -27,6 +27,7 @@ const Courses = () => { (state: RootState) => state.authentication, (prev, next) => prev.isAuthenticated === next.isAuthenticated ); + const currUserRole = auth.user.role.valueOf(); const navigate = useNavigate(); const location = useLocation(); const dispatch = useDispatch(); @@ -96,7 +97,7 @@ const Courses = () => { ); const tableColumns = useMemo( - () => COURSE_COLUMNS(onEditHandle, onDeleteHandle, onTAHandle, onCopyHandle), + () => COURSE_COLUMNS(onEditHandle, onDeleteHandle, onTAHandle, onCopyHandle, currUserRole), [onDeleteHandle, onEditHandle, onTAHandle, onCopyHandle] ); diff --git a/src/pages/Courses/CourseColumns.test.tsx b/src/pages/Courses/CourseColumns.test.tsx new file mode 100644 index 00000000..fbe52543 --- /dev/null +++ b/src/pages/Courses/CourseColumns.test.tsx @@ -0,0 +1,104 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Row } from "@tanstack/react-table"; +import { courseColumns } from "./CourseColumns"; + +// Mock the ColumnButton component +jest.mock("../../components/ColumnButton", () => ({ id, ...props }: any) => ( + +)); + +describe("courseColumns", () => { + const mockHandleEdit = jest.fn(); + const mockHandleDelete = jest.fn(); + const mockHandleTA = jest.fn(); + const mockHandleCopy = jest.fn(); + const mockRow: Partial> = { + original: { id: "123", name: "Test Course", institution: { name: "Test Institution" } }, + }; + + test("should define all required columns", () => { + const columns = courseColumns(mockHandleEdit, mockHandleDelete, mockHandleTA, mockHandleCopy, "Super Administrator"); + expect(columns).toHaveLength(5); + + // Check each column's header + expect(columns[0].header).toBe("Name"); + expect(columns[1].header).toBe("Institution"); + expect(columns[2].header).toBe("Creation Date"); + expect(columns[3].header).toBe("Updated Date"); + expect(columns[4].header).toBe("Actions"); + }); + + test("should call handleEdit when edit button is clicked", async () => { + const actionsColumn = courseColumns( + mockHandleEdit, + mockHandleDelete, + mockHandleTA, + mockHandleCopy, + "Super Administrator" + ).find((col) => col.id === "actions"); + const CellComponent = actionsColumn?.cell as React.FC<{ row: Row }>; + + render(} />); + const editButton = screen.getByTestId("edit"); + + userEvent.click(editButton); + expect(mockHandleEdit).toHaveBeenCalledTimes(1); + expect(mockHandleEdit).toHaveBeenCalledWith(mockRow); + }); + + test("should call handleDelete when delete button is clicked", async () => { + const actionsColumn = courseColumns( + mockHandleEdit, + mockHandleDelete, + mockHandleTA, + mockHandleCopy, + "Super Administrator" + ).find((col) => col.id === "actions"); + const CellComponent = actionsColumn?.cell as React.FC<{ row: Row }>; + + render(} />); + const deleteButton = screen.getByTestId("delete"); + + userEvent.click(deleteButton); + expect(mockHandleDelete).toHaveBeenCalledTimes(1); + expect(mockHandleDelete).toHaveBeenCalledWith(mockRow); + }); + + test("should call handleTA when assign TA button is clicked", async () => { + const actionsColumn = courseColumns( + mockHandleEdit, + mockHandleDelete, + mockHandleTA, + mockHandleCopy, + "Super Administrator" + ).find((col) => col.id === "actions"); + const CellComponent = actionsColumn?.cell as React.FC<{ row: Row }>; + + render(} />); + const assignTAButton = screen.getByTestId("assign-ta"); + + userEvent.click(assignTAButton); + expect(mockHandleTA).toHaveBeenCalledTimes(1); + expect(mockHandleTA).toHaveBeenCalledWith(mockRow); + }); + + test("should call handleCopy when copy button is clicked", async () => { + const actionsColumn = courseColumns( + mockHandleEdit, + mockHandleDelete, + mockHandleTA, + mockHandleCopy, + "Super Administrator" + ).find((col) => col.id === "actions"); + const CellComponent = actionsColumn?.cell as React.FC<{ row: Row }>; + + render(} />); + const copyButton = screen.getByTestId("copy"); + + userEvent.click(copyButton); + expect(mockHandleCopy).toHaveBeenCalledTimes(1); + expect(mockHandleCopy).toHaveBeenCalledWith(mockRow); + }); +}); diff --git a/src/pages/Courses/CourseColumns.tsx b/src/pages/Courses/CourseColumns.tsx index 25002d6f..87ff85b9 100644 --- a/src/pages/Courses/CourseColumns.tsx +++ b/src/pages/Courses/CourseColumns.tsx @@ -2,80 +2,118 @@ import { createColumnHelper, Row } from "@tanstack/react-table"; import { Button } from "react-bootstrap"; import { BsPencilFill, BsPersonXFill } from "react-icons/bs"; import { MdContentCopy, MdDelete } from "react-icons/md"; -import { ICourseResponse as ICourse } from "../../utils/interfaces"; +import { ICourseResponse as ICourse, ROLE } from "../../utils/interfaces"; +import ColumnButton from "../../components/ColumnButton"; /** - * @author Atharva Thorve, on December, 2023 - * @author Mrityunjay Joshi on December, 2023 + * @author Anurag Gorkar, on December, 2024 + * @author Makarand Pundalik, on December, 2024 + * @author Rutvik Kulkarni, on December, 2024 */ +/** + * Determines if the current user has the authority to add a TA to a course. + * Only users with roles SUPER_ADMIN, ADMIN, or INSTRUCTOR can assign TAs. + * @param {String} currUserRole - The role of the current user + * @returns {boolean} - True if the user has TA assignment authority, otherwise false + */ +const hasAddTAAuthority = (currUserRole: String): boolean => { + return ( + currUserRole === ROLE.SUPER_ADMIN.valueOf() || + currUserRole === ROLE.ADMIN.valueOf() || + currUserRole === ROLE.INSTRUCTOR.valueOf() + ); +}; + // Course Columns Configuration: Defines the columns for the courses table type Fn = (row: Row) => void; const columnHelper = createColumnHelper(); -export const courseColumns = (handleEdit: Fn, handleDelete: Fn, handleTA: Fn, handleCopy: Fn) => [ - // Column for the course name - columnHelper.accessor("name", { - id: "name", - header: "Name", - enableSorting: true, - enableColumnFilter: true, - enableGlobalFilter: false, - }), - // Column for the institution name - columnHelper.accessor("institution.name", { - id: "institution", - header: "Institution", - enableSorting: true, - enableMultiSort: true, - enableGlobalFilter: false, - }), +export const courseColumns = ( + handleEdit: Fn, + handleDelete: Fn, + handleTA: Fn, + handleCopy: Fn, + currUserRole: String +) => [ + // Column for the course name + columnHelper.accessor("name", { + id: "name", + header: "Name", + enableSorting: true, + enableColumnFilter: true, + enableGlobalFilter: false, + }), + + // Column for the institution name + columnHelper.accessor("institution.name", { + id: "institution", + header: "Institution", + enableSorting: true, + enableMultiSort: true, + enableGlobalFilter: false, + }), - // Column for the creation date - columnHelper.accessor("created_at", { - header: "Creation Date", - enableSorting: true, - enableColumnFilter: false, - enableGlobalFilter: false, - }), + // Column for the creation date + columnHelper.accessor("created_at", { + header: "Creation Date", + enableSorting: true, + enableColumnFilter: false, + enableGlobalFilter: false, + }), - // Column for the last updated date - columnHelper.accessor("updated_at", { - header: "Updated Date", - enableSorting: true, - enableColumnFilter: false, - enableGlobalFilter: false, - }), + // Column for the last updated date + columnHelper.accessor("updated_at", { + header: "Updated Date", + enableSorting: true, + enableColumnFilter: false, + enableGlobalFilter: false, + }), - // Actions column with edit, delete, TA, and copy buttons - columnHelper.display({ - id: "actions", - header: "Actions", - cell: ({ row }) => ( - <> - - - - - - ), - }), -]; + // Actions column with edit, delete, TA, and copy buttons + columnHelper.display({ + id: "actions", + header: "Actions", + cell: ({ row }) => ( + <> + handleEdit(row)} + tooltip="Edit this course" + icon={} + /> + handleDelete(row)} + tooltip="Delete this course" + icon={} + /> + {hasAddTAAuthority(currUserRole) && ( // Use the helper function to determine TA authority + handleTA(row)} + tooltip="Assign a TA to this course" + icon={} + /> + )} + handleCopy(row)} + tooltip="Copy course details" + icon={} + /> + + ), + }), + ]; \ No newline at end of file diff --git a/src/pages/TA/TA.tsx b/src/pages/TA/TA.tsx index 87a1b334..f15ffcb5 100644 --- a/src/pages/TA/TA.tsx +++ b/src/pages/TA/TA.tsx @@ -12,6 +12,7 @@ import { alertActions } from "store/slices/alertSlice"; import { RootState } from "../../store/store"; import { ITAResponse, ROLE } from "../../utils/interfaces"; import { TAColumns as TA_COLUMNS } from "./TAColumns"; +import ColumnButton from "../../components/ColumnButton"; import DeleteTA from "./TADelete"; /** @@ -85,26 +86,37 @@ const TAs = () => {
- - + + navigate("new")} + tooltip="Add TA to this course" + icon={} + /> - {showDeleteConfirmation.visible && ( - - )} - - - + {tableData.length === 0 ? ( + + +

No TAs are assigned for this course.

+ + + ) : ( + +
+ + )} diff --git a/src/pages/TA/TAColumns.test.tsx b/src/pages/TA/TAColumns.test.tsx new file mode 100644 index 00000000..d1e136f2 --- /dev/null +++ b/src/pages/TA/TAColumns.test.tsx @@ -0,0 +1,49 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Row } from "@tanstack/react-table"; +import { TAColumns } from "./TAColumns"; + +// Mock the ColumnButton component +jest.mock("../../components/ColumnButton", () => ({ id, ...props }: any) => ( + +)); + +describe("TAColumns", () => { + const mockHandleDelete = jest.fn(); + const mockRow: Partial> = { original: { id: "123", name: "Test TA" } }; + + test("should define all required columns", () => { + const columns = TAColumns(mockHandleDelete); + expect(columns).toHaveLength(5); + + // Check each column's header + expect(columns[0].header).toBe("Id"); + expect(columns[1].header).toBe("TA Name"); + expect(columns[2].header).toBe("Full Name"); + expect(columns[3].header).toBe("Email"); + expect(columns[4].header).toBe("Actions"); + }); + + test("should correctly render the actions column", () => { + const actionsColumn = TAColumns(mockHandleDelete).find((col) => col.id === "actions"); + expect(actionsColumn).toBeDefined(); + const CellComponent = actionsColumn?.cell as React.FC<{ row: Row }>; + + render(} />); + const deleteButton = screen.getByTestId("delete-ta"); + expect(deleteButton).toBeTruthy(); + }); + + test("should call handleDelete when delete button is clicked", async () => { + const actionsColumn = TAColumns(mockHandleDelete).find((col) => col.id === "actions"); + const CellComponent = actionsColumn?.cell as React.FC<{ row: Row }>; + + render(} />); + const deleteButton = screen.getByTestId("delete-ta"); + + await userEvent.click(deleteButton); + expect(mockHandleDelete).toHaveBeenCalledTimes(1); + expect(mockHandleDelete).toHaveBeenCalledWith(mockRow); + }); +}); diff --git a/src/pages/TA/TAColumns.tsx b/src/pages/TA/TAColumns.tsx index c4545b5f..03b7b88f 100644 --- a/src/pages/TA/TAColumns.tsx +++ b/src/pages/TA/TAColumns.tsx @@ -3,6 +3,7 @@ import { createColumnHelper, Row } from "@tanstack/react-table"; import { Button } from "react-bootstrap"; import { BsPersonXFill } from "react-icons/bs"; import { ITAResponse as ITA } from "../../utils/interfaces"; +import ColumnButton from "../../components/ColumnButton"; /** * @author Atharva Thorve, on December, 2023 @@ -38,14 +39,15 @@ export const TAColumns = (handleDelete: Fn) => [ header: "Actions", cell: ({ row }) => ( <> - + tooltip="Delete TA" + icon={} + /> ), }), diff --git a/src/pages/TA/TAEditor.tsx b/src/pages/TA/TAEditor.tsx index 6d742c05..20ff8752 100644 --- a/src/pages/TA/TAEditor.tsx +++ b/src/pages/TA/TAEditor.tsx @@ -1,8 +1,7 @@ -// Importing necessary interfaces and modules -import FormSelect from "components/Form/FormSelect"; +import React, { useEffect, useState } from "react"; +import Select from 'react-select'; import { Form, Formik, FormikHelpers } from "formik"; import useAPI from "hooks/useAPI"; -import React, { useEffect } from "react"; import { Button, InputGroup, Modal } from "react-bootstrap"; import { useDispatch } from "react-redux"; import { useLoaderData, useLocation, useNavigate, useParams } from "react-router-dom"; @@ -13,10 +12,19 @@ import { IEditor } from "../../utils/interfaces"; import { ITAFormValues, transformTARequest } from "./TAUtil"; /** - * @author Atharva Thorve, on December, 2023 - * @author Divit Kalathil, on December, 2023 + * @author Anurag Gorkar, on December, 2024 + * @author Makarand Pundalik, on December, 2024 + * @author Rutvik Kulkarni, on December, 2024 */ + +// Type definition for user options +type UserOption = { + label: string; + value: string | number; + role?: string; +}; + const initialValues: ITAFormValues = { name: "", }; @@ -29,7 +37,6 @@ const TAEditor: React.FC = ({ mode }) => { const { data: TAResponse, error: TAError, sendRequest } = useAPI(); const TAData = { ...initialValues }; - // Load data from the server const { taUsers }: any = useLoaderData(); const dispatch = useDispatch(); const navigate = useNavigate(); @@ -37,9 +44,9 @@ const TAEditor: React.FC = ({ mode }) => { const params = useParams(); const { courseId } = params; - // logged-in TA is the parent of the TA being created and the institution is the same as the parent's + const [showConfirmModal, setShowConfirmModal] = useState(false); + const [selectedUser, setSelectedUser] = useState({ label: "", value: "" }); - // Close the modal if the TA is updated successfully and navigate to the TAs page useEffect(() => { if (TAResponse && TAResponse.status >= 200 && TAResponse.status < 300) { dispatch( @@ -48,80 +55,130 @@ const TAEditor: React.FC = ({ mode }) => { message: `TA ${TAData.name} ${mode}d successfully!`, }) ); - navigate(location.state?.from ? location.state.from : "/TAs"); + navigate(location.state?.from ? location.state.from : `/courses/${courseId}/tas`); } - }, [dispatch, mode, navigate, TAData.name, TAResponse, location.state?.from]); + }, [dispatch, mode, navigate, TAData.name, TAResponse, location.state?.from, showConfirmModal]); - // Show the error message if the TA is not updated successfully useEffect(() => { TAError && dispatch(alertActions.showAlert({ variant: "danger", message: TAError })); }, [TAError, dispatch]); const onSubmit = (values: ITAFormValues, submitProps: FormikHelpers) => { + const selectedUserData = taUsers.find((user: UserOption) => + parseInt(String(user.value)) === parseInt(String(values.name)) + ); + + if (selectedUserData?.role === 'student') { + // If selected user is a student, show confirmation modal + console.log("Student role detected...", selectedUserData); + setSelectedUser(selectedUserData); + setShowConfirmModal(true); + } else { + // If TA or other role, directly submit + submitTA(values); + } + submitProps.setSubmitting(false); + }; + + const submitTA = (values: ITAFormValues) => { let method: HttpMethod = HttpMethod.GET; - // ToDo: Need to create API in the backend for this call. - // Note: The current API needs the TA id to create a new TA which is incorrect and needs to be fixed. - // Currently we send the username of the user we want to add as the TA for the course. let url: string = `/courses/${courseId}/add_ta/${values.name}`; - // to be used to display message when TA is created sendRequest({ url: url, method: method, data: {}, transformRequest: transformTARequest, }); - submitProps.setSubmitting(false); + }; + + const handleConfirmAddStudent = () => { + // Submit TA addition if confirmed + submitTA({ name: String(selectedUser.value) }); + setShowConfirmModal(false); }; const handleClose = () => navigate(location.state?.from ? location.state.from : `/courses/${courseId}/tas`); - //Validation of TA Entry + return ( - - - Add TA - - - {TAError &&

{TAError}

} - - {(formik) => { - return ( -
- TA - } - /> - - - - - - - ); - }} -
-
-
+ <> + + + Add TA + + + {TAError &&

{TAError}

} + + {(formik) => { + return ( +
+
+ +