diff --git a/src/actions/auth.js b/src/actions/auth.js deleted file mode 100644 index 18e177a..0000000 --- a/src/actions/auth.js +++ /dev/null @@ -1,8 +0,0 @@ -import { createActions } from "redux-actions"; -import service from "../services/lookup"; - -async function getMemberGroups() {} - -export default createActions({ - GET_MEMBER_GROUPS: getMemberGroups, -}); diff --git a/src/actions/challenges.js b/src/actions/challenges.js index 304fa5c..4f4914c 100644 --- a/src/actions/challenges.js +++ b/src/actions/challenges.js @@ -7,28 +7,71 @@ async function doGetChallenges(filter) { return service.getChallenges(filter); } -async function getActiveChallenges(filter) { - const activeFilter = { +async function getAllActiveChallenges(filter) { + const BUCKET_ALL_ACTIVE_CHALLENGES = constants.FILTER_BUCKETS[0]; + let page; + + if (util.isDisplayingBucket(filter, BUCKET_ALL_ACTIVE_CHALLENGES)) { + page = filter.page; + } else { + page = 1; + } + + const allActiveFilter = { ...util.createChallengeCriteria(filter), - ...util.createActiveChallengeCriteria(), + ...util.createAllActiveChallengeCriteria(), + page, }; - return doGetChallenges(activeFilter); + return doGetChallenges(allActiveFilter); } async function getOpenForRegistrationChallenges(filter) { + const BUCKET_OPEN_FOR_REGISTRATION = constants.FILTER_BUCKETS[1]; + let page; + + if (util.isDisplayingBucket(filter, BUCKET_OPEN_FOR_REGISTRATION)) { + page = filter.page; + } else { + page = 1; + } + const openForRegistrationFilter = { ...util.createChallengeCriteria(filter), ...util.createOpenForRegistrationChallengeCriteria(), + page, }; return doGetChallenges(openForRegistrationFilter); } -async function getPastChallenges(filter) { - const pastFilter = { +async function getClosedChallenges(filter) { + const BUCKET_CLOSED_CHALLENGES = constants.FILTER_BUCKETS[1]; + let page; + + if (util.isDisplayingBucket(filter, BUCKET_CLOSED_CHALLENGES)) { + page = filter.page; + } else { + page = 1; + } + + const closedFilter = { ...util.createChallengeCriteria(filter), - ...util.createPastChallengeCriteria(), + ...util.createClosedChallengeCriteria(), + page, }; - return doGetChallenges(pastFilter); + return doGetChallenges(closedFilter); +} + +async function getRecommendedChallenges(filter) { + let result = []; + result.meta = { total: 0 }; + + if (result.length === 0) { + const failbackFilter = { ...filter }; + result = await getOpenForRegistrationChallenges(failbackFilter); + result.loadingRecommendedChallengesError = true; + } + + return result; } function doFilterBySubSommunities(challenges) { @@ -43,46 +86,113 @@ function doFilterByPrizeTo(challenges) { async function getChallenges(filter, change) { const FILTER_BUCKETS = constants.FILTER_BUCKETS; - let challenges; - let challengesFiltered; - let total; - let filterChange = change; - - const getChallengesByBucket = async (f) => { - switch (f.bucket) { - case FILTER_BUCKETS[0]: - return getActiveChallenges(f); - case FILTER_BUCKETS[1]: - return getOpenForRegistrationChallenges(f); - case FILTER_BUCKETS[2]: - return getPastChallenges(f); - default: - return []; - } + const BUCKET_ALL_ACTIVE_CHALLENGES = FILTER_BUCKETS[0]; + const BUCKET_OPEN_FOR_REGISTRATION = FILTER_BUCKETS[1]; + const BUCKET_CLOSED_CHALLENGES = FILTER_BUCKETS[2]; + const filterChange = change; + const bucket = filter.bucket; + + const getChallengesByBuckets = async (f) => { + return FILTER_BUCKETS.includes(f.bucket) + ? Promise.all([ + getAllActiveChallenges(f), + f.recommended + ? getRecommendedChallenges(f) + : getOpenForRegistrationChallenges(f), + getClosedChallenges(f), + ]) + : [[], [], []]; }; + if (!filterChange) { + let [ + allActiveChallenges, + openForRegistrationChallenges, + closedChallenges, + ] = await getChallengesByBuckets(filter); + let challenges; + let openForRegistrationCount; + let total; + let loadingRecommendedChallengesError; + + switch (bucket) { + case BUCKET_ALL_ACTIVE_CHALLENGES: + challenges = allActiveChallenges; + break; + case BUCKET_OPEN_FOR_REGISTRATION: + challenges = openForRegistrationChallenges; + break; + case BUCKET_CLOSED_CHALLENGES: + challenges = closedChallenges; + break; + } + openForRegistrationCount = openForRegistrationChallenges.meta.total; + total = challenges.meta.total; + loadingRecommendedChallengesError = + challenges.loadingRecommendedChallengesError; + + return { + challenges, + total, + openForRegistrationCount, + loadingRecommendedChallengesError, + allActiveChallenges, + openForRegistrationChallenges, + closedChallenges, + }; + } + if (!util.checkRequiredFilterAttributes(filter)) { return { challenges: [], challengesFiltered: [], total: 0 }; } - if (!filterChange) { - const chs = await getChallengesByBucket(filter); - return { challenges: chs, challengesFiltered: chs, total: chs.meta.total }; - } + let allActiveChallenges; + let openForRegistrationChallenges; + let closedChallenges; + let challenges; + let openForRegistrationCount; + let total; + let loadingRecommendedChallengesError; if (util.shouldFetchChallenges(filterChange)) { - challenges = await getChallengesByBucket(filter); + [ + allActiveChallenges, + openForRegistrationChallenges, + closedChallenges, + ] = await getChallengesByBuckets(filter); + switch (bucket) { + case BUCKET_ALL_ACTIVE_CHALLENGES: + challenges = allActiveChallenges; + break; + case BUCKET_OPEN_FOR_REGISTRATION: + challenges = openForRegistrationChallenges; + break; + case BUCKET_CLOSED_CHALLENGES: + challenges = closedChallenges; + break; + } } - challengesFiltered = challenges; + openForRegistrationCount = openForRegistrationChallenges.meta.total; total = challenges.meta.total; + loadingRecommendedChallengesError = + challenges.loadingRecommendedChallengesError; + if (util.shouldFilterChallenges(filterChange)) { - challengesFiltered = doFilterBySubSommunities(challengesFiltered); - challengesFiltered = doFilterByPrizeFrom(challengesFiltered); - challengesFiltered = doFilterByPrizeTo(challengesFiltered); + challenges = doFilterBySubSommunities(challenges); + challenges = doFilterByPrizeFrom(challenges); + challenges = doFilterByPrizeTo(challenges); } - return { challenges, challengesFiltered, total }; + return { + challenges, + total, + openForRegistrationCount, + loadingRecommendedChallengesError, + allActiveChallenges, + openForRegistrationChallenges, + closedChallenges, + }; } export default createActions({ diff --git a/src/actions/filter.js b/src/actions/filter.js index 5d3944b..d24ba3e 100644 --- a/src/actions/filter.js +++ b/src/actions/filter.js @@ -1,9 +1,14 @@ import { createActions } from "redux-actions"; +function restoreFilter(filter) { + return filter; +} + function updateFilter(partialUpdate) { return partialUpdate; } export default createActions({ + RESTORE_FILTER: restoreFilter, UPDATE_FILTER: updateFilter, }); diff --git a/src/assets/icons/card-view.svg b/src/assets/icons/card-view.svg new file mode 100644 index 0000000..a25ef80 --- /dev/null +++ b/src/assets/icons/card-view.svg @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="UTF-8"?> +<svg width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> + <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"> + <g id="03_2_My-Challenges" transform="translate(-1315.000000, -123.000000)"> + <g id="icons/grid" transform="translate(1315.000000, 123.000000)"> + <path fill="#7F7F7F" d="M6,9 C6.6,9 7,9.4 7,10 L7,10 L7,15 C7,15.6 6.6,16 6,16 L6,16 L1,16 C0.4,16 0,15.6 0,15 L0,15 L0,10 C0,9.4 0.4,9 1,9 L1,9 Z M15,9 C15.6,9 16,9.4 16,10 L16,10 L16,15 C16,15.6 15.6,16 15,16 L15,16 L10,16 C9.4,16 9,15.6 9,15 L9,15 L9,10 C9,9.4 9.4,9 10,9 L10,9 Z M6,0 C6.6,0 7,0.4 7,1 L7,1 L7,6 C7,6.6 6.6,7 6,7 L6,7 L1,7 C0.4,7 0,6.6 0,6 L0,6 L0,1 C0,0.4 0.4,0 1,0 L1,0 Z M15,0 C15.6,0 16,0.4 16,1 L16,1 L16,6 C16,6.6 15.6,7 15,7 L15,7 L10,7 C9.4,7 9,6.6 9,6 L9,6 L9,1 C9,0.4 9.4,0 10,0 L10,0 Z"></path> + </g> + </g> + </g> +</svg> \ No newline at end of file diff --git a/src/assets/icons/list-view.svg b/src/assets/icons/list-view.svg new file mode 100644 index 0000000..ea5bd21 --- /dev/null +++ b/src/assets/icons/list-view.svg @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="UTF-8"?> +<svg width="16px" height="14px" viewBox="0 0 16 14" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> + <g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"> + <g transform="translate(-1279.000000, -124.000000)"> + <g transform="translate(1279.000000, 124.000000)"> + <path fill="#7F7F7F" d="M15,12 C15.5125714,12 15.9354694,12.3862857 15.9932682,12.8834315 L16,13 C16,13.552 15.552,14 15,14 L1,14 C0.487428571,14 0.0645306122,13.6137143 0.00673177843,13.1165685 L0,13 C0,12.448 0.448,12 1,12 L15,12 Z M15,7 C15.5125714,7 15.9354694,7.38628571 15.9932682,7.88343149 L16,8 C16,8.552 15.552,9 15,9 L1,9 C0.487428571,9 0.0645306122,8.61371429 0.00673177843,8.11656851 L0,8 C0,7.448 0.448,7 1,7 L15,7 Z M15,0 C15.552,0 16,0.448 16,1 L16,3 C16,3.552 15.552,4 15,4 L1,4 C0.448,4 0,3.552 0,3 L0,1 C0,0.448 0.448,0 1,0 L15,0 Z"></path> + </g> + </g> + </g> +</svg> \ No newline at end of file diff --git a/src/assets/icons/not-found-recommended.png b/src/assets/icons/not-found-recommended.png new file mode 100644 index 0000000..728a77b Binary files /dev/null and b/src/assets/icons/not-found-recommended.png differ diff --git a/src/components/Button/index.jsx b/src/components/Button/index.jsx index 2d28836..f30e79e 100644 --- a/src/components/Button/index.jsx +++ b/src/components/Button/index.jsx @@ -4,7 +4,7 @@ import PT from "prop-types"; import "./styles.scss"; const Button = ({ children, onClick }) => ( - <button styleName="button" onClick={onClick}> + <button styleName="button" onClick={onClick} type="button"> {children} </button> ); @@ -14,4 +14,17 @@ Button.propTypes = { onClick: PT.func, }; +const ButtonIcon = ({ children, onClick }) => ( + <button styleName="button-icon" onClick={onClick} type="button"> + {children} + </button> +); + +ButtonIcon.propTypes = { + children: PT.node, + onClick: PT.func, +}; + +export { Button, ButtonIcon }; + export default Button; diff --git a/src/components/Button/styles.scss b/src/components/Button/styles.scss index 88f1d74..608f1ec 100644 --- a/src/components/Button/styles.scss +++ b/src/components/Button/styles.scss @@ -22,5 +22,15 @@ background-color: $green; } -.button-lg {} -.button-sm {} +.button-icon { + width: 32px; + height: 32px; + padding: 0; + line-height: 0; + text-align: center; + vertical-align: middle; + appearance: none; + background: none; + border: 0; + border-radius: 50%; +} diff --git a/src/components/Checkbox/index.jsx b/src/components/Checkbox/index.jsx index 10343de..3ad7a08 100644 --- a/src/components/Checkbox/index.jsx +++ b/src/components/Checkbox/index.jsx @@ -1,7 +1,7 @@ /** * Checkbox component. */ -import React, { useRef, useState } from "react"; +import React, { useRef, useState, useEffect } from "react"; import PT from "prop-types"; import _ from "lodash"; import "./styles.scss"; @@ -22,6 +22,10 @@ function Checkbox({ checked, onChange, size, errorMsg }) { _.debounce((q, cb) => cb(q), process.env.GUIKIT.DEBOUNCE_ON_CHANGE_TIME) // eslint-disable-line no-undef ).current; + useEffect(() => { + setCheckedInternal(checked); + }, [checked]); + return ( <label styleName={`container ${sizeStyle}`}> <input diff --git a/src/components/DateRangePicker/DateInput/index.jsx b/src/components/DateRangePicker/DateInput/index.jsx index 83fbd8b..6141ffc 100644 --- a/src/components/DateRangePicker/DateInput/index.jsx +++ b/src/components/DateRangePicker/DateInput/index.jsx @@ -1,15 +1,153 @@ -import React from "react"; +import React, { useRef, useEffect, useState } from "react"; import PT from "prop-types"; import TextInput from "../../TextInput"; +import CalendarIcon from "assets/icons/icon-calendar.svg"; import "./styles.scss"; -const DateInput = ({ value, onClick }) => ( - <div onClick={onClick} styleName="date-range-input"> - <TextInput label="From" size="xs" value={value} readonly /> - </div> -); +const DateInput = ({ + id, + isStartDateActive, + startDateString, + onStartDateChange, + onStartDateFocus, + isEndDateActive, + endDateString, + onEndDateChange, + onEndDateFocus, + error, + onClickCalendarIcon, + onStartEndDateChange, +}) => { + const ref = useRef(null); + const [focused, setFocused] = useState(false); -DateInput.propTypes = {}; + let rangeText; + if (startDateString && endDateString) { + rangeText = `${startDateString} - ${endDateString}`; + } else { + rangeText = `${startDateString}${endDateString}`; + } + + useEffect(() => { + const inputElement = ref.current.querySelector("input"); + const onFocus = () => setFocused(true); + const onBlur = () => setFocused(false); + + inputElement.addEventListener("focus", onFocus); + inputElement.addEventListener("blur", onBlur); + + return () => { + inputElement.removeEventListener("focus", onFocus); + inputElement.removeEventListener("blur", onBlur); + }; + }, []); + + useEffect(() => { + const inputElement = ref.current.querySelector("input"); + + let caretPosition; + if (inputElement.selectionDirection === "forward") { + caretPosition = inputElement.selectionEnd; + } else { + caretPosition = inputElement.selectionStart; + } + + if (caretPosition < 14) { + onStartDateFocus(); + } else { + onEndDateFocus(); + } + }, [focused]); + + const onChangeRangeText = (value) => { + let [newStartDateString = "", newEndDateString = ""] = value + .trim() + .split("-"); + newStartDateString = newStartDateString.trim(); + newEndDateString = newEndDateString.trim(); + + if ( + newStartDateString !== startDateString && + newEndDateString !== endDateString + ) { + const event = { + startDateString: newStartDateString, + endDateString: newEndDateString, + }; + onStartEndDateChange(event); + onStartDateFocus(); + } else if (newStartDateString !== startDateString) { + onStartDateFocus(); + onStartDateChange(newStartDateString); + } else if (newEndDateString !== endDateString) { + onEndDateFocus(); + onEndDateChange(newEndDateString); + if (newEndDateString === "") { + onStartDateFocus(); + } + } + }; + + const onChangeRangeTextDebounced = useRef(_.debounce((f) => f(), 150)); + + const onClickIcon = () => { + const inputElement = ref.current.querySelector("input"); + + let caretPosition; + if (inputElement.selectionDirection === "forward") { + caretPosition = inputElement.selectionEnd; + } else { + caretPosition = inputElement.selectionStart; + } + + if (caretPosition < 14) { + onClickCalendarIcon("start"); + } else { + onClickCalendarIcon("end"); + } + }; + + const label = startDateString ? "From" : endDateString ? "To" : "From"; + + return ( + <div styleName={`container ${error ? "isError" : ""}`}> + <div styleName="date-range-input input-group" ref={ref}> + <TextInput + label={label} + size="xs" + value={rangeText} + onChange={(value) => { + onChangeRangeTextDebounced.current(() => onChangeRangeText(value)); + }} + /> + <div + id={id} + styleName="icon" + role="button" + onClick={onClickIcon} + > + <CalendarIcon /> + </div> + </div> + <div styleName="errorHint">{error}</div> + </div> + ); +}; + +DateInput.propTypes = { + id: PT.string, + isStartDateActive: PT.bool, + startDateString: PT.string, + onStartDateChange: PT.func, + onStartDateFocus: PT.func, + isEndDateActive: PT.bool, + endDateString: PT.string, + onEndDateChange: PT.func, + onEndDateFocus: PT.func, + error: PT.string, + onClickCalendarIcon: PT.func, + onStartEndDateChange: PT.func, +}; export default DateInput; diff --git a/src/components/DateRangePicker/DateInput/styles.scss b/src/components/DateRangePicker/DateInput/styles.scss index b6c4514..7afad43 100644 --- a/src/components/DateRangePicker/DateInput/styles.scss +++ b/src/components/DateRangePicker/DateInput/styles.scss @@ -1,11 +1,44 @@ @import "styles/variables"; +.container { + &.isError { + input { + border: 1px solid $tc-level-5; + } + + .errorHint { + display: block; + color: $tc-level-5; + font-size: 12px; + padding: 4px 0; + height: 20px; + } + } +} + .date-range-input { width: 230px; + margin-top: -12px; font-size: $font-size-sm; +} + +.input-group { + position: relative; + + .icon { + position: absolute; + top: 22px; + right: 14px; + z-index: 1; + display: block; + cursor: pointer; + } input { - color: $body-color !important; - border-color: $tc-gray-30 !important; + padding-right: 46px !important; } } + +.errorHint { + display: none; +} diff --git a/src/components/DateRangePicker/helpers.js b/src/components/DateRangePicker/helpers.js index cc6dab1..cef8582 100644 --- a/src/components/DateRangePicker/helpers.js +++ b/src/components/DateRangePicker/helpers.js @@ -50,11 +50,11 @@ const staticRangeHandler = { * @return {object[]} list of defined ranges */ export function createStaticRanges() { - const now = moment(); - const pastWeek = moment().subtract(1, "week"); - const pastMonth = moment().subtract(1, "month"); - const past6Months = moment().subtract(6, "month"); - const pastYear = moment().subtract(1, "year"); + 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 ranges = [ { diff --git a/src/components/DateRangePicker/index.jsx b/src/components/DateRangePicker/index.jsx index b838904..4e8fe55 100644 --- a/src/components/DateRangePicker/index.jsx +++ b/src/components/DateRangePicker/index.jsx @@ -16,13 +16,7 @@ import { } from "./helpers"; function DateRangePicker(props) { - const { - readOnly, - startDatePlaceholder, - endDatePlaceholder, - range, - onChange, - } = props; + const { id, range, onChange } = props; const [rangeString, setRangeString] = useState({ startDateString: "", @@ -45,15 +39,33 @@ function DateRangePicker(props) { const isStartDateFocused = focusedRange[1] === 0; const isEndDateFocused = focusedRange[1] === 1; + useEffect(() => { + setRangeString({ + startDateString: range.startDate + ? moment(range.startDate).format("MMM D, YYYY") + : "", + endDateString: range.endDate + ? moment(range.endDate).format("MMM D, YYYY") + : "", + }); + }, [range]); + /** * Handle end date change on user input * After user input the end date via keyboard, validate it then update the range state * @param {Object} e Input Event. */ - const onEndDateChange = (e) => { - const endDateString = e.target.value; - const endDate = moment(endDateString, "MM/DD/YYYY", true); - if (endDate.isValid()) { + const onEndDateChange = (value) => { + const endDateString = value; + const endDate = moment(endDateString, "MMM D, YYYY", true); + const startDate = moment(rangeString.startDateString, "MMM D, YYYY", true); + + if (endDate.isValid() && isBeforeDay(endDate, startDate)) { + setErrors({ + ...errors, + endDate: "Range Error", + }); + } else if (endDate.isValid()) { onChange({ endDate: endDate.toDate(), startDate: range.startDate, @@ -66,15 +78,23 @@ function DateRangePicker(props) { setRangeString({ ...rangeString, - endDateString: endDate.format("MM/DD/YYYY"), + endDateString: endDate.format("MMM D, YYYY"), + }); + } else if (endDateString === "") { + onChange({ + endDate: null, + startDate: range.startDate, + }); + + setErrors({ + ...errors, + endDate: "", }); } else { - if (endDateString && endDateString !== "mm/dd/yyyy") { - setErrors({ - ...errors, - endDate: "Invalid Format", - }); - } + setErrors({ + ...errors, + endDate: "Invalid End Date Format", + }); setRangeString({ ...rangeString, @@ -88,10 +108,21 @@ function DateRangePicker(props) { * After user input the start date via keyboard, validate it then update the range state * @param {Object} e Input Event. */ - const onStartDateChange = (e) => { - const startDateString = e.target.value; - const startDate = moment(startDateString, "MM/DD/YYYY", true); - if (startDate.isValid()) { + const onStartDateChange = (value) => { + const startDateString = value; + const startDate = moment(startDateString, "MMM D, YYYY", true); + const endDate = moment(rangeString.endDateString, "MMM D, YYYY", true); + + if ( + startDate.isValid() && + endDate.isValid() && + isAfterDay(startDate, endDate) + ) { + setErrors({ + ...errors, + startDate: "Range Error", + }); + } else if (startDate.isValid()) { onChange({ endDate: range.endDate, startDate: startDate.toDate(), @@ -104,15 +135,23 @@ function DateRangePicker(props) { setRangeString({ ...rangeString, - startDateString: startDate.format("MM/DD/YYYY"), + startDateString: startDate.format("MMM D, YYYY"), + }); + } else if (startDateString === "") { + onChange({ + endDate: range.endDate, + startDate: null, + }); + + setErrors({ + ...errors, + startDate: "", }); } else { - if (startDateString && startDateString !== "mm/dd/yyyy") { - setErrors({ - ...errors, - startDate: "Invalid Format", - }); - } + setErrors({ + ...errors, + startDate: "Invalid Start Date Format", + }); setRangeString({ ...rangeString, @@ -121,13 +160,92 @@ function DateRangePicker(props) { } }; + const onStartEndDateChange = ({ startDateString, endDateString }) => { + const startDate = moment(startDateString, "MMM D, YYYY", true); + const endDate = moment(endDateString, "MMM D, YYYY", true); + + if ( + startDate.isValid() && + endDate.isValid() && + isBeforeDay(endDate, startDate) + ) { + setErrors({ + ...errors, + endDate: "Range Error", + }); + } else if (startDate.isValid() && endDate.isValid()) { + onChange({ + endDate: endDate.toDate(), + startDate: startDate.toDate(), + }); + setErrors({ + startDate: "", + endDate: "", + }); + } else if (startDate.isValid()) { + onChange({ + endDate: null, + startDate: startDate.toDate(), + }); + setErrors({ + ...errors, + endDate: "Invalid End Date Format", + }); + } else if (endDate.isValid()) { + onChange({ + endDate: endDate.toDate(), + startDate: null, + }); + setErrors({ + ...errors, + startDate: "Invalid Start Date Format", + }); + } else if (startDateString === "" && endDateString === "") { + onChange({ + endDate: null, + startDate: null, + }); + setErrors({ + startDate: "", + endDate: "", + }); + } else if (startDateString === "") { + onChange({ + endDate: endDate.toDate(), + startDate: null, + }); + + setErrors({ + ...errors, + startDate: "", + }); + } else if (endDateString === "") { + onChange({ + endDate: null, + startDate: startDate.toDate(), + }); + + setErrors({ + ...errors, + endDate: "", + }); + } else { + onChange({ + endDate: null, + startDate: null, + }); + setErrors({ + startDate: "Invalid Start Date Format", + endDate: "Invalid End Date Format", + }); + } + }; + /** * Trigger to open calendar modal on calendar icon in start date input */ const onIconClickStartDate = () => { - const calendarIcon = document.querySelector( - "#input-start-date-range-calendar-icon" - ); + const calendarIcon = document.querySelector(id); if (calendarIcon) { calendarIcon.blur(); } @@ -141,9 +259,7 @@ function DateRangePicker(props) { * Trigger to open calendar modal on calendar icon in end date input */ const onIconClickEndDate = () => { - const calendarIcon = document.querySelector( - "#input-end-date-range-calendar-icon" - ); + const calendarIcon = document.querySelector(id); if (calendarIcon) { calendarIcon.blur(); } @@ -205,9 +321,9 @@ function DateRangePicker(props) { setRangeString({ startDateString: newStartDate - ? moment(newStartDate).format("MM/DD/YYYY") + ? moment(newStartDate).format("MMM D, YYYY") : "", - endDateString: newEndDate ? moment(newEndDate).format("MM/DD/YYYY") : "", + endDateString: newEndDate ? moment(newEndDate).format("MMM D, YYYY") : "", }); onChange({ @@ -405,22 +521,24 @@ function DateRangePicker(props) { ${(errors.startDate || errors.endDate) && styles.isErrorInput} `; - let rangeText; - if (rangeString.startDateString && rangeString.endDateString) { - rangeText = `${rangeString.startDateString} - ${rangeString.endDateString}`; - } else { - rangeText = `${rangeString.startDateString}${rangeString.endDateString}`; - } - return ( <div styleName="dateRangePicker" className={className}> <div styleName="dateInputWrapper"> <DateInput - onClick={() => { - onIconClickStartDate(); - setFocusedRange([0, 0]); + id={id} + isStartDateActive={focusedRange[1] === 0 && isComponentVisible} + startDateString={rangeString.startDateString} + onStartDateChange={onStartDateChange} + onStartDateFocus={() => setFocusedRange([0, 0])} + isEndDateActive={focusedRange[1] === 1 && isComponentVisible} + endDateString={rangeString.endDateString} + onEndDateChange={onEndDateChange} + onEndDateFocus={() => setFocusedRange([0, 1])} + error={errors.startDate || errors.endDate} + onClickCalendarIcon={(event) => { + event === "start" ? onIconClickStartDate() : onIconClickEndDate(); }} - value={rangeText} + onStartEndDateChange={onStartEndDateChange} /> </div> <div ref={calendarRef}> @@ -453,18 +571,14 @@ function DateRangePicker(props) { // It use https://www.npmjs.com/package/react-date-range internally // Check the docs for further options +DateRangePicker.defaultProps = { + id: "input-date-range-calendar-icon" +} + DateRangePicker.propTypes = { - readOnly: PropTypes.bool, - startDatePlaceholder: PropTypes.string, - endDatePlaceholder: PropTypes.string, + id: PropTypes.string, range: PropTypes.object.isRequired, onChange: PropTypes.func.isRequired, }; -DateRangePicker.defaultProps = { - readOnly: false, - startDatePlaceholder: "Start Date", - endDatePlaceholder: "End Date", -}; - export default DateRangePicker; diff --git a/src/components/DateRangePicker/style.scss b/src/components/DateRangePicker/style.scss index 4f863ce..8c36d00 100644 --- a/src/components/DateRangePicker/style.scss +++ b/src/components/DateRangePicker/style.scss @@ -166,7 +166,21 @@ } .rdrMonthAndYearPickers select { - background: url("data:image/svg+xml;utf8,<svg width='9px' height='6px' viewBox='0 0 9 6' version='1.1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink'><g id='Artboard' stroke='none' stroke-width='1' fill='none' fill-rule='evenodd' transform='translate(-636.000000, -171.000000)'><g id='input' transform='translate(172.000000, 37.000000)' fill='%230B71E6' fill-rule='nonzero'><g id='Group-9' transform='translate(323.000000, 127.000000)'><path d='M142.280245,7.23952813 C141.987305,6.92353472 141.512432,6.92361662 141.219585,7.23971106 C140.926739,7.5558055 140.926815,8.06821394 141.219755,8.38420735 L145.498801,13 L149.780245,8.38162071 C150.073185,8.0656273 150.073261,7.55321886 149.780415,7.23712442 C149.487568,6.92102998 149.012695,6.92094808 148.719755,7.23694149 L145.498801,10.7113732 L142.280245,7.23952813 Z' id='arrow'></path></g></g></g></svg>") no-repeat right 8px center; + background: url("data:image/svg+xml;utf8,<svg width='9px' height='6px' viewBox='0 0 9 6' version='1.1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink'><g id='Artboard' stroke='none' stroke-width='1' fill='none' fill-rule='evenodd' transform='translate(-636.000000, -171.000000)'><g id='input' transform='translate(172.000000, 37.000000)' fill='%23137D60' fill-rule='nonzero'><g id='Group-9' transform='translate(323.000000, 127.000000)'><path d='M142.280245,7.23952813 C141.987305,6.92353472 141.512432,6.92361662 141.219585,7.23971106 C140.926739,7.5558055 140.926815,8.06821394 141.219755,8.38420735 L145.498801,13 L149.780245,8.38162071 C150.073185,8.0656273 150.073261,7.55321886 149.780415,7.23712442 C149.487568,6.92102998 149.012695,6.92094808 148.719755,7.23694149 L145.498801,10.7113732 L142.280245,7.23952813 Z' id='arrow'></path></g></g></g></svg>") no-repeat right 8px center; + + option { + background: $white; + + &:checked { + font-weight: bold; + color: $white; + background-color: $green; + } + } + } + + rdrMonthAndYearPickers option:hover { + background-color: yellow !important; } .rdrMonths { @@ -336,6 +350,11 @@ z-index: 0; } } + + .rdrDayNumber { + top: 0; + bottom: 0; + } } .rdrDayStartOfWeek, diff --git a/src/components/DropdownTerms/index.jsx b/src/components/DropdownTerms/index.jsx index b2fa746..a90e716 100644 --- a/src/components/DropdownTerms/index.jsx +++ b/src/components/DropdownTerms/index.jsx @@ -16,6 +16,7 @@ function DropdownTerms({ onChange, errorMsg, addNewOptionPlaceholder, + size, }) { const [internalTerms, setInternalTerms] = useState(terms); const selectedOption = _.filter(internalTerms, { selected: true }).map( @@ -87,7 +88,9 @@ function DropdownTerms({ selectedOption && !!selectedOption.length ? "haveValue" : "" } ${errorMsg ? "haveError" : ""} ${ _.every(internalTerms, { selected: true }) ? "isEmptySelectList" : "" - } ${focused ? "isFocused" : ""}`} + } ${focused ? "isFocused" : ""} ${ + size === "lg" ? "term-lgSize" : "term-xsSize" + }`} > <div styleName="relative"> <Creatable @@ -179,6 +182,7 @@ DropdownTerms.defaultProps = { onChange: () => {}, errorMsg: "", addNewOptionPlaceholder: "", + size: "lg", }; DropdownTerms.propTypes = { @@ -194,6 +198,7 @@ DropdownTerms.propTypes = { onChange: PT.func, errorMsg: PT.string, addNewOptionPlaceholder: PT.string, + size: PT.oneOf(["xs", "lg"]), }; export default DropdownTerms; diff --git a/src/components/DropdownTerms/styles.scss b/src/components/DropdownTerms/styles.scss index c8bdae4..773068a 100644 --- a/src/components/DropdownTerms/styles.scss +++ b/src/components/DropdownTerms/styles.scss @@ -85,7 +85,7 @@ left: 0; right: 0; background: transparent; - height: 100% !important; + min-height: 100% !important; z-index: 5; margin: 0 !important; cursor: pointer; @@ -153,6 +153,39 @@ } } } + + &.term-lgSize { + } + + &.term-xsSize { + :global { + .Select-control { + min-height: 40px; + } + + .Select-placeholder { + font-size: 14px; + } + + .Select--multi .Select-multi-value-wrapper { + max-height: 90px; + } + + .Select.is-open .Select-multi-value-wrapper { + overflow: hidden; + } + + .Select--multi .Select-value { + line-height: 38px; + margin-top: 10px; + font-size: 14px; + } + } + + .errorMessage { + @include errorMessageXs; + } + } } .addAnotherSkill { diff --git a/src/components/Menu/index.jsx b/src/components/Menu/index.jsx index 261604b..328788f 100644 --- a/src/components/Menu/index.jsx +++ b/src/components/Menu/index.jsx @@ -31,6 +31,7 @@ const Menu = ({ menu, icons, selected, onSelect }) => { if (!submenu[key]) { return null; } + const subSubmenu = submenu[key]; return ( <ul styleName="sub-submenu"> @@ -52,6 +53,7 @@ const Menu = ({ menu, icons, selected, onSelect }) => { if (!menu[key]) { return null; } + const subMenu = menu[key]; return ( <ul styleName="sub-menu"> diff --git a/src/components/Menu/styles.scss b/src/components/Menu/styles.scss index 46acfb1..679db70 100644 --- a/src/components/Menu/styles.scss +++ b/src/components/Menu/styles.scss @@ -5,16 +5,16 @@ $menu-padding-y: 20px; .menu { padding: $menu-padding-y $menu-padding-x (3 * $base-unit); - line-height: 21px; } .menu-item { - padding: 7px 0; + padding: 4px 0; cursor: pointer; a { display: flex; align-items: center; + line-height: 26px; } .icon { @@ -45,10 +45,21 @@ $menu-padding-y: 20px; } &.selected > a { - color: $green; + color: $lightGreen; } } +.menu-item-main > a { + margin-left: -20px; + margin-right: -20px; + padding-left: 20px; + padding-right: 20px; +} + +.menu-item-main.active > a { + box-shadow: inset 4px 0 #06D6A0; +} + .menu-item-main > a + ul, .menu-item-secondary > a + ul { display: none; @@ -60,7 +71,7 @@ $menu-padding-y: 20px; } .menu-item-secondary.active.collapsed { - color: $green; + color: $lightGreen; } .sub-menu { diff --git a/src/components/Pagination/index.jsx b/src/components/Pagination/index.jsx index aceda01..de245ef 100644 --- a/src/components/Pagination/index.jsx +++ b/src/components/Pagination/index.jsx @@ -39,7 +39,7 @@ const Pagination = ({ length, pageIndex, pageSize, onChange }) => { const onChangePageSize = (options) => { const selectedOption = utils.getSelectedDropdownOption(options); const newPageSize = +selectedOption.label; - onChange({ pageIndex, pageSize: newPageSize }); + onChange({ pageIndex: 0, pageSize: newPageSize }); }; const onChangePageIndex = (newPageIndex) => { @@ -106,7 +106,11 @@ const Pagination = ({ length, pageIndex, pageSize, onChange }) => { </button> </li> ))} - <li styleName={`page next ${pageIndex === total - 1 ? "hidden" : ""}`}> + <li + styleName={`page next ${ + pageIndex === total - 1 || length === 0 ? "hidden" : "" + }`} + > <button onClick={next}>NEXT</button> </li> </ul> diff --git a/src/components/Pagination/styles.scss b/src/components/Pagination/styles.scss index edbcfa6..c7401fd 100644 --- a/src/components/Pagination/styles.scss +++ b/src/components/Pagination/styles.scss @@ -12,8 +12,7 @@ :global { .Select-value-label::after { - content: 'per page'; - margin-left: 0.15em; + content: ' per page'; } } } diff --git a/src/components/Toggle/index.jsx b/src/components/Toggle/index.jsx index 61dd77a..a4fd9f5 100644 --- a/src/components/Toggle/index.jsx +++ b/src/components/Toggle/index.jsx @@ -1,7 +1,7 @@ /** * Toggles component. */ -import React, { useRef, useState } from "react"; +import React, { useRef, useState, useEffect } from "react"; import PT from "prop-types"; import _ from "lodash"; import "./style.scss"; @@ -17,6 +17,10 @@ function Toggles({ checked, onChange, size }) { sizeStyle = size === "xs" ? "xsSize" : "smSize"; } + useEffect(() => { + setInternalChecked(checked); + }, [checked]); + return ( <label styleName={`container ${sizeStyle}`}> <input diff --git a/src/constants/index.js b/src/constants/index.js index a30d9bf..5115e6b 100644 --- a/src/constants/index.js +++ b/src/constants/index.js @@ -25,7 +25,7 @@ export const NAV_MENU_ICONS = { export const FILTER_BUCKETS = [ "All Active Challenges", "Open for Registration", - "Past Challenges", + "Closed Challenges", ]; export const FILTER_CHALLENGE_TYPES = ["Challenge", "First2Finish", "Task"]; @@ -40,14 +40,14 @@ export const FILTER_CHALLENGE_TRACKS = [ "Design", "Development", "Data Science", - "Quality Assurance", + "QA", ]; export const FILTER_CHALLENGE_TRACK_ABBREVIATIONS = { Design: "DES", Development: "DEV", "Data Science": "DS", - "Quality Assurance": "QA", + QA: "QA", }; export const CHALLENGE_SORT_BY = { @@ -57,6 +57,15 @@ export const CHALLENGE_SORT_BY = { Title: "name", }; +export const CHALLENGE_SORT_BY_RECOMMENDED = "bestMatch"; +export const CHALLENGE_SORT_BY_RECOMMENDED_LABEL = "Best Match"; +export const CHALLENGE_SORT_BY_DEFAULT = "updated"; + +export const SORT_ORDER = { + DESC: "desc", + ASC: "asc", +}; + export const TRACK_COLOR = { Design: "#2984BD", Development: "#35AC35", diff --git a/src/containers/Challenges/Listing/ChallengeError/index.jsx b/src/containers/Challenges/Listing/ChallengeError/index.jsx index f0b62c8..31611a1 100644 --- a/src/containers/Challenges/Listing/ChallengeError/index.jsx +++ b/src/containers/Challenges/Listing/ChallengeError/index.jsx @@ -1,6 +1,5 @@ import React from "react"; import IconNotFound from "assets/icons/not-found.png"; - import "./styles.scss"; const ChallengeError = () => ( @@ -9,7 +8,7 @@ const ChallengeError = () => ( <img src={IconNotFound} alt="not found" /> </h1> <p> - No challenges were found. You can try changing your search perimeters. + No challenges were found. You can try changing your search parameters. </p> </div> ); diff --git a/src/containers/Challenges/Listing/ChallengeItem/NumRegistrants/styles.scss b/src/containers/Challenges/Listing/ChallengeItem/NumRegistrants/styles.scss index 376bd0d..98627d8 100644 --- a/src/containers/Challenges/Listing/ChallengeItem/NumRegistrants/styles.scss +++ b/src/containers/Challenges/Listing/ChallengeItem/NumRegistrants/styles.scss @@ -9,7 +9,7 @@ display: inline-block; width: 14px; height: 16px; - margin-right: 7px; + margin-right: 14px; vertical-align: middle; } } diff --git a/src/containers/Challenges/Listing/ChallengeItem/NumSubmissions/styles.scss b/src/containers/Challenges/Listing/ChallengeItem/NumSubmissions/styles.scss index 62bd69f..95eda50 100644 --- a/src/containers/Challenges/Listing/ChallengeItem/NumSubmissions/styles.scss +++ b/src/containers/Challenges/Listing/ChallengeItem/NumSubmissions/styles.scss @@ -9,7 +9,7 @@ display: inline-block; width: 14px; height: 16px; - margin-right: 7px; + margin-right: 14px; vertical-align: middle; } } diff --git a/src/containers/Challenges/Listing/ChallengeItem/PhaseEndDate/index.jsx b/src/containers/Challenges/Listing/ChallengeItem/PhaseEndDate/index.jsx index ef1a959..6bb02ef 100644 --- a/src/containers/Challenges/Listing/ChallengeItem/PhaseEndDate/index.jsx +++ b/src/containers/Challenges/Listing/ChallengeItem/PhaseEndDate/index.jsx @@ -15,7 +15,13 @@ const PhaseEndDate = ({ challenge }) => { ); const timeLeftColor = timeLeft.time < 12 * 60 * 60 * 1000 ? "#EF476F" : ""; const timeLeftMessage = timeLeft.late ? ( - <span>{`Late by ${timeLeft.text}`}</span> + <span> + Late by + <span + style={{ color: "#EF476F" }} + styleName="uppercase" + >{` ${timeLeft.text}`}</span> + </span> ) : ( <span style={{ color: timeLeftColor }}> <span @@ -29,7 +35,7 @@ const PhaseEndDate = ({ challenge }) => { <span> <span styleName="phase-message"> {`${phaseMessage}`} {`${status}`}: - </span> + </span>{" "} <span styleName="time-left">{timeLeftMessage}</span> </span> ); diff --git a/src/containers/Challenges/Listing/ChallengeItem/Prize/styles.scss b/src/containers/Challenges/Listing/ChallengeItem/Prize/styles.scss index b558dd7..5b64e44 100644 --- a/src/containers/Challenges/Listing/ChallengeItem/Prize/styles.scss +++ b/src/containers/Challenges/Listing/ChallengeItem/Prize/styles.scss @@ -17,4 +17,8 @@ font-size: 24px; line-height: 26px; + + &::first-letter { + margin-right: -0.125em; + } } diff --git a/src/containers/Challenges/Listing/ChallengeItem/Tags/index.jsx b/src/containers/Challenges/Listing/ChallengeItem/Tags/index.jsx index 889e82f..58dfc8c 100644 --- a/src/containers/Challenges/Listing/ChallengeItem/Tags/index.jsx +++ b/src/containers/Challenges/Listing/ChallengeItem/Tags/index.jsx @@ -23,6 +23,10 @@ const Tags = ({ tags, onClickTag }) => { ); }; +Tags.defaultProps = { + tags: [], +}; + Tags.propTypes = { tags: PT.arrayOf(PT.string), onClickTag: PT.func, diff --git a/src/containers/Challenges/Listing/ChallengeItem/TrackIcon/styles.scss b/src/containers/Challenges/Listing/ChallengeItem/TrackIcon/styles.scss index 3ab927c..5135e80 100644 --- a/src/containers/Challenges/Listing/ChallengeItem/TrackIcon/styles.scss +++ b/src/containers/Challenges/Listing/ChallengeItem/TrackIcon/styles.scss @@ -2,6 +2,8 @@ .track-icon { position: relative; display: inline-block; + width: 42px; + height: 46px; vertical-align: middle; line-height: 1; diff --git a/src/containers/Challenges/Listing/ChallengeItem/index.jsx b/src/containers/Challenges/Listing/ChallengeItem/index.jsx index a5325a0..9a2f8bb 100644 --- a/src/containers/Challenges/Listing/ChallengeItem/index.jsx +++ b/src/containers/Challenges/Listing/ChallengeItem/index.jsx @@ -15,7 +15,7 @@ const ChallengeItem = ({ challenge, onClickTag, onClickTrack }) => { let purse = challenge.prizeSets ? utils.challenge.getChallengePurse(challenge.prizeSets) : ""; - purse = purse && utils.formatMoneyValue(purse); + purse = typeof purse === "number" && utils.formatMoneyValue(purse); return ( <div styleName="challenge-item"> diff --git a/src/containers/Challenges/Listing/ChallengeItem/styles.scss b/src/containers/Challenges/Listing/ChallengeItem/styles.scss index 693f532..bc53fbc 100644 --- a/src/containers/Challenges/Listing/ChallengeItem/styles.scss +++ b/src/containers/Challenges/Listing/ChallengeItem/styles.scss @@ -1,4 +1,5 @@ @import "styles/variables"; +@import "styles/mixins"; .challenge-item { display: flex; @@ -35,8 +36,14 @@ } .tags { - max-width: calc(50% - 32px); + max-width: calc(50% - 84px); + min-width: calc(50% - 84px); flex: 1 1 auto; + + @media (min-width: $screen-xxl + 1px) { + min-width: 294px; + max-width: 25%; + } } .nums { @@ -44,7 +51,15 @@ white-space: nowrap; > * { - margin: 0 16px; + margin: 0 20px; + + &:first-child { + margin-left: 0; + } + + &:last-child { + margin-right: 0; + } } } } diff --git a/src/containers/Challenges/Listing/ChallengeRecommendedError/index.jsx b/src/containers/Challenges/Listing/ChallengeRecommendedError/index.jsx new file mode 100644 index 0000000..bc7fa3e --- /dev/null +++ b/src/containers/Challenges/Listing/ChallengeRecommendedError/index.jsx @@ -0,0 +1,18 @@ +import React from "react"; +import IconNotFound from "assets/icons/not-found-recommended.png"; +import "./styles.scss"; + +const ChallengeRecommendedError = () => ( + <div styleName="challenge-recommended-error"> + <h1> + <img src={IconNotFound} alt="not found" /> + </h1> + <p> + Looks like there are no <strong>Recommended Challenges</strong> that best + match your skills at this point. But you can try to join other challenges + that work for you. + </p> + </div> +); + +export default ChallengeRecommendedError; diff --git a/src/containers/Challenges/Listing/ChallengeRecommendedError/styles.scss b/src/containers/Challenges/Listing/ChallengeRecommendedError/styles.scss new file mode 100644 index 0000000..a1efc68 --- /dev/null +++ b/src/containers/Challenges/Listing/ChallengeRecommendedError/styles.scss @@ -0,0 +1,24 @@ +@import "styles/variables"; + +.challenge-recommended-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; + } + + p { + margin-bottom: 20px; + } + + strong { + font-weight: bold; + } +} diff --git a/src/containers/Challenges/Listing/index.jsx b/src/containers/Challenges/Listing/index.jsx index 7c18893..eb4ec76 100644 --- a/src/containers/Challenges/Listing/index.jsx +++ b/src/containers/Challenges/Listing/index.jsx @@ -16,6 +16,7 @@ import "./styles.scss"; const Listing = ({ challenges, + search, page, perPage, sortBy, @@ -25,9 +26,10 @@ const Listing = ({ updateFilter, bucket, getChallenges, + challengeSortBys, }) => { const sortByOptions = utils.createDropdownOptions( - Object.keys(constants.CHALLENGE_SORT_BY), + challengeSortBys, utils.getSortByLabel(constants.CHALLENGE_SORT_BY, sortBy) ); @@ -42,6 +44,7 @@ const Listing = ({ <img src={IconSearch} alt="search" /> </span> <TextInput + value={search} placeholder="Search for challenges" size="xs" onChange={(value) => { @@ -64,11 +67,11 @@ const Listing = ({ options={sortByOptions} size="xs" onChange={(newSortByOptions) => { - const selectOption = utils.getSelectedDropdownOption( + const selectedOption = utils.getSelectedDropdownOption( newSortByOptions ); const filterChange = { - sortBy: constants.CHALLENGE_SORT_BY[selectOption.label], + sortBy: constants.CHALLENGE_SORT_BY[selectedOption.label], }; updateFilter(filterChange); getChallenges(filterChange); @@ -140,6 +143,7 @@ const Listing = ({ Listing.propTypes = { challenges: PT.arrayOf(PT.shape()), + search: PT.string, page: PT.number, perPage: PT.number, sortBy: PT.string, @@ -149,6 +153,7 @@ Listing.propTypes = { getChallenges: PT.func, updateFilter: PT.func, bucket: PT.string, + challengeSortBys: PT.arrayOf(PT.string), }; export default Listing; diff --git a/src/containers/Challenges/index.jsx b/src/containers/Challenges/index.jsx index 9a890c0..9ff80e3 100644 --- a/src/containers/Challenges/index.jsx +++ b/src/containers/Challenges/index.jsx @@ -4,10 +4,17 @@ import { connect } from "react-redux"; import Listing from "./Listing"; import actions from "../../actions"; import ChallengeError from "./Listing/ChallengeError"; +import ChallengeRecommendedError from "./Listing/ChallengeRecommendedError"; +import IconListView from "../../assets/icons/list-view.svg"; +import IconCardView from "../../assets/icons/card-view.svg"; +import { ButtonIcon } from "../../components/Button"; +import * as constants from "../../constants"; + import "./styles.scss"; const Challenges = ({ challenges, + search, page, perPage, sortBy, @@ -17,6 +24,8 @@ const Challenges = ({ getChallenges, updateFilter, bucket, + recommended, + loadingRecommendedChallengesError, }) => { const [initialized, setInitialized] = useState(false); @@ -24,23 +33,56 @@ const Challenges = ({ getChallenges().finally(() => setInitialized(true)); }, []); + const BUCKET_OPEN_FOR_REGISTRATION = constants.FILTER_BUCKETS[1]; + const isRecommended = recommended && bucket === BUCKET_OPEN_FOR_REGISTRATION; + const sortByValue = isRecommended + ? sortBy + : sortBy === constants.CHALLENGE_SORT_BY_RECOMMENDED + ? constants.CHALLENGE_SORT_BY_DEFAULT + : sortBy; + const sortByLabels = isRecommended + ? Object.keys(constants.CHALLENGE_SORT_BY) + : Object.keys(constants.CHALLENGE_SORT_BY).filter( + (label) => label !== constants.CHALLENGE_SORT_BY_RECOMMENDED_LABEL + ); + + const isNoRecommendedChallenges = + bucket === BUCKET_OPEN_FOR_REGISTRATION && + recommended && + loadingRecommendedChallengesError; + return ( <div styleName="page"> - <h1 styleName="title">CHALLENGES</h1> - {challenges.length === 0 ? ( - initialized && <ChallengeError /> - ) : ( + <h1 styleName="title"> + <span>CHALLENGES</span> + <span styleName="view-mode"> + <ButtonIcon> + <IconListView /> + </ButtonIcon> + <ButtonIcon> + <IconCardView /> + </ButtonIcon> + </span> + </h1> + + {isNoRecommendedChallenges && initialized && ( + <ChallengeRecommendedError /> + )} + {challenges.length === 0 && initialized && <ChallengeError />} + {challenges.length > 0 && ( <Listing challenges={challenges} + search={search} page={page} perPage={perPage} - sortBy={sortBy} + sortBy={sortByValue} total={total} endDateStart={endDateStart} startDateEnd={startDateEnd} updateFilter={updateFilter} bucket={bucket} getChallenges={getChallenges} + challengeSortBys={sortByLabels} /> )} </div> @@ -49,6 +91,7 @@ const Challenges = ({ Challenges.propTypes = { challenges: PT.arrayOf(PT.shape()), + search: PT.string, page: PT.number, perPage: PT.number, sortBy: PT.string, @@ -58,18 +101,24 @@ Challenges.propTypes = { getChallenges: PT.func, updateFilter: PT.func, bucket: PT.string, + recommended: PT.bool, + loadingRecommendedChallengesError: PT.bool, }; const mapStateToProps = (state) => ({ state: state, + search: state.filter.challenge.search, page: state.filter.challenge.page, perPage: state.filter.challenge.perPage, sortBy: state.filter.challenge.sortBy, total: state.challenges.total, endDateStart: state.filter.challenge.endDateStart, startDateEnd: state.filter.challenge.startDateEnd, - challenges: state.challenges.challengesFiltered, + challenges: state.challenges.challenges, bucket: state.filter.challenge.bucket, + recommended: state.filter.challenge.recommended, + loadingRecommendedChallengesError: + state.challenges.loadingRecommendedChallengesError, }); const mapDispatchToProps = { diff --git a/src/containers/Challenges/styles.scss b/src/containers/Challenges/styles.scss index b43a724..046abe5 100644 --- a/src/containers/Challenges/styles.scss +++ b/src/containers/Challenges/styles.scss @@ -9,3 +9,11 @@ @include barlow-condensed-medium; margin-bottom: 22px; } + +.view-mode { + float: right; + + > * { + margin: 0 2px; + } +} diff --git a/src/containers/Filter/ChallengeFilter/index.jsx b/src/containers/Filter/ChallengeFilter/index.jsx index 0575912..7764915 100644 --- a/src/containers/Filter/ChallengeFilter/index.jsx +++ b/src/containers/Filter/ChallengeFilter/index.jsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useState, useEffect, useRef } from "react"; import PT from "prop-types"; import _ from "lodash"; import RadioButton from "../../../components/RadioButton"; @@ -8,6 +8,7 @@ import Toggle from "../../../components/Toggle"; import Button from "../../../components/Button"; import TextInput from "../../../components/TextInput"; import * as utils from "../../../utils"; +import * as constants from "../../../constants"; import "./styles.scss"; @@ -18,6 +19,7 @@ const ChallengeFilter = ({ tags, prizeFrom, prizeTo, + recommended, subCommunities, challengeBuckets, challengeTypes, @@ -26,7 +28,10 @@ const ChallengeFilter = ({ challengeSubCommunities, saveFilter, clearFilter, + switchBucket, + openForRegistrationCount, }) => { + const BUCKET_OPEN_FOR_REGISTRATION = constants.FILTER_BUCKETS[1]; const tagOptions = utils.createDropdownTermOptions(challengeTags); const bucketOptions = utils.createRadioOptions(challengeBuckets, bucket); @@ -39,26 +44,66 @@ const ChallengeFilter = ({ prizeFrom, prizeTo, subCommunities, + recommended, }) ); utils.setSelectedDropdownTermOptions(tagOptions, filter.tags); + useEffect(() => { + const newFilter = _.cloneDeep({ + bucket, + types, + tracks, + tags, + prizeFrom, + prizeTo, + subCommunities, + recommended, + }); + setFilter(newFilter); + }, [ + bucket, + types, + tracks, + tags, + prizeFrom, + prizeTo, + subCommunities, + recommended, + ]); + + const ref = useRef(null); + + useEffect(() => { + if (!ref.current) { + return; + } + + const openForRegistrationElement = ref.current.children[0].children[1]; + const badgeElement = utils.icon.createBadgeElement( + openForRegistrationElement, + `${openForRegistrationCount}` + ); + + return () => { + badgeElement.parentElement.removeChild(badgeElement); + }; + }, [ref.current, openForRegistrationCount]); + return ( <div styleName="filter"> - <div styleName="buckets vertical-list"> + <div styleName="buckets vertical-list" ref={ref}> <RadioButton options={bucketOptions} onChange={(newBucketOptions) => { - const filterChange = { - ...filter, - bucket: utils.getSelectedRadioOption(newBucketOptions).label, - }; - setFilter(filterChange); - saveFilter(filterChange); + const selectedBucket = utils.getSelectedRadioOption( + newBucketOptions + ).label; + setFilter({ ...filter, bucket: selectedBucket }); + switchBucket(selectedBucket); }} /> - <span></span> </div> <div styleName="challenge-types"> @@ -115,14 +160,43 @@ const ChallengeFilter = ({ tags: selectedTagOptions.map((tagOption) => tagOption.label), }); }} + size="xs" /> </div> <div styleName="prize"> <h3>Prize Amount</h3> - <TextInput size="xs" label="From" value={`${prizeFrom}`} /> + <div styleName="input-group"> + <TextInput + value={filter.prizeFrom} + size="xs" + label="From" + value={`${utils.formatPrizeAmount(prizeFrom)}`} + onChange={(value) => { + setFilter({ + ...filter, + prizeFrom: utils.parsePrizeAmountText(value), + }); + }} + /> + <span styleName="suffix">USD</span> + </div> <span styleName="separator" /> - <TextInput size="xs" label="To" value={`${prizeTo}`} /> + <div styleName="input-group"> + <TextInput + value={filter.prizeTo} + size="xs" + label="To" + value={`${utils.formatPrizeAmount(prizeTo)}`} + onChange={(value) => { + setFilter({ + ...filter, + prizeTo: utils.parsePrizeAmountText(value), + }); + }} + /> + <span styleName="suffix">USD</span> + </div> </div> {challengeSubCommunities.length > 0 && ( @@ -145,12 +219,26 @@ const ChallengeFilter = ({ </div> </div> )} - <div styleName="recommended-challenges"> - <span styleName="toggle"> - <Toggle /> - </span> - <span>Recommended Challenges</span> - </div> + + {bucket === BUCKET_OPEN_FOR_REGISTRATION && ( + <div styleName="recommended-challenges"> + <span styleName="toggle"> + <Toggle + checked={filter.recommended} + onChange={(checked) => { + setFilter({ + ...filter, + recommended: checked, + sortBy: checked + ? constants.CHALLENGE_SORT_BY_RECOMMENDED + : constants.CHALLENGE_SORT_BY_DEFAULT, + }); + }} + /> + </span> + <span>Recommended Challenges</span> + </div> + )} <div styleName="footer"> <Button onClick={clearFilter}>CLEAR FILTER</Button> @@ -175,6 +263,8 @@ ChallengeFilter.propTypes = { challengeSubCommunities: PT.arrayOf(PT.string), saveFilter: PT.func, clearFilter: PT.func, + switchBucket: PT.func, + openForRegistrationCount: PT.number, }; export default ChallengeFilter; diff --git a/src/containers/Filter/ChallengeFilter/styles.scss b/src/containers/Filter/ChallengeFilter/styles.scss index 1af3174..7d1c7fc 100644 --- a/src/containers/Filter/ChallengeFilter/styles.scss +++ b/src/containers/Filter/ChallengeFilter/styles.scss @@ -17,7 +17,7 @@ $filter-padding-y: 3 * $base-unit; margin-bottom: 18px; > h3 { - margin-bottom: 12px; + margin-bottom: 15px; font-size: inherit; line-height: 19px; } @@ -29,6 +29,18 @@ $filter-padding-y: 3 * $base-unit; margin: $base-unit 0; } } + + :global(.badge) { + display: inline-block; + margin-left: $base-unit; + padding: 0 5px; + font-weight: bold; + font-size: 11px; + line-height: 16px; + color: white; + background: red; + border-radius: 13px; + } } .challenge-types, @@ -51,6 +63,7 @@ $filter-padding-y: 3 * $base-unit; .skills { margin-bottom: 34px; + max-width: 230px; } .prize { @@ -74,6 +87,27 @@ $filter-padding-y: 3 * $base-unit; margin-top: -12px; } +.input-group { + position: relative; + margin-top: -12px; + + input { + padding-right: 36px !important; + } + + .suffix { + @include roboto-medium; + + position: absolute; + top: 22px; + right: 10px; + font-size: 13px; + line-height: 22px; + color: $tc-gray-30; + pointer-events: none; + } +} + .recommended-challenges { margin: 20px 0 25px; font-size: $font-size-sm; @@ -98,3 +132,4 @@ $filter-padding-y: 3 * $base-unit; margin-left: 9px; } } + diff --git a/src/containers/Filter/index.jsx b/src/containers/Filter/index.jsx index e4717f2..13fdf4d 100644 --- a/src/containers/Filter/index.jsx +++ b/src/containers/Filter/index.jsx @@ -11,6 +11,7 @@ const Filter = ({ tags, prizeFrom, prizeTo, + recommended, subCommunities, challengeBuckets, challengeTypes, @@ -21,6 +22,7 @@ const Filter = ({ getChallenges, getTags, getSubCommunities, + openForRegistrationCount, }) => { useEffect(() => { getTags(); @@ -32,6 +34,10 @@ const Filter = ({ getChallenges(filter); }; + const onSwitchBucket = (bucket) => { + updateFilter({ bucket }); + }; + return ( <ChallengeFilter bucket={bucket} @@ -40,6 +46,7 @@ const Filter = ({ tags={tags} prizeFrom={prizeFrom} prizeTo={prizeTo} + recommended={recommended} subCommunities={subCommunities} challengeBuckets={challengeBuckets} challengeTypes={challengeTypes} @@ -48,6 +55,8 @@ const Filter = ({ challengeSubCommunities={challengeSubCommunities} saveFilter={onSaveFilter} clearFilter={() => {}} + switchBucket={onSwitchBucket} + openForRegistrationCount={openForRegistrationCount} /> ); }; @@ -59,6 +68,7 @@ Filter.propTypes = { tags: PT.arrayOf(PT.string), prizeFrom: PT.number, prizeTo: PT.number, + recommended: PT.bool, subCommunities: PT.arrayOf(PT.string), challengeBuckets: PT.arrayOf(PT.string), challengeTypes: PT.arrayOf(PT.string), @@ -69,6 +79,7 @@ Filter.propTypes = { getChallenges: PT.func, getTags: PT.func, getSubCommunities: PT.func, + openForRegistrationCount: PT.number, }; const mapStateToProps = (state) => ({ @@ -79,12 +90,14 @@ const mapStateToProps = (state) => ({ tags: state.filter.challenge.tags, prizeFrom: state.filter.challenge.prizeFrom, prizeTo: state.filter.challenge.prizeTo, + recommended: state.filter.challenge.recommended, subCommunities: state.filter.challenge.subCommunities, challengeBuckets: state.lookup.buckets, challengeTypes: state.lookup.types, challengeTracks: state.lookup.tracks, challengeTags: state.lookup.tags, challengeSubCommunities: state.lookup.subCommunities, + openForRegistrationCount: state.challenges.openForRegistrationCount, }); const mapDispatchToProps = { diff --git a/src/reducers/challenges.js b/src/reducers/challenges.js index 827b06f..055d9aa 100644 --- a/src/reducers/challenges.js +++ b/src/reducers/challenges.js @@ -1,15 +1,26 @@ import { handleActions } from "redux-actions"; +import * as util from "../utils/challenge"; +import * as constants from "../constants"; const defaultState = { loadingChallenges: false, loadingChallengesError: null, + loadingRecommendedChallengesError: false, challenges: [], - challengesFiltered: [], + allActiveChallenges: [], + openForRegistrationChallenges: [], + closedChallenges: [], + openForRegistrationCount: 0, total: 0, }; function onGetChallengesInit(state) { - return { ...state, loadingChallenges: true, loadingChallengesError: null }; + return { + ...state, + loadingChallenges: true, + loadingChallengesError: null, + loadingRecommendedChallengesError: false, + }; } function onGetChallengesDone(state, { payload }) { @@ -17,8 +28,13 @@ function onGetChallengesDone(state, { payload }) { ...state, loadingChallenges: false, loadingChallengesError: null, + loadingRecommendedChallengesError: + payload.loadingRecommendedChallengesError, challenges: payload.challenges, - challengesFiltered: payload.challengesFiltered, + allActiveChallenges: payload.allActiveChallenges, + openForRegistrationChallenges: payload.openForRegistrationChallenges, + closedChallenges: payload.closedChallenges, + openForRegistrationCount: payload.openForRegistrationCount, total: payload.total, }; } @@ -29,16 +45,55 @@ function onGetChallengesFailure(state, { payload }) { loadingChallenges: false, loadingChallengesError: payload, challenges: [], - challengesFiltered: [], + allActiveChallenges: [], + openForRegistrationChallenges: [], + closedChallenges: [], + openForRegistrationCount: 0, total: 0, }; } +function onUpdateFilter(state, { payload }) { + const FILTER_BUCKETS = constants.FILTER_BUCKETS; + const BUCKET_ALL_ACTIVE_CHALLENGES = FILTER_BUCKETS[0]; + const BUCKET_OPEN_FOR_REGISTRATION = FILTER_BUCKETS[1]; + const BUCKET_CLOSED_CHALLENGES = FILTER_BUCKETS[2]; + const filterChange = payload; + const { + allActiveChallenges, + openForRegistrationChallenges, + closedChallenges, + } = state; + + let challenges; + let total; + + if (util.isSwitchingBucket(filterChange)) { + switch (filterChange.bucket) { + case BUCKET_ALL_ACTIVE_CHALLENGES: + challenges = allActiveChallenges; + break; + case BUCKET_OPEN_FOR_REGISTRATION: + challenges = openForRegistrationChallenges; + break; + case BUCKET_CLOSED_CHALLENGES: + challenges = closedChallenges; + break; + } + total = challenges.meta.total; + + return { ...state, challenges, total }; + } + + return { ...state }; +} + export default handleActions( { GET_CHALLENGE_INIT: onGetChallengesInit, GET_CHALLENGES_DONE: onGetChallengesDone, GET_CHALLENGES_FAILURE: onGetChallengesFailure, + UPDATE_FILTER: onUpdateFilter, }, defaultState ); diff --git a/src/reducers/filter.js b/src/reducers/filter.js index 6346dce..073fef5 100644 --- a/src/reducers/filter.js +++ b/src/reducers/filter.js @@ -1,19 +1,20 @@ import { handleActions } from "redux-actions"; import * as constants from "../constants"; +import moment from "moment"; const defaultState = { challenge: { types: constants.FILTER_CHALLENGE_TYPES, - tracks: constants.FILTER_CHALLENGE_TRACKS, + tracks: constants.FILTER_CHALLENGE_TRACKS.filter((track) => track !== "QA"), search: "", tags: [], groups: [], - startDateEnd: null, - endDateStart: null, + endDateStart: moment().subtract(99, "year").toDate().toISOString(), + startDateEnd: moment().add(1, "year").toDate().toISOString(), page: 1, perPage: constants.PAGINATION_PER_PAGE[0], sortBy: constants.CHALLENGE_SORT_BY["Most recent"], - sortOrder: null, + sortOrder: constants.SORT_ORDER.ASC, // --- @@ -21,15 +22,21 @@ const defaultState = { prizeFrom: 0, prizeTo: 10000, subCommunities: [], + recommended: false, }, }; +function onRestoreFilter(state, { payload }) { + return { ...state, ...payload }; +} + function onUpdateFilter(state, { payload }) { return { ...state, challenge: { ...state.challenge, ...payload } }; } export default handleActions( { + RESTORE_FILTER: onRestoreFilter, UPDATE_FILTER: onUpdateFilter, }, defaultState diff --git a/src/root.component.js b/src/root.component.js index a49494c..4eb8c1c 100644 --- a/src/root.component.js +++ b/src/root.component.js @@ -5,6 +5,8 @@ import { createHistory, LocationProvider } from "@reach/router"; import { Provider } from "react-redux"; import store from "./store"; import App from "./App"; +import * as util from "./utils/session"; +import actions from "./actions"; // History for location provider const history = createHistory(window); @@ -13,6 +15,13 @@ export default function Root() { useEffect(() => { // when app starts it should set its side menu structure setAppMenu("/earn", appMenu); + + const unsubscribe = store.subscribe(() => + util.persistFilter(util.selectFilter(store.getState())) + ); + return () => { + unsubscribe(); + }; }, []); return ( diff --git a/src/services/challenges.js b/src/services/challenges.js index 1889d9c..b93a5dd 100644 --- a/src/services/challenges.js +++ b/src/services/challenges.js @@ -11,6 +11,12 @@ async function getChallenges(filter) { return api.get(`/challenges/${challengeQuery}`); } +async function getRecommendedChallenges(filter, handle) { + const challengeQuery = util.buildQueryString(filter); + return api.get(`/recommender-api/${handle}/${challengeQuery}`); +} + export default { getChallenges, + getRecommendedChallenges, }; diff --git a/src/store.js b/src/store.js index 0852395..4e2867f 100644 --- a/src/store.js +++ b/src/store.js @@ -4,6 +4,8 @@ import { createStore, compose, applyMiddleware } from "redux"; import { createPromise } from "redux-promise-middleware"; import root from "./reducers"; +import actions from "./actions"; +import * as util from "./utils/session"; const middlewares = [ createPromise({ promiseTypeSuffixes: ["INIT", "DONE", "FAILURE"] }), @@ -15,4 +17,8 @@ if (process.env.APPMODE === "development") { middlewares.push(logger); } -export default createStore(root, compose(applyMiddleware(...middlewares))); +const store = createStore(root, compose(applyMiddleware(...middlewares))); + +store.dispatch(actions.filter.restoreFilter(util.restoreFilter())); + +export default store; diff --git a/src/styles/_variables.scss b/src/styles/_variables.scss index 433ec8b..fde2cc4 100644 --- a/src/styles/_variables.scss +++ b/src/styles/_variables.scss @@ -127,6 +127,9 @@ $font-size-sm: 14px; $font-size-xs: 12px; /// APP + +$screen-xxl: 1366px; + $base-unit: 5px; $body-color: $tc-gray-90; diff --git a/src/utils/challenge.js b/src/utils/challenge.js index 158f9ea..dceacf3 100644 --- a/src/utils/challenge.js +++ b/src/utils/challenge.js @@ -34,7 +34,9 @@ export function createChallengeCriteria(filter) { startDateEnd: filter.startDateEnd, endDateStart: filter.endDateStart, endDateEnd: filter.endDateEnd, - sortBy: filter.sortBy, + sortBy: isValidCriteriaSortBy(filter.sortBy) + ? filter.sortBy + : constants.CHALLENGE_SORT_BY_DEFAULT, sortOrder: filter.sortOrder, groups: filter.groups, }; @@ -44,18 +46,22 @@ export function createOpenForRegistrationChallengeCriteria() { return { status: "Active", currentPhaseName: "Registration", + endDateStart: null, + startDateEnd: null, }; } -export function createActiveChallengeCriteria() { +export function createAllActiveChallengeCriteria() { return { status: "Active", currentPhaseName: "Submission", registrationEndDateEnd: new Date().toISOString(), + endDateStart: null, + startDateEnd: null, }; } -export function createPastChallengeCriteria() { +export function createClosedChallengeCriteria() { return { status: "Completed", }; @@ -102,6 +108,19 @@ export function checkRequiredFilterAttributes(filter) { return valid; } +export function isSwitchingBucket(filterChange) { + const keys = Object.keys(filterChange); + return keys.length === 1 && keys[0] === "bucket"; +} + +export function isDisplayingBucket(filter, bucket) { + return filter.bucket === bucket; +} + +export function isValidCriteriaSortBy(sortBy) { + return ["updated", "overview.totalPrizes", "name"].includes(sortBy); +} + /** * Returns phase's end date. * @param {Object} phase diff --git a/src/utils/icon.js b/src/utils/icon.js index 5cc61ff..759360e 100644 --- a/src/utils/icon.js +++ b/src/utils/icon.js @@ -119,7 +119,7 @@ function createTCOEventIcon (color) { <g id="01_3_Find-Work-Challenges-Non-Logged-In-Hover" transform="translate(-346.000000, -264.000000)"> <g id="Group-17" transform="translate(329.000000, 245.000000)"> <g id="icon-/-challenge-/-track-copy" transform="translate(17.000000, 19.531250)"> - <text id="TCO" fontFamily="Helvetica" fontSize="11" fontWeight="normal" lineSpacing="12" fill={color}> + <text id="TCO" fontFamily="Helvetica" fontSize="11" fontWeight="normal" linespacing="12" fill={color}> <tspan x="0" y="44.8854167">TCO</tspan> </text> <g id="icon-/-track-/-design" transform="translate(22.000000, 23.177083)"> @@ -136,3 +136,13 @@ function createTCOEventIcon (color) { </svg> ); } + +export function createBadgeElement(htmlElement, content) { + const badgeElement = document.createElement('span'); + + badgeElement.classList.add('badge'); + badgeElement.textContent = content; + htmlElement.appendChild(badgeElement); + + return badgeElement; +} diff --git a/src/utils/index.js b/src/utils/index.js index 62d01b8..5df8191 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -122,14 +122,51 @@ export function formatMoneyValue(value) { } if (val.startsWith("-")) { - val = `-$${val.slice(1)}`; + val = `-\uFF04${val.slice(1)}`; } else { - val = `$${val}`; + val = `\uFF04${val}`; } return val; } +/** + * Format a number value into the integer text of amount. + * Ex: 0 -> 0, greater than 10000 -> 10,000+ + */ +export function formatPrizeAmount(value) { + let val = value || 0; + let greaterThan10000 = val >= 10000; + + val = val.toLocaleString("en-US"); + + const i = val.indexOf("."); + if (i !== -1) { + val = val.slice(0, i); + } + + val = greaterThan10000 ? "10,000+" : val; + + return val; +} + +export function parsePrizeAmountText(s) { + let val = s; + if (val.endsWith("+")) { + val = val.slice(0, val.length - 1); + } + + const i = val.indexOf("."); + if (i !== -1) { + val = val.slice(0, i); + } + + val = val.replace("/,/g", ""); + val = parseInt(val); + + return isNaN(val) ? 0 : val; +} + export function clamp(value, min, max) { return Math.min(Math.max(value, min), max); } diff --git a/src/utils/menu.js b/src/utils/menu.js index b060439..b388ee6 100644 --- a/src/utils/menu.js +++ b/src/utils/menu.js @@ -9,7 +9,7 @@ export class MenuSelection { } travel(root) { - Object.keys(root).forEach((key) => { + this.getMenuItems(root).forEach((key) => { if (_.isObject(root[key])) { root[key].expanded = false; root[key].branch = true; @@ -24,35 +24,37 @@ export class MenuSelection { }); } + getMenuItems(menu) { + return Object.keys(_.omit(menu, "expanded", "active", "branch", "leaf")); + } + select(name) { let found = false; const selectInternal = (root) => { - Object.keys(_.omit(root, "expanded", "active", "branch", "leaf")).forEach( - (key) => { - if (found) { - return; - } + this.getMenuItems(root).forEach((key) => { + if (found) { + return; + } - if (key !== name) { - if (root[key].branch) { - selectInternal(root[key]); - } else { - root[key].active = false; - } + if (key !== name) { + if (root[key].branch) { + selectInternal(root[key]); } else { - if (root[key].leaf) { - root[key].active = true; - this.selectedMenuItem = name; - } else { - root[key].expanded = !root[key].expanded; - } - - found = true; - this.emitSelectionEvent(); + root[key].active = false; } + } else { + if (root[key].leaf) { + root[key].active = true; + this.selectedMenuItem = name; + } else { + root[key].expanded = !root[key].expanded; + } + + found = true; + this.emitSelectionEvent(); } - ); + }); }; selectInternal(this.menu); @@ -62,17 +64,15 @@ export class MenuSelection { let leaf = false; const isLeafInternal = (root) => { - Object.keys(_.omit(root, "expanded", "active", "branch", "leaf")).forEach( - (key) => { - if (key !== name) { - if (root[key].branch) { - isLeafInternal(root[key]); - } - } else if (root[key].leaf) { - leaf = true; + this.getMenuItems(root).forEach((key) => { + if (key !== name) { + if (root[key].branch) { + isLeafInternal(root[key]); } + } else if (root[key].leaf) { + leaf = true; } - ); + }); }; isLeafInternal(this.menu); @@ -85,20 +85,18 @@ export class MenuSelection { } isExpanded(name) { - let expanded = false; + let expanded; const isExpandedInternal = (root) => { - Object.keys(_.omit(root, "expanded", "active", "branch", "leaf")).forEach( - (key) => { - if (key !== name) { - if (root[key].branch) { - isExpandedInternal(root[key]); - } - } else if (root[key].branch) { - expanded = root[key].expanded; + this.getMenuItems(root).forEach((key) => { + if (key !== name) { + if (root[key].branch) { + isExpandedInternal(root[key]); } + } else if (root[key].branch) { + expanded = root[key].expanded; } - ); + }); }; isExpandedInternal(this.menu); @@ -123,20 +121,18 @@ export class MenuSelection { } const isActiveInternal = (root) => { - Object.keys(_.omit(root, "expanded", "active", "branch", "leaf")).forEach( - (key) => { - if (key !== this.selectedMenuItem) { - if (root[key].branch) { - stack.push(key); - isActiveInternal(root[key]); - stack.pop(key); - } - } else { + this.getMenuItems(root).forEach((key) => { + if (key !== this.selectedMenuItem) { + if (root[key].branch) { stack.push(key); - path = [...stack.arr]; + isActiveInternal(root[key]); + stack.pop(key); } + } else { + stack.push(key); + path = [...stack.arr]; } - ); + }); }; isActiveInternal(this.menu); diff --git a/src/utils/pagination.js b/src/utils/pagination.js index 8f0adf6..292d7fe 100644 --- a/src/utils/pagination.js +++ b/src/utils/pagination.js @@ -10,11 +10,11 @@ export function pageIndexToPage(pageIndex) { * @param {any} response Web APIs Response * @return {Object} pagination data */ -export function getResponseHeaders(reponse) { +export function getResponseHeaders(response) { return { - page: +(reponse.headers.get("X-Page") || 0), - perPage: +(reponse.headers.get("X-Per-Page") || 0), - total: +(reponse.headers.get("X-Total") || 0), - totalPages: +(reponse.headers.get("X-Total-Pages") || 0), + page: +(response.headers.get("X-Page") || 0), + perPage: +(response.headers.get("X-Per-Page") || 0), + total: +(response.headers.get("X-Total") || 0), + totalPages: +(response.headers.get("X-Total-Pages") || 0), }; } diff --git a/src/utils/session.js b/src/utils/session.js new file mode 100644 index 0000000..ef396f6 --- /dev/null +++ b/src/utils/session.js @@ -0,0 +1,29 @@ +function selectFilter(state) { + return state.filter; +} + +let currentFilterValue; +function persistFilter(filter) { + let previousFilterValue = currentFilterValue; + currentFilterValue = filter; + + if (previousFilterValue !== currentFilterValue) { + try { + sessionStorage.setItem("filter", JSON.stringify(filter)); + } catch (e) { + console.error(e); + } + } +} + +function restoreFilter() { + let filter; + try { + filter = JSON.parse(sessionStorage.getItem("filter")); + } catch (e) { + filter = {}; + } + return filter; +} + +export { selectFilter, persistFilter, restoreFilter }; diff --git a/src/utils/tag.js b/src/utils/tag.js index 39bea25..2b4418a 100644 --- a/src/utils/tag.js +++ b/src/utils/tag.js @@ -6,7 +6,7 @@ export function calculateNumberOfVisibleTags(tags) { let n = tags.length; if (tagsString.length > MAX_LEN) { let ss = ""; - for (n = 0; n < tags.length && ss.length < 20; n += 1) { + for (n = 0; n < tags.length && ss.length < MAX_LEN; n += 1) { ss = ss.concat(tags[n]); } } diff --git a/src/utils/url.js b/src/utils/url.js index bffc6d1..80fb7d3 100644 --- a/src/utils/url.js +++ b/src/utils/url.js @@ -8,7 +8,7 @@ import qs from "qs"; * `{ p: undefined }` => "" * `{ p: value }` => "p=value" * `{ p: [] }` => "" - * `{ p: ['Challenge', 'First2Finish', 'Task'] } => "p[]=Challenge&p[]=First2Finish&p[]=Taks` + * `{ p: ['Challenge', 'First2Finish', 'Task'] } => "p[]=Challenge&p[]=First2Finish&p[]=Task` * `{ p: ['Design', 'Development', 'Data Science', 'Quality Assurance'] }` => "p[]=Design&p[]=Development&p=Data%20Science&p[]=Quality%20Assurance" * `{ p: { Des: true, Dev: true, DS: false, QA: false } }` => "p[Des]=true&p[Dev]=true&p[DS]=false&p[QA]=false" *