diff --git a/.gitignore b/.gitignore index ac4ff706..847e0a84 100644 --- a/.gitignore +++ b/.gitignore @@ -103,5 +103,5 @@ dist # TernJS port file .tern-port -# macOS files +# macOS cache files .DS_Store diff --git a/README.md b/README.md index 4a827f43..a637dcb4 100644 --- a/README.md +++ b/README.md @@ -39,14 +39,6 @@ For application constants which don't depend on the running environment use `src | `npm run watch-tests` | Watch for file changes and run unit tests on changes | | `npm run coverage` | Generate test code coverage report | -## Local Deployment - -Inside the project folder run: - -- `npm i` - install dependencies -- `npm run dev` - run app in development mode -- As this app can be loaded only inside a frame single-spa, you have to run a `micro-frontends-frame` frame app and configure it to use the URL `http://localhost:8501/topcoder-micro-frontends-teams.js`. - ## Deployment to Production - `npm i` - install dependencies @@ -65,6 +57,81 @@ Make sure you have [Heroku CLI](https://devcenter.heroku.com/articles/heroku-cli - `git push heroku master` - push changes to Heroku and trigger deploying - Now you have to configure frame app to use the URL provided by Heroku like `https://.herokuapp.com/topcoder-micro-frontends-teams.js` to load this micro-app. -## Verification +## How to run Locally for Development + +TaaS App is done using Single SPA micro-frontend architecture https://single-spa.js.org/. So to start it, we would also have to run Frame App and Navbar App. Here I would show the steps to run locally everything we need. + +### Local Authentication + +First of all, to authenticate locally we have to run a local authentication service. +- Clone this repository into `taas-app`. +- Inside the folder `taas-app/local/login-locally` run `npm run start`. +- You would need npm 5+ for it. This would start a local sever on port 5000 which could be used for local Authentication. + +### Local Domain + +Some config files are using domain `local.topcoder-dev.com`. You can change it to `localhost` in all the configs of each repo mentioned below. Or on your local machine, update file `/etc/hosts` add the line `127.0.0.1 local.topcoder-dev.com`. This file has another path on Windows. + +### Run Applications + +1. Run **Frame** App: + ```sh + git clone https://github.com/topcoder-platform/micro-frontends-frame.git + cd micro-frontends-frame + + # inside folder "micro-frontends-frame" run: + + nvm use # or make sure to use Node 10 + npm i # to install dependencies + + # set environment variables: + + export APPMODE="development" + export APPENV="local-multi" + + npm run local-server + ``` + + open one more terminal window in the same folder and run: + + ```sh + # set environment variables: + + export APPMODE="development" + export APPENV="local-multi" + + npm run local-client + ``` + +2. Run **Navbar** micro-app: + ```sh + git clone https://github.com/topcoder-platform/micro-frontends-navbar-app.git + cd micro-frontends-navbar-app + ``` + + Update in file `micro-frontends-navbar-app/blob/dev/config/dev.js` values for `ACCOUNTS_APP_CONNECTOR` and `AUTH` to `http://localhost:5000` so Navbar app which handles authentication uses our local Authentication service. + + ```sh + # inside folder "micro-frontends-navbar-app" run: + + nvm use # or make sure to use Node 10 + npm i # to install dependencies + + npm run dev + ``` + +3. Run **TaaS** micro-app: + ```sh + # inside folder "taas-app" run: + + nvm use # or make sure to use Node 10 + npm i # to install dependencies + + npm run dev + ``` + +- Now open in the browser http://localhost:8080/taas/myteams. +- If you are not logged-in yet, you should be redirected to the login page. +- If you cannot see the application and redirect doesn't happen, make sure that file "http://local.topcoder-dev.com:8501/taas-app/topcoder-micro-frontends-teams.js" is loaded successfully in the Network tab. -Please check [verification-guide.md](verification-guide.md) +Congratulations, you successfully run the project. If you had some issue, please, try to go through README of https://github.com/topcoder-platform/micro-frontends-frame and https://github.com/topcoder-platform/micro-frontends-navbar-app. \ No newline at end of file diff --git a/local/login-locally/README b/local/login-locally/README new file mode 100644 index 00000000..a72052b8 --- /dev/null +++ b/local/login-locally/README @@ -0,0 +1,11 @@ +1. Run any static files server inside this directory, for example: + - `npx http-server . -p 5000` + - `php -S localhost:5000` +2. Run your application on http://localhost:3000. +3. Now you can open http://localhost:5000 in browser and click login (wait it a little bit, it may take time to redirect you). After you login, you should be redirected back to http://localhost:3000 + +PS. You may also download latest version of `setupAuth0WithRedirect.js` file from here - https://github.com/topcoder-platform/tc-auth-lib/blob/dev/web-assets/js/setupAuth0WithRedirect.js + + + + diff --git a/local/login-locally/index.html b/local/login-locally/index.html new file mode 100644 index 00000000..5217f1bc --- /dev/null +++ b/local/login-locally/index.html @@ -0,0 +1,18 @@ + + + + + Auth0 + + + + + + Loaded...redirecting to auth0.(see browser console log) + + Login + + + diff --git a/local/login-locally/package.json b/local/login-locally/package.json new file mode 100644 index 00000000..d710a7d3 --- /dev/null +++ b/local/login-locally/package.json @@ -0,0 +1,11 @@ +{ + "name": "login-locally", + "version": "1.0.0", + "description": "Run any static files server inside this directory.", + "scripts": { + "start": "npx http-server . -p 5000" + }, + "keywords": [], + "author": "", + "license": "ISC" +} diff --git a/local/login-locally/setupAuth0WithRedirect.js b/local/login-locally/setupAuth0WithRedirect.js new file mode 100644 index 00000000..8f6bdaf9 --- /dev/null +++ b/local/login-locally/setupAuth0WithRedirect.js @@ -0,0 +1,628 @@ +var script = document.createElement('script'); +script.src = "https://cdn.auth0.com/js/auth0-spa-js/1.10/auth0-spa-js.production.js"; +script.type = 'text/javascript'; +script.defer = true; +document.getElementsByTagName('head').item(0).appendChild(script); + +/** + * read query string + * + */ +const qs = (function (a) { + if (a == "") return {}; + let b = {}; + for (let i = 0; i < a.length; ++i) { + let p = a[i].split('=', 2); + if (p.length == 1) + b[p[0]] = ""; + else + b[p[0]] = decodeURIComponent(p[1].replace(/\+/g, " ")); + } + return b; +})(window.location.search.substr(1).split('&')); + +const authSetup = function () { + + let domain = 'auth.topcoder-dev.com'; + const clientId = 'BXWXUWnilVUPdN01t2Se29Tw2ZYNGZvH'; + const useLocalStorage = false; + const useRefreshTokens = false; + const v3JWTCookie = 'v3jwt'; + const tcJWTCookie = 'tcjwt'; + const tcSSOCookie = 'tcsso'; + const cookieExpireIn = 12 * 60; // 12 hrs + const refreshTokenInterval = 30000; // in milliseconds + const refreshTokenOffset = 65; // in seconds + const shouldLogout = qs['logout']; + const regSource = qs['regSource']; + const utmSource = qs['utm_source']; + const utmMedium = qs['utm_medium']; + const utmCampaign = qs['utm_campaign']; + const loggerMode = "dev"; + const IframeLogoutRequestType = "LOGOUT_REQUEST"; + const enterpriseCustomers = ['zurich', 'cs']; + const mode = qs['mode'] || 'signIn'; + let returnAppUrl = qs['retUrl']; + let appUrl = qs['appUrl'] || false; + + if (utmSource && + (utmSource != 'undefined') && + (enterpriseCustomers.indexOf(utmSource) > -1)) { + domain = "topcoder-dev.auth0.com"; + returnAppUrl += '&utm_source=' + utmSource; + } + + + var auth0 = null; + var isAuthenticated = false; + var idToken = null; + var callRefreshTokenFun = null; + var host = window.location.protocol + "//" + window.location.host + const registerSuccessUrl = host + '/register_success.html'; + + const init = function () { + correctOldUrl(); + changeWindowMessage(); + createAuth0Client({ + domain: domain, + client_id: clientId, + cacheLocation: useLocalStorage + ? 'localstorage' + : 'memory', + useRefreshTokens: useRefreshTokens + }).then(_init).catch(function (e) { + logger("Error occurred in initializing auth0 object: ", e); + window.location.reload(); + }); + window.addEventListener("message", receiveMessage, false); + }; + + const _init = function (authObj) { + auth0 = authObj + if (qs['code'] && qs['state']) { + auth0.handleRedirectCallback().then(function (data) { + logger('handleRedirectCallback() success: ', data); + showAuth0Info(); + storeToken(); + }).catch(function (e) { + logger('handleRedirectCallback() error: ', e); + }); + } else if (shouldLogout) { + host = returnAppUrl ? returnAppUrl : host; + logout(); + return; + } else if (!isLoggedIn() && returnAppUrl) { + login(); + } else if (qs['error'] && qs['state']) { + logger("Error in executing callback(): ", qs['error_description']); + showLoginError(qs['error_description'], appUrl); + } else { + logger("User already logged in", true); + postLogin(); + } + showAuthenticated(); + }; + + const showAuthenticated = function () { + auth0.isAuthenticated().then(function (isAuthenticated) { + isAuthenticated = isAuthenticated; + logger("_init:isAuthenticated", isAuthenticated); + }); + }; + + const refreshToken = function () { + let d = new Date(); + logger('checking token status at: ', `${d.getHours()}::${d.getMinutes()}::${d.getSeconds()} `); + var token = getCookie(v3JWTCookie); + if (!token || isTokenExpired(token)) { + logger('refreshing token... at: ', `${d.getHours()}::${d.getMinutes()}::${d.getSeconds()} `); + try { + let issuerHostname = ""; + if (token) { + let tokenJson = decodeToken(token); + let issuer = tokenJson.iss; + issuerHostname = extractHostname(issuer); + } + if (domain !== issuerHostname) { + domain = issuerHostname; + logger("reintialize auth0 for new domain..", domain); + createAuth0Client({ + domain: domain, + client_id: clientId, + cacheLocation: useLocalStorage + ? 'localstorage' + : 'memory', + useRefreshTokens: useRefreshTokens + }).then(function (newAuth0Obj) { + auth0 = newAuth0Obj; + auth0.getTokenSilently().then(function (token) { + showAuth0Info(); + storeToken(); + logger("refreshing token for new domain..", domain); + }).catch(function (e) { + logger("Error in refreshing token: ", e) + if (e.error && ((e.error == "login_required") || (e.error == "timeout"))) { + clearInterval(callRefreshTokenFun); + clearAllCookies(); + } + } + ); + }); + } else { + auth0.getTokenSilently().then(function (token) { + showAuth0Info(); + storeToken(); + }).catch(function (e) { + logger("Error in refreshing token: ", e) + if (e.error && ((e.error == "login_required") || (e.error == "timeout"))) { + clearInterval(callRefreshTokenFun); + clearAllCookies(); + } + } + ); + } + } catch (e) { + logger("Error in refresh token function ", e.message) + } + + } + }; + + const showAuth0Info = function () { + auth0.getUser().then(function (user) { + logger("User Profile: ", user); + }); + auth0.getIdTokenClaims().then(function (claims) { + idToken = claims.__raw; + logger("JWT Token: ", idToken); + }); + }; + + const login = function () { + auth0 + .loginWithRedirect({ + redirect_uri: host + '?appUrl=' + returnAppUrl, + regSource: regSource, + utmSource: utmSource, + utmCampaign: utmCampaign, + utmMedium: utmMedium, + returnUrl: returnAppUrl, + mode: mode + }) + .then(function () { + auth0.isAuthenticated().then(function (isAuthenticated) { + isAuthenticated = isAuthenticated; + if (isAuthenticated) { + showAuth0Info(); + storeToken(); + postLogin(); + } + }); + }); + }; + + const logout = function () { + clearAllCookies(); + auth0.logout({ + returnTo: host + }); + }; + + const clearAllCookies = function () { + // TODO + setCookie(tcJWTCookie, "", -1); + setCookie(v3JWTCookie, "", -1); + setCookie(tcSSOCookie, "", -1); + + // to clear any old session + setCookie('auth0Jwt', "", -1); + setCookie('zendeskJwt', "", -1); + setCookie('auth0Refresh', "", -1); + // for scorecard + setCookie('JSESSIONID', "", -1); + } + + const isLoggedIn = function () { + var token = getCookie(v3JWTCookie); + return token ? !isTokenExpired(token) : false; + }; + + const redirectToApp = function () { + logger("redirect to app", appUrl); + if (appUrl) { + window.location = appUrl; + } + }; + + const postLogin = function () { + if (isLoggedIn() && returnAppUrl) { + auth0.isAuthenticated().then(function (isAuthenticated) { + if (isAuthenticated) { + window.location = returnAppUrl; + } else { + login(); // old session exist case + } + }); + } + logger('calling postLogin: ', true); + logger('callRefreshTokenFun: ', callRefreshTokenFun); + if (callRefreshTokenFun != null) { + clearInterval(callRefreshTokenFun); + } + refreshToken(); + callRefreshTokenFun = setInterval(refreshToken, refreshTokenInterval); + } + + const storeToken = function () { + auth0.getIdTokenClaims().then(function (claims) { + idToken = claims.__raw; + let userActive = false; + Object.keys(claims).findIndex(function (key) { + if (key.includes('active')) { + userActive = claims[key]; + return true; + } + return false; + }); + if (userActive) { + let tcsso = ''; + Object.keys(claims).findIndex(function (key) { + if (key.includes(tcSSOCookie)) { + tcsso = claims[key]; + return true; + } + return false; + }); + logger('Storing token...', true); + try { + const exT = getCookieExpiry(idToken); + if (exT) { + setDomainCookie(tcJWTCookie, idToken, exT); + setDomainCookie(v3JWTCookie, idToken, exT); + setDomainCookie(tcSSOCookie, tcsso, exT); + } else { + setCookie(tcJWTCookie, idToken, cookieExpireIn); + setCookie(v3JWTCookie, idToken, cookieExpireIn); + setCookie(tcSSOCookie, tcsso, cookieExpireIn); + } + } catch (e) { + logger('Error occured in fecthing token expiry time', e.message); + } + + // session still active, but app calling login + if (!appUrl && returnAppUrl) { + appUrl = returnAppUrl + } + redirectToApp(); + } else { + logger("User active ? ", userActive); + host = registerSuccessUrl; + logout(); + } + }).catch(function (e) { + logger("Error in fetching token from auth0: ", e); + }); + }; + + /////// Token.js + + function getTokenExpirationDate(token) { + const decoded = decodeToken(token); + if (typeof decoded.exp === 'undefined') { + return null; + } + const d = new Date(0); // The 0 here is the key, which sets the date to the epoch + d.setUTCSeconds(decoded.exp); + return d; + } + + function decodeToken(token) { + const parts = token.split('.'); + + if (parts.length !== 3) { + throw new Error('The token is invalid'); + } + + const decoded = urlBase64Decode(parts[1]) + + if (!decoded) { + throw new Error('Cannot decode the token'); + } + + // covert base64 token in JSON object + let t = JSON.parse(decoded); + return t; + } + + function isTokenExpired(token, offsetSeconds = refreshTokenOffset) { + const d = getTokenExpirationDate(token) + + if (d === null) { + return false; + } + + // Token expired? + return !(d.valueOf() > (new Date().valueOf() + (offsetSeconds * 1000))); + } + + function urlBase64Decode(str) { + let output = str.replace(/-/g, '+').replace(/_/g, '/') + + switch (output.length % 4) { + case 0: + break; + + case 2: + output += '==' + break; + + case 3: + output += '=' + break; + + default: + throw 'Illegal base64url string!'; + } + return decodeURIComponent(escape(atob(output))); //polyfill https://github.com/davidchambers/Base64.js + } + + function setCookie(cname, cvalue, exMins) { + const cdomain = getHostDomain(); + + let d = new Date(); + d.setTime(d.getTime() + (exMins * 60 * 1000)); + + let expires = ";expires=" + d.toUTCString(); + document.cookie = cname + "=" + cvalue + cdomain + expires + ";path=/"; + } + + function getCookie(name) { + const v = document.cookie.match('(^|;) ?' + name + '=([^;]*)(;|$)'); + return v ? v[2] : undefined; + } + // end token.js + + function getHostDomain() { + let hostDomain = ""; + if (location.hostname !== 'localhost') { + hostDomain = ";domain=." + + location.hostname.split('.').reverse()[1] + + "." + location.hostname.split('.').reverse()[0]; + } + return hostDomain; + } + + function correctOldUrl() { + const pattern = '#!/member'; + const sso_pattern = '/#!/sso-login'; + const logout_pattern = '/#!/logout?'; + + const url = window.location.href; + + const result = url.match(/^(.+)(\#!\/(.+)\?)(.+)/); + + if (result) { + try { + const newUrl = result[1] + "?" + result[4]; + logger("new url: ", newUrl); + window.location.href = newUrl; + } catch (e) { + logger("Creating new url error: ", e.message); + } + } + // Need to cleanup below code, should never execute + if (window.location.href.indexOf(pattern) > -1) { + window.location.href = window.location.href.replace(pattern, ''); + } + + if (window.location.href.indexOf(sso_pattern) > -1) { + window.location.href = window.location.href.replace(sso_pattern, ''); + } + + if (window.location.href.indexOf(logout_pattern) > -1) { + window.location.href = window.location.href.replace(logout_pattern, '/?logout=true&'); + } + } + + function logger(label, message) { + if (loggerMode === "dev") { + console.log(label, message); + } + } + + /** + * will receive message from iframe + */ + function receiveMessage(e) { + logger("received Event:", e); + if (e.data && e.data.type && e.origin) { + if (e.data.type === IframeLogoutRequestType) { + host = e.origin; + logout(); + } + } + if (e.data && e.data.type && e.data.type === "REFRESH_TOKEN") { + const token = getCookie(v3JWTCookie); + const failed = { + type: "FAILURE" + }; + const success = { + type: "SUCCESS" + }; + + const informIt = function (payload) { + e.source.postMessage(payload, e.origin); + } + try { + const storeRefreshedToken = function (aObj) { + aObj.getIdTokenClaims().then(function (claims) { + idToken = claims.__raw; + let userActive = false; + Object.keys(claims).findIndex(function (key) { + if (key.includes('active')) { + userActive = claims[key]; + return true; + } + return false; + }); + if (userActive) { + let tcsso = ''; + Object.keys(claims).findIndex(function (key) { + if (key.includes(tcSSOCookie)) { + tcsso = claims[key]; + return true; + } + return false; + }); + logger('Storing refreshed token...', true); + try { + const exT = getCookieExpiry(idToken); + if (exT) { + setDomainCookie(tcJWTCookie, idToken, exT); + setDomainCookie(v3JWTCookie, idToken, exT); + setDomainCookie(tcSSOCookie, tcsso, exT); + } else { + setCookie(tcJWTCookie, idToken, cookieExpireIn); + setCookie(v3JWTCookie, idToken, cookieExpireIn); + setCookie(tcSSOCookie, tcsso, cookieExpireIn); + } + informIt(success); + } catch (e) { + logger('Error occured in fecthing token expiry time', e.message); + informIt(failed); + } + } else { + logger("Refeshed token - user active ? ", userActive); + informIt(failed); + } + }).catch(function (err) { + logger("Refeshed token - error in fetching token from auth0: ", err); + informIt(failed); + }); + }; + + const getToken = function (aObj) { + aObj.getTokenSilently({ timeoutInSeconds: 60 }).then(function (token) { + storeRefreshedToken(aObj); + }).catch(function (err) { + logger("receiveMessage: Error in refreshing token through iframe:", err) + informIt(failed); + }); + + }; + + // main execution start here + if (token && !isTokenExpired(token)) { + informIt(success); + } else if (!token) { + const auth0Session = getCookie('auth0.is.authenticated'); + logger('auth0 session available ?', auth0Session); + if (auth0Session) { + logger('auth session true', 1); + if (!auth0) { + createAuth0Client({ + domain: domain, + client_id: clientId, + cacheLocation: useLocalStorage + ? 'localstorage' + : 'memory', + useRefreshTokens: useRefreshTokens + }).then(function (newAuth0Obj) { + getToken(newAuth0Obj); + }).catch(function (e) { + logger("Error occurred in re-initializing auth0 object: ", e); + informIt(failed); + }); + } else { + getToken(auth0); + } + } else { + informIt(failed); + } + + } else { + if (auth0) { + getToken(auth0); + } else { + informIt(failed); + } + } + } catch (e) { + logger("error occured in iframe handler:", e.message); + informIt(failed); + } + } else { + // do nothing + } + } + + function changeWindowMessage() { + + if ((!returnAppUrl && !appUrl) || ((returnAppUrl == 'undefined') && (appUrl == 'undefined'))) { + try { + var hdomain = location.hostname.split('.').reverse()[1]; + var linkurl = "http://" + window.location.host + "/?logout=true&retUrl=http://" + window.location.host; + if (hdomain) { + linkurl = "https://" + window.location.host + "/?logout=true&retUrl=https://" + hdomain + ".com"; + } + document.getElementById("page-title-heading").innerHTML = "Alert"; + document.getElementById("loading_message_p").innerHTML = "Login/Logout action is not called. Please check return url (retUrl) value in query parameters or click here"; + } catch (err) { + logger("Error in changing loading message: ", err.message) + } + } + } + + function extractHostname(url) { + var hostname; + //find & remove protocol (http, ftp, etc.) and get hostname + + if (url.indexOf("//") > -1) { + hostname = url.split('/')[2]; + } + else { + hostname = url.split('/')[0]; + } + //find & remove port number + hostname = hostname.split(':')[0]; + //find & remove "?" + hostname = hostname.split('?')[0]; + + return hostname; + } + + function showLoginError(message, linkUrl) { + try { + document.getElementById("page-title-heading").innerHTML = "Alert"; + document.getElementById("loading_message_p").innerHTML = message + " click here"; + } catch (err) { + logger("Error in changing loading message: ", err.message) + } + } + + function getCookieExpiry(token) { + const d = getTokenExpirationDate(token) + if (d === null) { + return false; + } + const diff = d.valueOf() - (new Date().valueOf()); //in millseconds + if (diff > 0) { + return diff; // in milliseconds + } + return false; + } + + function setDomainCookie(cname, cvalue, exMilliSeconds) { + const cdomain = getHostDomain(); + + let d = new Date(); + d.setTime(d.getTime() + exMilliSeconds); + + let expires = ";expires=" + d.toUTCString(); + document.cookie = cname + "=" + cvalue + cdomain + expires + ";path=/"; + } + + + // execute + init(); +}; diff --git a/src/constants/index.js b/src/constants/index.js index 6afdbdf5..7b54640b 100644 --- a/src/constants/index.js +++ b/src/constants/index.js @@ -279,9 +279,9 @@ export const RESOURCE_TYPE_OPTIONS = [ ]; /** - * status options + * job status options */ -export const STATUS_OPTIONS = [ +export const JOB_STATUS_OPTIONS = [ { value: "sourcing", label: "sourcing" }, { value: "in-review", label: "in-review" }, { value: "assigned", label: "assigned" }, @@ -289,6 +289,15 @@ export const STATUS_OPTIONS = [ { value: "cancelled", label: "cancelled" }, ]; +/** + * resource booking status options + */ +export const RESOURCE_BOOKING_STATUS_OPTIONS = [ + { value: "assigned", label: "assigned" }, + { value: "closed", label: "closed" }, + { value: "cancelled", label: "cancelled" }, +]; + /* * show error message below the markdown editor when the markedown editor is disabled */ diff --git a/src/routes/JobForm/utils.js b/src/routes/JobForm/utils.js index cc6743ae..0ee34141 100644 --- a/src/routes/JobForm/utils.js +++ b/src/routes/JobForm/utils.js @@ -6,7 +6,7 @@ import { hasPermission } from "utils/permissions"; import { DISABLED_DESCRIPTION_MESSAGE } from "constants"; import { RATE_TYPE_OPTIONS, - STATUS_OPTIONS, + JOB_STATUS_OPTIONS, WORKLOAD_OPTIONS, RESOURCE_TYPE_OPTIONS, FORM_ROW_TYPE, @@ -120,7 +120,7 @@ export const getEditJobConfig = ( isRequired: true, validationMessage: "Please, select Status", name: "status", - selectOptions: STATUS_OPTIONS, + selectOptions: JOB_STATUS_OPTIONS, disabled: !hasPermission(PERMISSIONS.UPDATE_JOB_STATUS), }, ], diff --git a/src/routes/ResourceBookingForm/index.jsx b/src/routes/ResourceBookingForm/index.jsx index ba9d3813..f351e0bb 100644 --- a/src/routes/ResourceBookingForm/index.jsx +++ b/src/routes/ResourceBookingForm/index.jsx @@ -21,6 +21,7 @@ import withAuthentication from "../../hoc/withAuthentication"; import TCForm from "../../components/TCForm"; import { getEditResourceBookingConfig } from "./utils"; import "./styles.module.scss"; +import moment from "moment"; const ResourceBookingDetails = ({ teamId, resourceBookingId }) => { const [submitting, setSubmitting] = useState(false); @@ -66,8 +67,8 @@ const ResourceBookingDetails = ({ teamId, resourceBookingId }) => { // as we are using `PUT` method (not `PATCH`) we have send ALL the fields // fields which we don't send would become `null` otherwise - const getRequestData = (values) => - _.pick(values, [ + const getRequestData = (values) => { + const data = _.pick(values, [ "projectId", "userId", "jobId", @@ -79,6 +80,17 @@ const ResourceBookingDetails = ({ teamId, resourceBookingId }) => { "rateType", ]); + // convert dates to the API format before sending + if (data.startDate) { + data.startDate = moment(data.startDate).format('YYYY-MM-DD') + } + if (data.endDate) { + data.endDate = moment(data.endDate).format('YYYY-MM-DD') + } + + return data + } + return ( {!formData ? ( diff --git a/src/routes/ResourceBookingForm/utils.js b/src/routes/ResourceBookingForm/utils.js index 9f990c5c..4a425715 100644 --- a/src/routes/ResourceBookingForm/utils.js +++ b/src/routes/ResourceBookingForm/utils.js @@ -6,7 +6,7 @@ import moment from "moment"; import _ from "lodash"; import { - STATUS_OPTIONS, + RESOURCE_BOOKING_STATUS_OPTIONS, FORM_ROW_TYPE, FORM_FIELD_TYPE, } from "../../constants"; @@ -95,7 +95,7 @@ export const getEditResourceBookingConfig = (onSubmit) => { isRequired: true, validationMessage: "Please, select Status", name: "status", - selectOptions: STATUS_OPTIONS, + selectOptions: RESOURCE_BOOKING_STATUS_OPTIONS, }, ], onSubmit: onSubmit, diff --git a/verification-guide.md b/verification-guide.md deleted file mode 100644 index ad43ffb9..00000000 --- a/verification-guide.md +++ /dev/null @@ -1,191 +0,0 @@ -# Verification Guide - -## Local Deployment - -Please, use Node.js `10` and Npm `6` as they work good for all the micro-apps. - -I've made small changes to the **micro-frontends-frame** and **micro-frontends-navbar-app** provided on the forum. So use updated code from my submission. - -Before running the apps, add into your `/etc/hosts` the `line 127.0.0.1 local.topcoder-dev.com` so you could use domain `local.topcoder-dev.com` for the local testing. Alternatively, you may update file `micro-frontends-frame/config/local.json` to use domain `localhost` instead of `local.topcoder-dev.com`. Note, that without using domain `local.topcoder-dev.com` authorization would not work. - -Run each of 3 applications and Mock API server in a new terminal window. - -1. Run **micro-frontends-frame** app (provided on forum and updated): - - ```bash - cd micro-frontends-frame - - npm install - - npm run local - ``` - - This would host the **frame** app on http://local.topcoder-dev.com:3000/. - -2. Run **micro-frontends-navbar-app** app (provided on forum and updated): - - ```bash - cd micro-frontends-navbar-app - - npm install - - npm run dev - ``` - - This would host the **navbar** app on http://local.topcoder-dev.com:8080/topcoder-micro-frontends-navbar-app.js (cannot open outside frame). - -3. Run **micro-frontends-teams** app (this app) - - ```bash - - npm install - - npm run dev - ``` - - This would host the **teams** app on http://local.topcoder-dev.com:8501/topcoder-micro-frontends-teams.js (cannot open outside frame). - -4. Run Mock API server: - - ```bash - cd local/mock-server - - npm install - - npm run start - ``` - - This would host Mock API on http://local.topcoder-dev.com:8502. - -## Verification - -### Browser Support - -Open listing page http://localhost:3000/taas/myteams and details page http://localhost:3000/taas/myteams/1 in the latest Chrome, Safar, FireFox and Edge browsers. - -### Responsiveness - -To verify that UI is fluid and don't get broken on any screen size, change the width of the browser from the biggest width until `320px`. - -- See that UI adapts to any screen size, it's not getting broken and still shows data nicely. - -### Data Variations - -UI outputs and formats various data. We have to make sure, that all the supportable values are displayed and formatted correctly including extremely small, big and average. - -#### Weekly Cost - -Should format money with 1 digits, 3 digits, 4 digits and many digit like 9, for example: - -![](docs/data-weekly-cost.png) - -#### Remaining Time - -Remaining time should handle situations when we have less than 1 week left or if time is already passed: - -![](docs/data-remaining-time.png) - -#### Rating - -Star rating should handle edge cases like: full rating, zero rating and also ratings which have decimal points like `3.38`. Each of the teams demonstrate some of these cases: - -- http://localhost:3000/taas/myteams/1 - - ![](docs/data-rating-full.png) - -- http://localhost:3000/taas/myteams/3 - - ![](docs/data-rating-decimal.png) - -- http://localhost:3000/taas/myteams/4 - - ![](docs/data-rating-empty.png) - -#### No members or positions - -UI should handle gracefully, the situation when team doesn't have any members or positions. This is demonstrated by the 4th team http://localhost:3000/taas/myteams/4: - -![](docs/data-no-members-positions.png) - -### Dropdown/Tooltips and browser boundaries - -All the dropdowns and tooltips should respect browser boundaries and adjust their position when there is not enough space. - -Team menu should slightly move when we open it on the last team near the browser scrollbar: - -![](docs/popover-three-dots.png) - -Skills tooltip should open to the top, when we hover "more" link near the bottom edge of the browser, because there is not enough space below. - -![](docs/popover-skills.png) - -### Text Overflow - -As UI outputs user generated data sometimes it may have unexpectedly long texts. We have to make sure, that UI is not getting broken and handles such cases nicely. For this purpose I've created 3rd team with very long texts. -Open http://localhost:3000/taas/myteams/3 for verification. - -- Resize browser to check that it's not broken on any resolution. -- Note, that I've tried long texts **without spaces** and **with spaces**. - -![](docs/text-overflow.png) - -![](docs/text-overflow-list.png) - -### Members list filtering - -Open page with the first team http://localhost:3000/taas/myteams/1. - -Let's check filter by each of the supported fields: - -- **handle** Enter `con`: - - ![](docs/filter-handle.png) - -- **firstName** Enter `liza`: - - ![](docs/filter-first-name.png) - -- **lastName** Enter `Unknown`: - - ![](docs/filter-last-name.png) - -- **role** Enter `des`: - - ![](docs/filter-role.png) - -- **skill** Enter `react`: - - ![](docs/filter-skill.png) - -- **combination of fields** Enter `re`: - - ![](docs/filter-combine.png) - -### Job detail - -For verification, please use **Team Name 001** http://local.topcoder-dev.com:3000/taas/myteams/1 which has good positions demo data. - -![](docs/data-demo.png) - -For example of no candidates use **TEAM_WHICH_WILL_DO_EVERYTHING_TO_BREAK_YOUR_CSS** http://local.topcoder-dev.com:3000/taas/myteams/3/positions/32 - -![](docs/no-candidates.png) - -## Notes - -1. Rate in positions: `hourly`, `daily`, `weekly` and `monthly`. As UI doesn't mention units, for consistency I convert all the rates to `weekly` rate and show it everywhere for consistency. - - ![](docs/rate-convertion.png) - -2. In the Swagger file users have a field `photo_url` with avatar photo. Though previously we implemented a method to get user avatar by `userId`, so I'm keeping using that method of getting avatar by `userId`. As a result: - - - I didn't add `photo_url` to mock API - - I've added `userId` to mock API so I can use real Topcoder API to get user photo - -3. Where is not end date, we have to show `TBD` in the `Start - End Date` field. Additionally, I show `N/A` in the **Time Remaining** if there is no `endDate`: - - ![](docs/no-end-data.png) - -4. When all the candidates can be shown on one page I still showing disabled `Show more` and page `1` so when we change filters there is less jumping for UI consistency: - - ![](docs/pagination.png)