diff --git a/.circleci/config.yml b/.circleci/config.yml index 4be26f9..21fcb93 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -77,7 +77,6 @@ workflows: branches: only: - dev - - submission-page # Production builds are exectuted only on tagged commits to the # master branch. diff --git a/config/dev.js b/config/dev.js index 5ca5059..632f11b 100644 --- a/config/dev.js +++ b/config/dev.js @@ -139,4 +139,8 @@ module.exports = { process.env.FILESTACK_SUBMISSION_CONTAINER || "topcoder-dev-submissions-dmz", }, + /* Time in MS to wait before refreshing challenge details after register + * and unregister. Used to allow API sufficent time to update. + */ + CHALLENGE_DETAILS_REFRESH_DELAY: 3000, }; diff --git a/config/prod.js b/config/prod.js index ab93169..bb2f09b 100644 --- a/config/prod.js +++ b/config/prod.js @@ -134,4 +134,8 @@ module.exports = { process.env.FILESTACK_SUBMISSION_CONTAINER || "topcoder-dev-submissions-dmz", }, + /* Time in MS to wait before refreshing challenge details after register + * and unregister. Used to allow API sufficent time to update. + */ + CHALLENGE_DETAILS_REFRESH_DELAY: 3000, }; diff --git a/src/components/DateRangePicker/DateInput/index.jsx b/src/components/DateRangePicker/DateInput/index.jsx index 1d62dfb..f543b52 100644 --- a/src/components/DateRangePicker/DateInput/index.jsx +++ b/src/components/DateRangePicker/DateInput/index.jsx @@ -20,6 +20,7 @@ const DateInput = ({ onClickCalendarIcon, onStartEndDateChange, placeholder, + enterToSubmit, }) => { const ref = useRef(null); const [focused, setFocused] = useState(false); @@ -125,7 +126,14 @@ const DateInput = ({ size="xs" value={rangeText} onChange={(value) => { - onChangeRangeTextDebounced.current(() => onChangeRangeText(value)); + if (!enterToSubmit) { + onChangeRangeTextDebounced.current(() => + onChangeRangeText(value) + ); + } + }} + onEnterKey={(value) => { + onChangeRangeText(value); }} placeholder={placeholder} /> diff --git a/src/components/DateRangePicker/helpers.js b/src/components/DateRangePicker/helpers.js index cef8582..df745c9 100644 --- a/src/components/DateRangePicker/helpers.js +++ b/src/components/DateRangePicker/helpers.js @@ -50,39 +50,45 @@ const staticRangeHandler = { * @return {object[]} list of defined ranges */ export function createStaticRanges() { - const now = moment().utcOffset(0); - const pastWeek = now.clone().subtract(1, "week"); - const pastMonth = now.clone().subtract(1, "month"); - const past6Months = now.clone().subtract(6, "month"); - const pastYear = now.clone().subtract(1, "year"); + const today = moment(); + const endOfToday = today.set({ + hour: 23, + minute: 59, + second: 59, + millisecond: 999, + }); + const pastWeek = endOfToday.clone().subtract(1, "week"); + const pastMonth = endOfToday.clone().subtract(1, "month"); + const past6Months = endOfToday.clone().subtract(6, "month"); + const pastYear = endOfToday.clone().subtract(1, "year"); const ranges = [ { label: "Past Week", range: () => ({ startDate: pastWeek.startOf("day").toDate(), - endDate: now.endOf("day").toDate(), + endDate: endOfToday.toDate(), }), }, { label: "Past Month", range: () => ({ startDate: pastMonth.startOf("day").toDate(), - endDate: now.endOf("day").toDate(), + endDate: endOfToday.toDate(), }), }, { label: "Past 6 Months", range: () => ({ startDate: past6Months.startOf("day").toDate(), - endDate: now.endOf("day").toDate(), + endDate: endOfToday.toDate(), }), }, { label: "Past Year", range: () => ({ startDate: pastYear.startOf("day").toDate(), - endDate: now.endOf("day").toDate(), + endDate: endOfToday.toDate(), }), }, ]; diff --git a/src/components/DateRangePicker/index.jsx b/src/components/DateRangePicker/index.jsx index 64b5f52..5c82772 100644 --- a/src/components/DateRangePicker/index.jsx +++ b/src/components/DateRangePicker/index.jsx @@ -16,7 +16,7 @@ import { } from "./helpers"; function DateRangePicker(props) { - const { id, range, onChange, placeholder } = props; + const { id, range, onChange, placeholder, enterToSubmit = false } = props; const [rangeString, setRangeString] = useState({ startDateString: "", @@ -269,6 +269,37 @@ function DateRangePicker(props) { setPreview(null); }; + const onReset = (presetRange) => { + let newStartDate; + let newEndDate; + + if (presetRange) { + newStartDate = presetRange.startDate; + newEndDate = presetRange.endDate; + } + + setFocusedRange([0, 0]); + + setErrors({ + startDate: "", + endDate: "", + }); + + setRangeString({ + startDateString: newStartDate + ? moment(newStartDate).format("MMM D, YYYY") + : "", + endDateString: newEndDate ? moment(newEndDate).format("MMM D, YYYY") : "", + }); + + onChange({ + startDate: newStartDate ? moment(newStartDate) : null, + endDate: newEndDate ? moment(newEndDate) : null, + }); + + setIsComponentVisible(false); + } + /** * Event handler on date selection changes * @param {Object} newRange nnew range that has endDate and startDate data @@ -344,8 +375,19 @@ function DateRangePicker(props) { const onPreviewChange = (date) => { if (!(date instanceof Date)) { setPreview(null); - setActiveDate(null); - setFocusedRange([0, focusedRange[1]]); + + // --- + // workaround for fixing issue 132: + // - set the active range's background to transparent color + // to prevent the calendar auto focusing on the day of today by default when no + // start date nor end date are set. + // - does not set focus on the selection range when mouse leaves. + // --- + + // setActiveDate(null); + // if (range.startDate || range.endDate) { + // setFocusedRange([0, focusedRange[1]]); + // } return; } @@ -485,7 +527,7 @@ function DateRangePicker(props) { startDate: activeDate, endDate: activeDate, key: "active", - color: "#D8FDD8", + color: preview ? "#D8FDD8" : "#D8FDD800", }, ]; } @@ -538,6 +580,7 @@ function DateRangePicker(props) { }} onStartEndDateChange={onStartEndDateChange} placeholder={placeholder} + enterToSubmit={enterToSubmit} />
@@ -546,9 +589,13 @@ function DateRangePicker(props) { - onDateRangePickerChange(item.selection || item.active) - } + onChange={(item) => { + if (!preview) { + onReset(item.selection || item.active); + } else { + onDateRangePickerChange(item.selection || item.active); + } + }} dateDisplayFormat="MM/dd/yyyy" showDateDisplay={false} staticRanges={createStaticRanges()} @@ -562,18 +609,24 @@ function DateRangePicker(props) { preview={preview} onPreviewChange={onPreviewChange} /> - +
+ + +
)} diff --git a/src/components/DateRangePicker/style.scss b/src/components/DateRangePicker/style.scss index 8a9caa9..c0bd6f1 100644 --- a/src/components/DateRangePicker/style.scss +++ b/src/components/DateRangePicker/style.scss @@ -348,6 +348,10 @@ $darkGreen: #0AB88A;; } } + .rdrStartEdge.rdrEndEdge ~ .rdrDayNumber span { + color: $tc-black; + } + .rdrDayNumber { top: 0; bottom: 0; @@ -391,6 +395,7 @@ $darkGreen: #0AB88A;; z-index: 10; @include phone { + width: 100vw; position: fixed; top: 0; left: 0; @@ -402,7 +407,15 @@ $darkGreen: #0AB88A;; border-radius: 0; } - .reset-button { + .calendar-footer { + width: 100%; + + @include phone { + padding: 0 20px; + } + } + + .calendar-button { @include roboto-bold; width: 71px; @@ -421,7 +434,7 @@ $darkGreen: #0AB88A;; height: 26px; line-height: 27px; font-size: 12px; - margin: 20px 12px 0; + margin: 0 12px 0; } } } diff --git a/src/components/DropdownTerms/index.jsx b/src/components/DropdownTerms/index.jsx index 5b3ced8..b605974 100644 --- a/src/components/DropdownTerms/index.jsx +++ b/src/components/DropdownTerms/index.jsx @@ -51,8 +51,8 @@ function DropdownTerms({ } }, [focused, selectedOption]); useEffect(() => { - setInternalTerms(terms); - }, [terms]); + setInternalTerms(terms); // eslint-disable-next-line react-hooks/exhaustive-deps + }, [terms && terms.length]); const CustomReactSelectRow = React.forwardRef( ({ className, option, children, onSelect }, ref) => diff --git a/src/components/NotFoundError/index.jsx b/src/components/NotFoundError/index.jsx new file mode 100644 index 0000000..72c7f02 --- /dev/null +++ b/src/components/NotFoundError/index.jsx @@ -0,0 +1,16 @@ +import React from "react"; +import IconNotFound from "assets/icons/not-found.png"; + +import "./styles.scss"; + +const NotFoundError = ({ message }) => ( +
+
+ not found +
+

