diff --git a/src/components/SearchHandleField/index.jsx b/src/components/SearchHandleField/index.jsx index 804b919..5661515 100644 --- a/src/components/SearchHandleField/index.jsx +++ b/src/components/SearchHandleField/index.jsx @@ -1,19 +1,67 @@ -import React, { useCallback } from "react"; +import React, { useCallback, useState } from "react"; import PT from "prop-types"; import cn from "classnames"; -import AsyncSelect from "react-select/async"; +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"; +const loadingMessage = () => "Loading..."; + +const noOptionsMessage = () => "No suggestions"; + +function MenuList(props) { + let focusedOption = props.focusedOption; + focusedOption = props.selectProps.isMenuFocused + ? focusedOption + : props.getValue()[0]; + const setIsMenuFocused = props.selectProps.setIsMenuFocused; + + const onMouseEnter = useCallback(() => { + setIsMenuFocused(true); + }, [setIsMenuFocused]); + + return ( +
+ +
+ ); +} +MenuList.propTypes = { + focusedOption: PT.object, + getValue: PT.func, + selectProps: PT.shape({ + isMenuFocused: PT.oneOfType([PT.bool, PT.number]), + setIsMenuFocused: PT.func, + }), +}; + +function Option(props) { + return ( + + ); +} +Option.propTypes = { + isFocused: PT.bool, + isSelected: PT.bool, + selectProps: PT.shape({ + isMenuFocused: PT.oneOfType([PT.bool, PT.number]), + }), +}; + const selectComponents = { DropdownIndicator: () => null, + ClearIndicator: () => null, IndicatorSeparator: () => null, + MenuList, + Option, }; -const loadingMessage = () => "Loading..."; - -const noOptionsMessage = () => "No suggestions"; - /** * Displays search input field. * @@ -23,7 +71,9 @@ const noOptionsMessage = () => "No suggestions"; * @param {string} props.placeholder placeholder text * @param {string} props.name name for input element * @param {'medium'|'small'} [props.size] field size - * @param {function} props.onChange function called when input value changes + * @param {function} props.onChange function called when value changes + * @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 * @returns {JSX.Element} */ @@ -33,33 +83,104 @@ const SearchHandleField = ({ name, size = "medium", onChange, + onInputChange, + onBlur, placeholder, value, }) => { - const onValueChange = useCallback( - (option, { action }) => { - if (action === "clear") { - onChange(""); - } else { + const [inputValue, setInputValue] = useState(value); + const [isLoading, setIsLoading] = useState(false); + const [isMenuOpen, setIsMenuOpen] = useState(false); + const [isMenuFocused, setIsMenuFocused] = useState(false); + const [options, setOptions] = useState([]); + + const loadOptions = useCallback( + throttle( + async (value) => { + setIsLoading(true); + const options = await loadSuggestions(value); + setOptions(options); + setIsLoading(false); + setIsMenuOpen(true); + setIsMenuFocused(options.length && options[0].value === value); + }, + 300, + { leading: false } + ), + [] + ); + + const onValueChange = (option, { action }) => { + if (action === "input-change" || action === "select-option") { + setIsMenuFocused(false); + setIsMenuOpen(false); + if (!isMenuFocused) { + onChange(inputValue); + } else if (option) { onChange(option.value); } - }, - [onChange] - ); + } else if (action === "clear") { + setIsMenuFocused(false); + setIsMenuOpen(false); + onChange(""); + } + }; - const onInputChange = useCallback( + const onInputValueChange = useCallback( (value, { action }) => { if (action === "input-change") { - onChange(value); + setIsMenuFocused(false); + setInputValue(value); + onInputChange && onInputChange(value); + loadOptions(value); } }, - [onChange] + [onInputChange, loadOptions] ); + const onKeyDown = (event) => { + const key = event.key; + if (key === "Enter" || key === "Escape") { + setIsMenuFocused(false); + setIsMenuOpen(false); + if (!isMenuFocused) { + onChange(inputValue); + } + } else if (key === "ArrowDown") { + if (!isMenuFocused) { + event.preventDefault(); + event.stopPropagation(); + setIsMenuFocused(true); + } + } else if (key === "Backspace") { + if (!inputValue) { + event.preventDefault(); + event.stopPropagation(); + } + } + }; + + const onSelectBlur = useCallback(() => { + setIsMenuFocused(false); + setIsMenuOpen(false); + onBlur && onBlur(); + }, [onBlur]); + + useUpdateEffect(() => { + setInputValue(value); + }, [value]); + return ( -
+
-
); }; -const loadSuggestions = async (inputVal) => { +const loadSuggestions = async (inputValue) => { let options = []; - if (inputVal.length < 3) { + if (inputValue.length < 3) { return options; } try { - const res = await getMemberSuggestions(inputVal); + const res = await getMemberSuggestions(inputValue); const users = res.data.result.content; + let match = null; for (let i = 0, len = users.length; i < len; i++) { let value = users[i].handle; - options.push({ value, label: value }); + if (value === inputValue) { + match = { value, label: value }; + } else { + options.push({ value, label: value }); + } + } + if (match) { + options.unshift(match); } } catch (error) { console.error(error); @@ -108,6 +240,8 @@ SearchHandleField.propTypes = { size: PT.oneOf(["medium", "small"]), name: PT.string.isRequired, onChange: PT.func.isRequired, + onInputChange: PT.func, + onBlur: PT.func, placeholder: PT.string, value: PT.oneOfType([PT.number, PT.string]), }; diff --git a/src/components/SearchHandleField/styles.module.scss b/src/components/SearchHandleField/styles.module.scss index 1cc06f8..efea7a9 100644 --- a/src/components/SearchHandleField/styles.module.scss +++ b/src/components/SearchHandleField/styles.module.scss @@ -41,6 +41,10 @@ input.input { } } +.menuList { + display: block; +} + .select { z-index: 1; position: relative; @@ -157,6 +161,17 @@ input.input { } } + :global(.custom__option--is-selected) { + background-color: $primary-text-color !important; + color: #fff; + } + + :global(.custom__clear-indicator) { + display: none; + } +} + +.isMenuFocused { :global(.custom__option--is-focused) { background-color: $primary-text-color !important; color: #fff; diff --git a/src/constants/index.js b/src/constants/index.js index 198dbbb..0f93a11 100644 --- a/src/constants/index.js +++ b/src/constants/index.js @@ -4,6 +4,10 @@ export { PLATFORM_WEBSITE_URL, TOPCODER_WEBSITE_URL }; export const APP_BASE_PATH = "/taas-admin"; +export const WORK_PERIODS_PATH = `${APP_BASE_PATH}/work-periods`; + +export const FREELANCERS_PATH = `${APP_BASE_PATH}/freelancers`; + export const TAAS_BASE_PATH = "/taas"; export const ADMIN_ROLES = ["bookingmanager", "administrator"]; diff --git a/src/constants/workPeriods.js b/src/constants/workPeriods.js index ec45ca8..3ffefaa 100644 --- a/src/constants/workPeriods.js +++ b/src/constants/workPeriods.js @@ -19,7 +19,7 @@ export const DATE_FORMAT_API = "YYYY-MM-DD"; export const DATE_FORMAT_UI = "MMM DD, YYYY"; // Field names that are required to be retrieved for display, filtering and sorting. -export const REQUIRED_FIELDS = [ +export const API_REQUIRED_FIELDS = [ "id", "jobId", "projectId", @@ -40,15 +40,15 @@ export const REQUIRED_FIELDS = [ ]; // Valid parameter names for requests. -export const QUERY_PARAM_NAMES = [ +export const API_QUERY_PARAM_NAMES = [ "fields", "page", "perPage", "sortBy", "sortOrder", -].concat(REQUIRED_FIELDS); +].concat(API_REQUIRED_FIELDS); -export const FIELDS_QUERY = REQUIRED_FIELDS.join(","); +export const API_FIELDS_QUERY = API_REQUIRED_FIELDS.join(","); export const SORT_BY_DEFAULT = SORT_BY.USER_HANDLE; @@ -90,6 +90,24 @@ export const API_PAYMENT_STATUS_MAP = (function () { return obj; })(); +export const API_CHALLENGE_PAYMENT_STATUS_MAP = { + cancelled: PAYMENT_STATUS.CANCELLED, + completed: PAYMENT_STATUS.COMPLETED, + failed: PAYMENT_STATUS.FAILED, + "in-progress": PAYMENT_STATUS.IN_PROGRESS, + scheduled: PAYMENT_STATUS.SCHEDULED, +}; + +export const URL_QUERY_PARAM_MAP = new Map([ + ["startDate", "startDate"], + ["paymentStatuses", "status"], + ["userHandle", "user"], + ["criteria", "by"], + ["order", "order"], + ["pageSize", "perPage"], + ["pageNumber", "page"], +]); + export const JOB_NAME_LOADING = "Loading..."; export const JOB_NAME_NONE = ""; export const JOB_NAME_ERROR = ""; diff --git a/src/constants/workPeriods/paymentStatus.js b/src/constants/workPeriods/paymentStatus.js index 007dfa0..e16c899 100644 --- a/src/constants/workPeriods/paymentStatus.js +++ b/src/constants/workPeriods/paymentStatus.js @@ -3,3 +3,7 @@ export const COMPLETED = "COMPLETED"; export const PENDING = "PENDING"; export const IN_PROGRESS = "IN_PROGRESS"; export const NO_DAYS = "NO_DAYS"; +export const SCHEDULED = "SCHEDULED"; +export const FAILED = "FAILED"; +export const CANCELLED = "CANCELLED"; +export const UNDEFINED = "UNDEFINED"; diff --git a/src/root.component.jsx b/src/root.component.jsx index fe6e308..017dd42 100644 --- a/src/root.component.jsx +++ b/src/root.component.jsx @@ -5,7 +5,11 @@ import store from "store"; import { disableSidebarForRoute } from "@topcoder/micro-frontends-navbar-app"; import WorkPeriods from "routes/WorkPeriods"; import Freelancers from "routes/Freelancers"; -import { APP_BASE_PATH } from "./constants"; +import { + APP_BASE_PATH, + FREELANCERS_PATH, + WORK_PERIODS_PATH, +} from "./constants"; import "styles/global.scss"; export default function Root() { @@ -16,14 +20,9 @@ export default function Root() { return ( - - - + + + ); diff --git a/src/routes/WorkPeriods/components/PaymentStatus/styles.module.scss b/src/routes/WorkPeriods/components/PaymentStatus/styles.module.scss index 14d448a..782a9df 100644 --- a/src/routes/WorkPeriods/components/PaymentStatus/styles.module.scss +++ b/src/routes/WorkPeriods/components/PaymentStatus/styles.module.scss @@ -22,10 +22,12 @@ background: #efd047; } -.in_progress { +.in_progress, +.scheduled { background: #9d41c9; } +.cancelled, .no_days { background: #aaa; } @@ -34,6 +36,10 @@ background: #ef476f; } +.failed { + background: #e90c5a; +} + .undefined { padding: 0; @include roboto-regular; diff --git a/src/routes/WorkPeriods/components/PaymentsListItem/index.jsx b/src/routes/WorkPeriods/components/PaymentsListItem/index.jsx index 1914614..08cc8e0 100644 --- a/src/routes/WorkPeriods/components/PaymentsListItem/index.jsx +++ b/src/routes/WorkPeriods/components/PaymentsListItem/index.jsx @@ -2,11 +2,11 @@ /* eslint-disable jsx-a11y/no-static-element-interactions */ import React, { useCallback, useRef } from "react"; import PT from "prop-types"; -import cn from "classnames"; import styles from "./styles.module.scss"; -import { formatChallengeUrl } from "utils/formatters"; +import { currencyFormatter, formatChallengeUrl } from "utils/formatters"; +import PaymentStatus from "../PaymentStatus"; -const PaymentsListItem = ({ className, item }) => { +const PaymentsListItem = ({ item }) => { const inputRef = useRef(); const onCopyLinkClick = useCallback(() => { @@ -16,32 +16,50 @@ const PaymentsListItem = ({ className, item }) => { }, []); return ( -
- - - - - {item.id} - -
+ + +
+ + + + + {item.id} + +
+ + + {currencyFormatter.format(item.memberRate)} + + {item.days} + {currencyFormatter.format(item.amount)} + + + + ); }; PaymentsListItem.propTypes = { - className: PT.string, item: PT.shape({ - id: PT.oneOfType([PT.string, PT.number]), + id: PT.oneOfType([PT.string, PT.number]).isRequired, + amount: PT.number.isRequired, challengeId: PT.oneOfType([PT.string, PT.number]), + days: PT.number.isRequired, + memberRate: PT.number.isRequired, + status: PT.string.isRequired, }), }; diff --git a/src/routes/WorkPeriods/components/PaymentsListItem/styles.module.scss b/src/routes/WorkPeriods/components/PaymentsListItem/styles.module.scss index b71ea11..8d65bfb 100644 --- a/src/routes/WorkPeriods/components/PaymentsListItem/styles.module.scss +++ b/src/routes/WorkPeriods/components/PaymentsListItem/styles.module.scss @@ -1,27 +1,31 @@ @import "styles/mixins"; -.container { - display: flex; - align-items: center; +.challengeId { + display: inline-block; input { - display: block; + display: inline-block; margin: 0; - border: none; + border: none !important; padding: 0; - max-width: 70px; + width: 70px; height: 22px; background: #fff; @include roboto-medium; line-height: 22px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; outline: none !important; + box-shadow: none !important; + pointer-events: none; color: #0d61bf; } } .iconLink { - flex: 0 0 auto; display: inline-block; + vertical-align: -2px; margin-right: 5px; width: 16px; height: 16px; @@ -32,8 +36,8 @@ } .iconCopyLink { - flex: 0 0 auto; display: inline-block; + vertical-align: -2px; margin-left: 18px; width: 16px; height: 16px; @@ -45,8 +49,8 @@ } .iconOpenLink { - flex: 0 0 auto; display: inline-block; + vertical-align: -2px; margin-left: 16px; width: 16px; height: 16px; @@ -57,6 +61,12 @@ background-image: url("./../../../../assets/images/icon-open-outside.png"); } +.weeklyRate, +.days, +.amount { + text-align: right; +} + .hidden { display: none; } diff --git a/src/routes/WorkPeriods/components/PaymentsPopup/index.jsx b/src/routes/WorkPeriods/components/PaymentsPopup/index.jsx index 93e8324..2d760ed 100644 --- a/src/routes/WorkPeriods/components/PaymentsPopup/index.jsx +++ b/src/routes/WorkPeriods/components/PaymentsPopup/index.jsx @@ -13,12 +13,23 @@ import PaymentsListItem from "../PaymentsListItem"; const PaymentsPopup = ({ className, payments }) => { return (
-
Challenges for Payments
-
- {payments.map((payment) => ( - - ))} -
+ {/*
Challenges for Payments
*/} + + + + + + + + + + + + {payments.map((payment) => ( + + ))} + +
Challenge IDWeekly RateDaysAmountStatus
); }; diff --git a/src/routes/WorkPeriods/components/PaymentsPopup/styles.module.scss b/src/routes/WorkPeriods/components/PaymentsPopup/styles.module.scss index a29e703..94a017c 100644 --- a/src/routes/WorkPeriods/components/PaymentsPopup/styles.module.scss +++ b/src/routes/WorkPeriods/components/PaymentsPopup/styles.module.scss @@ -3,7 +3,7 @@ .container { position: relative; border-radius: 8px; - padding: 25px 30px 25px 23px; + padding: 18px 25px; box-shadow: 0 5px 35px 5px rgba(21, 21, 22, 0.1), 0 10px 14px 0 rgba(21, 21, 22, 0.3); background: #fff; @@ -15,6 +15,32 @@ white-space: nowrap; } -.paymentsList { +table.paymentsList { margin-top: 5px; + + th { + @include roboto-bold; + padding: 10px 7px; + font-size: 12px; + line-height: 16px; + white-space: nowrap; + text-align: right; + background: #f4f4f4; + + &:first-child, + &:last-child { + text-align: left; + } + + &:first-child { + padding-left: 28px; + } + } + + tr { + td { + padding: 5px 7px; + white-space: nowrap; + } + } } diff --git a/src/routes/WorkPeriods/components/PeriodFilters/index.jsx b/src/routes/WorkPeriods/components/PeriodFilters/index.jsx index 7d8974f..4c9a543 100644 --- a/src/routes/WorkPeriods/components/PeriodFilters/index.jsx +++ b/src/routes/WorkPeriods/components/PeriodFilters/index.jsx @@ -1,4 +1,4 @@ -import React, { useCallback } from "react"; +import React, { memo, useCallback } from "react"; import { useDispatch, useSelector } from "react-redux"; import debounce from "lodash/debounce"; import PT from "prop-types"; @@ -14,8 +14,12 @@ import { setWorkPeriodsPaymentStatuses, setWorkPeriodsUserHandle, } from "store/actions/workPeriods"; -import { loadWorkPeriodsPage as loadWorkingPeriodsPage } from "store/thunks/workPeriods"; +import { + loadWorkPeriodsPage, + updateQueryFromState, +} from "store/thunks/workPeriods"; import { useUpdateEffect } from "utils/hooks"; +import { preventDefault } from "utils/misc"; import styles from "./styles.module.scss"; /** @@ -34,6 +38,7 @@ const PeriodFilters = ({ className }) => { const onUserHandleChange = useCallback( (value) => { dispatch(setWorkPeriodsUserHandle(value)); + dispatch(updateQueryFromState()); }, [dispatch] ); @@ -41,18 +46,20 @@ const PeriodFilters = ({ className }) => { const onPaymentStatusesChange = useCallback( (statuses) => { dispatch(setWorkPeriodsPaymentStatuses(statuses)); + dispatch(updateQueryFromState()); }, [dispatch] ); const onClearFilter = useCallback(() => { dispatch(resetWorkPeriodsFilters()); + dispatch(updateQueryFromState()); }, [dispatch]); const loadWorkingPeriodsFirstPage = useCallback( debounce( () => { - dispatch(loadWorkingPeriodsPage(1)); + dispatch(loadWorkPeriodsPage); }, 300, { leading: false } @@ -64,7 +71,11 @@ const PeriodFilters = ({ className }) => { useUpdateEffect(loadWorkingPeriodsFirstPage, [filters]); return ( -
+
{ const onSortingChange = useCallback( (sorting) => { dispatch(setWorkPeriodsSorting(sorting)); + dispatch(updateQueryFromState()); }, [dispatch] ); diff --git a/src/routes/WorkPeriods/components/PeriodWeekPicker/index.jsx b/src/routes/WorkPeriods/components/PeriodWeekPicker/index.jsx index 212bea8..3c9db3f 100644 --- a/src/routes/WorkPeriods/components/PeriodWeekPicker/index.jsx +++ b/src/routes/WorkPeriods/components/PeriodWeekPicker/index.jsx @@ -6,6 +6,7 @@ import moment from "moment"; import WeekPicker from "components/WeekPicker"; import { getWorkPeriodsDateRange } from "store/selectors/workPeriods"; import { setWorkPeriodsDateRange } from "store/actions/workPeriods"; +import { updateQueryFromState } from "store/thunks/workPeriods"; import styles from "./styles.module.scss"; /** @@ -22,16 +23,19 @@ const PeriodWeekPicker = ({ className }) => { const onWeekSelect = useCallback( (date) => { dispatch(setWorkPeriodsDateRange(moment(date))); + dispatch(updateQueryFromState()); }, [dispatch] ); const onNextWeekSelect = useCallback(() => { dispatch(setWorkPeriodsDateRange(startDate.clone().add(1, "week"))); + dispatch(updateQueryFromState()); }, [startDate, dispatch]); const onPreviousWeekSelect = useCallback(() => { dispatch(setWorkPeriodsDateRange(startDate.clone().add(-1, "week"))); + dispatch(updateQueryFromState()); }, [startDate, dispatch]); return ( diff --git a/src/routes/WorkPeriods/components/Periods/index.jsx b/src/routes/WorkPeriods/components/Periods/index.jsx index 3f971da..c0a1b91 100644 --- a/src/routes/WorkPeriods/components/Periods/index.jsx +++ b/src/routes/WorkPeriods/components/Periods/index.jsx @@ -1,5 +1,6 @@ import React, { useEffect } from "react"; import { useSelector, useDispatch } from "react-redux"; +import { globalHistory } from "@reach/router"; import ContentMessage from "components/ContentMessage"; import PeriodList from "../PeriodList"; import { @@ -8,8 +9,8 @@ import { getWorkPeriodsPagination, getWorkPeriodsSorting, } from "store/selectors/workPeriods"; +import { updateStateFromQuery } from "store/actions/workPeriods"; import { loadWorkPeriodsPage } from "store/thunks/workPeriods"; -import { useUpdateEffect } from "utils/hooks"; /** * Displays working periods' list or a "Loading..." message or an error message. @@ -23,16 +24,18 @@ const Periods = () => { const isLoading = useSelector(getWorkPeriodsIsLoading); const dispatch = useDispatch(); - // Load working periods' first page once when page loads and then - // only if page size or sorting changes. + // Load working periods' page only if page number, page size or sorting changes. useEffect(() => { - dispatch(loadWorkPeriodsPage(1)); - }, [dispatch, pagination.pageSize, sorting]); + dispatch(loadWorkPeriodsPage); + }, [dispatch, pagination.pageNumber, pagination.pageSize, sorting]); - // Load working periods' new page if page number changes. - useUpdateEffect(() => { - dispatch(loadWorkPeriodsPage()); - }, [dispatch, pagination.pageNumber]); + useEffect(() => { + return globalHistory.listen(({ action, location }) => { + if (action === "POP") { + dispatch(updateStateFromQuery(location.search)); + } + }); + }, [dispatch]); return ( <> diff --git a/src/routes/WorkPeriods/components/PeriodsHistoryItem/index.jsx b/src/routes/WorkPeriods/components/PeriodsHistoryItem/index.jsx index fd61966..aca9171 100644 --- a/src/routes/WorkPeriods/components/PeriodsHistoryItem/index.jsx +++ b/src/routes/WorkPeriods/components/PeriodsHistoryItem/index.jsx @@ -74,7 +74,7 @@ const PeriodsHistoryItem = ({ isDisabled, item, data, currentStartDate }) => { - {data.paymentStatus === PAYMENT_STATUS.PAID ? ( + {data.paymentStatus === PAYMENT_STATUS.COMPLETED ? ( `${daysWorked} ${daysWorked === 1 ? "Day" : "Days"}` ) : ( -
- - {currencyFormatter.format(paymentTotal)} +
+ + + {currencyFormatter.format(paymentTotal)} + + ({daysPaid}) - ({daysPaid})
{hasPayments && isShowPopup && (
{ const onPageNumberClick = useCallback( (pageNumber) => { dispatch(setWorkPeriodsPageNumber(+pageNumber)); + dispatch(updateQueryFromState()); }, [dispatch] ); @@ -32,6 +34,7 @@ const PeriodsPagination = ({ className, id }) => { const onPageSizeChange = useCallback( (pageSize) => { dispatch(setWorkPeriodsPageSize(+pageSize)); + dispatch(updateQueryFromState()); }, [dispatch] ); diff --git a/src/services/workPeriods.js b/src/services/workPeriods.js index c906236..88e2385 100644 --- a/src/services/workPeriods.js +++ b/src/services/workPeriods.js @@ -4,7 +4,7 @@ import { JOBS_API_URL, PAYMENTS_API_URL, PROJECTS_API_URL, - QUERY_PARAM_NAMES, + API_QUERY_PARAM_NAMES, WORK_PERIODS_API_URL, } from "constants/workPeriods"; import { buildRequestQuery, extractResponseData } from "utils/misc"; @@ -98,9 +98,12 @@ export const fetchWorkPeriods = (rbId, source) => { export const fetchResourceBookings = (params) => { const source = CancelToken.source(); return [ - axios.get(`${RB_API_URL}?${buildRequestQuery(params, QUERY_PARAM_NAMES)}`, { - cancelToken: source.token, - }), + axios.get( + `${RB_API_URL}?${buildRequestQuery(params, API_QUERY_PARAM_NAMES)}`, + { + cancelToken: source.token, + } + ), source, ]; }; diff --git a/src/store/actionTypes/workPeriods.js b/src/store/actionTypes/workPeriods.js index 85eb490..273a3a5 100644 --- a/src/store/actionTypes/workPeriods.js +++ b/src/store/actionTypes/workPeriods.js @@ -33,3 +33,4 @@ export const WP_TOGGLE_PERIOD = "WP_TOGGLE_PERIOD"; export const WP_TOGGLE_PERIODS_ALL = "WP_TOGGLE_PERIODS_ALL"; export const WP_TOGGLE_PERIODS_VISIBLE = "WP_TOGGLE_PERIODS_VISIBLE"; export const WP_TOGGLE_PROCESSING_PAYMENTS = "WP_TOGGLE_PROCESSING_PAYMENTS"; +export const WP_UPDATE_STATE_FROM_QUERY = "WP_UPDATE_STATE_FROM_QUERY"; diff --git a/src/store/actions/workPeriods.js b/src/store/actions/workPeriods.js index 592cd1c..b50c3b5 100644 --- a/src/store/actions/workPeriods.js +++ b/src/store/actions/workPeriods.js @@ -7,12 +7,11 @@ let nextErrorId = 1; * Creates an action denoting the start of loading specific challenge page. * * @param {Object} cancelSource object that can be used to cancel network request - * @param {number} pageNumber the requested challenge page number * @returns {Object} */ -export const loadWorkPeriodsPagePending = (cancelSource, pageNumber) => ({ +export const loadWorkPeriodsPagePending = (cancelSource) => ({ type: ACTION_TYPE.WP_LOAD_PAGE_PENDING, - payload: { cancelSource, pageNumber }, + payload: cancelSource, }); /** @@ -383,3 +382,15 @@ export const toggleWorkPeriodsProcessingPeyments = (on = null) => ({ type: ACTION_TYPE.WP_TOGGLE_PROCESSING_PAYMENTS, payload: on, }); + +/** + * Creates an action denoting an update of working periods state slice using + * the provided query. + * + * @param {string} query URL search query + * @returns {Object} + */ +export const updateStateFromQuery = (query) => ({ + type: ACTION_TYPE.WP_UPDATE_STATE_FROM_QUERY, + payload: query, +}); diff --git a/src/store/reducers/workPeriods.js b/src/store/reducers/workPeriods.js index 7465c3a..f8b79bc 100644 --- a/src/store/reducers/workPeriods.js +++ b/src/store/reducers/workPeriods.js @@ -1,13 +1,17 @@ import moment from "moment"; import * as ACTION_TYPE from "store/actionTypes/workPeriods"; import { - SORT_BY_DEFAULT, - SORT_ORDER_DEFAULT, - JOB_NAME_ERROR, BILLING_ACCOUNTS_NONE, - JOB_NAME_LOADING, BILLING_ACCOUNTS_LOADING, BILLING_ACCOUNTS_ERROR, + JOB_NAME_ERROR, + JOB_NAME_LOADING, + PAYMENT_STATUS, + SORT_BY, + SORT_BY_DEFAULT, + SORT_ORDER, + SORT_ORDER_DEFAULT, + URL_QUERY_PARAM_MAP, } from "constants/workPeriods"; import { filterPeriodsByStartDate, @@ -18,6 +22,8 @@ import { createAssignedBillingAccountOption } from "utils/workPeriods"; const cancelSourceDummy = { cancel: () => {} }; +const PAGE_SIZES = [10, 20, 50, 100]; + const initPagination = () => ({ totalCount: 0, pageCount: 0, @@ -50,30 +56,30 @@ const initPeriodDetails = ( billingAccountsError: null, billingAccountsIsDisabled: true, billingAccountsIsLoading: true, + hidePastPeriods: false, periods: [], periodsVisible: [], periodsIsLoading: true, - hidePastPeriods: false, }); -const initialState = { - error: null, +const initialState = updateStateFromQuery(window.location.search, { cancelSource: cancelSourceDummy, + error: null, + filters: initFilters(), + isProcessingPayments: false, + isSelectedPeriodsAll: false, + isSelectedPeriodsVisible: false, + pagination: initPagination(), periods: [], periodsData: [{}], periodsDetails: {}, periodsFailed: {}, periodsSelected: {}, - isSelectedPeriodsAll: false, - isSelectedPeriodsVisible: false, - isProcessingPayments: false, - pagination: initPagination(), sorting: { criteria: SORT_BY_DEFAULT, order: SORT_ORDER_DEFAULT, }, - filters: initFilters(), -}; +}); const reducer = (state = initialState, action) => { if (action.type in actionHandlers) { @@ -83,24 +89,17 @@ const reducer = (state = initialState, action) => { }; const actionHandlers = { - [ACTION_TYPE.WP_LOAD_PAGE_PENDING]: ( - state, - { cancelSource, pageNumber } - ) => ({ + [ACTION_TYPE.WP_LOAD_PAGE_PENDING]: (state, cancelSource) => ({ ...state, cancelSource, error: null, + isSelectedPeriodsAll: false, + isSelectedPeriodsVisible: false, periods: [], periodsData: [{}], periodsDetails: {}, periodsFailed: {}, periodsSelected: {}, - isSelectedPeriodsAll: false, - isSelectedPeriodsVisible: false, - pagination: - pageNumber === state.pagination.pageNumber - ? state.pagination - : { ...state.pagination, pageNumber }, }), [ACTION_TYPE.WP_LOAD_PAGE_SUCCESS]: ( state, @@ -122,9 +121,9 @@ const actionHandlers = { ...state, cancelSource: null, error: null, + pagination, periods, periodsData: [periodsData], - pagination, }; }, [ACTION_TYPE.WP_LOAD_PAGE_ERROR]: (state, error) => { @@ -404,6 +403,10 @@ const actionHandlers = { [ACTION_TYPE.WP_RESET_FILTERS]: (state) => ({ ...state, filters: initFilters(), + pagination: { + ...state.pagination, + pageNumber: 1, + }, }), [ACTION_TYPE.WP_SET_DATE_RANGE]: (state, date) => { const oldRange = state.filters.dateRange; @@ -417,6 +420,10 @@ const actionHandlers = { ...state.filters, dateRange: range, }, + pagination: { + ...state.pagination, + pageNumber: 1, + }, }; }, [ACTION_TYPE.WP_SELECT_PERIODS]: (state, periods) => { @@ -462,10 +469,14 @@ const actionHandlers = { pagination: pageSize === state.pagination.pageSize ? state.pagination - : { ...state.pagination, pageSize }, + : { ...state.pagination, pageSize, pageNumber: 1 }, }), [ACTION_TYPE.WP_SET_SORT_BY]: (state, criteria) => ({ ...state, + pagination: { + ...state.pagination, + pageNumber: 1, + }, sorting: { ...state.sorting, criteria, @@ -481,6 +492,10 @@ const actionHandlers = { } return { ...state, + pagination: { + ...state.pagination, + pageNumber: 1, + }, sorting: { criteria: sortBy, order: sortOrder, @@ -496,6 +511,10 @@ const actionHandlers = { paymentStatuses ), }, + pagination: { + ...state.pagination, + pageNumber: 1, + }, }), [ACTION_TYPE.WP_SET_USER_HANDLE]: (state, userHandle) => { if (userHandle === state.filters.userHandle) { @@ -507,6 +526,10 @@ const actionHandlers = { ...state.filters, userHandle, }, + pagination: { + ...state.pagination, + pageNumber: 1, + }, }; }, [ACTION_TYPE.WP_SET_DATA_PENDING]: (state, { periodId, cancelSource }) => { @@ -647,6 +670,104 @@ const actionHandlers = { isProcessingPayments, }; }, + [ACTION_TYPE.WP_UPDATE_STATE_FROM_QUERY]: (state, query) => + updateStateFromQuery(query, state), }; +/** + * Updates state from current URL's query. + * + * @param {string} queryStr query string + * @param {Object} state working periods' state slice + * @returns {Object} initial state + */ +function updateStateFromQuery(queryStr, state) { + const params = {}; + const query = new URLSearchParams(queryStr); + for (let [stateKey, queryKey] of URL_QUERY_PARAM_MAP) { + let value = query.get(queryKey); + if (value) { + params[stateKey] = value; + } + } + let updateFilters = false; + let updatePagination = false; + let updateSorting = false; + const { filters, pagination, sorting } = state; + // checking payment statuses + const { dateRange } = filters; + let range = getWeekByDate(moment(params.startDate)); + if (!range[0].isSame(dateRange[0])) { + filters.dateRange = range; + updateFilters = true; + } + let hasSameStatuses = true; + const filtersPaymentStatuses = filters.paymentStatuses; + const queryPaymentStatuses = {}; + const paymentStatusesStr = params.paymentStatuses; + if (paymentStatusesStr) { + for (let status of paymentStatusesStr.split(",")) { + status = status.toUpperCase(); + if (status in PAYMENT_STATUS) { + queryPaymentStatuses[status] = true; + if (!filtersPaymentStatuses[status]) { + hasSameStatuses = false; + } + } + } + } + for (let status in filtersPaymentStatuses) { + if (!queryPaymentStatuses[status]) { + hasSameStatuses = false; + break; + } + } + if (!hasSameStatuses) { + filters.paymentStatuses = queryPaymentStatuses; + updateFilters = true; + } + // checking user handle + params.userHandle = params.userHandle || ""; + if (params.userHandle !== filters.userHandle) { + filters.userHandle = params.userHandle.slice(0, 256); + updateFilters = true; + } + // checking sorting criteria + const criteria = params.criteria?.toUpperCase(); + if (criteria in SORT_BY && criteria !== sorting.criteria) { + sorting.criteria = criteria; + updateSorting = true; + } + // checking sorting order + if (params.order in SORT_ORDER && params.order !== sorting.order) { + sorting.order = params.order; + updateSorting = true; + } + // checking page number + const pageNumber = +params.pageNumber; + if (pageNumber && pageNumber !== pagination.pageNumber) { + pagination.pageNumber = pageNumber; + updatePagination = true; + } + // checking page size + const pageSize = +params.pageSize; + if (PAGE_SIZES.includes(pageSize) && pageSize !== pagination.pageSize) { + pagination.pageSize = pageSize; + updatePagination = true; + } + if (updateFilters || updatePagination || updateSorting) { + state = { ...state }; + if (updateFilters) { + state.filters = { ...filters }; + } + if (updatePagination) { + state.pagination = { ...pagination }; + } + if (updateSorting) { + state.sorting = { ...sorting }; + } + } + return state; +} + export default reducer; diff --git a/src/store/selectors/workPeriods.js b/src/store/selectors/workPeriods.js index 060d93f..9a826e7 100644 --- a/src/store/selectors/workPeriods.js +++ b/src/store/selectors/workPeriods.js @@ -50,6 +50,9 @@ export const getWorkPeriodsSelected = (state) => */ export const getWorkPeriodsFilters = (state) => state.workPeriods.filters; +export const getWorkPeriodsPaymentStatuses = (state) => + state.workPeriods.filters.paymentStatuses; + export const getWorkPeriodsDateRange = (state) => state.workPeriods.filters.dateRange; @@ -59,9 +62,17 @@ export const getWorkPeriodsSorting = (state) => state.workPeriods.sorting; export const getWorkPeriodsPagination = (state) => state.workPeriods.pagination; +export const getWorkPeriodsPageNumber = (state) => + state.workPeriods.pagination.pageNumber; + export const getWorkPeriodsPageSize = (state) => state.workPeriods.pagination.pageSize; +export const getWorkPeriodsUrlQuery = (state) => state.workPeriods.query; + +export const getWorkPeriodsIsQueryFromState = (state) => + state.workPeriods.isQueryFromState; + export const getWorkPeriodsCount = (state) => state.workPeriods.periods.length; export const getWorkPeriodsData = (state) => state.workPeriods.periodsData; diff --git a/src/store/thunks/workPeriods.js b/src/store/thunks/workPeriods.js index 785f807..07abf0e 100644 --- a/src/store/thunks/workPeriods.js +++ b/src/store/thunks/workPeriods.js @@ -1,4 +1,5 @@ import axios from "axios"; +import { navigate } from "@reach/router"; import * as actions from "store/actions/workPeriods"; import * as selectors from "store/selectors/workPeriods"; import * as services from "services/workPeriods"; @@ -7,7 +8,7 @@ import { API_SORT_BY, DATE_FORMAT_API, PAYMENT_STATUS_MAP, - FIELDS_QUERY, + API_FIELDS_QUERY, JOB_NAME_NONE, } from "constants/workPeriods"; import { @@ -17,6 +18,7 @@ import { replaceItems, } from "utils/misc"; import { + makeUrlQuery, normalizeBillingAccounts, normalizeDetailsPeriodItems, normalizePeriodData, @@ -29,7 +31,7 @@ import { makeToastPaymentsWarning, makeToastPaymentsError, } from "routes/WorkPeriods/utils/toasts"; -import { RESOURCE_BOOKING_STATUS } from "constants/index.js"; +import { RESOURCE_BOOKING_STATUS, WORK_PERIODS_PATH } from "constants/index.js"; /** * Thunk that loads the specified working periods' page. If page number is not @@ -37,65 +39,74 @@ import { RESOURCE_BOOKING_STATUS } from "constants/index.js"; * working period filters are loaded from the current state to construct * a request query. * - * @param {number} [pageNumber] page number to load - * @returns {function} + * @returns {Promise} */ -export const loadWorkPeriodsPage = - (pageNumber) => async (dispatch, getState) => { - const workPeriods = selectors.getWorkPeriodsStateSlice(getState()); - if (workPeriods.cancelSource) { - // If there's an ongoing request we just cancel it since the data that comes - // with its response will not correspond to application's current state, - // namely filters and sorting. - workPeriods.cancelSource.cancel(); - } - const { filters, sorting, pagination } = workPeriods; +export const loadWorkPeriodsPage = async (dispatch, getState) => { + const workPeriods = selectors.getWorkPeriodsStateSlice(getState()); + if (workPeriods.cancelSource) { + // If there's an ongoing request we just cancel it since the data that comes + // with its response will not correspond to application's current state, + // namely filters and sorting. + workPeriods.cancelSource.cancel(); + } + const { filters, sorting, pagination } = workPeriods; - // If page number is not specified get it from current state. - pageNumber = pageNumber || pagination.pageNumber; + const sortOrder = sorting.order; + const sortBy = SORT_BY_MAP[sorting.criteria] || API_SORT_BY.USER_HANDLE; - const sortOrder = sorting.order; - const sortBy = SORT_BY_MAP[sorting.criteria] || API_SORT_BY.USER_HANDLE; + const [startDate] = filters.dateRange; + const paymentStatuses = replaceItems( + Object.keys(filters.paymentStatuses), + PAYMENT_STATUS_MAP + ); - const [startDate] = filters.dateRange; - const paymentStatuses = replaceItems( - Object.keys(filters.paymentStatuses), - PAYMENT_STATUS_MAP - ); + // For parameter description see: + // https://topcoder-platform.github.io/taas-apis/#/ResourceBookings/get_resourceBookings + const [promise, cancelSource] = services.fetchResourceBookings({ + fields: API_FIELDS_QUERY, + page: pagination.pageNumber, + perPage: pagination.pageSize, + sortBy, + sortOrder, + // we only want to show Resource Bookings with status "placed" + status: RESOURCE_BOOKING_STATUS.PLACED, + ["workPeriods.userHandle"]: filters.userHandle, + ["workPeriods.startDate"]: startDate.format(DATE_FORMAT_API), + ["workPeriods.paymentStatus"]: paymentStatuses, + }); + dispatch(actions.loadWorkPeriodsPagePending(cancelSource)); + let totalCount, periods, pageCount; + try { + const response = await promise; + ({ totalCount, pageCount } = extractResponsePagination(response)); + const data = extractResponseData(response); + periods = normalizePeriodItems(data); + } catch (error) { + // If request was cancelled by the next call to loadWorkPeriodsPage + // there's nothing more to do. + if (!axios.isCancel(error)) { + dispatch(actions.loadWorkPeriodsPageError(error.toString())); + } + return; + } + dispatch(actions.loadWorkPeriodsPageSuccess(periods, totalCount, pageCount)); +}; - // For parameter description see: - // https://topcoder-platform.github.io/taas-apis/#/ResourceBookings/get_resourceBookings - const [promise, cancelSource] = services.fetchResourceBookings({ - fields: FIELDS_QUERY, - page: pageNumber, - perPage: pagination.pageSize, - sortBy, - sortOrder, - // we only want to show Resource Bookings with status "placed" - status: RESOURCE_BOOKING_STATUS.PLACED, - ["workPeriods.userHandle"]: filters.userHandle, - ["workPeriods.startDate"]: startDate.format(DATE_FORMAT_API), - ["workPeriods.paymentStatus"]: paymentStatuses, - }); - dispatch(actions.loadWorkPeriodsPagePending(cancelSource, pageNumber)); - let totalCount, periods, pageCount; - try { - const response = await promise; - ({ totalCount, pageNumber, pageCount } = - extractResponsePagination(response)); - const data = extractResponseData(response); - periods = normalizePeriodItems(data); - } catch (error) { - // If request was cancelled by the next call to loadWorkPeriodsPage - // there's nothing more to do. - if (!axios.isCancel(error)) { - dispatch(actions.loadWorkPeriodsPageError(error.toString())); - } - return; +/** + * Updates URL from current state. + * + * @param {boolean} replace whether to push or replace the history state + * @returns {function} + */ +export const updateQueryFromState = + (replace = false) => + (dispatch, getState) => { + const query = makeUrlQuery(selectors.getWorkPeriodsStateSlice(getState())); + if (query !== window.location.search.slice(1)) { + setTimeout(() => { + navigate(`${WORK_PERIODS_PATH}?${query}`, { replace }); + }, 100); // if executed synchronously navigate() causes a noticable lag } - dispatch( - actions.loadWorkPeriodsPageSuccess(periods, totalCount, pageCount) - ); }; /** diff --git a/src/utils/misc.js b/src/utils/misc.js index 6e30d8a..b722470 100644 --- a/src/utils/misc.js +++ b/src/utils/misc.js @@ -67,6 +67,10 @@ export function replaceItems(array, map) { return result; } +export function preventDefault(event) { + event.preventDefault(); +} + /** * Stops event propagation. * diff --git a/src/utils/workPeriods.js b/src/utils/workPeriods.js index 1704540..8e67618 100644 --- a/src/utils/workPeriods.js +++ b/src/utils/workPeriods.js @@ -1,5 +1,42 @@ import moment from "moment"; -import { API_PAYMENT_STATUS_MAP, DATE_FORMAT_UI } from "constants/workPeriods"; +import { + API_CHALLENGE_PAYMENT_STATUS_MAP, + API_PAYMENT_STATUS_MAP, + DATE_FORMAT_API, + DATE_FORMAT_UI, + PAYMENT_STATUS, + URL_QUERY_PARAM_MAP, +} from "constants/workPeriods"; + +/** + * Creates a URL search query from current state. + * + * @param {Object} state working periods' newly created state slice + * @returns {Object} + */ +export function makeUrlQuery(state) { + const { filters, pagination, sorting } = state; + const { dateRange, paymentStatuses, userHandle } = filters; + const { pageNumber, pageSize } = pagination; + const { criteria, order } = sorting; + const params = { + startDate: dateRange[0].format(DATE_FORMAT_API), + paymentStatuses: Object.keys(paymentStatuses).join(",").toLowerCase(), + userHandle, + criteria: criteria.toLowerCase(), + order, + pageNumber, + pageSize, + }; + const queryParams = []; + for (let [stateKey, queryKey] of URL_QUERY_PARAM_MAP) { + let value = params[stateKey]; + if (value) { + queryParams.push(`${queryKey}=${value}`); + } + } + return queryParams.join("&"); +} export function normalizePeriodItems(items) { const empty = {}; @@ -81,11 +118,12 @@ export function createAssignedBillingAccountOption(accountId) { export function normalizeDetailsPeriodItems(items) { const periods = []; for (let item of items) { + let payments = item.payments || []; periods.push({ id: item.id, startDate: item.startDate ? moment(item.startDate).valueOf() : 0, endDate: item.endDate ? moment(item.endDate).valueOf() : 0, - payments: item.payments || [], + payments: payments.length ? normalizeDetailsPayments(payments) : payments, weeklyRate: item.memberRate, data: normalizePeriodData(item), }); @@ -94,6 +132,15 @@ export function normalizeDetailsPeriodItems(items) { return periods; } +function normalizeDetailsPayments(payments) { + for (let payment of payments) { + payment.status = + API_CHALLENGE_PAYMENT_STATUS_MAP[payment.status] || + PAYMENT_STATUS.UNDEFINED; + } + return payments; +} + export function normalizePaymentStatus(paymentStatus) { return API_PAYMENT_STATUS_MAP[paymentStatus]; }