diff --git a/src/assets/images/icon-menu-item-roles.svg b/src/assets/images/icon-menu-item-roles.svg new file mode 100644 index 0000000..b3c8674 --- /dev/null +++ b/src/assets/images/icon-menu-item-roles.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/src/assets/images/icon-role-fallback.svg b/src/assets/images/icon-role-fallback.svg new file mode 100644 index 0000000..47be19a --- /dev/null +++ b/src/assets/images/icon-role-fallback.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/images/icon-search.svg b/src/assets/images/icon-search.svg new file mode 100644 index 0000000..f694173 --- /dev/null +++ b/src/assets/images/icon-search.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/components/Icons/Roles/index.jsx b/src/components/Icons/Roles/index.jsx new file mode 100644 index 0000000..8ed4edb --- /dev/null +++ b/src/components/Icons/Roles/index.jsx @@ -0,0 +1,29 @@ +import React from "react"; +import PT from "prop-types"; +import cn from "classnames"; +import IconWrapper from "components/IconWrapper"; +import IconRoleManagement from "../../../assets/images/icon-menu-item-roles.svg"; +import styles from "./styles.module.scss"; + +/** + * Displays a "role management" icon used in navigation menu. + * + * @param {Object} props component props + * @param {string} [props.className] class name added to root element + * @param {boolean} [props.isActive] a flag indicating whether the icon is active + * @returns {JSX.Element} + */ +const Roles = ({ className, isActive = false }) => ( + + + +); + +Roles.propTypes = { + className: PT.string, + isActive: PT.bool, +}; + +export default Roles; diff --git a/src/components/Icons/Roles/styles.module.scss b/src/components/Icons/Roles/styles.module.scss new file mode 100644 index 0000000..8e4adfc --- /dev/null +++ b/src/components/Icons/Roles/styles.module.scss @@ -0,0 +1,19 @@ +.container { + svg { + display: block; + width: auto; + height: 100%; + + path { + fill: #7f7f7f; + } + } + + &.isActive { + svg { + path { + fill: #06d6a0; + } + } + } +} diff --git a/src/components/Sidebar/index.jsx b/src/components/Sidebar/index.jsx index 1424f71..c3d6bab 100644 --- a/src/components/Sidebar/index.jsx +++ b/src/components/Sidebar/index.jsx @@ -5,6 +5,7 @@ import NavMenu from "components/NavMenu"; import styles from "./styles.module.scss"; import WorkPeriods from "components/Icons/WorkPeriods"; import Freelancers from "components/Icons/Freelancers"; +import Roles from "components/Icons/Roles"; import { APP_BASE_PATH } from "../../constants"; /** @@ -38,6 +39,11 @@ const NAV_ITEMS = [ label: "Freelancers", path: `${APP_BASE_PATH}/freelancers`, }, + { + icon: Roles, + label: "Roles", + path: `${APP_BASE_PATH}/roles`, + }, ]; export default Sidebar; diff --git a/src/components/SearchHandleField/index.jsx b/src/components/Typeahead/index.jsx similarity index 89% rename from src/components/SearchHandleField/index.jsx rename to src/components/Typeahead/index.jsx index cc6c3e2..9d71979 100644 --- a/src/components/SearchHandleField/index.jsx +++ b/src/components/Typeahead/index.jsx @@ -1,9 +1,9 @@ import React, { useCallback, useRef, useState } from "react"; import PT from "prop-types"; import cn from "classnames"; +import get from "lodash/get"; import throttle from "lodash/throttle"; import Select, { components } from "react-select"; -import { getMemberSuggestions } from "services/teams"; import { useUpdateEffect } from "utils/hooks"; import styles from "./styles.module.scss"; @@ -75,9 +75,11 @@ const selectComponents = { * @param {function} [props.onInputChange] function called when input value changes * @param {function} [props.onBlur] function called on input blur * @param {string} props.value input value + * @param {function} props.getSuggestions the function to get suggestions + * @param {string} props.targetProp the target property of the returned object from getSuggestions * @returns {JSX.Element} */ -const SearchHandleField = ({ +const Typeahead = ({ className, id, name, @@ -87,6 +89,8 @@ const SearchHandleField = ({ onBlur, placeholder, value, + getSuggestions, + targetProp, }) => { const [inputValue, setInputValue] = useState(value); const [isLoading, setIsLoading] = useState(false); @@ -165,11 +169,15 @@ const SearchHandleField = ({ return; } setIsLoading(true); - const options = await loadSuggestions(value); + setIsMenuOpen(true); + const options = await loadSuggestions( + getSuggestions, + value, + targetProp + ); if (!isChangeAppliedRef.current) { setOptions(options); setIsLoading(false); - setIsMenuOpen(true); } }, 300, @@ -223,17 +231,17 @@ const SearchHandleField = ({ ); }; -const loadSuggestions = async (inputValue) => { +const loadSuggestions = async (getSuggestions, inputValue, targetProp) => { let options = []; if (inputValue.length < 3) { return options; } try { - const res = await getMemberSuggestions(inputValue); - const users = res.data.slice(0, 100); + const res = await getSuggestions(inputValue); + const items = res.data.slice(0, 100); let match = null; - for (let i = 0, len = users.length; i < len; i++) { - let value = users[i].handle; + for (let i = 0, len = items.length; i < len; i++) { + let value = get(items[i], targetProp); if (value === inputValue) { match = { value, label: value }; } else { @@ -250,7 +258,7 @@ const loadSuggestions = async (inputValue) => { return options; }; -SearchHandleField.propTypes = { +Typeahead.propTypes = { className: PT.string, id: PT.string.isRequired, size: PT.oneOf(["medium", "small"]), @@ -260,6 +268,8 @@ SearchHandleField.propTypes = { onBlur: PT.func, placeholder: PT.string, value: PT.oneOfType([PT.number, PT.string]), + getSuggestions: PT.func, + targetProp: PT.string, }; -export default SearchHandleField; +export default Typeahead; diff --git a/src/components/SearchHandleField/styles.module.scss b/src/components/Typeahead/styles.module.scss similarity index 100% rename from src/components/SearchHandleField/styles.module.scss rename to src/components/Typeahead/styles.module.scss diff --git a/src/constants/index.js b/src/constants/index.js index 0f93a11..1e2321f 100644 --- a/src/constants/index.js +++ b/src/constants/index.js @@ -8,6 +8,8 @@ export const WORK_PERIODS_PATH = `${APP_BASE_PATH}/work-periods`; export const FREELANCERS_PATH = `${APP_BASE_PATH}/freelancers`; +export const ROLES_PATH = `${APP_BASE_PATH}/roles`; + export const TAAS_BASE_PATH = "/taas"; export const ADMIN_ROLES = ["bookingmanager", "administrator"]; diff --git a/src/root.component.jsx b/src/root.component.jsx index 017dd42..bd83804 100644 --- a/src/root.component.jsx +++ b/src/root.component.jsx @@ -5,10 +5,12 @@ import store from "store"; import { disableSidebarForRoute } from "@topcoder/micro-frontends-navbar-app"; import WorkPeriods from "routes/WorkPeriods"; import Freelancers from "routes/Freelancers"; +import Roles from "routes/Roles"; import { APP_BASE_PATH, FREELANCERS_PATH, WORK_PERIODS_PATH, + ROLES_PATH, } from "./constants"; import "styles/global.scss"; @@ -23,6 +25,7 @@ export default function Root() { + ); diff --git a/src/routes/Roles/components/RoleForm/index.jsx b/src/routes/Roles/components/RoleForm/index.jsx new file mode 100644 index 0000000..93c9173 --- /dev/null +++ b/src/routes/Roles/components/RoleForm/index.jsx @@ -0,0 +1,412 @@ +/** + * Role Form + * Form component for role creation & edit operations. + */ +import React, { useState, useCallback } from "react"; +import { useSelector, useDispatch } from "react-redux"; +import cn from "classnames"; +import { getRolesModalRole, getRolesModalError } from "store/selectors/roles"; +import { setModalRole, setModalError } from "store/actions/roles"; +import { searchSkills } from "services/roles"; +import FallbackIcon from "../../../../assets/images/icon-role-fallback.svg"; +import Typeahead from "components/Typeahead"; +import IntegerField from "components/IntegerField"; +import IconArrowSmall from "components/Icons/ArrowSmall"; +import styles from "./styles.module.scss"; + +function RoleForm() { + const dispatch = useDispatch(); + const roleState = useSelector(getRolesModalRole); + const modalError = useSelector(getRolesModalError); + const [typeaheadInputValue, setTypeaheadInputValue] = useState(""); + const [expandedRateIdx, setExpandedRateIdx] = useState(null); + const [error, setError] = useState(false); + const onImgError = useCallback(() => setError(true), []); + + const onChange = useCallback( + (changes) => { + dispatch(setModalRole({ ...roleState, ...changes })); + if ("imageUrl" in changes) { + setError(false); + } + }, + [dispatch, roleState] + ); + + const toggleRate = useCallback( + (index) => { + if (expandedRateIdx === index) { + // collapse + setExpandedRateIdx(null); + } else { + // expand + setExpandedRateIdx(index); + } + }, + [expandedRateIdx] + ); + + const addSkill = useCallback( + (value) => { + if (value && !roleState.listOfSkills.includes(value)) { + dispatch( + setModalRole({ + ...roleState, + listOfSkills: [...roleState.listOfSkills, value], + }) + ); + setTypeaheadInputValue(""); + } + }, + [dispatch, roleState] + ); + + // add new rates with initial values + const addRate = useCallback(() => { + dispatch( + setModalRole({ + ...roleState, + rates: [ + ...roleState.rates, + { + global: 0, + inCountry: 0, + offShore: 0, + niche: 0, + rate30Niche: 0, + rate30Global: 0, + rate30InCountry: 0, + rate30OffShore: 0, + rate20Niche: 0, + rate20Global: 0, + rate20InCountry: 0, + rate20OffShore: 0, + }, + ], + }) + ); + }, [dispatch, roleState]); + + const editRate = useCallback( + (index, changes) => { + dispatch( + setModalRole({ + ...roleState, + rates: [ + ...roleState.rates.slice(0, index), + { ...roleState.rates[index], ...changes }, + ...roleState.rates.slice(index + 1), + ], + }) + ); + }, + [dispatch, roleState] + ); + + const deleteRate = useCallback( + (index) => { + dispatch( + setModalRole({ + ...roleState, + rates: [ + ...roleState.rates.slice(0, index), + ...roleState.rates.slice(index + 1), + ], + }) + ); + setExpandedRateIdx(null); + }, + [dispatch, roleState] + ); + + const removeSkill = useCallback( + (value) => { + dispatch( + setModalRole({ + ...roleState, + listOfSkills: roleState.listOfSkills.filter((s) => s !== value), + }) + ); + }, + [dispatch, roleState] + ); + + return ( +
+
+ {roleState.imageUrl && !error ? ( + Preview + ) : ( + + )} + Role Icon Preview +
+ {modalError && ( +
+ {modalError} + +
+ )} +
+
+ +
+
+ +
+
+ +
+ {roleState.listOfSkills.length > 0 ? ( + roleState.listOfSkills.map((s, i) => ( + + )) + ) : ( +

No skills added.

+ )} +
+
+
+ # of Members + onChange({ numberOfMembers: num })} + /> +
+
+ # of Available Members + onChange({ numberOfMembersAvailable: num })} + /> +
+
+ Time to Candidate + onChange({ timeToCandidate: num })} + /> +
+
+ Time to Interview + onChange({ timeToInterview: num })} + /> +
+
+ + +
+ ); +} + +export default RoleForm; diff --git a/src/routes/Roles/components/RoleForm/styles.module.scss b/src/routes/Roles/components/RoleForm/styles.module.scss new file mode 100644 index 0000000..aa64184 --- /dev/null +++ b/src/routes/Roles/components/RoleForm/styles.module.scss @@ -0,0 +1,217 @@ +@import "styles/mixins"; +@import "styles/variables"; + +.form { + display: flex; + flex-direction: column; + + .modal-error { + position: relative; + background-color: $error-color; + color: white; + padding: 15px; + border-radius: 4px; + margin-top: 5px; + margin-bottom: 5px; + button { + position: absolute; + color: white; + top: 5px; + right: 5px; + font-size: 16px; + font-weight: 500; + outline: none; + background: none; + border: none; + } + } + + .two-col { + display: flex; + justify-content: space-between; + .left { + display: flex; + flex-direction: column; + width: 49.5%; + } + .right { + display: flex; + flex-direction: column; + width: 49.5%; + } + } + + .input { + font-size: 16px; + line-height: 20px; + &::placeholder { + text-transform: none; + color: #aaa; + } + } + + .preview { + display: flex; + flex-direction: column; + align-items: center; + font-size: 12px; + .role-icon { + width: 42px; + height: 42px; + object-fit: cover; + } + } + + .skills { + margin: 10px 0; + display: flex; + align-items: flex-start; + justify-content: flex-start; + flex-wrap: wrap; + list-style: none; + .skill { + width: fit-content; + background-color: #e9e9e9; + border: none; + border-radius: 5px; + padding: 6px 9px; + margin-right: 6px; + margin-bottom: 10px; + font-size: 12px; + &:hover { + color: red; + cursor: pointer; + } + } + } + + .table { + display: flex; + justify-content: space-between; + margin-bottom: 10px; + .cell { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + + th { + border-bottom: 1px; + } + + .number { + width: 80px; + } + } + } + + textarea { + height: 80px; + max-width: 100%; + min-width: 100%; + } + + .rates { + display: flex; + flex-direction: column; + margin-bottom: 5px; + .rate { + display: flex; + width: 100%; + height: 30px; + border: 1px solid $control-border-color; + box-shadow: 0 0 0 1px #e9e9e9; + &:first-child { + border-top-right-radius: 4px; + border-top-left-radius: 4px; + } + &:nth-last-child(2) { + border-bottom-right-radius: 4px; + border-bottom-left-radius: 4px; + } + &:not(:nth-last-child(2)) { + border-bottom: none; + } + align-items: center; + justify-content: space-between; + padding: 7px; + font-size: 15px; + &:hover { + cursor: pointer; + } + + .arrow { + width: 20px; + height: 20px; + transition: transform 0.5s; + svg { + margin-top: 3px; + } + } + } + + .rate-content { + display: flex; + flex-direction: column; + padding: 12px; + border: 1px solid $control-border-color; + &:nth-last-child(2) { + border-bottom-right-radius: 4px; + border-bottom-left-radius: 4px; + } + + th { + width: 100%; + text-align: center; + background-color: #e9e9e9; + border: 1px solid $control-border-color; + &:not(:last-child) { + border-right: none; + } + } + + .cell { + width: 20%; + } + + .col-group { + display: flex; + align-items: center; + justify-content: space-between; + } + + .content-group { + border-left: 1px solid $control-border-color; + border-right: 1px solid $control-border-color; + padding: 7px; + display: flex; + justify-content: space-between; + &:nth-last-child(2) { + border-bottom: 1px solid $control-border-color; + } + } + } + + .link-button { + width: fit-content; + font-size: 14px; + line-height: 22px; + padding: 0; + outline: none; + background: none; + color: #0d61bf; + border: none; + text-align: left; + + &.hover-red { + &:hover { + color: red; + } + } + + &:hover { + text-decoration: underline; + } + } + } +} diff --git a/src/routes/Roles/components/RoleItem/index.jsx b/src/routes/Roles/components/RoleItem/index.jsx new file mode 100644 index 0000000..eaa0843 --- /dev/null +++ b/src/routes/Roles/components/RoleItem/index.jsx @@ -0,0 +1,51 @@ +/** + * Role Item + * An item for the Role List component. + * Shows an image and the name of the role, with additional controls for editing and deleting. + */ +import React, { useState, useCallback } from "react"; +import PT from "prop-types"; +import cn from "classnames"; +import FallbackIcon from "../../../../assets/images/icon-role-fallback.svg"; +import styles from "./styles.module.scss"; + +function RoleItem({ name, imageUrl, onEditClick, onDeleteClick }) { + const [error, setError] = useState(false); + const onImgError = useCallback(() => setError(true), []); + + return ( +
+ {imageUrl && !error ? ( + {name} + ) : ( + + )} +

{name}

+
+ + +
+
+ ); +} + +RoleItem.propTypes = { + name: PT.string, + imageUrl: PT.string, + onEditClick: PT.func, + onDeleteClick: PT.func, +}; + +export default RoleItem; diff --git a/src/routes/Roles/components/RoleItem/styles.module.scss b/src/routes/Roles/components/RoleItem/styles.module.scss new file mode 100644 index 0000000..eab3e72 --- /dev/null +++ b/src/routes/Roles/components/RoleItem/styles.module.scss @@ -0,0 +1,76 @@ +@import "styles/mixins"; +@import "styles/variables"; + +.item { + border: 1px solid #d4d4d4; + border-radius: 5px; + padding: 12px 16px; + width: 212px; + height: 136px; + display: flex; + flex-direction: column; + justify-content: space-evenly; + align-items: flex-start; + + .role-icon { + width: 42px; + height: 42px; + margin-left: 8px; + object-fit: cover; + } + + .item-text { + @include barlow-bold; + font-size: 16px; + font-weight: 600; + line-height: 20px; + text-transform: uppercase; + } + + &:hover { + .controls { + visibility: visible; + } + } + + .controls { + display: flex; + width: 40%; + justify-content: space-between; + visibility: hidden; + + .button { + font-size: 14px; + line-height: 22px; + padding: 0; + outline: none; + background: none; + color: #0d61bf; + border: none; + text-align: left; + + &.red { + color: $error-text-color; + } + + &:hover { + text-decoration: underline; + } + } + } +} + +@media screen and (max-width: 600px) { + .item { + width: 100%; + } +} + +@media screen and (max-width: 768px) { + .item { + .controls { + visibility: visible; + width: 25%; + } + } +} diff --git a/src/routes/Roles/components/RoleList/index.jsx b/src/routes/Roles/components/RoleList/index.jsx new file mode 100644 index 0000000..e49c5e9 --- /dev/null +++ b/src/routes/Roles/components/RoleList/index.jsx @@ -0,0 +1,69 @@ +/** + * Role List + * Lists all available roles. + * Allows filtering by name, editing & deleting roles. + */ +import React, { useState, useEffect } from "react"; +import PT from "prop-types"; +import { useSelector, useDispatch } from "react-redux"; +import { + getRoles, + getRolesError, + getRolesFilter, + getRolesIsLoading, +} from "store/selectors/roles"; +import { loadRoles } from "store/thunks/roles"; +import LoadingIndicator from "components/LoadingIndicator"; +import ContentMessage from "components/ContentMessage"; +import RoleItem from "../RoleItem"; +import styles from "./styles.module.scss"; + +function RoleList({ onEditClick, onDeleteClick }) { + const [filteredRoles, setFilteredRoles] = useState([]); + const roles = useSelector(getRoles); + const filter = useSelector(getRolesFilter); + const error = useSelector(getRolesError); + const isLoading = useSelector(getRolesIsLoading); + const dispatch = useDispatch(); + + // Load roles + useEffect(() => { + dispatch(loadRoles); + }, [dispatch]); + + useEffect(() => { + if (filter !== "") { + setFilteredRoles( + roles.filter((r) => r.name.toLowerCase().includes(filter.toLowerCase())) + ); + } else { + setFilteredRoles(roles); + } + }, [roles, filter]); + + return isLoading || error ? ( + + ) : filteredRoles.length > 0 ? ( +
+ {filteredRoles.map((role) => ( + onEditClick(role)} + onDeleteClick={() => onDeleteClick(role)} + /> + ))} +
+ ) : ( + No role found. + ); +} + +RoleList.propTypes = { + onEditClick: PT.func, + onDeleteClick: PT.func, +}; + +export default RoleList; diff --git a/src/routes/Roles/components/RoleList/styles.module.scss b/src/routes/Roles/components/RoleList/styles.module.scss new file mode 100644 index 0000000..1c05060 --- /dev/null +++ b/src/routes/Roles/components/RoleList/styles.module.scss @@ -0,0 +1,10 @@ +.list { + margin-top: 12px; + display: grid; + grid-gap: 12px; + grid-template-columns: repeat(auto-fill, minmax(212px, 1fr)); + + .center { + left: 50%; + } +} diff --git a/src/routes/Roles/index.jsx b/src/routes/Roles/index.jsx new file mode 100644 index 0000000..12e42ce --- /dev/null +++ b/src/routes/Roles/index.jsx @@ -0,0 +1,217 @@ +import React, { useCallback, useState, useMemo } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import debounce from "lodash/debounce"; +import omit from "lodash/omit"; +import withAuthentication from "hoc/withAuthentication"; +import { setFilter, setIsModalOpen, setModalRole } from "store/actions/roles"; +import { createRole, deleteRole, updateRole } from "store/thunks/roles"; +import { + getRolesIsModalOpen, + getRolesIsModalLoading, + getRolesModalRole, +} from "store/selectors/roles"; +import Content from "components/Content"; +import ContentBlock from "components/ContentBlock"; +import ContentHeader from "components/ContentHeader"; +import Page from "components/Page"; +import PageTitle from "components/PageTitle"; +import Sidebar from "components/Sidebar"; +import Button from "components/Button"; +import Modal from "components/Modal"; +import RoleList from "./components/RoleList"; +import RoleForm from "./components/RoleForm"; +import Spinner from "components/Spinner"; +import styles from "./styles.module.scss"; +import modalStyles from "components/Modal/styles.module.scss"; + +/** + * Displays route component for Roles' route. + * + * @returns {JSX.Element} + */ +const Roles = () => { + const dispatch = useDispatch(); + const isModalOpen = useSelector(getRolesIsModalOpen); + const isModalLoading = useSelector(getRolesIsModalLoading); + const [modalOperationType, setModalOperationType] = useState("CREATE"); + const modalRole = useSelector(getRolesModalRole); + const onFilterChange = useCallback( + debounce( + (filter) => { + dispatch(setFilter(filter)); + }, + 300, + { leading: false } + ), + [dispatch] + ); + + const onCreateClick = useCallback(() => { + setModalOperationType("CREATE"); + // role template with initial values + dispatch( + setModalRole({ + listOfSkills: [], + rates: [], + numberOfMembers: 0, + numberOfMembersAvailable: 0, + timeToCandidate: 0, + timeToInterview: 0, + }) + ); + dispatch(setIsModalOpen(true)); + }, [dispatch]); + + const onEditClick = useCallback( + (role) => { + setModalOperationType("EDIT"); + dispatch(setModalRole(role)); + dispatch(setIsModalOpen(true)); + }, + [dispatch] + ); + + const onDeleteClick = useCallback( + (role) => { + setModalOperationType("DELETE"); + dispatch(setModalRole(role)); + dispatch(setIsModalOpen(true)); + }, + [dispatch] + ); + + const onModalApproveClick = useCallback(() => { + if (modalOperationType === "DELETE") { + dispatch(deleteRole(modalRole.id)); + } else if (modalOperationType === "EDIT") { + dispatch( + updateRole( + modalRole.id, + omit(modalRole, [ + "id", + "createdBy", + "updatedBy", + "createdAt", + "updatedAt", + ]) + ) + ); + } else { + dispatch(createRole(modalRole)); + } + }, [dispatch, modalOperationType, modalRole]); + + const onModalDismissClick = useCallback(() => { + if (!isModalLoading) { + dispatch(setIsModalOpen(false)); + } + }, [dispatch, isModalLoading]); + + const modalTitle = useMemo(() => { + if (modalOperationType === "DELETE") { + return "Confirm Deletion"; + } else if (modalOperationType === "CREATE") { + return "Create New Role"; + } else { + return "Edit Role"; + } + }, [modalOperationType]); + + const modalButtonApproveTxt = useMemo(() => { + if (modalOperationType === "DELETE") { + return isModalLoading ? ( +
+ Deleting... +
+ ) : ( + "Delete" + ); + } else if (modalOperationType === "CREATE") { + return isModalLoading ? ( +
+ Creating... +
+ ) : ( + "Create" + ); + } else { + return isModalLoading ? ( +
+ Saving... +
+ ) : ( + "Save" + ); + } + }, [isModalLoading, modalOperationType]); + + const modalButtonApproveColor = useMemo(() => { + if (modalOperationType === "DELETE") { + return "warning"; + } else { + return "primary"; + } + }, [modalOperationType]); + + return ( + + + + + + + +
+
+ onFilterChange(event.target.value)} + /> + +
+ + + + +
+ } + title={modalTitle} + isOpen={isModalOpen} + onDismiss={onModalDismissClick} + > + {modalOperationType === "DELETE" && ( +

+ Are you sure you want to delete the{" "} + {modalRole.name} role? +

+ )} + {(modalOperationType === "EDIT" || + modalOperationType === "CREATE") && } + + +
+
+
+ ); +}; + +export default withAuthentication(Roles); diff --git a/src/routes/Roles/styles.module.scss b/src/routes/Roles/styles.module.scss new file mode 100644 index 0000000..50b8e42 --- /dev/null +++ b/src/routes/Roles/styles.module.scss @@ -0,0 +1,46 @@ +.page { + padding: 25px; + display: flex; + flex-direction: column; + .header { + display: flex; + justify-content: space-between; + .input { + width: 300px; + line-height: 22px; + &::placeholder { + color: #aaaaaa; + } + &:not(:focus) { + background-image: url("../../assets/images/icon-search.svg"); + background-repeat: no-repeat; + background-position: 10px center; + text-indent: 20px; + } + } + } +} + +strong { + font-weight: 700; +} + +.spinner-container { + display: flex; + align-items: center; + .spinner { + margin-right: 5px; + margin-top: 3px; + } +} + +@media screen and (max-width: 600px) { + .page { + .header { + flex-direction: column; + .input { + width: 100%; + } + } + } +} diff --git a/src/routes/WorkPeriods/components/PeriodFilters/index.jsx b/src/routes/WorkPeriods/components/PeriodFilters/index.jsx index f75adae..a743bd0 100644 --- a/src/routes/WorkPeriods/components/PeriodFilters/index.jsx +++ b/src/routes/WorkPeriods/components/PeriodFilters/index.jsx @@ -3,9 +3,10 @@ import { useDispatch, useSelector } from "react-redux"; import debounce from "lodash/debounce"; import PT from "prop-types"; import cn from "classnames"; +import { getMemberSuggestions } from "services/teams"; import Button from "components/Button"; import CheckboxList from "components/CheckboxList"; -import SearchHandleField from "components/SearchHandleField"; +import Typeahead from "components/Typeahead"; import SidebarSection from "components/SidebarSection"; import Toggle from "components/Toggle"; import { PAYMENT_STATUS, ALERT } from "constants/workPeriods"; @@ -97,12 +98,14 @@ const PeriodFilters = ({ className }) => { onSubmit={preventDefault} >
-
diff --git a/src/services/roles.js b/src/services/roles.js new file mode 100644 index 0000000..3eda362 --- /dev/null +++ b/src/services/roles.js @@ -0,0 +1,94 @@ +import axios from "./axios"; +import config from "../../config"; + +// skills cache +let skills; + +/** + * Returns the list of roles. + * + * @return {Promise} Array of roles + */ +export function getRoles() { + return axios.get(`${config.API.V5}/taas-roles`); +} + +/** + * Creates a role. + * + * @param {Object} body role body + * @return {Promise} Created role from the response + */ +export function createRole(body) { + return axios.post(`${config.API.V5}/taas-roles`, body); +} + +/** + * Updates a role. + * + * @param {string} id Role ID + * @param {Object} body role body + * @return {Promise} Updated role from the response + */ +export function updateRole(id, body) { + return axios.patch(`${config.API.V5}/taas-roles/${id}`, body); +} + +/** + * Deletes a role. + * + * @param {string} id Role ID + * @return {Promise} + */ +export function deleteRole(id) { + return axios.delete(`${config.API.V5}/taas-roles/${id}`); +} + +/** + * Search skills by name. + * + * @param {string} name skill name + * @return {Promise} Role Object + */ +export async function searchSkills(name) { + if (!skills) { + skills = await getAllTopcoderSkills(); + } + + return { + data: skills.filter((s) => + s.name.toLowerCase().includes(name.toLowerCase()) + ), + }; +} + +/** + * Retrieves all TC skills from the paginated endpoint. + * + * @returns {Promise} skills + */ +async function getAllTopcoderSkills() { + let page = 1; + const perPage = 100; + const result = []; + + const firstPageResponse = await axios.get( + `${config.API.V5}/taas-teams/skills`, + { + params: { page, perPage }, + } + ); + result.push(...firstPageResponse.data); + + const total = firstPageResponse.headers["x-total"]; + while (page++ * perPage <= total) { + const newPageResponse = await axios.get( + `${config.API.V5}/taas-teams/skills`, + { + params: { page, perPage }, + } + ); + result.push(...newPageResponse.data); + } + return result; +} diff --git a/src/store/actionTypes/roles.js b/src/store/actionTypes/roles.js new file mode 100644 index 0000000..941c7aa --- /dev/null +++ b/src/store/actionTypes/roles.js @@ -0,0 +1,11 @@ +export const SET_ROLES = "SET_ROLES"; +export const CREATE_ROLE = "CREATE_ROLE"; +export const UPDATE_ROLE = "UPDATE_ROLE"; +export const DELETE_ROLE = "DELETE_ROLE"; +export const SET_FILTER = "SET_FILTER"; +export const SET_MODAL_ROLE = "SET_MODAL_ROLE"; +export const SET_IS_LOADING = "SET_IS_LOADING"; +export const SET_IS_MODAL_OPEN = "SET_IS_MODAL_OPEN"; +export const SET_IS_MODAL_LOADING = "SET_IS_MODAL_LOADING"; +export const SET_MODAL_ERROR = "SET_MODAL_ERROR"; +export const SET_ERROR = "SET_ERROR"; diff --git a/src/store/actions/roles.js b/src/store/actions/roles.js new file mode 100644 index 0000000..2e36202 --- /dev/null +++ b/src/store/actions/roles.js @@ -0,0 +1,122 @@ +import * as ACTION_TYPE from "store/actionTypes/roles"; + +/** + * Creates an action for setting roles. + * + * @returns {Object} + */ +export const setRoles = (payload) => ({ + type: ACTION_TYPE.SET_ROLES, + payload, +}); + +/** + * Creates an action denoting the creation of role. + * + * @param {Object} payload action payload + * @returns {Object} + */ +export const createRole = (payload) => ({ + type: ACTION_TYPE.CREATE_ROLE, + payload, +}); + +/** + * Creates an action denoting the update of role. + * + * @param {string} roleId role id + * @param {Object} payload action payload + * @returns {Object} + */ +export const updateRole = (roleId, payload) => ({ + type: ACTION_TYPE.UPDATE_ROLE, + roleId, + payload, +}); + +/** + * Creates an action denoting the deletion of role. + * + * @param {string} roleId role id + * @returns {Object} + */ +export const deleteRole = (roleId) => ({ + type: ACTION_TYPE.DELETE_ROLE, + roleId, +}); + +/** + * Creates an action for setting filter. + * + * @param {boolean} payload payload + * @returns {Object} + */ +export const setFilter = (payload) => ({ + type: ACTION_TYPE.SET_FILTER, + payload, +}); + +/** + * Creates an action for setting isLoading. + * + * @param {boolean} payload payload + * @returns {Object} + */ +export const setIsLoading = (payload) => ({ + type: ACTION_TYPE.SET_IS_LOADING, + payload, +}); + +/** + * Creates an action for setting isModalLoading. + * + * @param {boolean} payload payload + * @returns {Object} + */ +export const setIsModalLoading = (payload) => ({ + type: ACTION_TYPE.SET_IS_MODAL_LOADING, + payload, +}); + +/** + * Creates an action for setting isModalOpen. + * + * @param {boolean} payload payload + * @returns {Object} + */ +export const setIsModalOpen = (payload) => ({ + type: ACTION_TYPE.SET_IS_MODAL_OPEN, + payload, +}); + +/** + * Creates an action for setting modalRole. + * + * @returns {Object} + */ +export const setModalRole = (payload) => ({ + type: ACTION_TYPE.SET_MODAL_ROLE, + payload, +}); + +/** + * Creates an action denoting the error with role actions. + * + * @param {Object} payload payload + * @returns {Object} + */ +export const setError = (payload) => ({ + type: ACTION_TYPE.SET_ERROR, + payload, +}); + +/** + * Creates an action denoting the error with the modal operation. + * + * @param {Object} payload payload + * @returns {Object} + */ +export const setModalError = (payload) => ({ + type: ACTION_TYPE.SET_MODAL_ERROR, + payload, +}); diff --git a/src/store/reducers/index.js b/src/store/reducers/index.js index d7b5d2e..6412ffe 100644 --- a/src/store/reducers/index.js +++ b/src/store/reducers/index.js @@ -2,11 +2,13 @@ import { combineReducers } from "redux"; import { reducer as toastrReducer } from "react-redux-toastr"; import authUserReducer from "hoc/withAuthentication/reducers"; import workPeriodsReducer from "store/reducers/workPeriods"; +import rolesReducer from "store/reducers/roles"; const reducer = combineReducers({ authUser: authUserReducer, toastr: toastrReducer, workPeriods: workPeriodsReducer, + roles: rolesReducer, }); export default reducer; diff --git a/src/store/reducers/roles.js b/src/store/reducers/roles.js new file mode 100644 index 0000000..9c97362 --- /dev/null +++ b/src/store/reducers/roles.js @@ -0,0 +1,101 @@ +import * as ACTION_TYPE from "store/actionTypes/roles"; + +const initialState = { + error: null, + filter: "", + isLoading: false, + isModalOpen: false, + isModalLoading: false, + modalRole: null, + modalError: null, + list: [], +}; + +const reducer = (state = initialState, action) => { + if (action.type in actionHandlers) { + const handler = actionHandlers[action.type]; + return action.roleId + ? handler(state, action.roleId, action.payload) + : handler(state, action.payload); + } + return state; +}; + +const actionHandlers = { + [ACTION_TYPE.SET_ROLES]: (state, payload) => { + return { + ...state, + list: payload, + }; + }, + [ACTION_TYPE.SET_FILTER]: (state, payload) => { + return { + ...state, + filter: payload, + }; + }, + [ACTION_TYPE.SET_IS_LOADING]: (state, payload) => { + return { + ...state, + isLoading: payload, + }; + }, + [ACTION_TYPE.SET_IS_MODAL_OPEN]: (state, payload) => { + return { + ...state, + isModalOpen: payload, + }; + }, + [ACTION_TYPE.SET_IS_MODAL_LOADING]: (state, payload) => { + return { + ...state, + isModalLoading: payload, + }; + }, + [ACTION_TYPE.SET_MODAL_ROLE]: (state, payload) => { + return { + ...state, + modalRole: payload, + }; + }, + [ACTION_TYPE.CREATE_ROLE]: (state, payload) => { + return { + ...state, + list: [...state.list, payload], + }; + }, + [ACTION_TYPE.UPDATE_ROLE]: (state, roleId, payload) => { + const roleIdx = state.list.findIndex((r) => r.id === roleId); + return { + ...state, + list: [ + ...state.list.slice(0, roleIdx), + payload, + ...state.list.slice(roleIdx + 1), + ], + }; + }, + [ACTION_TYPE.DELETE_ROLE]: (state, roleId) => { + const roleIdx = state.list.findIndex((r) => r.id === roleId); + return { + ...state, + list: [...state.list.slice(0, roleIdx), ...state.list.slice(roleIdx + 1)], + }; + }, + [ACTION_TYPE.SET_ERROR]: (state, error) => { + console.error(error); + return { + ...state, + error, + }; + }, + [ACTION_TYPE.SET_MODAL_ERROR]: (state, error) => { + console.error(error); + return { + ...state, + modalError: error, + }; + }, +}; + +export default reducer; diff --git a/src/store/selectors/roles.js b/src/store/selectors/roles.js new file mode 100644 index 0000000..fb90ecd --- /dev/null +++ b/src/store/selectors/roles.js @@ -0,0 +1,71 @@ +/** + * Returns roles state. + * + * @param {Object} state redux root state + * @returns {Object} + */ +export const getRolesState = (state) => state.roles; + +/** + * Returns currently loaded roles. + * + * @param {Object} state redux root state + * @returns {Array} + */ +export const getRoles = (state) => state.roles.list; + +/** + * Returns roles filter state. + * + * @param {Object} state redux root state + * @returns {Object} + */ +export const getRolesFilter = (state) => state.roles.filter; + +/** + * Returns roles error state. + * + * @param {Object} state redux root state + * @returns {Object} + */ +export const getRolesError = (state) => state.roles.error; + +/** + * Returns roles isLoading state. + * + * @param {Object} state redux root state + * @returns {Object} + */ +export const getRolesIsLoading = (state) => state.roles.isLoading; + +/** + * Returns roles isModalOpen state. + * + * @param {Object} state redux root state + * @returns {Object} + */ +export const getRolesIsModalOpen = (state) => state.roles.isModalOpen; + +/** + * Returns roles isModalLoading state. + * + * @param {Object} state redux root state + * @returns {Object} + */ +export const getRolesIsModalLoading = (state) => state.roles.isModalLoading; + +/** + * Returns modalRole. + * + * @param {Object} state redux root state + * @returns {Object} + */ +export const getRolesModalRole = (state) => state.roles.modalRole; + +/** + * Returns modalError. + * + * @param {Object} state redux root state + * @returns {Object} + */ +export const getRolesModalError = (state) => state.roles.modalError; diff --git a/src/store/thunks/roles.js b/src/store/thunks/roles.js new file mode 100644 index 0000000..3a3facd --- /dev/null +++ b/src/store/thunks/roles.js @@ -0,0 +1,95 @@ +import * as actions from "store/actions/roles"; +import * as services from "services/roles"; +import { extractResponseData } from "utils/misc"; +import { makeToast } from "components/ToastrMessage"; + +/** + * Thunk that loads the roles. + * + * @returns {Promise} + */ +export const loadRoles = async (dispatch) => { + dispatch(actions.setIsLoading(true)); + dispatch(actions.setError(null)); + dispatch(actions.setRoles([])); + try { + // For parameter description see: + // https://topcoder-platform.github.io/taas-apis/#/Roles/get_taas_roles + const response = await services.getRoles(); + const roles = extractResponseData(response); + dispatch(actions.setRoles(roles)); + } catch (error) { + dispatch(actions.setError(error)); + } finally { + dispatch(actions.setIsLoading(false)); + } +}; + +/** + * Thunk that creates a role. + * + * @param {Object} body role body + * @returns {Promise} + */ +export const createRole = (body) => async (dispatch) => { + dispatch(actions.setModalError(null)); + dispatch(actions.setIsModalLoading(true)); + try { + const response = await services.createRole(body); + const role = extractResponseData(response); + dispatch(actions.createRole(role)); + dispatch(actions.setIsModalOpen(false)); + makeToast("Successfully craeted the role.", "success"); + } catch (error) { + dispatch( + actions.setModalError(`Failed to create the role.\n${error.toString()}`) + ); + } finally { + dispatch(actions.setIsModalLoading(false)); + } +}; + +/** + * Thunk that updates a role. + * + * @param {string} roleId roleId + * @param {Object} body role body + * @returns {Promise} + */ +export const updateRole = (roleId, body) => async (dispatch) => { + dispatch(actions.setModalError(null)); + dispatch(actions.setIsModalLoading(true)); + try { + const response = await services.updateRole(roleId, body); + const role = extractResponseData(response); + dispatch(actions.updateRole(roleId, role)); + dispatch(actions.setIsModalOpen(false)); + makeToast("Successfully updated the role.", "success"); + } catch (error) { + dispatch( + actions.setModalError(`Failed to update the role.\n${error.toString()}`) + ); + } finally { + dispatch(actions.setIsModalLoading(false)); + } +}; + +/** + * Thunk that deletes a role. + * + * @param {string} roleId role id + * @returns {Promise} + */ +export const deleteRole = (roleId) => async (dispatch) => { + dispatch(actions.setIsModalLoading(true)); + try { + await services.deleteRole(roleId); + dispatch(actions.deleteRole(roleId)); + makeToast("Successfully deleted the role.", "success"); + } catch (error) { + makeToast(`Failed to delete the role.\n${error.toString()}`); + } finally { + dispatch(actions.setIsModalLoading(false)); + dispatch(actions.setIsModalOpen(false)); + } +};