diff --git a/README.md b/README.md index e22bfb1f..aa44f185 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,8 @@ This is a [single-spa](https://single-spa.js.org/) example React microapp. - Router via [Reach Router](https://reach.tech/router/) - CSS Modules with SCSS via [babel-plugin-react-css-modules](https://github.com/gajus/babel-plugin-react-css-modules) - [React Inline SVG](https://github.com/airbnb/babel-plugin-inline-react-svg) +- We use **Redux Store** for storing page data if we need to edit it. Otherwise we can use local state. +- [react-redux-toastr](https://www.npmjs.com/package/react-redux-toastr) for success/error popups in the bottom left corner. ## Config diff --git a/config/development.js b/config/development.js index 151a0de7..651de18a 100644 --- a/config/development.js +++ b/config/development.js @@ -8,6 +8,12 @@ module.exports = { * Email to report issues to */ EMAIL_REPORT_ISSUE: "support+team-issue@topcoder-dev.com", + + /** + * Email to request extension + */ + EMAIL_REQUEST_EXTENSION: "customersuccess@topcoder-dev.com", + API: { V5: "https://api.topcoder-dev.com/v5", }, diff --git a/config/production.js b/config/production.js index 42c064b2..de80270a 100644 --- a/config/production.js +++ b/config/production.js @@ -8,6 +8,12 @@ module.exports = { * Email to report issues to */ EMAIL_REPORT_ISSUE: "support+team-issue@topcoder.com", + + /** + * Email to request extension + */ + EMAIL_REQUEST_EXTENSION: "customersuccess@topcoder.com", + API: { V5: "https://api.topcoder.com/v5", }, diff --git a/package-lock.json b/package-lock.json index 9133be21..422e1d0a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4680,8 +4680,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "optional": true + "dev": true }, "to-regex-range": { "version": "5.0.1", @@ -5708,6 +5707,11 @@ "integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=", "dev": true }, + "deep-diff": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/deep-diff/-/deep-diff-0.3.8.tgz", + "integrity": "sha1-wB3mPvsO7JeYgB1Ax+Da4ltYLIQ=" + }, "deep-equal": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.1.tgz", @@ -7819,6 +7823,14 @@ "minimalistic-crypto-utils": "^1.0.1" } }, + "hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "requires": { + "react-is": "^16.7.0" + } + }, "homedir-polyfill": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz", @@ -7999,6 +8011,11 @@ "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==", "dev": true }, + "immutability-helper": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/immutability-helper/-/immutability-helper-3.1.1.tgz", + "integrity": "sha512-Q0QaXjPjwIju/28TsugCHNEASwoCcJSyJV3uO1sOIQGI0jKgm9f41Lvz0DZj3n46cNCyAZTsEYoY4C2bVRUzyQ==" + }, "import-fresh": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.2.1.tgz", @@ -14295,6 +14312,44 @@ } } }, + "react-redux": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.2.tgz", + "integrity": "sha512-8+CQ1EvIVFkYL/vu6Olo7JFLWop1qRUeb46sGtIMDCSpgwPQq8fPLpirIB0iTqFe9XYEFPHssdX8/UwN6pAkEA==", + "requires": { + "@babel/runtime": "^7.12.1", + "hoist-non-react-statics": "^3.3.2", + "loose-envify": "^1.4.0", + "prop-types": "^15.7.2", + "react-is": "^16.13.1" + }, + "dependencies": { + "@babel/runtime": { + "version": "7.12.5", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.12.5.tgz", + "integrity": "sha512-plcc+hbExy3McchJCEQG3knOsuh3HH+Prx1P6cLIkET/0dLuQDEnrT+s27Axgc9bqfsmNUNHfscgMUdBpC9xfg==", + "requires": { + "regenerator-runtime": "^0.13.4" + } + } + } + }, + "react-redux-toastr": { + "version": "7.6.5", + "resolved": "https://registry.npmjs.org/react-redux-toastr/-/react-redux-toastr-7.6.5.tgz", + "integrity": "sha512-tbf8z18O85BGKXhDfYsgNmurbKlP9p7UXjtdBNS1wWqCAY0QNAyW1W5ulChPEFWoBWVzQywgMGIZ5j3an//srg==", + "requires": { + "classnames": "^2.2.3", + "eventemitter3": "^3.1.0" + }, + "dependencies": { + "eventemitter3": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-3.1.2.tgz", + "integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==" + } + } + }, "react-universal-interface": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/react-universal-interface/-/react-universal-interface-0.6.2.tgz", @@ -14393,6 +14448,33 @@ "strip-indent": "^3.0.0" } }, + "redux": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.0.5.tgz", + "integrity": "sha512-VSz1uMAH24DM6MF72vcojpYPtrTUu3ByVWfPL1nPfVRb5mZVTve5GnNCUV53QM/BZ66xfWrm0CTWoM+Xlz8V1w==", + "requires": { + "loose-envify": "^1.4.0", + "symbol-observable": "^1.2.0" + } + }, + "redux-logger": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/redux-logger/-/redux-logger-3.0.6.tgz", + "integrity": "sha1-91VZZvMJjzyIYExEnPC69XeCdL8=", + "requires": { + "deep-diff": "^0.3.5" + } + }, + "redux-promise-middleware": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/redux-promise-middleware/-/redux-promise-middleware-6.1.2.tgz", + "integrity": "sha512-ZqZu/nnSzGgwTtNbGoGVontpk7LjTOv0kigtt3CcgXI9gpq+8WlfXTXRZD0WTD5yaohRq0q2nYmJXSTjwXs83Q==" + }, + "redux-thunk": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.3.0.tgz", + "integrity": "sha512-km6dclyFnmcvxhAcrQV2AkZmPQjzPDjgVlQtR0EQjxZPyJ0BnMf3in1ryuR8A2qU0HldVRfxYXbFSKlI3N7Slw==" + }, "reflect.ownkeys": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/reflect.ownkeys/-/reflect.ownkeys-0.2.0.tgz", @@ -16042,6 +16124,11 @@ } } }, + "symbol-observable": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz", + "integrity": "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==" + }, "symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", diff --git a/package.json b/package.json index e8a9c18e..3a16e2cc 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "axios": "^0.21.0", "classnames": "^2.2.6", "express": "^4.17.1", + "immutability-helper": "^3.1.1", "lodash": "^4.17.20", "moment": "^2.29.1", "prop-types": "^15.7.2", @@ -67,7 +68,13 @@ "react-dom": "^16.12.0", "react-outside-click-handler": "^1.3.0", "react-popper": "^2.2.3", - "react-use": "^15.3.4" + "react-redux": "^7.2.2", + "react-redux-toastr": "^7.6.5", + "react-use": "^15.3.4", + "redux": "^4.0.5", + "redux-logger": "^3.0.6", + "redux-promise-middleware": "^6.1.2", + "redux-thunk": "^2.3.0" }, "browserslist": [ "last 1 version", diff --git a/src/components/LoadingIndicator/index.jsx b/src/components/LoadingIndicator/index.jsx index a5f70dd1..c6668f08 100644 --- a/src/components/LoadingIndicator/index.jsx +++ b/src/components/LoadingIndicator/index.jsx @@ -4,17 +4,22 @@ * Optionally shows error. */ import React from "react"; +import _ from "lodash"; import PT from "prop-types"; import "./styles.module.scss"; const LoadingIndicator = ({ error }) => { return ( -
{!error ? "Loading..." : error}
+
+ {!error + ? "Loading..." + : _.get(error, "response.data.message", error.toString())} +
); }; LoadingIndicator.propTypes = { - error: PT.string, + error: PT.object, }; export default LoadingIndicator; diff --git a/src/components/SkillsList/index.jsx b/src/components/SkillsList/index.jsx index 5718f1e8..edc64219 100644 --- a/src/components/SkillsList/index.jsx +++ b/src/components/SkillsList/index.jsx @@ -2,9 +2,8 @@ * SkillsList * * Shows list of skills with "N more" link which is showing tooltip with a full list of skills. - * If `showMatches = true` it marks matched and not matched required skills. */ -import React, { useCallback, useState } from "react"; +import React, { useCallback, useState, useMemo } from "react"; import PT from "prop-types"; import _ from "lodash"; import "./styles.module.scss"; @@ -13,17 +12,19 @@ import IconCross from "../../assets/images/icon-cross.svg"; import { usePopper } from "react-popper"; import OutsideClickHandler from "react-outside-click-handler"; -const SkillsList = ({ - skills, - limit = 3, - showMatches = false, -}) => { +const SkillsList = ({ requiredSkills, skills, limit = 3 }) => { const skillsToShow = skills.slice(0, limit); const skillsToHide = skills.slice(limit); + // if has requiredSkills, show two columns, eles show only one column + const showMatches = !!requiredSkills; const [isOpen, setIsOpen] = useState(false); const [referenceElement, setReferenceElement] = useState(null); const [popperElement, setPopperElement] = useState(null); + + const otherSkills = useMemo(() => { + return _.differenceBy(skills, requiredSkills, "id"); + }, [requiredSkills, skills]); const { styles, attributes } = usePopper(referenceElement, popperElement, { placement: "bottom", modifiers: [ @@ -96,11 +97,30 @@ const SkillsList = ({ {...attributes.popper} >
- {skills && ( + {requiredSkills && ( +
+
Required Job Skills
+ +
+ )} + {otherSkills && (
-
Skills
+
+ {showMatches ? "Other User Skills" : "Required Skills"} +
@@ -123,8 +143,8 @@ export const skillShape = PT.shape({ SkillsList.propTypes = { skills: PT.arrayOf(skillShape), + requiredSkills: PT.arrayOf(skillShape), limit: PT.number, - showMatches: PT.bool, }; export default SkillsList; diff --git a/src/components/SkillsSummary/index.jsx b/src/components/SkillsSummary/index.jsx index 66ae1e56..ceccbb62 100644 --- a/src/components/SkillsSummary/index.jsx +++ b/src/components/SkillsSummary/index.jsx @@ -10,17 +10,23 @@ import PercentageBar from "components/PercentageBar"; import SkillsList from "components/SkillsList"; import "./styles.module.scss"; -const SkillsSummary = ({ skills, skillMatched, limit }) => { +const SkillsSummary = ({ skills, requiredSkills = [], limit }) => { + const skillsMatched = _.intersectionBy(skills, requiredSkills, "id"); + const skillsMatchedRatio = + requiredSkills.length > 0 + ? skillsMatched.length / requiredSkills.length + : 1; + return (
- - {Math.round(skillMatched)}% skill matched + + {Math.round(skillsMatchedRatio * 100)}% skill matched
); @@ -33,8 +39,8 @@ const skillShape = PT.shape({ SkillsSummary.propTypes = { skills: PT.arrayOf(skillShape), + requiredSkills: PT.arrayOf(skillShape), limit: PT.number, - skillMatched: PT.number, }; export default SkillsSummary; diff --git a/src/constants/index.js b/src/constants/index.js index 6ad60999..e65c089b 100644 --- a/src/constants/index.js +++ b/src/constants/index.js @@ -12,11 +12,21 @@ export const DAY_FORMAT = "MM/DD/YYYY"; */ export const TEAM_MEMBERS_PER_PAGE = 5; +/** + * How many teams show per page by default + */ +export const TEAMS_PER_PAGE = 20; + /** * How many position candidates show per page by default */ export const POSITION_CANDIDATES_PER_PAGE = 5; +/** + * Input debounce delay (ms) + */ +export const INPUT_DEBOUNCE_DELAY = 200; + /** * Position statuses */ @@ -110,7 +120,7 @@ export const CANDIDATE_STATUS_FILTERS = [ * Candidates "sort by" values */ export const CANDIDATES_SORT_BY = { - SKILL_MATCHED: "skillMatched", + SKILL_MATCHED: "skillsMatched", HANDLE: "handle", }; @@ -121,3 +131,20 @@ export const CANDIDATES_SORT_OPTIONS = [ { label: "Skill Matched", value: CANDIDATES_SORT_BY.SKILL_MATCHED }, { label: "Handle", value: CANDIDATES_SORT_BY.HANDLE }, ]; + +/** + * All action types + */ +export const ACTION_TYPE = { + LOAD_POSITION: "LOAD_POSITION", + LOAD_POSITION_PENDING: "LOAD_POSITION_PENDING", + LOAD_POSITION_SUCCESS: "LOAD_POSITION_SUCCESS", + LOAD_POSITION_ERROR: "LOAD_POSITION_ERROR", + + RESET_POSITION_STATE: "RESET_POSITION_STATE", + + UPDATE_CANDIDATE: "UPDATE_CANDIDATE", + UPDATE_CANDIDATE_PENDING: "UPDATE_CANDIDATE_PENDING", + UPDATE_CANDIDATE_SUCCESS: "UPDATE_CANDIDATE_SUCCESS", + UPDATE_CANDIDATE_ERROR: "UPDATE_CANDIDATE_ERROR", +}; diff --git a/src/hoc/withAuthentication.js b/src/hoc/withAuthentication.js new file mode 100644 index 00000000..785dcdcb --- /dev/null +++ b/src/hoc/withAuthentication.js @@ -0,0 +1,45 @@ +/** + * Authentication + * + * wrap component for authentication + */ +import React, { useState, useEffect } from "react"; +import { getAuthUserTokens, login } from "@topcoder/micro-frontends-navbar-app"; +import LoadingIndicator from "../components/LoadingIndicator"; + +export default function withAuthentication(Component) { + const AuthenticatedComponent = (props) => { + let [isLoggedIn, setIsLoggedIn] = useState(null); + let [authError, setAuthError] = useState(false); + + useEffect(() => { + // prevent page redirecting to login page when unmount + let isUnmount = false; + getAuthUserTokens() + .then(({ tokenV3 }) => { + if (!!tokenV3) { + setIsLoggedIn(!!tokenV3); + } else if (!isUnmount) { + login(); + } + }) + .catch(setAuthError); + return () => { + isUnmount = true; + }; + }, []); + + return ( + <> + {/* Show loading indicator until we know if user is logged-in or no. + In we got error during this process, show error */} + {isLoggedIn === null && } + + {/* Show component only if user is logged-in */} + {isLoggedIn === true ? : null} + + ); + }; + + return AuthenticatedComponent; +} diff --git a/src/reducers/index.js b/src/reducers/index.js new file mode 100644 index 00000000..7c85976f --- /dev/null +++ b/src/reducers/index.js @@ -0,0 +1,13 @@ +/** + * Root Redux Reducer + */ +import { combineReducers } from "redux"; +import { reducer as toastrReducer } from "react-redux-toastr"; +import positionDetailsReducer from "../routes/PositionDetails/reducers"; + +const rootReducer = combineReducers({ + toastr: toastrReducer, + positionDetails: positionDetailsReducer, +}); + +export default rootReducer; diff --git a/src/root.component.jsx b/src/root.component.jsx index eca449ee..834234fb 100644 --- a/src/root.component.jsx +++ b/src/root.component.jsx @@ -1,18 +1,33 @@ import React from "react"; -import { Router } from "@reach/router"; +import { Provider } from "react-redux"; +import { Router, Redirect } from "@reach/router"; import MyTeamsList from "./routes/MyTeamsList"; import MyTeamsDetails from "./routes/MyTeamsDetails"; import PositionDetails from "./routes/PositionDetails"; -import "./styles/main.module.scss"; +import ReduxToastr from "react-redux-toastr"; +import store from "./store"; +import "./styles/main.vendor.scss"; +import styles from "./styles/main.module.scss"; export default function Root() { return ( -
- - - - - +
+ + + + + + + + + {/* Global config for Toastr popups */} + +
); } diff --git a/src/routes/MyTeamsDetails/components/TeamMembers/index.jsx b/src/routes/MyTeamsDetails/components/TeamMembers/index.jsx index 26d8488b..945995fb 100644 --- a/src/routes/MyTeamsDetails/components/TeamMembers/index.jsx +++ b/src/routes/MyTeamsDetails/components/TeamMembers/index.jsx @@ -7,7 +7,6 @@ import React, { useState, useCallback, useMemo } from "react"; import PT from "prop-types"; import "./styles.module.scss"; import _ from "lodash"; -import moment from "moment"; import User from "components/User"; import CardHeader from "components/CardHeader"; // import Rating from "components/Rating"; @@ -15,8 +14,13 @@ import SkillsSummary from "components/SkillsSummary"; import ActionsMenu from "components/ActionsMenu"; import Button from "components/Button"; import Pagination from "components/Pagination"; -import { DAY_FORMAT, TEAM_MEMBERS_PER_PAGE } from "constants"; -import { formatMoney, formatReportIssueUrl } from "utils/format"; +import { TEAM_MEMBERS_PER_PAGE } from "constants"; +import { + formatDateRange, + formatMoney, + formatReportIssueUrl, + formatRequestExtensionUrl, +} from "utils/format"; import Input from "components/Input"; import { skillShape } from "components/SkillsList"; @@ -26,31 +30,32 @@ const TeamMembers = ({ team }) => { const filteredMembers = useMemo( () => - resources.filter((member) => { - const filterLowerCase = filter.toLowerCase(); - const lookupFields = _.compact([ - member.handle, - member.firstName, - member.lastName, - member.role, - ..._.map(member.skills, "name"), - ]).map((field) => field.toLowerCase()); + resources + // populate resources with job data first + .map((member) => ({ + ...member, + job: _.find(jobs, { id: member.jobId }) || {}, + })) + // now we can filter resources + .filter((member) => { + const filterLowerCase = filter.toLowerCase().trim(); + const lookupFields = _.compact([ + member.handle, + member.firstName, + member.lastName, + `${member.firstName} ${member.lastName}`, // full name + member.job.title, + ..._.map(member.skills, "name"), + ]).map((field) => field.toLowerCase()); - return _.some( - lookupFields, - (field) => field.indexOf(filterLowerCase) > -1 - ); - }), - [resources, filter] + return _.some( + lookupFields, + (field) => field.indexOf(filterLowerCase) > -1 + ); + }), + [resources, filter, jobs] ); - const filteredMembersWithJobs = useMemo(() => { - return filteredMembers.map((member) => ({ - ...member, - job: _.find(jobs, { id: member.jobId }) || {}, - })); - }, [filteredMembers, jobs]); - const onFilterChange = useCallback( (event) => { setFilter(event.target.value); @@ -72,8 +77,8 @@ const TeamMembers = ({ team }) => { const pagesTotal = Math.ceil(filteredMembers.length / perPage); const pageMembers = useMemo( - () => filteredMembersWithJobs.slice((page - 1) * perPage, page * perPage), - [filteredMembersWithJobs, page, perPage] + () => filteredMembers.slice((page - 1) * perPage, page * perPage), + [filteredMembers, page, perPage] ); const onPageClick = useCallback( @@ -97,10 +102,10 @@ const TeamMembers = ({ team }) => { } /> {resources.length === 0 &&
No members
} - {resources.length > 0 && filteredMembersWithJobs.length === 0 && ( + {resources.length > 0 && filteredMembers.length === 0 && (
No members matching filter
)} - {filteredMembersWithJobs.length > 0 && ( + {filteredMembers.length > 0 && (
{pageMembers.map((member) => (
@@ -114,12 +119,9 @@ const TeamMembers = ({ team }) => { />
-
- {member.job.description} -
+
{member.job.title}
- {moment(member.startDate).format(DAY_FORMAT)} -{" "} - {moment(member.endDate).format(DAY_FORMAT)} + {formatDateRange(member.startDate, member.endDate)}
{member.customerRate && member.customerRate > 0 @@ -132,7 +134,7 @@ const TeamMembers = ({ team }) => {
@@ -153,7 +155,16 @@ const TeamMembers = ({ team }) => { ); }, }, - { label: "Request an Extension", action: () => {} }, + { + label: "Request an Extension", + action: () => { + window.open( + formatRequestExtensionUrl( + `Request extension for ${member.handle} on ${team.name}` + ) + ); + }, + }, ]} />
@@ -168,15 +179,15 @@ const TeamMembers = ({ team }) => { type="secondary" onClick={showMore} disabled={ - filteredMembersWithJobs.length === 0 || // if no members to show + filteredMembers.length === 0 || // if no members to show page === pagesTotal // if we are already on the last page } > Show More - {filteredMembersWithJobs.length > 0 && ( + {filteredMembers.length > 0 && ( { return ( @@ -28,13 +23,12 @@ const TeamPositions = ({ teamId, positions }) => {
- {position.description} + {position.title}
- {moment(position.startDate).format(DAY_FORMAT)} -{" "} - {moment(position.endDate).format(DAY_FORMAT)} + {formatDateRange(position.startDate, position.endDate)}
{/* Hide rate as we don't have data for it */} @@ -70,7 +64,7 @@ TeamPositions.propTypes = { teamId: PT.string, positions: PT.arrayOf( PT.shape({ - description: PT.string, + title: PT.string, customerRate: PT.number, rateType: PT.oneOf(Object.values(RATE_TYPE)), skills: PT.arrayOf(skillShape), diff --git a/src/routes/MyTeamsDetails/index.jsx b/src/routes/MyTeamsDetails/index.jsx index 51f124d3..c8e258d1 100644 --- a/src/routes/MyTeamsDetails/index.jsx +++ b/src/routes/MyTeamsDetails/index.jsx @@ -14,20 +14,16 @@ import LoadingIndicator from "components/LoadingIndicator"; import TeamSummary from "./components/TeamSummary"; import TeamMembers from "./components/TeamMembers"; import TeamPositions from "./components/TeamPositions"; +import withAuthentication from "../../hoc/withAuthentication"; import { useAsync } from "react-use"; -import { - getAuthUserTokens, -} from "@topcoder/micro-frontends-navbar-app"; const MyTeamsDetails = ({ teamId }) => { - const authUserTokens = useAsync(getAuthUserTokens); - const tokenV3 = authUserTokens.value ? authUserTokens.value.tokenV3 : null; - const [team, loadingError] = useData(getTeamById, tokenV3, teamId); + const [team, loadingError] = useData(getTeamById, teamId); return ( {!team ? ( - + ) : ( <> @@ -44,4 +40,4 @@ MyTeamsDetails.propTypes = { teamId: PT.string, }; -export default MyTeamsDetails; +export default withAuthentication(MyTeamsDetails); diff --git a/src/routes/MyTeamsList/components/TeamCard/index.jsx b/src/routes/MyTeamsList/components/TeamCard/index.jsx index d8ea8b52..f4c9e9ac 100644 --- a/src/routes/MyTeamsList/components/TeamCard/index.jsx +++ b/src/routes/MyTeamsList/components/TeamCard/index.jsx @@ -14,6 +14,7 @@ import IconClock from "../../../../assets/images/icon-clock.svg"; import IconMoney from "../../../../assets/images/icon-money.svg"; import IconPeople from "../../../../assets/images/icon-people.svg"; import { + formatDateRange, formatMoney, formatRemainingTimeForTeam, formatReportIssueUrl, @@ -27,12 +28,6 @@ const TeamCard = ({ team }) => {
{} }, - { label: "Team Invoices", action: () => {} }, - { label: "Team Reports", action: () => {} }, - { separator: true }, - { label: "Add Team Member", action: () => {} }, - { separator: true }, { label: "Report an Issue", action: () => { @@ -51,14 +46,9 @@ const TeamCard = ({ team }) => {
}> - {team.startDate && team.endDate ? ( - <> - {moment(team.startDate).format(DAY_FORMAT)} -{" "} - {moment(team.endDate).format(DAY_FORMAT)} - - ) : ( - <>TBD - )} + {team.startDate && team.endDate + ? formatDateRange(team.startDate, team.endDate) + : "TBD"} }> diff --git a/src/routes/MyTeamsList/index.jsx b/src/routes/MyTeamsList/index.jsx index 819f97c5..afc92e73 100644 --- a/src/routes/MyTeamsList/index.jsx +++ b/src/routes/MyTeamsList/index.jsx @@ -3,38 +3,100 @@ * * Page for the list of teams. */ -import React from "react"; +import React, { useCallback, useState, useEffect } from "react"; +import _ from "lodash"; import LayoutContainer from "components/LayoutContainer"; import PageHeader from "components/PageHeader"; -import { useData } from "../../hooks/useData"; +import Input from "components/Input"; +import Pagination from "components/Pagination"; import { getMyTeams } from "../../services/teams"; import TeamCard from "./components/TeamCard"; import TeamCardGrid from "./components/TeamCardGrid"; import LoadingIndicator from "../../components/LoadingIndicator"; -import { useAsync } from "react-use"; -import { - getAuthUserTokens, -} from "@topcoder/micro-frontends-navbar-app"; +import withAuthentication from "../../hoc/withAuthentication"; +import { useDebounce } from "react-use"; +import { TEAMS_PER_PAGE } from "constants"; +import "./styles.module.scss"; +import { INPUT_DEBOUNCE_DELAY } from "constants/"; const MyTeamsList = () => { - const authUserTokens = useAsync(getAuthUserTokens); - const tokenV3 = authUserTokens.value ? authUserTokens.value.tokenV3 : null; - const [myTeams, loadingError] = useData(getMyTeams, tokenV3); + let [myTeams, setMyTeams] = useState(null); + const [debouncedFilter, setDebouncedFilter] = useState(""); + const [filter, setFilter] = React.useState(""); + const [loadingError, setLoadingError] = useState(false); + const [page, setPage] = useState(1); + const [total, setTotal] = useState(0); + + const onFilterChange = (evt) => { + setFilter(evt.target.value); + }; + + useDebounce( + () => { + setDebouncedFilter(filter); + setPage(1); + }, + INPUT_DEBOUNCE_DELAY, + [filter] + ); + + useEffect(() => { + setMyTeams(null); + getMyTeams(debouncedFilter, page, TEAMS_PER_PAGE) + .then((response) => { + setMyTeams(response.data); + setTotal(response.headers["x-total"]); + }) + .catch((responseError) => { + setLoadingError(responseError); + }); + }, [debouncedFilter, page]); + + const onPageClick = useCallback( + (newPage) => { + setPage(newPage); + }, + [setPage] + ); return ( - + + } + /> + {myTeams && myTeams.length === 0 && ( +
No teams found
+ )} {!myTeams ? ( - + ) : ( - - {myTeams.map((team) => ( - - ))} - + <> + + {myTeams.map((team) => ( + + ))} + + {myTeams.length > 0 && ( +
+ +
+ )} + )}
); }; -export default MyTeamsList; +export default withAuthentication(MyTeamsList); diff --git a/src/routes/MyTeamsList/styles.module.scss b/src/routes/MyTeamsList/styles.module.scss new file mode 100644 index 00000000..b2158822 --- /dev/null +++ b/src/routes/MyTeamsList/styles.module.scss @@ -0,0 +1,36 @@ +@import "styles/include"; + +.filter-input { + width: 380px; + background-color: #FFFFFF; + border: 1px solid #AAAAAA; + border-radius: 6px; + box-sizing: border-box; + color: #2A2A2A; + font-size: 14px; + height: 40px; + line-height: 38px; + outline: none; + padding: 0 15px; + + &::placeholder { + color: #AAAAAA; + } +} + +.empty { + text-align: center; +} + +.pagination-wrapper { + margin-top: 20px; + margin-right: 20px; + display: flex; + justify-content: flex-end; +} + +@media (max-width: 650px) { + .filter-input { + width: 100%; + } +} diff --git a/src/routes/PositionDetails/actions/index.js b/src/routes/PositionDetails/actions/index.js new file mode 100644 index 00000000..e7fdf31a --- /dev/null +++ b/src/routes/PositionDetails/actions/index.js @@ -0,0 +1,56 @@ +/** + * Position Details page actions + */ +import { getPositionDetails, patchPositionCandidate } from "services/teams"; +import { ACTION_TYPE } from "constants"; + +/** + * Load Team Position details (team job) + * + * @param {string} teamId team id + * @param {string} positionId position id + * + * @returns {Promise} loaded data or error + */ +export const loadPosition = (teamId, positionId) => ({ + type: ACTION_TYPE.LOAD_POSITION, + payload: async () => { + const response = await getPositionDetails(teamId, positionId); + + return response.data; + }, + meta: { + teamId, + positionId, + }, +}); + +/** + * Update candidate on the server and in Redux store + * + * @param {string} candidateId position candidate id + * @param {object} partialCandidateData partial candidate data + * + * @returns {Promise} updated candidate data or error + */ +export const updateCandidate = (candidateId, partialCandidateData) => ({ + type: ACTION_TYPE.UPDATE_CANDIDATE, + payload: async () => { + const response = await patchPositionCandidate( + candidateId, + partialCandidateData + ); + + return response.data; + }, + meta: { + candidateId, + }, +}); + +/** + * Reset position state + */ +export const resetPositionState = () => ({ + type: ACTION_TYPE.RESET_POSITION_STATE, +}); diff --git a/src/routes/PositionDetails/CandidatesStatusFilter/index.jsx b/src/routes/PositionDetails/components/CandidatesStatusFilter/index.jsx similarity index 100% rename from src/routes/PositionDetails/CandidatesStatusFilter/index.jsx rename to src/routes/PositionDetails/components/CandidatesStatusFilter/index.jsx diff --git a/src/routes/PositionDetails/CandidatesStatusFilter/styles.module.scss b/src/routes/PositionDetails/components/CandidatesStatusFilter/styles.module.scss similarity index 100% rename from src/routes/PositionDetails/CandidatesStatusFilter/styles.module.scss rename to src/routes/PositionDetails/components/CandidatesStatusFilter/styles.module.scss diff --git a/src/routes/PositionDetails/PositionCandidates/index.jsx b/src/routes/PositionDetails/components/PositionCandidates/index.jsx similarity index 62% rename from src/routes/PositionDetails/PositionCandidates/index.jsx rename to src/routes/PositionDetails/components/PositionCandidates/index.jsx index 6f1a6407..6b893b39 100644 --- a/src/routes/PositionDetails/PositionCandidates/index.jsx +++ b/src/routes/PositionDetails/components/PositionCandidates/index.jsx @@ -20,8 +20,8 @@ import User from "components/User"; import SkillsSummary from "components/SkillsSummary"; import Button from "components/Button"; import Pagination from "components/Pagination"; -import IconResume from "../../../assets/images/icon-resume.svg"; -import { skillShape } from "components/SkillsList"; +import IconResume from "../../../../assets/images/icon-resume.svg"; +import { toastr } from "react-redux-toastr"; /** * Generates a function to sort candidates @@ -33,7 +33,7 @@ import { skillShape } from "components/SkillsList"; const createSortCandidatesMethod = (sortBy) => (candidate1, candidate2) => { switch (sortBy) { case CANDIDATES_SORT_BY.SKILL_MATCHED: - return candidate2.skillMatched - candidate1.skillMatched; + return candidate2.skillsMatched - candidate1.skillsMatched; case CANDIDATES_SORT_BY.HANDLE: return new Intl.Collator().compare( candidate1.handle.toLowerCase(), @@ -42,17 +42,31 @@ const createSortCandidatesMethod = (sortBy) => (candidate1, candidate2) => { } }; -const PositionCandidates = ({ - candidates, - candidateStatus, -}) => { +/** + * Populates candidate objects with `skillsMatched` property + * which define the number of candidate skills that match position skills + * + * @param {object} position position + * @param {object} candidate candidate for position + */ +const populateSkillsMatched = (position, candidate) => ({ + ...candidate, + skillsMatched: _.intersectionBy(position.skills, candidate.skills, "id"), +}); + +const PositionCandidates = ({ position, candidateStatus, updateCandidate }) => { + const { candidates } = position; const [sortBy, setSortBy] = useState(CANDIDATES_SORT_BY.SKILL_MATCHED); const filteredCandidates = useMemo( () => - _.filter(candidates, { status: candidateStatus }).sort( - createSortCandidatesMethod(sortBy) - ), - [candidates, candidateStatus, sortBy] + _.chain(candidates) + .map((candidate) => populateSkillsMatched(position, candidate)) + .filter({ + status: candidateStatus, + }) + .value() + .sort(createSortCandidatesMethod(sortBy)), + [candidates, candidateStatus, sortBy, position] ); const onSortByChange = useCallback( @@ -86,6 +100,42 @@ const PositionCandidates = ({ [setPage] ); + const markCandidateShortlisted = useCallback( + (candidateId) => { + updateCandidate(candidateId, { + status: CANDIDATE_STATUS.SHORTLIST, + }) + .then(() => { + toastr.success("Candidate is marked as interested."); + }) + .catch((error) => { + toastr.error( + "Failed to mark candidate as interested", + error.toString() + ); + }); + }, + [updateCandidate] + ); + + const markCandidateRejected = useCallback( + (candidateId) => { + updateCandidate(candidateId, { + status: CANDIDATE_STATUS.REJECTED, + }) + .then(() => { + toastr.success("Candidate is marked as not interested."); + }) + .catch((error) => { + toastr.error( + "Failed to mark candidate as not interested", + error.toString() + ); + }); + }, + [updateCandidate] + ); + return (
- {candidate.resumeLink && ( + {candidate.resume && ( Download Resume @@ -135,14 +186,24 @@ const PositionCandidates = ({ )}
- {candidateStatus === CANDIDATE_STATUS.SHORTLIST ? ( - - ) : ( + {candidateStatus === CANDIDATE_STATUS.OPEN && ( <> Interested in this candidate?
- - + +
)} @@ -177,7 +238,7 @@ const PositionCandidates = ({ }; PositionCandidates.propType = { - candidates: PT.array, + position: PT.object, candidateStatus: PT.oneOf(Object.values(CANDIDATE_STATUS)), }; diff --git a/src/routes/PositionDetails/PositionCandidates/styles.module.scss b/src/routes/PositionDetails/components/PositionCandidates/styles.module.scss similarity index 100% rename from src/routes/PositionDetails/PositionCandidates/styles.module.scss rename to src/routes/PositionDetails/components/PositionCandidates/styles.module.scss diff --git a/src/routes/PositionDetails/hooks/useTeamPositionsState.js b/src/routes/PositionDetails/hooks/useTeamPositionsState.js new file mode 100644 index 00000000..2f196abb --- /dev/null +++ b/src/routes/PositionDetails/hooks/useTeamPositionsState.js @@ -0,0 +1,38 @@ +/** + * State for PositionDetails page + */ +import { useCallback, useLayoutEffect } from "react"; +import { useSelector, useDispatch } from "react-redux"; +import { loadPosition, updateCandidate, resetPositionState } from "../actions"; + +/** + * Hook which provides state for PositionDetails page with all possible actions. + * + * @param {string} teamId team id + * @param {string} positionId position id + * + * @returns {{ state: {}, updateCandidate: Function }} PositionDetails page state and possible actions + */ +export const useTeamPositionsState = (teamId, positionId) => { + const state = useSelector((state) => state.positionDetails); + const dispatch = useDispatch(); + + // load team position details on mount + useLayoutEffect(() => { + dispatch(loadPosition(teamId, positionId)); + + // clear state when we leave the page + return () => { + dispatch(resetPositionState()); + }; + }, [dispatch, teamId, positionId]); + + // bind actions to dispatch method + const updateCandidateCallback = useCallback( + (candidateId, partialCandidateData) => + dispatch(updateCandidate(candidateId, partialCandidateData)), + [dispatch] + ); + + return { state, updateCandidate: updateCandidateCallback }; +}; diff --git a/src/routes/PositionDetails/index.jsx b/src/routes/PositionDetails/index.jsx index 09d48fa9..cb9d3ae6 100644 --- a/src/routes/PositionDetails/index.jsx +++ b/src/routes/PositionDetails/index.jsx @@ -9,26 +9,18 @@ import LayoutContainer from "components/LayoutContainer"; import LoadingIndicator from "components/LoadingIndicator"; import PageHeader from "components/PageHeader"; import { CANDIDATE_STATUS } from "constants"; -import { useData } from "hooks/useData"; -import { getPositionDetails } from "services/teams"; -import PositionCandidates from "./PositionCandidates"; +import withAuthentication from "../../hoc/withAuthentication"; +import PositionCandidates from "./components/PositionCandidates"; +import CandidatesStatusFilter from "./components/CandidatesStatusFilter"; +import { useTeamPositionsState } from "./hooks/useTeamPositionsState"; import "./styles.module.scss"; -import CandidatesStatusFilter from "./CandidatesStatusFilter"; -import { useAsync } from "react-use"; -import { - getAuthUserTokens, -} from "@topcoder/micro-frontends-navbar-app"; const PositionDetails = ({ teamId, positionId }) => { - const authUserTokens = useAsync(getAuthUserTokens); - const tokenV3 = authUserTokens.value ? authUserTokens.value.tokenV3 : null; const [candidateStatus, setCandidateStatus] = useState(CANDIDATE_STATUS.OPEN); - const [position, loadingError] = useData( - getPositionDetails, - tokenV3, - teamId, - positionId - ); + const { + state: { position, error }, + updateCandidate, + } = useTeamPositionsState(teamId, positionId); const onCandidateStatusChange = useCallback( (status) => { @@ -40,11 +32,11 @@ const PositionDetails = ({ teamId, positionId }) => { return ( {!position ? ( - + ) : ( <> { } /> )} @@ -69,4 +62,4 @@ PositionDetails.propTypes = { positionId: PT.string, }; -export default PositionDetails; +export default withAuthentication(PositionDetails); diff --git a/src/routes/PositionDetails/reducers/index.js b/src/routes/PositionDetails/reducers/index.js new file mode 100644 index 00000000..4b01961c --- /dev/null +++ b/src/routes/PositionDetails/reducers/index.js @@ -0,0 +1,95 @@ +/** + * Reducer for PositionDetails page + */ +import _ from "lodash"; +import update from "immutability-helper"; +import { ACTION_TYPE } from "constants"; + +const initialState = { + position: undefined, + loading: false, + error: undefined, +}; + +/** + * Patch candidate inside position state without state mutation + * + * @param {object} state state + * @param {string} candidateId candidate id + * @param {object} partialCandidateData partial candidate data + * + * @returns {object} new state + */ +const patchCandidateInState = (state, candidateId, partialCandidateData) => { + const candidateIndex = _.findIndex(_.get(state, "position.candidates"), { + id: candidateId, + }); + + if (candidateIndex === -1) { + return state; + } + + const updatedCandidate = update(state.position.candidates[candidateIndex], { + $merge: partialCandidateData, + }); + + return update(state, { + position: { + candidates: { + $splice: [[candidateIndex, 1, updatedCandidate]], + }, + }, + }); +}; + +const reducer = (state = initialState, action) => { + switch (action.type) { + case ACTION_TYPE.RESET_POSITION_STATE: + return initialState; + + case ACTION_TYPE.LOAD_POSITION_PENDING: + return { + ...state, + loading: true, + error: undefined, + }; + + case ACTION_TYPE.LOAD_POSITION_SUCCESS: + return { + ...state, + position: action.payload, + loading: false, + error: undefined, + }; + + case ACTION_TYPE.LOAD_POSITION_ERROR: + return { + ...state, + loading: false, + error: action.payload, + }; + + case ACTION_TYPE.UPDATE_CANDIDATE_PENDING: + return patchCandidateInState(state, action.meta.candidateId, { + updating: true, + error: undefined, + }); + + case ACTION_TYPE.UPDATE_CANDIDATE_SUCCESS: + return patchCandidateInState(state, action.meta.candidateId, { + updating: false, + ...action.payload, + }); + + case ACTION_TYPE.UPDATE_CANDIDATE_ERROR: + return patchCandidateInState(state, action.meta.candidateId, { + updating: false, + error: action.payload, + }); + + default: + return state; + } +}; + +export default reducer; diff --git a/src/services/requestInterceptor.js b/src/services/requestInterceptor.js new file mode 100644 index 00000000..ec369c2b --- /dev/null +++ b/src/services/requestInterceptor.js @@ -0,0 +1,21 @@ +import axios from "axios"; +import store from "../store"; +import { getAuthUserTokens } from "@topcoder/micro-frontends-navbar-app"; + +export const axiosInstance = axios.create({ + headers: { + "Content-Type": "application/json", + }, +}); + +// request interceptor to pass auth token +axiosInstance.interceptors.request.use((config) => { + return getAuthUserTokens() + .then(({ tokenV3: token }) => { + config.headers["Authorization"] = `Bearer ${token}`; + return config; + }) + .catch((err) => { + return config; + }); +}); diff --git a/src/services/teams.js b/src/services/teams.js index 95ffcf7b..7b8739c2 100644 --- a/src/services/teams.js +++ b/src/services/teams.js @@ -1,64 +1,59 @@ /** - * Topcoder Teams Service - * - * NOTE: It uses mock at the moment. + * Topcoder TaaS Service */ -import axios from "axios"; +import { axiosInstance as axios } from "./requestInterceptor"; import config from "../../config"; /** * Get my teams. - * - * @param {string} tokenV3 login token + * @param {string|number} name team name + * @param {number} page current page + * @param {number} perPage perPage * * @returns {Promise} list of teams */ -export const getMyTeams = (tokenV3) => { - if (!tokenV3) { - return Promise.resolve({ - data: null, - }); +export const getMyTeams = (name, page = 1, perPage) => { + let query = `page=${page}&perPage=${perPage}`; + if (name) { + query += `&name=*${name}*`; // wrap with asterisks to search by substrings } - return axios.get(`${config.API.V5}/taas-teams`, { - headers: { Authorization: `Bearer ${tokenV3}` }, - }); + + return axios.get(`${config.API.V5}/taas-teams?${query}`); }; /** * Get team by id. * - * @param {string} tokenV3 login token * @param {string|number} teamId team id * * @returns {Promise<{}>} team object */ -export const getTeamById = (tokenV3, teamId) => { - if (!tokenV3) { - return Promise.resolve({ - data: null, - }); - } - return axios.get(`${config.API.V5}/taas-teams/${teamId}`, { - headers: { Authorization: `Bearer ${tokenV3}` }, - }); +export const getTeamById = (teamId) => { + return axios.get(`${config.API.V5}/taas-teams/${teamId}`); }; /** * Get team position details. * - * @param {string} tokenV3 login token * @param {string|number} teamId team id * @param {string|number} positionId position id * * @returns {Promise} job object */ -export const getPositionDetails = (tokenV3, teamId, positionId) => { - if (!tokenV3) { - return Promise.resolve({ - data: null, - }); - } - return axios.get(`${config.API.V5}/taas-teams/${teamId}/jobs/${positionId}`, { - headers: { Authorization: `Bearer ${tokenV3}` }, - }); +export const getPositionDetails = (teamId, positionId) => { + return axios.get(`${config.API.V5}/taas-teams/${teamId}/jobs/${positionId}`); +}; + +/** + * Patch Position Candidate + * + * @param {string} candidateId position candidate id + * + * @returns {Promise} position candidate + */ +export const patchPositionCandidate = (candidateId, partialCandidateData) => { + return axios.patch( + `${config.API.V5}/jobCandidates/${candidateId}`, + partialCandidateData + ); }; diff --git a/src/store.js b/src/store.js new file mode 100644 index 00000000..9e1ae7a6 --- /dev/null +++ b/src/store.js @@ -0,0 +1,28 @@ +/* global process */ +/** + * Configure Redux Store + */ +import { createStore, applyMiddleware } from "redux"; +import { createLogger } from "redux-logger"; +import thunk from "redux-thunk"; +import { createPromise } from "redux-promise-middleware"; +import rootReducer from "./reducers"; + +const middlewares = [ + // if payload of action is promise it would split action into 3 states + createPromise({ + promiseTypeSuffixes: ["PENDING", "SUCCESS", "ERROR"], + }), + thunk, +]; + +// enable Redux Logger in in DEV environment +if (process.env.NODE_ENV === "development") { + const { createLogger } = require("redux-logger"); + const logger = createLogger(); + middlewares.push(logger); +} + +const store = createStore(rootReducer, applyMiddleware(...middlewares)); + +export default store; diff --git a/src/styles/main.module.scss b/src/styles/main.module.scss index cfc5164b..84a0cbc3 100644 --- a/src/styles/main.module.scss +++ b/src/styles/main.module.scss @@ -4,5 +4,5 @@ .topcoder-micro-frontends-teams-app { @include font-roboto; - color: #2A2A2A; + color: #2a2a2a; } diff --git a/src/styles/main.vendor.scss b/src/styles/main.vendor.scss new file mode 100644 index 00000000..2986d29d --- /dev/null +++ b/src/styles/main.vendor.scss @@ -0,0 +1,4 @@ +// This file can import 3rd parties css/scss files globally +// without applying CSS Modules + +@import "~react-redux-toastr/src/styles/index"; diff --git a/src/utils/format.js b/src/utils/format.js index 3f2ad1f0..edf98638 100644 --- a/src/utils/format.js +++ b/src/utils/format.js @@ -3,8 +3,9 @@ */ import _ from "lodash"; import { RATE_TYPE } from "constants"; -import { EMAIL_REPORT_ISSUE } from "../../config"; +import { EMAIL_REPORT_ISSUE, EMAIL_REQUEST_EXTENSION } from "../../config"; import moment from "moment"; +import { DAY_FORMAT } from "constants/"; /** * Formats number with base word in singular or plural form depend on the number. @@ -120,3 +121,31 @@ export const formatFullName = (firstName, lastName) => { export const formatReportIssueUrl = (subject) => { return `mailto:${EMAIL_REPORT_ISSUE}?subject=${encodeURIComponent(subject)}`; }; + +/** + * Format Request an Extension URL (mailto:) + * + * @param {string} subject email subject + * + * @returns {string} request an extension URL + */ +export const formatRequestExtensionUrl = (subject) => { + return `mailto:${EMAIL_REQUEST_EXTENSION}?subject=${encodeURIComponent( + subject + )}`; +}; + +/** + * Form date range + * + * @param {string|Date|moment.Moment} startDate start date + * @param {string|Date|moment.Moment} endDate end date + * + * @returns {string} formatted date range + */ +export const formatDateRange = (startDate, endDate) => { + const startDateStr = startDate ? moment(startDate).format(DAY_FORMAT) : ""; + const endDateStr = endDate ? moment(endDate).format(DAY_FORMAT) : ""; + + return `${startDateStr} - ${endDateStr}`; +};