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