diff --git a/config/constants/dev.js b/config/constants/dev.js index f5baf2b0a..de73f34ae 100644 --- a/config/constants/dev.js +++ b/config/constants/dev.js @@ -46,5 +46,7 @@ module.exports = { TC_NOTIFICATION_URL: 'https://api.topcoder-dev.com/v5/notifications', CONNECT_MESSAGE_API_URL: 'https://api.topcoder-dev.com/v5', TC_SYSTEM_USERID: process.env.DEV_TC_SYSTEM_USERID, - MAINTENANCE_MODE: process.env.DEV_MAINTENANCE_MODE + MAINTENANCE_MODE: process.env.DEV_MAINTENANCE_MODE, + + RESET_PASSWORD_URL: 'https://accounts.topcoder-dev.com/connect/reset-password' } diff --git a/config/constants/master.js b/config/constants/master.js index 54cfa5a33..669a4ba56 100644 --- a/config/constants/master.js +++ b/config/constants/master.js @@ -46,5 +46,7 @@ module.exports = { TC_NOTIFICATION_URL: 'https://api.topcoder.com/v5/notifications', CONNECT_MESSAGE_API_URL: 'https://api.topcoder.com/v5', TC_SYSTEM_USERID: process.env.PROD_TC_SYSTEM_USERID, - MAINTENANCE_MODE: process.env.PROD_MAINTENANCE_MODE + MAINTENANCE_MODE: process.env.PROD_MAINTENANCE_MODE, + + RESET_PASSWORD_URL: 'https://accounts.topcoder.com/connect/reset-password' } diff --git a/src/actions/loadUser.js b/src/actions/loadUser.js index 22404dd2c..31bb64096 100644 --- a/src/actions/loadUser.js +++ b/src/actions/loadUser.js @@ -49,8 +49,6 @@ export function loadUserSuccess(dispatch, token) { if (currentUser) { getUserProfile(currentUser.handle).then((profile) => { currentUser = _.assign(currentUser, profile) - // keeping profile for backward compatibility - currentUser.profile = profile // determine user role let userRole if (_.indexOf(currentUser.roles, ROLE_ADMINISTRATOR) > -1) { diff --git a/src/api/s3.js b/src/api/s3.js new file mode 100644 index 000000000..624fdf083 --- /dev/null +++ b/src/api/s3.js @@ -0,0 +1,35 @@ +/** + * Amazon S3 Service API + */ + +/** + * Upload file to S3 using pre-signed URL + * + * @param {String} preSignedURL pre-signed URL + * @param {File} file file to upload + * + * @returns {Promise} pre-signed URL + */ +export const uploadFileToS3 = (preSignedURL, file) => { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest() + + xhr.open('PUT', preSignedURL, true) + xhr.setRequestHeader('Content-Type', file.type) + + xhr.onreadystatechange = () => { + const { status } = xhr + if (((status >= 200 && status < 300) || status === 304) && xhr.readyState === 4) { + resolve(preSignedURL) + } else if (status >= 400) { + const err = new Error('Could not upload image') + err.status = status + reject(err) + } + } + xhr.onerror = (err) => { + reject(err) + } + xhr.send(file) + }) +} \ No newline at end of file diff --git a/src/api/users.js b/src/api/users.js index 2fabd64a8..5f7d01843 100644 --- a/src/api/users.js +++ b/src/api/users.js @@ -1,11 +1,14 @@ import _ from 'lodash' import { axiosInstance as axios } from './requestInterceptor' import { TC_API_URL } from '../config/constants' +import { RESET_PASSWORD_URL } from '../../config/constants' /** - * Get a user basd on it's handle/username - * @param {integer} handle unique identifier of the user - * @return {object} user returned by api + * Get a user based on it's handle/username + * + * @param {String} handle user handle + * + * @returns {Promise} user profile data */ export function getUserProfile(handle) { return axios.get(`${TC_API_URL}/v3/members/${handle}/`) @@ -13,3 +16,120 @@ export function getUserProfile(handle) { return _.get(resp.data, 'result.content', {}) }) } + +/** + * Update user profile + * + * @param {String} handle user handle + * @param {Object} updatedProfile updated user data + * + * @returns {Promise} user profile data + */ +export function updateUserProfile(handle, updatedProfile) { + return axios.put(`${TC_API_URL}/v3/members/${handle}/`, { + param: updatedProfile + }) + .then(resp => { + return _.get(resp.data, 'result.content', {}) + }) +} + +/** + * Get member traits + * + * @param {String} handle member handle + * + * @returns {Promise} member traits + */ +export const getMemberTraits = (handle) => { + return axios.get(`${TC_API_URL}/v3/members/${handle}/traits`) + .then(resp => _.get(resp.data, 'result.content', {})) +} + +/** + * Update member traits + * + * @param {String} handle member handle + * @param {Array} updatedTraits list of updated traits + * + * @returns {Promise} member traits + */ +export const updateMemberTraits = (handle, updatedTraits) => { + return axios.put(`${TC_API_URL}/v3/members/${handle}/traits`, { + param: updatedTraits + }) + .then(resp => _.get(resp.data, 'result.content', {})) +} + +/** + * Update member photo + * + * @param {String} handle member handle + * @param {Object} data params to update photo + * @param {String} data.contentType photo file content type + * @param {String} data.token token provided by pre signed URL + * + * @returns {Promise} photo URL + */ +export const updateMemberPhoto = (handle, data) => { + return axios.put(`${TC_API_URL}/v3/members/${handle}/photo`, { + param: data + }) + .then(resp => _.get(resp.data, 'result.content', {})) +} + +/** + * Get pre-signed URL for member photo + * + * @param {String} handle member handle + * @param {File} file file to upload + * + * @returns {Promise} data of pre-signed URL + */ +export const getPreSignedUrl = (handle, file) => { + return axios.post(`${TC_API_URL}/v3/members/${handle}/photoUploadUrl`, { + param: { + contentType: file.type + } + }) + .then(resp => _.get(resp.data, 'result.content', {})) +} + +/** + * Check if email is available to be used for a user + * + * @param {String} email email to validate + * + * @returns {Promise} response body + */ +export const checkEmailValidity = (email) => { + return axios.get(`${TC_API_URL}/v3/users/validateEmail?email=${email}`) + .then(resp => _.get(resp.data, 'result.content', {})) +} + +/** + * Update user password + * + * @param {Number} userId user id + * @param {Object} credential user credentials old and new one + * + * @returns {Promise} response body + */ +export const updatePassword = (userId, credential) => { + return axios.patch(`${TC_API_URL}/v3/users/${userId}`, { + param: { credential } + }) + .then(resp => _.get(resp.data, 'result.content', {})) +} + +/** + * Send reset password email to the user + * + * @param {String} email user email + * + * @returns {Promise} response body + */ +export const resetPassword = (email) => { + return axios.get(`${TC_API_URL}/v3/users/resetToken?email=${encodeURIComponent(email)}&resetPasswordUrlPrefix=${encodeURIComponent(RESET_PASSWORD_URL)}`) + .then(resp => _.get(resp.data, 'result.content', {})) +} \ No newline at end of file diff --git a/src/components/CoderBot/CoderBot.jsx b/src/components/CoderBot/CoderBot.jsx index 4265b89ec..1fb8eed79 100644 --- a/src/components/CoderBot/CoderBot.jsx +++ b/src/components/CoderBot/CoderBot.jsx @@ -2,6 +2,7 @@ import React from 'react' import PropTypes from 'prop-types' import './CoderBot.scss' import CoderBroken from '../../assets/icons/coder-broken.svg' +import CoderHappy from '../../assets/icons/coder-welcome.svg' @@ -26,14 +27,17 @@ const getMessage = code => { } } -const CoderBot = ({code, message}) => { +const CoderBot = ({code, message, heading, children}) => { return (
-

{ getHeading(code) }

-

- +

{ heading || getHeading(code) }

+
+

+

{children}
+
+ {code !== 200 ? : } {code !== 200 && code}
diff --git a/src/components/CoderBot/CoderBot.scss b/src/components/CoderBot/CoderBot.scss index fb7395ae3..d0ba07835 100644 --- a/src/components/CoderBot/CoderBot.scss +++ b/src/components/CoderBot/CoderBot.scss @@ -33,12 +33,6 @@ } } background-size: 307px 300px; - a{ - color: $tc-dark-blue; - &:hover { - text-decoration: underline; - } - } h3{ color: $tc-gray-70; @include roboto-medium; @@ -56,9 +50,11 @@ font-size: 30px; } } + .content { + min-height: 120px; + } p{ text-align: left; - min-height: 120px; padding: 0 168px; @include roboto; font-size: $tc-label-lg; @@ -71,6 +67,13 @@ @media screen and (max-width: $screen-md - 1px) { padding: 0 28px; } + + a{ + color: $tc-dark-blue; + &:hover { + text-decoration: underline; + } + } } span{ position: absolute; diff --git a/src/components/FileBtn/FileBtn.jsx b/src/components/FileBtn/FileBtn.jsx index 5bc0dfa10..03b41d0aa 100644 --- a/src/components/FileBtn/FileBtn.jsx +++ b/src/components/FileBtn/FileBtn.jsx @@ -8,18 +8,20 @@ import './FileBtn.scss' const FileBtn = (props) => { const fileProps = _.pick(props, 'accept', 'onChange') - + const { disabled } = props return (
- - + +
) } FileBtn.propTypes = { + label: PropTypes.string.isRequired, accept: PropTypes.string, - onChange: PropTypes.func + onChange: PropTypes.func, + disabled: PropTypes.bool } export default FileBtn diff --git a/src/components/FileBtn/FileBtn.scss b/src/components/FileBtn/FileBtn.scss index 82b35228c..d29de0078 100644 --- a/src/components/FileBtn/FileBtn.scss +++ b/src/components/FileBtn/FileBtn.scss @@ -14,19 +14,23 @@ right: 0; top: 0; font-size: 100px; + + &[disabled] { + cursor: default; + } } /* here we reproduce styles for tc-btn-default from tc-ui package */ > .file.loading:enabled + .tc-btn-default, > .file:focus:enabled + .tc-btn-default { - background: $tc-white; - border-color: $tc-dark-blue-70; + border-color: $tc-dark-blue-100; box-shadow: 0 0 2px 1px $tc-dark-blue-30; } > .file:active:enabled + .tc-btn-default, > .file:hover:enabled + .tc-btn-default { - background-image: linear-gradient(0deg, $tc-gray-neutral-light 0%, $tc-white 49%, $tc-white 100%); + // background-image: linear-gradient(0deg, $tc-gray-neutral-light 0%, $tc-white 49%, $tc-white 100%); + border-color: $tc-dark-blue-100; border-color: $tc-gray-40; } diff --git a/src/components/Footer/Footer.jsx b/src/components/Footer/Footer.jsx index 6cf128749..4ccf196c2 100644 --- a/src/components/Footer/Footer.jsx +++ b/src/components/Footer/Footer.jsx @@ -18,9 +18,10 @@ const Footer = () => { const isProjectDetails = /projects\/\d+/.test(window.location.pathname) const isCreateProject = window.location.pathname.startsWith(NEW_PROJECT_PATH) const isNotificationsPage = window.location.pathname.startsWith('/notifications') + const isSettingsPage = window.location.pathname.startsWith('/settings/') // TODO this looks like a bad way of doing it, I think it should be re-factored - const shouldHideOnDesktop = isProjectDetails || isCreateProject || isNotificationsPage + const shouldHideOnDesktop = isProjectDetails || isCreateProject || isNotificationsPage || isSettingsPage // on mobile show footer only when user is logged-out, so only root page is available const shouldHideOnMobile = window.location.pathname !== '/' diff --git a/src/components/TopBar/ProjectsToolBar.js b/src/components/TopBar/ProjectsToolBar.js index e5ffeda74..53eb37cd6 100644 --- a/src/components/TopBar/ProjectsToolBar.js +++ b/src/components/TopBar/ProjectsToolBar.js @@ -173,6 +173,7 @@ class ProjectsToolBar extends Component { const { user, criteria, creatingProject, projectCreationError, searchTermTag, projectTypes } = this.props const { errorCreatingProject, isFilterVisible, isMobileMenuOpen, isMobileSearchVisible } = this.state return (nextProps.user || {}).handle !== (user || {}).handle + || (nextProps.user || {}).photoURL !== (this.props.user || {}).photoURL || JSON.stringify(nextProps.criteria) !== JSON.stringify(criteria) || nextProps.creatingProject !== creatingProject || nextProps.projectCreationError !== projectCreationError diff --git a/src/components/TopBar/SectionToolBar.scss b/src/components/TopBar/SectionToolBar.scss index 6053d5460..9f32ead22 100644 --- a/src/components/TopBar/SectionToolBar.scss +++ b/src/components/TopBar/SectionToolBar.scss @@ -4,7 +4,7 @@ :global { .tc-header.tc-header__connect .top-bar{ height: 60px; - background-color: $tc-gray-90; + background-color: $tc-gray-100; position: relative; display: flex; @@ -30,7 +30,7 @@ } .title { - @include roboto-light; + @include roboto; font-size: 20px; color: $tc-gray-10; text-align: center; @@ -65,11 +65,11 @@ } .icon-x-mark { - fill: $tc-white; + fill: $tc-gray-100; margin: -6px; @media screen and (max-width: $screen-md - 1px) { - fill: $tc-white; + fill: $tc-gray-100; } } } @@ -102,7 +102,6 @@ .icon-connect-logo-mono { height: auto; width: 53px; - margin-top: 13px; path { fill: $tc-gray-10; diff --git a/src/components/TopBar/TopBarContainer.js b/src/components/TopBar/TopBarContainer.js index 4fdcffd32..ae814017e 100644 --- a/src/components/TopBar/TopBarContainer.js +++ b/src/components/TopBar/TopBarContainer.js @@ -32,6 +32,7 @@ class TopBarContainer extends React.Component { shouldComponentUpdate(nextProps) { return (nextProps.user || {}).handle !== (this.props.user || {}).handle + || (nextProps.user || {}).photoURL !== (this.props.user || {}).photoURL || nextProps.toolbar !== this.props.toolbar || this.props.location.pathname !== nextProps.location.pathname } @@ -61,9 +62,9 @@ class TopBarContainer extends React.Component { const { user, toolbar, userRoles, isPowerUser } = this.props const userHandle = _.get(user, 'handle') - const userImage = _.get(user, 'profile.photoURL') - const userFirstName = _.get(user, 'profile.firstName') - const userLastName = _.get(user, 'profile.lastName') + const userImage = _.get(user, 'photoURL') + const userFirstName = _.get(user, 'firstName') + const userLastName = _.get(user, 'lastName') let userName = userFirstName if (userName && userLastName) { userName += ' ' + userLastName @@ -73,7 +74,6 @@ class TopBarContainer extends React.Component { const isHomePage = this.props.match.path === '/' const loginUrl = `${ACCOUNTS_APP_LOGIN_URL}?retUrl=${window.location.protocol}//${window.location.host}/` const registerUrl = !isHomePage ? ACCOUNTS_APP_REGISTER_URL : null - const profileUrl = `https://${DOMAIN}/settings/profile/` const isLoggedIn = !!(userRoles && userRoles.length) const logoutClick = (evt) => { @@ -85,7 +85,11 @@ class TopBarContainer extends React.Component { const userMenuItems = [ [ - { label: 'Profile Settings', link: profileUrl, absolute: true, id: 0}, + { label: 'My profile', link: '/settings/profile' }, + { label: 'Account and security', link: '/settings/account' }, + { label: 'Notification settings', link: '/settings/notifications' }, + ], + [ { label: 'Help', link: 'https://help.topcoder.com/hc/en-us', absolute: true, id: 0 } ], [ @@ -98,6 +102,9 @@ class TopBarContainer extends React.Component { style: 'big', items: [ { label: 'All projects', link: isPowerUser ? '/projects?sort=updatedAt%20desc' : '/projects' }, + { label: 'My profile', link: '/settings/profile' }, + { label: 'Account and security', link: '/settings/account' }, + { label: 'Notification settings', link: '/settings/notifications' }, { label: 'Getting Started', link: 'https://www.topcoder.com/about-topcoder/connect/', absolute: true }, { label: 'Help', link: 'https://help.topcoder.com/hc/en-us', absolute: true }, ] diff --git a/src/projects/detail/components/TwoColsLayout/TwoColsLayout.jsx b/src/components/TwoColsLayout/TwoColsLayout.jsx similarity index 100% rename from src/projects/detail/components/TwoColsLayout/TwoColsLayout.jsx rename to src/components/TwoColsLayout/TwoColsLayout.jsx diff --git a/src/projects/detail/components/TwoColsLayout/TwoColsLayout.scss b/src/components/TwoColsLayout/TwoColsLayout.scss similarity index 100% rename from src/projects/detail/components/TwoColsLayout/TwoColsLayout.scss rename to src/components/TwoColsLayout/TwoColsLayout.scss diff --git a/src/projects/detail/components/TwoColsLayout/index.jsx b/src/components/TwoColsLayout/index.jsx similarity index 100% rename from src/projects/detail/components/TwoColsLayout/index.jsx rename to src/components/TwoColsLayout/index.jsx diff --git a/src/config/constants.js b/src/config/constants.js index e8a6e1923..270189791 100644 --- a/src/config/constants.js +++ b/src/config/constants.js @@ -24,6 +24,9 @@ export const TOGGLE_NOTIFICATIONS_DROPDOWN_WEB = 'TOGGLE_NOTIFICATIONS_DROPDOWN_ export const MARK_NOTIFICATIONS_READ = 'MARK_NOTIFICATIONS_READ' // Settings +export const GET_SYSTEM_SETTINGS_PENDING = 'GET_SYSTEM_SETTINGS_PENDING' +export const GET_SYSTEM_SETTINGS_SUCCESS = 'GET_SYSTEM_SETTINGS_SUCCESS' +export const GET_SYSTEM_SETTINGS_FAILURE = 'GET_SYSTEM_SETTINGS_FAILURE' export const CHECK_EMAIL_AVAILABILITY_PENDING = 'CHECK_EMAIL_AVAILABILITY_PENDING' export const CHECK_EMAIL_AVAILABILITY_SUCCESS = 'CHECK_EMAIL_AVAILABILITY_SUCCESS' export const CHECK_EMAIL_AVAILABILITY_FAILURE = 'CHECK_EMAIL_AVAILABILITY_FAILURE' @@ -36,6 +39,10 @@ export const CHANGE_PASSWORD_PENDING = 'CHANGE_PASSWORD_PENDING' export const CHANGE_PASSWORD_SUCCESS = 'CHANGE_PASSWORD_SUCCESS' export const CHANGE_PASSWORD_FAILURE = 'CHANGE_PASSWORD_FAILURE' +export const RESET_PASSWORD_PENDING = 'RESET_PASSWORD_PENDING' +export const RESET_PASSWORD_SUCCESS = 'RESET_PASSWORD_SUCCESS' +export const RESET_PASSWORD_FAILURE = 'RESET_PASSWORD_FAILURE' + export const GET_NOTIFICATION_SETTINGS_PENDING = 'GET_NOTIFICATION_SETTINGS_PENDING' export const GET_NOTIFICATION_SETTINGS_SUCCESS = 'GET_NOTIFICATION_SETTINGS_SUCCESS' export const GET_NOTIFICATION_SETTINGS_FAILURE = 'GET_NOTIFICATION_SETTINGS_FAILURE' @@ -43,6 +50,17 @@ export const SAVE_NOTIFICATION_SETTINGS_PENDING = 'SAVE_NOTIFICATION_SETTINGS_PE export const SAVE_NOTIFICATION_SETTINGS_SUCCESS = 'SAVE_NOTIFICATION_SETTINGS_SUCCESS' export const SAVE_NOTIFICATION_SETTINGS_FAILURE = 'SAVE_NOTIFICATION_SETTINGS_FAILURE' +export const GET_PROFILE_SETTINGS_PENDING = 'GET_PROFILE_SETTINGS_PENDING' +export const GET_PROFILE_SETTINGS_SUCCESS = 'GET_PROFILE_SETTINGS_SUCCESS' +export const GET_PROFILE_SETTINGS_FAILURE = 'GET_PROFILE_SETTINGS_FAILURE' +export const SAVE_PROFILE_SETTINGS_PENDING = 'SAVE_PROFILE_SETTINGS_PENDING' +export const SAVE_PROFILE_SETTINGS_SUCCESS = 'SAVE_PROFILE_SETTINGS_SUCCESS' +export const SAVE_PROFILE_SETTINGS_FAILURE = 'SAVE_PROFILE_SETTINGS_FAILURE' +export const CLEAR_PROFILE_SETTINGS_PHOTO = 'CLEAR_PROFILE_SETTINGS_PHOTO' +export const SAVE_PROFILE_PHOTO_SUCCESS = 'SAVE_PROFILE_PHOTO_SUCCESS' +export const SAVE_PROFILE_PHOTO_FAILURE = 'SAVE_PROFILE_PHOTO_FAILURE' +export const SAVE_PROFILE_PHOTO_PENDING = 'SAVE_PROFILE_PHOTO_PENDING' + // Search Term export const SET_SEARCH_TERM = 'SET_SEARCH_TERM' export const SET_SEARCH_TAG = 'SET_SEARCH_TAG' diff --git a/src/projects/detail/containers/DashboardContainer.jsx b/src/projects/detail/containers/DashboardContainer.jsx index fe86dbf55..45ca3b502 100644 --- a/src/projects/detail/containers/DashboardContainer.jsx +++ b/src/projects/detail/containers/DashboardContainer.jsx @@ -31,7 +31,7 @@ import ProjectInfoContainer from './ProjectInfoContainer' import FeedContainer from './FeedContainer' import Sticky from '../../../components/Sticky' import { SCREEN_BREAKPOINT_MD } from '../../../config/constants' -import TwoColsLayout from '../components/TwoColsLayout' +import TwoColsLayout from '../../../components/TwoColsLayout' import SystemFeed from '../../../components/Feed/SystemFeed' import WorkInProgress from '../components/WorkInProgress' import NotificationsReader from '../../../components/NotificationsReader' diff --git a/src/projects/detail/containers/FullscreenFeedContainer.jsx b/src/projects/detail/containers/FullscreenFeedContainer.jsx index a94bbf739..66ac5761a 100644 --- a/src/projects/detail/containers/FullscreenFeedContainer.jsx +++ b/src/projects/detail/containers/FullscreenFeedContainer.jsx @@ -5,7 +5,7 @@ import Sticky from '../../../components/Sticky' import MediaQuery from 'react-responsive' import MobilePage from '../../../components/MobilePage/MobilePage' -import TwoColsLayout from '../components/TwoColsLayout' +import TwoColsLayout from '../../../components/TwoColsLayout' import ProjectInfoContainer from './ProjectInfoContainer' import { SCREEN_BREAKPOINT_MD } from '../../../config/constants' diff --git a/src/projects/detail/containers/ProjectPlanContainer.jsx b/src/projects/detail/containers/ProjectPlanContainer.jsx index 55eb9c48e..b8fbf2d82 100644 --- a/src/projects/detail/containers/ProjectPlanContainer.jsx +++ b/src/projects/detail/containers/ProjectPlanContainer.jsx @@ -20,7 +20,7 @@ import { } from '../../actions/project' import { addProductAttachment, updateProductAttachment, removeProductAttachment } from '../../actions/projectAttachment' -import TwoColsLayout from '../components/TwoColsLayout' +import TwoColsLayout from '../../../components/TwoColsLayout' import ProjectStages from '../components/ProjectStages' import ProjectPlanEmpty from '../components/ProjectPlanEmpty' import MediaQuery from 'react-responsive' diff --git a/src/projects/detail/containers/ScopeAndSpecificationContainer.jsx b/src/projects/detail/containers/ScopeAndSpecificationContainer.jsx index 3c032b6f6..bcbae900f 100644 --- a/src/projects/detail/containers/ScopeAndSpecificationContainer.jsx +++ b/src/projects/detail/containers/ScopeAndSpecificationContainer.jsx @@ -13,7 +13,7 @@ import MediaQuery from 'react-responsive' import ProjectSpecSidebar from '../components/ProjectSpecSidebar' import EditProjectForm from '../components/EditProjectForm' -import TwoColsLayout from '../components/TwoColsLayout' +import TwoColsLayout from '../../../components/TwoColsLayout' import { SCREEN_BREAKPOINT_MD, PROJECT_ATTACHMENTS_FOLDER, diff --git a/src/projects/list/components/Projects/Projects.jsx b/src/projects/list/components/Projects/Projects.jsx index b11391333..23575e033 100755 --- a/src/projects/list/components/Projects/Projects.jsx +++ b/src/projects/list/components/Projects/Projects.jsx @@ -252,9 +252,9 @@ const mapStateToProps = ({ projectSearch, members, loadUser, projectState, templ } return { currentUser : { - userId: loadUser.user.profile.userId, - firstName: loadUser.user.profile.firstName, - lastName: loadUser.user.profile.lastName, + userId: loadUser.user.userId, + firstName: loadUser.user.firstName, + lastName: loadUser.user.lastName, roles: loadUser.user.roles }, isLoading : projectSearch.isLoading, diff --git a/src/reducers/loadUser.js b/src/reducers/loadUser.js index 8f1818968..1ec28d3be 100644 --- a/src/reducers/loadUser.js +++ b/src/reducers/loadUser.js @@ -1,5 +1,9 @@ +import _ from 'lodash' import { - LOAD_USER_SUCCESS, LOAD_USER_FAILURE + LOAD_USER_SUCCESS, + LOAD_USER_FAILURE, + SAVE_PROFILE_PHOTO_SUCCESS, + SAVE_PROFILE_SETTINGS_SUCCESS, } from '../config/constants' export const initialState = { @@ -24,6 +28,36 @@ export default function(state = initialState, action) { isLoggedIn: false }) + // update user photo when it's updated in settings + case SAVE_PROFILE_PHOTO_SUCCESS: + return { + ...state, + user: { + ...state.user, + photoURL: action.payload.photoUrl + } + } + + // update user first and last name when it's updated in settings + case SAVE_PROFILE_SETTINGS_SUCCESS: { + const basicTrait = _.find(action.payload.data, { traitId: 'basic_info' }) + + if (basicTrait) { + const traitData = _.get(basicTrait, 'traits.data[0]') + + return { + ...state, + user: { + ...state.user, + firstName: traitData.firstName, + lastName: traitData.lastName, + } + } + } + + return state + } + default: return state } diff --git a/src/routes/settings/actions/index.js b/src/routes/settings/actions/index.js index d8d3cd06c..2dae7498e 100644 --- a/src/routes/settings/actions/index.js +++ b/src/routes/settings/actions/index.js @@ -1,6 +1,7 @@ /** * Settings related actions */ +import _ from 'lodash' import { CHECK_EMAIL_AVAILABILITY_PENDING, CHECK_EMAIL_AVAILABILITY_SUCCESS, @@ -16,65 +17,142 @@ import { GET_NOTIFICATION_SETTINGS_FAILURE, SAVE_NOTIFICATION_SETTINGS_PENDING, SAVE_NOTIFICATION_SETTINGS_SUCCESS, - SAVE_NOTIFICATION_SETTINGS_FAILURE + SAVE_NOTIFICATION_SETTINGS_FAILURE, + GET_PROFILE_SETTINGS_PENDING, + GET_PROFILE_SETTINGS_SUCCESS, + GET_PROFILE_SETTINGS_FAILURE, + SAVE_PROFILE_SETTINGS_PENDING, + SAVE_PROFILE_SETTINGS_SUCCESS, + SAVE_PROFILE_SETTINGS_FAILURE, + SAVE_PROFILE_PHOTO_PENDING, + SAVE_PROFILE_PHOTO_SUCCESS, + SAVE_PROFILE_PHOTO_FAILURE, + GET_SYSTEM_SETTINGS_PENDING, + GET_SYSTEM_SETTINGS_SUCCESS, + RESET_PASSWORD_PENDING, + RESET_PASSWORD_SUCCESS, + RESET_PASSWORD_FAILURE, + CLEAR_PROFILE_SETTINGS_PHOTO, } from '../../../config/constants' -import settingsSerivce from '../services/settings' +import settingsService from '../services/settings' +import * as memberService from '../../../api/users' +import { uploadFileToS3 } from '../../../api/s3' +import { applyProfileSettingsToTraits } from '../helpers/settings' import Alert from 'react-s-alert' + +export const getSystemSettings = () => (dispatch, getState) => { + dispatch({ + type: GET_SYSTEM_SETTINGS_PENDING + }) + + const state = getState() + const handle = _.get(state, 'loadUser.user.handle') + + memberService.getUserProfile(handle) + .then(data => { + dispatch({ + type: GET_SYSTEM_SETTINGS_SUCCESS, + payload: { data } + }) + }) +} + export const checkEmailAvailability = (email) => (dispatch) => { dispatch({ type: CHECK_EMAIL_AVAILABILITY_PENDING, payload: { email } }) - settingsSerivce.checkEmailAvailability(email).then(isEmailAvailable => { - dispatch({ - type: CHECK_EMAIL_AVAILABILITY_SUCCESS, - payload: { email, isEmailAvailable } + memberService.checkEmailValidity(email) + .then(data => { + const isEmailAvailable = _.get(data, 'valid') + dispatch({ + type: CHECK_EMAIL_AVAILABILITY_SUCCESS, + payload: {email, isEmailAvailable} + }) }) - }).catch(err => { - dispatch({ - type: CHECK_EMAIL_AVAILABILITY_FAILURE, - payload: { email, error: err.message } + .catch(err => { + dispatch({ + type: CHECK_EMAIL_AVAILABILITY_FAILURE, + payload: {error: err.message} + }) }) - }) } -export const changeEmail = (newEmail) => (dispatch) => { +export const changeEmail = (email) => (dispatch, getState) => { dispatch({ type: CHANGE_EMAIL_PENDING }) - settingsSerivce.changeEmail(newEmail).then((changedEmail) => { - Alert.success('Email successfully changed.') - dispatch({ - type: CHANGE_EMAIL_SUCCESS, - payload: { email: changedEmail } + const state = getState() + const handle = _.get(state, 'loadUser.user.handle') + const profile = _.get(state, 'settings.system.settings') + // `achievements` and `ratingSummary` are read-only and cannot be updated in member profile + const newProfile = _.omit(profile, 'achievements', 'ratingSummary') + // as we used `omit` above we have a new object and can directly update it + newProfile.email = email + + memberService.updateUserProfile(handle, newProfile) + .then(data => { + dispatch({ + type: CHANGE_EMAIL_SUCCESS, + payload: { data } + }) }) - }).catch(err => { - Alert.error(`Failed to change email. ${err.message}`) - dispatch({ - type: CHANGE_EMAIL_FAILURE + .catch(err => { + Alert.error(`Failed to update email: ${err.message}`) + dispatch({ + type: CHANGE_EMAIL_FAILURE, + }) }) - }) } -export const changePassword = (newPassword) => (dispatch) => { +export const changePassword = (credential) => (dispatch, getState) => { dispatch({ type: CHANGE_PASSWORD_PENDING }) - settingsSerivce.changePassword(newPassword).then(() => { - Alert.success('Password successfully changed.') - dispatch({ - type: CHANGE_PASSWORD_SUCCESS + const state = getState() + const userId = _.get(state, 'settings.system.settings.userId') + + memberService.updatePassword(userId, credential) + .then(() => { + Alert.success('Password changed successfully') + dispatch({ + type: CHANGE_PASSWORD_SUCCESS + }) }) - }).catch(err => { - Alert.error(`Failed to change password. ${err.message}`) - dispatch({ - type: CHANGE_PASSWORD_FAILURE + .catch(err => { + const msg = _.get(err, 'response.data.result.content') || err.message + Alert.error(`Failed to update password: ${msg}`) + dispatch({ + type: CHANGE_PASSWORD_FAILURE + }) }) +} + +export const resetPassword = () => (dispatch, getState) => { + dispatch({ + type: RESET_PASSWORD_PENDING }) + + const state = getState() + const email = _.get(state, 'settings.system.settings.email') + + memberService.resetPassword(email) + .then(() => { + dispatch({ + type: RESET_PASSWORD_SUCCESS + }) + }) + .catch(err => { + const message = _.get(err, 'response.data.result.content') || err.message + Alert.error(`Failed to reset password: ${message}`) + dispatch({ + type: RESET_PASSWORD_FAILURE + }) + }) } export const getNotificationSettings = () => (dispatch) => { @@ -82,7 +160,7 @@ export const getNotificationSettings = () => (dispatch) => { type: GET_NOTIFICATION_SETTINGS_PENDING }) - settingsSerivce.getNotificationSettings().then(data => { + settingsService.getNotificationSettings().then(data => { dispatch({ type: GET_NOTIFICATION_SETTINGS_SUCCESS, payload: { data } @@ -100,7 +178,7 @@ export const saveNotificationSettings = (data) => (dispatch) => { type: SAVE_NOTIFICATION_SETTINGS_PENDING }) - settingsSerivce.saveNotificationSettings(data).then(() => { + settingsService.saveNotificationSettings(data).then(() => { Alert.success('Settings successfully saved.') dispatch({ type: SAVE_NOTIFICATION_SETTINGS_SUCCESS, @@ -114,3 +192,88 @@ export const saveNotificationSettings = (data) => (dispatch) => { }) }) } + +export const saveProfileSettings = (settings) => (dispatch, getState) => { + dispatch({ + type: SAVE_PROFILE_SETTINGS_PENDING + }) + + const state = getState() + const handle = _.get(state, 'loadUser.user.handle') + const traits = _.get(state, 'settings.profile.traits') + const updatedTraits = applyProfileSettingsToTraits(traits, settings) + + memberService.updateMemberTraits(handle, updatedTraits) + // TODO, now we don't update store with the data from server as backend returns wrong + // data when we update see https://github.com/appirio-tech/ap-member-microservice/issues/165. + // So we update the store with the data we sent to the server. + .then(() => _.cloneDeep(updatedTraits)) + .then((data) => { + Alert.success('Settings successfully saved.') + dispatch({ + type: SAVE_PROFILE_SETTINGS_SUCCESS, + payload: { data } + }) + }) + .catch((err) => { + Alert.error(`Failed to save settings. ${err.message}`) + dispatch({ + type: SAVE_PROFILE_SETTINGS_FAILURE + }) + }) +} + +export const getProfileSettings = () => (dispatch, getState) => { + dispatch({ + type: GET_PROFILE_SETTINGS_PENDING + }) + + const state = getState() + const handle = _.get(state, 'loadUser.user.handle') + + memberService.getMemberTraits(handle).then(data => { + dispatch({ + type: GET_PROFILE_SETTINGS_SUCCESS, + payload: { data } + }) + }).catch((err) => { + Alert.error(`Failed to get settings. ${err.message}`) + dispatch({ + type: GET_PROFILE_SETTINGS_FAILURE, + }) + }) +} + +export const uploadProfilePhoto = (file) => (dispatch, getState) => { + dispatch({ + type: SAVE_PROFILE_PHOTO_PENDING + }) + + const state = getState() + const handle = _.get(state, 'loadUser.user.handle') + + memberService.getPreSignedUrl(handle, file) + .then(({ preSignedURL, token }) => { + return uploadFileToS3(preSignedURL, file) + .then(() => memberService.updateMemberPhoto(handle, { + contentType: file.type, + token, + })) + }).then(photoUrl => { + Alert.success('Profile photo uploaded successfully') + // clear photo first, otherwise old photo will be there until new one fully loaded + // which can take time and give impression that new photo hasn't been loaded + dispatch({ + type: CLEAR_PROFILE_SETTINGS_PHOTO, + }) + dispatch({ + type: SAVE_PROFILE_PHOTO_SUCCESS, + payload: { photoUrl } + }) + }).catch(err => { + Alert.error(`Failed to upload photo. ${err.message}`) + dispatch({ + type: SAVE_PROFILE_PHOTO_FAILURE, + }) + }) +} diff --git a/src/routes/settings/components/SettingsPanel.jsx b/src/routes/settings/components/SettingsPanel.jsx index 2f9824f35..e7974af3e 100644 --- a/src/routes/settings/components/SettingsPanel.jsx +++ b/src/routes/settings/components/SettingsPanel.jsx @@ -5,32 +5,45 @@ */ import React from 'react' import PropTypes from 'prop-types' -import cn from 'classnames' + +import Sticky from 'react-stickynode' +import MediaQuery from 'react-responsive' +import TwoColsLayout from '../../../components/TwoColsLayout' +import SettingsSidebar from './SettingsSidebar' + +import { SCREEN_BREAKPOINT_MD } from '../../../config/constants' + import './SettingsPanel.scss' const SettingsPanel = (props) => ( -
-
-

{props.title}

-

- {props.text} - {props.link && {props.link.text}} -

-
{props.children}
-
-
+ + + + {(matches) => { + if (matches) { + return ( + + + + ) + } else { + return + } + }} + + + +
+

{props.title}

+
{props.children}
+
+
+
) SettingsPanel.propTypes = { - isWide: PropTypes.bool, title: PropTypes.string.isRequired, - text: PropTypes.string, - link: PropTypes.shape({ - to: PropTypes.string.isRequired, - text: PropTypes.string.isRequired - }), children: PropTypes.node, - onSaveClick: PropTypes.func } export default SettingsPanel diff --git a/src/routes/settings/components/SettingsPanel.scss b/src/routes/settings/components/SettingsPanel.scss index 3a10f9c1e..85e1255d5 100644 --- a/src/routes/settings/components/SettingsPanel.scss +++ b/src/routes/settings/components/SettingsPanel.scss @@ -1,82 +1,37 @@ // this is to include tc styles in the output library @import '~tc-ui/src/styles/tc-includes'; -:global { - .settings-panel { - margin: 20px auto 0; - padding: 0 20px; - - @media screen and (max-width: $screen-md - 1px) { - margin: 0; - padding: 0; - } - - > .inner { - background-color: $tc-white; - border-radius: 6px; - margin: 0 auto; - max-width: 720px; - padding: 35px 20px 20px 20px; - - @media screen and (max-width: $screen-md - 1px) { - padding: 20px 0; - } - - > .title { - @include roboto-light; - color: $tc-heading-md; - font-size: 32px; - line-height: 30px; - text-align: center; - - @media screen and (max-width: $screen-md - 1px) { - display: none; - } - } - - > .text { - @include roboto; - color: $tc-gray-90; - font-size: 15px; - line-height: 25px; - margin-top: 20px; - - @media screen and (max-width: $screen-md - 1px) { - margin-top: 0; - } - } - - > .content { - border-top: 1px solid $tc-gray-50; - margin-top: 25px; - @media screen and (max-width: $screen-md - 1px) { - margin-top: 0; - } - } - } - - &.wide { - > .inner { - max-width: 960px; - - > .content { - border-top: 0; - } - } - } +.main { + background-color: $tc-white; + border-radius: 6px; + max-width: 760px; + padding: 35px 0px 20px 0px; + flex-grow: 1; + margin: auto; + margin-top: 20px; + + @media screen and (max-width: $screen-md - 1px) { + padding: 0; + margin: 0; + max-width: none; + } +} - a { - @include roboto; - } - // Link colors - a:link, - a:visited { - color: $tc-dark-blue; - } +.title { + @include roboto-light; + color: $tc-black; + font-size: 28px; + line-height: 35px; + text-align: center; - a:hover, - a:active { - color: $tc-dark-blue-70; - } + @media screen and (max-width: $screen-md - 1px) { + display: none; } } + +.content { + margin-top: 25px; + @media screen and (max-width: $screen-md - 1px) { + margin-top: 0; + } +} \ No newline at end of file diff --git a/src/routes/settings/components/SettingsSidebar.jsx b/src/routes/settings/components/SettingsSidebar.jsx new file mode 100644 index 000000000..680974ac6 --- /dev/null +++ b/src/routes/settings/components/SettingsSidebar.jsx @@ -0,0 +1,63 @@ +import React from 'react' +import { Link } from 'react-router-dom' + +import FooterV2 from '../../../components/FooterV2/FooterV2' + +import './SettingsSidebar.scss' + +const settings = [{ + name: 'My profile', + path: '/settings/profile' +}, { + name: 'Account and security', + path: '/settings/account' +}, { + name: 'Notifications', + path: '/settings/notifications' +}] + +const getOption = (selected, setting) => { + const selectedStyle = (setting.name === selected) ? 'selected-option' : '' + return ( + +
+ {setting.name} +
+ + ) +} + +const getMobileOption = (selected, setting) => { + const selectedStyle = (setting.name === selected) ? 'selected' : '' + return ( + +
+ {setting.name} +
+ {(setting.name === selected) && +
+ } + + ) +} + +const Sidebar = ({selected}) => { + return ( +
+
+
+ TOPCODER SETTINGS +
+ {settings.map(getOption.bind(this, selected))} +
+ +
+
+
+ {settings.map(getMobileOption.bind(this, selected))} +
+
+ ) +} + +export default Sidebar \ No newline at end of file diff --git a/src/routes/settings/components/SettingsSidebar.scss b/src/routes/settings/components/SettingsSidebar.scss new file mode 100644 index 000000000..5f3f02c9f --- /dev/null +++ b/src/routes/settings/components/SettingsSidebar.scss @@ -0,0 +1,93 @@ +@import '~tc-ui/src/styles/tc-includes'; + +.container { + min-height: calc(100vh - 60px); + position: relative; + + @media screen and (max-width: $screen-md - 1px) { + min-height: 0; + } +} + +.sidebar { + background: $tc-white; + + @media screen and (max-width: $screen-md - 1px) { + display: none; + } +} + +.topbar::-webkit-scrollbar { + display:none; +} + +.topbar { + background-color: $tc-gray-neutral-light; + box-shadow: 0 1px 0 0 rgba(195, 195, 200, 0.699999988079071), 0 1px 0 0 #DCDCE0; + width: 100%; + height: 100%; + display: inline-flex; + overflow: auto; + + @media screen and (min-width: $screen-md - 1px) { + display: none; + } + + > a { + flex: 0 0 160px; + position: relative; + + > .option-mobile { + @include roboto-bold; + color: $tc-gray-40; + font-size: 13px; + text-align: center; + text-transform: uppercase; + line-height: 50px; + } + + > .selected { + color: $tc-black; + } + } + + .option-highlight { + position: absolute; + height: 2px; + width: 20px; + bottom: 7px; + left: 0; + right: 0; + margin-left: auto; + margin-right: auto; + background: $tc-dark-blue-110; + } +} + +.title { + @include roboto-bold; + color: $tc-black; + font-size: 11px; + padding: 20px 0px 5px 20px; +} + +.options { + @include roboto; + height: 30px; + padding-left: 20px; + color: $tc-black; + font-size: 13px; + line-height: 30px; +} + +.selected-option { + @include roboto-bold; + background-color: $tc-dark-blue-30; +} + +.footer { + bottom: 2 * $base-unit; + padding: 0px 4 * $base-unit; + position: absolute; + width: 100%; +} \ No newline at end of file diff --git a/src/routes/settings/components/SettingsToolBar.jsx b/src/routes/settings/components/SettingsToolBar.jsx index a634d56e3..6b6c71b4c 100644 --- a/src/routes/settings/components/SettingsToolBar.jsx +++ b/src/routes/settings/components/SettingsToolBar.jsx @@ -2,17 +2,11 @@ * Settings pages tool bar */ import React from 'react' -import { NavLink } from 'react-router-dom' import SectionTopBar from '../../../components/TopBar/SectionToolBar' const SettingsToolBar = () => ( Profile, - // System, - Notifications - ]} + title="Settings" /> ) diff --git a/src/routes/settings/helpers/settings.js b/src/routes/settings/helpers/settings.js new file mode 100644 index 000000000..9eb1e1987 --- /dev/null +++ b/src/routes/settings/helpers/settings.js @@ -0,0 +1,92 @@ +/** + * Setting related helper methods + */ +import _ from 'lodash' + +/** + * Format row member traits data to the format which can be rendered by the form + * on profile settings page. + * + * @param {Array} traits list of member traits + * + * @returns {Object} data formated for profile settings page form + */ +export const formatProfileSettings = (traits) => { + const connectTrait = _.find(traits, ['traitId', 'connect_info']) + let data = {} + + if (connectTrait) { + const traitData = _.get(connectTrait, 'traits.data') + if (traitData && traitData.length > 0) { + data = traitData[0] + } + } + + const basicTrait = _.find(traits, ['traitId', 'basic_info']) + if (basicTrait) { + const traitData = _.get(basicTrait, 'traits.data') + if (traitData && traitData.length > 0) { + data.photoUrl = traitData[0].photoURL + data.firstNLastName = `${traitData[0].firstName} ${traitData[0].lastName}` + } + } + + return data +} + +/** + * Applies profile settings from the form to row member traits data. + * This method doesn't mutate traits. + * + * @param {Array} traits list of member traits + * @param {Object} profileSettings profile settings + * + * @returns {Array} updated member traits data + */ +export const applyProfileSettingsToTraits = (traits, profileSettings) => { + const updatedTraits = traits.map((trait) => { + // we put all the info from profile settings to `connect_info` trait as it is, skipping `photoUrl` + if (trait.traitId === 'connect_info') { + const updatedTrait = {...trait} + const updatedProps = _.omit(profileSettings, 'photoUrl') + + updatedTrait.traits = { + ...trait.traits, + data: [{ + ..._.get(trait, 'traits.data[0]'), + ...updatedProps + }] + } + + return updatedTrait + } + + // to the `basic_info` we put just photoUrl, firstName and lastName + if (trait.traitId === 'basic_info') { + const updatedTrait = {...trait} + const [firstName, lastName] = profileSettings.firstNLastName ? profileSettings.firstNLastName.split(/\s+/) : [] + const photoURL = profileSettings.photoUrl + + // update only if new values are defined + const updatedProps = _.omitBy({ + photoURL, + firstName, + lastName, + }, _.isUndefined) + + updatedTrait.traits = { + ...trait.traits, + data: [{ + ..._.get(trait, 'traits.data[0]'), + ...updatedProps + }] + } + + return updatedTrait + } + + return trait + }) + + return updatedTraits +} \ No newline at end of file diff --git a/src/routes/settings/reducers/index.js b/src/routes/settings/reducers/index.js index 580d78521..105747c16 100644 --- a/src/routes/settings/reducers/index.js +++ b/src/routes/settings/reducers/index.js @@ -1,6 +1,7 @@ /** * Settings related reducers */ +import _ from 'lodash' import { CHECK_EMAIL_AVAILABILITY_PENDING, CHECK_EMAIL_AVAILABILITY_SUCCESS, @@ -16,11 +17,26 @@ import { GET_NOTIFICATION_SETTINGS_FAILURE, SAVE_NOTIFICATION_SETTINGS_PENDING, SAVE_NOTIFICATION_SETTINGS_SUCCESS, - SAVE_NOTIFICATION_SETTINGS_FAILURE + SAVE_NOTIFICATION_SETTINGS_FAILURE, + GET_PROFILE_SETTINGS_PENDING, + GET_PROFILE_SETTINGS_SUCCESS, + SAVE_PROFILE_SETTINGS_PENDING, + GET_PROFILE_SETTINGS_FAILURE, + SAVE_PROFILE_SETTINGS_SUCCESS, + SAVE_PROFILE_SETTINGS_FAILURE, + SAVE_PROFILE_PHOTO_PENDING, + SAVE_PROFILE_PHOTO_SUCCESS, + SAVE_PROFILE_PHOTO_FAILURE, + GET_SYSTEM_SETTINGS_PENDING, + GET_SYSTEM_SETTINGS_SUCCESS, + GET_SYSTEM_SETTINGS_FAILURE, + RESET_PASSWORD_PENDING, + RESET_PASSWORD_SUCCESS, + RESET_PASSWORD_FAILURE, + CLEAR_PROFILE_SETTINGS_PHOTO, } from '../../../config/constants' +import { applyProfileSettingsToTraits } from '../helpers/settings' -// TODO initial state with mocked data for demo should be removed -// once service and actions are implemented const initialState = { notifications: { settings: null, @@ -29,16 +45,25 @@ const initialState = { bundleEmail: '24h' }, system: { - email: 'p.monahan@incrediblereality.com' + isLoading: true, + checkingEmail: null, + checkedEmail: null, + isEmailAvailable: undefined, + checkingEmailError: null, + emailSubmitted: false, + isEmailChanging: false, + isPasswordChanging: false, + passwordSubmitted: false, + isResettingPassword: false, + passwordResetSubmitted: false, + + settings: {} }, profile: { - username: 'pat_monahan', - photoSrc: 'https://topcoder-dev-media.s3.amazonaws.com/member/profile/cp-superstar-1473358622637.png', - firstname: 'Patrik', - lastname: 'Monahan', - company: 'Acme Corp.', - mobilephone1: '+1 (555) 555-3240', - mobilephone2: '+1 (555) 555-3240' + isLoading: true, + isUploadingPhoto: false, + pending: false, + traits: [], } } @@ -83,13 +108,36 @@ export default (state = initialState, action) => { } } + case GET_SYSTEM_SETTINGS_PENDING: + return {...state, + system: {...state.system, + isLoading: true + } + } + + case GET_SYSTEM_SETTINGS_SUCCESS: + return {...state, + system: {...state.system, + isLoading: false, + settings: action.payload.data + } + } + + case GET_SYSTEM_SETTINGS_FAILURE: + return {...state, + system: {...state.system, + isLoading: false, + } + } + case CHECK_EMAIL_AVAILABILITY_PENDING: return {...state, system: {...state.system, checkingEmail: action.payload.email, checkedEmail: null, isEmailAvailable: undefined, - checkingEmailError: null + checkingEmailError: null, + emailSubmitted: false, } } @@ -99,7 +147,8 @@ export default (state = initialState, action) => { system: state.system.checkingEmail === action.payload.email ? {...state.system, checkingEmail: null, checkedEmail: action.payload.email, - isEmailAvailable: action.payload.isEmailAvailable + isEmailAvailable: action.payload.isEmailAvailable, + checkingEmailError: null } : state.system } @@ -116,7 +165,10 @@ export default (state = initialState, action) => { case CHANGE_EMAIL_PENDING: return {...state, system: {...state.system, - isEmailChanging: true + isEmailChanging: true, + emailSubmitted: false, + checkedEmail: null, + isEmailAvailable: undefined, } } @@ -124,32 +176,142 @@ export default (state = initialState, action) => { return {...state, system: {...state.system, isEmailChanging: false, - email: action.payload.email + emailSubmitted: true, + settings: action.payload.data } } case CHANGE_EMAIL_FAILURE: return {...state, system: {...state.system, - isEmailChanging: false + isEmailChanging: false, + emailSubmitted: false, } } case CHANGE_PASSWORD_PENDING: return {...state, system: {...state.system, - isPasswordChanging: true + isPasswordChanging: true, + passwordSubmitted: false, } } case CHANGE_PASSWORD_SUCCESS: + return {...state, + system: {...state.system, + passwordSubmitted: true, + isPasswordChanging: false + } + } + case CHANGE_PASSWORD_FAILURE: return {...state, system: {...state.system, + passwordSubmitted: true, isPasswordChanging: false } } + case RESET_PASSWORD_PENDING: + return {...state, + system: {...state.system, + isResettingPassword: true, + passwordResetSubmitted: false, + } + } + + case RESET_PASSWORD_SUCCESS: + return {...state, + system: {...state.system, + isResettingPassword: false, + passwordResetSubmitted: true, + } + } + + case RESET_PASSWORD_FAILURE: + return {...state, + system: {...state.system, + isResettingPassword: false, + passwordResetSubmitted: false, + } + } + + case GET_PROFILE_SETTINGS_PENDING: + return {...state, + profile: {...state.profile, + isLoading: true + } + } + + case GET_PROFILE_SETTINGS_SUCCESS: + return {...state, + profile: {...state.profile, + isLoading: false, + traits: action.payload.data, + } + } + + case GET_PROFILE_SETTINGS_FAILURE: + return {...state, + profile: {...state.profile, + isLoading: false, + } + } + + case SAVE_PROFILE_SETTINGS_PENDING: + return {...state, + profile: {...state.profile, + pending: true, + } + } + + case SAVE_PROFILE_SETTINGS_SUCCESS: + return {...state, + profile: {...state.profile, + pending: false, + traits: action.payload.data + } + } + + case SAVE_PROFILE_SETTINGS_FAILURE: + return {...state, + profile: {...state.profile, + pending: false, + } + } + + case SAVE_PROFILE_PHOTO_PENDING: + return {...state, + profile: {...state.profile, + pending: true, + isUploadingPhoto: true + } + } + + case CLEAR_PROFILE_SETTINGS_PHOTO: + case SAVE_PROFILE_PHOTO_SUCCESS: { + const updatedTraits = applyProfileSettingsToTraits(state.profile.traits, { + photoUrl: _.get(action, 'payload.photoUrl', null), + }) + + return {...state, + profile: {...state.profile, + pending: false, + isUploadingPhoto: false, + traits: updatedTraits, + } + } + } + + case SAVE_PROFILE_PHOTO_FAILURE: + return {...state, + profile: {...state.profile, + pending: false, + isUploadingPhoto: false + } + } + default: return state } diff --git a/src/routes/settings/routes.jsx b/src/routes/settings/routes.jsx index 900456a6e..4c28b2640 100644 --- a/src/routes/settings/routes.jsx +++ b/src/routes/settings/routes.jsx @@ -5,14 +5,19 @@ import React from 'react' import { Route } from 'react-router-dom' import { renderApp } from '../../components/App/App' import TopBarContainer from '../../components/TopBar/TopBarContainer' -import NotificationsToolBar from '../notifications/components/NotificationsToolBar' import NotificationSettingsContainer from './routes/notifications/containers/NotificationSettingsContainer' -// import SettingsToolBar from './components/SettingsToolBar' -// import SystemSettingsContainer from './routes/system/containers/SystemSettingsContainer' -// import ProfileSettingsContainer from './routes/profile/containers/ProfileSettingsContainer' +import SettingsToolBar from './components/SettingsToolBar' +import SystemSettingsContainer from './routes/system/containers/SystemSettingsContainer' +import ProfileSettingsContainer from './routes/profile/containers/ProfileSettingsContainer' +import EmailVerificationFailure from './routes/email-verification/components/Failure' +import EmailVerificationSuccessContainer from './routes/email-verification/containers/SuccessContainer' -export default ( - {/*, )} />*/}, - {/*, )} />*/}, - , )} /> -) +export default [ + , )} />, + , )} />, + , )} />, + + , )} />, + , )} />, + +] diff --git a/src/routes/settings/routes/email-verification/components/Expired/Expired.jsx b/src/routes/settings/routes/email-verification/components/Expired/Expired.jsx new file mode 100644 index 000000000..0de8741bc --- /dev/null +++ b/src/routes/settings/routes/email-verification/components/Expired/Expired.jsx @@ -0,0 +1,26 @@ +/** + * Message to show when new email verification link is expired. + */ +import React from 'react' +import { Link } from 'react-router-dom' +import CoderBot from '../../../../../../components/CoderBot/CoderBot' + +import './Expired.scss' + +const Expired = () => ( + +
    +
  • It has already been verified.
  • +
  • It has expired or has been cancelled, any pending email change that is cancelled is no longer subject to verification.
  • +
+
+ Back to My Account +
+
+) + +export default Expired \ No newline at end of file diff --git a/src/routes/settings/routes/email-verification/components/Expired/Expired.scss b/src/routes/settings/routes/email-verification/components/Expired/Expired.scss new file mode 100644 index 000000000..2882283d2 --- /dev/null +++ b/src/routes/settings/routes/email-verification/components/Expired/Expired.scss @@ -0,0 +1,27 @@ +@import '~tc-ui/src/styles/tc-includes'; + +.list { + @include roboto; + font-size: $tc-label-lg; + color: $tc-gray-70; + letter-spacing: 0px; + line-height: 23px; + padding: 0 168px; + text-align: left; + + @media screen and (max-width: 1000px - 1px) { + padding: 0 100px; + } + + @media screen and (max-width: $screen-md - 1px) { + padding: 0 28px; + } + + li { + list-style: inside circle; + } +} + +.controls { + margin-top: 5 * $base-unit; +} \ No newline at end of file diff --git a/src/routes/settings/routes/email-verification/components/Expired/index.jsx b/src/routes/settings/routes/email-verification/components/Expired/index.jsx new file mode 100644 index 000000000..3f1500447 --- /dev/null +++ b/src/routes/settings/routes/email-verification/components/Expired/index.jsx @@ -0,0 +1,2 @@ +import Expired from './Expired' +export default Expired diff --git a/src/routes/settings/routes/email-verification/components/Failure/Failure.jsx b/src/routes/settings/routes/email-verification/components/Failure/Failure.jsx new file mode 100644 index 000000000..383c6080b --- /dev/null +++ b/src/routes/settings/routes/email-verification/components/Failure/Failure.jsx @@ -0,0 +1,22 @@ +/** + * Message to show when new email verification failed. + */ +import React from 'react' +import { Link } from 'react-router-dom' +import CoderBot from '../../../../../../components/CoderBot/CoderBot' + +import './Failure.scss' + +const Failure = () => ( + +
+ Back to My Account +
+
+) + +export default Failure \ No newline at end of file diff --git a/src/routes/settings/routes/email-verification/components/Failure/Failure.scss b/src/routes/settings/routes/email-verification/components/Failure/Failure.scss new file mode 100644 index 000000000..389525de3 --- /dev/null +++ b/src/routes/settings/routes/email-verification/components/Failure/Failure.scss @@ -0,0 +1,5 @@ +@import '~tc-ui/src/styles/tc-includes'; + +.controls { + margin-top: 5 * $base-unit; +} \ No newline at end of file diff --git a/src/routes/settings/routes/email-verification/components/Failure/index.jsx b/src/routes/settings/routes/email-verification/components/Failure/index.jsx new file mode 100644 index 000000000..ed787dfa3 --- /dev/null +++ b/src/routes/settings/routes/email-verification/components/Failure/index.jsx @@ -0,0 +1,2 @@ +import Failure from './Failure' +export default Failure diff --git a/src/routes/settings/routes/email-verification/components/Success/Success.jsx b/src/routes/settings/routes/email-verification/components/Success/Success.jsx new file mode 100644 index 000000000..d4920535d --- /dev/null +++ b/src/routes/settings/routes/email-verification/components/Success/Success.jsx @@ -0,0 +1,22 @@ +/** + * Message to show when new email verification was success. + */ +import React from 'react' +import { Link } from 'react-router-dom' +import CoderBot from '../../../../../../components/CoderBot/CoderBot' + +import './Success.scss' + +const Success = () => ( + +
+ Back to My Account +
+
+) + +export default Success \ No newline at end of file diff --git a/src/routes/settings/routes/email-verification/components/Success/Success.scss b/src/routes/settings/routes/email-verification/components/Success/Success.scss new file mode 100644 index 000000000..2882283d2 --- /dev/null +++ b/src/routes/settings/routes/email-verification/components/Success/Success.scss @@ -0,0 +1,27 @@ +@import '~tc-ui/src/styles/tc-includes'; + +.list { + @include roboto; + font-size: $tc-label-lg; + color: $tc-gray-70; + letter-spacing: 0px; + line-height: 23px; + padding: 0 168px; + text-align: left; + + @media screen and (max-width: 1000px - 1px) { + padding: 0 100px; + } + + @media screen and (max-width: $screen-md - 1px) { + padding: 0 28px; + } + + li { + list-style: inside circle; + } +} + +.controls { + margin-top: 5 * $base-unit; +} \ No newline at end of file diff --git a/src/routes/settings/routes/email-verification/components/Success/index.jsx b/src/routes/settings/routes/email-verification/components/Success/index.jsx new file mode 100644 index 000000000..762b27654 --- /dev/null +++ b/src/routes/settings/routes/email-verification/components/Success/index.jsx @@ -0,0 +1,2 @@ +import Success from './Success' +export default Success diff --git a/src/routes/settings/routes/email-verification/containers/SuccessContainer.jsx b/src/routes/settings/routes/email-verification/containers/SuccessContainer.jsx new file mode 100644 index 000000000..b2a225bf7 --- /dev/null +++ b/src/routes/settings/routes/email-verification/containers/SuccessContainer.jsx @@ -0,0 +1,71 @@ +/** + * Email verification container. + * + * This container sends request to the backend to verify a new email and shows + * success/failure/expired message according to the backend response. + */ +import React from 'react' +import PT from 'prop-types' +import _ from 'lodash' +import axios from 'axios' +import { withRouter } from 'react-router-dom' + +import LoadingIndicator from '../../../../../components/LoadingIndicator/LoadingIndicator' +import Success from '../components/Success' +import Failure from '../components/Failure' +import Expired from '../components/Expired' + +import { TC_API_URL } from '../../../../../config/constants' + +class SuccessContainer extends React.Component { + constructor(props) { + super(props) + + this.state = { + isLoading: true, + statusCode: null, + } + } + + componentDidMount() { + const { match } = this.props + + setTimeout(() => axios + .post(`${TC_API_URL}/v3/members/${match.params.handle}/verify?newEmail=${match.params.newEmail}&oldEmail=${match.params.oldEmail}&token=${match.params.token}`, {}, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${match.params.jwtToken}` + } + }) + .then(res => this.setState({ statusCode: res.status, isLoading: false })) + .catch(err => this.setState({ statusCode: _.get(err, 'response.status', 400), isLoading: false })), 2000) + } + + render() { + const { isLoading, statusCode } = this.state + + if (isLoading) { + return + } + + switch (statusCode) { + case 200: return + case 401: return + default: return + } + } +} + +SuccessContainer.propTypes = { + match: PT.shape({ + params: PT.shape({ + handle: PT.string.isRequired, + token: PT.string.isRequired, + newEmail: PT.string.isRequired, + oldEmail: PT.string.isRequired, + jwtToken: PT.string.isRequired, + }), + }).isRequired, +} + +export default withRouter(SuccessContainer) \ No newline at end of file diff --git a/src/routes/settings/routes/notifications/components/NotificationSettingsForm.jsx b/src/routes/settings/routes/notifications/components/NotificationSettingsForm.jsx index 34a892f69..8fead62a4 100644 --- a/src/routes/settings/routes/notifications/components/NotificationSettingsForm.jsx +++ b/src/routes/settings/routes/notifications/components/NotificationSettingsForm.jsx @@ -162,20 +162,45 @@ class NotificationSettingsForm extends React.Component { constructor(props) { super(props) + const initialSettings = initSettings(props.values.settings) + this.state = { - settings: initSettings(props.values.settings) + initialSettings, + settings: initialSettings, + dirty: false, } this.handleEmailConfigurationChange = this.handleEmailConfigurationChange.bind(this) this.handleWebConfigurationChange = this.handleWebConfigurationChange.bind(this) + this.onChange = this.onChange.bind(this) + } + + componentWillReceiveProps(newProps) { + // after setting were updated on the server + // reinit form with udpdated values + if (this.props.values.pending && !newProps.values.pending) { + const initialSettings = initSettings(this.props.values.settings) + + this.setState({ + initialSettings, + settings: initialSettings, + dirty: false, + }) + } } handleEmailConfigurationChange(selectedOption, topicIndex) { const notifications = {...this.state.settings.notifications} // update values for all types of the topic topics[topicIndex].types.forEach((type) => { - notifications[type].email.enabled = selectedOption.value === 'off' ? 'no' : 'yes' - notifications[type].email.bundlePeriod = selectedOption.value === 'off' ? '' : selectedOption.value + notifications[type] = { + ...notifications[type], + email: { + ...notifications[type].email, + enabled: selectedOption.value === 'off' ? 'no' : 'yes', + bundlePeriod: selectedOption.value === 'off' ? '' : selectedOption.value + } + } }) this.setState({ @@ -183,7 +208,7 @@ class NotificationSettingsForm extends React.Component { ...this.state.settings, notifications, } - }) + }, this.onChange) } stopPropagation(e) { @@ -197,7 +222,13 @@ class NotificationSettingsForm extends React.Component { // update values for all types of the topic topics[topicIndex].types.forEach((type) => { - notifications[type].web.enabled = notifications[type].web.enabled === 'yes' ? 'no' : 'yes' + notifications[type] = { + ...notifications[type], + web: { + ...notifications[type].web, + enabled: notifications[type].web.enabled === 'yes' ? 'no' : 'yes' + } + } }) this.setState({ @@ -205,7 +236,19 @@ class NotificationSettingsForm extends React.Component { ...this.state.settings, notifications, } - }) + }, this.onChange) + } + + isChanged() { + return !_.isEqual(this.state.initialSettings, this.state.settings) + } + + onChange() { + const isChanged = this.isChanged() + + if (this.state.dirty !== isChanged) { + this.setState({ dirty: isChanged }) + } } render() { @@ -313,7 +356,7 @@ class NotificationSettingsForm extends React.Component {
- +
) diff --git a/src/routes/settings/routes/notifications/components/NotificationSettingsForm.scss b/src/routes/settings/routes/notifications/components/NotificationSettingsForm.scss index 94d23bbf1..7709d23b8 100644 --- a/src/routes/settings/routes/notifications/components/NotificationSettingsForm.scss +++ b/src/routes/settings/routes/notifications/components/NotificationSettingsForm.scss @@ -3,6 +3,12 @@ :global { .notification-settings-form { + padding: 0px 20px; + + @media screen and (max-width: $screen-md - 1px) { + padding: 0px; + } + > .table { border-collapse: collapse; border-radius: 2 * $corner-radius; diff --git a/src/routes/settings/routes/profile/components/ProfileSeetingsAvatar.jsx b/src/routes/settings/routes/profile/components/ProfileSeetingsAvatar.jsx deleted file mode 100644 index ce6bb5727..000000000 --- a/src/routes/settings/routes/profile/components/ProfileSeetingsAvatar.jsx +++ /dev/null @@ -1,60 +0,0 @@ -/** - * Profile avatar settings - */ -import React from 'react' -import PropTypes from 'prop-types' -import FileBtn from '../../../../../components/FileBtn/FileBtn' -import Alert from 'react-s-alert' -import './ProfileSeetingsAvatar.scss' - -class ProfileSeetingsAvatar extends React.Component { - constructor(props) { - super(props) - - this.state = { - photoSrc: props.defaultPhotoSrc - } - - this.onFileChange = this.onFileChange.bind(this) - } - - onFileChange(evt) { - if (evt.target.files && evt.target.files.length > 0) { - const file = evt.target.files[0] - - // for browsers which don't restrict file type by accept param - if (!file.type.match(/image\/*/)) { - Alert.error('Please, choose an image of type jpeg or png.') - return - } - - const reader = new FileReader() - - reader.onload = (e) => { - this.setState({ photoSrc: e.target.result }) - } - - reader.readAsDataURL(file) - } - } - - render() { - const { photoSrc } = this.state - - return ( -
-
Your Avatar
- -
- -
-
- ) - } -} - -ProfileSeetingsAvatar.propTypes = { - defaultPhotoSrc: PropTypes.string -} - -export default ProfileSeetingsAvatar diff --git a/src/routes/settings/routes/profile/components/ProfileSeetingsAvatar.scss b/src/routes/settings/routes/profile/components/ProfileSeetingsAvatar.scss deleted file mode 100644 index 2eb8c8408..000000000 --- a/src/routes/settings/routes/profile/components/ProfileSeetingsAvatar.scss +++ /dev/null @@ -1,31 +0,0 @@ -// this is to include tc styles in the output library -@import '~tc-ui/src/styles/tc-includes'; - -:global { - .profile-settings-avatar { - padding-bottom: 27px; - text-align: center; - - > .label { - @include roboto; - color: $tc-gray-70; - font-size: 13px; - line-height: 20px; - text-align: center; - } - - > .photo { - box-sizing: content-box; - border: 2px solid $tc-gray-50; - border-radius: 50%; - height: 120px; - margin-top: 10px; - width: 120px; - } - - > .controls { - margin-top: 15px; - } - } -} - \ No newline at end of file diff --git a/src/routes/settings/routes/profile/components/ProfileSettingsAvatar.jsx b/src/routes/settings/routes/profile/components/ProfileSettingsAvatar.jsx new file mode 100644 index 000000000..614ef54ac --- /dev/null +++ b/src/routes/settings/routes/profile/components/ProfileSettingsAvatar.jsx @@ -0,0 +1,48 @@ +/** + * Profile avatar settings + */ +import React from 'react' +import PropTypes from 'prop-types' +import FileBtn from '../../../../../components/FileBtn/FileBtn' +import './ProfileSettingsAvatar.scss' + +class ProfileSettingsAvatar extends React.Component { + constructor(props) { + super(props) + this.onFileChange = this.onFileChange.bind(this) + } + + onFileChange(evt) { + const { uploadPhoto } = this.props + if (evt.target.files && evt.target.files.length > 0) { + const file = evt.target.files[0] + uploadPhoto(file) + } + } + + render() { + const { photoUrl, isUploading } = this.props + const label = isUploading ? 'Uploading, please wait' : 'Upload a new photo' + return ( +
+ +
+ +
+
+ ) + } +} + +ProfileSettingsAvatar.propTypes = { + photoUrl: PropTypes.string, + isUploading: PropTypes.bool, + uploadPhoto: PropTypes.func +} + +export default ProfileSettingsAvatar diff --git a/src/routes/settings/routes/profile/components/ProfileSettingsAvatar.scss b/src/routes/settings/routes/profile/components/ProfileSettingsAvatar.scss new file mode 100644 index 000000000..a5898e70b --- /dev/null +++ b/src/routes/settings/routes/profile/components/ProfileSettingsAvatar.scss @@ -0,0 +1,45 @@ +// this is to include tc styles in the output library +@import '~tc-ui/src/styles/tc-includes'; + +:global { + .profile-settings-avatar { + display: flex; + justify-content: center; + text-align: center; + align-items: center; + + > .label { + @include roboto; + color: $tc-gray-70; + font-size: 13px; + line-height: 20px; + text-align: center; + } + + > .photo { + -webkit-box-sizing: content-box; + box-sizing: content-box; + background: $tc-gray-10; + border-radius: 50%; + height: 80px; + width: 80px; + } + + > .controls { + margin-left: 20px; + + > .file-btn { + > button { + @include roboto-medium; + background-color: $tc-dark-blue-100; + border-radius: 4px; + height: 30px; + border: none; + color: $tc-white; + font-size: 13px; + line-height: 30px; + } + } + } + } +} diff --git a/src/routes/settings/routes/profile/components/ProfileSettingsForm.jsx b/src/routes/settings/routes/profile/components/ProfileSettingsForm.jsx index 35a614fd9..d15a0470e 100644 --- a/src/routes/settings/routes/profile/components/ProfileSettingsForm.jsx +++ b/src/routes/settings/routes/profile/components/ProfileSettingsForm.jsx @@ -1,72 +1,151 @@ /** * Profile settings form */ -import React from 'react' +import React, { Component } from 'react' import PropTypes from 'prop-types' import FormsyForm from 'appirio-tech-react-components/components/Formsy' const TCFormFields = FormsyForm.Fields const Formsy = FormsyForm.Formsy -import TextInputWithCounter from '../../../../../components/TextInputWithCounter/TextInputWithCounter' -import ProfileSeetingsAvatar from './ProfileSeetingsAvatar' -import { MAX_USERNAME_LENGTH } from '../../../../../config/constants' +import ProfileSettingsAvatar from './ProfileSettingsAvatar' import './ProfileSettingsForm.scss' -import IconImage from '../../../../../assets/icons/users-16px_single-01.svg' +const companySizeRadioOptions = ['1-15', '16-50', '51-500', '500+'] +class ProfileSettingsForm extends Component { + constructor(props) { + super(props) + this.state = { + valid: false, + dirty: false, + } + this.onSubmit = this.onSubmit.bind(this) + this.onValid = this.onValid.bind(this) + this.onInvalid = this.onInvalid.bind(this) + this.onChange = this.onChange.bind(this) + } -const ProfileSettingsForm = (props) => { - const { username, photoSrc, firstname, lastname, company, mobilephone1, mobilephone2 } = props - - return ( - - - + getField(label, name, isRequired=false) { + let validations = null + if (name === 'businessPhone') { + validations = { + matchRegexp: /^([+]?\d{1,2}[.-\s]?)?(\d{3}[.-]?){2}\d{4}$/ + } + } + return (
-
-
- -
+
{label}
+
+ ) + } -
- -
+ onSubmit(data) { + // we have to use initial data as a base for updated data + // as form could update not all fields, thus they won't be included in `data` + // for example user avatar is not included in `data` thus will be removed if don't use + // this.props.values.settings as a base + const updatedDate = { + ...this.props.values.settings, + ...data, + } + this.props.saveSettings(updatedDate) + } -
- -
+ onValid() { + this.setState({valid: true}) + } -
- -
+ onInvalid() { + this.setState({valid: false}) + } -
- -
+ onChange(currentValues, isChanged) { + if (this.state.dirty !== isChanged) { + this.setState({ dirty: isChanged }) + } + } -
- -
- -
- -
-
- ) + render() { + return ( + +
Personal information
+
+
Avatar
+ +
+ {this.getField('First and last name', 'firstNLastName', true)} + {this.getField('Title', 'title', true)} + {this.getField('Business phone', 'businessPhone', true)} + {this.getField('Company name', 'companyName', true)} +
+
Company size
+ ({option: label, label, value: label}))} + required + /> +
+
Business address
+ {this.getField('Address', 'address')} + {this.getField('City', 'city')} +
+
State
+
+ +
ZIP
+ +
+
+ {this.getField('Country', 'country')} +
+ +
+
+ ) + } } ProfileSettingsForm.propTypes = { - username: PropTypes.string, - photoSrc: PropTypes.string, - firstname: PropTypes.string, - lastname: PropTypes.string, - company: PropTypes.string, - mobilephone1: PropTypes.string, - mobilephone2: PropTypes.string, - onSubmit: PropTypes.func + values: PropTypes.object.isRequired, + saveSettings: PropTypes.func.isRequired, + uploadPhoto: PropTypes.func.isRequired } export default ProfileSettingsForm diff --git a/src/routes/settings/routes/profile/components/ProfileSettingsForm.scss b/src/routes/settings/routes/profile/components/ProfileSettingsForm.scss index 3c74617df..1d15ba9e6 100644 --- a/src/routes/settings/routes/profile/components/ProfileSettingsForm.scss +++ b/src/routes/settings/routes/profile/components/ProfileSettingsForm.scss @@ -3,45 +3,132 @@ :global { .profile-settings-form { - margin: 18px auto 0; - width: 452px; - + padding: 0px 20px; + + @media screen and (max-width: $screen-md - 1px) { + padding: 0px; + } + > .field { + display: flex; + flex-wrap: wrap; + align-items: center; + + @media screen and (max-width: $screen-md - 1px) { + flex-direction: column; + align-items: flex-start; + padding: 0px 20px 0px 10px; + } + &:not(:first-child) { margin-top: 32px; + + @media screen and (max-width: $screen-md - 1px) { + margin-top: 20px; + } + } + + .label { + @include roboto-medium; + flex: 1 0 120px; + min-width: 180px; + max-width: 200px; + text-align: right; + padding-right: 15px; + color: $tc-gray-80; + font-size: 15px; + line-height: 18px; + + @media screen and (max-width: $screen-md - 1px) { + flex: none; + margin-bottom: 10px; + margin-left: 10px; + text-align: left; + } + } + + > .radio-group-input { + display: flex; + justify-content: center; + + > .radio-group-options { + flex-wrap: wrap; + } + } + + .input-field { + flex-grow: 1; + max-width: 450px; + + @media screen and (max-width: $screen-md - 1px) { + width: 100%; + max-width: none; + } + + > label { + display: none; + } + + > input { + margin-bottom: 0px; + } + } + + .zip-container { + display: flex; + max-width: 450px; + flex-grow: 1; + + @media screen and (max-width: $screen-md - 1px) { + flex-direction: column; + align-items: flex-start; + } + } + + .zip { + max-width: 30px; + min-width: 30px; + margin-left: 40px; + margin-right: 15px; + text-align: center; + line-height: 40px; + + @media screen and (max-width: $screen-md - 1px) { + margin: 30px 0px 15px 5px; + line-height: 0px; + } + } + + .zip-input { + max-width: 80px; } } - - .username { - position: relative; - - > .username-icon { - bottom: 13px; - content: ''; - display: block; - left: 12px; - pointer-events: none; - position: absolute; - z-index: 1; - - > svg { - display: block; - } - - > svg > path { - fill: $tc-gray-30; - } - } - - input { - padding-left: 36px; + + .section-heading { + @include roboto-bold; + background-color: $tc-gray-neutral-dark; + border-radius: 4px 4px 0 0; + height: 50px; + color: $tc-black; + font-size: 15px; + text-align: left; + line-height: 50px; + padding-left: 20px; + margin-bottom: 40px; + + &:not(:first-child) { + margin-top: 60px; + + @media screen and (max-width: $screen-md - 1px) { + margin-top: 20px; + } } } - + > .controls { - margin-top: 30px; + margin-top: 20px; + margin-bottom: 20px; text-align: center; } } } - \ No newline at end of file diff --git a/src/routes/settings/routes/profile/containers/ProfileSettingsContainer.jsx b/src/routes/settings/routes/profile/containers/ProfileSettingsContainer.jsx index cf66d8c2a..ce6f53407 100644 --- a/src/routes/settings/routes/profile/containers/ProfileSettingsContainer.jsx +++ b/src/routes/settings/routes/profile/containers/ProfileSettingsContainer.jsx @@ -1,35 +1,58 @@ /** * Container for profile settings */ -import React from 'react' +import React, { Component } from 'react' import PropTypes from 'prop-types' import { connect } from 'react-redux' import ProfileSettingsForm from '../components/ProfileSettingsForm' import SettingsPanel from '../../../components/SettingsPanel' +import spinnerWhileLoading from '../../../../../components/LoadingSpinner' import { requiresAuthentication } from '../../../../../components/AuthenticatedComponent' +import { getProfileSettings, saveProfileSettings, uploadProfilePhoto } from '../../../actions/index' +import { formatProfileSettings } from '../../../helpers/settings' -const ProfileSettingsContainer = (props) => { - const { profileSettings } = props - - return ( - - - - ) +const enhance = spinnerWhileLoading(props => !props.values.isLoading) +const ProfileSettingsFormEnhanced = enhance(ProfileSettingsForm) +class ProfileSettingsContainer extends Component { + componentDidMount() { + this.props.getProfileSettings() + } + + render() { + const { profileSettings, saveProfileSettings, uploadProfilePhoto } = this.props + + return ( + + + + ) + } } ProfileSettingsContainer.propTypes = { - profileSettings: PropTypes.object.isRequired + profileSettings: PropTypes.object.isRequired, + getProfileSettings: PropTypes.func.isRequired } const ProfileSettingsContainerWithAuth = requiresAuthentication(ProfileSettingsContainer) const mapStateToProps = ({ settings }) => ({ - profileSettings: settings.profile + profileSettings: { + ...settings.profile, + settings: formatProfileSettings(settings.profile.traits) + } }) -export default connect(mapStateToProps)(ProfileSettingsContainerWithAuth) +const mapDispatchToProps = { + getProfileSettings, + saveProfileSettings, + uploadProfilePhoto, +} + +export default connect(mapStateToProps, mapDispatchToProps)(ProfileSettingsContainerWithAuth) diff --git a/src/routes/settings/routes/system/components/ChangeEmailForm.jsx b/src/routes/settings/routes/system/components/ChangeEmailForm.jsx index 6a1d7ea26..c4dce38a4 100644 --- a/src/routes/settings/routes/system/components/ChangeEmailForm.jsx +++ b/src/routes/settings/routes/system/components/ChangeEmailForm.jsx @@ -13,21 +13,23 @@ import FormsyForm from 'appirio-tech-react-components/components/Formsy' const TCFormFields = FormsyForm.Fields const Formsy = FormsyForm.Formsy import './ChangeEmailForm.scss' -import IconCheck from '../../../../../assets/icons/check.svg' - +import LoadingIndicator from '../../../../../components/LoadingIndicator/LoadingIndicator' class ChangeEmailForm extends React.Component { constructor(props) { super(props) this.state = { + isFocused: false, isValid: true, - currentEmail: this.props.email + currentEmail: this.props.settings.email } this.onValid = this.onValid.bind(this) this.onInvalid = this.onInvalid.bind(this) this.onChange = this.onChange.bind(this) + this.cancel = this.cancel.bind(this) + this.submit = this.submit.bind(this) // debounced checkEmailAvailability function to prevent polluting server with requests this.debouncedAvailabilityCheck = _.debounce(this.checkEmailAvailability, EMAIL_AVAILABILITY_CHECK_DEBOUNCE) @@ -51,13 +53,24 @@ class ChangeEmailForm extends React.Component { } } + cancel() { + this.formRef && this.formRef.updateInputsWithError({}) + this.emailRef.setValue(this.props.settings.email) + this.debouncedAvailabilityCheck.cancel() + this.setState({isFocused: false}) + } + + submit() { + this.props.onSubmit(this.state.currentEmail) + } + checkEmailAvailability() { this.props.checkEmailAvailability(this.state.currentEmail) } onValid() { // if we haven't changed email, then don't check it - if (this.state.currentEmail !== this.props.email) { + if (this.state.currentEmail !== this.props.settings.email) { this.debouncedAvailabilityCheck() } else { // cancel availability check if current email hasn't been changed @@ -73,52 +86,85 @@ class ChangeEmailForm extends React.Component { } onChange(data) { - this.setState({ currentEmail: data.email }) + if (data.email === this.props.settings.email) { + this.setState({currentEmail: data.email, isFocused: false}) + } else { + this.setState({ currentEmail: data.email, isFocused: true }) + } // clear all server validation errors when email is changed this.formRef && this.formRef.updateInputsWithError({}) } render() { - const { email: initialEmail, onSubmit, checkingEmail, checkedEmail, isEmailAvailable, isEmailChanging } = this.props - const { currentEmail, isValid } = this.state + const { settings, checkingEmail, checkedEmail, isEmailAvailable, isEmailChanging, emailSubmitted } = this.props + const { currentEmail, isValid, isFocused } = this.state const currentEmailAvailable = checkedEmail === currentEmail && isEmailAvailable const isCheckingCurrentEmail = checkingEmail === currentEmail - const isEmailChanged = initialEmail !== currentEmail + const isEmailChanged = settings.email !== currentEmail const isDisabledSubmit = !isValid || !currentEmailAvailable || !isEmailChanged || isEmailChanging + const hideActions = !isFocused || emailSubmitted + let formStyle = '' + + if (isFocused) { + formStyle = 'focused-form' + if (emailSubmitted) { + formStyle = 'submitted-form' + } + } return ( this.formRef = ref} > -
- -
- {isCheckingCurrentEmail && } - {isEmailChanged && currentEmailAvailable && } +
+
+ Email +
+
+ this.emailRef = ref} + /> + { isFocused && isCheckingCurrentEmail && ( +
+ Verifying email + +
+ )} + { (isFocused && isEmailChanged && currentEmailAvailable || isEmailChanging) && ( +
+ Email is available +
+ )} + { isFocused && emailSubmitted && ( +
+ We sent you a verification email, please check your mailbox +
+ )}
-
- -
+ { !hideActions && +
+ {!isEmailChanging && } + +
+ } ) } diff --git a/src/routes/settings/routes/system/components/ChangeEmailForm.scss b/src/routes/settings/routes/system/components/ChangeEmailForm.scss index 14879de12..552c3c047 100644 --- a/src/routes/settings/routes/system/components/ChangeEmailForm.scss +++ b/src/routes/settings/routes/system/components/ChangeEmailForm.scss @@ -2,31 +2,121 @@ @import '~tc-ui/src/styles/tc-includes'; :global { + .focused-form { + background: $tc-dark-blue-10; + } + + .submitted-form { + background: $tc-yellow-30; + } + .change-email-form { - > .field { - position: relative; - - > .field-status { - bottom: 4px; - height: 32px; - position: absolute; - right: -34px; - width: 32px; - - > svg { - margin: 8px 0 0 8px; - - > polygon { - fill: $tc-green-70 + padding-top: 10px; + margin-top: 10px; + padding-right: 20px; + + @media screen and (max-width: $screen-md - 1px) { + padding-top: 0px; + } + + > .email-container { + display: flex; + flex-wrap: wrap; + + @media screen and (max-width: $screen-md - 1px) { + flex-direction: column; + align-items: flex-start; + padding: 0px 0px 0px 10px; + } + + > .label { + @include roboto-medium; + flex: 1 0 120px; + min-width: 180px; + max-width: 200px; + text-align: right; + padding-right: 15px; + color: $tc-gray-80; + font-size: 15px; + line-height: 18px; + padding-top: 15px; + + @media screen and (max-width: $screen-md - 1px) { + flex: none; + margin-bottom: 10px; + margin-left: 10px; + text-align: left; + } + } + + > .field { + flex-grow: 1; + max-width: 450px; + min-width: 350px; + position: relative; + + @media screen and (max-width: $screen-md - 1px) { + max-width: none; + min-width: 0; + width: 100%; + } + + .error-message { + background: none; + border: none; + text-align: right; + padding: 0; + } + + > .field-status { + @include roboto; + bottom: 4px; + height: 32px; + width: 100%; + display: flex; + justify-content: flex-end; + align-items: center; + font-size: 13px; + line-height: 20px; + color: $tc-gray-80; + + > .loading-indicator { + margin-left: 10px; + } + + > svg { + margin: 8px 0 0 8px; + + > polygon { + fill: $tc-green-70 + } } } + + .success-status { + color: $tc-green-110; + } + + .red-status { + color: $tc-red-110; + font-style: italic; + } } } - + > .controls { - margin-top: 10px; + padding: 10px 0px; text-align: center; + + > button { + &:not(:first-child) { + margin-left: 10px; + } + } + + > button[disabled] { + filter: none; + } } } } - \ No newline at end of file diff --git a/src/routes/settings/routes/system/components/ChangePasswordForm.jsx b/src/routes/settings/routes/system/components/ChangePasswordForm.jsx index b4ff64acc..ae7ea8315 100644 --- a/src/routes/settings/routes/system/components/ChangePasswordForm.jsx +++ b/src/routes/settings/routes/system/components/ChangePasswordForm.jsx @@ -7,7 +7,7 @@ import React from 'react' import PropTypes from 'prop-types' import { PASSWORD_MIN_LENGTH, PASSWORD_REG_EXP } from '../../../../../config/constants' import FormsyForm from 'appirio-tech-react-components/components/Formsy' -const TCFormFields = FormsyForm.Fields +import PasswordInput from 'appirio-tech-react-components/components/Formsy/PasswordInput' const Formsy = FormsyForm.Formsy import './ChangePasswordForm.scss' @@ -16,62 +16,195 @@ class ChangePasswordForm extends React.Component { super() this.state = { - isValid: true + isValid: true, + isFocused: false, + showReset: false, + currentPassword: '', + newPassword: '', + verifyPassword: '', + forcedError: { + newPassword: null, + verifyPassword: null, + } } this.onValid = this.onValid.bind(this) this.onInvalid = this.onInvalid.bind(this) + this.onPasswordChange = this.onPasswordChange.bind(this) + this.onCancel = this.onCancel.bind(this) + this.onSubmit = this.onSubmit.bind(this) + this.onShowReset = this.onShowReset.bind(this) + } + + componentWillReceiveProps(nextProps) { + if (nextProps.passwordSubmitted && this.props.passwordSubmitted === false) { + this.formRef && this.formRef.reset() + this.setState({isFocused: false}) + } } onValid() { - this.setState({ isValid: true }) + const { forcedError } = this.state + const isValid = forcedError.newPassword === null && forcedError.verifyPassword === null + this.setState({ isValid }) } onInvalid() { this.setState({ isValid: false }) } - render() { - const { onSubmit, isPasswordChanging } = this.props - const { isValid } = this.state - const isDisabledSubmit = !isValid || isPasswordChanging + onCancel() { + this.formRef && this.formRef.reset() + this.setState({ isFocused: false, isValid: false, showReset: false, forcedError: {}}) + } + + validate(state) { + const errors = { + newPassword: null, + verifyPassword: null, + } + if (state.newPassword !== '' && state.currentPassword === '') { + errors.newPassword = 'Enter your current password' + } + if (state.verifyPassword !== '' && state.verifyPassword !== state.newPassword) { + errors.verifyPassword = 'Passwords do not match' + } + return errors + } + handleVerifyPassword(value) { + this.setState({verifyPassword: value}) + } + + onPasswordChange(type, value) { + const newState = {...this.state, + [type]: value, + isFocused: true, + } + newState.forcedError = this.validate(newState) + this.setState(newState) + } + + onShowReset() { + this.setState({isFocused: true, showReset: true}) + } + + getPasswordField(key, name, validations, errMsg) { + const { isPasswordChanging } = this.props + const { forcedError } = this.state + if (forcedError[key] !== null) { + validations = null + } + return ( +
+
+ {name} +
+
+ + { (key === 'currentPassword') &&
+ Forgot password? +
} +
+
+ ) + } + + onSubmit() { + this.props.onSubmit({ + password: this.state.newPassword, + currentPassword: this.state.currentPassword + }) + } + + passwordForm() { + const { isPasswordChanging } = this.props + const { isValid, isFocused } = this.state + const isDisabledSubmit = !isValid || isPasswordChanging return ( this.formRef = ref} > - - -
- -
+ {this.getPasswordField('currentPassword', 'Current password')} + {this.getPasswordField('newPassword', 'New password', { + minLength: PASSWORD_MIN_LENGTH, + matchRegexp: PASSWORD_REG_EXP, + isRequired: true + }, 'Password should be 8-64 characters, use A-Z, a-z, 0-9, ! ? . = _')} + {this.getPasswordField('verifyPassword', 'Verify new password', null, 'Passwords do not match')} + + { isFocused && +
+ {!isPasswordChanging && } + +
+ }
) } + + resetPasswordForm() { + const { isResettingPassword, passwordResetSubmitted, onReset } = this.props + const title = (passwordResetSubmitted) + ? 'We sent you password reset instructions' : 'Reset your password' + return ( +
+
+ {title} +
+
+ { passwordResetSubmitted && + Please follow the instructions otherwise you won’t be able to log in back to Topcoder. + If you experience problems please contact Support. + } + { !passwordResetSubmitted && + No worries, we all forget sometimes. + We will send you a password reset link to your email address. + Please follow the instruction in the email + } +
+ { !passwordResetSubmitted && +
+ {!isResettingPassword && } + +
+ } +
+ ) + } + + render() { + const { isFocused, showReset } = this.state + const formClass = (isFocused) ? 'focused-container' : '' + return ( +
+ {!showReset && this.passwordForm()} + {showReset && this.resetPasswordForm()} +
+ ) + } } ChangePasswordForm.propTypes = { onSubmit: PropTypes.func.isRequired, + onReset: PropTypes.func.isRequired, + passwordSubmitted: PropTypes.bool, + isResettingPassword: PropTypes.bool, isPasswordChanging: PropTypes.bool } diff --git a/src/routes/settings/routes/system/components/ChangePasswordForm.scss b/src/routes/settings/routes/system/components/ChangePasswordForm.scss index 7ef151f0f..6e656f64e 100644 --- a/src/routes/settings/routes/system/components/ChangePasswordForm.scss +++ b/src/routes/settings/routes/system/components/ChangePasswordForm.scss @@ -2,11 +2,170 @@ @import '~tc-ui/src/styles/tc-includes'; :global { + .focused-container { + padding: 10px 0px 10px 0px; + background: $tc-dark-blue-10; + } + + .reset-container { + display: flex; + flex-direction: column; + align-items: center; + padding: 20px 0px 20px 20px; + + > .reset-heading { + @include roboto-bold; + color: $tc-gray-100; + font-size: 20px; + text-align: center; + } + + > .reset-body { + @include roboto; + color: $tc-gray-100; + font-size: 15px; + line-height: 25px; + max-width: 500px; + margin-top: 10px; + + a, + a:hover, + a:active, + a:visited { + color: $tc-dark-blue-110; + } + } + + > .controls { + margin-top: 30px; + + > button { + &:not(:first-child) { + margin-left: 10px; + } + } + + > button[disabled] { + filter: none; + } + } + + } + .change-password-form { + padding-right: 20px; + + @media screen and (max-width: $screen-md - 1px) { + padding: 0px; + } + + > .field { + display: flex; + flex-wrap: wrap; + + @media screen and (max-width: $screen-md - 1px) { + flex-direction: column; + align-items: flex-start; + padding: 0px 20px 0px 10px; + } + + &:not(:first-child) { + margin-top: 32px; + + @media screen and (max-width: $screen-md - 1px) { + margin-top: 20px; + } + } + + .label { + @include roboto-medium; + flex: 1 0 120px; + min-width: 180px; + max-width: 200px; + text-align: right; + padding-right: 15px; + color: $tc-gray-80; + font-size: 15px; + line-height: 18px; + margin-top: 10px; + + @media screen and (max-width: $screen-md - 1px) { + flex: none; + margin-bottom: 10px; + margin-left: 10px; + text-align: left; + } + } + + .input-field { + flex-grow: 1; + max-width: 450px; + + @media screen and (max-width: $screen-md - 1px) { + width: 100%; + max-width: none; + } + + .hint { + @include roboto; + color: $tc-dark-blue-110; + font-size: 13px; + text-align: right; + cursor: pointer; + margin-top: 5px; + } + + .error-message { + background: none; + border: none; + text-align: right; + padding: 0; + } + + label { + display: none; + } + + input { + margin-bottom: 0px; + } + + svg { + position: absolute; + right: -20px; + top: 17px; + + path { + fill: $tc-black; + } + + @media screen and (max-width: $screen-md - 1px) { + right: 5px; + top: -15px; + } + } + + .show-hide-button { + height: 38px; + cursor: pointer; + } + } + } + > .controls { margin-top: 10px; + padding: 10px 0px; text-align: center; + + > button { + &:not(:first-child) { + margin-left: 10px; + } + } + + > button[disabled] { + filter: none; + } } } } - \ No newline at end of file diff --git a/src/routes/settings/routes/system/components/SystemSettingsForm.jsx b/src/routes/settings/routes/system/components/SystemSettingsForm.jsx new file mode 100644 index 000000000..7e92ffe3c --- /dev/null +++ b/src/routes/settings/routes/system/components/SystemSettingsForm.jsx @@ -0,0 +1,62 @@ +import React, { Component } from 'react' +import ChangeEmailForm from '../components/ChangeEmailForm' +import ChangePasswordForm from '../components/ChangePasswordForm' +import FormsyForm from 'appirio-tech-react-components/components/Formsy' +import './SystemSettingsForm.scss' + +const Formsy = FormsyForm.Formsy +const TCFormFields = FormsyForm.Fields + +class SystemSettingsForm extends Component { + render() { + const { changePassword, checkEmailAvailability, changeEmail, systemSettings, resetPassword } = this.props + return ( +
+ +
+ Account details +
+ +
+
+ Username +
+ + +
+ To change the username please get in touch with support +
+
+
+ +
+ changeEmail(email)} + {...systemSettings} + /> +
+ +
+ Retrieve or change your password +
+ +
+ changePassword(data)} + onReset={() => resetPassword()} + {...systemSettings} + /> +
+
+ ) + } +} + +export default SystemSettingsForm \ No newline at end of file diff --git a/src/routes/settings/routes/system/components/SystemSettingsForm.scss b/src/routes/settings/routes/system/components/SystemSettingsForm.scss new file mode 100644 index 000000000..4ee0a70ed --- /dev/null +++ b/src/routes/settings/routes/system/components/SystemSettingsForm.scss @@ -0,0 +1,121 @@ +@import '~tc-ui/src/styles/tc-includes'; + +.system-settings-container { + display: flex; + flex-direction: column; + padding-bottom: 30px; +} + +.username { + display: flex; + flex-wrap: wrap; + padding-right: 20px; + + @media screen and (max-width: $screen-md - 1px) { + flex-direction: column; + align-items: flex-start; + padding: 0px 20px 0px 10px; + widows: 100%; + } + + .label { + @include roboto-medium; + flex: 1 0 120px; + min-width: 180px; + max-width: 200px; + text-align: right; + padding-right: 15px; + color: $tc-gray-80; + font-size: 15px; + line-height: 18px; + padding-top: 10px; + + @media screen and (max-width: $screen-md - 1px) { + flex: none; + margin-bottom: 10px; + margin-left: 10px; + text-align: left; + } + } + + .input-container { + flex-grow: 1; + + @media screen and (max-width: $screen-md - 1px) { + width: 100%; + } + + :global { + .input-field { + flex-grow: 1; + max-width: 450px; + + @media screen and (max-width: $screen-md - 1px) { + width: 100%; + max-width: none; + } + + > label { + display: none; + } + + > input { + margin-bottom: 0px; + background-color: $tc-gray-neutral-light; + border: 1px solid $tc-gray-neutral-dark; + border-radius: 4px; + } + } + } + } + + .username-hint { + @include roboto; + color: $tc-gray-50; + font-size: 13px; + max-width: 450px; + line-height: 20px; + text-align: right; + margin-top: 5px; + + > a, + > a:hover, + > a:active, + > a:visited { + color: $tc-dark-blue-110; + } + + @media screen and (max-width: $screen-md - 1px) { + width: 100%; + max-width: none; + } + } +} + +.section-heading { + @include roboto-bold; + background-color: $tc-gray-neutral-dark; + border-radius: 4px 4px 0 0; + height: 50px; + color: $tc-black; + font-size: 15px; + text-align: left; + line-height: 50px; + padding-left: 20px; + margin: 0px 20px 40px 20px; + + &:not(:first-child) { + margin-top: 60px; + margin-bottom: 18px; + + @media screen and (max-width: $screen-md - 1px) { + margin-top: 20px; + } + } + + @media screen and (max-width: $screen-md - 1px) { + margin-left: 0px; + margin-right: 0px; + margin-bottom: 18px; + } +} diff --git a/src/routes/settings/routes/system/containers/SystemSettingsContainer.jsx b/src/routes/settings/routes/system/containers/SystemSettingsContainer.jsx index bade17380..aacaa98d2 100644 --- a/src/routes/settings/routes/system/containers/SystemSettingsContainer.jsx +++ b/src/routes/settings/routes/system/containers/SystemSettingsContainer.jsx @@ -1,48 +1,36 @@ /** * Container for system settings */ -import React from 'react' +import React, { Component } from 'react' import PropTypes from 'prop-types' import { connect } from 'react-redux' +import spinnerWhileLoading from '../../../../../components/LoadingSpinner' import SettingsPanel from '../../../components/SettingsPanel' -import ChangeEmailForm from '../components/ChangeEmailForm' -import ChangePasswordForm from '../components/ChangePasswordForm' -import { checkEmailAvailability, changeEmail, changePassword } from '../../../actions' +import { checkEmailAvailability, changeEmail, changePassword, getSystemSettings, resetPassword } from '../../../actions' import { requiresAuthentication } from '../../../../../components/AuthenticatedComponent' +import SystemSettingsForm from '../components/SystemSettingsForm' import './SystemSettingsContainer.scss' -const SystemSettingsContainer = (props) => { - const { systemSettings, checkEmailAvailability, changeEmail, changePassword } = props - - return ( - -
-
- changePassword(data.password)} - {...systemSettings} - /> -
- -
- changeEmail(data.email)} - {...systemSettings} - /> -
- -
- -
-
-
- ) +const enhance = spinnerWhileLoading(props => !props.systemSettings.isLoading) +const FormEnhanced = enhance(SystemSettingsForm) + +class SystemSettingsContainer extends Component { + componentDidMount() { + this.props.getSystemSettings() + } + + render() { + return ( + + + + ) + } } SystemSettingsContainer.propTypes = { @@ -56,9 +44,11 @@ const mapStateToProps = ({ settings }) => ({ }) const mapDispatchToProps = { + getSystemSettings, checkEmailAvailability, changeEmail, - changePassword + changePassword, + resetPassword, } export default connect(mapStateToProps, mapDispatchToProps)(SystemSettingsContainerWithAuth) diff --git a/src/routes/settings/routes/system/containers/SystemSettingsContainer.scss b/src/routes/settings/routes/system/containers/SystemSettingsContainer.scss index ce0ef7cc8..296460421 100644 --- a/src/routes/settings/routes/system/containers/SystemSettingsContainer.scss +++ b/src/routes/settings/routes/system/containers/SystemSettingsContainer.scss @@ -2,15 +2,14 @@ .system-settings-container { margin: 42px auto 0; width: 452px; - + > .controls { margin-top: 30px; text-align: center; } - + > .form:not(:first-child) { margin-top: 33px; } } } - \ No newline at end of file diff --git a/src/routes/settings/services/settings.js b/src/routes/settings/services/settings.js index 52295aea5..50b1e13a3 100644 --- a/src/routes/settings/services/settings.js +++ b/src/routes/settings/services/settings.js @@ -1,78 +1,31 @@ /** * Mocked service for settings - * - * TODO has to be replaced with the real service */ import { axiosInstance as axios } from '../../../api/requestInterceptor' import { TC_NOTIFICATION_URL } from '../../../config/constants' -// mocked fetching timeout -const mockedTimeout = 1000 - -const mockedFetch = (errorMessage, data) => new Promise((resolve, reject) => { - setTimeout(() => { - if (errorMessage) { - reject(new Error(errorMessage)) - } else { - resolve(data) - } - }, mockedTimeout) -}) - -const checkEmailAvailability = (email) => { - // for demo we only treat these emails as available - const isAvailable = ['p.monahan@incrediblereality.com', 'good@test.com', 'bad@test.com'].indexOf(email) > -1 - let mockedResponse - - // for demo throw error when email like this - if (email === 'error@test.com') { - mockedResponse = mockedFetch('This is mocked request error when email "error@test.com" is entered.') - } else { - mockedResponse = mockedFetch(null, isAvailable) - } - - return mockedResponse -} - -const changeEmail = (email) => { - let mockedResponse - - // for demo throw error when email like this - if (email === 'bad@test.com') { - mockedResponse = mockedFetch('This is mocked request error when email is changed to "bad@test.com".') - } else { - mockedResponse = mockedFetch(null, email) - } - - return mockedResponse -} - -const changePassword = (password) => { - let mockedResponse - - // for demo throw error when password like this - if (password === 'fake-password') { - mockedResponse = mockedFetch('This is mocked request error when password is changed to "fake-password".') - } else { - mockedResponse = mockedFetch(null) - } - - return mockedResponse -} - +/** + * Get notifications settings + * + * @returns {Promise} notifications settings + */ const getNotificationSettings = () => { return axios.get(`${TC_NOTIFICATION_URL}/settings`) .then(resp => resp.data) } +/** + * Save notifications settings + * + * @param {Object} notifications settings + * + * @returns {Promise} notifications settings + */ const saveNotificationSettings = (data) => { return axios.put(`${TC_NOTIFICATION_URL}/settings`, data) } export default { - checkEmailAvailability, - changeEmail, - changePassword, getNotificationSettings, - saveNotificationSettings + saveNotificationSettings, }