404 Not found

+

Sorry, we couldn’t find that page

+
+); + +export default NotFoundError; diff --git a/src/components/NotFoundError/styles.scss b/src/components/NotFoundError/styles.scss new file mode 100644 index 0000000..fb8a1d4 --- /dev/null +++ b/src/components/NotFoundError/styles.scss @@ -0,0 +1,21 @@ +@import "styles/variables"; + +.not-found-error { + padding: 16px 24px; + min-height: 136px; + margin-bottom: 35px; + font-size: $font-size-sm; + line-height: 22px; + text-align: center; + background: $white; + border-radius: $border-radius-lg; + + h1 { + padding: 15px 0 10px; + margin-bottom: 20px; + } + + p { + margin-bottom: 8px; + } +} diff --git a/src/components/Pagination/index.jsx b/src/components/Pagination/index.jsx index 1d7835b..ee625ef 100644 --- a/src/components/Pagination/index.jsx +++ b/src/components/Pagination/index.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from "react"; +import React, { useRef, useState, useEffect } from "react"; import PT from "prop-types"; import Dropdown from "../Dropdown"; import { @@ -12,6 +12,20 @@ import "./styles.scss"; const N = PAGINATION_MAX_PAGE_DISPLAY; +const createDisplayPages = (p, n) => { + const pages = []; + for ( + let start = utils.clamp(p - N, 0, n), + end = utils.clamp(p + N, 0, n), + i = start; + i < end; + i += 1 + ) { + pages.push(i); + } + return pages.slice(-N); +}; + /** * Pagination with the first page index being as 0 and the last page index being as `total - 1` */ @@ -20,24 +34,6 @@ const Pagination = ({ length, pageIndex, pageSize, onChange }) => { const perPageOptions = utils.createDropdownOptions(PAGINATION_PER_PAGES); utils.setSelectedDropdownOption(perPageOptions, `${pageSize}`); - const createDisplayPages = (p, n) => { - const pages = []; - for ( - let start = utils.clamp(p - N, 0, n), - end = utils.clamp(p + N, 0, n), - i = start; - i < end; - i += 1 - ) { - pages.push(i); - } - return pages.slice(-N); - }; - - const [displayPages, setDisplayPages] = useState( - createDisplayPages(pageIndex, total) - ); - const onChangePageSize = (options) => { const selectedOption = utils.getSelectedDropdownOption(options); const newPageSize = +selectedOption.label; @@ -59,33 +55,45 @@ const Pagination = ({ length, pageIndex, pageSize, onChange }) => { } }; - const latestPropsRef = useRef(null); - latestPropsRef.current = { displayPages, pageIndex }; + const previousPropsRef = useRef(); + const [displayPages, setDisplayPages] = useState([]); useEffect(() => { - const newTotal = Math.ceil(length / pageSize); - const _pageIndex = latestPropsRef.current.pageIndex; - setDisplayPages(createDisplayPages(_pageIndex, newTotal)); - }, [length, pageSize]); + let _displayPages = displayPages; - useEffect(() => { - const _displayPages = latestPropsRef.current.displayPages; - const start = _displayPages[0]; - const end = _displayPages[_displayPages.length - 1]; - - const updateDisplayPages = []; - if (pageIndex < start) { - for (let i = pageIndex; i < pageIndex + N; i += 1) { - updateDisplayPages.push(i); - } - setDisplayPages(updateDisplayPages); - } else if (pageIndex > end) { - for (let i = pageIndex; i > pageIndex - N; i -= 1) { - updateDisplayPages.unshift(i); + if ( + !previousPropsRef.current || + previousPropsRef.current.length !== length || + previousPropsRef.current.pageSize !== pageSize + ) { + const newTotal = Math.ceil(length / pageSize); + _displayPages = createDisplayPages(pageIndex, newTotal); + setDisplayPages(_displayPages); + } + + if ( + !previousPropsRef.current || + previousPropsRef.current.pageIndex !== pageIndex + ) { + const start = _displayPages[0]; + const end = _displayPages[_displayPages.length - 1]; + + const updateDisplayPages = []; + if (pageIndex < start) { + for (let i = pageIndex; i < pageIndex + N; i += 1) { + updateDisplayPages.push(i); + } + setDisplayPages(updateDisplayPages); + } else if (pageIndex > end) { + for (let i = pageIndex; i > pageIndex - N; i -= 1) { + updateDisplayPages.unshift(i); + } + setDisplayPages(updateDisplayPages); } - setDisplayPages(updateDisplayPages); } - }, [pageIndex]); + + previousPropsRef.current = { length, pageSize, pageIndex }; + }, [length, pageSize, pageIndex, displayPages, setDisplayPages]); const formatPage = (p) => `${p + 1}`; diff --git a/src/components/TextInput/index.jsx b/src/components/TextInput/index.jsx index e543b0a..149cde7 100644 --- a/src/components/TextInput/index.jsx +++ b/src/components/TextInput/index.jsx @@ -18,6 +18,7 @@ function TextInput({ type, onEnterKey, readonly, + maxLength, }) { const [val, setVal] = useState(value); const delayedOnChange = useRef( @@ -41,6 +42,7 @@ function TextInput({ readOnly={readonly} defaultValue={value} type={type} + maxLength={maxLength} placeholder={`${placeholder}${placeholder && required ? " *" : ""}`} styleName={`${value || val ? "haveValue" : ""} ${ errorMsg ? "haveError" : "" @@ -55,7 +57,7 @@ function TextInput({ }} onKeyPress={(e) => { if (e.key === "Enter") { - onEnterKey(); + onEnterKey(e.target.value); } }} /> @@ -85,6 +87,7 @@ TextInput.defaultProps = { type: "text", onEnterKey: () => {}, readonly: false, + maxLength: undefined, }; TextInput.propTypes = { diff --git a/src/components/challenge-detail/Header/ChallengeTags.jsx b/src/components/challenge-detail/Header/ChallengeTags.jsx index 7ca45e9..f4f8ef2 100644 --- a/src/components/challenge-detail/Header/ChallengeTags.jsx +++ b/src/components/challenge-detail/Header/ChallengeTags.jsx @@ -26,6 +26,8 @@ import { COMPETITION_TRACKS } from "utils/tc"; import VerifiedTag from "components/challenge-listing/VerifiedTag"; import MatchScore from "components/challenge-listing/ChallengeCard/MatchScore"; import { calculateScore } from "../../../utils/challenge-listing/helper"; +import * as urlUtil from "utils/url"; +import * as constants from "constants"; import "./style.module.scss"; export default function ChallengeTags(props) { @@ -75,6 +77,19 @@ export default function ChallengeTags(props) { const tags = technPlatforms.filter((tag) => !matchSkills.includes(tag)); + const filterByChallengeType = urlUtil.buildQueryString({ + bucket: constants.FILTER_BUCKETS[1], + tracks: _.values(constants.FILTER_CHALLENGE_TRACK_ABBREVIATIONS), + page: 1, + }); + + const filterByTag = urlUtil.buildQueryString({ + bucket: constants.FILTER_BUCKETS[1], + tracks: _.values(constants.FILTER_CHALLENGE_TRACK_ABBREVIATIONS), + page: 1, + types: _.values(constants.FILTER_CHALLENGE_TYPE_ABBREVIATIONS), + }); + return (
{challengeType && ( @@ -84,7 +99,7 @@ export default function ChallengeTags(props) { setChallengeListingFilter({ types: [challengeType.name] }) ) } - to={`${challengesUrl}?types[]=${encodeURIComponent( + to={`${challengesUrl}${filterByChallengeType}&types[]=${encodeURIComponent( challengeType.abbreviation )}`} > @@ -112,7 +127,7 @@ export default function ChallengeTags(props) { onClick={() => setImmediate(() => setChallengeListingFilter({ tags: [tag] })) } - to={`${challengesUrl}?tags[]=${encodeURIComponent(tag)}`} + to={`${challengesUrl}${filterByTag}&tags[]=${encodeURIComponent(tag)}`} > {tag} diff --git a/src/components/challenge-detail/Submissions/index.jsx b/src/components/challenge-detail/Submissions/index.jsx index 7317378..6c17173 100644 --- a/src/components/challenge-detail/Submissions/index.jsx +++ b/src/components/challenge-detail/Submissions/index.jsx @@ -206,8 +206,8 @@ class SubmissionsComponent extends React.Component { valueA = getFinalScore(a); valueB = getFinalScore(b); } else { - valueA = !_.isEmpty(a.review) && a.review[0].score; - valueB = !_.isEmpty(b.review) && b.review[0].score; + valueA = !_.isEmpty(a.review) ? a.review[0].score : 0; + valueB = !_.isEmpty(b.review) ? b.review[0].score : 0; } break; } diff --git a/src/constants/index.js b/src/constants/index.js index f679d1d..77faca2 100644 --- a/src/constants/index.js +++ b/src/constants/index.js @@ -29,14 +29,14 @@ export const FILTER_CHALLENGE_TRACKS = [ "Design", "Development", "Data Science", - "QA", + "Quality Assurance", ]; export const FILTER_CHALLENGE_TRACK_ABBREVIATIONS = { Design: "DES", Development: "DEV", "Data Science": "DS", - QA: "QA", + "Quality Assurance": "QA", }; export const CHALLENGE_SORT_BY = { diff --git a/src/containers/Challenges/Listing/ChallengeItem/TrackIcon/styles.scss b/src/containers/Challenges/Listing/ChallengeItem/TrackIcon/styles.scss index 1528470..489052a 100644 --- a/src/containers/Challenges/Listing/ChallengeItem/TrackIcon/styles.scss +++ b/src/containers/Challenges/Listing/ChallengeItem/TrackIcon/styles.scss @@ -6,6 +6,7 @@ height: 36px; vertical-align: middle; line-height: 1; + cursor: pointer; > svg { position: absolute; diff --git a/src/containers/Challenges/Listing/ChallengeItem/index.jsx b/src/containers/Challenges/Listing/ChallengeItem/index.jsx index 82653ef..e9605b0 100644 --- a/src/containers/Challenges/Listing/ChallengeItem/index.jsx +++ b/src/containers/Challenges/Listing/ChallengeItem/index.jsx @@ -12,6 +12,8 @@ import * as utils from "../../../../utils"; import ProgressTooltip from "../tooltips/ProgressTooltip"; import PlacementsTooltip from "../tooltips/PlacementsTooltip"; import TagsMoreTooltip from "../tooltips/TagsMoreTooltip"; +import { CHALLENGES_URL } from 'constants'; +import { Link } from '@reach/router'; import "./styles.scss"; @@ -25,7 +27,7 @@ const ChallengeItem = ({ challenge, onClickTag, onClickTrack, isLoggedIn }) => { challenge.prizeSets ); - let submissionLink = `/earn/find/challenges/${challenge.id}`; + let submissionLink = `${CHALLENGES_URL}/${challenge.id}`; if (isLoggedIn && challenge.numOfSubmissions > 0) { submissionLink += "?tab=submissions"; } @@ -43,11 +45,11 @@ const ChallengeItem = ({ challenge, onClickTag, onClickTrack, isLoggedIn }) => {
- {challenge.name} - +
{ />
- - - + + - +
diff --git a/src/containers/Challenges/Listing/index.jsx b/src/containers/Challenges/Listing/index.jsx index 17a0ec5..e376d19 100644 --- a/src/containers/Challenges/Listing/index.jsx +++ b/src/containers/Challenges/Listing/index.jsx @@ -3,6 +3,7 @@ import PT from "prop-types"; import _ from "lodash"; import moment from "moment"; import Panel from "../../../components/Panel"; +import ChallengeError from "../Listing/errors/ChallengeError"; import Pagination from "../../../components/Pagination"; import ChallengeItem from "./ChallengeItem"; import TextInput from "../../../components/TextInput"; @@ -35,6 +36,14 @@ const Listing = ({ ); const onSearch = useRef(_.debounce((f) => f(), 1000)); + const onChangeSortBy = (newSortByOptions) => { + const selectedOption = utils.getSelectedDropdownOption(newSortByOptions); + const filterChange = { + sortBy: constants.CHALLENGE_SORT_BY[selectedOption.label], + page: 1, + }; + updateFilter(filterChange); + }; return ( @@ -50,10 +59,14 @@ const Listing = ({ size="xs" onChange={(value) => { onSearch.current(() => { - const filterChange = { search: value }; + const filterChange = { + search: value, + page: 1, + }; updateFilter(filterChange); }); }} + maxLength="100" />
@@ -66,15 +79,7 @@ const Listing = ({ label="Sort by" options={sortByOptions} size="xs" - onChange={(newSortByOptions) => { - const selectedOption = utils.getSelectedDropdownOption( - newSortByOptions - ); - const filterChange = { - sortBy: constants.CHALLENGE_SORT_BY[selectedOption.label], - }; - updateFilter(filterChange); - }} + onChange={_.debounce(onChangeSortBy, 1000)} />
{ const d = range.endDate ? moment(range.endDate).toISOString() @@ -90,7 +96,11 @@ const Listing = ({ const s = range.startDate ? moment(range.startDate).toISOString() : null; - const filterChange = { endDateStart: s, startDateEnd: d }; + const filterChange = { + endDateStart: s, + startDateEnd: d, + page: 1, + }; updateFilter(filterChange); }} range={{ @@ -101,38 +111,51 @@ const Listing = ({
- - {challenges.map((challenge, index) => ( -
- { - const filterChange = { tags: [tag] }; - updateFilter(filterChange); - }} - onClickTrack={(track) => { - const filterChange = { tracks: [track] }; + {challenges.length ? ( + + {challenges.map((challenge, index) => ( +
+ { + const filterChange = { + tags: [tag], + page: 1, + }; + updateFilter(filterChange); + }} + onClickTrack={(track) => { + const filterChange = { + tracks: [track], + page: 1, + }; + updateFilter(filterChange); + }} + isLoggedIn={isLoggedIn} + /> +
+ ))} +
+ { + const filterChange = { + page: utils.pagination.pageIndexToPage(event.pageIndex), + perPage: event.pageSize, + }; updateFilter(filterChange); }} - isLoggedIn={isLoggedIn} />
- ))} -
- { - const filterChange = { - page: utils.pagination.pageIndexToPage(event.pageIndex), - perPage: event.pageSize, - }; - updateFilter(filterChange); - }} - /> -
-
+ + ) : ( + + )} ); }; diff --git a/src/containers/Challenges/index.jsx b/src/containers/Challenges/index.jsx index cb1a484..9ed26b9 100644 --- a/src/containers/Challenges/index.jsx +++ b/src/containers/Challenges/index.jsx @@ -3,7 +3,6 @@ import PT from "prop-types"; import { connect } from "react-redux"; import Listing from "./Listing"; import actions from "../../actions"; -import ChallengeError from "./Listing/errors/ChallengeError"; // import ChallengeRecommendedError from "./Listing/errors/ChallengeRecommendedError"; import * as constants from "../../constants"; import IconListView from "../../assets/icons/list-view.svg"; @@ -15,6 +14,7 @@ import "./styles.scss"; const Challenges = ({ challenges, + challengesMeta, search, page, perPage, @@ -39,6 +39,21 @@ const Challenges = ({ checkIsLoggedIn(); }, []); + // reset pagination + if ( + page > 1 && + challengesMeta.total && + challengesMeta.total > 0 && + challenges.length === 0 + ) { + updateFilter({ + page: 1, + }); + updateQuery({ + page: 1, + }); + } + const BUCKET_OPEN_FOR_REGISTRATION = constants.FILTER_BUCKETS[1]; const isRecommended = recommended && bucket === BUCKET_OPEN_FOR_REGISTRATION; const sortByValue = isRecommended @@ -70,8 +85,7 @@ const Challenges = ({ - {challenges.length === 0 && initialized && } - {challenges.length > 0 && ( + {initialized && ( <> {/*noRecommendedChallenges && */} ({ endDateStart: state.filter.challenge.endDateStart, startDateEnd: state.filter.challenge.startDateEnd, challenges: state.challenges.challenges, + challengesMeta: state.challenges.challengesMeta, bucket: state.filter.challenge.bucket, recommended: state.filter.challenge.recommended, recommendedChallenges: state.challenges.recommendedChallenges, diff --git a/src/containers/Filter/ChallengeFilter/index.jsx b/src/containers/Filter/ChallengeFilter/index.jsx index d28ff5b..cc3e9ae 100644 --- a/src/containers/Filter/ChallengeFilter/index.jsx +++ b/src/containers/Filter/ChallengeFilter/index.jsx @@ -35,7 +35,7 @@ const ChallengeFilter = ({ // const BUCKET_OPEN_FOR_REGISTRATION = constants.FILTER_BUCKETS[1]; const tagOptions = utils.createDropdownTermOptions(challengeTags, tags); const bucketOptions = utils.createRadioOptions(challengeBuckets, bucket); - + const maxPrize = 100000; const caseSensitive = false; utils.setSelectedDropdownTermOptions(tagOptions, tags, caseSensitive); @@ -90,7 +90,10 @@ const ChallengeFilter = ({ const newTypes = checked ? types.concat(type) : types.filter((i) => i !== type); - const filterChange = { types: newTypes }; + const filterChange = { + types: newTypes, + page: 1, + }; updateFilter(filterChange); }} /> @@ -111,11 +114,14 @@ const ChallengeFilter = ({ const newTracks = checked ? tracks.concat(track) : tracks.filter((i) => i !== track); - const filterChange = { tracks: newTracks }; + const filterChange = { + tracks: newTracks, + page: 1, + }; updateFilter(filterChange); }} /> - {track} + {track.replace('Quality Assurance', 'QA')} ))}
@@ -132,6 +138,7 @@ const ChallengeFilter = ({ ); const filterChange = { tags: selectedTagOptions.map((tagOption) => tagOption.label), + page: 1, }; updateFilter(filterChange); }} @@ -156,6 +163,12 @@ const ChallengeFilter = ({ if (value == null) { setTotalPrizesFromError("Invalid format"); return; + } else if (value > maxPrize) { + setTotalPrizesFromError("Too big"); + return; + } else if (value >= totalPrizesTo) { + setTotalPrizesFromError("Too big"); + return; } else { setTotalPrizesFromError(null); } @@ -164,6 +177,7 @@ const ChallengeFilter = ({ } const filterChange = { totalPrizesFrom: value, + page: 1, }; updateFilter(filterChange); }) @@ -188,6 +202,12 @@ const ChallengeFilter = ({ if (value == null) { setTotalPrizesToError("Invalid format"); return; + } else if (value > maxPrize) { + setTotalPrizesToError("Too big"); + return; + } else if (value <= totalPrizesFrom) { + setTotalPrizesToError("Too small"); + return; } else { setTotalPrizesToError(null); } @@ -196,6 +216,7 @@ const ChallengeFilter = ({ } const filterChange = { totalPrizesTo: value, + page: 1, }; updateFilter(filterChange); }) @@ -237,7 +258,10 @@ const ChallengeFilter = ({ event !== utils.challenge.getCommunityEvent(subCommunity) ); - filterChange = { events: newEvents }; + filterChange = { + events: newEvents, + page: 1, + }; } else { const newGroups = checked ? groups.concat( @@ -248,7 +272,10 @@ const ChallengeFilter = ({ group !== utils.challenge.getCommunityGroup(subCommunity) ); - filterChange = { groups: newGroups }; + filterChange = { + groups: newGroups, + page: 1, + }; } updateFilter(filterChange); diff --git a/src/containers/Submission/Submit/Header/index.jsx b/src/containers/Submission/Submit/Header/index.jsx index 946eef2..447268a 100644 --- a/src/containers/Submission/Submit/Header/index.jsx +++ b/src/containers/Submission/Submit/Header/index.jsx @@ -1,14 +1,16 @@ import React from "react"; import PT from "prop-types"; import { Link } from "@reach/router"; -import config from '../../../../../config' +import config from "../../../../../config"; import "./styles.scss"; const Header = ({ title, challengeId }) => { return (
- +

Back to challenge

diff --git a/src/containers/Submission/Submit/Uploading/index.jsx b/src/containers/Submission/Submit/Uploading/index.jsx index 8c712dd..9f83d8d 100644 --- a/src/containers/Submission/Submit/Uploading/index.jsx +++ b/src/containers/Submission/Submit/Uploading/index.jsx @@ -1,6 +1,6 @@ -import React from "react"; +import React, { useEffect, useRef } from "react"; import PT from "prop-types"; -import { Link } from "@reach/router"; +import { Link, navigate } from "@reach/router"; import { PrimaryButton, DefaultButton as Button } from "components/Buttons"; import { COMPETITION_TRACKS, CHALLENGES_URL } from "../../../../constants"; import RobotHappy from "assets/icons/robot-happy.svg"; @@ -20,6 +20,22 @@ const Uploading = ({ uploadProgress, back, }) => { + const propsRef = useRef(); + propsRef.current = { submitDone, challengeId }; + + useEffect(() => { + return () => { + if (propsRef.current.submitDone) { + const backUrl = window.location.pathname; + if (backUrl === `${CHALLENGES_URL}/${challengeId}`) { + navigate( + `${CHALLENGES_URL}/${propsRef.current.challengeId}?reload=true` + ); + } + } + }; // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return (
diff --git a/src/containers/challenge-detail/index.jsx b/src/containers/challenge-detail/index.jsx index b2a17b1..f3f3cfc 100644 --- a/src/containers/challenge-detail/index.jsx +++ b/src/containers/challenge-detail/index.jsx @@ -221,6 +221,10 @@ class ChallengeDetailPageContainer extends React.Component { challenge, // loadingRecommendedChallengesUUID, history, + loadChallengeDetails, + loadFullChallengeDetails, + isLoadingChallenge, + isLoadingTerms, } = this.props; if ( @@ -247,6 +251,19 @@ class ChallengeDetailPageContainer extends React.Component { // getAllRecommendedChallenges(auth.tokenV3, recommendedTechnology); // } + const query = new URLSearchParams(history.location.search); + const isReloading = isLoadingChallenge || isLoadingTerms; + if (query.get("reload") && !isReloading) { + history.replace(history.location.pathname, history.state); + loadChallengeDetails( + nextProps.auth, + challengeId, + loadFullChallengeDetails + ); + + return; + } + const { thriveArticles } = this.state; const userId = _.get(this, "props.auth.user.userId"); const nextUserId = _.get(nextProps, "auth.user.userId"); @@ -961,8 +978,7 @@ const mapDispatchToProps = (dispatch) => { } dispatch(updateFilter(change)); dispatch( - updateQuery({ ...stateProps.filter.challenge, ...change }), - change + updateQuery({ ...stateProps.filter.challenge, ...change }) ); }, setSpecsTabState: (state) => diff --git a/src/reducers/challenges.js b/src/reducers/challenges.js index dc8d54c..31ae15a 100644 --- a/src/reducers/challenges.js +++ b/src/reducers/challenges.js @@ -4,6 +4,7 @@ const defaultState = { loadingChallenges: false, loadingChallengesError: null, challenges: [], + challengesMeta: {}, total: 0, loadingRecommendedChallenges: false, loadingRecommendedChallengesError: null, @@ -16,7 +17,24 @@ function onGetChallengesInit(state) { return { ...state, loadingChallenges: true, loadingChallengesError: null }; } -function onGetChallengesDone(state, { payload }) { +function onGetChallengesDone(state, { error, payload }) { + if (error) { + return onGetChallengesFailure(state, { payload }); + } + + return { + ...state, + loadingChallenges: false, + loadingChallengesError: null, + challenges: payload.challenges, + challengesMeta: payload.challenges?.meta, + total: payload.total, + openForRegistrationCount: payload.openForRegistrationCount, + initialized: true, + }; +} + +function onGetChallengesFailure(state, { payload }) { const error = payload; if (error.name === "AbortError") { return { @@ -29,40 +47,18 @@ function onGetChallengesDone(state, { payload }) { return { ...state, loadingChallenges: false, - loadingChallengesError: null, - challenges: payload.challenges, - total: payload.total, - openForRegistrationCount: payload.openForRegistrationCount, + loadingChallengesError: payload, + challenges: [], + total: 0, + openForRegistrationCount: 0, initialized: true, }; } -// function onGetChallengesFailure(state, { payload }) { -// const error = payload; -// if (error.name === "AbortError") { -// return { -// ...state, -// loadingChallenges: false, -// loadingChallengesError: null, -// }; -// } - -// return { -// ...state, -// loadingChallenges: false, -// loadingChallengesError: payload, -// challenges: [], -// total: 0, -// openForRegistrationCount: 0, -// initialized: true, -// }; -// } - export default handleActions( { GET_CHALLENGE_INIT: onGetChallengesInit, GET_CHALLENGES_DONE: onGetChallengesDone, - // GET_CHALLENGES_FAILURE: onGetChallengesFailure, }, defaultState ); diff --git a/src/routers/challenge-list/index.jsx b/src/routers/challenge-list/index.jsx index 49bcaa2..6adcfb0 100644 --- a/src/routers/challenge-list/index.jsx +++ b/src/routers/challenge-list/index.jsx @@ -9,7 +9,7 @@ import { FeedbackButton, showMenu } from "@topcoder/micro-frontends-earn-app"; import actions from "../../actions"; import * as utils from "../../utils"; import store from "../../store"; -import { initialChallengeFilter } from "../..//reducers/filter"; +import { initialChallengeFilter } from "../../reducers/filter"; import _ from "lodash"; import "react-date-range/dist/theme/default.css"; @@ -29,14 +29,24 @@ const App = () => { useEffect(() => { if (!location.search) { - store.dispatch(actions.challenges.getChallengesInit()); - store.dispatch( - actions.challenges.getChallengesDone(initialChallengeFilter) - ); + const currentFilter = store.getState().filter.challenge; + const diff = !_.isEqual(initialChallengeFilter, currentFilter); + + if (diff) { + const params = utils.challenge.createChallengeParams(currentFilter); + utils.url.updateQuery(params, true); + } else { + store.dispatch(actions.challenges.getChallengesInit()); + store.dispatch(actions.challenges.getChallengesDone(currentFilter)); + } + return; } - const params = utils.url.parseUrlQuery(location.search); + let search = location.href.split("?").length + ? "?" + location.href.split("?")[1] + : ""; + const params = utils.url.parseUrlQuery(search); const toUpdate = utils.challenge.createChallengeFilter(params); if (!toUpdate.types) toUpdate.types = []; diff --git a/src/services/challenges.js b/src/services/challenges.js index 7e6728a..2ecbe5d 100644 --- a/src/services/challenges.js +++ b/src/services/challenges.js @@ -17,7 +17,7 @@ import { getService as getSubmissionsService } from "./submissions"; * @return {Array} challenges */ async function getChallenges(filter, cancellationSignal) { - const challengeQuery = util.buildQueryString(filter); + const challengeQuery = util.buildQueryString(filter, true); return api.get( `/challenges/${challengeQuery}`, undefined, diff --git a/src/topcoder-micro-frontends-challenges-app.js b/src/topcoder-micro-frontends-challenges-app.js index bee6e7e..1bb7678 100644 --- a/src/topcoder-micro-frontends-challenges-app.js +++ b/src/topcoder-micro-frontends-challenges-app.js @@ -4,6 +4,7 @@ import ReactDOM from "react-dom"; import singleSpaReact from "single-spa-react"; import Root from "./root.component"; import appInit from "./utils/lifeCycle"; +import NotFoundError from "./components/NotFoundError"; const appLifecycles = appInit(); @@ -13,7 +14,7 @@ const lifecycles = singleSpaReact({ rootComponent: Root, errorBoundary(err, info, props) { // Customize the root error boundary for your microfrontend here. - return null; + return ; }, }); diff --git a/src/utils/challenge.js b/src/utils/challenge.js index 5aca8a4..1775a04 100644 --- a/src/utils/challenge.js +++ b/src/utils/challenge.js @@ -6,34 +6,49 @@ import Joi from "joi"; import { initialChallengeFilter } from "../reducers/filter"; Joi.optionalId = () => Joi.string().uuid(); -Joi.page = () => Joi.number().integer().min(1); + +Joi.page = () => + Joi.alternatives() + .try( + Joi.number() + .min(1), + Joi.any().custom(() => 1) + ); + Joi.perPage = () => - Joi.number() - .integer() - .min(1) - .max(100) - .valid(...constants.PAGINATION_PER_PAGES); + Joi.alternatives() + .try( + Joi.number() + .integer() + .min(1) + .max(100) + .valid(...constants.PAGINATION_PER_PAGES), + Joi.any().custom(() => constants.PAGINATION_PER_PAGES[0]) + ); + Joi.bucket = () => Joi.string().custom((param) => constants.FILTER_BUCKETS.find( (bucket) => param && param.toLowerCase() === bucket.toLowerCase() - ) + ) || null ); + Joi.track = () => Joi.string().custom((param) => _.findKey( constants.FILTER_CHALLENGE_TRACK_ABBREVIATIONS, (trackAbbreviation) => param && param.toLowerCase() === trackAbbreviation.toLowerCase() - ) + ) || null ); + Joi.type = () => Joi.string().custom((param) => _.findKey( constants.FILTER_CHALLENGE_TYPE_ABBREVIATIONS, (typeAbbreviation) => param && param.toLowerCase() === typeAbbreviation.toLowerCase() - ) + ) || null ); export function getCurrencySymbol(prizeSets) { @@ -65,6 +80,7 @@ export function getCheckpointPrizes(prizeSets) { */ export function createChallengeFilter(params) { const schema = createChallengeFilter.schema; + const normalized = Joi.attempt( params, schema, diff --git a/src/utils/index.js b/src/utils/index.js index b3fc4e1..ada0b97 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -167,14 +167,14 @@ export function parseTotalPrizes(s) { valid = valid && n.toLocaleString("en-US") === val; } } - if (valid) return n; + return n; } -export function triggerDownload(fileName,blob) { +export function triggerDownload(fileName, blob) { const url = window.URL.createObjectURL(new Blob([blob])); - const link = document.createElement('a'); + const link = document.createElement("a"); link.href = url; - link.setAttribute('download', fileName); + link.setAttribute("download", fileName); document.body.appendChild(link); link.click(); link.parentNode.removeChild(link); diff --git a/src/utils/lifeCycle.js b/src/utils/lifeCycle.js index 735a9c3..0f64daf 100644 --- a/src/utils/lifeCycle.js +++ b/src/utils/lifeCycle.js @@ -1,26 +1,35 @@ import store from "../store"; import action from "../actions/initApp"; import * as utils from "../utils"; +import { CHALLENGES_URL } from '../constants'; export default function appInit() { let initialQuery; let urlPath; + let firstMounted = true; function bootstrap() { return Promise.resolve().then(() => { initialQuery = window.location.search; - urlPath = window.location.pathname; + urlPath = utils.url.removeTrailingSlash(window.location.pathname); }); } - function mount() { - if (initialQuery) { - const params = utils.url.parseUrlQuery(initialQuery); - const filter = utils.challenge.createChallengeFilter(params); - store.dispatch(action.initApp(filter)); + async function mount() { + try { + if (firstMounted) { + if (initialQuery && urlPath === CHALLENGES_URL) { + const params = utils.url.parseUrlQuery(initialQuery); + const filter = utils.challenge.createChallengeFilter(params); + store.dispatch(action.initApp(filter)); + } + firstMounted = false; + } + } catch (error) { + console.error(error); + } finally { + return Promise.resolve(); } - - return Promise.resolve(); } function unmount() { diff --git a/src/utils/url.js b/src/utils/url.js index 501c761..a2598c6 100644 --- a/src/utils/url.js +++ b/src/utils/url.js @@ -15,9 +15,11 @@ import qs from "qs"; * @params {Object<{[key: string]: any}>} params Query string parameters * @return {String} */ -export function buildQueryString(params) { +export function buildQueryString(params, disableEncode) { params = _.omitBy(params, (p) => p == null || p === "" || p.length === 0); - + if (!disableEncode) { + params.tags = _.map(params.tags, (t) => encodeURIComponent(t)); + } let queryString = qs.stringify(params, { encode: false, arrayFormat: "brackets", @@ -28,15 +30,23 @@ export function buildQueryString(params) { } export function parseUrlQuery(queryString) { - return qs.parse(queryString, { ignoreQueryPrefix: true }); + let params = qs.parse(queryString, { ignoreQueryPrefix: true }); + if (params.tags) { + params.tags = _.map(params.tags, (t) => decodeURIComponent(t)); + } + return params; } -export function updateQuery(params) { +export function updateQuery(params, replace = false) { const oldQuery = decodeURIComponent(window.location.search); let query = buildQueryString(params); query = `?${query.substring(1).split("&").sort().join("&")}`; if (query !== oldQuery) { - window.history.pushState(window.history.state, "", query); + if (replace) { + window.history.replaceState(window.history.state, "", query); + } else { + window.history.pushState(window.history.state, "", query); + } } }