From 06e071c400f9846e08fac64579030eafda9974a5 Mon Sep 17 00:00:00 2001 From: Oleg Petrov Date: Thu, 1 Jul 2021 09:24:30 +0300 Subject: [PATCH 01/30] Fixed (regression): 'Process Payments' button and other related controls are not disabled when processing payments. --- src/store/actions/workPeriods.js | 2 +- src/store/thunks/workPeriods.js | 22 ++++++++++++++++------ 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/src/store/actions/workPeriods.js b/src/store/actions/workPeriods.js index e1e72a4..10f2514 100644 --- a/src/store/actions/workPeriods.js +++ b/src/store/actions/workPeriods.js @@ -384,7 +384,7 @@ export const toggleWorkingPeriodsVisible = (on = null) => ({ * @param {?boolean} on whether to turn processing-payments state on or off * @returns {Object} */ -export const toggleWorkPeriodsProcessingPeyments = (on = null) => ({ +export const toggleWorkPeriodsProcessingPayments = (on = null) => ({ type: ACTION_TYPE.WP_TOGGLE_PROCESSING_PAYMENTS, payload: on, }); diff --git a/src/store/thunks/workPeriods.js b/src/store/thunks/workPeriods.js index a406918..358c716 100644 --- a/src/store/thunks/workPeriods.js +++ b/src/store/thunks/workPeriods.js @@ -293,16 +293,26 @@ export const updateWorkPeriodWorkingDays = * @param {function} getState function returning redux store root state */ export const processPayments = async (dispatch, getState) => { - dispatch(actions.toggleWorkPeriodsProcessingPeyments(true)); const state = getState(); + const isProcessing = selectors.getWorkPeriodsIsProcessingPayments(state); + if (isProcessing) { + return; + } + dispatch(actions.toggleWorkPeriodsProcessingPayments(true)); const isSelectedAll = selectors.getWorkPeriodsIsSelectedAll(state); const { pageSize, totalCount } = selectors.getWorkPeriodsPagination(state); - if (isSelectedAll && totalCount > pageSize) { - processPaymentsAll(dispatch, getState); - } else { - processPaymentsSpecific(dispatch, getState); + const promise = + isSelectedAll && totalCount > pageSize + ? processPaymentsAll(dispatch, getState) + : processPaymentsSpecific(dispatch, getState); + // The promise returned by processPaymentsAll or processPaymentsSpecific + // should never be rejected but adding try-catch block just in case. + try { + await promise; + } catch (error) { + console.error(error); } - dispatch(actions.toggleWorkPeriodsProcessingPeyments(false)); + dispatch(actions.toggleWorkPeriodsProcessingPayments(false)); }; const processPaymentsAll = async (dispatch, getState) => { From 6bfea500f234afbd9e6e73d55c31b969ace3be11 Mon Sep 17 00:00:00 2001 From: Oleg Petrov Date: Thu, 1 Jul 2021 12:50:17 +0300 Subject: [PATCH 02/30] Fixed: page extended past the edge of screen. --- src/components/Content/styles.module.scss | 3 +- src/components/SelectField/styles.module.scss | 6 ++- .../PaymentErrorDetails/styles.module.scss | 1 - .../components/PeriodDetails/index.jsx | 8 +-- .../PeriodDetails/styles.module.scss | 6 ++- .../components/PeriodItem/styles.module.scss | 8 +-- .../components/PeriodList/index.jsx | 8 ++- .../components/PeriodList/styles.module.scss | 7 +++ src/routes/WorkPeriods/index.jsx | 11 ++-- src/routes/WorkPeriods/styles.module.scss | 54 ++++++++++++++----- 10 files changed, 77 insertions(+), 35 deletions(-) diff --git a/src/components/Content/styles.module.scss b/src/components/Content/styles.module.scss index 01ca520..dcb5511 100644 --- a/src/components/Content/styles.module.scss +++ b/src/components/Content/styles.module.scss @@ -4,7 +4,8 @@ padding: 0 10px; @include desktop { - flex: 1 1 auto; + flex: 1 1 0; padding: 0 35px; + min-width: 0; } } diff --git a/src/components/SelectField/styles.module.scss b/src/components/SelectField/styles.module.scss index 3d578f5..9b693e3 100644 --- a/src/components/SelectField/styles.module.scss +++ b/src/components/SelectField/styles.module.scss @@ -104,14 +104,16 @@ .medium { :global(.custom__value-container) { - margin-top: 4px; + margin-top: 2px; + margin-bottom: 2px; padding: 6px 15px; } } .small { :global(.custom__value-container) { - margin-top: 2px; + margin-top: 1px; + margin-bottom: 1px; padding: 2px 7px 2px 13px; } diff --git a/src/routes/WorkPeriods/components/PaymentErrorDetails/styles.module.scss b/src/routes/WorkPeriods/components/PaymentErrorDetails/styles.module.scss index a1f4bf1..bbbed10 100644 --- a/src/routes/WorkPeriods/components/PaymentErrorDetails/styles.module.scss +++ b/src/routes/WorkPeriods/components/PaymentErrorDetails/styles.module.scss @@ -2,7 +2,6 @@ .container { display: block; - max-width: 480px; text-align: left; } diff --git a/src/routes/WorkPeriods/components/PeriodDetails/index.jsx b/src/routes/WorkPeriods/components/PeriodDetails/index.jsx index e34978b..cc5bc1a 100644 --- a/src/routes/WorkPeriods/components/PeriodDetails/index.jsx +++ b/src/routes/WorkPeriods/components/PeriodDetails/index.jsx @@ -104,12 +104,12 @@ const PeriodDetails = ({ className, details, isDisabled, isFailed }) => { -
+
Billing Account
td { - padding-left: 17px; - padding-right: 17px; + padding-left: 15px; + padding-right: 18px; background: #fff; } @@ -41,7 +41,7 @@ tr.container { } td.toggle { - padding: 12px 20px 12px 15px; + padding: 12px 18px 12px 15px; line-height: 15px; } diff --git a/src/routes/WorkPeriods/components/PeriodList/index.jsx b/src/routes/WorkPeriods/components/PeriodList/index.jsx index 6394127..8533de4 100644 --- a/src/routes/WorkPeriods/components/PeriodList/index.jsx +++ b/src/routes/WorkPeriods/components/PeriodList/index.jsx @@ -32,7 +32,13 @@ const PeriodList = ({ className }) => { return ( -
+
diff --git a/src/routes/WorkPeriods/components/PeriodList/styles.module.scss b/src/routes/WorkPeriods/components/PeriodList/styles.module.scss index 9bb7222..2e8d7aa 100644 --- a/src/routes/WorkPeriods/components/PeriodList/styles.module.scss +++ b/src/routes/WorkPeriods/components/PeriodList/styles.module.scss @@ -3,6 +3,13 @@ .container { position: relative; padding: 0 20px 0 15px; + width: 100%; + overflow-x: auto; + overflow-y: visible; + + &.hasItems { + min-height: 348px; + } } .table { diff --git a/src/routes/WorkPeriods/index.jsx b/src/routes/WorkPeriods/index.jsx index 3e9649f..25d6a96 100644 --- a/src/routes/WorkPeriods/index.jsx +++ b/src/routes/WorkPeriods/index.jsx @@ -20,28 +20,25 @@ import styles from "./styles.module.scss"; * @returns {JSX.Element} */ const WorkPeriods = () => ( - + - +
- +
diff --git a/src/routes/WorkPeriods/styles.module.scss b/src/routes/WorkPeriods/styles.module.scss index 84a9490..08c193f 100644 --- a/src/routes/WorkPeriods/styles.module.scss +++ b/src/routes/WorkPeriods/styles.module.scss @@ -1,31 +1,57 @@ -.container { -} - -.periodsBlock { -} +@import "styles/mixins"; .periodsHeader { display: flex; + flex-wrap: wrap; justify-content: space-between; align-items: center; padding: 13px 13px 13px 32px; -} -.periodsFooter { - display: flex; - justify-content: flex-end; - align-items: center; - padding: 13px 13px 13px 32px; + @include desktop { + flex-wrap: nowrap; + } } .periodCount { + margin-right: 20px; + margin-bottom: 13px; white-space: nowrap; + + @include tablet { + margin-bottom: 0; + } + + @include desktop { + margin-right: 40px; + } } .periodWeekPicker { - margin-left: 40px; + margin-bottom: 13px; + + @include tablet { + margin-bottom: 0; + } +} + +.periodsPaginationTop { + width: 100%; + + @include tablet { + margin-left: 20px; + width: auto; + min-width: 382px; + } + + @include desktop { + margin-left: 40px; + min-width: 505px; + } } -.periodsPagination { - margin-left: 40px; +.periodsFooter { + display: flex; + justify-content: flex-end; + align-items: center; + padding: 13px 13px 13px 32px; } From 12a7c71f1f3d906cf83d5945226825631067fde0 Mon Sep 17 00:00:00 2001 From: Oleg Petrov Date: Thu, 1 Jul 2021 13:08:06 +0300 Subject: [PATCH 03/30] Fixed: toastr messages for single resources were in plural form. --- .../components/ToastPaymentsError/index.jsx | 3 ++- .../components/ToastPaymentsProcessing/index.jsx | 3 ++- .../components/ToastPaymentsSuccess/index.jsx | 3 ++- .../components/ToastPaymentsWarning/index.jsx | 7 +++++-- src/utils/formatters.js | 12 ++++++++++++ 5 files changed, 23 insertions(+), 5 deletions(-) diff --git a/src/routes/WorkPeriods/components/ToastPaymentsError/index.jsx b/src/routes/WorkPeriods/components/ToastPaymentsError/index.jsx index fd12b97..4812271 100644 --- a/src/routes/WorkPeriods/components/ToastPaymentsError/index.jsx +++ b/src/routes/WorkPeriods/components/ToastPaymentsError/index.jsx @@ -1,6 +1,7 @@ import React from "react"; import PT from "prop-types"; import ToastMessage from "components/ToastrMessage"; +import { formatPlural } from "utils/formatters"; /** * Displays a toastr message with info about the number of resources payments @@ -12,7 +13,7 @@ import ToastMessage from "components/ToastrMessage"; const ToastPaymentsError = ({ resourceCount, remove }) => { return ( - Failed to schedule payments for {resourceCount} resources + Failed to schedule payment for {formatPlural(resourceCount, "resource")} ); }; diff --git a/src/routes/WorkPeriods/components/ToastPaymentsProcessing/index.jsx b/src/routes/WorkPeriods/components/ToastPaymentsProcessing/index.jsx index 76357d0..a6216cf 100644 --- a/src/routes/WorkPeriods/components/ToastPaymentsProcessing/index.jsx +++ b/src/routes/WorkPeriods/components/ToastPaymentsProcessing/index.jsx @@ -1,6 +1,7 @@ import React from "react"; import PT from "prop-types"; import ToastMessage from "components/ToastrMessage"; +import { formatPlural } from "utils/formatters"; import styles from "./styles.module.scss"; /** @@ -14,7 +15,7 @@ const ToastPaymentsProcessing = ({ resourceCount, remove }) => { return ( - Payment in progress for {resourceCount} resources + Payment in progress for {formatPlural(resourceCount, "resource")} ); }; diff --git a/src/routes/WorkPeriods/components/ToastPaymentsSuccess/index.jsx b/src/routes/WorkPeriods/components/ToastPaymentsSuccess/index.jsx index bba3215..fe7ac0b 100644 --- a/src/routes/WorkPeriods/components/ToastPaymentsSuccess/index.jsx +++ b/src/routes/WorkPeriods/components/ToastPaymentsSuccess/index.jsx @@ -1,6 +1,7 @@ import React from "react"; import PT from "prop-types"; import ToastMessage from "components/ToastrMessage"; +import { formatPlural } from "utils/formatters"; /** * Displays a toastr message with info about the number of resources payments @@ -12,7 +13,7 @@ import ToastMessage from "components/ToastrMessage"; const ToastPaymentsSuccess = ({ resourceCount, remove }) => { return ( - Payment scheduled for {resourceCount} resources + Payment scheduled for {formatPlural(resourceCount, "resource")} ); }; diff --git a/src/routes/WorkPeriods/components/ToastPaymentsWarning/index.jsx b/src/routes/WorkPeriods/components/ToastPaymentsWarning/index.jsx index 43d4456..edf1307 100644 --- a/src/routes/WorkPeriods/components/ToastPaymentsWarning/index.jsx +++ b/src/routes/WorkPeriods/components/ToastPaymentsWarning/index.jsx @@ -1,6 +1,7 @@ import React from "react"; import PT from "prop-types"; import ToastMessage from "components/ToastrMessage"; +import { formatPlural } from "utils/formatters"; import styles from "./styles.module.scss"; /** @@ -28,12 +29,14 @@ const ToastPaymentsWarning = ({
- Payment scheduled for {resourcesSucceededCount} resources + Payment scheduled for{" "} + {formatPlural(resourcesSucceededCount, "resource")}
- Failed to schedule payment for {resourcesFailedCount} resources + Failed to schedule payment for{" "} + {formatPlural(resourcesFailedCount, "resource")} {resourcesFailed && resourcesFailed.length ? ":" : ""}
{resourcesFailed && resourcesFailed.length && ( diff --git a/src/utils/formatters.js b/src/utils/formatters.js index d3c0a17..de354bb 100644 --- a/src/utils/formatters.js +++ b/src/utils/formatters.js @@ -71,6 +71,18 @@ export function formatPaymentStatus(status) { return paymentStatus; } +/** + * Creates the string with the number of items and the word describing the item + * possibly in plural form. + * + * @param {number} count number of items + * @param {string} baseWord word describing the item + * @returns {string} + */ +export function formatPlural(count, baseWord) { + return `${count} ${baseWord}${count > 1 ? "s" : ""}`; +} + /** * Formats user handle link. * From 9366a10895cbed90794bd7a54ff511c678577811 Mon Sep 17 00:00:00 2001 From: Oleg Petrov Date: Thu, 1 Jul 2021 20:15:51 +0300 Subject: [PATCH 04/30] Added working days update hint. --- src/assets/images/icon-checkmark-circled.svg | 5 ++ .../Icons/CheckmarkCircled/index.jsx | 61 ++++++++++++++ .../Icons/CheckmarkCircled/styles.module.scss | 79 +++++++++++++++++++ src/decls/svg.d.ts | 6 +- .../components/PeriodItem/index.jsx | 21 +++-- .../components/PeriodItem/styles.module.scss | 22 ++++-- .../components/PeriodListHead/index.jsx | 4 +- .../PeriodListHead/styles.module.scss | 12 ++- .../components/PeriodWorkingDays/index.jsx | 69 ++++++++++++++++ .../PeriodWorkingDays/styles.module.scss | 21 +++++ .../components/PeriodsHistoryItem/index.jsx | 24 +++--- src/store/actionTypes/workPeriods.js | 1 + src/store/actions/workPeriods.js | 13 +++ src/store/reducers/workPeriods.js | 34 ++++++-- 14 files changed, 337 insertions(+), 35 deletions(-) create mode 100644 src/assets/images/icon-checkmark-circled.svg create mode 100644 src/components/Icons/CheckmarkCircled/index.jsx create mode 100644 src/components/Icons/CheckmarkCircled/styles.module.scss create mode 100644 src/routes/WorkPeriods/components/PeriodWorkingDays/index.jsx create mode 100644 src/routes/WorkPeriods/components/PeriodWorkingDays/styles.module.scss diff --git a/src/assets/images/icon-checkmark-circled.svg b/src/assets/images/icon-checkmark-circled.svg new file mode 100644 index 0000000..c551790 --- /dev/null +++ b/src/assets/images/icon-checkmark-circled.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/components/Icons/CheckmarkCircled/index.jsx b/src/components/Icons/CheckmarkCircled/index.jsx new file mode 100644 index 0000000..4b9fcdb --- /dev/null +++ b/src/components/Icons/CheckmarkCircled/index.jsx @@ -0,0 +1,61 @@ +import React, { useEffect, useState } from "react"; +import PT from "prop-types"; +import cn from "classnames"; +import Icon from "../../../assets/images/icon-checkmark-circled.svg"; +import styles from "./styles.module.scss"; + +/** + * Displays an animated checkmark inside circle. After the specified timeout + * the checkmark is faded out and after fade transition ends the onTimeout + * is called. + * + * @param {Object} props component properties + * @param {string} [props.className] class name to be added to root element + * @param {() => void} props.onTimeout + * @param {number} props.timeout timeout milliseconds + * @returns {JSX.Element} + */ +const CheckmarkCircled = ({ className, onTimeout, timeout = 2000 }) => { + const [isAnimated, setIsAnimated] = useState(false); + const [isTimedOut, setIsTimedOut] = useState(false); + + useEffect(() => { + setIsAnimated(true); + }, []); + + useEffect(() => { + setIsTimedOut(false); + let timeoutId = setTimeout(() => { + timeoutId = 0; + setIsTimedOut(true); + }, Math.max(timeout, /* total CSS animation duration */ 1200)); + return () => { + if (timeoutId) { + clearTimeout(timeoutId); + } + }; + }, [timeout]); + + return ( + + + + ); +}; + +CheckmarkCircled.propTypes = { + className: PT.string, + onTimeout: PT.func.isRequired, + timeout: PT.number, +}; + +export default CheckmarkCircled; diff --git a/src/components/Icons/CheckmarkCircled/styles.module.scss b/src/components/Icons/CheckmarkCircled/styles.module.scss new file mode 100644 index 0000000..108fac3 --- /dev/null +++ b/src/components/Icons/CheckmarkCircled/styles.module.scss @@ -0,0 +1,79 @@ +@import "styles/variables"; + +.container { + display: inline-block; + width: 30px; + height: 30px; + opacity: 1; + transition: opacity 0.2s ease; +} + +.checkmark { + display: block; + width: auto; + height: 100%; + border-radius: 999px; + stroke-width: 2; + stroke: $primary-color; + stroke-miterlimit: 10; + box-shadow: inset 0px 0px 0px $primary-color; + animation-play-state: paused; + animation: /*checkmark-circled-fill 0.4s ease-in-out 0.4s forwards,*/ checkmark-circled-scale + 0.3s ease-in-out 0.9s both; + + :global(.checkmark__circle) { + stroke-dasharray: 166; + stroke-dashoffset: 166; + stroke-width: 2; + stroke-miterlimit: 10; + stroke: $primary-color; + fill: rgba(255, 255, 255, 0); + animation-play-state: paused; + animation: checkmark-circled-stroke 0.6s cubic-bezier(0.65, 0, 0.45, 1) + forwards; + } + + :global(.checkmark__check) { + transform-origin: 50% 50%; + stroke-dasharray: 48; + stroke-dashoffset: 48; + animation-play-state: paused; + animation: checkmark-circled-stroke 0.3s cubic-bezier(0.65, 0, 0.45, 1) 0.8s + forwards; + } +} + +.animated { + animation-play-state: running; + + :global(.checkmark__circle), + :global(.checkmark__check) { + animation-play-state: running; + } +} + +.fadeOut { + opacity: 0; +} + +@keyframes checkmark-circled-stroke { + 100% { + stroke-dashoffset: 0; + } +} + +@keyframes checkmark-circled-scale { + 0%, + 100% { + transform: none; + } + 50% { + transform: scale3d(1.1, 1.1, 1); + } +} + +@keyframes checkmark-circled-fill { + 100% { + box-shadow: inset 0px 0px 0px 10px $primary-color; + } +} diff --git a/src/decls/svg.d.ts b/src/decls/svg.d.ts index 9348424..a180524 100644 --- a/src/decls/svg.d.ts +++ b/src/decls/svg.d.ts @@ -1,4 +1,6 @@ -declare module '*.svg' { - const value: string; +declare module "*.svg" { + const value: import("react").FunctionComponent< + React.SVGAttributes + >; export default value; } diff --git a/src/routes/WorkPeriods/components/PeriodItem/index.jsx b/src/routes/WorkPeriods/components/PeriodItem/index.jsx index 7e68b21..7628002 100644 --- a/src/routes/WorkPeriods/components/PeriodItem/index.jsx +++ b/src/routes/WorkPeriods/components/PeriodItem/index.jsx @@ -4,7 +4,6 @@ import PT from "prop-types"; import cn from "classnames"; import debounce from "lodash/debounce"; import Checkbox from "components/Checkbox"; -import IntegerField from "components/IntegerField"; import ProjectName from "components/ProjectName"; import PaymentError from "../PaymentError"; import PaymentStatus from "../PaymentStatus"; @@ -13,6 +12,7 @@ import PeriodDetails from "../PeriodDetails"; import { PAYMENT_STATUS } from "constants/workPeriods"; import { setWorkPeriodWorkingDays, + toggleWorkingDaysUpdated, toggleWorkPeriod, } from "store/actions/workPeriods"; import { @@ -23,6 +23,7 @@ import { useUpdateEffect } from "utils/hooks"; import { formatUserHandleLink, formatWeeklyRate } from "utils/formatters"; import { stopPropagation } from "utils/misc"; import styles from "./styles.module.scss"; +import PeriodWorkingDays from "../PeriodWorkingDays"; /** * Displays the working period data row to be used in PeriodList component. @@ -57,6 +58,10 @@ const PeriodItem = ({ dispatch(toggleWorkPeriodDetails(item)); }, [dispatch, item]); + const onWorkingDaysUpdateHintTimeout = useCallback(() => { + dispatch(toggleWorkingDaysUpdated(item.id, false)); + }, [dispatch, item.id]); + const onWorkingDaysChange = useCallback( (daysWorked) => { dispatch(setWorkPeriodWorkingDays(item.id, daysWorked)); @@ -141,19 +146,19 @@ const PeriodItem = ({
{details && ( td { padding-left: 15px; - padding-right: 18px; + padding-right: 15px; background: #fff; } @@ -40,6 +40,20 @@ } } +:global(.period-details) { + + .container.hasDetails { + > td { + &.toggle { + padding-top: 12px; + } + + &.daysWorked { + padding-top: 5px; + } + } + } +} + td.toggle { padding: 12px 18px 12px 15px; line-height: 15px; @@ -67,6 +81,8 @@ td.teamName { td.startDate, td.endDate { + padding-left: 10px; + padding-right: 10px; white-space: nowrap; } @@ -90,7 +106,3 @@ td.paymentTotal { td.daysWorked { padding: 5px 10px; } - -.daysWorkedControl { - width: 100px; -} diff --git a/src/routes/WorkPeriods/components/PeriodListHead/index.jsx b/src/routes/WorkPeriods/components/PeriodListHead/index.jsx index 936765a..bfad809 100644 --- a/src/routes/WorkPeriods/components/PeriodListHead/index.jsx +++ b/src/routes/WorkPeriods/components/PeriodListHead/index.jsx @@ -73,8 +73,8 @@ const PeriodListHead = () => { const HEAD_CELLS = [ { label: "Topcoder Handle", id: SORT_BY.USER_HANDLE }, { label: "Team Name", id: SORT_BY.TEAM_NAME, disableSort: true }, - { label: "Start Date", id: SORT_BY.START_DATE }, - { label: "End Date", id: SORT_BY.END_DATE }, + { label: "Start Date", id: SORT_BY.START_DATE, className: "startDate" }, + { label: "End Date", id: SORT_BY.END_DATE, className: "endDate" }, { label: "Weekly Rate", id: SORT_BY.WEEKLY_RATE, className: "weeklyRate" }, { label: "Total Paid", id: SORT_BY.PAYMENT_TOTAL, className: "totalPaid" }, { label: "Status", id: SORT_BY.PAYMENT_STATUS }, diff --git a/src/routes/WorkPeriods/components/PeriodListHead/styles.module.scss b/src/routes/WorkPeriods/components/PeriodListHead/styles.module.scss index 68decd6..f94f410 100644 --- a/src/routes/WorkPeriods/components/PeriodListHead/styles.module.scss +++ b/src/routes/WorkPeriods/components/PeriodListHead/styles.module.scss @@ -1,7 +1,7 @@ @import "styles/mixins"; .container { - th { + > th { text-align: left; background: #f4f4f4; @@ -34,7 +34,7 @@ &:last-child { .colHead { - padding: 12px 10px; + padding: 12px 10px 12px 50px; &::before { right: -20px; @@ -43,6 +43,12 @@ } } + :global(.startDate), + :global(.endDate) { + padding-left: 10px; + padding-right: 10px; + } + :global(.weeklyRate), :global(.totalPaid) { justify-content: flex-end; @@ -54,7 +60,7 @@ display: flex; justify-content: flex-start; align-items: center; - padding: 12px 17px; + padding: 12px 15px; height: 40px; } diff --git a/src/routes/WorkPeriods/components/PeriodWorkingDays/index.jsx b/src/routes/WorkPeriods/components/PeriodWorkingDays/index.jsx new file mode 100644 index 0000000..5c221f7 --- /dev/null +++ b/src/routes/WorkPeriods/components/PeriodWorkingDays/index.jsx @@ -0,0 +1,69 @@ +import React from "react"; +import PT from "prop-types"; +import cn from "classnames"; +import IntegerField from "components/IntegerField"; +import IconCheckmarkCircled from "components/Icons/CheckmarkCircled"; +import styles from "./styles.module.scss"; + +/** + * Displays working days input field with an icon hinting about the update. + * + * @param {Object} props component properties + * @param {string} [props.className] class name to be added to root element + * @param {string} props.controlName working days input control name + * @param {Object} props.data working period data object + * @param {boolean} props.isDisabled whether the input field should be disabled + * @param {(v: number) => void} props.onWorkingDaysChange function called when + * working days change + * @param {() => void} props.onWorkingDaysUpdateHintTimeout function called when + * update hint icon has finished its animation + * @param {number} [props.updateHintTimeout] timeout in milliseconds for update + * hint icon + * @returns {JSX.Element} + */ +const PeriodWorkingDays = ({ + className, + controlName, + data, + isDisabled, + onWorkingDaysChange, + onWorkingDaysUpdateHintTimeout, + updateHintTimeout = 2000, +}) => ( +
+ + {data.daysWorkedIsUpdated && ( + + )} + + +
+); + +PeriodWorkingDays.propTypes = { + className: PT.string, + controlName: PT.string.isRequired, + data: PT.shape({ + daysPaid: PT.number.isRequired, + daysWorked: PT.number.isRequired, + daysWorkedIsUpdated: PT.bool.isRequired, + }).isRequired, + isDisabled: PT.bool.isRequired, + onWorkingDaysChange: PT.func.isRequired, + onWorkingDaysUpdateHintTimeout: PT.func.isRequired, + updateHintTimeout: PT.number, +}; + +export default PeriodWorkingDays; diff --git a/src/routes/WorkPeriods/components/PeriodWorkingDays/styles.module.scss b/src/routes/WorkPeriods/components/PeriodWorkingDays/styles.module.scss new file mode 100644 index 0000000..bca1443 --- /dev/null +++ b/src/routes/WorkPeriods/components/PeriodWorkingDays/styles.module.scss @@ -0,0 +1,21 @@ +.container { + display: flex; + align-items: baseline; +} + +.iconPlaceholder { + align-self: center; + margin-right: 10px; + width: 30px; + height: 30px; +} + +.checkmarkIcon { + display: block; + width: 100%; + height: 100%; +} + +.daysWorkedControl { + width: 100px; +} diff --git a/src/routes/WorkPeriods/components/PeriodsHistoryItem/index.jsx b/src/routes/WorkPeriods/components/PeriodsHistoryItem/index.jsx index f38e4dc..c8ae8b7 100644 --- a/src/routes/WorkPeriods/components/PeriodsHistoryItem/index.jsx +++ b/src/routes/WorkPeriods/components/PeriodsHistoryItem/index.jsx @@ -4,12 +4,15 @@ import PT from "prop-types"; import cn from "classnames"; import debounce from "lodash/debounce"; import moment from "moment"; -import IntegerField from "components/IntegerField"; import PaymentError from "../PaymentError"; import PaymentStatus from "../PaymentStatus"; import PaymentTotal from "../PaymentTotal"; +import PeriodWorkingDays from "../PeriodWorkingDays"; import { PAYMENT_STATUS } from "constants/workPeriods"; -import { setDetailsWorkingDays } from "store/actions/workPeriods"; +import { + setDetailsWorkingDays, + toggleWorkingDaysUpdated, +} from "store/actions/workPeriods"; import { updateWorkPeriodWorkingDays } from "store/thunks/workPeriods"; import { useUpdateEffect } from "utils/hooks"; import { formatDateLabel, formatDateRange } from "utils/formatters"; @@ -35,6 +38,10 @@ const PeriodsHistoryItem = ({ isDisabled, item, data, currentStartDate }) => { [dispatch, item.id] ); + const onWorkingDaysUpdateHintTimeout = useCallback(() => { + dispatch(toggleWorkingDaysUpdated(item.id, false)); + }, [dispatch, item.id]); + const updateWorkingDays = useCallback( debounce( (daysWorked) => { @@ -85,14 +92,13 @@ const PeriodsHistoryItem = ({ isDisabled, item, data, currentStartDate }) => { {data.paymentStatus === PAYMENT_STATUS.COMPLETED ? ( `${daysWorked} ${daysWorked === 1 ? "Day" : "Days"}` ) : ( - )} diff --git a/src/store/actionTypes/workPeriods.js b/src/store/actionTypes/workPeriods.js index de5fdf2..30ddbd2 100644 --- a/src/store/actionTypes/workPeriods.js +++ b/src/store/actionTypes/workPeriods.js @@ -34,4 +34,5 @@ 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_TOGGLE_WORKING_DAYS_UPDATED = "WP_TOGGLE_WORKING_DAYS_UPDATED"; 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 10f2514..c4ef757 100644 --- a/src/store/actions/workPeriods.js +++ b/src/store/actions/workPeriods.js @@ -389,6 +389,19 @@ export const toggleWorkPeriodsProcessingPayments = (on = null) => ({ payload: on, }); +/** + * Creates an action denoting the change of working-days-updated flag for + * working period with the specified id. + * + * @param {string} periodId working period id + * @param {boolean} on whether to toggle working-days-updated flag on or off. + * @returns {Object} + */ +export const toggleWorkingDaysUpdated = (periodId, on) => ({ + type: ACTION_TYPE.WP_TOGGLE_WORKING_DAYS_UPDATED, + payload: { periodId, on }, +}); + /** * Creates an action denoting an update of working periods state slice using * the provided query. diff --git a/src/store/reducers/workPeriods.js b/src/store/reducers/workPeriods.js index e34ddeb..4aaa439 100644 --- a/src/store/reducers/workPeriods.js +++ b/src/store/reducers/workPeriods.js @@ -38,6 +38,14 @@ const initFilters = () => ({ userHandle: "", }); +const initPeriodData = (period) => { + const data = period.data; + data.cancelSource = null; + data.daysWorkedIsUpdated = false; + delete period.data; + return data; +}; + const initPeriodDetails = ( periodId, rbId, @@ -114,9 +122,7 @@ const actionHandlers = { : oldPagination; const periodsData = {}; for (let period of periods) { - period.data.cancelSource = null; - periodsData[period.id] = period.data; - delete period.data; + periodsData[period.id] = initPeriodData(period); } return { ...state, @@ -200,9 +206,7 @@ const actionHandlers = { } const periodsData = state.periodsData[0]; for (let period of details.periods) { - period.data.cancelSource = null; - periodsData[period.id] = period.data; - delete period.data; + periodsData[period.id] = initPeriodData(period); } periodDetails = { ...periodDetails, @@ -545,6 +549,7 @@ const actionHandlers = { periodsData[periodId] = { ...periodData, cancelSource, + daysWorkedIsUpdated: false, }; return { ...state, @@ -561,6 +566,7 @@ const actionHandlers = { ...periodData, ...data, cancelSource: null, + daysWorkedIsUpdated: true, }; return { ...state, @@ -576,6 +582,7 @@ const actionHandlers = { periodsData[periodId] = { ...periodData, cancelSource: null, + daysWorkedIsUpdated: false, }; return { ...state, @@ -680,6 +687,21 @@ const actionHandlers = { isSelectedPeriodsVisible, }; }, + [ACTION_TYPE.WP_TOGGLE_WORKING_DAYS_UPDATED]: (state, { periodId, on }) => { + const periodsData = state.periodsData[0]; + const periodData = periodsData[periodId]; + if (!periodData || periodData.daysWorkedIsUpdated === on) { + return state; + } + periodsData[periodId] = { + ...periodData, + daysWorkedIsUpdated: on, + }; + return { + ...state, + periodsData: [periodsData], + }; + }, [ACTION_TYPE.WP_TOGGLE_PROCESSING_PAYMENTS]: (state, on) => { let periodsFailed = state.periodsFailed; let isProcessingPayments = on === null ? !state.isProcessingPayments : on; From 77f86856def8ea2fcdfc84a19fa9932a2ef8b1d4 Mon Sep 17 00:00:00 2001 From: Oleg Petrov Date: Fri, 2 Jul 2021 09:48:36 +0300 Subject: [PATCH 05/30] Minor change in Work Period header's padding. --- src/routes/WorkPeriods/styles.module.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/WorkPeriods/styles.module.scss b/src/routes/WorkPeriods/styles.module.scss index 08c193f..ef2c3b6 100644 --- a/src/routes/WorkPeriods/styles.module.scss +++ b/src/routes/WorkPeriods/styles.module.scss @@ -5,7 +5,7 @@ flex-wrap: wrap; justify-content: space-between; align-items: center; - padding: 13px 13px 13px 32px; + padding: 13px 13px 13px 29px; @include desktop { flex-wrap: nowrap; From b2cc7a98e3350d25c1fac5980338be209d71229d Mon Sep 17 00:00:00 2001 From: Oleg Petrov Date: Fri, 2 Jul 2021 12:27:10 +0300 Subject: [PATCH 06/30] Removed global classes where possible. --- src/components/Popup/index.jsx | 8 ++++++-- src/components/Popup/styles.module.scss | 2 +- src/routes/WorkPeriods/components/PeriodItem/index.jsx | 2 +- .../WorkPeriods/components/PeriodItem/styles.module.scss | 2 +- .../WorkPeriods/components/PeriodListHead/index.jsx | 2 +- .../components/PeriodListHead/styles.module.scss | 8 ++++---- 6 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/components/Popup/index.jsx b/src/components/Popup/index.jsx index dd22d5b..b5be2db 100644 --- a/src/components/Popup/index.jsx +++ b/src/components/Popup/index.jsx @@ -35,12 +35,16 @@ const Popup = ({ return (
{children} -
+
); }; diff --git a/src/components/Popup/styles.module.scss b/src/components/Popup/styles.module.scss index 6a800ea..72f1e2b 100644 --- a/src/components/Popup/styles.module.scss +++ b/src/components/Popup/styles.module.scss @@ -7,7 +7,7 @@ background: #fff; box-shadow: $popover-box-shadow; - :global(.popup-arrow) { + .popupArrow { display: none; } } diff --git a/src/routes/WorkPeriods/components/PeriodItem/index.jsx b/src/routes/WorkPeriods/components/PeriodItem/index.jsx index 7628002..c8c940e 100644 --- a/src/routes/WorkPeriods/components/PeriodItem/index.jsx +++ b/src/routes/WorkPeriods/components/PeriodItem/index.jsx @@ -158,7 +158,7 @@ const PeriodItem = ({ {details && ( td { &.toggle { diff --git a/src/routes/WorkPeriods/components/PeriodListHead/index.jsx b/src/routes/WorkPeriods/components/PeriodListHead/index.jsx index bfad809..12b987e 100644 --- a/src/routes/WorkPeriods/components/PeriodListHead/index.jsx +++ b/src/routes/WorkPeriods/components/PeriodListHead/index.jsx @@ -53,7 +53,7 @@ const PeriodListHead = () => { {HEAD_CELLS.map(({ id, className, label, disableSort }) => (
@@ -175,6 +202,7 @@ PeriodItem.propTypes = { isSelected: PT.bool.isRequired, item: PT.shape({ id: PT.oneOfType([PT.number, PT.string]).isRequired, + jobId: PT.string.isRequired, rbId: PT.string.isRequired, projectId: PT.oneOfType([PT.number, PT.string]).isRequired, userHandle: PT.string.isRequired, diff --git a/src/routes/WorkPeriods/components/PeriodItem/styles.module.scss b/src/routes/WorkPeriods/components/PeriodItem/styles.module.scss index e8b86a1..bbb9bd9 100644 --- a/src/routes/WorkPeriods/components/PeriodItem/styles.module.scss +++ b/src/routes/WorkPeriods/components/PeriodItem/styles.module.scss @@ -64,14 +64,14 @@ td.toggle { @include roboto-bold; color: #0d61bf; } +} - span { - display: block; - max-width: 20em; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } +.userHandleContainer { + display: inline-block; + max-width: 20em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } td.teamName { @@ -106,3 +106,14 @@ td.paymentTotal { td.daysWorked { padding: 5px 10px; } + +.tooltipContent { + white-space: nowrap; + font-weight: 400; +} + +.tooltipLabel { + margin-right: 5px; + white-space: nowrap; + font-weight: 500; +} From a1d3aaba852c681b23739f55a8f0b842a0787c90 Mon Sep 17 00:00:00 2001 From: Oleg Petrov Date: Sat, 3 Jul 2021 06:51:50 +0300 Subject: [PATCH 10/30] Added tooltip with error messages for work period selection checkbox. --- src/components/Tooltip/index.jsx | 9 +- src/components/Tooltip/styles.module.scss | 1 + src/constants/workPeriods.js | 9 + src/constants/workPeriods/reasonDisabled.js | 3 + .../components/PeriodItem/index.jsx | 62 ++++- .../components/PeriodList/index.jsx | 7 +- .../PeriodsSelectionMessage/index.jsx | 4 + src/store/reducers/workPeriods.js | 231 ++++++++++++------ src/store/selectors/workPeriods.js | 32 +-- src/store/thunks/workPeriods.js | 9 +- src/utils/misc.js | 7 + src/utils/workPeriods.js | 46 ++++ 12 files changed, 306 insertions(+), 114 deletions(-) create mode 100644 src/constants/workPeriods/reasonDisabled.js diff --git a/src/components/Tooltip/index.jsx b/src/components/Tooltip/index.jsx index f659310..737128c 100644 --- a/src/components/Tooltip/index.jsx +++ b/src/components/Tooltip/index.jsx @@ -12,6 +12,7 @@ import compStyles from "./styles.module.scss"; * @param {string} [props.className] class name to be added to root element * @param {any} props.content tooltip content * @param {number} [props.delay] postpone showing the tooltip after this delay + * @param {boolean} [props.isDisabled] whether the tooltip is disabled * @param {import('@popperjs/core').Placement} [props.placement] tooltip's * preferred placement as defined in PopperJS documentation * @param {'absolute'|'fixed'} [props.strategy] tooltip positioning strategy @@ -27,6 +28,7 @@ const Tooltip = ({ className, content, delay = 150, + isDisabled = false, placement = "top", strategy = "absolute", targetClassName, @@ -83,8 +85,8 @@ const Tooltip = ({
{children} - {isTooltipShown && ( + {!isDisabled && isTooltipShown && (
{ const dispatch = useDispatch(); @@ -107,6 +112,15 @@ const PeriodItem = ({ [item.projectId] ); + const reasonsDisabledElement = useMemo( + () => ( + + {formatReasonsDisabled(reasonsDisabled)} + + ), + [reasonsDisabled] + ); + return ( <>
@@ -138,6 +139,7 @@ const PeriodItem = ({ targetClassName={styles.checkboxContainer} > + + {reasonFailed && ( + + )} + ) : ( @@ -122,7 +122,7 @@ const PeriodDetails = ({ - + @@ -261,6 +267,7 @@ PeriodItem.propTypes = { endDate: PT.string.isRequired, weeklyRate: PT.number, }).isRequired, + alerts: PT.arrayOf(PT.string), data: PT.shape({ daysWorked: PT.number.isRequired, daysPaid: PT.number.isRequired, diff --git a/src/routes/WorkPeriods/components/PeriodList/index.jsx b/src/routes/WorkPeriods/components/PeriodList/index.jsx index 1b41f12..f1b0e76 100644 --- a/src/routes/WorkPeriods/components/PeriodList/index.jsx +++ b/src/routes/WorkPeriods/components/PeriodList/index.jsx @@ -8,6 +8,7 @@ import PeriodItem from "../PeriodItem"; import PeriodListHead from "../PeriodListHead"; import { getWorkPeriods, + getWorkPeriodsAlerts, getWorkPeriodsData, getWorkPeriodsDetails, getWorkPeriodsDisabled, @@ -26,6 +27,7 @@ import styles from "./styles.module.scss"; */ const PeriodList = ({ className }) => { const periods = useSelector(getWorkPeriods); + const periodsAlerts = useSelector(getWorkPeriodsAlerts); const [periodsData] = useSelector(getWorkPeriodsData); const periodsDetails = useSelector(getWorkPeriodsDetails); const [periodsDisabledMap] = useSelector(getWorkPeriodsDisabled); @@ -49,7 +51,7 @@ const PeriodList = ({ className }) => { - + {periods.map((period) => ( { isDisabled={isProcessingPayments} isSelected={periodsSelectedSet.has(period.id)} item={period} + alerts={periodsAlerts[period.id]} data={periodsData[period.id]} details={periodsDetails[period.id]} reasonFailed={periodsFailed[period.id]} diff --git a/src/routes/WorkPeriods/components/PeriodListHead/index.jsx b/src/routes/WorkPeriods/components/PeriodListHead/index.jsx index 12b987e..ee70844 100644 --- a/src/routes/WorkPeriods/components/PeriodListHead/index.jsx +++ b/src/routes/WorkPeriods/components/PeriodListHead/index.jsx @@ -75,6 +75,7 @@ const HEAD_CELLS = [ { label: "Team Name", id: SORT_BY.TEAM_NAME, disableSort: true }, { label: "Start Date", id: SORT_BY.START_DATE, className: "startDate" }, { label: "End Date", id: SORT_BY.END_DATE, className: "endDate" }, + { label: "Alert", id: SORT_BY.ALERT, disableSort: true, className: "alert" }, { label: "Weekly Rate", id: SORT_BY.WEEKLY_RATE, className: "weeklyRate" }, { label: "Total Paid", id: SORT_BY.PAYMENT_TOTAL, className: "totalPaid" }, { label: "Status", id: SORT_BY.PAYMENT_STATUS }, diff --git a/src/store/reducers/workPeriods.js b/src/store/reducers/workPeriods.js index 1a0fbcc..5495f15 100644 --- a/src/store/reducers/workPeriods.js +++ b/src/store/reducers/workPeriods.js @@ -11,6 +11,7 @@ import { SORT_ORDER_DEFAULT, URL_QUERY_PARAM_MAP, REASON_DISABLED, + ALERT, } from "constants/workPeriods"; import { filterPeriodsByStartDate, @@ -18,10 +19,11 @@ import { updateOptionMap, } from "utils/misc"; import { - addReasonDisabled, + addValueImmutable, + createPeriodAlerts, createAssignedBillingAccountOption, findReasonsDisabled, - removeReasonDisabled, + removeValueImmutable, } from "utils/workPeriods"; const cancelSourceDummy = { cancel: () => {} }; @@ -75,6 +77,7 @@ const initialState = updateStateFromQuery(window.location.search, { isSelectedPeriodsVisible: false, pagination: initPagination(), periods: [], + periodsAlerts: {}, periodsById: {}, periodsData: [{}], periodsDetails: {}, @@ -102,6 +105,7 @@ const actionHandlers = { isSelectedPeriodsAll: false, isSelectedPeriodsVisible: false, periods: [], + periodsAlerts: {}, periodsById: {}, periodsData: [{}], periodsDetails: {}, @@ -119,9 +123,11 @@ const actionHandlers = { oldPagination.pageCount !== pageCount ? { ...oldPagination, totalCount, pageCount } : oldPagination; + const periodsAlerts = {}; const periodsById = {}; const periodsData = {}; const periodsDisabledMap = new Map(); + const periodEndDate = state.filters.dateRange[1]; for (let period of periods) { periodsById[period.id] = true; periodsData[period.id] = initPeriodData(period); @@ -129,6 +135,10 @@ const actionHandlers = { if (reasonsDisabled) { periodsDisabledMap.set(period.id, reasonsDisabled); } + let alerts = createPeriodAlerts(period, periodEndDate); + if (alerts) { + periodsAlerts[period.id] = alerts; + } delete period.data; } return { @@ -137,6 +147,7 @@ const actionHandlers = { error: null, pagination, periods, + periodsAlerts, periodsById, periodsData: [periodsData], periodsDisabled: [periodsDisabledMap], @@ -342,7 +353,7 @@ const actionHandlers = { // updating reasons for which the period's selection may be disabled const periodsDisabledMap = state.periodsDisabled[0]; const oldReasonsDisabled = periodsDisabledMap.get(periodId); - const reasonsDisabled = removeReasonDisabled( + const reasonsDisabled = removeValueImmutable( oldReasonsDisabled, REASON_DISABLED.NO_BILLING_ACCOUNT ); @@ -355,6 +366,18 @@ const actionHandlers = { state.periodsDisabled = [periodsDisabledMap]; updateSelectedPeriodsFlags(state); } + // updating period's alerts + const periodsAlerts = state.periodsAlerts; + const oldAlerts = periodsAlerts[periodId]; + const alerts = removeValueImmutable(oldAlerts, ALERT.BA_NOT_ASSIGNED); + if (oldAlerts !== alerts) { + if (alerts) { + periodsAlerts[periodId] = alerts; + } else { + delete periodsAlerts[periodId]; + } + state.periodsAlerts = { ...periodsAlerts }; + } return state; }, [ACTION_TYPE.WP_SET_DETAILS_HIDE_PAST_PERIODS]: ( @@ -699,11 +722,11 @@ function updateStateAfterWorkingDaysChange(periodId, state) { const oldReasonsDisabled = periodsDisabledMap.get(periodId); let reasonsDisabled = periodData.daysWorked === periodData.daysPaid - ? addReasonDisabled( + ? addValueImmutable( oldReasonsDisabled, REASON_DISABLED.NO_DAYS_TO_PAY_FOR ) - : removeReasonDisabled( + : removeValueImmutable( oldReasonsDisabled, REASON_DISABLED.NO_DAYS_TO_PAY_FOR ); diff --git a/src/store/selectors/workPeriods.js b/src/store/selectors/workPeriods.js index 23da36d..4b7b3a6 100644 --- a/src/store/selectors/workPeriods.js +++ b/src/store/selectors/workPeriods.js @@ -14,6 +14,14 @@ export const getWorkPeriodsStateSlice = (state) => state.workPeriods; */ export const getWorkPeriods = (state) => state.workPeriods.periods; +/** + * Returns an object with period ids as keys and alerts' arrays as values; + * + * @param {Object} state redux root state + * @returns {Object} + */ +export const getWorkPeriodsAlerts = (state) => state.workPeriods.periodsAlerts; + /** * Returns working periods' details. * diff --git a/src/utils/workPeriods.js b/src/utils/workPeriods.js index e769736..8d908d2 100644 --- a/src/utils/workPeriods.js +++ b/src/utils/workPeriods.js @@ -1,5 +1,6 @@ import moment from "moment"; import { + ALERT, API_CHALLENGE_PAYMENT_STATUS_MAP, API_PAYMENT_STATUS_MAP, DATE_FORMAT_API, @@ -9,6 +10,24 @@ import { URL_QUERY_PARAM_MAP, } from "constants/workPeriods"; +/** + * Returns an array of working period's alert ids. + * + * @param {Object} period working period basic data object + * @param {Object} periodEndDate Moment object with current period end date + * @returns {Array} + */ +export function createPeriodAlerts(period, periodEndDate) { + const alerts = []; + if (!period.billingAccountId) { + alerts.push(ALERT.BA_NOT_ASSIGNED); + } + if (periodEndDate.isSameOrAfter(period.endDate)) { + alerts.push(ALERT.LAST_BOOKING_WEEK); + } + return alerts.length ? alerts : undefined; +} + /** * Checks for reasons the specified working period should be disabled for * payment processing. @@ -31,29 +50,27 @@ export function findReasonsDisabled(period) { return reasons.length ? reasons : undefined; } -export function createAlerts(period, bookingEndDate) {} - -export function addReasonDisabled(reasons, reason) { - if (!reasons) { - return [reason]; +export function addValueImmutable(items, value) { + if (!items) { + return [value]; } - if (reasons.indexOf(reason) < 0) { - reasons = [...reasons, reason]; + if (items.indexOf(value) < 0) { + items = [...items, value]; } - return reasons; + return items; } -export function removeReasonDisabled(reasons, reason) { - if (!reasons) { +export function removeValueImmutable(items, value) { + if (!items) { return undefined; } - let index = reasons.indexOf(reason); + let index = items.indexOf(value); if (index >= 0) { - let newReasons = [...reasons]; - newReasons.splice(index, 1); - return newReasons.length ? newReasons : undefined; + let newItems = [...items]; + newItems.splice(index, 1); + return newItems.length ? newItems : undefined; } - return reasons; + return items; } /** @@ -102,9 +119,11 @@ export function normalizePeriodItems(items) { billingAccountId: billingAccountId === null ? 0 : billingAccountId, teamName: "", userHandle: workPeriod.userHandle || "", + // resource booking period start date startDate: item.startDate ? moment(item.startDate).format(DATE_FORMAT_UI) : "", + // resource booking period end date endDate: item.endDate ? moment(item.endDate).format(DATE_FORMAT_UI) : "", weeklyRate: item.memberRate, data: normalizePeriodData(workPeriod), @@ -118,7 +137,9 @@ export function normalizeDetailsPeriodItems(items) { for (let item of items) { periods.push({ id: item.id, + // working period start date startDate: item.startDate ? moment(item.startDate).valueOf() : 0, + // working period end date endDate: item.endDate ? moment(item.endDate).valueOf() : 0, weeklyRate: item.memberRate, data: normalizePeriodData(item), From 4b77b44e58cf97203f25daa7915b57af758d99b2 Mon Sep 17 00:00:00 2001 From: Oleg Petrov Date: Mon, 5 Jul 2021 03:13:16 +0300 Subject: [PATCH 16/30] Implemented Modal component and payment cancelling. --- src/assets/images/icon-cross-light.svg | 30 +++++ src/components/Button/index.jsx | 25 ++-- src/components/Button/styles.module.scss | 21 ++++ src/components/Modal/index.jsx | 78 ++++++++++++ src/components/Modal/styles.module.scss | 69 +++++++++++ src/components/Spinner/index.jsx | 22 +++- src/components/Spinner/styles.module.scss | 44 +------ src/constants/workPeriods.js | 1 + .../components/PaymentCancel/index.jsx | 117 ++++++++++++++++++ .../PaymentCancel/styles.module.scss | 3 + .../components/PaymentsList/index.jsx | 3 +- .../PaymentsList/styles.module.scss | 42 ++++--- .../components/PaymentsListItem/index.jsx | 10 +- src/services/workPeriods.js | 29 ++++- src/store/actionTypes/workPeriods.js | 4 + src/store/actions/workPeriods.js | 43 +++++-- src/store/reducers/workPeriods.js | 96 +++++++++++++- src/store/thunks/workPeriods.js | 46 ++++++- src/styles/variables/_colors.scss | 3 + src/utils/misc.js | 5 + src/utils/workPeriods.js | 10 +- 21 files changed, 603 insertions(+), 98 deletions(-) create mode 100644 src/assets/images/icon-cross-light.svg create mode 100644 src/components/Modal/index.jsx create mode 100644 src/components/Modal/styles.module.scss create mode 100644 src/routes/WorkPeriods/components/PaymentCancel/index.jsx create mode 100644 src/routes/WorkPeriods/components/PaymentCancel/styles.module.scss diff --git a/src/assets/images/icon-cross-light.svg b/src/assets/images/icon-cross-light.svg new file mode 100644 index 0000000..add82d0 --- /dev/null +++ b/src/assets/images/icon-cross-light.svg @@ -0,0 +1,30 @@ + + + + + diff --git a/src/components/Button/index.jsx b/src/components/Button/index.jsx index a9ca78b..509cf17 100644 --- a/src/components/Button/index.jsx +++ b/src/components/Button/index.jsx @@ -9,11 +9,12 @@ import styles from "./styles.module.scss"; * @param {Object} props component properties * @param {Object} props.children button text * @param {string} [props.className] class name added to root element - * @param {'primary'|'primary-dark'|'primary-light'} [props.color] button color + * @param {'primary'|'primary-dark'|'primary-light'|'error'|'warning'} [props.color] + * button color * @param {boolean} [props.isDisabled] if button is disabled * @param {boolean} [props.isSelected] if button is selected * @param {string} [props.name] button name - * @param {(e: any) => void} props.onClick function called when button is clicked + * @param {(e: any) => void} [props.onClick] function called when button is clicked * @param {'medium'|'small'} [props.size] button size * @param {'circle'|'rounded'} [props.style] button style * @param {'button'|'submit'|'reset'} [props.type] button type @@ -42,13 +43,11 @@ const Button = ({ type={type} className={cn( styles.button, - { - [styles.selected]: isSelected, - [styles[color]]: true, - [styles[size]]: true, - [styles[style]]: true, - [styles[variant]]: true, - }, + styles[color], + styles[size], + styles[style], + styles[variant], + { [styles.selected]: isSelected }, className )} onClick={onClick} @@ -60,7 +59,13 @@ const Button = ({ Button.propTypes = { children: PT.node, className: PT.string, - color: PT.oneOf(["primary"]), + color: PT.oneOf([ + "primary", + "primary-dark", + "primary-light", + "error", + "warning", + ]), isDisabled: PT.bool, isSelected: PT.bool, name: PT.string, diff --git a/src/components/Button/styles.module.scss b/src/components/Button/styles.module.scss index 2ae6cd1..1cfbe34 100644 --- a/src/components/Button/styles.module.scss +++ b/src/components/Button/styles.module.scss @@ -7,6 +7,7 @@ align-items: center; @include roboto-bold; letter-spacing: 0.8px; + white-space: nowrap; text-transform: uppercase; outline: none; cursor: pointer; @@ -61,6 +62,16 @@ color: $primary-dark-text-color; } + &.error { + border-color: $error-color; + color: $error-text-color; + } + + &.warning { + border-color: $warning-color; + color: $warning-text-color; + } + &:disabled { border-color: $control-disabled-border-color; background-color: $control-disabled-bg-color; @@ -88,6 +99,16 @@ background-color: $primary-dark-color; } + &.error { + border-color: $error-color; + background-color: $error-color; + } + + &.warning { + border-color: $warning-color; + background-color: $warning-color; + } + &:disabled { border-color: $control-disabled-border-color; background-color: $control-disabled-bg-color; diff --git a/src/components/Modal/index.jsx b/src/components/Modal/index.jsx new file mode 100644 index 0000000..8f3d4e2 --- /dev/null +++ b/src/components/Modal/index.jsx @@ -0,0 +1,78 @@ +import React from "react"; +import PT from "prop-types"; +import { Modal as ReactModal } from "react-responsive-modal"; +import Button from "components/Button"; +import IconCross from "../../assets/images/icon-cross-light.svg"; +import { stopImmediatePropagation } from "utils/misc"; +import styles from "./styles.module.scss"; +import "react-responsive-modal/styles.css"; + +const classNames = { + modal: styles.modal, + modalContainer: styles.modalContainer, +}; +const closeIcon = ; + +const Modal = ({ + approveText = "Apply", + children, + controls, + dismissText = "Cancel", + isOpen, + onApprove, + onDismiss, + title, +}) => ( + +
+ {title &&
{title}
} +
{children}
+ {controls || controls === null ? ( + controls + ) : ( +
+ + +
+ )} + +
+
+); + +Modal.propTypes = { + approveText: PT.string, + children: PT.node, + container: PT.element, + controls: PT.node, + dismissText: PT.string, + isOpen: PT.bool.isRequired, + onApprove: PT.func, + onDismiss: PT.func.isRequired, + title: PT.string, +}; + +export default Modal; diff --git a/src/components/Modal/styles.module.scss b/src/components/Modal/styles.module.scss new file mode 100644 index 0000000..13987c9 --- /dev/null +++ b/src/components/Modal/styles.module.scss @@ -0,0 +1,69 @@ +@import "styles/mixins"; + +div.modalContainer { + padding: 20px; +} + +div.modal { + margin: 0; + border-radius: 8px; + border: none; + padding: 0; + width: 640px; + max-width: 100%; +} + +.wrapper { + padding: 32px 32px 22px; +} + +button.closeButton { + display: inline-block; + position: absolute; + top: 14px; + right: 14px; + border: none; + padding: 0; + width: 15px; + background: transparent; + outline: none !important; + box-shadow: none !important; + + svg { + display: block; + width: 100%; + height: auto; + } +} + +.title { + margin: 0 0 24px; + font-size: 34px; + line-height: 38px; + text-transform: uppercase; + @include barlow-condensed; +} + +.content { + margin: 0 0 10px; + font-size: 16px; + line-height: 22px; + @include roboto-regular; + + + .controls { + margin-top: 24px; + } +} + +.controls { + display: flex; + flex-wrap: wrap; +} + +.button { + margin: 0 10px 10px 0; + + &:last-child { + margin-right: 0; + } +} diff --git a/src/components/Spinner/index.jsx b/src/components/Spinner/index.jsx index 57a61cd..afd4885 100644 --- a/src/components/Spinner/index.jsx +++ b/src/components/Spinner/index.jsx @@ -1,6 +1,7 @@ import React from "react"; import PT from "prop-types"; import cn from "classnames"; +import Loader from "react-loader-spinner"; import styles from "./styles.module.scss"; /** @@ -8,18 +9,31 @@ import styles from "./styles.module.scss"; * * @param {Object} props component props * @param {string} [props.className] class name added to root element - * @param {string} [props.spinnerClassName] class name added to spinner element + * @param {string} [props.color] spinner color in HEX format + * @param {any} [props.type] spinner type as defined in + * react-loader-spinner documentation + * @param {number} [props.width] spinner width + * @param {number} [props.height] spinner height * @returns {JSX.Element} */ -const Spinner = ({ className, spinnerClassName }) => ( +const Spinner = ({ + className, + color = "#00BFFF", + type = "TailSpin", + width = 80, + height = 0, +}) => (
- Loading... +
); Spinner.propTypes = { className: PT.string, - spinnerClassName: PT.string, + color: PT.string, + type: PT.string, + width: PT.number, + height: PT.number, }; export default Spinner; diff --git a/src/components/Spinner/styles.module.scss b/src/components/Spinner/styles.module.scss index 88292f9..824eedd 100644 --- a/src/components/Spinner/styles.module.scss +++ b/src/components/Spinner/styles.module.scss @@ -1,43 +1,7 @@ -@import 'styles/variables'; +@import "styles/variables"; .container { - position: relative; - padding-bottom: 100%; - width: 100%; - height: 0; - overflow: hidden; -} - -.spinner { - position: absolute; - left: 0; - top: 0; - right: 0; - bottom: 0; - margin: auto; - padding-bottom: 50%; - width: 50%; - height: 0; - color: transparent; - user-select: none; - - &::after { - content: ''; - display: block; - position: absolute; - left: 0; - top: 0; - right: 0; - bottom: 0; - border: 10px solid $control-border-color; - border-right-color: transparent; - border-radius: 9999px; - animation: loading-indicator 0.75s linear infinite; - } -} - -@keyframes loading-indicator { - to { - transform: rotate(360deg); - } + display: flex; + flex-direction: column; + align-items: center; } diff --git a/src/constants/workPeriods.js b/src/constants/workPeriods.js index 2da71de..f52b080 100644 --- a/src/constants/workPeriods.js +++ b/src/constants/workPeriods.js @@ -56,6 +56,7 @@ export const API_REQUIRED_FIELDS = [ "workPeriods.payments.memberRate", "workPeriods.payments.status", "workPeriods.payments.statusDetails", + "workPeriods.payments.workPeriodId", ]; // Valid parameter names for requests. diff --git a/src/routes/WorkPeriods/components/PaymentCancel/index.jsx b/src/routes/WorkPeriods/components/PaymentCancel/index.jsx new file mode 100644 index 0000000..b60221d --- /dev/null +++ b/src/routes/WorkPeriods/components/PaymentCancel/index.jsx @@ -0,0 +1,117 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { useDispatch } from "react-redux"; +import PT from "prop-types"; +import cn from "classnames"; +import Button from "components/Button"; +import Modal from "components/Modal"; +import Spinner from "components/Spinner"; +import { makeToast } from "components/ToastrMessage"; +import { PAYMENT_STATUS } from "constants/workPeriods"; +import { setWorkPeriodPaymentData } from "store/actions/workPeriods"; +import { cancelWorkPeriodPayment } from "services/workPeriods"; +import styles from "./styles.module.scss"; +import { loadWorkPeriodAfterPaymentCancel } from "store/thunks/workPeriods"; + +const PaymentCancel = ({ className, item, timeout = 3000 }) => { + const { id: paymentId, workPeriodId: periodId } = item; + const [isModalOpen, setIsModalOpen] = useState(false); + const [isCancelPending, setIsCancelPending] = useState(false); + const [isCancelSuccess, setIsCancelSuccess] = useState(false); + const dispatch = useDispatch(); + + const onApprove = useCallback(() => { + setIsCancelPending(true); + }, []); + + const onDismiss = useCallback(() => { + setIsModalOpen(false); + }, []); + + const openModal = useCallback(() => { + setIsModalOpen(true); + }, []); + + useEffect(() => { + if (isCancelPending) { + cancelWorkPeriodPayment(paymentId) + .then((paymentData) => { + dispatch(setWorkPeriodPaymentData(paymentData)); + setIsCancelSuccess(true); + }) + .catch((error) => { + makeToast(error.toString()); + setIsCancelPending(false); + }); + } + }, [isCancelPending, paymentId, dispatch]); + + useEffect(() => { + let timeoutId = 0; + if (isCancelSuccess) { + timeoutId = window.setTimeout(async () => { + timeoutId = 0; + await dispatch(loadWorkPeriodAfterPaymentCancel(periodId, paymentId)); + setIsModalOpen(false); + setIsCancelSuccess(false); + setIsCancelPending(false); + }, timeout); + } + return () => { + if (timeoutId) { + clearTimeout(timeoutId); + } + }; + }, [isCancelSuccess, paymentId, periodId, timeout, dispatch]); + + let title, controls; + if (isCancelPending) { + controls = null; + title = "Marking as cancelled..."; + } else { + controls = undefined; + title = "Warning!"; + } + + return ( +
+ + + {isCancelPending ? ( + + ) : ( + `Cancelling payment here will only mark it as cancelled in TaaS system. + Before cancelling it here, make sure that actual payment is cancelled in + PACTS first, and only after that you may mark it as cancelled here.` + )} + +
+ ); +}; + +PaymentCancel.propTypes = { + className: PT.string, + item: PT.shape({ + id: PT.string.isRequired, + status: PT.string.isRequired, + workPeriodId: PT.string.isRequired, + }).isRequired, + timeout: PT.number, +}; + +export default PaymentCancel; diff --git a/src/routes/WorkPeriods/components/PaymentCancel/styles.module.scss b/src/routes/WorkPeriods/components/PaymentCancel/styles.module.scss new file mode 100644 index 0000000..7b5a0a2 --- /dev/null +++ b/src/routes/WorkPeriods/components/PaymentCancel/styles.module.scss @@ -0,0 +1,3 @@ +.container { + display: inline-block; +} diff --git a/src/routes/WorkPeriods/components/PaymentsList/index.jsx b/src/routes/WorkPeriods/components/PaymentsList/index.jsx index 7391317..580a9eb 100644 --- a/src/routes/WorkPeriods/components/PaymentsList/index.jsx +++ b/src/routes/WorkPeriods/components/PaymentsList/index.jsx @@ -19,7 +19,8 @@ const PaymentsList = ({ className, payments }) => (
- + + diff --git a/src/routes/WorkPeriods/components/PaymentsList/styles.module.scss b/src/routes/WorkPeriods/components/PaymentsList/styles.module.scss index a878a33..c8b9cec 100644 --- a/src/routes/WorkPeriods/components/PaymentsList/styles.module.scss +++ b/src/routes/WorkPeriods/components/PaymentsList/styles.module.scss @@ -13,29 +13,35 @@ 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; + > thead { + > tr { + > 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, + &.paymentStatus { + text-align: left; + } - &:first-child { - padding-left: 28px; + &:first-child { + padding-left: 28px; + } + } } } - tr { - td { - padding: 5px 7px; - white-space: nowrap; + > tbody { + > tr { + > td { + padding: 5px 7px; + white-space: nowrap; + } } } } diff --git a/src/routes/WorkPeriods/components/PaymentsListItem/index.jsx b/src/routes/WorkPeriods/components/PaymentsListItem/index.jsx index 7e968c2..4b01148 100644 --- a/src/routes/WorkPeriods/components/PaymentsListItem/index.jsx +++ b/src/routes/WorkPeriods/components/PaymentsListItem/index.jsx @@ -2,11 +2,12 @@ /* eslint-disable jsx-a11y/no-static-element-interactions */ import React, { useCallback, useRef } from "react"; import PT from "prop-types"; +import PaymentCancel from "../PaymentCancel"; +import PaymentError from "../PaymentError"; +import PaymentStatus from "../PaymentStatus"; import { currencyFormatter, formatChallengeUrl } from "utils/formatters"; import { PAYMENT_STATUS } from "constants/workPeriods"; -import PaymentStatus from "../PaymentStatus"; import styles from "./styles.module.scss"; -import ProcessingError from "../PaymentError"; const PaymentsListItem = ({ item }) => { const inputRef = useRef(); @@ -51,13 +52,16 @@ const PaymentsListItem = ({ item }) => {
{item.status === PAYMENT_STATUS.FAILED && ( - )}
+ ); }; diff --git a/src/services/workPeriods.js b/src/services/workPeriods.js index 88e2385..7d38a9a 100644 --- a/src/services/workPeriods.js +++ b/src/services/workPeriods.js @@ -6,6 +6,7 @@ import { PROJECTS_API_URL, API_QUERY_PARAM_NAMES, WORK_PERIODS_API_URL, + API_CHALLENGE_PAYMENT_STATUS, } from "constants/workPeriods"; import { buildRequestQuery, extractResponseData } from "utils/misc"; @@ -100,14 +101,22 @@ export const fetchResourceBookings = (params) => { return [ axios.get( `${RB_API_URL}?${buildRequestQuery(params, API_QUERY_PARAM_NAMES)}`, - { - cancelToken: source.token, - } + { cancelToken: source.token } ), source, ]; }; +export const fetchWorkPeriod = (periodId) => { + const source = CancelToken.source(); + return [ + axios + .get(`${WORK_PERIODS_API_URL}/${periodId}`, { cancelToken: source.token }) + .then(extractResponseData), + source, + ]; +}; + /** * Updates working period's working days. * @@ -140,6 +149,20 @@ export const patchWorkPeriodBillingAccount = (rbId, billingAccountId) => { return axios.patch(`${RB_API_URL}/${rbId}`, { billingAccountId }); }; +/** + * Sends request to cancel specific working period's payment. + * + * @param {string} paymentId payment id + * @returns {Promise} + */ +export const cancelWorkPeriodPayment = (paymentId) => { + return axios + .patch(`${PAYMENTS_API_URL}/${paymentId}`, { + status: API_CHALLENGE_PAYMENT_STATUS.CANCELLED, + }) + .then(extractResponseData); +}; + /** * Sends request to queue payments for specific working periods and amounts * inside the provided array. diff --git a/src/store/actionTypes/workPeriods.js b/src/store/actionTypes/workPeriods.js index 89d986c..d7fb1ac 100644 --- a/src/store/actionTypes/workPeriods.js +++ b/src/store/actionTypes/workPeriods.js @@ -18,6 +18,7 @@ export const WP_SET_DETAILS_HIDE_PAST_PERIODS = export const WP_SET_PAGE_NUMBER = "WP_SET_PAGE_NUMBER"; export const WP_SET_PAGE_SIZE = "WP_SET_PAGE_SIZE"; export const WP_SET_DATE_RANGE = "WP_SET_DATE_RANGE"; +export const WP_SET_PAYMENT_DATA = "WP_SET_PAYMENT_DATA"; export const WP_SET_PAYMENT_STATUSES = "WP_SET_PAYMENT_STATUSES"; export const WP_SET_PERIOD_DATA_PENDING = "WP_SET_PERIOD_DATA_PENDING"; export const WP_SET_PERIOD_DATA_SUCCESS = "WP_SET_PERIOD_DATA_SUCCESS"; @@ -27,6 +28,9 @@ export const WP_SET_SORT_ORDER = "WP_SET_SORT_ORDER"; export const WP_SET_SORTING = "WP_SET_SORTING"; export const WP_SET_USER_HANDLE = "WP_SET_USER_HANDLE"; export const WP_SET_WORKING_DAYS = "WP_SET_WORKING_DAYS"; +export const WP_SET_WORKING_DAYS_PENDING = "WP_SET_WORKING_DAYS_PENDING"; +export const WP_SET_WORKING_DAYS_SUCCESS = "WP_SET_WORKING_DAYS_SUCCESS"; +export const WP_SET_WORKING_DAYS_ERROR = "WP_SET_WORKING_DAYS_ERROR"; export const WP_TOGGLE_ONLY_FAILED_PAYMENTS = "WP_TOGGLE_ONLY_FAILED_PAYMENTS"; export const WP_TOGGLE_PERIOD = "WP_TOGGLE_PERIOD"; export const WP_TOGGLE_PERIODS_ALL = "WP_TOGGLE_PERIODS_ALL"; diff --git a/src/store/actions/workPeriods.js b/src/store/actions/workPeriods.js index 262a972..11f83cc 100644 --- a/src/store/actions/workPeriods.js +++ b/src/store/actions/workPeriods.js @@ -276,6 +276,34 @@ export const setWorkPeriodsUserHandle = (handle) => ({ payload: handle, }); +/** + * Creates an action denoting an attempt to update working period's data + * on the server. + * + * @param {Object} periodId working period id + * @param {Object} cancelSource axios cancel token source + * @returns {Object} + */ +export const setWorkPeriodDataPending = (periodId, cancelSource) => ({ + type: ACTION_TYPE.WP_SET_PERIOD_DATA_PENDING, + payload: { periodId, cancelSource }, +}); + +export const setWorkPeriodDataSuccess = (periodId, data) => ({ + type: ACTION_TYPE.WP_SET_PERIOD_DATA_SUCCESS, + payload: { periodId, data }, +}); + +export const setWorkPeriodDataError = (periodId, message) => ({ + type: ACTION_TYPE.WP_SET_PERIOD_DATA_ERROR, + payload: { periodId, message }, +}); + +export const setWorkPeriodPaymentData = (paymentData) => ({ + type: ACTION_TYPE.WP_SET_PAYMENT_DATA, + payload: paymentData, +}); + /** * Creates an action to change working days for specific working period. * @@ -289,24 +317,25 @@ export const setWorkPeriodWorkingDays = (periodId, daysWorked) => ({ }); /** - * Creates an action denoting the update of working period's changeable data. + * Creates an action denoting an attempt to update working period's working days + * on the server. * * @param {Object} periodId working period id * @param {Object} cancelSource axios cancel token source * @returns {Object} */ -export const setWorkPeriodDataPending = (periodId, cancelSource) => ({ - type: ACTION_TYPE.WP_SET_PERIOD_DATA_PENDING, +export const setWorkPeriodWorkingDaysPending = (periodId, cancelSource) => ({ + type: ACTION_TYPE.WP_SET_WORKING_DAYS_PENDING, payload: { periodId, cancelSource }, }); -export const setWorkPeriodDataSuccess = (periodId, data) => ({ - type: ACTION_TYPE.WP_SET_PERIOD_DATA_SUCCESS, +export const setWorkPeriodWorkingDaysSuccess = (periodId, data) => ({ + type: ACTION_TYPE.WP_SET_WORKING_DAYS_SUCCESS, payload: { periodId, data }, }); -export const setWorkPeriodDataError = (periodId, message) => ({ - type: ACTION_TYPE.WP_SET_PERIOD_DATA_ERROR, +export const setWorkPeriodWorkingDaysError = (periodId, message) => ({ + type: ACTION_TYPE.WP_SET_WORKING_DAYS_ERROR, payload: { periodId, message }, }); diff --git a/src/store/reducers/workPeriods.js b/src/store/reducers/workPeriods.js index 5495f15..12c0d8c 100644 --- a/src/store/reducers/workPeriods.js +++ b/src/store/reducers/workPeriods.js @@ -554,7 +554,6 @@ const actionHandlers = { periodsData[periodId] = { ...periodData, cancelSource, - daysWorkedIsUpdated: false, }; return { ...state, @@ -567,11 +566,10 @@ const actionHandlers = { if (!periodData) { return state; } - periodData = periodsData[periodId] = { + periodsData[periodId] = { ...periodData, ...data, cancelSource: null, - daysWorkedIsUpdated: true, }; state = { ...state, @@ -581,7 +579,8 @@ const actionHandlers = { ? updateStateAfterWorkingDaysChange(periodId, state) : state; }, - [ACTION_TYPE.WP_SET_PERIOD_DATA_ERROR]: (state, { periodId }) => { + [ACTION_TYPE.WP_SET_PERIOD_DATA_ERROR]: (state, { periodId, message }) => { + console.error(message); const periodsData = state.periodsData[0]; const periodData = periodsData[periodId]; if (!periodData) { @@ -590,7 +589,35 @@ const actionHandlers = { periodsData[periodId] = { ...periodData, cancelSource: null, - daysWorkedIsUpdated: false, + }; + return { + ...state, + periodsData: [periodsData], + }; + }, + [ACTION_TYPE.WP_SET_PAYMENT_DATA]: (state, paymentData) => { + const periodId = paymentData.workPeriodId; + const periodsData = state.periodsData[0]; + const periodData = periodsData[periodId]; + if (!periodData) { + return state; + } + const paymentId = paymentData.id; + const payments = periodData.payments; + let lastFailedPayment = null; + for (let i = 0, len = payments.length; i < len; i++) { + let payment = payments[i]; + if (payment.id === paymentId) { + payments[i] = paymentData; + periodData.payments = [...payments]; + } + if (payment.status === PAYMENT_STATUS.FAILED) { + lastFailedPayment = payment; + } + } + periodsData[periodId] = { + ...periodData, + paymentErrorLast: lastFailedPayment?.statusDetails, }; return { ...state, @@ -613,6 +640,62 @@ const actionHandlers = { periodsData: [periodsData], }); }, + [ACTION_TYPE.WP_SET_WORKING_DAYS_PENDING]: ( + state, + { periodId, cancelSource } + ) => { + const periodsData = state.periodsData[0]; + const periodData = periodsData[periodId]; + if (!periodData) { + return state; + } + periodsData[periodId] = { + ...periodData, + cancelSource, + daysWorkedIsUpdated: false, + }; + return { + ...state, + periodsData: [periodsData], + }; + }, + [ACTION_TYPE.WP_SET_WORKING_DAYS_SUCCESS]: (state, { periodId, data }) => { + const periodsData = state.periodsData[0]; + let periodData = periodsData[periodId]; + if (!periodData) { + return state; + } + periodData = periodsData[periodId] = { + ...periodData, + ...data, + cancelSource: null, + daysWorkedIsUpdated: true, + }; + state = { + ...state, + periodsData: [periodsData], + }; + return periodId in state.periodsById + ? updateStateAfterWorkingDaysChange(periodId, state) + : state; + }, + [ACTION_TYPE.WP_SET_WORKING_DAYS_ERROR]: (state, { periodId, message }) => { + console.error(message); + const periodsData = state.periodsData[0]; + const periodData = periodsData[periodId]; + if (!periodData) { + return state; + } + periodsData[periodId] = { + ...periodData, + cancelSource: null, + daysWorkedIsUpdated: false, + }; + return { + ...state, + periodsData: [periodsData], + }; + }, [ACTION_TYPE.WP_TOGGLE_ONLY_FAILED_PAYMENTS]: (state, on) => { const filters = state.filters; on = on === null ? !filters.onlyFailedPayments : on; @@ -754,7 +837,8 @@ function updateSelectedPeriodsFlags(state) { const selectedCount = state.periodsSelected[0].size; const pageSize = state.pagination.pageSize; const totalCount = state.pagination.totalCount; - const maxSelectedOnPageCount = pageSize - state.periodsDisabled[0].size; + const maxSelectedOnPageCount = + Math.min(pageSize, totalCount) - state.periodsDisabled[0].size; if (totalCount > pageSize) { if (selectedCount === maxSelectedOnPageCount) { isSelectedPeriodsVisible = true; diff --git a/src/store/thunks/workPeriods.js b/src/store/thunks/workPeriods.js index 0c4469a..755bb71 100644 --- a/src/store/thunks/workPeriods.js +++ b/src/store/thunks/workPeriods.js @@ -31,6 +31,46 @@ import { makeToastPaymentsError, } from "routes/WorkPeriods/utils/toasts"; import { RESOURCE_BOOKING_STATUS, WORK_PERIODS_PATH } from "constants/index.js"; +import { currencyFormatter } from "utils/formatters"; + +export const loadWorkPeriodAfterPaymentCancel = + (periodId, paymentId) => async (dispatch, getState) => { + let [periodsData] = selectors.getWorkPeriodsData(getState()); + periodsData[periodId]?.cancelSource?.cancel(); + const [promise, source] = services.fetchWorkPeriod(periodId); + dispatch(actions.setWorkPeriodDataPending(periodId, source)); + let periodData = null; + let userHandle = null; + let errorMessage = null; + try { + const data = await promise; + periodData = normalizePeriodData(data); + userHandle = data.userHandle; + } catch (error) { + if (!axios.isCancel(error)) { + errorMessage = error.toString(); + } + } + if (periodData) { + let amount = null; + for (let payment of periodData.payments) { + if (payment.id === paymentId) { + amount = currencyFormatter.format(payment.amount); + break; + } + } + dispatch(actions.setWorkPeriodDataSuccess(periodId, periodData)); + makeToast( + `Payment ${amount} for ${userHandle} was marked as "cancelled"`, + "success" + ); + } else if (errorMessage) { + dispatch(actions.setWorkPeriodDataError(periodId, errorMessage)); + makeToast( + `Failed to load data for working period ${periodId}.\n` + errorMessage + ); + } + }; /** * Thunk that loads the specified working periods' page. If page number is not @@ -226,7 +266,7 @@ export const updateWorkPeriodWorkingDays = periodId, daysWorked ); - dispatch(actions.setWorkPeriodDataPending(periodId, source)); + dispatch(actions.setWorkPeriodWorkingDaysPending(periodId, source)); let periodData = null; let errorMessage = null; try { @@ -250,9 +290,9 @@ export const updateWorkPeriodWorkingDays = // and there will be a new request at the end of which the period's data // will be updated so again we don't need to update the state. if (periodData && periodData.daysWorked === currentDaysWorked) { - dispatch(actions.setWorkPeriodDataSuccess(periodId, periodData)); + dispatch(actions.setWorkPeriodWorkingDaysSuccess(periodId, periodData)); } else if (errorMessage) { - dispatch(actions.setWorkPeriodDataError(periodId, errorMessage)); + dispatch(actions.setWorkPeriodWorkingDaysError(periodId, errorMessage)); } }; diff --git a/src/styles/variables/_colors.scss b/src/styles/variables/_colors.scss index 5e0a4c6..1abfa76 100644 --- a/src/styles/variables/_colors.scss +++ b/src/styles/variables/_colors.scss @@ -6,6 +6,9 @@ $primary-dark-color: #137d60; // currently not used, can be changed $primary-dark-text-color: #137d60; // currently not used, can be changed $error-color: #e90c5a; +$error-text-color: #eb145f; +$warning-color: #ef476f; +$warning-text-color: #f05c7e; $text-color: #2a2a2a; $page-bg-color: #f4f5f6; diff --git a/src/utils/misc.js b/src/utils/misc.js index 4737b7a..e6f6dcf 100644 --- a/src/utils/misc.js +++ b/src/utils/misc.js @@ -80,6 +80,11 @@ export function stopPropagation(event) { event.stopPropagation(); } +export function stopImmediatePropagation(event) { + event.stopPropagation(); + event.nativeEvent.stopImmediatePropagation(); +} + /** * This function takes keys referring to truthy values in `newOptions` * and adds them to `oldOptions` returning a new object. diff --git a/src/utils/workPeriods.js b/src/utils/workPeriods.js index 8d908d2..1540a86 100644 --- a/src/utils/workPeriods.js +++ b/src/utils/workPeriods.js @@ -172,9 +172,7 @@ export function normalizePeriodData(period) { if (payments) { let lastFailedPayment = null; for (let payment of payments) { - payment.status = - API_CHALLENGE_PAYMENT_STATUS_MAP[payment.status] || - PAYMENT_STATUS.UNDEFINED; + payment.status = normalizeChallengePaymentStatus(payment.status); if (payment.status === PAYMENT_STATUS.FAILED) { lastFailedPayment = payment; } @@ -185,6 +183,12 @@ export function normalizePeriodData(period) { return data; } +export function normalizeChallengePaymentStatus(paymentStatus) { + return ( + API_CHALLENGE_PAYMENT_STATUS_MAP[paymentStatus] || PAYMENT_STATUS.UNDEFINED + ); +} + export function normalizePaymentStatus(paymentStatus) { return API_PAYMENT_STATUS_MAP[paymentStatus]; } From 519a28924783de94f3e050d988252d792e721ef6 Mon Sep 17 00:00:00 2001 From: Oleg Petrov Date: Mon, 5 Jul 2021 03:32:34 +0300 Subject: [PATCH 17/30] Fixed: closing modal sometimes closed popover. --- src/components/Modal/index.jsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/Modal/index.jsx b/src/components/Modal/index.jsx index 8f3d4e2..7ab7d4d 100644 --- a/src/components/Modal/index.jsx +++ b/src/components/Modal/index.jsx @@ -33,6 +33,8 @@ const Modal = ({ >
Date: Mon, 5 Jul 2021 12:17:43 +0300 Subject: [PATCH 18/30] fix: get Team Name using TaaS API ref issue #49 --- src/constants/workPeriods.js | 1 + src/services/workPeriods.js | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/constants/workPeriods.js b/src/constants/workPeriods.js index 0a63e83..ab68074 100644 --- a/src/constants/workPeriods.js +++ b/src/constants/workPeriods.js @@ -24,6 +24,7 @@ export const JOBS_API_URL = `${API.V5}/jobs`; export const PAYMENTS_API_URL = `${API.V5}/work-period-payments`; export const PROJECTS_API_URL = `${API.V5}/projects`; export const WORK_PERIODS_API_URL = `${API.V5}/work-periods`; +export const TAAS_TEAM_API_URL = `${API.V5}/taas-teams`; export const DATE_FORMAT_API = "YYYY-MM-DD"; export const DATE_FORMAT_UI = "MMM DD, YYYY"; diff --git a/src/services/workPeriods.js b/src/services/workPeriods.js index 88e2385..6c3780f 100644 --- a/src/services/workPeriods.js +++ b/src/services/workPeriods.js @@ -6,6 +6,7 @@ import { PROJECTS_API_URL, API_QUERY_PARAM_NAMES, WORK_PERIODS_API_URL, + TAAS_TEAM_API_URL, } from "constants/workPeriods"; import { buildRequestQuery, extractResponseData } from "utils/misc"; @@ -38,7 +39,7 @@ export const fetchJob = (jobId, source) => { */ export const fetchProject = (projectId) => { return axios - .get(`${PROJECTS_API_URL}/${projectId}?fields=projectId,name`) + .get(`${TAAS_TEAM_API_URL}/${projectId}`) .then(extractResponseData); }; From a8b751ff312661906ff006a4e85d92651407e96e Mon Sep 17 00:00:00 2001 From: Maksym Mykhailenko Date: Mon, 5 Jul 2021 12:21:27 +0300 Subject: [PATCH 19/30] fix: typo ref issue #48 --- src/constants/workPeriods.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/constants/workPeriods.js b/src/constants/workPeriods.js index ab68074..08f0911 100644 --- a/src/constants/workPeriods.js +++ b/src/constants/workPeriods.js @@ -141,7 +141,7 @@ export const BILLING_ACCOUNTS_ERROR = ""; export const REASON_DISABLED_MESSAGE_MAP = { [REASON_DISABLED.NO_BILLING_ACCOUNT]: - "Billing Account is not set for the Resorce Booking", + "Billing Account is not set for the Resource Booking", [REASON_DISABLED.NO_DAYS_TO_PAY_FOR]: "There are no days to pay for", [REASON_DISABLED.NO_MEMBER_RATE]: "Member Rate should be greater than 0", }; From 9e469c40f779539e6a8ddfaec58ed8f18f652d5d Mon Sep 17 00:00:00 2001 From: Maksym Mykhailenko Date: Mon, 5 Jul 2021 12:31:23 +0300 Subject: [PATCH 20/30] fix: added dashes to messages ref issue #48 --- src/routes/WorkPeriods/components/PeriodItem/index.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/routes/WorkPeriods/components/PeriodItem/index.jsx b/src/routes/WorkPeriods/components/PeriodItem/index.jsx index 0f0e83f..0419f24 100644 --- a/src/routes/WorkPeriods/components/PeriodItem/index.jsx +++ b/src/routes/WorkPeriods/components/PeriodItem/index.jsx @@ -273,10 +273,10 @@ function formatReasonsDisabled(reasonIds) { return null; } const reasons = []; - reasons.push(REASON_DISABLED_MESSAGE_MAP[reasonIds[0]]); + reasons.push('– ' + REASON_DISABLED_MESSAGE_MAP[reasonIds[0]]); for (let i = 1, len = reasonIds.length; i < len; i++) { reasons.push(
); - reasons.push(REASON_DISABLED_MESSAGE_MAP[reasonIds[i]]); + reasons.push('– ' + REASON_DISABLED_MESSAGE_MAP[reasonIds[i]]); } return reasons; } From 213c7741857548edfb77b362027665b6f29a51ff Mon Sep 17 00:00:00 2001 From: Oleg Petrov Date: Mon, 5 Jul 2021 12:54:27 +0300 Subject: [PATCH 21/30] Added comments for Modal. Disabled payment Cancel button for payments with Cancelled status. --- src/components/Modal/index.jsx | 15 +++++++++++++++ .../components/PaymentCancel/index.jsx | 5 ++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/components/Modal/index.jsx b/src/components/Modal/index.jsx index 7ab7d4d..09ea934 100644 --- a/src/components/Modal/index.jsx +++ b/src/components/Modal/index.jsx @@ -13,6 +13,21 @@ const classNames = { }; const closeIcon = ; +/** + * Displays a modal with Approve- and Dismiss-button and an overlay. + * + * @param {Object} props component properties + * @param {string} [props.approveText] text for Approve-button + * @param {Object} props.children elements that will be shown inside modal + * @param {?Object} [props.controls] custom controls that will be shown below + * modal's contents + * @param {string} [props.dismissText] text for Dismiss-button + * @param {boolean} props.isOpen whether to show or hide the modal + * @param {() => void} [props.onApprove] function called on approve action + * @param {() => void} props.onDismiss function called on dismiss action + * @param {string} [props.title] text for modal title + * @returns {JSX.Element} + */ const Modal = ({ approveText = "Apply", children, diff --git a/src/routes/WorkPeriods/components/PaymentCancel/index.jsx b/src/routes/WorkPeriods/components/PaymentCancel/index.jsx index b60221d..0f1cf8d 100644 --- a/src/routes/WorkPeriods/components/PaymentCancel/index.jsx +++ b/src/routes/WorkPeriods/components/PaymentCancel/index.jsx @@ -75,7 +75,10 @@ const PaymentCancel = ({ className, item, timeout = 3000 }) => { return (
@@ -177,6 +179,8 @@ PeriodDetails.propTypes = { rbId: PT.string.isRequired, jobId: PT.string.isRequired, billingAccountId: PT.number.isRequired, + bookingStart: PT.string.isRequired, + bookingEnd: PT.string.isRequired, }).isRequired, }; diff --git a/src/routes/WorkPeriods/components/PeriodItem/index.jsx b/src/routes/WorkPeriods/components/PeriodItem/index.jsx index c1b7ab6..9f890a0 100644 --- a/src/routes/WorkPeriods/components/PeriodItem/index.jsx +++ b/src/routes/WorkPeriods/components/PeriodItem/index.jsx @@ -27,7 +27,11 @@ import { updateWorkPeriodWorkingDays, } from "store/thunks/workPeriods"; import { useUpdateEffect } from "utils/hooks"; -import { formatUserHandleLink, formatWeeklyRate } from "utils/formatters"; +import { + formatDate, + formatUserHandleLink, + formatWeeklyRate, +} from "utils/formatters"; import { stopPropagation } from "utils/misc"; import styles from "./styles.module.scss"; import PeriodAlerts from "../PeriodAlerts"; @@ -178,8 +182,8 @@ const PeriodItem = ({ -
- + + @@ -208,12 +212,14 @@ const PeriodItem = ({ @@ -266,14 +272,14 @@ PeriodItem.propTypes = { projectId: PT.oneOfType([PT.number, PT.string]).isRequired, userHandle: PT.string.isRequired, teamName: PT.oneOfType([PT.number, PT.string]).isRequired, - startDate: PT.string.isRequired, - endDate: PT.string.isRequired, + bookingStart: PT.string.isRequired, + bookingEnd: PT.string.isRequired, weeklyRate: PT.number, }).isRequired, alerts: PT.arrayOf(PT.string), data: PT.shape({ - daysWorked: PT.number.isRequired, daysPaid: PT.number.isRequired, + daysWorked: PT.number.isRequired, paymentErrorLast: PT.object, payments: PT.array, paymentStatus: PT.string.isRequired, diff --git a/src/routes/WorkPeriods/components/PeriodWorkingDays/index.jsx b/src/routes/WorkPeriods/components/PeriodWorkingDays/index.jsx index 5c221f7..d2c525b 100644 --- a/src/routes/WorkPeriods/components/PeriodWorkingDays/index.jsx +++ b/src/routes/WorkPeriods/components/PeriodWorkingDays/index.jsx @@ -1,14 +1,18 @@ -import React from "react"; +import React, { useMemo } from "react"; import PT from "prop-types"; import cn from "classnames"; -import IntegerField from "components/IntegerField"; +import Tooltip from "components/Tooltip"; import IconCheckmarkCircled from "components/Icons/CheckmarkCircled"; +import { formatDate } from "utils/formatters"; +import { stopPropagation } from "utils/misc"; import styles from "./styles.module.scss"; /** * Displays working days input field with an icon hinting about the update. * * @param {Object} props component properties + * @param {string} props.bookingStart resource booking start date + * @param {string} props.bookingEnd resource booking end date * @param {string} [props.className] class name to be added to root element * @param {string} props.controlName working days input control name * @param {Object} props.data working period data object @@ -22,42 +26,109 @@ import styles from "./styles.module.scss"; * @returns {JSX.Element} */ const PeriodWorkingDays = ({ + bookingStart, + bookingEnd, className, controlName, - data, + data: { daysPaid, daysWorked, daysWorkedMax, daysWorkedIsUpdated }, isDisabled, onWorkingDaysChange, onWorkingDaysUpdateHintTimeout, updateHintTimeout = 2000, -}) => ( -
- - {data.daysWorkedIsUpdated && ( - { + const isBtnMinusDisabled = daysWorked > 0 && daysWorked <= daysPaid; + const isBtnPlusDisabled = daysWorked < 5 && daysWorked >= daysWorkedMax; + const decreaseDaysWorkedMessage = useMemo( + () => `Cannot decrease "Working Days" below the number of days already + paid for: ${daysPaid}`, + [daysPaid] + ); + const increaseDaysWorkedMessage = useMemo( + () => `Cannot increase "Working Days" because the Resource Booking period + is between ${formatDate(bookingStart)} and ${formatDate(bookingEnd)}`, + [bookingStart, bookingEnd] + ); + + return ( +
+ + {daysWorkedIsUpdated && ( + + )} + +
+ - )} - - -
-); + +
+
+ ); +}; PeriodWorkingDays.propTypes = { + bookingStart: PT.string.isRequired, + bookingEnd: PT.string.isRequired, className: PT.string, controlName: PT.string.isRequired, data: PT.shape({ daysPaid: PT.number.isRequired, daysWorked: PT.number.isRequired, + daysWorkedMax: PT.number.isRequired, daysWorkedIsUpdated: PT.bool.isRequired, }).isRequired, isDisabled: PT.bool.isRequired, diff --git a/src/routes/WorkPeriods/components/PeriodWorkingDays/styles.module.scss b/src/routes/WorkPeriods/components/PeriodWorkingDays/styles.module.scss index bca1443..b27f3bd 100644 --- a/src/routes/WorkPeriods/components/PeriodWorkingDays/styles.module.scss +++ b/src/routes/WorkPeriods/components/PeriodWorkingDays/styles.module.scss @@ -1,3 +1,5 @@ +@import "styles/variables"; + .container { display: flex; align-items: baseline; @@ -17,5 +19,126 @@ } .daysWorkedControl { + position: relative; + display: inline-flex; + align-items: center; + border: 1px solid $control-border-color; + border-radius: 6px; width: 100px; + overflow: hidden; +} + +input.input { + flex: 1 1 0; + margin: 0; + border: none !important; + padding: 3px 0; + height: 28px; + line-height: 22px; + background: #fff; + outline: none !important; + box-shadow: none !important; + text-align: center; + + &:disabled { + border-color: $control-disabled-border-color; + background-color: $control-disabled-bg-color; + color: $control-disabled-text-color; + cursor: not-allowed; + + ~ .btnMinus, + ~ .btnPlus { + cursor: not-allowed; + } + } +} + +.tooltip { + max-width: 400px; +} + +.tooltipTarget { + display: block; + width: 100%; + height: 100%; + + &.notAllowed { + cursor: not-allowed; + } +} + +.btnMinus, +.btnPlus { + position: absolute; + top: 0; + bottom: 0; + margin: auto; + width: 30px; + height: 30px; + + button { + display: block; + position: relative; + border: none; + padding: 0; + width: 100%; + height: 100%; + background: transparent; + outline: none !important; + cursor: pointer; + + &:disabled { + background: #ddd; + opacity: 1; + } + } +} + +.btnMinus { + left: 0; + + button { + &::before { + content: ""; + display: block; + position: absolute; + left: 0; + top: 0; + right: 0; + bottom: 0; + margin: auto; + width: 8px; + height: 1px; + background: #7f7f7f; + } + } +} + +.btnPlus { + right: 0; + + button { + &::before, + &::after { + content: ""; + display: block; + position: absolute; + left: 0; + top: 0; + right: 0; + bottom: 0; + margin: auto; + background: #7f7f7f; + } + + &::before { + width: 9px; + height: 1px; + } + + &::after { + width: 1px; + height: 9px; + } + } } diff --git a/src/routes/WorkPeriods/components/PeriodsHistory/index.jsx b/src/routes/WorkPeriods/components/PeriodsHistory/index.jsx index c1cf309..caeaed8 100644 --- a/src/routes/WorkPeriods/components/PeriodsHistory/index.jsx +++ b/src/routes/WorkPeriods/components/PeriodsHistory/index.jsx @@ -15,7 +15,13 @@ import styles from "./styles.module.scss"; * @param {Object} props component properties * @returns {JSX.Element} */ -const PeriodsHistory = ({ className, isDisabled, periods }) => { +const PeriodsHistory = ({ + bookingStart, + bookingEnd, + className, + isDisabled, + periods, +}) => { const [periodsData] = useSelector(getWorkPeriodsData); const [startDate] = useSelector(getWorkPeriodsDateRange); return ( @@ -25,6 +31,8 @@ const PeriodsHistory = ({ className, isDisabled, periods }) => { {periods.map((period) => ( { }; PeriodsHistory.propTypes = { + bookingStart: PT.string.isRequired, + bookingEnd: PT.string.isRequired, className: PT.string, isDisabled: PT.bool.isRequired, periods: PT.arrayOf(PT.object), diff --git a/src/routes/WorkPeriods/components/PeriodsHistoryItem/index.jsx b/src/routes/WorkPeriods/components/PeriodsHistoryItem/index.jsx index a1d29e3..235047a 100644 --- a/src/routes/WorkPeriods/components/PeriodsHistoryItem/index.jsx +++ b/src/routes/WorkPeriods/components/PeriodsHistoryItem/index.jsx @@ -3,7 +3,6 @@ import { useDispatch } from "react-redux"; import PT from "prop-types"; import cn from "classnames"; import debounce from "lodash/debounce"; -import moment from "moment"; import ProcessingError from "../PaymentError"; import PaymentStatus from "../PaymentStatus"; import PaymentTotal from "../PaymentTotal"; @@ -15,7 +14,11 @@ import { } from "store/actions/workPeriods"; import { updateWorkPeriodWorkingDays } from "store/thunks/workPeriods"; import { useUpdateEffect } from "utils/hooks"; -import { formatDateLabel, formatDateRange } from "utils/formatters"; +import { + formatDateLabel, + formatDateRange, + formatPlural, +} from "utils/formatters"; import styles from "./styles.module.scss"; /** @@ -24,12 +27,19 @@ import styles from "./styles.module.scss"; * @param {Object} props component properties * @returns {JSX.Element} */ -const PeriodsHistoryItem = ({ isDisabled, item, data, currentStartDate }) => { +const PeriodsHistoryItem = ({ + bookingStart, + bookingEnd, + isDisabled, + item, + data, + currentStartDate, +}) => { const dispatch = useDispatch(); - const dateLabel = formatDateLabel(item.startDate, currentStartDate); + const dateLabel = formatDateLabel(item.start, currentStartDate); const daysWorked = data.daysWorked; - const isCurrent = moment(item.startDate).isSame(currentStartDate, "date"); + const isCurrent = item.start.isSame(currentStartDate, "date"); const onWorkingDaysChange = useCallback( (daysWorked) => { @@ -56,9 +66,9 @@ const PeriodsHistoryItem = ({ isDisabled, item, data, currentStartDate }) => { // Update working days on server if working days change. useUpdateEffect(() => { if (!isCurrent) { - updateWorkingDays(data.daysWorked); + updateWorkingDays(daysWorked); } - }, [data.daysWorked, isCurrent]); + }, [daysWorked, isCurrent]); return ( { })} > @@ -110,18 +124,20 @@ PeriodsHistoryItem.propTypes = { isDisabled: PT.bool.isRequired, item: PT.shape({ id: PT.string.isRequired, - startDate: PT.oneOfType([PT.string, PT.number]).isRequired, - endDate: PT.oneOfType([PT.string, PT.number]).isRequired, + start: PT.object.isRequired, + end: PT.object.isRequired, weeklyRate: PT.number, }).isRequired, data: PT.shape({ - daysWorked: PT.number.isRequired, daysPaid: PT.number.isRequired, + daysWorked: PT.number.isRequired, paymentErrorLast: PT.object, payments: PT.array, paymentStatus: PT.string.isRequired, paymentTotal: PT.number.isRequired, }).isRequired, + bookingStart: PT.string.isRequired, + bookingEnd: PT.string.isRequired, currentStartDate: PT.oneOfType([PT.string, PT.number, PT.object]).isRequired, }; diff --git a/src/routes/WorkPeriods/components/PeriodsHistoryItem/styles.module.scss b/src/routes/WorkPeriods/components/PeriodsHistoryItem/styles.module.scss index d85f175..1db84fa 100644 --- a/src/routes/WorkPeriods/components/PeriodsHistoryItem/styles.module.scss +++ b/src/routes/WorkPeriods/components/PeriodsHistoryItem/styles.module.scss @@ -48,6 +48,10 @@ padding: 4px 10px; } +.daysWorkedLabel { + margin-left: 40px; +} + .daysWorkedControl { display: block; width: 100px; diff --git a/src/store/reducers/workPeriods.js b/src/store/reducers/workPeriods.js index 12c0d8c..4ca2844 100644 --- a/src/store/reducers/workPeriods.js +++ b/src/store/reducers/workPeriods.js @@ -20,6 +20,7 @@ import { } from "utils/misc"; import { addValueImmutable, + computeDaysWorkedMax, createPeriodAlerts, createAssignedBillingAccountOption, findReasonsDisabled, @@ -127,15 +128,24 @@ const actionHandlers = { const periodsById = {}; const periodsData = {}; const periodsDisabledMap = new Map(); - const periodEndDate = state.filters.dateRange[1]; + const dateRange = state.filters.dateRange; + const periodStart = dateRange[0]; + const periodEnd = dateRange[1]; for (let period of periods) { periodsById[period.id] = true; - periodsData[period.id] = initPeriodData(period); + let periodData = initPeriodData(period); + periodData.daysWorkedMax = computeDaysWorkedMax( + period.bookingStart, + period.bookingEnd, + periodStart, + periodEnd + ); + periodsData[period.id] = periodData; let reasonsDisabled = findReasonsDisabled(period); if (reasonsDisabled) { periodsDisabledMap.set(period.id, reasonsDisabled); } - let alerts = createPeriodAlerts(period, periodEndDate); + let alerts = createPeriodAlerts(period, periodEnd); if (alerts) { periodsAlerts[period.id] = alerts; } @@ -221,9 +231,26 @@ const actionHandlers = { // This branch should not be reachable but just in case. return state; } + const periods = state.periods; + let period = null; + for (let i = 0, len = periods.length; i < len; i++) { + period = periods[i]; + if (period.id === periodId) { + break; + } + } + const { bookingStart, bookingEnd } = period; const periodsData = state.periodsData[0]; for (let period of details.periods) { - periodsData[period.id] = initPeriodData(period); + let periodData = initPeriodData(period); + periodData.daysWorkedMax = computeDaysWorkedMax( + bookingStart, + bookingEnd, + period.start, + period.end + ); + periodsData[period.id] = periodData; + delete period.data; } periodDetails = { ...periodDetails, @@ -413,7 +440,10 @@ const actionHandlers = { if (!periodData) { return state; } - daysWorked = Math.min(Math.max(daysWorked, periodData.daysPaid), 5); + daysWorked = Math.min( + Math.max(daysWorked, periodData.daysPaid), + periodData.daysWorkedMax + ); if (daysWorked === periodData.daysWorked) { return state; } @@ -630,7 +660,10 @@ const actionHandlers = { if (!periodData) { return state; } - daysWorked = Math.min(Math.max(daysWorked, periodData.daysPaid), 5); + daysWorked = Math.min( + Math.max(daysWorked, periodData.daysPaid), + periodData.daysWorkedMax + ); if (daysWorked === periodData.daysWorked) { return state; } diff --git a/src/utils/formatters.js b/src/utils/formatters.js index de354bb..f31fb18 100644 --- a/src/utils/formatters.js +++ b/src/utils/formatters.js @@ -1,6 +1,6 @@ import moment from "moment"; import isNumber from "lodash/isNumber"; -import { PAYMENT_STATUS_LABELS } from "constants/workPeriods"; +import { DATE_FORMAT_UI, PAYMENT_STATUS_LABELS } from "constants/workPeriods"; import { PLATFORM_WEBSITE_URL, TAAS_BASE_PATH, @@ -19,6 +19,16 @@ export function formatChallengeUrl(challengeId) { return `${TOPCODER_WEBSITE_URL}/challenges/${challengeId}`; } +/** + * Returns formatted date for working period rows. + * + * @param {any} date any value accepted by MomentJS + * @returns {string} + */ +export function formatDate(date) { + return date ? moment(date).format(DATE_FORMAT_UI) : "-"; +} + /** * Returns a string denoting whether the specified start date corresponds to the * current period or future period. diff --git a/src/utils/misc.js b/src/utils/misc.js index e6f6dcf..968a5c9 100644 --- a/src/utils/misc.js +++ b/src/utils/misc.js @@ -15,7 +15,7 @@ export function filterPeriodsByStartDate(periods, startDate) { const items = []; startDate = moment(startDate); for (let period of periods) { - if (moment(period.startDate).isSameOrAfter(startDate, "date")) { + if (period.start.isSameOrAfter(startDate, "date")) { items.push(period); } } diff --git a/src/utils/workPeriods.js b/src/utils/workPeriods.js index 1540a86..82ebbae 100644 --- a/src/utils/workPeriods.js +++ b/src/utils/workPeriods.js @@ -4,25 +4,55 @@ import { API_CHALLENGE_PAYMENT_STATUS_MAP, API_PAYMENT_STATUS_MAP, DATE_FORMAT_API, - DATE_FORMAT_UI, + DATE_FORMAT_ISO, PAYMENT_STATUS, REASON_DISABLED, URL_QUERY_PARAM_MAP, } from "constants/workPeriods"; +/** + * Computes maximum allowed working days based on resource booking start and end + * dates and working period start and end dates. + * + * @param {string} bookingStart resource booking start date + * @param {string} bookingEnd resource booking end date + * @param {Object} periodStart working period start date + * @param {Object} periodEnd working period end date + * @returns {number} + */ +export function computeDaysWorkedMax( + bookingStart, + bookingEnd, + periodStart, + periodEnd +) { + let start = periodStart.day() + 1; // Monday + let end = periodEnd.day() - 1; // Friday + if (periodStart.isBefore(bookingStart, "date")) { + // booking starts from Monday, Tuesday and so on + start = moment(bookingStart).day(); + } + if (periodEnd.isAfter(bookingEnd, "date")) { + // booking ends at Friday, Thursday and so on + end = moment(bookingEnd).day(); + } + return end - start + 1; +} + /** * Returns an array of working period's alert ids. * - * @param {Object} period working period basic data object - * @param {Object} periodEndDate Moment object with current period end date + * @param {Object} period working period basic data object containing + * resource booking end date + * @param {Object} periodEnd Moment object with working period end * @returns {Array} */ -export function createPeriodAlerts(period, periodEndDate) { +export function createPeriodAlerts(period, periodEnd) { const alerts = []; if (!period.billingAccountId) { alerts.push(ALERT.BA_NOT_ASSIGNED); } - if (periodEndDate.isSameOrAfter(period.endDate)) { + if (periodEnd.isSameOrAfter(period.bookingEnd, "date")) { alerts.push(ALERT.LAST_BOOKING_WEEK); } return alerts.length ? alerts : undefined; @@ -120,11 +150,13 @@ export function normalizePeriodItems(items) { teamName: "", userHandle: workPeriod.userHandle || "", // resource booking period start date - startDate: item.startDate - ? moment(item.startDate).format(DATE_FORMAT_UI) + bookingStart: item.startDate + ? moment(item.startDate).format(DATE_FORMAT_ISO) : "", // resource booking period end date - endDate: item.endDate ? moment(item.endDate).format(DATE_FORMAT_UI) : "", + bookingEnd: item.endDate + ? moment(item.endDate).format(DATE_FORMAT_ISO) + : "", weeklyRate: item.memberRate, data: normalizePeriodData(workPeriod), }); @@ -138,14 +170,16 @@ export function normalizeDetailsPeriodItems(items) { periods.push({ id: item.id, // working period start date - startDate: item.startDate ? moment(item.startDate).valueOf() : 0, + start: moment(item.startDate || undefined), // working period end date - endDate: item.endDate ? moment(item.endDate).valueOf() : 0, + end: moment(item.endDate || undefined), weeklyRate: item.memberRate, data: normalizePeriodData(item), }); } - periods.sort(sortByStartDate); + periods.sort( + (periodA, periodB) => periodA.start.valueOf() - periodB.start.valueOf() + ); return periods; } @@ -163,8 +197,8 @@ export function normalizeDetailsPeriodItems(items) { */ export function normalizePeriodData(period) { const data = { - daysWorked: period.daysWorked === null ? 5 : +period.daysWorked || 0, daysPaid: +period.daysPaid || 0, + daysWorked: period.daysWorked === null ? 5 : +period.daysWorked || 0, paymentStatus: normalizePaymentStatus(period.paymentStatus), paymentTotal: +period.paymentTotal || 0, }; @@ -172,13 +206,16 @@ export function normalizePeriodData(period) { if (payments) { let lastFailedPayment = null; for (let payment of payments) { + payment.createdAt = moment(payment.createdAt).valueOf(); payment.status = normalizeChallengePaymentStatus(payment.status); if (payment.status === PAYMENT_STATUS.FAILED) { lastFailedPayment = payment; } } data.paymentErrorLast = lastFailedPayment?.statusDetails; - data.payments = payments; + data.payments = payments.sort( + (paymentA, paymentB) => paymentA.createdAt - paymentB.createdAt + ); } return data; } @@ -218,7 +255,3 @@ export function normalizeBillingAccounts(accounts) { export function createAssignedBillingAccountOption(accountId) { return { value: accountId, label: ` (${accountId})` }; } - -export function sortByStartDate(itemA, itemB) { - return itemA.startDate - itemB.startDate; -} From 429b32e76e250e69890d0aac61f5c088f24a7981 Mon Sep 17 00:00:00 2001 From: Maksym Mykhailenko Date: Tue, 6 Jul 2021 09:14:13 +0300 Subject: [PATCH 28/30] fix: plural "is"/"are" ref issue #46 --- .../components/PeriodsSelectionMessage/index.jsx | 8 +++++--- src/utils/formatters.js | 10 ++++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/routes/WorkPeriods/components/PeriodsSelectionMessage/index.jsx b/src/routes/WorkPeriods/components/PeriodsSelectionMessage/index.jsx index 4979d37..3d27a53 100644 --- a/src/routes/WorkPeriods/components/PeriodsSelectionMessage/index.jsx +++ b/src/routes/WorkPeriods/components/PeriodsSelectionMessage/index.jsx @@ -11,7 +11,7 @@ import { } from "store/selectors/workPeriods"; import { toggleWorkingPeriodsAll } from "store/actions/workPeriods"; import styles from "./styles.module.scss"; -import { formatPlural } from "utils/formatters"; +import { formatIsAre, formatPlural } from "utils/formatters"; /** * Displays messages about the number of selected periods and selection controls. @@ -37,11 +37,13 @@ const PeriodsSelectionMessage = ({ className }) => { {isSelectedVisible && totalCount > pageSize && ( {isSelectedAll - ? `All ${formatPlural(totalCount, "record")} are selected. ` + ? `All ${formatPlural(totalCount, "record")} ${formatIsAre( + totalCount + )} selected. ` : `${selectedCount < pageSize ? "" : "All"} ${formatPlural( selectedCount, "record" - )} on this page are selected. `} + )} on this page ${formatIsAre(selectedCount)} selected. `} 1 ? "s" : ""}`; } +/** + * Returns "is" or "are" for singular/plural phrases. + * + * @param {number} count + * @returns {string} "is" or "are" + */ +export function formatIsAre(count) { + return count > 1 ? "are" : "is"; +} + /** * Formats user handle link. * From 714651c46c8915afc5900224e71b628cad5befa6 Mon Sep 17 00:00:00 2001 From: Maksym Mykhailenko Date: Tue, 6 Jul 2021 09:39:35 +0300 Subject: [PATCH 29/30] fix: cancel button for In-Progress ref issue #56 --- src/routes/WorkPeriods/components/PaymentCancel/index.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/WorkPeriods/components/PaymentCancel/index.jsx b/src/routes/WorkPeriods/components/PaymentCancel/index.jsx index 187341f..a5115c1 100644 --- a/src/routes/WorkPeriods/components/PaymentCancel/index.jsx +++ b/src/routes/WorkPeriods/components/PaymentCancel/index.jsx @@ -88,7 +88,7 @@ const PaymentCancel = ({ className, item, timeout = 3000 }) => {
-
-
+
{label} {!disableSort && ( Date: Fri, 2 Jul 2021 14:19:31 +0300 Subject: [PATCH 07/30] Implemented JobName component that loads and caches job name by job id. --- src/components/JobName/index.jsx | 30 +++++++++ src/components/JobName/styles.module.scss | 7 ++ src/components/JobNameProvider/index.jsx | 62 +++++++++++++++++ src/components/ProjectName/index.jsx | 2 +- .../index.jsx | 0 .../components/PeriodDetails/index.jsx | 13 ++-- .../components/PeriodList/index.jsx | 67 ++++++++++--------- src/store/actions/workPeriods.js | 14 ++-- src/store/reducers/workPeriods.js | 25 +++---- src/store/thunks/workPeriods.js | 39 +++++------ 10 files changed, 168 insertions(+), 91 deletions(-) create mode 100644 src/components/JobName/index.jsx create mode 100644 src/components/JobName/styles.module.scss create mode 100644 src/components/JobNameProvider/index.jsx rename src/components/{ProjectNameContextProvider => ProjectNameProvider}/index.jsx (100%) diff --git a/src/components/JobName/index.jsx b/src/components/JobName/index.jsx new file mode 100644 index 0000000..e7236bf --- /dev/null +++ b/src/components/JobName/index.jsx @@ -0,0 +1,30 @@ +import React, { memo, useContext, useEffect } from "react"; +import PT from "prop-types"; +import cn from "classnames"; +import { JobNameContext } from "components/JobNameProvider"; +import { JOB_NAME_LOADING } from "constants/workPeriods"; +import styles from "./styles.module.scss"; + +const JobName = ({ className, jobId }) => { + const [getName, fetchName] = useContext(JobNameContext); + const [jobName, error] = getName(jobId); + + useEffect(() => { + fetchName(jobId); + }, [fetchName, jobId]); + + return ( + + {jobName || JOB_NAME_LOADING} + + ); +}; + +JobName.propTypes = { + className: PT.string, + jobId: PT.oneOfType([PT.number, PT.string]).isRequired, +}; + +export default memo(JobName); diff --git a/src/components/JobName/styles.module.scss b/src/components/JobName/styles.module.scss new file mode 100644 index 0000000..001d597 --- /dev/null +++ b/src/components/JobName/styles.module.scss @@ -0,0 +1,7 @@ +.container { + display: inline; +} + +.error { + color: #e90c5a; +} diff --git a/src/components/JobNameProvider/index.jsx b/src/components/JobNameProvider/index.jsx new file mode 100644 index 0000000..d0074a3 --- /dev/null +++ b/src/components/JobNameProvider/index.jsx @@ -0,0 +1,62 @@ +import React, { createContext, useCallback, useState } from "react"; +import PT from "prop-types"; +import { fetchJob } from "services/workPeriods"; +import { increment } from "utils/misc"; +import { JOB_NAME_ERROR, JOB_NAME_LOADING } from "constants/workPeriods"; + +const names = {}; +const errors = {}; +const promises = {}; + +/** + * Returns a tuple containing job name and possibly an error. + * + * @param {number|string} id job id + * @returns {Array} + */ +const getName = (id) => [names[id], errors[id]]; + +export const JobNameContext = createContext([ + getName, + (id) => { + `${id}`; + }, +]); + +const JobNameProvider = ({ children }) => { + const [, setCount] = useState(Number.MIN_SAFE_INTEGER); + + const fetchName = useCallback((id) => { + if ((id in names || id in promises) && !(id in errors)) { + return; + } + names[id] = JOB_NAME_LOADING; + delete errors[id]; + setCount(increment); + const [promise] = fetchJob(id); + promises[id] = promise + .then((data) => { + names[id] = data.title; + }) + .catch((error) => { + names[id] = JOB_NAME_ERROR; + errors[id] = error; + }) + .finally(() => { + delete promises[id]; + setCount(increment); + }); + }, []); + + return ( + + {children} + + ); +}; + +JobNameProvider.propTypes = { + children: PT.node, +}; + +export default JobNameProvider; diff --git a/src/components/ProjectName/index.jsx b/src/components/ProjectName/index.jsx index 33be6fd..2550af0 100644 --- a/src/components/ProjectName/index.jsx +++ b/src/components/ProjectName/index.jsx @@ -1,7 +1,7 @@ import React, { memo, useContext, useEffect } from "react"; import PT from "prop-types"; import cn from "classnames"; -import { ProjectNameContext } from "components/ProjectNameContextProvider"; +import { ProjectNameContext } from "components/ProjectNameProvider"; import styles from "./styles.module.scss"; const ProjectName = ({ className, projectId }) => { diff --git a/src/components/ProjectNameContextProvider/index.jsx b/src/components/ProjectNameProvider/index.jsx similarity index 100% rename from src/components/ProjectNameContextProvider/index.jsx rename to src/components/ProjectNameProvider/index.jsx diff --git a/src/routes/WorkPeriods/components/PeriodDetails/index.jsx b/src/routes/WorkPeriods/components/PeriodDetails/index.jsx index cc5bc1a..9547510 100644 --- a/src/routes/WorkPeriods/components/PeriodDetails/index.jsx +++ b/src/routes/WorkPeriods/components/PeriodDetails/index.jsx @@ -6,6 +6,7 @@ import debounce from "lodash/debounce"; import Button from "components/Button"; import Toggle from "components/Toggle"; import SelectField from "components/SelectField"; +import JobName from "components/JobName"; import PeriodsHistory from "../PeriodsHistory"; import IconComputer from "../../../../assets/images/icon-computer.svg"; import { @@ -13,9 +14,9 @@ import { setBillingAccount, setDetailsHidePastPeriods, } from "store/actions/workPeriods"; -import styles from "./styles.module.scss"; import { updateWorkPeriodBillingAccount } from "store/thunks/workPeriods"; import { useUpdateEffect } from "utils/hooks"; +import styles from "./styles.module.scss"; /** * Displays working period details. @@ -32,6 +33,7 @@ const PeriodDetails = ({ className, details, isDisabled, isFailed }) => { const { periodId, rbId, + jobId, jobName, jobNameError, billingAccountId, @@ -95,13 +97,7 @@ const PeriodDetails = ({ className, details, isDisabled, isFailed }) => {
Job Name
-
- {jobName} -
+
@@ -161,6 +157,7 @@ PeriodDetails.propTypes = { details: PT.shape({ periodId: PT.string.isRequired, rbId: PT.string.isRequired, + jobId: PT.string.isRequired, jobName: PT.string, jobNameError: PT.string, jobNameIsLoading: PT.bool.isRequired, diff --git a/src/routes/WorkPeriods/components/PeriodList/index.jsx b/src/routes/WorkPeriods/components/PeriodList/index.jsx index 8533de4..18c06a5 100644 --- a/src/routes/WorkPeriods/components/PeriodList/index.jsx +++ b/src/routes/WorkPeriods/components/PeriodList/index.jsx @@ -2,7 +2,8 @@ import React from "react"; import { useSelector } from "react-redux"; import PT from "prop-types"; import cn from "classnames"; -import ProjectNameContextProvider from "components/ProjectNameContextProvider"; +import JobNameProvider from "components/JobNameProvider"; +import ProjectNameProvider from "components/ProjectNameProvider"; import PeriodItem from "../PeriodItem"; import PeriodListHead from "../PeriodListHead"; import { @@ -31,37 +32,39 @@ const PeriodList = ({ className }) => { const isProcessingPayments = useSelector(getWorkPeriodsIsProcessingPayments); return ( - -
- - - - - - - - - {periods.map((period) => ( - - ))} - -
-
-
+ + +
+ + + + + + + + + {periods.map((period) => ( + + ))} + +
+
+
+
); }; diff --git a/src/store/actions/workPeriods.js b/src/store/actions/workPeriods.js index c4ef757..6cccaf5 100644 --- a/src/store/actions/workPeriods.js +++ b/src/store/actions/workPeriods.js @@ -54,20 +54,14 @@ export const hideWorkPeriodDetails = (periodId) => ({ /** * Creates an action denoting the loading of working period's details. * - * @param {string} periodId working period id - * @param {string} rbId resource booking id - * @param {number} billingAccountId billing account id + * @param {Object} period working period object with basic data such as id, + * rbId, jobId, billingAccountId and etc * @param {Object} cancelSource axios cancel token source * @returns {Object} */ -export const loadWorkPeriodDetailsPending = ( - periodId, - rbId, - billingAccountId, - cancelSource -) => ({ +export const loadWorkPeriodDetailsPending = (period, cancelSource) => ({ type: ACTION_TYPE.WP_LOAD_PERIOD_DETAILS_PENDING, - payload: { periodId, rbId, billingAccountId, cancelSource }, + payload: { period, cancelSource }, }); /** diff --git a/src/store/reducers/workPeriods.js b/src/store/reducers/workPeriods.js index 4aaa439..ee1c1f3 100644 --- a/src/store/reducers/workPeriods.js +++ b/src/store/reducers/workPeriods.js @@ -46,21 +46,17 @@ const initPeriodData = (period) => { return data; }; -const initPeriodDetails = ( - periodId, - rbId, - billingAccountId = 0, - cancelSource = cancelSourceDummy -) => ({ - periodId, - rbId, +const initPeriodDetails = (period, cancelSource = cancelSourceDummy) => ({ + periodId: period.id, + rbId: period.rbId, cancelSource, + jobId: period.jobId, jobName: JOB_NAME_LOADING, jobNameError: null, jobNameIsLoading: true, - billingAccountId, + billingAccountId: period.billingAccountId || 0, billingAccounts: [ - { value: billingAccountId, label: BILLING_ACCOUNTS_LOADING }, + { value: period.billingAccountId || 0, label: BILLING_ACCOUNTS_LOADING }, ], billingAccountsError: null, billingAccountsIsDisabled: true, @@ -179,15 +175,10 @@ const actionHandlers = { }, [ACTION_TYPE.WP_LOAD_PERIOD_DETAILS_PENDING]: ( state, - { periodId, rbId, billingAccountId, cancelSource } + { period, cancelSource } ) => { const periodsDetails = { ...state.periodsDetails }; - periodsDetails[periodId] = initPeriodDetails( - periodId, - rbId, - billingAccountId, - cancelSource - ); + periodsDetails[period.id] = initPeriodDetails(period, cancelSource); return { ...state, periodsDetails, diff --git a/src/store/thunks/workPeriods.js b/src/store/thunks/workPeriods.js index 358c716..3cbf9c0 100644 --- a/src/store/thunks/workPeriods.js +++ b/src/store/thunks/workPeriods.js @@ -150,30 +150,23 @@ export const toggleWorkPeriodDetails = // reload details? } else { const source = axios.CancelToken.source(); - dispatch( - actions.loadWorkPeriodDetailsPending( - period.id, - period.rbId, - period.billingAccountId, - source - ) - ); + dispatch(actions.loadWorkPeriodDetailsPending(period, source)); - if (period.jobId) { - const [jobNamePromise] = services.fetchJob(period.jobId, source); - jobNamePromise - .then((data) => { - const jobName = extractJobName(data); - dispatch(actions.loadJobNameSuccess(period.id, jobName)); - }) - .catch((error) => { - if (!axios.isCancel(error)) { - dispatch(actions.loadJobNameError(period.id, error.toString())); - } - }); - } else { - dispatch(actions.loadJobNameSuccess(period.id, JOB_NAME_NONE)); - } + // if (period.jobId) { + // const [jobNamePromise] = services.fetchJob(period.jobId, source); + // jobNamePromise + // .then((data) => { + // const jobName = extractJobName(data); + // dispatch(actions.loadJobNameSuccess(period.id, jobName)); + // }) + // .catch((error) => { + // if (!axios.isCancel(error)) { + // dispatch(actions.loadJobNameError(period.id, error.toString())); + // } + // }); + // } else { + // dispatch(actions.loadJobNameSuccess(period.id, JOB_NAME_NONE)); + // } const [bilAccsPromise] = services.fetchBillingAccounts( period.projectId, From 9b10ac2d7391612296e5202d69adf3fc43651ca7 Mon Sep 17 00:00:00 2001 From: Oleg Petrov Date: Fri, 2 Jul 2021 14:55:37 +0300 Subject: [PATCH 08/30] Removed code related to loading job name into redux store for working period details. --- src/components/JobNameProvider/index.jsx | 10 +++- .../components/PeriodDetails/index.jsx | 2 - src/store/actionTypes/workPeriods.js | 2 - src/store/actions/workPeriods.js | 24 -------- src/store/reducers/workPeriods.js | 55 ++----------------- src/store/thunks/workPeriods.js | 18 ------ 6 files changed, 12 insertions(+), 99 deletions(-) diff --git a/src/components/JobNameProvider/index.jsx b/src/components/JobNameProvider/index.jsx index d0074a3..485c09a 100644 --- a/src/components/JobNameProvider/index.jsx +++ b/src/components/JobNameProvider/index.jsx @@ -2,7 +2,11 @@ import React, { createContext, useCallback, useState } from "react"; import PT from "prop-types"; import { fetchJob } from "services/workPeriods"; import { increment } from "utils/misc"; -import { JOB_NAME_ERROR, JOB_NAME_LOADING } from "constants/workPeriods"; +import { + JOB_NAME_ERROR, + JOB_NAME_LOADING, + JOB_NAME_NONE, +} from "constants/workPeriods"; const names = {}; const errors = {}; @@ -14,7 +18,7 @@ const promises = {}; * @param {number|string} id job id * @returns {Array} */ -const getName = (id) => [names[id], errors[id]]; +const getName = (id) => (id ? [names[id], errors[id]] : [JOB_NAME_NONE, null]); export const JobNameContext = createContext([ getName, @@ -27,7 +31,7 @@ const JobNameProvider = ({ children }) => { const [, setCount] = useState(Number.MIN_SAFE_INTEGER); const fetchName = useCallback((id) => { - if ((id in names || id in promises) && !(id in errors)) { + if (!id || ((id in names || id in promises) && !(id in errors))) { return; } names[id] = JOB_NAME_LOADING; diff --git a/src/routes/WorkPeriods/components/PeriodDetails/index.jsx b/src/routes/WorkPeriods/components/PeriodDetails/index.jsx index 9547510..809cea2 100644 --- a/src/routes/WorkPeriods/components/PeriodDetails/index.jsx +++ b/src/routes/WorkPeriods/components/PeriodDetails/index.jsx @@ -34,8 +34,6 @@ const PeriodDetails = ({ className, details, isDisabled, isFailed }) => { periodId, rbId, jobId, - jobName, - jobNameError, billingAccountId, billingAccounts, billingAccountsError, diff --git a/src/store/actionTypes/workPeriods.js b/src/store/actionTypes/workPeriods.js index 30ddbd2..89d986c 100644 --- a/src/store/actionTypes/workPeriods.js +++ b/src/store/actionTypes/workPeriods.js @@ -6,8 +6,6 @@ export const WP_HIGHLIGHT_FAILED_PERIODS = "WP_HIGHLIGHT_FAILED_PERIODS"; export const WP_LOAD_PERIOD_DETAILS_PENDING = "WP_LOAD_PERIOD_DETAILS_PENDING"; export const WP_LOAD_PERIOD_DETAILS_ERROR = "WP_LOAD_PERIOD_DETAILS_ERROR"; export const WP_LOAD_PERIOD_DETAILS_SUCCESS = "WP_LOAD_PERIOD_DETAILS_SUCCESS"; -export const WP_LOAD_JOB_NAME_ERROR = "WP_LOAD_JOB_NAME_ERROR"; -export const WP_LOAD_JOB_NAME_SUCCESS = "WP_LOAD_JOB_NAME_SUCCESS"; export const WP_LOAD_BILLING_ACCOUNTS_ERROR = "WP_LOAD_BILLING_ACCOUNTS_ERROR"; export const WP_LOAD_BILLING_ACCOUNTS_SUCCESS = "WP_LOAD_BILLING_ACCOUNTS_SUCCESS"; diff --git a/src/store/actions/workPeriods.js b/src/store/actions/workPeriods.js index 6cccaf5..84e54be 100644 --- a/src/store/actions/workPeriods.js +++ b/src/store/actions/workPeriods.js @@ -89,30 +89,6 @@ export const loadWorkPeriodDetailsError = (periodId, message) => ({ payload: { periodId, message, id: nextErrorId++ }, }); -/** - * Creates an action denoting successful loading of resource booking's job name. - * - * @param {string} periodId working period id - * @param {string} jobName working period job name - * @returns {Object} - */ -export const loadJobNameSuccess = (periodId, jobName) => ({ - type: ACTION_TYPE.WP_LOAD_JOB_NAME_SUCCESS, - payload: { periodId, jobName }, -}); - -/** - * Creates an action denoting an error for loading resource booking's job name. - * - * @param {string} periodId working period id - * @param {string} message error message - * @returns {Object} - */ -export const loadJobNameError = (periodId, message) => ({ - type: ACTION_TYPE.WP_LOAD_JOB_NAME_ERROR, - payload: { periodId, message, id: nextErrorId++ }, -}); - /** * Creates an action denoting successful load of billing accounts. * diff --git a/src/store/reducers/workPeriods.js b/src/store/reducers/workPeriods.js index ee1c1f3..e824299 100644 --- a/src/store/reducers/workPeriods.js +++ b/src/store/reducers/workPeriods.js @@ -4,8 +4,6 @@ import { BILLING_ACCOUNTS_NONE, BILLING_ACCOUNTS_LOADING, BILLING_ACCOUNTS_ERROR, - JOB_NAME_ERROR, - JOB_NAME_LOADING, PAYMENT_STATUS, SORT_BY, SORT_BY_DEFAULT, @@ -51,9 +49,6 @@ const initPeriodDetails = (period, cancelSource = cancelSourceDummy) => ({ rbId: period.rbId, cancelSource, jobId: period.jobId, - jobName: JOB_NAME_LOADING, - jobNameError: null, - jobNameIsLoading: true, billingAccountId: period.billingAccountId || 0, billingAccounts: [ { value: period.billingAccountId || 0, label: BILLING_ACCOUNTS_LOADING }, @@ -204,6 +199,9 @@ const actionHandlers = { periods: details.periods, periodsIsLoading: false, }; + if (!periodDetails.billingAccountsIsLoading) { + periodDetails.cancelSource = null; + } if (periodDetails.hidePastPeriods) { periodDetails.periodsVisible = filterPeriodsByStartDate( periodDetails.periods, @@ -232,49 +230,6 @@ const actionHandlers = { periodsDetails, }; }, - [ACTION_TYPE.WP_LOAD_JOB_NAME_SUCCESS]: (state, { periodId, jobName }) => { - const periodsDetails = { ...state.periodsDetails }; - let periodDetails = periodsDetails[periodId]; - if (!periodDetails) { - // Period details may be removed at this point so we must handle this case. - return state; - } - periodDetails = { - ...periodDetails, - jobName, - jobNameError: null, - jobNameIsLoading: false, - }; - if (!periodDetails.billingAccountsIsLoading) { - periodDetails.cancelSource = null; - } - periodsDetails[periodId] = periodDetails; - return { - ...state, - periodsDetails, - }; - }, - [ACTION_TYPE.WP_LOAD_JOB_NAME_ERROR]: (state, { periodId, message }) => { - const periodsDetails = { ...state.periodsDetails }; - let periodDetails = periodsDetails[periodId]; - if (!periodDetails) { - return state; - } - periodDetails = { - ...periodDetails, - jobName: JOB_NAME_ERROR, - jobNameError: message, - jobNameIsLoading: false, - }; - if (!periodDetails.billingAccountsIsLoading) { - periodDetails.cancelSource = null; - } - periodsDetails[periodId] = periodDetails; - return { - ...state, - periodsDetails, - }; - }, [ACTION_TYPE.WP_LOAD_BILLING_ACCOUNTS_SUCCESS]: ( state, { periodId, accounts } @@ -298,7 +253,7 @@ const actionHandlers = { billingAccountsIsDisabled, billingAccountsIsLoading: false, }; - if (!periodDetails.jobNameIsLoading) { + if (!periodDetails.periodsIsLoading) { periodDetails.cancelSource = null; } periodsDetails[periodId] = periodDetails; @@ -332,7 +287,7 @@ const actionHandlers = { billingAccountsIsDisabled, billingAccountsIsLoading: false, }; - if (!periodDetails.jobNameIsLoading) { + if (!periodDetails.periodsIsLoading) { periodDetails.cancelSource = null; } periodsDetails[periodId] = periodDetails; diff --git a/src/store/thunks/workPeriods.js b/src/store/thunks/workPeriods.js index 3cbf9c0..b533ac0 100644 --- a/src/store/thunks/workPeriods.js +++ b/src/store/thunks/workPeriods.js @@ -9,11 +9,9 @@ import { DATE_FORMAT_API, PAYMENT_STATUS_MAP, API_FIELDS_QUERY, - JOB_NAME_NONE, API_CHALLENGE_PAYMENT_STATUS, } from "constants/workPeriods"; import { - extractJobName, extractResponseData, extractResponsePagination, replaceItems, @@ -152,22 +150,6 @@ export const toggleWorkPeriodDetails = const source = axios.CancelToken.source(); dispatch(actions.loadWorkPeriodDetailsPending(period, source)); - // if (period.jobId) { - // const [jobNamePromise] = services.fetchJob(period.jobId, source); - // jobNamePromise - // .then((data) => { - // const jobName = extractJobName(data); - // dispatch(actions.loadJobNameSuccess(period.id, jobName)); - // }) - // .catch((error) => { - // if (!axios.isCancel(error)) { - // dispatch(actions.loadJobNameError(period.id, error.toString())); - // } - // }); - // } else { - // dispatch(actions.loadJobNameSuccess(period.id, JOB_NAME_NONE)); - // } - const [bilAccsPromise] = services.fetchBillingAccounts( period.projectId, source From 99ac1d1fe401ac271e3e75d7501d52150fbc6c9c Mon Sep 17 00:00:00 2001 From: Oleg Petrov Date: Fri, 2 Jul 2021 17:04:45 +0300 Subject: [PATCH 09/30] Implemented Tooltip component and added tooltips for user handle and team name. --- src/components/ProjectName/styles.module.scss | 2 +- src/components/Tooltip/index.jsx | 125 ++++++++++++++++++ src/components/Tooltip/styles.module.scss | 28 ++++ .../components/PeriodItem/index.jsx | 38 +++++- .../components/PeriodItem/styles.module.scss | 25 +++- 5 files changed, 205 insertions(+), 13 deletions(-) create mode 100644 src/components/Tooltip/index.jsx create mode 100644 src/components/Tooltip/styles.module.scss diff --git a/src/components/ProjectName/styles.module.scss b/src/components/ProjectName/styles.module.scss index 5900b9c..b61d947 100644 --- a/src/components/ProjectName/styles.module.scss +++ b/src/components/ProjectName/styles.module.scss @@ -1,7 +1,7 @@ @import "styles/mixins"; .container { - display: block; + display: inline-block; max-width: 20em; overflow: hidden; text-overflow: ellipsis; diff --git a/src/components/Tooltip/index.jsx b/src/components/Tooltip/index.jsx new file mode 100644 index 0000000..f659310 --- /dev/null +++ b/src/components/Tooltip/index.jsx @@ -0,0 +1,125 @@ +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { usePopper } from "react-popper"; +import PT from "prop-types"; +import cn from "classnames"; +import compStyles from "./styles.module.scss"; + +/** + * Displays a tooltip + * + * @param {Object} props component properties + * @param {any} props.children tooltip target + * @param {string} [props.className] class name to be added to root element + * @param {any} props.content tooltip content + * @param {number} [props.delay] postpone showing the tooltip after this delay + * @param {import('@popperjs/core').Placement} [props.placement] tooltip's + * preferred placement as defined in PopperJS documentation + * @param {'absolute'|'fixed'} [props.strategy] tooltip positioning strategy + * as defined in PopperJS documentation + * @param {string} [props.targetClassName] class name to be added to element + * wrapping around component's children + * @param {string} [props.tooltipClassName] class name to be added to tooltip + * element itself + * @returns {JSX.Element} + */ +const Tooltip = ({ + children, + className, + content, + delay = 150, + placement = "top", + strategy = "absolute", + targetClassName, + tooltipClassName, +}) => { + const containerRef = useRef(null); + const timeoutIdRef = useRef(0); + const [isTooltipShown, setIsTooltipShown] = useState(false); + const [referenceElement, setReferenceElement] = useState(null); + const [popperElement, setPopperElement] = useState(null); + const [arrowElement, setArrowElement] = useState(null); + const { styles, attributes, update } = usePopper( + referenceElement, + popperElement, + { + placement, + strategy, + modifiers: [ + { name: "arrow", options: { element: arrowElement, padding: 10 } }, + { name: "offset", options: { offset: [0, 10] } }, + { name: "preventOverflow", options: { padding: 15 } }, + ], + } + ); + + const onMouseEnter = useCallback(() => { + timeoutIdRef.current = window.setTimeout(() => { + timeoutIdRef.current = 0; + setIsTooltipShown(true); + }, delay); + }, [delay]); + + const onMouseLeave = useCallback(() => { + if (timeoutIdRef.current) { + clearTimeout(timeoutIdRef.current); + } + setIsTooltipShown(false); + }, []); + + useEffect(() => { + let observer = null; + if (isTooltipShown && popperElement && update) { + observer = new ResizeObserver(update); + observer.observe(popperElement); + } + return () => { + if (observer) { + observer.unobserve(popperElement); + } + }; + }, [isTooltipShown, popperElement, update]); + + return ( +
+ + {children} + + {isTooltipShown && ( +
+ {content} +
+
+ )} +
+ ); +}; + +Tooltip.propTypes = { + children: PT.node, + className: PT.string, + content: PT.node, + delay: PT.number, + placement: PT.string, + strategy: PT.oneOf(["absolute", "fixed"]), + targetClassName: PT.string, + tooltipClassName: PT.string, +}; + +export default Tooltip; diff --git a/src/components/Tooltip/styles.module.scss b/src/components/Tooltip/styles.module.scss new file mode 100644 index 0000000..27840b5 --- /dev/null +++ b/src/components/Tooltip/styles.module.scss @@ -0,0 +1,28 @@ +.container { + position: relative; + display: inline-flex; + align-items: baseline; +} + +.target { + display: inline-flex; + align-items: baseline; +} + +.tooltip { + z-index: 8; + border-radius: 8px; + padding: 10px 15px; + box-shadow: 0px 5px 25px #c6c6c6; + background: #fff; + + .tooltipArrow { + display: block; + top: 100%; + border: 10px solid transparent; + border-bottom: none; + border-top-color: #fff; + width: 0; + height: 0; + } +} diff --git a/src/routes/WorkPeriods/components/PeriodItem/index.jsx b/src/routes/WorkPeriods/components/PeriodItem/index.jsx index c8c940e..cb28b18 100644 --- a/src/routes/WorkPeriods/components/PeriodItem/index.jsx +++ b/src/routes/WorkPeriods/components/PeriodItem/index.jsx @@ -1,13 +1,16 @@ -import React, { memo, useCallback } from "react"; +import React, { memo, useCallback, useMemo } from "react"; import { useDispatch } from "react-redux"; import PT from "prop-types"; import cn from "classnames"; import debounce from "lodash/debounce"; import Checkbox from "components/Checkbox"; +import JobName from "components/JobName"; import ProjectName from "components/ProjectName"; +import Tooltip from "components/Tooltip"; import PaymentError from "../PaymentError"; import PaymentStatus from "../PaymentStatus"; import PaymentTotal from "../PaymentTotal"; +import PeriodWorkingDays from "../PeriodWorkingDays"; import PeriodDetails from "../PeriodDetails"; import { PAYMENT_STATUS } from "constants/workPeriods"; import { @@ -23,7 +26,6 @@ import { useUpdateEffect } from "utils/hooks"; import { formatUserHandleLink, formatWeeklyRate } from "utils/formatters"; import { stopPropagation } from "utils/misc"; import styles from "./styles.module.scss"; -import PeriodWorkingDays from "../PeriodWorkingDays"; /** * Displays the working period data row to be used in PeriodList component. @@ -85,6 +87,26 @@ const PeriodItem = ({ updateWorkingDays(data.daysWorked); }, [data.daysWorked]); + const jobName = useMemo( + () => ( + + Job Title:  + + + ), + [item.jobId] + ); + + const projectId = useMemo( + () => ( + + Project ID:  + {item.projectId} + + ), + [item.projectId] + ); + return ( <>
- + {item.userHandle} - + - + + + {item.startDate} {item.endDate}
- + + + ); + reasons.push(REASON_DISABLED_MESSAGE_MAP[reasonIds[i]]); + } + return reasons; +} diff --git a/src/routes/WorkPeriods/components/PeriodList/index.jsx b/src/routes/WorkPeriods/components/PeriodList/index.jsx index 18c06a5..09a3f2a 100644 --- a/src/routes/WorkPeriods/components/PeriodList/index.jsx +++ b/src/routes/WorkPeriods/components/PeriodList/index.jsx @@ -10,6 +10,7 @@ import { getWorkPeriods, getWorkPeriodsData, getWorkPeriodsDetails, + getWorkPeriodsDisabled, getWorkPeriodsFailed, getWorkPeriodsIsProcessingPayments, getWorkPeriodsSelected, @@ -27,8 +28,9 @@ const PeriodList = ({ className }) => { const periods = useSelector(getWorkPeriods); const [periodsData] = useSelector(getWorkPeriodsData); const periodsDetails = useSelector(getWorkPeriodsDetails); + const [periodsDisabledMap] = useSelector(getWorkPeriodsDisabled); const periodsFailed = useSelector(getWorkPeriodsFailed); - const periodsSelected = useSelector(getWorkPeriodsSelected); + const [periodsSelectedSet] = useSelector(getWorkPeriodsSelected); const isProcessingPayments = useSelector(getWorkPeriodsIsProcessingPayments); return ( @@ -54,10 +56,11 @@ const PeriodList = ({ className }) => { key={period.id} isDisabled={isProcessingPayments} isFailed={period.id in periodsFailed} - isSelected={period.id in periodsSelected} + isSelected={periodsSelectedSet.has(period.id)} item={period} data={periodsData[period.id]} details={periodsDetails[period.id]} + reasonsDisabled={periodsDisabledMap.get(period.id)} /> ))} diff --git a/src/routes/WorkPeriods/components/PeriodsSelectionMessage/index.jsx b/src/routes/WorkPeriods/components/PeriodsSelectionMessage/index.jsx index f937827..d598279 100644 --- a/src/routes/WorkPeriods/components/PeriodsSelectionMessage/index.jsx +++ b/src/routes/WorkPeriods/components/PeriodsSelectionMessage/index.jsx @@ -6,6 +6,7 @@ import { getWorkPeriodsIsSelectedAll, getWorkPeriodsIsSelectedVisible, getWorkPeriodsPageSize, + getWorkPeriodsSelectedCount, getWorkPeriodsTotalCount, } from "store/selectors/workPeriods"; import { toggleWorkingPeriodsAll } from "store/actions/workPeriods"; @@ -21,6 +22,7 @@ import styles from "./styles.module.scss"; const PeriodsSelectionMessage = ({ className }) => { const isSelectedAll = useSelector(getWorkPeriodsIsSelectedAll); const isSelectedVisible = useSelector(getWorkPeriodsIsSelectedVisible); + const selectedCount = useSelector(getWorkPeriodsSelectedCount); const pageSize = useSelector(getWorkPeriodsPageSize); const totalCount = useSelector(getWorkPeriodsTotalCount); const dispatch = useDispatch(); @@ -35,6 +37,8 @@ const PeriodsSelectionMessage = ({ className }) => { {isSelectedAll ? `All ${totalCount} Records are selected. ` + : selectedCount < pageSize + ? `${selectedCount} Records on this page are selected. ` : `All ${pageSize} Records on this page are selected. `} {} }; @@ -40,7 +46,6 @@ const initPeriodData = (period) => { const data = period.data; data.cancelSource = null; data.daysWorkedIsUpdated = false; - delete period.data; return data; }; @@ -71,10 +76,12 @@ const initialState = updateStateFromQuery(window.location.search, { isSelectedPeriodsVisible: false, pagination: initPagination(), periods: [], + periodsById: {}, periodsData: [{}], periodsDetails: {}, + periodsDisabled: [new Map()], periodsFailed: {}, - periodsSelected: {}, + periodsSelected: [new Set()], sorting: { criteria: SORT_BY_DEFAULT, order: SORT_ORDER_DEFAULT, @@ -96,10 +103,12 @@ const actionHandlers = { isSelectedPeriodsAll: false, isSelectedPeriodsVisible: false, periods: [], + periodsById: {}, periodsData: [{}], periodsDetails: {}, + periodsDisabled: [new Map()], periodsFailed: {}, - periodsSelected: {}, + periodsSelected: [new Set()], }), [ACTION_TYPE.WP_LOAD_PAGE_SUCCESS]: ( state, @@ -111,9 +120,17 @@ const actionHandlers = { oldPagination.pageCount !== pageCount ? { ...oldPagination, totalCount, pageCount } : oldPagination; + const periodsById = {}; const periodsData = {}; + const periodsDisabledMap = new Map(); for (let period of periods) { + periodsById[period.id] = true; periodsData[period.id] = initPeriodData(period); + let reasonsDisabled = findReasonsDisabled(period); + if (reasonsDisabled) { + periodsDisabledMap.set(period.id, reasonsDisabled); + } + delete period.data; } return { ...state, @@ -121,7 +138,9 @@ const actionHandlers = { error: null, pagination, periods, + periodsById, periodsData: [periodsData], + periodsDisabled: [periodsDisabledMap], }; }, [ACTION_TYPE.WP_LOAD_PAGE_ERROR]: (state, error) => { @@ -130,7 +149,6 @@ const actionHandlers = { ...state, cancelSource: null, error: error.message, - periods: [], }; }, [ACTION_TYPE.WP_HIDE_PERIOD_DETAILS]: (state, periodId) => { @@ -146,27 +164,26 @@ const actionHandlers = { if (!periodIds.length) { return state; } - let isSelectedPeriodsAll = state.isSelectedPeriodsAll; - let isSelectedPeriodsVisible = state.isSelectedPeriodsVisible; const periodsFailed = { ...state.periodsFailed }; - const periodsSelected = { ...state.periodsSelected }; + const periodsSelectedSet = state.periodsSelected[0]; + const oldPeriodsSelectedCount = periodsSelectedSet.size; for (let periodId of periodIds) { if (periods[periodId]) { periodsFailed[periodId] = true; - periodsSelected[periodId] = true; + periodsSelectedSet.add(periodId); } else { - isSelectedPeriodsAll = false; - isSelectedPeriodsVisible = false; - delete periodsSelected[periodId]; + periodsSelectedSet.delete(periodId); } } - return { + state = { ...state, - isSelectedPeriodsAll, - isSelectedPeriodsVisible, periodsFailed, - periodsSelected, }; + if (periodsSelectedSet.size !== oldPeriodsSelectedCount) { + state.periodsSelected = [periodsSelectedSet]; + updateSelectedPeriodsFlags(state); + } + return state; }, [ACTION_TYPE.WP_LOAD_PERIOD_DETAILS_PENDING]: ( state, @@ -297,7 +314,7 @@ const actionHandlers = { }; }, [ACTION_TYPE.WP_SET_BILLING_ACCOUNT]: (state, { periodId, accountId }) => { - const periodsDetails = { ...state.periodsDetails }; + let periodsDetails = state.periodsDetails; const periodDetails = periodsDetails[periodId]; if (!periodDetails) { return state; @@ -306,10 +323,27 @@ const actionHandlers = { ...periodDetails, billingAccountId: accountId, }; - return { + periodsDetails = { ...periodsDetails }; + state = { ...state, periodsDetails, }; + const periodsDisabledMap = state.periodsDisabled[0]; + const oldReasonsDisabled = periodsDisabledMap.get(periodId); + const reasonsDisabled = removeReasonDisabled( + oldReasonsDisabled, + REASON_DISABLED.NO_BILLING_ACCOUNT + ); + if (oldReasonsDisabled !== reasonsDisabled) { + if (reasonsDisabled) { + periodsDisabledMap.set(periodId, reasonsDisabled); + } else { + periodsDisabledMap.delete(periodId); + } + state.periodsDisabled = [periodsDisabledMap]; + updateSelectedPeriodsFlags(state); + } + return state; }, [ACTION_TYPE.WP_SET_DETAILS_HIDE_PAST_PERIODS]: ( state, @@ -340,16 +374,22 @@ const actionHandlers = { { periodId, daysWorked } ) => { const periodsData = state.periodsData[0]; - let periodData = periodsData[periodId]; + const periodData = periodsData[periodId]; + if (!periodData) { + return state; + } daysWorked = Math.min(Math.max(daysWorked, periodData.daysPaid), 5); if (daysWorked === periodData.daysWorked) { return state; } periodsData[periodId] = { ...periodData, daysWorked }; - return { + state = { ...state, periodsData: [periodsData], }; + return periodId in state.periodsById + ? updateStateAfterWorkingDaysChange(periodId, state) + : state; }, [ACTION_TYPE.WP_RESET_FILTERS]: (state) => ({ ...state, @@ -378,35 +418,19 @@ const actionHandlers = { }; }, [ACTION_TYPE.WP_SELECT_PERIODS]: (state, periods) => { - let isSelectedPeriodsAll = state.isSelectedPeriodsAll; - let isSelectedPeriodsVisible = state.isSelectedPeriodsVisible; - let periodsSelected = { ...state.periodsSelected }; + const periodsSelectedSet = state.periodsSelected[0]; for (let periodId in periods) { if (periods[periodId] === true) { - periodsSelected[periodId] = true; + periodsSelectedSet.add(periodId); } else { - isSelectedPeriodsAll = false; - isSelectedPeriodsVisible = false; - delete periodsSelected[periodId]; + periodsSelectedSet.delete(periodId); } } - const selectedCount = Object.keys(periodsSelected).length; - const pageSize = state.pagination.pageSize; - const totalCount = state.pagination.totalCount; - if (totalCount > pageSize) { - if (selectedCount === pageSize) { - isSelectedPeriodsVisible = true; - } - } else if (selectedCount === totalCount) { - isSelectedPeriodsAll = true; - isSelectedPeriodsVisible = true; - } - return { + state = { ...state, - isSelectedPeriodsAll, - isSelectedPeriodsVisible, - periodsSelected, + periodsSelected: [periodsSelectedSet], }; + return updateSelectedPeriodsFlags(state); }, [ACTION_TYPE.WP_SET_PAGE_NUMBER]: (state, pageNumber) => ({ ...state, @@ -504,20 +528,23 @@ const actionHandlers = { }, [ACTION_TYPE.WP_SET_PERIOD_DATA_SUCCESS]: (state, { periodId, data }) => { const periodsData = state.periodsData[0]; - const periodData = periodsData[periodId]; + let periodData = periodsData[periodId]; if (!periodData) { return state; } - periodsData[periodId] = { + periodData = periodsData[periodId] = { ...periodData, ...data, cancelSource: null, daysWorkedIsUpdated: true, }; - return { + state = { ...state, periodsData: [periodsData], }; + return periodId in state.periodsById + ? updateStateAfterWorkingDaysChange(periodId, state) + : state; }, [ACTION_TYPE.WP_SET_PERIOD_DATA_ERROR]: (state, { periodId }) => { const periodsData = state.periodsData[0]; @@ -546,10 +573,10 @@ const actionHandlers = { return state; } periodsData[periodId] = { ...periodData, daysWorked }; - return { + return updateStateAfterWorkingDaysChange(periodId, { ...state, periodsData: [periodsData], - }; + }); }, [ACTION_TYPE.WP_TOGGLE_ONLY_FAILED_PAYMENTS]: (state, on) => { const filters = state.filters; @@ -570,65 +597,55 @@ const actionHandlers = { }; }, [ACTION_TYPE.WP_TOGGLE_PERIOD]: (state, periodId) => { - let isSelectedPeriodsAll = state.isSelectedPeriodsAll; - let isSelectedPeriodsVisible = state.isSelectedPeriodsVisible; - const periodsSelected = { ...state.periodsSelected }; - const isSelected = !periodsSelected[periodId]; - if (isSelected) { - periodsSelected[periodId] = true; - const selectedCount = Object.keys(periodsSelected).length; - const pageSize = state.pagination.pageSize; - const totalCount = state.pagination.totalCount; - if (totalCount > pageSize) { - if (selectedCount === pageSize) { - isSelectedPeriodsVisible = true; - } - } else if (selectedCount === totalCount) { - isSelectedPeriodsAll = true; - isSelectedPeriodsVisible = true; - } + const periodsSelectedSet = state.periodsSelected[0]; + if (periodsSelectedSet.has(periodId)) { + periodsSelectedSet.delete(periodId); } else { - isSelectedPeriodsAll = false; - isSelectedPeriodsVisible = false; - delete periodsSelected[periodId]; + periodsSelectedSet.add(periodId); } - return { + return updateSelectedPeriodsFlags({ ...state, - periodsSelected, - isSelectedPeriodsAll, - isSelectedPeriodsVisible, - }; + periodsSelected: [periodsSelectedSet], + }); }, [ACTION_TYPE.WP_TOGGLE_PERIODS_ALL]: (state, on) => { + const periodsSelectedSet = new Set(); const isSelected = on === null ? !state.isSelectedPeriodsAll : on; - const periodsSelected = {}; if (isSelected) { + const periodsDisabledMap = state.periodsDisabled[0]; for (let period of state.periods) { - periodsSelected[period.id] = true; + let periodId = period.id; + if (!periodsDisabledMap.has(periodId)) { + periodsSelectedSet.add(periodId); + } } } return { ...state, - periodsSelected, + periodsSelected: [periodsSelectedSet], isSelectedPeriodsAll: isSelected, isSelectedPeriodsVisible: isSelected, }; }, [ACTION_TYPE.WP_TOGGLE_PERIODS_VISIBLE]: (state, on) => { + const periodsSelectedSet = new Set(); let isSelectedPeriodsAll = false; const isSelectedPeriodsVisible = on === null ? !state.isSelectedPeriodsVisible : on; - const periodsSelected = {}; if (isSelectedPeriodsVisible) { + const periodsDisabledMap = state.periodsDisabled[0]; for (let period of state.periods) { - periodsSelected[period.id] = true; + let periodId = period.id; + if (!periodsDisabledMap.has(periodId)) { + periodsSelectedSet.add(periodId); + } } isSelectedPeriodsAll = state.periods.length === state.pagination.totalCount; } return { ...state, - periodsSelected, + periodsSelected: [periodsSelectedSet], isSelectedPeriodsAll, isSelectedPeriodsVisible, }; @@ -664,6 +681,64 @@ const actionHandlers = { updateStateFromQuery(query, state), }; +function updateStateAfterWorkingDaysChange(periodId, state) { + const periodData = state.periodsData[0][periodId]; + const periodsDisabledMap = state.periodsDisabled[0]; + const oldReasonsDisabled = periodsDisabledMap.get(periodId); + let reasonsDisabled = + periodData.daysWorked === periodData.daysPaid + ? addReasonDisabled( + oldReasonsDisabled, + REASON_DISABLED.NO_DAYS_TO_PAY_FOR + ) + : removeReasonDisabled( + oldReasonsDisabled, + REASON_DISABLED.NO_DAYS_TO_PAY_FOR + ); + if (oldReasonsDisabled !== reasonsDisabled) { + const periodsSelectedSet = state.periodsSelected[0]; + const oldPeriodsSelectedCount = periodsSelectedSet.size; + if (reasonsDisabled) { + periodsDisabledMap.set(periodId, reasonsDisabled); + periodsSelectedSet.delete(periodId); + } else { + periodsDisabledMap.delete(periodId); + } + state.periodsDisabled = [periodsDisabledMap]; + if (periodsSelectedSet.size !== oldPeriodsSelectedCount) { + state.periodsSelected = [periodsSelectedSet]; + } + updateSelectedPeriodsFlags(state); + } + return state; +} + +function updateSelectedPeriodsFlags(state) { + let isSelectedPeriodsAll = state.isSelectedPeriodsAll; + let isSelectedPeriodsVisible = state.isSelectedPeriodsVisible; + const selectedCount = state.periodsSelected[0].size; + const pageSize = state.pagination.pageSize; + const totalCount = state.pagination.totalCount; + const maxSelectedOnPageCount = pageSize - state.periodsDisabled[0].size; + if (totalCount > pageSize) { + if (selectedCount === maxSelectedOnPageCount) { + isSelectedPeriodsVisible = true; + } else { + isSelectedPeriodsAll = false; + isSelectedPeriodsVisible = false; + } + } else if (selectedCount === maxSelectedOnPageCount) { + isSelectedPeriodsAll = true; + isSelectedPeriodsVisible = true; + } else { + isSelectedPeriodsAll = false; + isSelectedPeriodsVisible = false; + } + state.isSelectedPeriodsAll = isSelectedPeriodsAll; + state.isSelectedPeriodsVisible = isSelectedPeriodsVisible; + return state; +} + /** * Updates state from current URL's query. * diff --git a/src/store/selectors/workPeriods.js b/src/store/selectors/workPeriods.js index 9a826e7..23da36d 100644 --- a/src/store/selectors/workPeriods.js +++ b/src/store/selectors/workPeriods.js @@ -23,6 +23,16 @@ export const getWorkPeriods = (state) => state.workPeriods.periods; export const getWorkPeriodsDetails = (state) => state.workPeriods.periodsDetails; +/** + * Returns a Map wrapped in an array containing arrays of reasons the specific + * working period is disabled. + * + * @param {Object} state redux root state + * @returns {[Map]} + */ +export const getWorkPeriodsDisabled = (state) => + state.workPeriods.periodsDisabled; + /** * Returns an object which has working periods' ids for which the payments * were failed to be scheduled as keys. @@ -33,15 +43,17 @@ export const getWorkPeriodsDetails = (state) => export const getWorkPeriodsFailed = (state) => state.workPeriods.periodsFailed; /** - * Returns an object with working periods' ids as keys and booleans showing - * whether the period is selected as values. + * Returns a Set wrapped in array containing the ids of selected working periods. * * @param {Object} state redux root state - * @returns {Object.} + * @returns {[Set]} */ export const getWorkPeriodsSelected = (state) => state.workPeriods.periodsSelected; +export const getWorkPeriodsSelectedCount = (state) => + state.workPeriods.periodsSelected[0].size; + /** * Returns working periods filters' state. * @@ -68,11 +80,6 @@ export const getWorkPeriodsPageNumber = (state) => 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; @@ -80,13 +87,8 @@ export const getWorkPeriodsData = (state) => state.workPeriods.periodsData; export const getWorkPeriodsTotalCount = (state) => state.workPeriods.pagination.totalCount; -export const getWorkPeriodsHasSelectedItems = (state) => { - const periodsSelected = state.workPeriods.periodsSelected; - for (let id in periodsSelected) { - return true; - } - return false; -}; +export const getWorkPeriodsHasSelectedItems = (state) => + !!state.workPeriods.periodsSelected[0].size; export const getWorkPeriodsIsLoading = (state) => !!state.workPeriods.cancelSource; diff --git a/src/store/thunks/workPeriods.js b/src/store/thunks/workPeriods.js index b533ac0..a352be0 100644 --- a/src/store/thunks/workPeriods.js +++ b/src/store/thunks/workPeriods.js @@ -337,13 +337,10 @@ const processPaymentsAll = async (dispatch, getState) => { const processPaymentsSpecific = async (dispatch, getState) => { const state = getState(); - const periods = selectors.getWorkPeriods(state); - const periodsSelected = selectors.getWorkPeriodsSelected(state); + const [periodsSelectedSet] = selectors.getWorkPeriodsSelected(state); const payments = []; - for (let period of periods) { - if (period.id in periodsSelected) { - payments.push({ workPeriodId: period.id }); - } + for (let workPeriodId of periodsSelectedSet) { + payments.push({ workPeriodId }); } makeToastPaymentsProcessing(payments.length); let results = null; diff --git a/src/utils/misc.js b/src/utils/misc.js index 0811244..4737b7a 100644 --- a/src/utils/misc.js +++ b/src/utils/misc.js @@ -146,6 +146,13 @@ export const extractJobName = (data) => data.title; export const extractResponseData = (response) => response.data; +export const hasKey = (obj) => { + for (let key in obj) { + return true; + } + return false; +}; + export const increment = (value) => value + 1; export const negate = (value) => !value; diff --git a/src/utils/workPeriods.js b/src/utils/workPeriods.js index ec97f28..d40dd12 100644 --- a/src/utils/workPeriods.js +++ b/src/utils/workPeriods.js @@ -5,9 +5,55 @@ import { DATE_FORMAT_API, DATE_FORMAT_UI, PAYMENT_STATUS, + REASON_DISABLED, URL_QUERY_PARAM_MAP, } from "constants/workPeriods"; +/** + * Checks for reasons the specified working period should be disabled for + * payment processing. + * + * @param {Object} period working period object + * @returns {?string[]} + */ +export function findReasonsDisabled(period) { + const reasons = []; + if (!period.billingAccountId) { + reasons.push(REASON_DISABLED.NO_BILLING_ACCOUNT); + } + if (!period.weeklyRate) { + reasons.push(REASON_DISABLED.NO_MEMBER_RATE); + } + const data = period.data; + if (data && data.daysWorked === data.daysPaid) { + reasons.push(REASON_DISABLED.NO_DAYS_TO_PAY_FOR); + } + return reasons.length ? reasons : undefined; +} + +export function addReasonDisabled(reasons, reason) { + if (!reasons) { + return [reason]; + } + if (reasons.indexOf(reason) < 0) { + reasons = [...reasons, reason]; + } + return reasons; +} + +export function removeReasonDisabled(reasons, reason) { + if (!reasons) { + return undefined; + } + let index = reasons.indexOf(reason); + if (index >= 0) { + let newReasons = [...reasons]; + newReasons.splice(index, 1); + return newReasons.length ? newReasons : undefined; + } + return reasons; +} + /** * Creates a URL search query from current state. * From bd05dbf87e175810eef827b8a30e4c6eb54dbec0 Mon Sep 17 00:00:00 2001 From: Maksym Mykhailenko Date: Sat, 3 Jul 2021 10:13:54 +0300 Subject: [PATCH 11/30] chore: rename to "Job Name" --- src/routes/WorkPeriods/components/PeriodItem/index.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/WorkPeriods/components/PeriodItem/index.jsx b/src/routes/WorkPeriods/components/PeriodItem/index.jsx index 8550b2d..0f0e83f 100644 --- a/src/routes/WorkPeriods/components/PeriodItem/index.jsx +++ b/src/routes/WorkPeriods/components/PeriodItem/index.jsx @@ -95,7 +95,7 @@ const PeriodItem = ({ const jobName = useMemo( () => ( - Job Title:  + Job Name:  ), From 4701efd23b43fc0535ed88c2eb4165d6dd1841f9 Mon Sep 17 00:00:00 2001 From: Oleg Petrov Date: Sat, 3 Jul 2021 18:32:42 +0300 Subject: [PATCH 12/30] Implemented error messages in popovers for failed working period rows. --- src/components/Checkbox/index.jsx | 5 +- src/components/Checkbox/styles.module.scss | 2 +- .../Icons/ExclamationMarkCircled/index.jsx | 20 +++++ .../ExclamationMarkCircled/styles.module.scss | 22 +++++ src/components/Page/styles.module.scss | 4 +- src/components/Popover/index.jsx | 84 +++++++++++++++++++ src/components/Popover/styles.module.scss | 10 +++ src/components/Popup/index.jsx | 13 ++- .../components/PaymentError/index.jsx | 46 ++++------ .../PaymentError/styles.module.scss | 17 ---- .../components/PaymentTotal/index.jsx | 59 +++++-------- .../components/PaymentsListItem/index.jsx | 4 +- .../components/PeriodDetails/index.jsx | 3 - .../components/PeriodItem/index.jsx | 67 ++++++++------- .../components/PeriodItem/styles.module.scss | 10 +++ .../components/PeriodList/index.jsx | 2 +- .../components/PeriodsHistoryItem/index.jsx | 4 +- .../components/ProcessingError/index.jsx | 34 ++++++++ .../ProcessingError/styles.module.scss | 17 ++++ .../ToastPaymentsWarning/styles.module.scss | 2 +- src/store/reducers/workPeriods.js | 7 +- src/store/thunks/workPeriods.js | 7 +- src/styles/typography.scss | 0 src/styles/variables.scss | 1 + src/styles/variables/_typography.scss | 2 + src/utils/hooks.js | 26 +++--- 26 files changed, 318 insertions(+), 150 deletions(-) create mode 100644 src/components/Icons/ExclamationMarkCircled/index.jsx create mode 100644 src/components/Icons/ExclamationMarkCircled/styles.module.scss create mode 100644 src/components/Popover/index.jsx create mode 100644 src/components/Popover/styles.module.scss create mode 100644 src/routes/WorkPeriods/components/ProcessingError/index.jsx create mode 100644 src/routes/WorkPeriods/components/ProcessingError/styles.module.scss delete mode 100644 src/styles/typography.scss create mode 100644 src/styles/variables/_typography.scss diff --git a/src/components/Checkbox/index.jsx b/src/components/Checkbox/index.jsx index da21e84..10e4eb4 100644 --- a/src/components/Checkbox/index.jsx +++ b/src/components/Checkbox/index.jsx @@ -10,6 +10,7 @@ import styles from "./styles.module.scss"; * @param {Object} props component properties * @param {boolean} props.checked whether checkbox is checked * @param {string} [props.className] class name added to root element + * @param {string} [props.impostorClassName] class name added to checkbox impostor * @param {boolean} [props.isDisabled] if checkbox is disabled * @param {string} props.name name for input element * @param {() => void} props.onChange function called when checkbox changes state @@ -21,6 +22,7 @@ import styles from "./styles.module.scss"; const Checkbox = ({ checked, className, + impostorClassName, isDisabled = false, name, onChange, @@ -47,7 +49,7 @@ const Checkbox = ({ checked={checked} value={option ? option.value : ""} /> - + {option && option.label && ( {option.label} )} @@ -57,6 +59,7 @@ const Checkbox = ({ Checkbox.propTypes = { checked: PT.bool, className: PT.string, + impostorClassName: PT.string, isDisabled: PT.bool, name: PT.string.isRequired, size: PT.oneOf(["medium", "small"]), diff --git a/src/components/Checkbox/styles.module.scss b/src/components/Checkbox/styles.module.scss index c07881a..b114fd2 100644 --- a/src/components/Checkbox/styles.module.scss +++ b/src/components/Checkbox/styles.module.scss @@ -96,7 +96,7 @@ input.checkbox { z-index: 2; position: relative; display: inline-block; - vertical-align: -2px; + vertical-align: -3px; width: 15px; height: 15px; line-height: 13px; diff --git a/src/components/Icons/ExclamationMarkCircled/index.jsx b/src/components/Icons/ExclamationMarkCircled/index.jsx new file mode 100644 index 0000000..a7ac21f --- /dev/null +++ b/src/components/Icons/ExclamationMarkCircled/index.jsx @@ -0,0 +1,20 @@ +import React from "react"; +import PT from "prop-types"; +import cn from "classnames"; +import styles from "./styles.module.scss"; + +/** + * Displays a white exclamation mark inside red circle. + * + * @param {Object} props component properties + * @returns {JSX.Element} + */ +const ExclamationMarkCircled = (props) => ( + +); + +ExclamationMarkCircled.propTypes = { + className: PT.string, +}; + +export default ExclamationMarkCircled; diff --git a/src/components/Icons/ExclamationMarkCircled/styles.module.scss b/src/components/Icons/ExclamationMarkCircled/styles.module.scss new file mode 100644 index 0000000..260762c --- /dev/null +++ b/src/components/Icons/ExclamationMarkCircled/styles.module.scss @@ -0,0 +1,22 @@ +@import "styles/mixins"; +@import "styles/variables"; + +.icon { + display: inline-block; + padding: 2px 0 0; + font-size: 12px; + width: 16px; + height: 16px; + border-radius: 8px; + line-height: 14px; + @include roboto-bold; + text-align: center; + background: $error-color; + color: #fff; + cursor: pointer; + + &::before { + content: "!"; + display: inline; + } +} diff --git a/src/components/Page/styles.module.scss b/src/components/Page/styles.module.scss index 6fecdb9..49417bf 100644 --- a/src/components/Page/styles.module.scss +++ b/src/components/Page/styles.module.scss @@ -7,8 +7,8 @@ display: flex; flex-direction: column; @include roboto-regular; - font-size: 14px; - line-height: (22/14); + font-size: $font-size-px; + line-height: ($line-height-px/$font-size-px); color: $text-color; background-color: $page-bg-color; diff --git a/src/components/Popover/index.jsx b/src/components/Popover/index.jsx new file mode 100644 index 0000000..df5832d --- /dev/null +++ b/src/components/Popover/index.jsx @@ -0,0 +1,84 @@ +import React, { useCallback, useState } from "react"; +import PT from "prop-types"; +import cn from "classnames"; +import Popup from "components/Popup"; +import { negate, stopPropagation } from "utils/misc"; +import styles from "./styles.module.scss"; + +/** + * Displays a popover with provided content when clicked on the provided + * target children; + * + * @param {Object} props component properties + * @param {Object} props.children target children + * @param {string} [props.className] class name to be added to root element + * @param {Object} props.content content to show in popover + * @param {string} [props.popupClassName] class name to be added to popup + * @param {boolean} [props.stopClickPropagation] whether to prevent propagation + * of click events on target content + * @param {'absolute'|'fixed'} [props.strategy] popup positioning strategy + * @param {string} [props.targetClassName] class name to be added to wrapper + * element around target children + * @returns {JSX.Element} + */ +const Popover = ({ + children, + className, + content, + popupClassName, + stopClickPropagation = false, + strategy = "absolute", + targetClassName, +}) => { + const [isShown, setIsShown] = useState(false); + const [refElem, setRefElem] = useState(null); + + const onTargetClick = useCallback(() => { + setIsShown(negate); + }, []); + + const onClickOutside = useCallback(() => { + setIsShown(false); + }, []); + + return ( +
+ + {children} + + {!!content && isShown && ( + + {content} + + )} +
+ ); +}; + +Popover.propTypes = { + children: PT.node, + className: PT.string, + content: PT.node, + popupClassName: PT.string, + stopClickPropagation: PT.bool, + strategy: PT.oneOf(["absolute", "fixed"]), + targetClassName: PT.string, +}; + +export default Popover; diff --git a/src/components/Popover/styles.module.scss b/src/components/Popover/styles.module.scss new file mode 100644 index 0000000..76ae7b2 --- /dev/null +++ b/src/components/Popover/styles.module.scss @@ -0,0 +1,10 @@ +.container { + position: relative; + display: inline-flex; + align-items: baseline; +} + +.target { + display: inline-flex; + align-items: baseline; +} diff --git a/src/components/Popup/index.jsx b/src/components/Popup/index.jsx index b5be2db..73ad4fe 100644 --- a/src/components/Popup/index.jsx +++ b/src/components/Popup/index.jsx @@ -2,6 +2,7 @@ import React, { useState } from "react"; import { usePopper } from "react-popper"; import PT from "prop-types"; import cn from "classnames"; +import { useClickOutside } from "utils/hooks"; import compStyles from "./styles.module.scss"; /** @@ -10,6 +11,10 @@ import compStyles from "./styles.module.scss"; * @param {Object} props component properties * @param {any} [props.children] child nodes * @param {string} [props.className] class name to be added to root element + * @param {() => void} [props.onClickOutside] function called when user clicks + * outside the popup + * @param {import('@popperjs/core').Placement} [props.placement] popup placement + * as defined in PopperJS documentation * @param {Object} props.referenceElement reference element * @param {'absolute'|'fixed'} [props.strategy] positioning strategy * @returns {JSX.Element} @@ -17,13 +22,15 @@ import compStyles from "./styles.module.scss"; const Popup = ({ children, className, + onClickOutside, + placement = "bottom", referenceElement, strategy = "absolute", }) => { const [popperElement, setPopperElement] = useState(null); const [arrowElement, setArrowElement] = useState(null); const { styles, attributes } = usePopper(referenceElement, popperElement, { - placement: "bottom", + placement, strategy, modifiers: [ { name: "arrow", options: { element: arrowElement, padding: 10 } }, @@ -32,6 +39,8 @@ const Popup = ({ ], }); + useClickOutside(popperElement, onClickOutside, []); + return (
{ - const [isShowPopup, setIsShowPopup] = useState(false); - const [refElem, setRefElem] = useState(null); - const containerRef = useRef(null); - - const onIconClick = useCallback((event) => { - event.stopPropagation(); - setIsShowPopup(negate); - }, []); - - const onClickOutside = useCallback(() => { - setIsShowPopup(false); - }, []); - - useClickOutside(containerRef, onClickOutside, []); - + const paymentErrorDetails = useMemo( + () => , + [errorDetails] + ); return ( -
- + - {isShowPopup && errorDetails && ( - - - - )} -
+ ); }; diff --git a/src/routes/WorkPeriods/components/PaymentError/styles.module.scss b/src/routes/WorkPeriods/components/PaymentError/styles.module.scss index ff2dbb1..00a44b9 100644 --- a/src/routes/WorkPeriods/components/PaymentError/styles.module.scss +++ b/src/routes/WorkPeriods/components/PaymentError/styles.module.scss @@ -6,26 +6,9 @@ } .icon { - display: inline-block; - padding: 2px 0 0; - font-size: 12px; - width: 16px; - height: 16px; - border-radius: 8px; - line-height: 14px; - text-align: center; - background: $error-color; - color: #fff; opacity: 0.3; - cursor: pointer; &.isImportant { opacity: 1; } - - &::before { - content: "!"; - display: inline; - font-weight: 700; - } } diff --git a/src/routes/WorkPeriods/components/PaymentTotal/index.jsx b/src/routes/WorkPeriods/components/PaymentTotal/index.jsx index 8b2d1cb..3dc104d 100644 --- a/src/routes/WorkPeriods/components/PaymentTotal/index.jsx +++ b/src/routes/WorkPeriods/components/PaymentTotal/index.jsx @@ -1,12 +1,9 @@ -/* eslint-disable jsx-a11y/no-static-element-interactions */ -import React, { useCallback, useRef, useState } from "react"; +import React, { useMemo } from "react"; import PT from "prop-types"; import cn from "classnames"; -import Popup from "components/Popup"; +import Popover from "components/Popover"; import PaymentsList from "../PaymentsList"; -import { useClickOutside } from "utils/hooks"; import { currencyFormatter } from "utils/formatters"; -import { negate, stopPropagation } from "utils/misc"; import styles from "./styles.module.scss"; /** @@ -27,47 +24,29 @@ const PaymentTotal = ({ daysPaid, popupStrategy = "absolute", }) => { - const [isShowPopup, setIsShowPopup] = useState(false); - const [refElem, setRefElem] = useState(null); - const containerRef = useRef(null); - - const onWeeklyRateClick = useCallback(() => { - setIsShowPopup(negate); - }, []); - - const onClickOutside = useCallback(() => { - setIsShowPopup(false); - }, []); - const hasPayments = !!payments && !!payments.length; - useClickOutside(containerRef, onClickOutside, []); + const paymentsList = useMemo( + () => (hasPayments ? : null), + [hasPayments, payments] + ); return ( -
- - - {currencyFormatter.format(paymentTotal)} - -   - ({daysPaid}) + + {currencyFormatter.format(paymentTotal)} - {hasPayments && isShowPopup && ( - - - - )} -
+   + ({daysPaid}) + ); }; diff --git a/src/routes/WorkPeriods/components/PaymentsListItem/index.jsx b/src/routes/WorkPeriods/components/PaymentsListItem/index.jsx index 939f998..7e968c2 100644 --- a/src/routes/WorkPeriods/components/PaymentsListItem/index.jsx +++ b/src/routes/WorkPeriods/components/PaymentsListItem/index.jsx @@ -6,7 +6,7 @@ import { currencyFormatter, formatChallengeUrl } from "utils/formatters"; import { PAYMENT_STATUS } from "constants/workPeriods"; import PaymentStatus from "../PaymentStatus"; import styles from "./styles.module.scss"; -import PaymentError from "../PaymentError"; +import ProcessingError from "../PaymentError"; const PaymentsListItem = ({ item }) => { const inputRef = useRef(); @@ -51,7 +51,7 @@ const PaymentsListItem = ({ item }) => {
{item.status === PAYMENT_STATUS.FAILED && ( - diff --git a/src/routes/WorkPeriods/components/PeriodDetails/index.jsx b/src/routes/WorkPeriods/components/PeriodDetails/index.jsx index 809cea2..d9f4d6f 100644 --- a/src/routes/WorkPeriods/components/PeriodDetails/index.jsx +++ b/src/routes/WorkPeriods/components/PeriodDetails/index.jsx @@ -156,9 +156,6 @@ PeriodDetails.propTypes = { periodId: PT.string.isRequired, rbId: PT.string.isRequired, jobId: PT.string.isRequired, - jobName: PT.string, - jobNameError: PT.string, - jobNameIsLoading: PT.bool.isRequired, billingAccountId: PT.number.isRequired, billingAccounts: PT.arrayOf( PT.shape({ diff --git a/src/routes/WorkPeriods/components/PeriodItem/index.jsx b/src/routes/WorkPeriods/components/PeriodItem/index.jsx index 0f0e83f..e8da02f 100644 --- a/src/routes/WorkPeriods/components/PeriodItem/index.jsx +++ b/src/routes/WorkPeriods/components/PeriodItem/index.jsx @@ -12,6 +12,7 @@ import PaymentStatus from "../PaymentStatus"; import PaymentTotal from "../PaymentTotal"; import PeriodWorkingDays from "../PeriodWorkingDays"; import PeriodDetails from "../PeriodDetails"; +import ProcessingError from "../ProcessingError"; import { PAYMENT_STATUS, REASON_DISABLED_MESSAGE_MAP, @@ -35,21 +36,21 @@ import styles from "./styles.module.scss"; * * @param {Object} props component properties * @param {boolean} [props.isDisabled] whether the item is disabled - * @param {boolean} [props.isFailed] whether the item should be highlighted as failed * @param {boolean} props.isSelected whether the item is selected * @param {Object} props.item object describing a working period * @param {Object} props.data changeable working period data such as working days * @param {Object} [props.details] object with working period details + * @param {Object} [props.reasonFailed] error object denoting payment processing failure * @param {Array} [props.reasonsDisabled] array of REASON_DISABLED values. * @returns {JSX.Element} */ const PeriodItem = ({ isDisabled = false, - isFailed = false, isSelected, item, data, details, + reasonFailed, reasonsDisabled, }) => { const dispatch = useDispatch(); @@ -114,9 +115,9 @@ const PeriodItem = ({ const reasonsDisabledElement = useMemo( () => ( - +
{formatReasonsDisabled(reasonsDisabled)} - +
), [reasonsDisabled] ); @@ -126,7 +127,7 @@ const PeriodItem = ({
)} ); }; +/** + * Returns a string produced by concatenation of all provided reasons some + * working period is disabled. + * + * @param {Array} reasonIds array of REASON_DISABLED values + * @returns {?Array} + */ +function formatReasonsDisabled(reasonIds) { + if (!reasonIds) { + return null; + } + const reasons = []; + for (let i = 0, len = reasonIds.length; i < len; i++) { + let reasonId = reasonIds[i]; + reasons.push( +
{REASON_DISABLED_MESSAGE_MAP[reasonId]}
+ ); + } + return reasons; +} + PeriodItem.propTypes = { className: PT.string, isDisabled: PT.bool, - isFailed: PT.bool, isSelected: PT.bool.isRequired, item: PT.shape({ id: PT.oneOfType([PT.number, PT.string]).isRequired, - jobId: PT.string.isRequired, + jobId: PT.string, rbId: PT.string.isRequired, projectId: PT.oneOfType([PT.number, PT.string]).isRequired, userHandle: PT.string.isRequired, @@ -243,8 +270,7 @@ PeriodItem.propTypes = { details: PT.shape({ periodId: PT.string.isRequired, rbId: PT.string.isRequired, - jobName: PT.string.isRequired, - jobNameIsLoading: PT.bool.isRequired, + jobId: PT.string.isRequired, billingAccountId: PT.number.isRequired, billingAccounts: PT.arrayOf( PT.shape({ @@ -256,27 +282,8 @@ PeriodItem.propTypes = { periods: PT.array.isRequired, periodsIsLoading: PT.bool.isRequired, }), + reasonFailed: PT.object, reasonsDisabled: PT.arrayOf(PT.string), }; export default memo(PeriodItem); - -/** - * Returns a string produced by concatenation of all provided reasons some - * working period is disabled. - * - * @param {Array} reasonIds array of REASON_DISABLED values - * @returns {?Array} - */ -function formatReasonsDisabled(reasonIds) { - if (!reasonIds) { - return null; - } - const reasons = []; - reasons.push(REASON_DISABLED_MESSAGE_MAP[reasonIds[0]]); - for (let i = 1, len = reasonIds.length; i < len; i++) { - reasons.push(
); - reasons.push(REASON_DISABLED_MESSAGE_MAP[reasonIds[i]]); - } - return reasons; -} diff --git a/src/routes/WorkPeriods/components/PeriodItem/styles.module.scss b/src/routes/WorkPeriods/components/PeriodItem/styles.module.scss index bbb9bd9..4e6247c 100644 --- a/src/routes/WorkPeriods/components/PeriodItem/styles.module.scss +++ b/src/routes/WorkPeriods/components/PeriodItem/styles.module.scss @@ -59,6 +59,16 @@ td.toggle { line-height: 15px; } +.selectionCheckbox { + display: inline-block; +} + +.processingError { + display: inline-block; + margin-left: 10px; + width: 15px; +} + .userHandle { a { @include roboto-bold; diff --git a/src/routes/WorkPeriods/components/PeriodList/index.jsx b/src/routes/WorkPeriods/components/PeriodList/index.jsx index 09a3f2a..1b41f12 100644 --- a/src/routes/WorkPeriods/components/PeriodList/index.jsx +++ b/src/routes/WorkPeriods/components/PeriodList/index.jsx @@ -55,11 +55,11 @@ const PeriodList = ({ className }) => { ))} diff --git a/src/routes/WorkPeriods/components/PeriodsHistoryItem/index.jsx b/src/routes/WorkPeriods/components/PeriodsHistoryItem/index.jsx index c8ae8b7..a1d29e3 100644 --- a/src/routes/WorkPeriods/components/PeriodsHistoryItem/index.jsx +++ b/src/routes/WorkPeriods/components/PeriodsHistoryItem/index.jsx @@ -4,7 +4,7 @@ import PT from "prop-types"; import cn from "classnames"; import debounce from "lodash/debounce"; import moment from "moment"; -import PaymentError from "../PaymentError"; +import ProcessingError from "../PaymentError"; import PaymentStatus from "../PaymentStatus"; import PaymentTotal from "../PaymentTotal"; import PeriodWorkingDays from "../PeriodWorkingDays"; @@ -72,7 +72,7 @@ const PeriodsHistoryItem = ({ isDisabled, item, data, currentStartDate }) => {
{dateLabel} {data.paymentErrorLast && ( - ( + + + +); + +ProcessingError.propTypes = { + className: PT.string, + error: PT.object, + popupStrategy: PT.oneOf(["absolute", "fixed"]), +}; + +export default ProcessingError; diff --git a/src/routes/WorkPeriods/components/ProcessingError/styles.module.scss b/src/routes/WorkPeriods/components/ProcessingError/styles.module.scss new file mode 100644 index 0000000..abad395 --- /dev/null +++ b/src/routes/WorkPeriods/components/ProcessingError/styles.module.scss @@ -0,0 +1,17 @@ +@import "styles/variables"; + +.container { + display: inline-block; + position: relative; +} + +.popup { + max-width: 400px; + line-height: $line-height-px; +} + +.icon { + padding-top: 1px; + width: 15px; + height: 15px; +} diff --git a/src/routes/WorkPeriods/components/ToastPaymentsWarning/styles.module.scss b/src/routes/WorkPeriods/components/ToastPaymentsWarning/styles.module.scss index 5b0aebb..94d0f58 100644 --- a/src/routes/WorkPeriods/components/ToastPaymentsWarning/styles.module.scss +++ b/src/routes/WorkPeriods/components/ToastPaymentsWarning/styles.module.scss @@ -3,5 +3,5 @@ } .sectionSucceeded { - margin-bottom: 5px; + margin-bottom: 0; } diff --git a/src/store/reducers/workPeriods.js b/src/store/reducers/workPeriods.js index b5da291..1ebe0cf 100644 --- a/src/store/reducers/workPeriods.js +++ b/src/store/reducers/workPeriods.js @@ -164,12 +164,13 @@ const actionHandlers = { if (!periodIds.length) { return state; } - const periodsFailed = { ...state.periodsFailed }; + const periodsFailed = {}; const periodsSelectedSet = state.periodsSelected[0]; const oldPeriodsSelectedCount = periodsSelectedSet.size; for (let periodId of periodIds) { - if (periods[periodId]) { - periodsFailed[periodId] = true; + let error = periods[periodId]; + if (error) { + periodsFailed[periodId] = error; periodsSelectedSet.add(periodId); } else { periodsSelectedSet.delete(periodId); diff --git a/src/store/thunks/workPeriods.js b/src/store/thunks/workPeriods.js index a352be0..9884b12 100644 --- a/src/store/thunks/workPeriods.js +++ b/src/store/thunks/workPeriods.js @@ -355,9 +355,9 @@ const processPaymentsSpecific = async (dispatch, getState) => { const resourcesSucceeded = []; const resourcesFailed = []; for (let result of results) { - let isFailed = "error" in result; - periodsToHighlight[result.workPeriodId] = isFailed; - if (isFailed) { + let error = result.error; + periodsToHighlight[result.workPeriodId] = error; + if (error) { resourcesFailed.push(result); } else { resourcesSucceeded.push(result); @@ -369,7 +369,6 @@ const processPaymentsSpecific = async (dispatch, getState) => { if (resourcesFailed.length) { makeToastPaymentsWarning({ resourcesSucceededCount: resourcesSucceeded.length, - resourcesFailed, resourcesFailedCount: resourcesFailed.length, }); } else { diff --git a/src/styles/typography.scss b/src/styles/typography.scss deleted file mode 100644 index e69de29..0000000 diff --git a/src/styles/variables.scss b/src/styles/variables.scss index e3a1501..8ce416a 100644 --- a/src/styles/variables.scss +++ b/src/styles/variables.scss @@ -1,5 +1,6 @@ @import "variables/screenSizes"; @import "variables/layout"; +@import "variables/typography"; @import "variables/colors"; @import "variables/forms"; @import "variables/popup"; diff --git a/src/styles/variables/_typography.scss b/src/styles/variables/_typography.scss new file mode 100644 index 0000000..2edb922 --- /dev/null +++ b/src/styles/variables/_typography.scss @@ -0,0 +1,2 @@ +$font-size-px: 14px; +$line-height-px: 22px; diff --git a/src/utils/hooks.js b/src/utils/hooks.js index ccb7b6b..ba6a1d2 100644 --- a/src/utils/hooks.js +++ b/src/utils/hooks.js @@ -3,26 +3,30 @@ import { useEffect, useRef } from "react"; /** * By "click" it is implied "mousedown" or "touchstart" * - * @param {Object} ref element reference obtained with useRef + * @param {Object} element HTML element * @param {function} listener function with stable identity * that will be executed on click outside * @param {Array} deps dependencies * when click happens outside the element referred by ref */ -export const useClickOutside = (ref, listener, deps) => { +export const useClickOutside = (element, listener, deps) => { useEffect(() => { - const onClick = (event) => { - let elem = ref.current; - if (elem && !elem.contains(event.target)) { - listener(); - } - }; - document.addEventListener("click", onClick); + let onClick = null; + if (element && listener) { + onClick = (event) => { + if (!element.contains(event.target)) { + listener(); + } + }; + document.addEventListener("click", onClick); + } return () => { - document.removeEventListener("click", onClick); + if (onClick) { + document.removeEventListener("click", onClick); + } }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [listener, ...deps]); + }, [element, listener, ...deps]); }; /** From 9a4057cb2ef0276dc684755e821becbc81d8a0e9 Mon Sep 17 00:00:00 2001 From: Oleg Petrov Date: Sat, 3 Jul 2021 19:41:51 +0300 Subject: [PATCH 13/30] Changed endpoint for user handle suggestions. --- src/components/SearchHandleField/index.jsx | 21 ++++++++++++--------- src/services/teams.js | 2 +- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/components/SearchHandleField/index.jsx b/src/components/SearchHandleField/index.jsx index b4e9c5d..cc6c3e2 100644 --- a/src/components/SearchHandleField/index.jsx +++ b/src/components/SearchHandleField/index.jsx @@ -102,6 +102,7 @@ const SearchHandleField = ({ setIsMenuFocused(false); setIsMenuOpen(false); setIsLoading(false); + setInputValue(option.value); onChange(option.value); } } else if (action === "clear") { @@ -109,6 +110,7 @@ const SearchHandleField = ({ setIsMenuFocused(false); setIsMenuOpen(false); setIsLoading(false); + setInputValue(""); onChange(""); } }; @@ -128,7 +130,7 @@ const SearchHandleField = ({ const onKeyDown = (event) => { const key = event.key; if (key === "Enter" || key === "Escape") { - if (!isMenuFocused || isLoading) { + if (!isMenuFocused) { isChangeAppliedRef.current = true; setIsMenuFocused(false); setIsMenuOpen(false); @@ -159,14 +161,15 @@ const SearchHandleField = ({ const loadOptions = useCallback( throttle( async (value) => { + if (isChangeAppliedRef.current) { + return; + } + setIsLoading(true); + const options = await loadSuggestions(value); if (!isChangeAppliedRef.current) { - setIsLoading(true); - const options = await loadSuggestions(value); - if (!isChangeAppliedRef.current) { - setOptions(options); - setIsLoading(false); - setIsMenuOpen(true); - } + setOptions(options); + setIsLoading(false); + setIsMenuOpen(true); } }, 300, @@ -227,7 +230,7 @@ const loadSuggestions = async (inputValue) => { } try { const res = await getMemberSuggestions(inputValue); - const users = res.data.result.content.slice(0, 100); + const users = res.data.slice(0, 100); let match = null; for (let i = 0, len = users.length; i < len; i++) { let value = users[i].handle; diff --git a/src/services/teams.js b/src/services/teams.js index 1ffede2..d9606b6 100644 --- a/src/services/teams.js +++ b/src/services/teams.js @@ -9,5 +9,5 @@ import config from "../../config"; * @returns {Promise} */ export const getMemberSuggestions = (fragment) => { - return axios.get(`${config.API.V3}/members/_suggest/${fragment}`); + return axios.get(`${config.API.V5}/taas-teams/members-suggest/${fragment}`); }; From ead7ac84325aa4d3ee6d628f2ac607c8bca41fac Mon Sep 17 00:00:00 2001 From: Oleg Petrov Date: Sun, 4 Jul 2021 14:39:33 +0300 Subject: [PATCH 14/30] Fixed: billing account was reset in UI after reopening working period's details. --- .../components/PeriodDetails/index.jsx | 24 +++--- .../components/PeriodItem/index.jsx | 6 +- src/store/actions/workPeriods.js | 12 +-- src/store/reducers/workPeriods.js | 73 +++++++++++-------- src/store/thunks/workPeriods.js | 11 +-- src/utils/workPeriods.js | 10 +-- 6 files changed, 71 insertions(+), 65 deletions(-) diff --git a/src/routes/WorkPeriods/components/PeriodDetails/index.jsx b/src/routes/WorkPeriods/components/PeriodDetails/index.jsx index d9f4d6f..1385e3f 100644 --- a/src/routes/WorkPeriods/components/PeriodDetails/index.jsx +++ b/src/routes/WorkPeriods/components/PeriodDetails/index.jsx @@ -26,15 +26,19 @@ import styles from "./styles.module.scss"; * @param {Object} props.details working period details object * @param {boolean} props.isDisabled whether the details are disabled * @param {boolean} props.isFailed whether the payments for the period has failed + * @param {Object} props.period working period basic data object * @returns {JSX.Element} */ -const PeriodDetails = ({ className, details, isDisabled, isFailed }) => { +const PeriodDetails = ({ + className, + details, + isDisabled, + isFailed, + period, +}) => { const dispatch = useDispatch(); + const { id: periodId, rbId, jobId, billingAccountId } = period; const { - periodId, - rbId, - jobId, - billingAccountId, billingAccounts, billingAccountsError, billingAccountsIsDisabled, @@ -153,10 +157,6 @@ const PeriodDetails = ({ className, details, isDisabled, isFailed }) => { PeriodDetails.propTypes = { className: PT.string, details: PT.shape({ - periodId: PT.string.isRequired, - rbId: PT.string.isRequired, - jobId: PT.string.isRequired, - billingAccountId: PT.number.isRequired, billingAccounts: PT.arrayOf( PT.shape({ label: PT.string.isRequired, @@ -172,6 +172,12 @@ PeriodDetails.propTypes = { }).isRequired, isDisabled: PT.bool.isRequired, isFailed: PT.bool.isRequired, + period: PT.shape({ + id: PT.string.isRequired, + rbId: PT.string.isRequired, + jobId: PT.string.isRequired, + billingAccountId: PT.number.isRequired, + }).isRequired, }; export default memo(PeriodDetails); diff --git a/src/routes/WorkPeriods/components/PeriodItem/index.jsx b/src/routes/WorkPeriods/components/PeriodItem/index.jsx index e8da02f..ab94876 100644 --- a/src/routes/WorkPeriods/components/PeriodItem/index.jsx +++ b/src/routes/WorkPeriods/components/PeriodItem/index.jsx @@ -214,6 +214,7 @@ const PeriodItem = ({ {details && ( ({ /** * Creates an action denoting successful load of billing accounts. * - * @param {string} periodId working period id + * @param {Object} period working period basic data object * @param {Array} accounts billing accounts * @returns {Object} */ -export const loadBillingAccountsSuccess = (periodId, accounts) => ({ +export const loadBillingAccountsSuccess = (period, accounts) => ({ type: ACTION_TYPE.WP_LOAD_BILLING_ACCOUNTS_SUCCESS, - payload: { periodId, accounts }, + payload: { period, accounts }, }); /** * Creates an action denoting an error while loading billing accounts. * - * @param {string} periodId working period id + * @param {Object} period working period basic data object * @param {string} message error message * @returns {Object} */ -export const loadBillingAccountsError = (periodId, message) => ({ +export const loadBillingAccountsError = (period, message) => ({ type: ACTION_TYPE.WP_LOAD_BILLING_ACCOUNTS_ERROR, - payload: { periodId, message, id: nextErrorId++ }, + payload: { period, message, id: nextErrorId++ }, }); /** diff --git a/src/store/reducers/workPeriods.js b/src/store/reducers/workPeriods.js index 1ebe0cf..1a0fbcc 100644 --- a/src/store/reducers/workPeriods.js +++ b/src/store/reducers/workPeriods.js @@ -49,14 +49,13 @@ const initPeriodData = (period) => { return data; }; -const initPeriodDetails = (period, cancelSource = cancelSourceDummy) => ({ - periodId: period.id, - rbId: period.rbId, +const initPeriodDetails = ( + billingAccountId = 0, + cancelSource = cancelSourceDummy +) => ({ cancelSource, - jobId: period.jobId, - billingAccountId: period.billingAccountId || 0, billingAccounts: [ - { value: period.billingAccountId || 0, label: BILLING_ACCOUNTS_LOADING }, + { value: billingAccountId, label: BILLING_ACCOUNTS_LOADING }, ], billingAccountsError: null, billingAccountsIsDisabled: true, @@ -191,7 +190,10 @@ const actionHandlers = { { period, cancelSource } ) => { const periodsDetails = { ...state.periodsDetails }; - periodsDetails[period.id] = initPeriodDetails(period, cancelSource); + periodsDetails[period.id] = initPeriodDetails( + period.billingAccountId, + cancelSource + ); return { ...state, periodsDetails, @@ -201,7 +203,7 @@ const actionHandlers = { state, { periodId, details } ) => { - const periodsDetails = { ...state.periodsDetails }; + const periodsDetails = state.periodsDetails; let periodDetails = periodsDetails[periodId]; // period details object must already be initialized if (!periodDetails) { @@ -232,7 +234,7 @@ const actionHandlers = { return { ...state, periodsData: [periodsData], - periodsDetails, + periodsDetails: { ...periodsDetails }, }; }, [ACTION_TYPE.WP_LOAD_PERIOD_DETAILS_ERROR]: ( @@ -250,16 +252,26 @@ const actionHandlers = { }, [ACTION_TYPE.WP_LOAD_BILLING_ACCOUNTS_SUCCESS]: ( state, - { periodId, accounts } + { period, accounts } ) => { - const periodsDetails = { ...state.periodsDetails }; - let periodDetails = periodsDetails[periodId]; + const periodsDetails = state.periodsDetails; + let periodDetails = periodsDetails[period.id]; if (!periodDetails) { // Period details may be removed at this point so we must handle this case. return state; } + let accountId = period.billingAccountId; + let hasAssignedAccount = false; + for (let account of accounts) { + if (account.value === accountId) { + hasAssignedAccount = true; + break; + } + } + if (accountId > 0 && !hasAssignedAccount) { + accounts.unshift(createAssignedBillingAccountOption(accountId)); + } let billingAccountsIsDisabled = false; - let accountId = periodDetails.billingAccountId; if (!accounts.length) { accounts.push({ value: accountId, label: BILLING_ACCOUNTS_NONE }); billingAccountsIsDisabled = true; @@ -274,24 +286,24 @@ const actionHandlers = { if (!periodDetails.periodsIsLoading) { periodDetails.cancelSource = null; } - periodsDetails[periodId] = periodDetails; + periodsDetails[period.id] = periodDetails; return { ...state, - periodsDetails, + periodsDetails: { ...periodsDetails }, }; }, [ACTION_TYPE.WP_LOAD_BILLING_ACCOUNTS_ERROR]: ( state, - { periodId, message } + { period, message } ) => { - const periodsDetails = { ...state.periodsDetails }; - let periodDetails = periodsDetails[periodId]; + const periodsDetails = state.periodsDetails; + let periodDetails = periodsDetails[period.id]; if (!periodDetails) { return state; } let billingAccounts = []; let billingAccountsIsDisabled = true; - let accountId = periodDetails.billingAccountId; + let accountId = period.billingAccountId; if (accountId) { billingAccounts.push(createAssignedBillingAccountOption(accountId)); billingAccountsIsDisabled = false; @@ -308,27 +320,26 @@ const actionHandlers = { if (!periodDetails.periodsIsLoading) { periodDetails.cancelSource = null; } - periodsDetails[periodId] = periodDetails; + periodsDetails[period.id] = periodDetails; return { ...state, - periodsDetails, + periodsDetails: { ...periodsDetails }, }; }, [ACTION_TYPE.WP_SET_BILLING_ACCOUNT]: (state, { periodId, accountId }) => { - let periodsDetails = state.periodsDetails; - const periodDetails = periodsDetails[periodId]; - if (!periodDetails) { - return state; + const periods = state.periods; + for (let i = 0, len = periods.length; i < len; i++) { + let period = periods[i]; + if (period.id === periodId) { + periods[i] = { ...period, billingAccountId: accountId }; + break; + } } - periodsDetails[periodId] = { - ...periodDetails, - billingAccountId: accountId, - }; - periodsDetails = { ...periodsDetails }; state = { ...state, - periodsDetails, + periods: [...periods], }; + // updating reasons for which the period's selection may be disabled const periodsDisabledMap = state.periodsDisabled[0]; const oldReasonsDisabled = periodsDisabledMap.get(periodId); const reasonsDisabled = removeReasonDisabled( diff --git a/src/store/thunks/workPeriods.js b/src/store/thunks/workPeriods.js index 9884b12..0c4469a 100644 --- a/src/store/thunks/workPeriods.js +++ b/src/store/thunks/workPeriods.js @@ -156,18 +156,13 @@ export const toggleWorkPeriodDetails = ); bilAccsPromise .then((data) => { - const periodsDetails = selectors.getWorkPeriodsDetails(getState()); - const periodDetails = periodsDetails[period.id]; - const billingAccountId = - (periodDetails && periodDetails.billingAccountId) || - period.billingAccountId; - const accounts = normalizeBillingAccounts(data, billingAccountId); - dispatch(actions.loadBillingAccountsSuccess(period.id, accounts)); + const accounts = normalizeBillingAccounts(data); + dispatch(actions.loadBillingAccountsSuccess(period, accounts)); }) .catch((error) => { if (!axios.isCancel(error)) { dispatch( - actions.loadBillingAccountsError(period.id, error.toString()) + actions.loadBillingAccountsError(period, error.toString()) ); } }); diff --git a/src/utils/workPeriods.js b/src/utils/workPeriods.js index d40dd12..e769736 100644 --- a/src/utils/workPeriods.js +++ b/src/utils/workPeriods.js @@ -31,6 +31,8 @@ export function findReasonsDisabled(period) { return reasons.length ? reasons : undefined; } +export function createAlerts(period, bookingEndDate) {} + export function addReasonDisabled(reasons, reason) { if (!reasons) { return [reason]; @@ -171,15 +173,12 @@ export function normalizePaymentStatus(paymentStatus) { * billing account. * * @param {Array} accounts array of billing accounts received for specific project - * @param {number} accountId resource booking's billing account id * @returns {Array} */ -export function normalizeBillingAccounts(accounts, accountId = -1) { +export function normalizeBillingAccounts(accounts) { const accs = []; - let hasSelectedAccount = false; for (let acc of accounts) { const value = +acc.tcBillingAccountId; - hasSelectedAccount = hasSelectedAccount || value === accountId; const endDate = acc.endDate ? moment(acc.endDate).format("DD MMM YYYY") : ""; @@ -188,9 +187,6 @@ export function normalizeBillingAccounts(accounts, accountId = -1) { label: `${acc.name} (${value})` + (endDate ? ` - ${endDate}` : ""), }); } - if (!hasSelectedAccount && accountId > 0) { - accs.unshift(createAssignedBillingAccountOption(accountId)); - } return accs; } From 02c6a824cd7e4d16c3ce09bea5a980248795d80d Mon Sep 17 00:00:00 2001 From: Oleg Petrov Date: Sun, 4 Jul 2021 17:05:04 +0300 Subject: [PATCH 15/30] Added working period alert column. --- src/constants/workPeriods.js | 7 +++ src/constants/workPeriods/alerts.js | 2 + .../components/PeriodAlerts/index.jsx | 48 +++++++++++++++++ .../PeriodAlerts/styles.module.scss | 40 +++++++++++++++ .../components/PeriodDetails/index.jsx | 4 +- .../components/PeriodItem/index.jsx | 7 +++ .../components/PeriodList/index.jsx | 5 +- .../components/PeriodListHead/index.jsx | 1 + src/store/reducers/workPeriods.js | 33 ++++++++++-- src/store/selectors/workPeriods.js | 8 +++ src/utils/workPeriods.js | 51 +++++++++++++------ 11 files changed, 183 insertions(+), 23 deletions(-) create mode 100644 src/constants/workPeriods/alerts.js create mode 100644 src/routes/WorkPeriods/components/PeriodAlerts/index.jsx create mode 100644 src/routes/WorkPeriods/components/PeriodAlerts/styles.module.scss diff --git a/src/constants/workPeriods.js b/src/constants/workPeriods.js index 0a63e83..2da71de 100644 --- a/src/constants/workPeriods.js +++ b/src/constants/workPeriods.js @@ -1,5 +1,6 @@ // @ts-ignore import { API } from "../../config"; +import * as ALERT from "./workPeriods/alerts"; import * as API_CHALLENGE_PAYMENT_STATUS from "./workPeriods/apiChallengePaymentStatus"; import * as API_PAYMENT_STATUS from "./workPeriods/apiPaymentStatus"; import * as API_SORT_BY from "./workPeriods/apiSortBy"; @@ -9,6 +10,7 @@ import * as PAYMENT_STATUS from "./workPeriods/paymentStatus"; import * as REASON_DISABLED from "./workPeriods/reasonDisabled"; export { + ALERT, API_CHALLENGE_PAYMENT_STATUS, API_PAYMENT_STATUS, API_SORT_BY, @@ -144,3 +146,8 @@ export const REASON_DISABLED_MESSAGE_MAP = { [REASON_DISABLED.NO_DAYS_TO_PAY_FOR]: "There are no days to pay for", [REASON_DISABLED.NO_MEMBER_RATE]: "Member Rate should be greater than 0", }; + +export const ALERT_MESSAGE_MAP = { + [ALERT.BA_NOT_ASSIGNED]: "BA - Not Assigned", + [ALERT.LAST_BOOKING_WEEK]: "Last Booking Week", +}; diff --git a/src/constants/workPeriods/alerts.js b/src/constants/workPeriods/alerts.js new file mode 100644 index 0000000..cda3a22 --- /dev/null +++ b/src/constants/workPeriods/alerts.js @@ -0,0 +1,2 @@ +export const BA_NOT_ASSIGNED = "BA_NOT_ASSIGNED"; +export const LAST_BOOKING_WEEK = "LAST_BOOKING_WEEK"; diff --git a/src/routes/WorkPeriods/components/PeriodAlerts/index.jsx b/src/routes/WorkPeriods/components/PeriodAlerts/index.jsx new file mode 100644 index 0000000..133dacb --- /dev/null +++ b/src/routes/WorkPeriods/components/PeriodAlerts/index.jsx @@ -0,0 +1,48 @@ +import React, { useMemo } from "react"; +import PT from "prop-types"; +import cn from "classnames"; +import Tooltip from "components/Tooltip"; +import { ALERT_MESSAGE_MAP } from "constants/workPeriods"; +import styles from "./styles.module.scss"; + +/** + * Displays alerts for working period. + * + * @param {Object} props component properties + * @param {string[]} [props.alerts] array of alert ids + * @param {string} [props.className] class name to be added to alerts wrapper + * @returns {JSX.Element} + */ +const PeriodAlerts = ({ alerts, className }) => { + const alertsTooltipContent = useMemo(() => { + return alerts + ? alerts.map((alertId) => ( +
{ALERT_MESSAGE_MAP[alertId]}
+ )) + : null; + }, [alerts]); + + return ( + + {alerts + ? alerts.map((alertId) => ALERT_MESSAGE_MAP[alertId]).join(", ") + : "None"} + + ); +}; + +PeriodAlerts.propTypes = { + alerts: PT.arrayOf(PT.string), + className: PT.string, +}; + +export default PeriodAlerts; diff --git a/src/routes/WorkPeriods/components/PeriodAlerts/styles.module.scss b/src/routes/WorkPeriods/components/PeriodAlerts/styles.module.scss new file mode 100644 index 0000000..ed78d8f --- /dev/null +++ b/src/routes/WorkPeriods/components/PeriodAlerts/styles.module.scss @@ -0,0 +1,40 @@ +@import "styles/mixins"; +@import "styles/variables"; + +.container { + display: inline-block; + + &.hasAlerts { + border-radius: 5px; + padding: 3px 5px 1px; + height: 20px; + max-width: 15em; + line-height: 16px; + font-size: 11px; + @include roboto-medium; + text-align: left; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + background-color: #ffc43d; + + &::before { + content: "!"; + display: inline-block; + margin-right: 4px; + border: 2px solid $text-color; + border-radius: 7px; + padding: 1px 0 0; + width: 13px; + height: 13px; + line-height: 8px; + font-size: 10px; + @include roboto-bold; + text-align: center; + } + } +} + +.tooltip { + white-space: nowrap; +} diff --git a/src/routes/WorkPeriods/components/PeriodDetails/index.jsx b/src/routes/WorkPeriods/components/PeriodDetails/index.jsx index 1385e3f..8f28cd6 100644 --- a/src/routes/WorkPeriods/components/PeriodDetails/index.jsx +++ b/src/routes/WorkPeriods/components/PeriodDetails/index.jsx @@ -89,7 +89,7 @@ const PeriodDetails = ({ )} > {periodsIsLoading ? ( -
+
Loading...
+
History diff --git a/src/routes/WorkPeriods/components/PeriodItem/index.jsx b/src/routes/WorkPeriods/components/PeriodItem/index.jsx index ab94876..940e934 100644 --- a/src/routes/WorkPeriods/components/PeriodItem/index.jsx +++ b/src/routes/WorkPeriods/components/PeriodItem/index.jsx @@ -30,6 +30,7 @@ import { useUpdateEffect } from "utils/hooks"; import { formatUserHandleLink, formatWeeklyRate } from "utils/formatters"; import { stopPropagation } from "utils/misc"; import styles from "./styles.module.scss"; +import PeriodAlerts from "../PeriodAlerts"; /** * Displays the working period data row to be used in PeriodList component. @@ -38,6 +39,7 @@ import styles from "./styles.module.scss"; * @param {boolean} [props.isDisabled] whether the item is disabled * @param {boolean} props.isSelected whether the item is selected * @param {Object} props.item object describing a working period + * @param {Array} [props.alerts] array with alert ids * @param {Object} props.data changeable working period data such as working days * @param {Object} [props.details] object with working period details * @param {Object} [props.reasonFailed] error object denoting payment processing failure @@ -48,6 +50,7 @@ const PeriodItem = ({ isDisabled = false, isSelected, item, + alerts, data, details, reasonFailed, @@ -177,6 +180,9 @@ const PeriodItem = ({
{item.startDate} {item.endDate} + + {formatWeeklyRate(item.weeklyRate)}
Weekly Rate Days AmountStatusStatus
+ +
{item.startDate}{item.endDate}{formatDate(item.bookingStart)}{formatDate(item.bookingEnd)}
- {formatDateRange(item.startDate, item.endDate)} + {formatDateRange(item.start, item.end)} {dateLabel} @@ -90,15 +100,19 @@ const PeriodsHistoryItem = ({ isDisabled, item, data, currentStartDate }) => { {data.paymentStatus === PAYMENT_STATUS.COMPLETED ? ( - `${daysWorked} ${daysWorked === 1 ? "Day" : "Days"}` + + {formatPlural(daysWorked, "Day")} + ) : ( )}