- {currencyFormatter.format(paymentTotal)}
+
+
+
+ {currencyFormatter.format(paymentTotal)}
+
+ ({daysPaid})
- ({daysPaid})
{hasPayments && isShowPopup && (
{
const onPageNumberClick = useCallback(
(pageNumber) => {
dispatch(setWorkPeriodsPageNumber(+pageNumber));
+ dispatch(updateQueryFromState());
},
[dispatch]
);
@@ -32,6 +34,7 @@ const PeriodsPagination = ({ className, id }) => {
const onPageSizeChange = useCallback(
(pageSize) => {
dispatch(setWorkPeriodsPageSize(+pageSize));
+ dispatch(updateQueryFromState());
},
[dispatch]
);
diff --git a/src/services/workPeriods.js b/src/services/workPeriods.js
index c906236..88e2385 100644
--- a/src/services/workPeriods.js
+++ b/src/services/workPeriods.js
@@ -4,7 +4,7 @@ import {
JOBS_API_URL,
PAYMENTS_API_URL,
PROJECTS_API_URL,
- QUERY_PARAM_NAMES,
+ API_QUERY_PARAM_NAMES,
WORK_PERIODS_API_URL,
} from "constants/workPeriods";
import { buildRequestQuery, extractResponseData } from "utils/misc";
@@ -98,9 +98,12 @@ export const fetchWorkPeriods = (rbId, source) => {
export const fetchResourceBookings = (params) => {
const source = CancelToken.source();
return [
- axios.get(`${RB_API_URL}?${buildRequestQuery(params, QUERY_PARAM_NAMES)}`, {
- cancelToken: source.token,
- }),
+ axios.get(
+ `${RB_API_URL}?${buildRequestQuery(params, API_QUERY_PARAM_NAMES)}`,
+ {
+ cancelToken: source.token,
+ }
+ ),
source,
];
};
diff --git a/src/store/actionTypes/workPeriods.js b/src/store/actionTypes/workPeriods.js
index 85eb490..273a3a5 100644
--- a/src/store/actionTypes/workPeriods.js
+++ b/src/store/actionTypes/workPeriods.js
@@ -33,3 +33,4 @@ export const WP_TOGGLE_PERIOD = "WP_TOGGLE_PERIOD";
export const WP_TOGGLE_PERIODS_ALL = "WP_TOGGLE_PERIODS_ALL";
export const WP_TOGGLE_PERIODS_VISIBLE = "WP_TOGGLE_PERIODS_VISIBLE";
export const WP_TOGGLE_PROCESSING_PAYMENTS = "WP_TOGGLE_PROCESSING_PAYMENTS";
+export const WP_UPDATE_STATE_FROM_QUERY = "WP_UPDATE_STATE_FROM_QUERY";
diff --git a/src/store/actions/workPeriods.js b/src/store/actions/workPeriods.js
index 592cd1c..b50c3b5 100644
--- a/src/store/actions/workPeriods.js
+++ b/src/store/actions/workPeriods.js
@@ -7,12 +7,11 @@ let nextErrorId = 1;
* Creates an action denoting the start of loading specific challenge page.
*
* @param {Object} cancelSource object that can be used to cancel network request
- * @param {number} pageNumber the requested challenge page number
* @returns {Object}
*/
-export const loadWorkPeriodsPagePending = (cancelSource, pageNumber) => ({
+export const loadWorkPeriodsPagePending = (cancelSource) => ({
type: ACTION_TYPE.WP_LOAD_PAGE_PENDING,
- payload: { cancelSource, pageNumber },
+ payload: cancelSource,
});
/**
@@ -383,3 +382,15 @@ export const toggleWorkPeriodsProcessingPeyments = (on = null) => ({
type: ACTION_TYPE.WP_TOGGLE_PROCESSING_PAYMENTS,
payload: on,
});
+
+/**
+ * Creates an action denoting an update of working periods state slice using
+ * the provided query.
+ *
+ * @param {string} query URL search query
+ * @returns {Object}
+ */
+export const updateStateFromQuery = (query) => ({
+ type: ACTION_TYPE.WP_UPDATE_STATE_FROM_QUERY,
+ payload: query,
+});
diff --git a/src/store/reducers/workPeriods.js b/src/store/reducers/workPeriods.js
index 7465c3a..f8b79bc 100644
--- a/src/store/reducers/workPeriods.js
+++ b/src/store/reducers/workPeriods.js
@@ -1,13 +1,17 @@
import moment from "moment";
import * as ACTION_TYPE from "store/actionTypes/workPeriods";
import {
- SORT_BY_DEFAULT,
- SORT_ORDER_DEFAULT,
- JOB_NAME_ERROR,
BILLING_ACCOUNTS_NONE,
- JOB_NAME_LOADING,
BILLING_ACCOUNTS_LOADING,
BILLING_ACCOUNTS_ERROR,
+ JOB_NAME_ERROR,
+ JOB_NAME_LOADING,
+ PAYMENT_STATUS,
+ SORT_BY,
+ SORT_BY_DEFAULT,
+ SORT_ORDER,
+ SORT_ORDER_DEFAULT,
+ URL_QUERY_PARAM_MAP,
} from "constants/workPeriods";
import {
filterPeriodsByStartDate,
@@ -18,6 +22,8 @@ import { createAssignedBillingAccountOption } from "utils/workPeriods";
const cancelSourceDummy = { cancel: () => {} };
+const PAGE_SIZES = [10, 20, 50, 100];
+
const initPagination = () => ({
totalCount: 0,
pageCount: 0,
@@ -50,30 +56,30 @@ const initPeriodDetails = (
billingAccountsError: null,
billingAccountsIsDisabled: true,
billingAccountsIsLoading: true,
+ hidePastPeriods: false,
periods: [],
periodsVisible: [],
periodsIsLoading: true,
- hidePastPeriods: false,
});
-const initialState = {
- error: null,
+const initialState = updateStateFromQuery(window.location.search, {
cancelSource: cancelSourceDummy,
+ error: null,
+ filters: initFilters(),
+ isProcessingPayments: false,
+ isSelectedPeriodsAll: false,
+ isSelectedPeriodsVisible: false,
+ pagination: initPagination(),
periods: [],
periodsData: [{}],
periodsDetails: {},
periodsFailed: {},
periodsSelected: {},
- isSelectedPeriodsAll: false,
- isSelectedPeriodsVisible: false,
- isProcessingPayments: false,
- pagination: initPagination(),
sorting: {
criteria: SORT_BY_DEFAULT,
order: SORT_ORDER_DEFAULT,
},
- filters: initFilters(),
-};
+});
const reducer = (state = initialState, action) => {
if (action.type in actionHandlers) {
@@ -83,24 +89,17 @@ const reducer = (state = initialState, action) => {
};
const actionHandlers = {
- [ACTION_TYPE.WP_LOAD_PAGE_PENDING]: (
- state,
- { cancelSource, pageNumber }
- ) => ({
+ [ACTION_TYPE.WP_LOAD_PAGE_PENDING]: (state, cancelSource) => ({
...state,
cancelSource,
error: null,
+ isSelectedPeriodsAll: false,
+ isSelectedPeriodsVisible: false,
periods: [],
periodsData: [{}],
periodsDetails: {},
periodsFailed: {},
periodsSelected: {},
- isSelectedPeriodsAll: false,
- isSelectedPeriodsVisible: false,
- pagination:
- pageNumber === state.pagination.pageNumber
- ? state.pagination
- : { ...state.pagination, pageNumber },
}),
[ACTION_TYPE.WP_LOAD_PAGE_SUCCESS]: (
state,
@@ -122,9 +121,9 @@ const actionHandlers = {
...state,
cancelSource: null,
error: null,
+ pagination,
periods,
periodsData: [periodsData],
- pagination,
};
},
[ACTION_TYPE.WP_LOAD_PAGE_ERROR]: (state, error) => {
@@ -404,6 +403,10 @@ const actionHandlers = {
[ACTION_TYPE.WP_RESET_FILTERS]: (state) => ({
...state,
filters: initFilters(),
+ pagination: {
+ ...state.pagination,
+ pageNumber: 1,
+ },
}),
[ACTION_TYPE.WP_SET_DATE_RANGE]: (state, date) => {
const oldRange = state.filters.dateRange;
@@ -417,6 +420,10 @@ const actionHandlers = {
...state.filters,
dateRange: range,
},
+ pagination: {
+ ...state.pagination,
+ pageNumber: 1,
+ },
};
},
[ACTION_TYPE.WP_SELECT_PERIODS]: (state, periods) => {
@@ -462,10 +469,14 @@ const actionHandlers = {
pagination:
pageSize === state.pagination.pageSize
? state.pagination
- : { ...state.pagination, pageSize },
+ : { ...state.pagination, pageSize, pageNumber: 1 },
}),
[ACTION_TYPE.WP_SET_SORT_BY]: (state, criteria) => ({
...state,
+ pagination: {
+ ...state.pagination,
+ pageNumber: 1,
+ },
sorting: {
...state.sorting,
criteria,
@@ -481,6 +492,10 @@ const actionHandlers = {
}
return {
...state,
+ pagination: {
+ ...state.pagination,
+ pageNumber: 1,
+ },
sorting: {
criteria: sortBy,
order: sortOrder,
@@ -496,6 +511,10 @@ const actionHandlers = {
paymentStatuses
),
},
+ pagination: {
+ ...state.pagination,
+ pageNumber: 1,
+ },
}),
[ACTION_TYPE.WP_SET_USER_HANDLE]: (state, userHandle) => {
if (userHandle === state.filters.userHandle) {
@@ -507,6 +526,10 @@ const actionHandlers = {
...state.filters,
userHandle,
},
+ pagination: {
+ ...state.pagination,
+ pageNumber: 1,
+ },
};
},
[ACTION_TYPE.WP_SET_DATA_PENDING]: (state, { periodId, cancelSource }) => {
@@ -647,6 +670,104 @@ const actionHandlers = {
isProcessingPayments,
};
},
+ [ACTION_TYPE.WP_UPDATE_STATE_FROM_QUERY]: (state, query) =>
+ updateStateFromQuery(query, state),
};
+/**
+ * Updates state from current URL's query.
+ *
+ * @param {string} queryStr query string
+ * @param {Object} state working periods' state slice
+ * @returns {Object} initial state
+ */
+function updateStateFromQuery(queryStr, state) {
+ const params = {};
+ const query = new URLSearchParams(queryStr);
+ for (let [stateKey, queryKey] of URL_QUERY_PARAM_MAP) {
+ let value = query.get(queryKey);
+ if (value) {
+ params[stateKey] = value;
+ }
+ }
+ let updateFilters = false;
+ let updatePagination = false;
+ let updateSorting = false;
+ const { filters, pagination, sorting } = state;
+ // checking payment statuses
+ const { dateRange } = filters;
+ let range = getWeekByDate(moment(params.startDate));
+ if (!range[0].isSame(dateRange[0])) {
+ filters.dateRange = range;
+ updateFilters = true;
+ }
+ let hasSameStatuses = true;
+ const filtersPaymentStatuses = filters.paymentStatuses;
+ const queryPaymentStatuses = {};
+ const paymentStatusesStr = params.paymentStatuses;
+ if (paymentStatusesStr) {
+ for (let status of paymentStatusesStr.split(",")) {
+ status = status.toUpperCase();
+ if (status in PAYMENT_STATUS) {
+ queryPaymentStatuses[status] = true;
+ if (!filtersPaymentStatuses[status]) {
+ hasSameStatuses = false;
+ }
+ }
+ }
+ }
+ for (let status in filtersPaymentStatuses) {
+ if (!queryPaymentStatuses[status]) {
+ hasSameStatuses = false;
+ break;
+ }
+ }
+ if (!hasSameStatuses) {
+ filters.paymentStatuses = queryPaymentStatuses;
+ updateFilters = true;
+ }
+ // checking user handle
+ params.userHandle = params.userHandle || "";
+ if (params.userHandle !== filters.userHandle) {
+ filters.userHandle = params.userHandle.slice(0, 256);
+ updateFilters = true;
+ }
+ // checking sorting criteria
+ const criteria = params.criteria?.toUpperCase();
+ if (criteria in SORT_BY && criteria !== sorting.criteria) {
+ sorting.criteria = criteria;
+ updateSorting = true;
+ }
+ // checking sorting order
+ if (params.order in SORT_ORDER && params.order !== sorting.order) {
+ sorting.order = params.order;
+ updateSorting = true;
+ }
+ // checking page number
+ const pageNumber = +params.pageNumber;
+ if (pageNumber && pageNumber !== pagination.pageNumber) {
+ pagination.pageNumber = pageNumber;
+ updatePagination = true;
+ }
+ // checking page size
+ const pageSize = +params.pageSize;
+ if (PAGE_SIZES.includes(pageSize) && pageSize !== pagination.pageSize) {
+ pagination.pageSize = pageSize;
+ updatePagination = true;
+ }
+ if (updateFilters || updatePagination || updateSorting) {
+ state = { ...state };
+ if (updateFilters) {
+ state.filters = { ...filters };
+ }
+ if (updatePagination) {
+ state.pagination = { ...pagination };
+ }
+ if (updateSorting) {
+ state.sorting = { ...sorting };
+ }
+ }
+ return state;
+}
+
export default reducer;
diff --git a/src/store/selectors/workPeriods.js b/src/store/selectors/workPeriods.js
index 060d93f..9a826e7 100644
--- a/src/store/selectors/workPeriods.js
+++ b/src/store/selectors/workPeriods.js
@@ -50,6 +50,9 @@ export const getWorkPeriodsSelected = (state) =>
*/
export const getWorkPeriodsFilters = (state) => state.workPeriods.filters;
+export const getWorkPeriodsPaymentStatuses = (state) =>
+ state.workPeriods.filters.paymentStatuses;
+
export const getWorkPeriodsDateRange = (state) =>
state.workPeriods.filters.dateRange;
@@ -59,9 +62,17 @@ export const getWorkPeriodsSorting = (state) => state.workPeriods.sorting;
export const getWorkPeriodsPagination = (state) => state.workPeriods.pagination;
+export const getWorkPeriodsPageNumber = (state) =>
+ state.workPeriods.pagination.pageNumber;
+
export const getWorkPeriodsPageSize = (state) =>
state.workPeriods.pagination.pageSize;
+export const getWorkPeriodsUrlQuery = (state) => state.workPeriods.query;
+
+export const getWorkPeriodsIsQueryFromState = (state) =>
+ state.workPeriods.isQueryFromState;
+
export const getWorkPeriodsCount = (state) => state.workPeriods.periods.length;
export const getWorkPeriodsData = (state) => state.workPeriods.periodsData;
diff --git a/src/store/thunks/workPeriods.js b/src/store/thunks/workPeriods.js
index 785f807..07abf0e 100644
--- a/src/store/thunks/workPeriods.js
+++ b/src/store/thunks/workPeriods.js
@@ -1,4 +1,5 @@
import axios from "axios";
+import { navigate } from "@reach/router";
import * as actions from "store/actions/workPeriods";
import * as selectors from "store/selectors/workPeriods";
import * as services from "services/workPeriods";
@@ -7,7 +8,7 @@ import {
API_SORT_BY,
DATE_FORMAT_API,
PAYMENT_STATUS_MAP,
- FIELDS_QUERY,
+ API_FIELDS_QUERY,
JOB_NAME_NONE,
} from "constants/workPeriods";
import {
@@ -17,6 +18,7 @@ import {
replaceItems,
} from "utils/misc";
import {
+ makeUrlQuery,
normalizeBillingAccounts,
normalizeDetailsPeriodItems,
normalizePeriodData,
@@ -29,7 +31,7 @@ import {
makeToastPaymentsWarning,
makeToastPaymentsError,
} from "routes/WorkPeriods/utils/toasts";
-import { RESOURCE_BOOKING_STATUS } from "constants/index.js";
+import { RESOURCE_BOOKING_STATUS, WORK_PERIODS_PATH } from "constants/index.js";
/**
* Thunk that loads the specified working periods' page. If page number is not
@@ -37,65 +39,74 @@ import { RESOURCE_BOOKING_STATUS } from "constants/index.js";
* working period filters are loaded from the current state to construct
* a request query.
*
- * @param {number} [pageNumber] page number to load
- * @returns {function}
+ * @returns {Promise}
*/
-export const loadWorkPeriodsPage =
- (pageNumber) => async (dispatch, getState) => {
- const workPeriods = selectors.getWorkPeriodsStateSlice(getState());
- if (workPeriods.cancelSource) {
- // If there's an ongoing request we just cancel it since the data that comes
- // with its response will not correspond to application's current state,
- // namely filters and sorting.
- workPeriods.cancelSource.cancel();
- }
- const { filters, sorting, pagination } = workPeriods;
+export const loadWorkPeriodsPage = async (dispatch, getState) => {
+ const workPeriods = selectors.getWorkPeriodsStateSlice(getState());
+ if (workPeriods.cancelSource) {
+ // If there's an ongoing request we just cancel it since the data that comes
+ // with its response will not correspond to application's current state,
+ // namely filters and sorting.
+ workPeriods.cancelSource.cancel();
+ }
+ const { filters, sorting, pagination } = workPeriods;
- // If page number is not specified get it from current state.
- pageNumber = pageNumber || pagination.pageNumber;
+ const sortOrder = sorting.order;
+ const sortBy = SORT_BY_MAP[sorting.criteria] || API_SORT_BY.USER_HANDLE;
- const sortOrder = sorting.order;
- const sortBy = SORT_BY_MAP[sorting.criteria] || API_SORT_BY.USER_HANDLE;
+ const [startDate] = filters.dateRange;
+ const paymentStatuses = replaceItems(
+ Object.keys(filters.paymentStatuses),
+ PAYMENT_STATUS_MAP
+ );
- const [startDate] = filters.dateRange;
- const paymentStatuses = replaceItems(
- Object.keys(filters.paymentStatuses),
- PAYMENT_STATUS_MAP
- );
+ // For parameter description see:
+ // https://topcoder-platform.github.io/taas-apis/#/ResourceBookings/get_resourceBookings
+ const [promise, cancelSource] = services.fetchResourceBookings({
+ fields: API_FIELDS_QUERY,
+ page: pagination.pageNumber,
+ perPage: pagination.pageSize,
+ sortBy,
+ sortOrder,
+ // we only want to show Resource Bookings with status "placed"
+ status: RESOURCE_BOOKING_STATUS.PLACED,
+ ["workPeriods.userHandle"]: filters.userHandle,
+ ["workPeriods.startDate"]: startDate.format(DATE_FORMAT_API),
+ ["workPeriods.paymentStatus"]: paymentStatuses,
+ });
+ dispatch(actions.loadWorkPeriodsPagePending(cancelSource));
+ let totalCount, periods, pageCount;
+ try {
+ const response = await promise;
+ ({ totalCount, pageCount } = extractResponsePagination(response));
+ const data = extractResponseData(response);
+ periods = normalizePeriodItems(data);
+ } catch (error) {
+ // If request was cancelled by the next call to loadWorkPeriodsPage
+ // there's nothing more to do.
+ if (!axios.isCancel(error)) {
+ dispatch(actions.loadWorkPeriodsPageError(error.toString()));
+ }
+ return;
+ }
+ dispatch(actions.loadWorkPeriodsPageSuccess(periods, totalCount, pageCount));
+};
- // For parameter description see:
- // https://topcoder-platform.github.io/taas-apis/#/ResourceBookings/get_resourceBookings
- const [promise, cancelSource] = services.fetchResourceBookings({
- fields: FIELDS_QUERY,
- page: pageNumber,
- perPage: pagination.pageSize,
- sortBy,
- sortOrder,
- // we only want to show Resource Bookings with status "placed"
- status: RESOURCE_BOOKING_STATUS.PLACED,
- ["workPeriods.userHandle"]: filters.userHandle,
- ["workPeriods.startDate"]: startDate.format(DATE_FORMAT_API),
- ["workPeriods.paymentStatus"]: paymentStatuses,
- });
- dispatch(actions.loadWorkPeriodsPagePending(cancelSource, pageNumber));
- let totalCount, periods, pageCount;
- try {
- const response = await promise;
- ({ totalCount, pageNumber, pageCount } =
- extractResponsePagination(response));
- const data = extractResponseData(response);
- periods = normalizePeriodItems(data);
- } catch (error) {
- // If request was cancelled by the next call to loadWorkPeriodsPage
- // there's nothing more to do.
- if (!axios.isCancel(error)) {
- dispatch(actions.loadWorkPeriodsPageError(error.toString()));
- }
- return;
+/**
+ * Updates URL from current state.
+ *
+ * @param {boolean} replace whether to push or replace the history state
+ * @returns {function}
+ */
+export const updateQueryFromState =
+ (replace = false) =>
+ (dispatch, getState) => {
+ const query = makeUrlQuery(selectors.getWorkPeriodsStateSlice(getState()));
+ if (query !== window.location.search.slice(1)) {
+ setTimeout(() => {
+ navigate(`${WORK_PERIODS_PATH}?${query}`, { replace });
+ }, 100); // if executed synchronously navigate() causes a noticable lag
}
- dispatch(
- actions.loadWorkPeriodsPageSuccess(periods, totalCount, pageCount)
- );
};
/**
diff --git a/src/utils/misc.js b/src/utils/misc.js
index 6e30d8a..b722470 100644
--- a/src/utils/misc.js
+++ b/src/utils/misc.js
@@ -67,6 +67,10 @@ export function replaceItems(array, map) {
return result;
}
+export function preventDefault(event) {
+ event.preventDefault();
+}
+
/**
* Stops event propagation.
*
diff --git a/src/utils/workPeriods.js b/src/utils/workPeriods.js
index 1704540..8e67618 100644
--- a/src/utils/workPeriods.js
+++ b/src/utils/workPeriods.js
@@ -1,5 +1,42 @@
import moment from "moment";
-import { API_PAYMENT_STATUS_MAP, DATE_FORMAT_UI } from "constants/workPeriods";
+import {
+ API_CHALLENGE_PAYMENT_STATUS_MAP,
+ API_PAYMENT_STATUS_MAP,
+ DATE_FORMAT_API,
+ DATE_FORMAT_UI,
+ PAYMENT_STATUS,
+ URL_QUERY_PARAM_MAP,
+} from "constants/workPeriods";
+
+/**
+ * Creates a URL search query from current state.
+ *
+ * @param {Object} state working periods' newly created state slice
+ * @returns {Object}
+ */
+export function makeUrlQuery(state) {
+ const { filters, pagination, sorting } = state;
+ const { dateRange, paymentStatuses, userHandle } = filters;
+ const { pageNumber, pageSize } = pagination;
+ const { criteria, order } = sorting;
+ const params = {
+ startDate: dateRange[0].format(DATE_FORMAT_API),
+ paymentStatuses: Object.keys(paymentStatuses).join(",").toLowerCase(),
+ userHandle,
+ criteria: criteria.toLowerCase(),
+ order,
+ pageNumber,
+ pageSize,
+ };
+ const queryParams = [];
+ for (let [stateKey, queryKey] of URL_QUERY_PARAM_MAP) {
+ let value = params[stateKey];
+ if (value) {
+ queryParams.push(`${queryKey}=${value}`);
+ }
+ }
+ return queryParams.join("&");
+}
export function normalizePeriodItems(items) {
const empty = {};
@@ -81,11 +118,12 @@ export function createAssignedBillingAccountOption(accountId) {
export function normalizeDetailsPeriodItems(items) {
const periods = [];
for (let item of items) {
+ let payments = item.payments || [];
periods.push({
id: item.id,
startDate: item.startDate ? moment(item.startDate).valueOf() : 0,
endDate: item.endDate ? moment(item.endDate).valueOf() : 0,
- payments: item.payments || [],
+ payments: payments.length ? normalizeDetailsPayments(payments) : payments,
weeklyRate: item.memberRate,
data: normalizePeriodData(item),
});
@@ -94,6 +132,15 @@ export function normalizeDetailsPeriodItems(items) {
return periods;
}
+function normalizeDetailsPayments(payments) {
+ for (let payment of payments) {
+ payment.status =
+ API_CHALLENGE_PAYMENT_STATUS_MAP[payment.status] ||
+ PAYMENT_STATUS.UNDEFINED;
+ }
+ return payments;
+}
+
export function normalizePaymentStatus(paymentStatus) {
return API_PAYMENT_STATUS_MAP[paymentStatus];
}