diff --git a/.circleci/config.yml b/.circleci/config.yml index 99749f140f..d723a4d47b 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -282,7 +282,7 @@ workflows: filters: branches: only: - - email-pref-revamp + - free # This is alternate dev env for parallel testing - "build-qa": context : org-global diff --git a/Dockerfile b/Dockerfile index 59d39c94bc..343cd978b6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -65,6 +65,7 @@ ARG TC_M2M_AUTH0_URL ARG AUTH_SECRET ARG COMMUNITY_APP_URL +ARG GSHEETS_API_KEY ################################################################################ # Setting of environment variables in the Docker image. @@ -120,6 +121,7 @@ ENV CONTENTFUL_EDU_CDN_API_KEY=$CONTENTFUL_EDU_CDN_API_KEY ENV CONTENTFUL_EDU_PREVIEW_API_KEY=$CONTENTFUL_EDU_PREVIEW_API_KEY ENV RECRUITCRM_API_KEY=$RECRUITCRM_API_KEY ENV COMMUNITY_APP_URL=$COMMUNITY_APP_URL +ENV GSHEETS_API_KEY=$GSHEETS_API_KEY ################################################################################ # Testing and build of the application inside the container. diff --git a/build.sh b/build.sh index 8c4660937b..865bd28537 100755 --- a/build.sh +++ b/build.sh @@ -44,6 +44,7 @@ docker build -t $TAG \ --build-arg CONTENTFUL_COMCAST_CDN_API_KEY=$CONTENTFUL_COMCAST_CDN_API_KEY \ --build-arg CONTENTFUL_COMCAST_PREVIEW_API_KEY=$CONTENTFUL_COMCAST_PREVIEW_API_KEY \ --build-arg RECRUITCRM_API_KEY=$RECRUITCRM_API_KEY \ + --build-arg GSHEETS_API_KEY=$GSHEETS_API_KEY \ --build-arg COMMUNITY_APP_URL=$COMMUNITY_APP_URL . # Copies "node_modules" from the created image, if necessary for caching. diff --git a/config/custom-environment-variables.js b/config/custom-environment-variables.js index 51264f7d73..ffb0182dda 100644 --- a/config/custom-environment-variables.js +++ b/config/custom-environment-variables.js @@ -104,4 +104,5 @@ module.exports = { AUTH0_PROXY_SERVER_URL: 'TC_M2M_AUTH0_PROXY_SERVER_URL', TOKEN_CACHE_TIME: 'TOKEN_CACHE_TIME', }, + GSHEETS_API_KEY: 'GSHEETS_API_KEY', }; diff --git a/config/default.js b/config/default.js index 9f7fed509e..9aa6c934d8 100644 --- a/config/default.js +++ b/config/default.js @@ -249,6 +249,8 @@ module.exports = { RECRUITCRM_API_KEY: '', }, + GSHEETS_API_KEY: 'AIzaSyBRdKySN5JNCb2H6ZxJdTTvp3cWU51jiOQ', + AUTH_CONFIG: { AUTH0_URL: 'TC_M2M_AUTH0_URL', AUTH0_AUDIENCE: 'TC_M2M_AUDIENCE', diff --git a/package-lock.json b/package-lock.json index a40169150d..e82b894f8a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1771,6 +1771,14 @@ "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==" }, + "abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "requires": { + "event-target-shim": "^5.0.0" + } + }, "accepts": { "version": "1.3.7", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.7.tgz", @@ -2182,8 +2190,7 @@ "arrify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz", - "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==", - "dev": true + "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==" }, "asap": { "version": "2.0.6", @@ -7984,6 +7991,11 @@ "resolved": "https://registry.npmjs.org/event-lite/-/event-lite-0.1.2.tgz", "integrity": "sha512-HnSYx1BsJ87/p6swwzv+2v6B4X+uxUteoDfRxsAb1S1BePzQqOLevVmkdA15GHJVd9A9Ok6wygUR18Hu0YeV9g==" }, + "event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==" + }, "eventemitter3": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-3.1.2.tgz", @@ -8551,6 +8563,11 @@ "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.0.7.tgz", "integrity": "sha512-Utm6CdzT+6xsDk2m8S6uL8VHxNwI6Jub+e9NYTcAms28T84pTa25GJQV9j0CY0N1rM8hK4x6grpF2BQf+2qwVA==" }, + "fast-text-encoding": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/fast-text-encoding/-/fast-text-encoding-1.0.3.tgz", + "integrity": "sha512-dtm4QZH9nZtcDt8qJiOH9fcQd1NAgi+K1O2DbE6GG1PPCK/BWfOH3idCTRQ4ImXRUOyopDEgDEnVEE7Y/2Wrig==" + }, "fastparse": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/fastparse/-/fastparse-1.1.2.tgz", @@ -9428,6 +9445,30 @@ "wide-align": "^1.1.0" } }, + "gaxios": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-4.1.0.tgz", + "integrity": "sha512-vb0to8xzGnA2qcgywAjtshOKKVDf2eQhJoiL6fHhgW5tVN7wNk7egnYIO9zotfn3lQ3De1VPdf7V5/BWfCtCmg==", + "requires": { + "abort-controller": "^3.0.0", + "extend": "^3.0.2", + "https-proxy-agent": "^5.0.0", + "is-stream": "^2.0.0", + "node-fetch": "^2.3.0" + }, + "dependencies": { + "is-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.0.tgz", + "integrity": "sha512-XCoy+WlUr7d1+Z8GgSuXmpuUFC9fOhRXglJMx+dwLKTkL44Cjd4W1Z5P+BQZpr+cR93aGP4S/s7Ftw6Nd/kiEw==" + }, + "node-fetch": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz", + "integrity": "sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==" + } + } + }, "gaze": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/gaze/-/gaze-1.1.3.tgz", @@ -9437,6 +9478,15 @@ "globule": "^1.0.0" } }, + "gcp-metadata": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-4.2.1.tgz", + "integrity": "sha512-tSk+REe5iq/N+K+SK1XjZJUrFPuDqGZVzCy2vocIHIGmPlTGsa8owXMJwGkrXr73NO0AzhPW4MF2DEHz7P2AVw==", + "requires": { + "gaxios": "^4.0.0", + "json-bigint": "^1.0.0" + } + }, "generic-names": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/generic-names/-/generic-names-1.0.3.tgz", @@ -10218,6 +10268,82 @@ "delegate": "^3.1.2" } }, + "google-auth-library": { + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-6.1.6.tgz", + "integrity": "sha512-Q+ZjUEvLQj/lrVHF/IQwRo6p3s8Nc44Zk/DALsN+ac3T4HY/g/3rrufkgtl+nZ1TW7DNAw5cTChdVp4apUXVgQ==", + "requires": { + "arrify": "^2.0.0", + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "fast-text-encoding": "^1.0.0", + "gaxios": "^4.0.0", + "gcp-metadata": "^4.2.0", + "gtoken": "^5.0.4", + "jws": "^4.0.0", + "lru-cache": "^6.0.0" + }, + "dependencies": { + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "requires": { + "yallist": "^4.0.0" + } + }, + "yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + } + } + }, + "google-p12-pem": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/google-p12-pem/-/google-p12-pem-3.0.3.tgz", + "integrity": "sha512-wS0ek4ZtFx/ACKYF3JhyGe5kzH7pgiQ7J5otlumqR9psmWMYc+U9cErKlCYVYHoUaidXHdZ2xbo34kB+S+24hA==", + "requires": { + "node-forge": "^0.10.0" + }, + "dependencies": { + "node-forge": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.10.0.tgz", + "integrity": "sha512-PPmu8eEeG9saEUvI97fm4OYxXVB6bFvyNTyiUOBichBpFG8A1Ljw3bY62+5oOjDEMHRnd0Y7HQ+x7uzxOzC6JA==" + } + } + }, + "google-spreadsheet": { + "version": "3.1.15", + "resolved": "https://registry.npmjs.org/google-spreadsheet/-/google-spreadsheet-3.1.15.tgz", + "integrity": "sha512-S5477f3Gf3Mz6AXgCw7dbaYnzu5aHou1AX4sDqrGboQWnAytkxqJGKQiXN+zzRTTcYzSTJCe0g7KqCPZO9xiOw==", + "requires": { + "axios": "^0.21.1", + "google-auth-library": "^6.1.3", + "lodash": "^4.17.20" + }, + "dependencies": { + "axios": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz", + "integrity": "sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==", + "requires": { + "follow-redirects": "^1.10.0" + } + }, + "follow-redirects": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.13.2.tgz", + "integrity": "sha512-6mPTgLxYm3r6Bkkg0vNM0HTjfGrOEtsfbhagQvbxDEsEkpNhw582upBaoRZylzen6krEmxXJgt9Ju6HiI4O7BA==" + }, + "lodash": { + "version": "4.17.20", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz", + "integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==" + } + } + }, "graceful-fs": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.4.tgz", @@ -10229,6 +10355,16 @@ "integrity": "sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE=", "dev": true }, + "gtoken": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-5.2.1.tgz", + "integrity": "sha512-OY0BfPKe3QnMsY9MzTHTSKn+Vl2l1CcLe6BwDEQj00mbbkl5nyQ/7EUREstg4fQNZ8iYE7br4JJ7TdKeDOPWmw==", + "requires": { + "gaxios": "^4.0.0", + "google-p12-pem": "^3.0.3", + "jws": "^4.0.0" + } + }, "handlebars": { "version": "4.7.6", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.6.tgz", @@ -13294,6 +13430,21 @@ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-1.3.0.tgz", "integrity": "sha1-RsP+yMGJKxKwgz25vHYiF226s0s=" }, + "json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "requires": { + "bignumber.js": "^9.0.0" + }, + "dependencies": { + "bignumber.js": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.0.1.tgz", + "integrity": "sha512-IdZR9mh6ahOBv/hYGiXyVuyCetmGJhtYkqLBpTStdhEGjegpPlUawydyaF3pbIOFynJTpllEs+NP+CS9jKFLjA==" + } + } + }, "json-fallback": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/json-fallback/-/json-fallback-0.0.1.tgz", @@ -13398,6 +13549,25 @@ "semver": "^5.6.0" }, "dependencies": { + "jwa": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", + "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "requires": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "requires": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, "ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -13459,9 +13629,9 @@ "integrity": "sha512-mjzgSOFzlrurlURaHVjnQodyPNvrHrf1TbQP2XU9NSqBtHQPuHZ+Eb6TAJP7ASeJN9h9K0KXoRTs8u6ouHBKvg==" }, "jwa": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", - "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz", + "integrity": "sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==", "requires": { "buffer-equal-constant-time": "1.0.1", "ecdsa-sig-formatter": "1.0.11", @@ -13521,11 +13691,11 @@ } }, "jws": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", - "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", + "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", "requires": { - "jwa": "^1.4.1", + "jwa": "^2.0.0", "safe-buffer": "^5.0.1" } }, diff --git a/package.json b/package.json index a27f2f045d..246554d141 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "flag-icon-css": "^3.3.0", "focus-trap-react": "^6.0.0", "form-data": "^3.0.0", + "google-spreadsheet": "^3.1.15", "helmet": "^3.12.1", "highlight.js": "^9.18.1", "html-to-text": "^5.1.1", diff --git a/src/server/index.js b/src/server/index.js index b4892eb12f..1338e6c447 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -28,6 +28,7 @@ import mailChimpRouter from './routes/mailchimp'; import mockDocuSignFactory from './__mocks__/docu-sign-mock'; import recruitCRMRouter from './routes/recruitCRM'; import mmLeaderboardRouter from './routes/mmLeaderboard'; +import gSheetsRouter from './routes/gSheet'; /* Dome API for topcoder communities */ import tcCommunitiesDemoApi from './tc-communities'; @@ -137,6 +138,7 @@ async function onExpressJsSetup(server) { server.use('/api/mailchimp', mailChimpRouter); server.use('/api/recruit', recruitCRMRouter); server.use('/api/mml', mmLeaderboardRouter); + server.use('/api/gsheets', gSheetsRouter); // serve demo api server.use( diff --git a/src/server/routes/gSheet.js b/src/server/routes/gSheet.js new file mode 100644 index 0000000000..432dbcfb7b --- /dev/null +++ b/src/server/routes/gSheet.js @@ -0,0 +1,19 @@ +/** + * The routes related to GSheets integration + */ + +import express from 'express'; +import GSheetService from '../services/gSheet'; + +const cors = require('cors'); + +const routes = express.Router(); + +// Enables CORS on those routes according config above +// ToDo configure CORS for set of our trusted domains +routes.use(cors()); +routes.options('*', cors()); + +routes.get('/:id', (req, res) => new GSheetService().getSheet(req, res)); + +export default routes; diff --git a/src/server/services/gSheet.js b/src/server/services/gSheet.js new file mode 100644 index 0000000000..a54d6428c4 --- /dev/null +++ b/src/server/services/gSheet.js @@ -0,0 +1,52 @@ +/* eslint-disable consistent-return */ +/* eslint-disable class-methods-use-this */ +/** + * Server-side functions necessary for effective integration with gsheets + */ +import config from 'config'; + +const { GoogleSpreadsheet } = require('google-spreadsheet'); + +const getCircularReplacer = () => { + const seen = new WeakSet(); + return (key, value) => { + if (typeof value === 'object' && value !== null) { + if (seen.has(value)) { + return; + } + seen.add(value); + } + return value; + }; +}; + +/** + * Auxiliary class that handles communication with mailchimp + * APIs in the same uniform manner. + */ +export default class GSheetService { + /** + * getSheet + * @param {Object} req the request + * @param {Object} res the response + */ + async getSheet(req, res) { + const { index } = req.query; + const { id } = req.params; + const doc = new GoogleSpreadsheet(id); + doc.useApiKey(config.GSHEETS_API_KEY); + try { + await doc.loadInfo(); + // the first sheet if not selected via query + const sheet = doc.sheetsByIndex[index || 0]; + const rows = await sheet.getRows(); + const rowsJson = JSON.stringify(rows, getCircularReplacer()); + return res.send({ + rows: JSON.parse(rowsJson), + }); + } catch (e) { + res.status((e.response && e.response.status) || 500); + return res.send((e.response && e.response.data) || { ...e, message: e.message }); + } + } +} diff --git a/src/shared/actions/gSheet.js b/src/shared/actions/gSheet.js new file mode 100644 index 0000000000..bd09ca5132 --- /dev/null +++ b/src/shared/actions/gSheet.js @@ -0,0 +1,33 @@ +/** + * Actions related to gsheets + */ +/* global fetch */ +import { redux } from 'topcoder-react-utils'; +import Service from 'services/gSheet'; + +/** + * Fetch init + */ +function getGsheetInit(id, index) { + return { id, index }; +} + +/** + * Fetch done + */ +async function getGsheetDone(id, index) { + const ss = new Service(); + const res = await ss.getSheet(id, index); + return { + id, + index, + data: res, + }; +} + +export default redux.createActions({ + GSHEETS: { + GET_GSHEET_INIT: getGsheetInit, + GET_GSHEET_DONE: getGsheetDone, + }, +}); diff --git a/src/shared/components/Contentful/AppComponent/index.jsx b/src/shared/components/Contentful/AppComponent/index.jsx index 47251bfcfc..5d19f81fee 100644 --- a/src/shared/components/Contentful/AppComponent/index.jsx +++ b/src/shared/components/Contentful/AppComponent/index.jsx @@ -11,6 +11,7 @@ import { errors } from 'topcoder-react-lib'; import Leaderboard from 'containers/tco/Leaderboard'; import RecruitCRMJobs from 'containers/Gigs/RecruitCRMJobs'; import EmailSubscribeForm from 'containers/EmailSubscribeForm'; +import GSheet from 'containers/GSheet'; const { fireErrorMessage } = errors; @@ -39,7 +40,10 @@ export function AppComponentSwitch(appComponent) { if (appComponent.fields.type === 'EmailSubscribeForm') { return ; } - fireErrorMessage('Unsupported app component type from contentful', ''); + if (appComponent.fields.type === 'GSheet') { + return ; + } + fireErrorMessage(`Unsupported app component type ${appComponent.fields.type}`, ''); return null; } diff --git a/src/shared/components/GSheet/index.jsx b/src/shared/components/GSheet/index.jsx new file mode 100644 index 0000000000..c09a00fa4f --- /dev/null +++ b/src/shared/components/GSheet/index.jsx @@ -0,0 +1,118 @@ +/* eslint-disable no-underscore-dangle */ +/** + * GSheet component + * renders a table with data from google sheets + */ + +import PT from 'prop-types'; +import _ from 'lodash'; +import React, { Component } from 'react'; +import { Scrollbars } from 'react-custom-scrollbars'; +import cn from 'classnames'; +import './style.scss'; + +export default class GSheet extends Component { + constructor(props) { + super(props); + + this.state = { + sortParam: { + order: '', + field: '', + }, + }; + } + + render() { + const { + sheet, config, + } = this.props; + + const { sortParam } = this.state; + let data = sheet.rows; + + if (sortParam.field) { + // Use Lodash to sort array + data = _.orderBy( + sheet.rows, + [d => Number(d[sortParam.field]) || String(d[sortParam.field]).toLowerCase()], + [sortParam.order], + ); + } + + const renderData = () => ( + + + + + { + (config.pick || sheet.rows[0]._sheet.headerValues).map(c => ( + + )) + } + + + + { + data.map(record => ( + + { + (config.pick || sheet.rows[0]._sheet.headerValues).map(c => ( + + )) + } + + )) + } + +
+
+ {c} + +
+
+ {c.toLowerCase() === 'handle' ? ({record[c]}) : record[c]} +
+
+ ); + + return ( + + { sheet.rows && sheet.rows.length ? renderData() :

No data available yet.

} +
+ ); + } +} + +GSheet.defaultProps = { + +}; + +GSheet.propTypes = { + sheet: PT.shape().isRequired, + config: PT.shape().isRequired, +}; diff --git a/src/shared/components/GSheet/style.scss b/src/shared/components/GSheet/style.scss new file mode 100644 index 0000000000..435f9e456a --- /dev/null +++ b/src/shared/components/GSheet/style.scss @@ -0,0 +1,127 @@ +@import '~styles/mixins'; + +$light-gray: #d4d4d4; + +.no-data-title { + text-align: center; +} + +.body-row, +.header-cell { + border-bottom: 1px solid $light-gray; + font-family: Roboto, sans-serif; +} + +.header-cell { + text-transform: uppercase; +} + +.sort-container > div { + width: 0; + height: 0; + border-left: 4px solid transparent; + border-right: 4px solid transparent; +} + +.component-container { + min-height: 300px; + + > div:last-child { + width: 7px !important; + border-radius: 4.5px !important; + } + + > div:last-child > div { + background-color: rgba(#2a2a2a, 0.3) !important; + border-radius: 4.5px !important; + } + + /* width */ + > div:first-child::-webkit-scrollbar { + width: 7px; + } + + /* Track */ + > div:first-child::-webkit-scrollbar-track { + box-shadow: none; + } + + /* Handle */ + > div:first-child::-webkit-scrollbar-thumb { + background: rgba(#2a2a2a, 0.3); + border-radius: 4.5px; + width: 7px; + } + + /* Handle on hover */ + > div:first-child::-webkit-scrollbar-thumb:hover { + background: rgba(#2a2a2a, 0.5); + } + + .table-container { + width: 100%; + + th { + @include roboto-medium; + } + + td, + th { + padding: 0 5px; + font-size: 14px; + text-align: left; + color: #2a2a2a; + line-height: 51px; + letter-spacing: 0.5px; + + &:last-child { + padding-right: 20px; + } + } + } +} + +.header-table-content { + display: flex; + align-items: center; +} + +.sort-container { + display: flex; + flex-direction: column; + margin-left: 5px; + padding: 0; + border: none; + outline: none; + background: transparent; +} + +.sort-up { + border-bottom: 4px solid $light-gray; + margin-bottom: 2px; + + &.active { + border-bottom: 4px solid $tc-black; + } +} + +.sort-down { + border-top: 4px solid $light-gray; + + &.active { + border-top: 4px solid $tc-black; + } +} + +.handle-link { + @include roboto-medium; + + font-weight: 400; + color: #0d61bf !important; + text-decoration: underline; + font-size: 16px; + + &:hover { + text-decoration: none; + } +} diff --git a/src/shared/containers/GSheet/index.jsx b/src/shared/containers/GSheet/index.jsx new file mode 100644 index 0000000000..591ee4c3c9 --- /dev/null +++ b/src/shared/containers/GSheet/index.jsx @@ -0,0 +1,64 @@ +/** + * Google sheets container to load data from sheets + * and render it as table + */ +import React from 'react'; +import PT from 'prop-types'; +import { connect } from 'react-redux'; +import { isEmpty } from 'lodash'; +import actions from 'actions/gSheet'; +import LoadingIndicator from 'components/LoadingIndicator'; +import GSheet from 'components/GSheet'; + +class GSeetContainer extends React.Component { + componentDidMount() { + const { + id, index, sheet, getGSheet, + } = this.props; + if (isEmpty(sheet)) { + getGSheet(id, index); + } + } + + render() { + const { sheet, config } = this.props; + return isEmpty(sheet) ? : ; + } +} + +GSeetContainer.defaultProps = { + index: 0, + config: {}, + sheet: null, +}; + +GSeetContainer.propTypes = { + id: PT.string.isRequired, + index: PT.number, + sheet: PT.shape(), + getGSheet: PT.func.isRequired, + config: PT.shape(), +}; + +function mapStateToProps(state, props) { + const { id, index } = props; + return { + id: props.id, + sheet: state.gSheet ? state.gSheet[`${id}-${index === undefined ? 0 : index}`] : {}, + }; +} + +function mapDispatchToProps(dispatch) { + const a = actions.gsheets; + return { + getGSheet: (id, index) => { + dispatch(a.getGsheetInit(id, index)); + dispatch(a.getGsheetDone(id, index)); + }, + }; +} + +export default connect( + mapStateToProps, + mapDispatchToProps, +)(GSeetContainer); diff --git a/src/shared/reducers/gSheet.js b/src/shared/reducers/gSheet.js new file mode 100644 index 0000000000..31410bb31d --- /dev/null +++ b/src/shared/reducers/gSheet.js @@ -0,0 +1,44 @@ +/** + * Reducer for state.gSheet + */ +import actions from 'actions/gSheet'; +import { handleActions } from 'redux-actions'; + +/** + * Handles getMmleaderboardInit action. + * @param {Object} state Previous state. + */ +function onInit(state, { payload }) { + return { + ...state, + [`${payload.id}-${payload.index}`]: {}, + }; +} + +/** + * Handles getMmleaderboardDone action. + * @param {Object} state Previous state. + * @param {Object} action The action. + */ +function onDone(state, { payload }) { + return { + ...state, + [`${payload.id}-${payload.index}`]: payload.data, + }; +} + +/** + * Creates mmleaderboard reducer with the specified initial state. + * @param {Object} state Optional. If not given, the default one is + * generated automatically. + * @return {Function} Reducer. + */ +function create(state = {}) { + return handleActions({ + [actions.gsheets.getGsheetInit]: onInit, + [actions.gsheets.getGsheetDone]: onDone, + }, state); +} + +/* Reducer with the default initial state. */ +export default create(); diff --git a/src/shared/reducers/index.js b/src/shared/reducers/index.js index 758a8f6d6d..518f7de831 100644 --- a/src/shared/reducers/index.js +++ b/src/shared/reducers/index.js @@ -38,6 +38,7 @@ import { factory as termsFactory } from './terms'; import newsletterPreferences from './newsletterPreferences'; import mmLeaderboard from './mmLeaderboard'; import recruitCRM from './recruitCRM'; +import gSheet from './gSheet'; /** * Given HTTP request, generates options for SSR by topcoder-react-lib's reducer @@ -144,6 +145,7 @@ export function factory(req) { newsletterPreferences, recruitCRM, mmLeaderboard, + gSheet, })); } diff --git a/src/shared/services/gSheet.js b/src/shared/services/gSheet.js new file mode 100644 index 0000000000..56355fe4e2 --- /dev/null +++ b/src/shared/services/gSheet.js @@ -0,0 +1,22 @@ +import fetch from 'isomorphic-fetch'; +import { logger } from 'topcoder-react-lib'; + +const PROXY_ENDPOINT = '/api/gsheets'; + +export default class Service { + baseUrl = PROXY_ENDPOINT; + + /** + * Get gsheet by id + * @param {string} id The sheet id + * @param {number} index sheet index + */ + async getSheet(id, index) { + const res = await fetch(`${this.baseUrl}/${id}${index !== undefined ? `?index=${index}` : ''}`); + if (!res.ok) { + const error = new Error(`Failed to get gsheet ${id}`); + logger.error(error, res); + } + return res.json(); + } +}