From 848668bc3c9e7a1acb2749c9c2749c565a217105 Mon Sep 17 00:00:00 2001 From: maxceem <maxceem@gmail.com> Date: Thu, 17 Dec 2020 13:31:46 +0200 Subject: [PATCH 01/23] feat: shortlist/reject candidates ref issue #25 --- package-lock.json | 88 +++++++++++++++++ package.json | 9 +- src/constants/index.js | 17 ++++ src/reducers/index.js | 13 +++ src/root.component.jsx | 28 ++++-- src/routes/PositionDetails/actions/index.js | 64 ++++++++++++ .../CandidatesStatusFilter/index.jsx | 0 .../CandidatesStatusFilter/styles.module.scss | 0 .../PositionCandidates/index.jsx | 66 ++++++++++--- .../PositionCandidates/styles.module.scss | 0 .../hooks/useTeamPositionsState.js | 38 +++++++ src/routes/PositionDetails/index.jsx | 26 ++--- src/routes/PositionDetails/reducers/index.js | 99 +++++++++++++++++++ src/services/teams.js | 26 ++++- src/store.js | 28 ++++++ src/styles/main.module.scss | 2 +- src/styles/main.vendor.scss | 4 + 17 files changed, 468 insertions(+), 40 deletions(-) create mode 100644 src/reducers/index.js create mode 100644 src/routes/PositionDetails/actions/index.js rename src/routes/PositionDetails/{ => components}/CandidatesStatusFilter/index.jsx (100%) rename src/routes/PositionDetails/{ => components}/CandidatesStatusFilter/styles.module.scss (100%) rename src/routes/PositionDetails/{ => components}/PositionCandidates/index.jsx (73%) rename src/routes/PositionDetails/{ => components}/PositionCandidates/styles.module.scss (100%) create mode 100644 src/routes/PositionDetails/hooks/useTeamPositionsState.js create mode 100644 src/routes/PositionDetails/reducers/index.js create mode 100644 src/store.js create mode 100644 src/styles/main.vendor.scss diff --git a/package-lock.json b/package-lock.json index 9133be21..939b1bc4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5708,6 +5708,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 +7824,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 +8012,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 +14313,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 +14449,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 +16125,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/constants/index.js b/src/constants/index.js index 6ad60999..7e6e5784 100644 --- a/src/constants/index.js +++ b/src/constants/index.js @@ -121,3 +121,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/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..dd6bd948 100644 --- a/src/root.component.jsx +++ b/src/root.component.jsx @@ -1,18 +1,32 @@ import React from "react"; +import { Provider } from "react-redux"; import { Router } 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 ( - <div styleName="topcoder-micro-frontends-teams-app"> - <Router> - <MyTeamsList path="/taas/myteams" /> - <MyTeamsDetails path="/taas/myteams/:teamId" /> - <PositionDetails path="/taas/myteams/:teamId/positions/:positionId" /> - </Router> + <div className={styles['topcoder-micro-frontends-teams-app']}> + <Provider store={store}> + <Router> + <MyTeamsList path="/taas/myteams" /> + <MyTeamsDetails path="/taas/myteams/:teamId" /> + <PositionDetails path="/taas/myteams/:teamId/positions/:positionId" /> + </Router> + + {/* Global config for Toastr popups */} + <ReduxToastr + timeOut={4000} + position="bottom-left" + transitionIn="fadeIn" + transitionOut="fadeOut" + /> + </Provider> </div> ); } diff --git a/src/routes/PositionDetails/actions/index.js b/src/routes/PositionDetails/actions/index.js new file mode 100644 index 00000000..c5278a01 --- /dev/null +++ b/src/routes/PositionDetails/actions/index.js @@ -0,0 +1,64 @@ +/** + * Position Details page actions + */ +import { getPositionDetails, patchPositionCandidate } from "services/teams"; +import { getAuthUserTokens } from "@topcoder/micro-frontends-navbar-app"; +import { ACTION_TYPE } from "constants"; + +/** + * Load Team Position details (team job) + * + * @param {string} teamId team id + * @param {string} positionId position id + * + * @returns {Promise<any>} loaded data or error + */ +export const loadPosition = (teamId, positionId) => ({ + type: ACTION_TYPE.LOAD_POSITION, + payload: async () => { + const tokens = await getAuthUserTokens(); + const response = await getPositionDetails( + tokens.tokenV3, + 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 tokens = await getAuthUserTokens(); + const response = await patchPositionCandidate( + tokens.tokenV3, + 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 73% rename from src/routes/PositionDetails/PositionCandidates/index.jsx rename to src/routes/PositionDetails/components/PositionCandidates/index.jsx index 6f1a6407..3fde4647 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 @@ -45,6 +45,7 @@ const createSortCandidatesMethod = (sortBy) => (candidate1, candidate2) => { const PositionCandidates = ({ candidates, candidateStatus, + updateCandidate, }) => { const [sortBy, setSortBy] = useState(CANDIDATES_SORT_BY.SKILL_MATCHED); const filteredCandidates = useMemo( @@ -86,6 +87,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 ( <div styleName="position-candidates"> <CardHeader @@ -125,24 +162,31 @@ const PositionCandidates = ({ limit={7} /> {candidate.resumeLink && ( - <a - href={`${candidate.resumeLink}`} - styleName="resume-link" - > + <a href={`${candidate.resumeLink}`} styleName="resume-link"> <IconResume /> Download Resume </a> )} </div> <div styleName="table-cell cell-action"> - {candidateStatus === CANDIDATE_STATUS.SHORTLIST ? ( - <Button type="primary">Schedule Interview</Button> - ) : ( + {candidateStatus === CANDIDATE_STATUS.OPEN && ( <> Interested in this candidate? <div styleName="actions"> - <Button type="secondary">No</Button> - <Button type="primary">Yes</Button> + <Button + type="secondary" + onClick={() => markCandidateRejected(candidate.id)} + disabled={candidate.updating} + > + No + </Button> + <Button + type="primary" + onClick={() => markCandidateShortlisted(candidate.id)} + disabled={candidate.updating} + > + Yes + </Button> </div> </> )} 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..6095d3f6 --- /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..b67e0b1b 100644 --- a/src/routes/PositionDetails/index.jsx +++ b/src/routes/PositionDetails/index.jsx @@ -9,26 +9,17 @@ 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 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,7 +31,7 @@ const PositionDetails = ({ teamId, positionId }) => { return ( <LayoutContainer> {!position ? ( - <LoadingIndicator error={loadingError && loadingError.toString()} /> + <LoadingIndicator error={error} /> ) : ( <> <PageHeader @@ -57,6 +48,7 @@ const PositionDetails = ({ teamId, positionId }) => { <PositionCandidates candidates={position.candidates} candidateStatus={candidateStatus} + updateCandidate={updateCandidate} /> </> )} diff --git a/src/routes/PositionDetails/reducers/index.js b/src/routes/PositionDetails/reducers/index.js new file mode 100644 index 00000000..f522c503 --- /dev/null +++ b/src/routes/PositionDetails/reducers/index.js @@ -0,0 +1,99 @@ +/** + * 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/teams.js b/src/services/teams.js index 95ffcf7b..65e158bb 100644 --- a/src/services/teams.js +++ b/src/services/teams.js @@ -1,7 +1,5 @@ /** - * Topcoder Teams Service - * - * NOTE: It uses mock at the moment. + * Topcoder TaaS Service */ import axios from "axios"; import config from "../../config"; @@ -62,3 +60,25 @@ export const getPositionDetails = (tokenV3, teamId, positionId) => { headers: { Authorization: `Bearer ${tokenV3}` }, }); }; + +/** + * Patch Position Candidate + * + * @param {string} tokenV3 login token + * @param {string} candidateId position candidate id + * + * @returns {Promise<object{}>} position candidate + */ +export const patchPositionCandidate = ( + tokenV3, + candidateId, + partialCandidateData +) => { + return axios.patch( + `${config.API.V5}/jobCandidates/${candidateId}`, + partialCandidateData, + { + headers: { Authorization: `Bearer ${tokenV3}` }, + } + ); +}; 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"; From 22ce5509c5c78ccd148ae52b9a249fca76fc188e Mon Sep 17 00:00:00 2001 From: maxceem <maxceem@gmail.com> Date: Thu, 17 Dec 2020 14:01:42 +0200 Subject: [PATCH 02/23] docs: updated "Technology Stack" --- README.md | 2 ++ 1 file changed, 2 insertions(+) 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 From 0a9cda2dedf55c525371b96dcf97fc9a737015b9 Mon Sep 17 00:00:00 2001 From: maxceem <maxceem@gmail.com> Date: Thu, 17 Dec 2020 14:12:23 +0200 Subject: [PATCH 03/23] feat: "Request an Extension" action ref issue #26 --- config/development.js | 6 ++++++ config/production.js | 6 ++++++ .../MyTeamsDetails/components/TeamMembers/index.jsx | 13 +++++++++++-- .../MyTeamsList/components/TeamCard/index.jsx | 6 ------ src/utils/format.js | 13 ++++++++++++- 5 files changed, 35 insertions(+), 9 deletions(-) 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/src/routes/MyTeamsDetails/components/TeamMembers/index.jsx b/src/routes/MyTeamsDetails/components/TeamMembers/index.jsx index 26d8488b..6f325f38 100644 --- a/src/routes/MyTeamsDetails/components/TeamMembers/index.jsx +++ b/src/routes/MyTeamsDetails/components/TeamMembers/index.jsx @@ -16,7 +16,7 @@ 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 { formatMoney, formatReportIssueUrl, formatRequestExtensionUrl } from "utils/format"; import Input from "components/Input"; import { skillShape } from "components/SkillsList"; @@ -153,7 +153,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}` + ) + ); + }, + }, ]} /> </div> diff --git a/src/routes/MyTeamsList/components/TeamCard/index.jsx b/src/routes/MyTeamsList/components/TeamCard/index.jsx index d8ea8b52..6c360719 100644 --- a/src/routes/MyTeamsList/components/TeamCard/index.jsx +++ b/src/routes/MyTeamsList/components/TeamCard/index.jsx @@ -27,12 +27,6 @@ const TeamCard = ({ team }) => { <div styleName="three-dots-menu"> <ThreeDotsMenu options={[ - { label: "Team Feedback", action: () => {} }, - { label: "Team Invoices", action: () => {} }, - { label: "Team Reports", action: () => {} }, - { separator: true }, - { label: "Add Team Member", action: () => {} }, - { separator: true }, { label: "Report an Issue", action: () => { diff --git a/src/utils/format.js b/src/utils/format.js index 3f2ad1f0..fef9e488 100644 --- a/src/utils/format.js +++ b/src/utils/format.js @@ -3,7 +3,7 @@ */ 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"; /** @@ -120,3 +120,14 @@ 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)}`; +}; From 3aa1bf4d565a16b5007125179743b49a326e226d Mon Sep 17 00:00:00 2001 From: maxceem <maxceem@gmail.com> Date: Fri, 25 Dec 2020 17:19:15 +0200 Subject: [PATCH 04/23] fix: skillMatched don't use skillMatched from the server and calculate it client-side correctly --- src/components/SkillsList/index.jsx | 8 +--- src/components/SkillsSummary/index.jsx | 20 +++++----- src/constants/index.js | 2 +- .../components/TeamMembers/index.jsx | 10 +++-- .../components/PositionCandidates/index.jsx | 37 +++++++++++++------ src/routes/PositionDetails/index.jsx | 2 +- 6 files changed, 46 insertions(+), 33 deletions(-) diff --git a/src/components/SkillsList/index.jsx b/src/components/SkillsList/index.jsx index 5718f1e8..da11f1a7 100644 --- a/src/components/SkillsList/index.jsx +++ b/src/components/SkillsList/index.jsx @@ -2,7 +2,6 @@ * 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 PT from "prop-types"; @@ -13,11 +12,7 @@ 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 = ({ skills, limit = 3 }) => { const skillsToShow = skills.slice(0, limit); const skillsToHide = skills.slice(limit); @@ -124,7 +119,6 @@ export const skillShape = PT.shape({ SkillsList.propTypes = { skills: 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..33faaaf3 100644 --- a/src/components/SkillsSummary/index.jsx +++ b/src/components/SkillsSummary/index.jsx @@ -10,18 +10,20 @@ 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 ( <div> <div styleName="percentage"> - <PercentageBar ratio={skillMatched / 100} styleName="percentage-bar" /> - {Math.round(skillMatched)}% skill matched + <PercentageBar ratio={skillsMatchedRatio} styleName="percentage-bar" /> + {Math.round(skillsMatchedRatio * 100)}% skill matched </div> - <SkillsList - skills={skills} - limit={limit} - showMatches - /> + <SkillsList skills={skills} limit={limit} /> </div> ); }; @@ -33,8 +35,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 7e6e5784..5237b12e 100644 --- a/src/constants/index.js +++ b/src/constants/index.js @@ -110,7 +110,7 @@ export const CANDIDATE_STATUS_FILTERS = [ * Candidates "sort by" values */ export const CANDIDATES_SORT_BY = { - SKILL_MATCHED: "skillMatched", + SKILL_MATCHED: "skillsMatched", HANDLE: "handle", }; diff --git a/src/routes/MyTeamsDetails/components/TeamMembers/index.jsx b/src/routes/MyTeamsDetails/components/TeamMembers/index.jsx index 6f325f38..83b55e31 100644 --- a/src/routes/MyTeamsDetails/components/TeamMembers/index.jsx +++ b/src/routes/MyTeamsDetails/components/TeamMembers/index.jsx @@ -16,7 +16,11 @@ 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, formatRequestExtensionUrl } from "utils/format"; +import { + formatMoney, + formatReportIssueUrl, + formatRequestExtensionUrl, +} from "utils/format"; import Input from "components/Input"; import { skillShape } from "components/SkillsList"; @@ -132,7 +136,7 @@ const TeamMembers = ({ team }) => { <div styleName="table-cell cell-skills"> <SkillsSummary skills={member.skills} - skillMatched={member.skillMatched} + requiredSkills={member.job.skills} /> </div> <div styleName="table-group-second-inner"> @@ -206,7 +210,7 @@ TeamMembers.propTypes = { firstName: PT.string, lastName: PT.string, skills: PT.arrayOf(skillShape), - skillMatched: PT.number, + skillsMatched: PT.number, }) ), }), diff --git a/src/routes/PositionDetails/components/PositionCandidates/index.jsx b/src/routes/PositionDetails/components/PositionCandidates/index.jsx index 3fde4647..24e1248a 100644 --- a/src/routes/PositionDetails/components/PositionCandidates/index.jsx +++ b/src/routes/PositionDetails/components/PositionCandidates/index.jsx @@ -33,7 +33,7 @@ import { toastr } from "react-redux-toastr"; 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,18 +42,31 @@ const createSortCandidatesMethod = (sortBy) => (candidate1, candidate2) => { } }; -const PositionCandidates = ({ - candidates, - candidateStatus, - updateCandidate, -}) => { +/** + * 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( @@ -158,7 +171,7 @@ const PositionCandidates = ({ <div styleName="table-cell cell-skills"> <SkillsSummary skills={candidate.skills} - skillMatched={candidate.skillMatched} + requiredSkills={position.skills} limit={7} /> {candidate.resumeLink && ( @@ -221,7 +234,7 @@ const PositionCandidates = ({ }; PositionCandidates.propType = { - candidates: PT.array, + position: PT.object, candidateStatus: PT.oneOf(Object.values(CANDIDATE_STATUS)), }; diff --git a/src/routes/PositionDetails/index.jsx b/src/routes/PositionDetails/index.jsx index b67e0b1b..13594078 100644 --- a/src/routes/PositionDetails/index.jsx +++ b/src/routes/PositionDetails/index.jsx @@ -46,7 +46,7 @@ const PositionDetails = ({ teamId, positionId }) => { } /> <PositionCandidates - candidates={position.candidates} + position={position} candidateStatus={candidateStatus} updateCandidate={updateCandidate} /> From 2f87b4182fddacd5748e77da1281bf7d41de55a9 Mon Sep 17 00:00:00 2001 From: maxceem <maxceem@gmail.com> Date: Fri, 25 Dec 2020 17:28:35 +0200 Subject: [PATCH 05/23] fix: filter resources by role and full name --- .../components/TeamMembers/index.jsx | 59 ++++++++++--------- 1 file changed, 30 insertions(+), 29 deletions(-) diff --git a/src/routes/MyTeamsDetails/components/TeamMembers/index.jsx b/src/routes/MyTeamsDetails/components/TeamMembers/index.jsx index 83b55e31..88b850f1 100644 --- a/src/routes/MyTeamsDetails/components/TeamMembers/index.jsx +++ b/src/routes/MyTeamsDetails/components/TeamMembers/index.jsx @@ -30,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.description, + ..._.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); @@ -76,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( @@ -101,10 +102,10 @@ const TeamMembers = ({ team }) => { } /> {resources.length === 0 && <div styleName="no-members">No members</div>} - {resources.length > 0 && filteredMembersWithJobs.length === 0 && ( + {resources.length > 0 && filteredMembers.length === 0 && ( <div styleName="no-members">No members matching filter</div> )} - {filteredMembersWithJobs.length > 0 && ( + {filteredMembers.length > 0 && ( <div styleName="table"> {pageMembers.map((member) => ( <div styleName="table-row" key={member.id}> @@ -181,15 +182,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 </Button> - {filteredMembersWithJobs.length > 0 && ( + {filteredMembers.length > 0 && ( <Pagination - total={filteredMembersWithJobs.length} + total={filteredMembers.length} currentPage={page} perPage={perPage} onPageClick={onPageClick} From 6b05bfd6401985db56f4cfbfefcbcb4d0b051434 Mon Sep 17 00:00:00 2001 From: yoution <zhuyan207@gmail.com> Date: Wed, 30 Dec 2020 20:29:03 +0800 Subject: [PATCH 06/23] fix: issue #16 --- package-lock.json | 7 +++ package.json | 1 + src/routes/MyTeamsDetails/index.jsx | 7 +-- src/routes/MyTeamsList/index.jsx | 7 +-- src/routes/PositionDetails/actions/index.js | 12 +---- src/services/requestInterceptor.js | 43 +++++++++++++++++ src/services/teams.js | 52 ++++----------------- 7 files changed, 65 insertions(+), 64 deletions(-) create mode 100644 src/services/requestInterceptor.js diff --git a/package-lock.json b/package-lock.json index 939b1bc4..dc06ef85 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2725,6 +2725,13 @@ "@types/testing-library__react": "^9.1.2" } }, + "@topcoder-platform/tc-auth-lib": { + "version": "git+https://github.com/topcoder-platform/tc-auth-lib.git#68fdc22464810c51b703a33e529cdbd6d09437de", + "from": "git+https://github.com/topcoder-platform/tc-auth-lib.git#1.0.4", + "requires": { + "lodash": "^4.17.19" + } + }, "@types/anymatch": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/@types/anymatch/-/anymatch-1.3.1.tgz", diff --git a/package.json b/package.json index 3a16e2cc..872ec191 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "dependencies": { "@popperjs/core": "^2.5.4", "@reach/router": "^1.3.4", + "@topcoder-platform/tc-auth-lib": "git+https://github.com/topcoder-platform/tc-auth-lib.git#1.0.4", "axios": "^0.21.0", "classnames": "^2.2.6", "express": "^4.17.1", diff --git a/src/routes/MyTeamsDetails/index.jsx b/src/routes/MyTeamsDetails/index.jsx index 51f124d3..2f0d5c75 100644 --- a/src/routes/MyTeamsDetails/index.jsx +++ b/src/routes/MyTeamsDetails/index.jsx @@ -15,14 +15,9 @@ import TeamSummary from "./components/TeamSummary"; import TeamMembers from "./components/TeamMembers"; import TeamPositions from "./components/TeamPositions"; 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 ( <LayoutContainer> diff --git a/src/routes/MyTeamsList/index.jsx b/src/routes/MyTeamsList/index.jsx index 819f97c5..dfb6d320 100644 --- a/src/routes/MyTeamsList/index.jsx +++ b/src/routes/MyTeamsList/index.jsx @@ -12,14 +12,9 @@ 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"; const MyTeamsList = () => { - const authUserTokens = useAsync(getAuthUserTokens); - const tokenV3 = authUserTokens.value ? authUserTokens.value.tokenV3 : null; - const [myTeams, loadingError] = useData(getMyTeams, tokenV3); + const [myTeams, loadingError] = useData(getMyTeams); return ( <LayoutContainer> diff --git a/src/routes/PositionDetails/actions/index.js b/src/routes/PositionDetails/actions/index.js index c5278a01..e7fdf31a 100644 --- a/src/routes/PositionDetails/actions/index.js +++ b/src/routes/PositionDetails/actions/index.js @@ -2,7 +2,6 @@ * Position Details page actions */ import { getPositionDetails, patchPositionCandidate } from "services/teams"; -import { getAuthUserTokens } from "@topcoder/micro-frontends-navbar-app"; import { ACTION_TYPE } from "constants"; /** @@ -16,12 +15,7 @@ import { ACTION_TYPE } from "constants"; export const loadPosition = (teamId, positionId) => ({ type: ACTION_TYPE.LOAD_POSITION, payload: async () => { - const tokens = await getAuthUserTokens(); - const response = await getPositionDetails( - tokens.tokenV3, - teamId, - positionId - ); + const response = await getPositionDetails(teamId, positionId); return response.data; }, @@ -42,9 +36,7 @@ export const loadPosition = (teamId, positionId) => ({ export const updateCandidate = (candidateId, partialCandidateData) => ({ type: ACTION_TYPE.UPDATE_CANDIDATE, payload: async () => { - const tokens = await getAuthUserTokens(); const response = await patchPositionCandidate( - tokens.tokenV3, candidateId, partialCandidateData ); @@ -61,4 +53,4 @@ export const updateCandidate = (candidateId, partialCandidateData) => ({ */ export const resetPositionState = () => ({ type: ACTION_TYPE.RESET_POSITION_STATE, -}) +}); diff --git a/src/services/requestInterceptor.js b/src/services/requestInterceptor.js new file mode 100644 index 00000000..8adcb1f2 --- /dev/null +++ b/src/services/requestInterceptor.js @@ -0,0 +1,43 @@ +import axios from "axios"; +import store from "../store"; +import { getFreshToken, isTokenExpired } from "@topcoder-platform/tc-auth-lib"; +import { getAuthUserTokens } from "@topcoder/micro-frontends-navbar-app"; + +export const getToken = () => { + return new Promise(async (resolve, reject) => { + const authUserTokens = await getAuthUserTokens(); + const token = authUserTokens ? authUserTokens.tokenV3 : null; + if (token && !isTokenExpired(token)) { + return resolve(token); + } else { + return getFreshToken() + .then((token) => { + resolve(token); + }) + .catch((err) => { + console.log(err); + reject(err); + }); + } + }); +}; + +export const axiosInstance = axios.create({ + headers: { + "Content-Type": "application/json", + }, +}); + +// request interceptor to pass auth token +axiosInstance.interceptors.request.use((config) => { + return getToken() + .then((token) => { + config.headers["Authorization"] = `Bearer ${token}`; + return config; + }) + .catch((err) => { + // TODO handle this error somehow + console.log(err); + return config; + }); +}); diff --git a/src/services/teams.js b/src/services/teams.js index 65e158bb..eebc6c24 100644 --- a/src/services/teams.js +++ b/src/services/teams.js @@ -1,84 +1,52 @@ /** * 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 - * * @returns {Promise<object[]>} list of teams */ -export const getMyTeams = (tokenV3) => { - if (!tokenV3) { - return Promise.resolve({ - data: null, - }); - } - return axios.get(`${config.API.V5}/taas-teams`, { - headers: { Authorization: `Bearer ${tokenV3}` }, - }); +export const getMyTeams = () => { + debugger; + return axios.get(`${config.API.V5}/taas-teams`); }; /** * 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<object{}>} 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} tokenV3 login token * @param {string} candidateId position candidate id * * @returns {Promise<object{}>} position candidate */ -export const patchPositionCandidate = ( - tokenV3, - candidateId, - partialCandidateData -) => { +export const patchPositionCandidate = (candidateId, partialCandidateData) => { return axios.patch( `${config.API.V5}/jobCandidates/${candidateId}`, - partialCandidateData, - { - headers: { Authorization: `Bearer ${tokenV3}` }, - } + partialCandidateData ); }; From 5181c3f9a95bc160b44d148754b13f840bdc06d6 Mon Sep 17 00:00:00 2001 From: yoution <zhuyan207@gmail.com> Date: Sun, 3 Jan 2021 17:09:45 +0800 Subject: [PATCH 07/23] fix: fix lint error #39 --- package-lock.json | 7 ----- package.json | 1 - .../hooks/useTeamPositionsState.js | 2 +- src/routes/PositionDetails/reducers/index.js | 14 ++++------ src/services/requestInterceptor.js | 26 ++++++------------- src/services/teams.js | 1 - src/utils/format.js | 4 ++- 7 files changed, 17 insertions(+), 38 deletions(-) diff --git a/package-lock.json b/package-lock.json index dc06ef85..939b1bc4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2725,13 +2725,6 @@ "@types/testing-library__react": "^9.1.2" } }, - "@topcoder-platform/tc-auth-lib": { - "version": "git+https://github.com/topcoder-platform/tc-auth-lib.git#68fdc22464810c51b703a33e529cdbd6d09437de", - "from": "git+https://github.com/topcoder-platform/tc-auth-lib.git#1.0.4", - "requires": { - "lodash": "^4.17.19" - } - }, "@types/anymatch": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/@types/anymatch/-/anymatch-1.3.1.tgz", diff --git a/package.json b/package.json index 872ec191..3a16e2cc 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,6 @@ "dependencies": { "@popperjs/core": "^2.5.4", "@reach/router": "^1.3.4", - "@topcoder-platform/tc-auth-lib": "git+https://github.com/topcoder-platform/tc-auth-lib.git#1.0.4", "axios": "^0.21.0", "classnames": "^2.2.6", "express": "^4.17.1", diff --git a/src/routes/PositionDetails/hooks/useTeamPositionsState.js b/src/routes/PositionDetails/hooks/useTeamPositionsState.js index 6095d3f6..2f196abb 100644 --- a/src/routes/PositionDetails/hooks/useTeamPositionsState.js +++ b/src/routes/PositionDetails/hooks/useTeamPositionsState.js @@ -24,7 +24,7 @@ export const useTeamPositionsState = (teamId, positionId) => { // clear state when we leave the page return () => { dispatch(resetPositionState()); - } + }; }, [dispatch, teamId, positionId]); // bind actions to dispatch method diff --git a/src/routes/PositionDetails/reducers/index.js b/src/routes/PositionDetails/reducers/index.js index f522c503..4b01961c 100644 --- a/src/routes/PositionDetails/reducers/index.js +++ b/src/routes/PositionDetails/reducers/index.js @@ -76,14 +76,10 @@ const reducer = (state = initialState, action) => { }); case ACTION_TYPE.UPDATE_CANDIDATE_SUCCESS: - return patchCandidateInState( - state, - action.meta.candidateId, - { - updating: false, - ...action.payload, - } - ); + return patchCandidateInState(state, action.meta.candidateId, { + updating: false, + ...action.payload, + }); case ACTION_TYPE.UPDATE_CANDIDATE_ERROR: return patchCandidateInState(state, action.meta.candidateId, { @@ -92,7 +88,7 @@ const reducer = (state = initialState, action) => { }); default: - return state + return state; } }; diff --git a/src/services/requestInterceptor.js b/src/services/requestInterceptor.js index 8adcb1f2..471255f4 100644 --- a/src/services/requestInterceptor.js +++ b/src/services/requestInterceptor.js @@ -1,24 +1,16 @@ import axios from "axios"; import store from "../store"; -import { getFreshToken, isTokenExpired } from "@topcoder-platform/tc-auth-lib"; import { getAuthUserTokens } from "@topcoder/micro-frontends-navbar-app"; export const getToken = () => { - return new Promise(async (resolve, reject) => { - const authUserTokens = await getAuthUserTokens(); - const token = authUserTokens ? authUserTokens.tokenV3 : null; - if (token && !isTokenExpired(token)) { - return resolve(token); - } else { - return getFreshToken() - .then((token) => { - resolve(token); - }) - .catch((err) => { - console.log(err); - reject(err); - }); - } + return new Promise((resolve, reject) => { + return getAuthUserTokens() + .then(({ tokenV3: token }) => { + return resolve(token); + }) + .catch((err) => { + reject(err); + }); }); }; @@ -36,8 +28,6 @@ axiosInstance.interceptors.request.use((config) => { return config; }) .catch((err) => { - // TODO handle this error somehow - console.log(err); return config; }); }); diff --git a/src/services/teams.js b/src/services/teams.js index eebc6c24..5278705c 100644 --- a/src/services/teams.js +++ b/src/services/teams.js @@ -10,7 +10,6 @@ import config from "../../config"; * @returns {Promise<object[]>} list of teams */ export const getMyTeams = () => { - debugger; return axios.get(`${config.API.V5}/taas-teams`); }; diff --git a/src/utils/format.js b/src/utils/format.js index fef9e488..c6786d00 100644 --- a/src/utils/format.js +++ b/src/utils/format.js @@ -129,5 +129,7 @@ export const formatReportIssueUrl = (subject) => { * @returns {string} request an extension URL */ export const formatRequestExtensionUrl = (subject) => { - return `mailto:${EMAIL_REQUEST_EXTENSION}?subject=${encodeURIComponent(subject)}`; + return `mailto:${EMAIL_REQUEST_EXTENSION}?subject=${encodeURIComponent( + subject + )}`; }; From 5596aadef097194aa82d1ff25255287ffc169daf Mon Sep 17 00:00:00 2001 From: yoution <zhuyan207@gmail.com> Date: Sun, 3 Jan 2021 19:20:14 +0800 Subject: [PATCH 08/23] fix: issue #37 --- package-lock.json | 5 ++ package.json | 4 +- src/constants/index.js | 5 ++ src/routes/MyTeamsList/index.jsx | 72 ++++++++++++++++++++--- src/routes/MyTeamsList/styles.module.scss | 33 +++++++++++ src/services/teams.js | 12 +++- 6 files changed, 119 insertions(+), 12 deletions(-) create mode 100644 src/routes/MyTeamsList/styles.module.scss diff --git a/package-lock.json b/package-lock.json index 939b1bc4..8563ac4d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16700,6 +16700,11 @@ "integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==", "dev": true }, + "use-debounce": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-5.2.0.tgz", + "integrity": "sha512-lW4tbPsTnvPKYqOYXp5xZ7SP7No/ARLqqQqoyRKuSzP0HxR9arhSAhznXUZFoNPWDRij8fog+N6sYbjb8c3kzw==" + }, "util": { "version": "0.11.1", "resolved": "https://registry.npmjs.org/util/-/util-0.11.1.tgz", diff --git a/package.json b/package.json index 3a16e2cc..bfd4a751 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "dev": "webpack-dev-server --port 8501 --host 0.0.0.0", "dev-https": "webpack-dev-server --https --port 8501 --host 0.0.0.0", "build": "webpack --mode=${APPMODE:-development} --env.config=${APPENV:-dev}", + "build:prod": "webpack --mode=${APPMODE:-production} --env.config=${APPENV:-prod}", "analyze": "webpack --mode=production --env.analyze=true", "lint": "eslint src --ext js", "format": "prettier --write \"./**\"", @@ -74,7 +75,8 @@ "redux": "^4.0.5", "redux-logger": "^3.0.6", "redux-promise-middleware": "^6.1.2", - "redux-thunk": "^2.3.0" + "redux-thunk": "^2.3.0", + "use-debounce": "^5.2.0" }, "browserslist": [ "last 1 version", diff --git a/src/constants/index.js b/src/constants/index.js index 5237b12e..98a0c5c4 100644 --- a/src/constants/index.js +++ b/src/constants/index.js @@ -12,6 +12,11 @@ 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 */ diff --git a/src/routes/MyTeamsList/index.jsx b/src/routes/MyTeamsList/index.jsx index dfb6d320..7f7a3513 100644 --- a/src/routes/MyTeamsList/index.jsx +++ b/src/routes/MyTeamsList/index.jsx @@ -3,30 +3,84 @@ * * 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 { useDebouncedCallback } from "use-debounce"; 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 { TEAMS_PER_PAGE } from "constants"; +import "./styles.module.scss"; const MyTeamsList = () => { - const [myTeams, loadingError] = useData(getMyTeams); + // let [myTeams, loadingError] = useData(getMyTeams); + let [myTeams, setMyTeams] = useState(); + const [filter, setFilter] = useState(""); + const [loadingError, setLoadingError] = useState(false); + const [page, setPage] = useState(1); + const [total, setTotal] = useState(0); + + const onFilterChange = useDebouncedCallback((value) => { + setFilter(value); + setPage(1); + }, 200); + + useEffect(() => { + getMyTeams(filter, page, TEAMS_PER_PAGE) + .then((response) => { + setMyTeams(response.data); + setTotal(response.headers["x-total"]); + }) + .catch((responseError) => { + setLoadingError(responseError); + }); + }, [filter, page]); + + const onPageClick = useCallback( + (newPage) => { + setPage(newPage); + }, + [setPage] + ); return ( <LayoutContainer> - <PageHeader title="My Teams" /> + <PageHeader + title="My Teams" + aside={ + <Input + placeholder="Filter by team name" + styleName="filter-input" + onChange={(e) => onFilterChange.callback(e.target.value)} + /> + } + /> {!myTeams ? ( <LoadingIndicator error={loadingError && loadingError.toString()} /> ) : ( - <TeamCardGrid> - {myTeams.map((team) => ( - <TeamCard key={team.id} team={team} /> - ))} - </TeamCardGrid> + <> + <TeamCardGrid> + {myTeams.map((team) => ( + <TeamCard key={team.id} team={team} /> + ))} + </TeamCardGrid> + {myTeams.length > 0 && ( + <div styleName="pagination-wrapper"> + <Pagination + total={total} + currentPage={page} + perPage={TEAMS_PER_PAGE} + onPageClick={onPageClick} + /> + </div> + )} + </> )} </LayoutContainer> ); diff --git a/src/routes/MyTeamsList/styles.module.scss b/src/routes/MyTeamsList/styles.module.scss new file mode 100644 index 00000000..5d77d644 --- /dev/null +++ b/src/routes/MyTeamsList/styles.module.scss @@ -0,0 +1,33 @@ +@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; + } +} + + +.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/services/teams.js b/src/services/teams.js index 5278705c..b9121c46 100644 --- a/src/services/teams.js +++ b/src/services/teams.js @@ -6,11 +6,19 @@ import config from "../../config"; /** * Get my teams. + * @param {string|number} name team name + * @param {number} page current page + * @param {number} perPage perPage * * @returns {Promise<object[]>} list of teams */ -export const getMyTeams = () => { - return axios.get(`${config.API.V5}/taas-teams`); +export const getMyTeams = (name, page = 1, perPage) => { + let query = `page=${page}&perPage=${perPage}`; + if (name) { + query += `&name=${name}`; + } + + return axios.get(`${config.API.V5}/taas-teams?${query}`); }; /** From b924dc839835257af1d13e35d3dc33d2740a59de Mon Sep 17 00:00:00 2001 From: yoution <zhuyan207@gmail.com> Date: Tue, 5 Jan 2021 14:31:53 +0800 Subject: [PATCH 09/23] fix: #36 --- src/components/SkillsList/index.jsx | 38 ++++++++++++++++--- src/components/SkillsSummary/index.jsx | 2 +- src/routes/PositionDetails/actions/index.js | 2 +- .../hooks/useTeamPositionsState.js | 2 +- src/routes/PositionDetails/reducers/index.js | 14 +++---- src/utils/format.js | 4 +- 6 files changed, 44 insertions(+), 18 deletions(-) diff --git a/src/components/SkillsList/index.jsx b/src/components/SkillsList/index.jsx index da11f1a7..6f326aca 100644 --- a/src/components/SkillsList/index.jsx +++ b/src/components/SkillsList/index.jsx @@ -3,7 +3,7 @@ * * Shows list of skills with "N more" link which is showing tooltip with a full list of 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"; @@ -12,13 +12,22 @@ 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 }) => { +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: [ @@ -91,11 +100,29 @@ const SkillsList = ({ skills, limit = 3 }) => { {...attributes.popper} > <div styleName="popover-content"> - {skills && ( + {requiredSkills && ( + <div styleName="skills-section"> + <div styleName="skills-title">Required Job Skills</div> + <ul styleName="skills-list"> + {requiredSkills.map((skill) => ( + <li key={skill.id}> + {showMatches && + (_.find(skills, { id: skill.id }) ? ( + <IconCheck /> + ) : ( + <IconCross /> + ))}{" "} + {skill.name} + </li> + ))} + </ul> + </div> + )} + {otherSkills && ( <div styleName="skills-section"> - <div styleName="skills-title">Skills</div> + <div styleName="skills-title">{showMatches ? 'Other User Skills': 'Required Skills'}</div> <ul styleName="skills-list"> - {skills.map((skill) => ( + {otherSkills.map((skill) => ( <li key={skill.id}>{skill.name}</li> ))} </ul> @@ -118,6 +145,7 @@ export const skillShape = PT.shape({ SkillsList.propTypes = { skills: PT.arrayOf(skillShape), + requiredSkills: PT.arrayOf(skillShape), limit: PT.number, }; diff --git a/src/components/SkillsSummary/index.jsx b/src/components/SkillsSummary/index.jsx index 33faaaf3..c20af3b9 100644 --- a/src/components/SkillsSummary/index.jsx +++ b/src/components/SkillsSummary/index.jsx @@ -23,7 +23,7 @@ const SkillsSummary = ({ skills, requiredSkills = [], limit }) => { <PercentageBar ratio={skillsMatchedRatio} styleName="percentage-bar" /> {Math.round(skillsMatchedRatio * 100)}% skill matched </div> - <SkillsList skills={skills} limit={limit} /> + <SkillsList skills={skills} requiredSkills={requiredSkills} limit={limit} /> </div> ); }; diff --git a/src/routes/PositionDetails/actions/index.js b/src/routes/PositionDetails/actions/index.js index c5278a01..0cd73876 100644 --- a/src/routes/PositionDetails/actions/index.js +++ b/src/routes/PositionDetails/actions/index.js @@ -61,4 +61,4 @@ export const updateCandidate = (candidateId, partialCandidateData) => ({ */ export const resetPositionState = () => ({ type: ACTION_TYPE.RESET_POSITION_STATE, -}) +}); diff --git a/src/routes/PositionDetails/hooks/useTeamPositionsState.js b/src/routes/PositionDetails/hooks/useTeamPositionsState.js index 6095d3f6..2f196abb 100644 --- a/src/routes/PositionDetails/hooks/useTeamPositionsState.js +++ b/src/routes/PositionDetails/hooks/useTeamPositionsState.js @@ -24,7 +24,7 @@ export const useTeamPositionsState = (teamId, positionId) => { // clear state when we leave the page return () => { dispatch(resetPositionState()); - } + }; }, [dispatch, teamId, positionId]); // bind actions to dispatch method diff --git a/src/routes/PositionDetails/reducers/index.js b/src/routes/PositionDetails/reducers/index.js index f522c503..4b01961c 100644 --- a/src/routes/PositionDetails/reducers/index.js +++ b/src/routes/PositionDetails/reducers/index.js @@ -76,14 +76,10 @@ const reducer = (state = initialState, action) => { }); case ACTION_TYPE.UPDATE_CANDIDATE_SUCCESS: - return patchCandidateInState( - state, - action.meta.candidateId, - { - updating: false, - ...action.payload, - } - ); + return patchCandidateInState(state, action.meta.candidateId, { + updating: false, + ...action.payload, + }); case ACTION_TYPE.UPDATE_CANDIDATE_ERROR: return patchCandidateInState(state, action.meta.candidateId, { @@ -92,7 +88,7 @@ const reducer = (state = initialState, action) => { }); default: - return state + return state; } }; diff --git a/src/utils/format.js b/src/utils/format.js index fef9e488..c6786d00 100644 --- a/src/utils/format.js +++ b/src/utils/format.js @@ -129,5 +129,7 @@ export const formatReportIssueUrl = (subject) => { * @returns {string} request an extension URL */ export const formatRequestExtensionUrl = (subject) => { - return `mailto:${EMAIL_REQUEST_EXTENSION}?subject=${encodeURIComponent(subject)}`; + return `mailto:${EMAIL_REQUEST_EXTENSION}?subject=${encodeURIComponent( + subject + )}`; }; From fcbf5d2100dd95198dc315db918d618264f1a580 Mon Sep 17 00:00:00 2001 From: maxceem <maxceem@gmail.com> Date: Wed, 6 Jan 2021 10:06:39 +0200 Subject: [PATCH 10/23] refactor: improve skills component --- src/components/SkillsList/index.jsx | 28 +++++++++++++--------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/src/components/SkillsList/index.jsx b/src/components/SkillsList/index.jsx index 6f326aca..edc64219 100644 --- a/src/components/SkillsList/index.jsx +++ b/src/components/SkillsList/index.jsx @@ -12,22 +12,19 @@ import IconCross from "../../assets/images/icon-cross.svg"; import { usePopper } from "react-popper"; import OutsideClickHandler from "react-outside-click-handler"; -const SkillsList = ({requiredSkills, skills, limit = 3 }) => { +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 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 otherSkills = useMemo(() => { + return _.differenceBy(skills, requiredSkills, "id"); + }, [requiredSkills, skills]); const { styles, attributes } = usePopper(referenceElement, popperElement, { placement: "bottom", modifiers: [ @@ -106,12 +103,11 @@ const SkillsList = ({requiredSkills, skills, limit = 3 }) => { <ul styleName="skills-list"> {requiredSkills.map((skill) => ( <li key={skill.id}> - {showMatches && - (_.find(skills, { id: skill.id }) ? ( - <IconCheck /> - ) : ( - <IconCross /> - ))}{" "} + {_.find(skills, { id: skill.id }) ? ( + <IconCheck /> + ) : ( + <IconCross /> + )}{" "} {skill.name} </li> ))} @@ -120,7 +116,9 @@ const SkillsList = ({requiredSkills, skills, limit = 3 }) => { )} {otherSkills && ( <div styleName="skills-section"> - <div styleName="skills-title">{showMatches ? 'Other User Skills': 'Required Skills'}</div> + <div styleName="skills-title"> + {showMatches ? "Other User Skills" : "Required Skills"} + </div> <ul styleName="skills-list"> {otherSkills.map((skill) => ( <li key={skill.id}>{skill.name}</li> From d8f522cac7915a5eb5f5387cc09783d04515f365 Mon Sep 17 00:00:00 2001 From: maxceem <maxceem@gmail.com> Date: Wed, 6 Jan 2021 10:20:21 +0200 Subject: [PATCH 11/23] refactor: fix formating --- src/components/SkillsSummary/index.jsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/components/SkillsSummary/index.jsx b/src/components/SkillsSummary/index.jsx index c20af3b9..ceccbb62 100644 --- a/src/components/SkillsSummary/index.jsx +++ b/src/components/SkillsSummary/index.jsx @@ -23,7 +23,11 @@ const SkillsSummary = ({ skills, requiredSkills = [], limit }) => { <PercentageBar ratio={skillsMatchedRatio} styleName="percentage-bar" /> {Math.round(skillsMatchedRatio * 100)}% skill matched </div> - <SkillsList skills={skills} requiredSkills={requiredSkills} limit={limit} /> + <SkillsList + skills={skills} + requiredSkills={requiredSkills} + limit={limit} + /> </div> ); }; From d935ee33b9b4065d56c423cf643505b902990ee5 Mon Sep 17 00:00:00 2001 From: yoution <zhuyan207@gmail.com> Date: Wed, 6 Jan 2021 20:08:10 +0800 Subject: [PATCH 12/23] fix: issue #37 --- package-lock.json | 8 +------- package.json | 4 +--- src/routes/MyTeamsList/index.jsx | 21 +++++++++++++-------- src/routes/MyTeamsList/styles.module.scss | 3 +++ src/services/requestInterceptor.js | 16 ++-------------- 5 files changed, 20 insertions(+), 32 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8563ac4d..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", @@ -16700,11 +16699,6 @@ "integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==", "dev": true }, - "use-debounce": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-5.2.0.tgz", - "integrity": "sha512-lW4tbPsTnvPKYqOYXp5xZ7SP7No/ARLqqQqoyRKuSzP0HxR9arhSAhznXUZFoNPWDRij8fog+N6sYbjb8c3kzw==" - }, "util": { "version": "0.11.1", "resolved": "https://registry.npmjs.org/util/-/util-0.11.1.tgz", diff --git a/package.json b/package.json index bfd4a751..3a16e2cc 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,6 @@ "dev": "webpack-dev-server --port 8501 --host 0.0.0.0", "dev-https": "webpack-dev-server --https --port 8501 --host 0.0.0.0", "build": "webpack --mode=${APPMODE:-development} --env.config=${APPENV:-dev}", - "build:prod": "webpack --mode=${APPMODE:-production} --env.config=${APPENV:-prod}", "analyze": "webpack --mode=production --env.analyze=true", "lint": "eslint src --ext js", "format": "prettier --write \"./**\"", @@ -75,8 +74,7 @@ "redux": "^4.0.5", "redux-logger": "^3.0.6", "redux-promise-middleware": "^6.1.2", - "redux-thunk": "^2.3.0", - "use-debounce": "^5.2.0" + "redux-thunk": "^2.3.0" }, "browserslist": [ "last 1 version", diff --git a/src/routes/MyTeamsList/index.jsx b/src/routes/MyTeamsList/index.jsx index 7f7a3513..e173c2f1 100644 --- a/src/routes/MyTeamsList/index.jsx +++ b/src/routes/MyTeamsList/index.jsx @@ -6,7 +6,6 @@ import React, { useCallback, useState, useEffect } from "react"; import _ from "lodash"; import LayoutContainer from "components/LayoutContainer"; -import { useDebouncedCallback } from "use-debounce"; import PageHeader from "components/PageHeader"; import Input from "components/Input"; import Pagination from "components/Pagination"; @@ -14,24 +13,29 @@ 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 { useDebounce } from "react-use"; import { TEAMS_PER_PAGE } from "constants"; import "./styles.module.scss"; const MyTeamsList = () => { - // let [myTeams, loadingError] = useData(getMyTeams); - let [myTeams, setMyTeams] = useState(); + let [myTeams, setMyTeams] = useState(null); const [filter, setFilter] = useState(""); + const [tempFilter, setTempFilter] = React.useState(''); const [loadingError, setLoadingError] = useState(false); const [page, setPage] = useState(1); const [total, setTotal] = useState(0); + const onFilterChange = (evt) => { + setTempFilter(evt.target.value) + } - const onFilterChange = useDebouncedCallback((value) => { - setFilter(value); + useDebounce((value) => { + console.log('xxxx', value) + setFilter(tempFilter); setPage(1); - }, 200); + }, 200, [tempFilter]); useEffect(() => { + setMyTeams(null); getMyTeams(filter, page, TEAMS_PER_PAGE) .then((response) => { setMyTeams(response.data); @@ -57,10 +61,11 @@ const MyTeamsList = () => { <Input placeholder="Filter by team name" styleName="filter-input" - onChange={(e) => onFilterChange.callback(e.target.value)} + onChange={onFilterChange} /> } /> + {myTeams && myTeams.length === 0 && (<div styleName="empty">No teams found</div>)} {!myTeams ? ( <LoadingIndicator error={loadingError && loadingError.toString()} /> ) : ( diff --git a/src/routes/MyTeamsList/styles.module.scss b/src/routes/MyTeamsList/styles.module.scss index 5d77d644..b2158822 100644 --- a/src/routes/MyTeamsList/styles.module.scss +++ b/src/routes/MyTeamsList/styles.module.scss @@ -18,6 +18,9 @@ } } +.empty { + text-align: center; +} .pagination-wrapper { margin-top: 20px; diff --git a/src/services/requestInterceptor.js b/src/services/requestInterceptor.js index 471255f4..ec369c2b 100644 --- a/src/services/requestInterceptor.js +++ b/src/services/requestInterceptor.js @@ -2,18 +2,6 @@ import axios from "axios"; import store from "../store"; import { getAuthUserTokens } from "@topcoder/micro-frontends-navbar-app"; -export const getToken = () => { - return new Promise((resolve, reject) => { - return getAuthUserTokens() - .then(({ tokenV3: token }) => { - return resolve(token); - }) - .catch((err) => { - reject(err); - }); - }); -}; - export const axiosInstance = axios.create({ headers: { "Content-Type": "application/json", @@ -22,8 +10,8 @@ export const axiosInstance = axios.create({ // request interceptor to pass auth token axiosInstance.interceptors.request.use((config) => { - return getToken() - .then((token) => { + return getAuthUserTokens() + .then(({ tokenV3: token }) => { config.headers["Authorization"] = `Bearer ${token}`; return config; }) From 945065f29dc6793dff055e169b1203ceaf83c31b Mon Sep 17 00:00:00 2001 From: maxceem <maxceem@gmail.com> Date: Thu, 7 Jan 2021 00:01:13 +0200 Subject: [PATCH 13/23] refactor: debounce filter --- src/constants/index.js | 5 +++++ src/routes/MyTeamsList/index.jsx | 31 +++++++++++++++++++------------ 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/src/constants/index.js b/src/constants/index.js index 98a0c5c4..e65c089b 100644 --- a/src/constants/index.js +++ b/src/constants/index.js @@ -22,6 +22,11 @@ export const TEAMS_PER_PAGE = 20; */ export const POSITION_CANDIDATES_PER_PAGE = 5; +/** + * Input debounce delay (ms) + */ +export const INPUT_DEBOUNCE_DELAY = 200; + /** * Position statuses */ diff --git a/src/routes/MyTeamsList/index.jsx b/src/routes/MyTeamsList/index.jsx index e173c2f1..8db13129 100644 --- a/src/routes/MyTeamsList/index.jsx +++ b/src/routes/MyTeamsList/index.jsx @@ -16,27 +16,32 @@ import LoadingIndicator from "../../components/LoadingIndicator"; import { useDebounce } from "react-use"; import { TEAMS_PER_PAGE } from "constants"; import "./styles.module.scss"; +import { INPUT_DEBOUNCE_DELAY } from "constants/"; const MyTeamsList = () => { let [myTeams, setMyTeams] = useState(null); - const [filter, setFilter] = useState(""); - const [tempFilter, setTempFilter] = React.useState(''); + 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) => { - setTempFilter(evt.target.value) - } + setFilter(evt.target.value); + }; - useDebounce((value) => { - console.log('xxxx', value) - setFilter(tempFilter); - setPage(1); - }, 200, [tempFilter]); + useDebounce( + () => { + setDebouncedFilter(filter); + setPage(1); + }, + INPUT_DEBOUNCE_DELAY, + [filter] + ); useEffect(() => { setMyTeams(null); - getMyTeams(filter, page, TEAMS_PER_PAGE) + getMyTeams(debouncedFilter, page, TEAMS_PER_PAGE) .then((response) => { setMyTeams(response.data); setTotal(response.headers["x-total"]); @@ -44,7 +49,7 @@ const MyTeamsList = () => { .catch((responseError) => { setLoadingError(responseError); }); - }, [filter, page]); + }, [debouncedFilter, page]); const onPageClick = useCallback( (newPage) => { @@ -65,7 +70,9 @@ const MyTeamsList = () => { /> } /> - {myTeams && myTeams.length === 0 && (<div styleName="empty">No teams found</div>)} + {myTeams && myTeams.length === 0 && ( + <div styleName="empty">No teams found</div> + )} {!myTeams ? ( <LoadingIndicator error={loadingError && loadingError.toString()} /> ) : ( From cd8ad9c298c799653cd50e448a5fe176e7738d4d Mon Sep 17 00:00:00 2001 From: maxceem <maxceem@gmail.com> Date: Thu, 7 Jan 2021 00:03:12 +0200 Subject: [PATCH 14/23] feat: filter teams by substring --- src/services/teams.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/teams.js b/src/services/teams.js index b9121c46..7b8739c2 100644 --- a/src/services/teams.js +++ b/src/services/teams.js @@ -15,7 +15,7 @@ import config from "../../config"; export const getMyTeams = (name, page = 1, perPage) => { let query = `page=${page}&perPage=${perPage}`; if (name) { - query += `&name=${name}`; + query += `&name=*${name}*`; // wrap with asterisks to search by substrings } return axios.get(`${config.API.V5}/taas-teams?${query}`); From a2657fcbfdb27884dcd6149c8e7ab004fc14d98c Mon Sep 17 00:00:00 2001 From: maxceem <maxceem@gmail.com> Date: Thu, 7 Jan 2021 17:32:10 +0200 Subject: [PATCH 15/23] feat: use "title" for jobs --- src/routes/MyTeamsDetails/components/TeamMembers/index.jsx | 6 ++---- .../MyTeamsDetails/components/TeamPositions/index.jsx | 4 ++-- src/routes/PositionDetails/index.jsx | 2 +- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/routes/MyTeamsDetails/components/TeamMembers/index.jsx b/src/routes/MyTeamsDetails/components/TeamMembers/index.jsx index 88b850f1..9443651c 100644 --- a/src/routes/MyTeamsDetails/components/TeamMembers/index.jsx +++ b/src/routes/MyTeamsDetails/components/TeamMembers/index.jsx @@ -44,7 +44,7 @@ const TeamMembers = ({ team }) => { member.firstName, member.lastName, `${member.firstName} ${member.lastName}`, // full name - member.job.description, + member.job.title, ..._.map(member.skills, "name"), ]).map((field) => field.toLowerCase()); @@ -119,9 +119,7 @@ const TeamMembers = ({ team }) => { /> </div> <div styleName="table-group-first-inner"> - <div styleName="table-cell cell-role"> - {member.job.description} - </div> + <div styleName="table-cell cell-role">{member.job.title}</div> <div styleName="table-cell cell-date"> {moment(member.startDate).format(DAY_FORMAT)} -{" "} {moment(member.endDate).format(DAY_FORMAT)} diff --git a/src/routes/MyTeamsDetails/components/TeamPositions/index.jsx b/src/routes/MyTeamsDetails/components/TeamPositions/index.jsx index 91c90ab1..26db9e0b 100644 --- a/src/routes/MyTeamsDetails/components/TeamPositions/index.jsx +++ b/src/routes/MyTeamsDetails/components/TeamPositions/index.jsx @@ -28,7 +28,7 @@ const TeamPositions = ({ teamId, positions }) => { <div styleName="table-row" key={index}> <div styleName="table-group-first"> <div styleName="table-cell cell-skills"> - <strong>{position.description}</strong> + <strong>{position.title}</strong> <SkillsList skills={position.skills} limit={5} /> </div> <div styleName="table-group-first-inner"> @@ -70,7 +70,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/PositionDetails/index.jsx b/src/routes/PositionDetails/index.jsx index 13594078..4c30df09 100644 --- a/src/routes/PositionDetails/index.jsx +++ b/src/routes/PositionDetails/index.jsx @@ -35,7 +35,7 @@ const PositionDetails = ({ teamId, positionId }) => { ) : ( <> <PageHeader - title={position.description} + title={position.title} backTo={`/taas/myteams/${teamId}`} aside={ <CandidatesStatusFilter From 820d8ca5c78571b4a2e00192ee71e410b5c0667c Mon Sep 17 00:00:00 2001 From: yoution <zhuyan207@gmail.com> Date: Fri, 8 Jan 2021 13:56:17 +0800 Subject: [PATCH 16/23] fix: error message for #44 --- src/components/Authentication/index.jsx | 39 +++++++++++++++++++++++ src/components/LoadingIndicator/index.jsx | 5 +-- src/root.component.jsx | 6 ++-- src/routes/MyTeamsDetails/index.jsx | 5 +-- src/routes/MyTeamsList/index.jsx | 5 +-- src/routes/PositionDetails/index.jsx | 3 +- 6 files changed, 53 insertions(+), 10 deletions(-) create mode 100644 src/components/Authentication/index.jsx diff --git a/src/components/Authentication/index.jsx b/src/components/Authentication/index.jsx new file mode 100644 index 00000000..2335d674 --- /dev/null +++ b/src/components/Authentication/index.jsx @@ -0,0 +1,39 @@ +/** + * Authentication + * + * wrap component for authentication + */ +import React, { useCallback, useState, useEffect } from "react"; +import { getAuthUserTokens, login } from "@topcoder/micro-frontends-navbar-app"; + +export default function Authentication(Component) { + + const AuthenticatedComponent = (props) => { + let [isLoggedIn, setIsLoggedIn] = useState(null); + + useEffect(() => { + if (props.auth) { + getAuthUserTokens() + .then(({ tokenV3 }) => { + if (!!tokenV3) { + setIsLoggedIn(!!tokenV3) + } else { + login() + } + }) + } + }, [props.auth]); + + return ( + <div> + { + (!props.auth || props.auth && isLoggedIn === true) + ? <Component { ...props}/> + : null + } + </div> + ) + } + + return AuthenticatedComponent +} diff --git a/src/components/LoadingIndicator/index.jsx b/src/components/LoadingIndicator/index.jsx index a5f70dd1..6d7e180d 100644 --- a/src/components/LoadingIndicator/index.jsx +++ b/src/components/LoadingIndicator/index.jsx @@ -4,17 +4,18 @@ * Optionally shows error. */ import React from "react"; +import _ from "lodash"; import PT from "prop-types"; import "./styles.module.scss"; const LoadingIndicator = ({ error }) => { return ( - <div styleName="loading-indicator">{!error ? "Loading..." : error}</div> + <div styleName="loading-indicator">{!error ? "Loading..." : _.get(error, 'response.data.message') || error}</div> ); }; LoadingIndicator.propTypes = { - error: PT.string, + error: PT.object, }; export default LoadingIndicator; diff --git a/src/root.component.jsx b/src/root.component.jsx index dd6bd948..eed88770 100644 --- a/src/root.component.jsx +++ b/src/root.component.jsx @@ -14,9 +14,9 @@ export default function Root() { <div className={styles['topcoder-micro-frontends-teams-app']}> <Provider store={store}> <Router> - <MyTeamsList path="/taas/myteams" /> - <MyTeamsDetails path="/taas/myteams/:teamId" /> - <PositionDetails path="/taas/myteams/:teamId/positions/:positionId" /> + <MyTeamsList path="/taas/myteams" auth/> + <MyTeamsDetails path="/taas/myteams/:teamId" auth /> + <PositionDetails path="/taas/myteams/:teamId/positions/:positionId" auth/> </Router> {/* Global config for Toastr popups */} diff --git a/src/routes/MyTeamsDetails/index.jsx b/src/routes/MyTeamsDetails/index.jsx index 2f0d5c75..444690b4 100644 --- a/src/routes/MyTeamsDetails/index.jsx +++ b/src/routes/MyTeamsDetails/index.jsx @@ -14,6 +14,7 @@ import LoadingIndicator from "components/LoadingIndicator"; import TeamSummary from "./components/TeamSummary"; import TeamMembers from "./components/TeamMembers"; import TeamPositions from "./components/TeamPositions"; +import Authentication from '../../components/Authentication' import { useAsync } from "react-use"; const MyTeamsDetails = ({ teamId }) => { @@ -22,7 +23,7 @@ const MyTeamsDetails = ({ teamId }) => { return ( <LayoutContainer> {!team ? ( - <LoadingIndicator error={loadingError && loadingError.toString()} /> + <LoadingIndicator error={loadingError} /> ) : ( <> <PageHeader title={team.name} backTo="/taas/myteams" /> @@ -39,4 +40,4 @@ MyTeamsDetails.propTypes = { teamId: PT.string, }; -export default MyTeamsDetails; +export default Authentication(MyTeamsDetails); diff --git a/src/routes/MyTeamsList/index.jsx b/src/routes/MyTeamsList/index.jsx index 8db13129..1ae10f9e 100644 --- a/src/routes/MyTeamsList/index.jsx +++ b/src/routes/MyTeamsList/index.jsx @@ -13,6 +13,7 @@ import { getMyTeams } from "../../services/teams"; import TeamCard from "./components/TeamCard"; import TeamCardGrid from "./components/TeamCardGrid"; import LoadingIndicator from "../../components/LoadingIndicator"; +import Authentication from '../../components/Authentication' import { useDebounce } from "react-use"; import { TEAMS_PER_PAGE } from "constants"; import "./styles.module.scss"; @@ -74,7 +75,7 @@ const MyTeamsList = () => { <div styleName="empty">No teams found</div> )} {!myTeams ? ( - <LoadingIndicator error={loadingError && loadingError.toString()} /> + <LoadingIndicator error={loadingError} /> ) : ( <> <TeamCardGrid> @@ -98,4 +99,4 @@ const MyTeamsList = () => { ); }; -export default MyTeamsList; +export default Authentication(MyTeamsList); diff --git a/src/routes/PositionDetails/index.jsx b/src/routes/PositionDetails/index.jsx index 4c30df09..8a994206 100644 --- a/src/routes/PositionDetails/index.jsx +++ b/src/routes/PositionDetails/index.jsx @@ -9,6 +9,7 @@ import LayoutContainer from "components/LayoutContainer"; import LoadingIndicator from "components/LoadingIndicator"; import PageHeader from "components/PageHeader"; import { CANDIDATE_STATUS } from "constants"; +import Authentication from '../../components/Authentication' import PositionCandidates from "./components/PositionCandidates"; import CandidatesStatusFilter from "./components/CandidatesStatusFilter"; import { useTeamPositionsState } from "./hooks/useTeamPositionsState"; @@ -61,4 +62,4 @@ PositionDetails.propTypes = { positionId: PT.string, }; -export default PositionDetails; +export default Authentication(PositionDetails); From 32ffdd4cb822f2493ac7c0c6c93a9f5cd3f35101 Mon Sep 17 00:00:00 2001 From: yoution <zhuyan207@gmail.com> Date: Sat, 9 Jan 2021 22:21:12 +0800 Subject: [PATCH 17/23] fix: issue #44 --- src/components/Authentication/index.jsx | 39 --------------------- src/components/LoadingIndicator/index.jsx | 4 ++- src/hoc/withAuthentication.js | 42 +++++++++++++++++++++++ src/root.component.jsx | 11 +++--- src/routes/MyTeamsDetails/index.jsx | 4 +-- src/routes/MyTeamsList/index.jsx | 4 +-- src/routes/PositionDetails/index.jsx | 4 +-- 7 files changed, 58 insertions(+), 50 deletions(-) delete mode 100644 src/components/Authentication/index.jsx create mode 100644 src/hoc/withAuthentication.js diff --git a/src/components/Authentication/index.jsx b/src/components/Authentication/index.jsx deleted file mode 100644 index 2335d674..00000000 --- a/src/components/Authentication/index.jsx +++ /dev/null @@ -1,39 +0,0 @@ -/** - * Authentication - * - * wrap component for authentication - */ -import React, { useCallback, useState, useEffect } from "react"; -import { getAuthUserTokens, login } from "@topcoder/micro-frontends-navbar-app"; - -export default function Authentication(Component) { - - const AuthenticatedComponent = (props) => { - let [isLoggedIn, setIsLoggedIn] = useState(null); - - useEffect(() => { - if (props.auth) { - getAuthUserTokens() - .then(({ tokenV3 }) => { - if (!!tokenV3) { - setIsLoggedIn(!!tokenV3) - } else { - login() - } - }) - } - }, [props.auth]); - - return ( - <div> - { - (!props.auth || props.auth && isLoggedIn === true) - ? <Component { ...props}/> - : null - } - </div> - ) - } - - return AuthenticatedComponent -} diff --git a/src/components/LoadingIndicator/index.jsx b/src/components/LoadingIndicator/index.jsx index 6d7e180d..96faef90 100644 --- a/src/components/LoadingIndicator/index.jsx +++ b/src/components/LoadingIndicator/index.jsx @@ -10,7 +10,9 @@ import "./styles.module.scss"; const LoadingIndicator = ({ error }) => { return ( - <div styleName="loading-indicator">{!error ? "Loading..." : _.get(error, 'response.data.message') || error}</div> + <div styleName="loading-indicator"> + {!error ? "Loading..." : _.get(error, "response.data.message") || error} + </div> ); }; diff --git a/src/hoc/withAuthentication.js b/src/hoc/withAuthentication.js new file mode 100644 index 00000000..0385666b --- /dev/null +++ b/src/hoc/withAuthentication.js @@ -0,0 +1,42 @@ +/** + * Authentication + * + * wrap component for authentication + */ +import React, { useCallback, 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(() => { + if (props.auth) { + getAuthUserTokens() + .then(({ tokenV3 }) => { + if (!!tokenV3) { + setIsLoggedIn(!!tokenV3); + } else { + login(); + } + }) + .catch((err) => { + setAuthError(err); + }); + } + }, [props.auth]); + + return ( + <> + {authError && <LoadingIndicator error={authError.toString()} />} + {!props.auth || (props.auth && isLoggedIn === true) ? ( + <Component {...props} /> + ) : null} + </> + ); + }; + + return AuthenticatedComponent; +} diff --git a/src/root.component.jsx b/src/root.component.jsx index eed88770..6ae1658e 100644 --- a/src/root.component.jsx +++ b/src/root.component.jsx @@ -4,19 +4,22 @@ import { Router } from "@reach/router"; import MyTeamsList from "./routes/MyTeamsList"; import MyTeamsDetails from "./routes/MyTeamsDetails"; import PositionDetails from "./routes/PositionDetails"; -import ReduxToastr from 'react-redux-toastr' +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 ( - <div className={styles['topcoder-micro-frontends-teams-app']}> + <div className={styles["topcoder-micro-frontends-teams-app"]}> <Provider store={store}> <Router> - <MyTeamsList path="/taas/myteams" auth/> + <MyTeamsList path="/taas/myteams" auth /> <MyTeamsDetails path="/taas/myteams/:teamId" auth /> - <PositionDetails path="/taas/myteams/:teamId/positions/:positionId" auth/> + <PositionDetails + path="/taas/myteams/:teamId/positions/:positionId" + auth + /> </Router> {/* Global config for Toastr popups */} diff --git a/src/routes/MyTeamsDetails/index.jsx b/src/routes/MyTeamsDetails/index.jsx index 444690b4..c8e258d1 100644 --- a/src/routes/MyTeamsDetails/index.jsx +++ b/src/routes/MyTeamsDetails/index.jsx @@ -14,7 +14,7 @@ import LoadingIndicator from "components/LoadingIndicator"; import TeamSummary from "./components/TeamSummary"; import TeamMembers from "./components/TeamMembers"; import TeamPositions from "./components/TeamPositions"; -import Authentication from '../../components/Authentication' +import withAuthentication from "../../hoc/withAuthentication"; import { useAsync } from "react-use"; const MyTeamsDetails = ({ teamId }) => { @@ -40,4 +40,4 @@ MyTeamsDetails.propTypes = { teamId: PT.string, }; -export default Authentication(MyTeamsDetails); +export default withAuthentication(MyTeamsDetails); diff --git a/src/routes/MyTeamsList/index.jsx b/src/routes/MyTeamsList/index.jsx index 1ae10f9e..afc92e73 100644 --- a/src/routes/MyTeamsList/index.jsx +++ b/src/routes/MyTeamsList/index.jsx @@ -13,7 +13,7 @@ import { getMyTeams } from "../../services/teams"; import TeamCard from "./components/TeamCard"; import TeamCardGrid from "./components/TeamCardGrid"; import LoadingIndicator from "../../components/LoadingIndicator"; -import Authentication from '../../components/Authentication' +import withAuthentication from "../../hoc/withAuthentication"; import { useDebounce } from "react-use"; import { TEAMS_PER_PAGE } from "constants"; import "./styles.module.scss"; @@ -99,4 +99,4 @@ const MyTeamsList = () => { ); }; -export default Authentication(MyTeamsList); +export default withAuthentication(MyTeamsList); diff --git a/src/routes/PositionDetails/index.jsx b/src/routes/PositionDetails/index.jsx index 8a994206..cb9d3ae6 100644 --- a/src/routes/PositionDetails/index.jsx +++ b/src/routes/PositionDetails/index.jsx @@ -9,7 +9,7 @@ import LayoutContainer from "components/LayoutContainer"; import LoadingIndicator from "components/LoadingIndicator"; import PageHeader from "components/PageHeader"; import { CANDIDATE_STATUS } from "constants"; -import Authentication from '../../components/Authentication' +import withAuthentication from "../../hoc/withAuthentication"; import PositionCandidates from "./components/PositionCandidates"; import CandidatesStatusFilter from "./components/CandidatesStatusFilter"; import { useTeamPositionsState } from "./hooks/useTeamPositionsState"; @@ -62,4 +62,4 @@ PositionDetails.propTypes = { positionId: PT.string, }; -export default Authentication(PositionDetails); +export default withAuthentication(PositionDetails); From b6ff645904794bf9aa82415f172ec328023cff9c Mon Sep 17 00:00:00 2001 From: maxceem <maxceem@gmail.com> Date: Sun, 10 Jan 2021 11:22:05 +0200 Subject: [PATCH 18/23] refactor: remove "auth" ref issue #44 --- src/components/LoadingIndicator/index.jsx | 4 ++- src/hoc/withAuthentication.js | 36 +++++++++++------------ src/root.component.jsx | 9 ++---- 3 files changed, 23 insertions(+), 26 deletions(-) diff --git a/src/components/LoadingIndicator/index.jsx b/src/components/LoadingIndicator/index.jsx index 96faef90..c6668f08 100644 --- a/src/components/LoadingIndicator/index.jsx +++ b/src/components/LoadingIndicator/index.jsx @@ -11,7 +11,9 @@ import "./styles.module.scss"; const LoadingIndicator = ({ error }) => { return ( <div styleName="loading-indicator"> - {!error ? "Loading..." : _.get(error, "response.data.message") || error} + {!error + ? "Loading..." + : _.get(error, "response.data.message", error.toString())} </div> ); }; diff --git a/src/hoc/withAuthentication.js b/src/hoc/withAuthentication.js index 0385666b..56a2fcec 100644 --- a/src/hoc/withAuthentication.js +++ b/src/hoc/withAuthentication.js @@ -3,7 +3,7 @@ * * wrap component for authentication */ -import React, { useCallback, useState, useEffect } from "react"; +import React, { useState, useEffect } from "react"; import { getAuthUserTokens, login } from "@topcoder/micro-frontends-navbar-app"; import LoadingIndicator from "../components/LoadingIndicator"; @@ -13,27 +13,25 @@ export default function withAuthentication(Component) { let [authError, setAuthError] = useState(false); useEffect(() => { - if (props.auth) { - getAuthUserTokens() - .then(({ tokenV3 }) => { - if (!!tokenV3) { - setIsLoggedIn(!!tokenV3); - } else { - login(); - } - }) - .catch((err) => { - setAuthError(err); - }); - } - }, [props.auth]); + getAuthUserTokens() + .then(({ tokenV3 }) => { + if (!!tokenV3) { + setIsLoggedIn(!!tokenV3); + } else { + login(); + } + }) + .catch(setAuthError); + }, []); return ( <> - {authError && <LoadingIndicator error={authError.toString()} />} - {!props.auth || (props.auth && isLoggedIn === true) ? ( - <Component {...props} /> - ) : null} + {/* Show loading indicator until we know if user is logged-in or no. + In we got error during this process, show error */} + {isLoggedIn === null && <LoadingIndicator error={authError} />} + + {/* Show component only if user is logged-in */} + {isLoggedIn === true ? <Component {...props} /> : null} </> ); }; diff --git a/src/root.component.jsx b/src/root.component.jsx index 6ae1658e..b48ea5cd 100644 --- a/src/root.component.jsx +++ b/src/root.component.jsx @@ -14,12 +14,9 @@ export default function Root() { <div className={styles["topcoder-micro-frontends-teams-app"]}> <Provider store={store}> <Router> - <MyTeamsList path="/taas/myteams" auth /> - <MyTeamsDetails path="/taas/myteams/:teamId" auth /> - <PositionDetails - path="/taas/myteams/:teamId/positions/:positionId" - auth - /> + <MyTeamsList path="/taas/myteams" /> + <MyTeamsDetails path="/taas/myteams/:teamId" /> + <PositionDetails path="/taas/myteams/:teamId/positions/:positionId" /> </Router> {/* Global config for Toastr popups */} From 124d317865ab60db2c893b29ca73964a6e2e40c1 Mon Sep 17 00:00:00 2001 From: yoution <zhuyan207@gmail.com> Date: Sun, 10 Jan 2021 19:13:22 +0800 Subject: [PATCH 19/23] fix: issue #44 --- src/hoc/withAuthentication.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/hoc/withAuthentication.js b/src/hoc/withAuthentication.js index 56a2fcec..785dcdcb 100644 --- a/src/hoc/withAuthentication.js +++ b/src/hoc/withAuthentication.js @@ -13,15 +13,20 @@ export default function withAuthentication(Component) { 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 { + } else if (!isUnmount) { login(); } }) .catch(setAuthError); + return () => { + isUnmount = true; + }; }, []); return ( From 4a4773319e9a52d2427639edd05f8a4e42f6d4c9 Mon Sep 17 00:00:00 2001 From: maxceem <maxceem@gmail.com> Date: Sun, 10 Jan 2021 14:24:01 +0200 Subject: [PATCH 20/23] fix: use "resume" instead of "resumeLink" --- .../PositionDetails/components/PositionCandidates/index.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/routes/PositionDetails/components/PositionCandidates/index.jsx b/src/routes/PositionDetails/components/PositionCandidates/index.jsx index 24e1248a..2a840849 100644 --- a/src/routes/PositionDetails/components/PositionCandidates/index.jsx +++ b/src/routes/PositionDetails/components/PositionCandidates/index.jsx @@ -174,8 +174,8 @@ const PositionCandidates = ({ position, candidateStatus, updateCandidate }) => { requiredSkills={position.skills} limit={7} /> - {candidate.resumeLink && ( - <a href={`${candidate.resumeLink}`} styleName="resume-link"> + {candidate.resume && ( + <a href={`${candidate.resume}`} styleName="resume-link"> <IconResume /> Download Resume </a> From 80bf6f46717c0d4897a1899d6363a99d95eb859c Mon Sep 17 00:00:00 2001 From: maxceem <maxceem@gmail.com> Date: Mon, 11 Jan 2021 10:30:33 +0200 Subject: [PATCH 21/23] fix: date range formatting Cover the cases when not all dates are defined. --- .../components/TeamMembers/index.jsx | 7 +++---- .../components/TeamPositions/index.jsx | 12 +++--------- .../MyTeamsList/components/TeamCard/index.jsx | 12 ++++-------- src/utils/format.js | 16 ++++++++++++++++ 4 files changed, 26 insertions(+), 21 deletions(-) diff --git a/src/routes/MyTeamsDetails/components/TeamMembers/index.jsx b/src/routes/MyTeamsDetails/components/TeamMembers/index.jsx index 9443651c..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,9 @@ 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 { TEAM_MEMBERS_PER_PAGE } from "constants"; import { + formatDateRange, formatMoney, formatReportIssueUrl, formatRequestExtensionUrl, @@ -121,8 +121,7 @@ const TeamMembers = ({ team }) => { <div styleName="table-group-first-inner"> <div styleName="table-cell cell-role">{member.job.title}</div> <div styleName="table-cell cell-date"> - {moment(member.startDate).format(DAY_FORMAT)} -{" "} - {moment(member.endDate).format(DAY_FORMAT)} + {formatDateRange(member.startDate, member.endDate)} </div> <div styleName="table-cell cell-money"> {member.customerRate && member.customerRate > 0 diff --git a/src/routes/MyTeamsDetails/components/TeamPositions/index.jsx b/src/routes/MyTeamsDetails/components/TeamPositions/index.jsx index 26db9e0b..f69f4f88 100644 --- a/src/routes/MyTeamsDetails/components/TeamPositions/index.jsx +++ b/src/routes/MyTeamsDetails/components/TeamPositions/index.jsx @@ -5,17 +5,12 @@ */ import React from "react"; import PT from "prop-types"; -import moment from "moment"; import CardHeader from "components/CardHeader"; import SkillsList, { skillShape } from "components/SkillsList"; import Button from "components/Button"; -import { - DAY_FORMAT, - POSITION_STATUS, - POSITION_STATUS_TO_TEXT, - RATE_TYPE, -} from "constants"; +import { POSITION_STATUS, POSITION_STATUS_TO_TEXT, RATE_TYPE } from "constants"; import "./styles.module.scss"; +import { formatDateRange } from "utils/format"; const TeamPositions = ({ teamId, positions }) => { return ( @@ -33,8 +28,7 @@ const TeamPositions = ({ teamId, positions }) => { </div> <div styleName="table-group-first-inner"> <div styleName="table-cell cell-date"> - {moment(position.startDate).format(DAY_FORMAT)} -{" "} - {moment(position.endDate).format(DAY_FORMAT)} + {formatDateRange(position.startDate, position.endDate)} </div> <div styleName="table-cell cell-money"> {/* Hide rate as we don't have data for it */} diff --git a/src/routes/MyTeamsList/components/TeamCard/index.jsx b/src/routes/MyTeamsList/components/TeamCard/index.jsx index 6c360719..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, @@ -45,14 +46,9 @@ const TeamCard = ({ team }) => { <div styleName="data-items"> <DataItem title="Start - End Date" icon={<IconCalendar />}> - {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"} </DataItem> <DataItem title="Time Remaining" icon={<IconClock />}> diff --git a/src/utils/format.js b/src/utils/format.js index c6786d00..edf98638 100644 --- a/src/utils/format.js +++ b/src/utils/format.js @@ -5,6 +5,7 @@ import _ from "lodash"; import { RATE_TYPE } from "constants"; 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. @@ -133,3 +134,18 @@ export const formatRequestExtensionUrl = (subject) => { 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}`; +}; From 2b58cf0765b1fb6f05852714b1ce5a8f95a99020 Mon Sep 17 00:00:00 2001 From: maxceem <maxceem@gmail.com> Date: Mon, 11 Jan 2021 10:42:05 +0200 Subject: [PATCH 22/23] feat: redirect from "/taas" to "/taas/myteams" ref issue #44 --- src/root.component.jsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/root.component.jsx b/src/root.component.jsx index b48ea5cd..834234fb 100644 --- a/src/root.component.jsx +++ b/src/root.component.jsx @@ -1,6 +1,6 @@ import React from "react"; import { Provider } from "react-redux"; -import { Router } from "@reach/router"; +import { Router, Redirect } from "@reach/router"; import MyTeamsList from "./routes/MyTeamsList"; import MyTeamsDetails from "./routes/MyTeamsDetails"; import PositionDetails from "./routes/PositionDetails"; @@ -14,6 +14,7 @@ export default function Root() { <div className={styles["topcoder-micro-frontends-teams-app"]}> <Provider store={store}> <Router> + <Redirect from="/taas" to="/taas/myteams" exact /> <MyTeamsList path="/taas/myteams" /> <MyTeamsDetails path="/taas/myteams/:teamId" /> <PositionDetails path="/taas/myteams/:teamId/positions/:positionId" /> From 3f1d9cb7c2ae8f1fdb99671cbd6234e91d20525f Mon Sep 17 00:00:00 2001 From: maxceem <maxceem@gmail.com> Date: Mon, 11 Jan 2021 10:46:52 +0200 Subject: [PATCH 23/23] feat: open resume in a new page --- .../PositionDetails/components/PositionCandidates/index.jsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/routes/PositionDetails/components/PositionCandidates/index.jsx b/src/routes/PositionDetails/components/PositionCandidates/index.jsx index 2a840849..6b893b39 100644 --- a/src/routes/PositionDetails/components/PositionCandidates/index.jsx +++ b/src/routes/PositionDetails/components/PositionCandidates/index.jsx @@ -175,7 +175,11 @@ const PositionCandidates = ({ position, candidateStatus, updateCandidate }) => { limit={7} /> {candidate.resume && ( - <a href={`${candidate.resume}`} styleName="resume-link"> + <a + href={`${candidate.resume}`} + styleName="resume-link" + target="_blank" + > <IconResume /> Download Resume </a>