diff --git a/.eslintignore b/.eslintignore index 24c24a1..9053902 100644 --- a/.eslintignore +++ b/.eslintignore @@ -9,4 +9,17 @@ npm-debug.log .idea **/coverage/** **/storybook-static/** -**/server/** \ No newline at end of file +**/server/** +lighthouserc.js +lingui.config.js +__tests__ +internals/**/*.* +coverage/**/*.* +reports/**/*.* +badges/**/*.* +assets/**/*.* +**/tests/**/*.test.js +playwright.config.js +babel.config.js +app/translations/*.js +app/**/stories/**/*.* \ No newline at end of file diff --git a/.eslintrc.js b/.eslintrc.js index 0c340bf..5304585 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -9,15 +9,36 @@ module.exports = { es2021: true, 'jest/globals': true }, + plugins: ['jest', 'immutable', 'prettier'], extends: ['eslint:recommended', 'prettier', 'plugin:import/recommended', 'next'], - plugins: ['prettier', 'jest'], rules: { 'prettier/prettier': ['error', prettierOptions], 'import/no-webpack-loader-syntax': 0, 'react/display-name': 0, 'react/react-in-jsx-scope': 'off', curly: ['error', 'all'], - 'no-console': ['error', { allow: ['error'] }] + 'no-console': ['error', { allow: ['error'] }], + 'max-lines': ['error', { max: 300, skipBlankLines: true, skipComments: true }], + 'max-lines-per-function': ['error', 250], + 'no-else-return': 'error', + 'max-params': ['error', 4], + 'require-jsdoc': [ + 'error', + { + require: { + FunctionDeclaration: true, + MethodDefinition: false, + ClassDeclaration: false, + ArrowFunctionExpression: false, + FunctionExpression: false + } + } + ], + 'no-shadow': 'error', + complexity: ['error', 5], + 'no-empty': 'error', + 'import/order': ['error', { groups: [['builtin', 'external', 'internal', 'parent', 'sibling', 'index']] }], + 'eslint-comments/no-use': 0 }, globals: { GLOBAL: false, diff --git a/app/components/Clickable/index.js b/app/components/Clickable/index.js index 2fb3a26..7e56ec4 100644 --- a/app/components/Clickable/index.js +++ b/app/components/Clickable/index.js @@ -15,6 +15,12 @@ const StyledClickable = styled.div` cursor: pointer; } `; + +/** + * A component that can be clicked + * @param {function} onClick - The function to call when the component is clicked + * @param {string} textId - The id of the text to display + */ function Clickable({ onClick, textId }) { return ( diff --git a/app/components/ErrorState/index.js b/app/components/ErrorState/index.js index 9f3bdda..be7fa75 100644 --- a/app/components/ErrorState/index.js +++ b/app/components/ErrorState/index.js @@ -14,27 +14,32 @@ import { CustomCard } from '../styled/repos'; const ErrorState = (props) => { const { intl, reposError, loading, reposData } = props; - let repoError; - if (reposError) { - repoError = reposError; - } else if (!get(reposData, 'totalCount', 0)) { - repoError = 'respo_search_default'; - } - if (!loading && !repoError) { + const getRepoError = () => { + if (reposError) { + return reposError; + } else if (!get(reposData, 'totalCount', 0)) { + return 'respo_search_default'; + } return null; - } - return ( - !loading && - repoError && ( - - - - ) - ); + }; + + const renderErrorCard = (repoError) => { + return ( + !loading && + repoError && ( + + + + ) + ); + }; + + const repoError = getRepoError(); + return renderErrorCard(repoError); }; ErrorState.propTypes = { diff --git a/app/components/Meta/index.js b/app/components/Meta/index.js index 33b0029..522983b 100644 --- a/app/components/Meta/index.js +++ b/app/components/Meta/index.js @@ -10,6 +10,12 @@ import PropTypes from 'prop-types'; import { useIntl } from 'react-intl'; import favicon from '@images/favicon.ico'; +/** + * The Meta component + * @param {string} title - The title of the page + * @param {string} description - The description of the page + * @param {boolean} useTranslation - Whether to use translation for the title and description + */ function Meta({ title, description, useTranslation }) { const intl = useIntl(); diff --git a/app/components/RepoList/index.js b/app/components/RepoList/index.js index 2ce309b..731f703 100644 --- a/app/components/RepoList/index.js +++ b/app/components/RepoList/index.js @@ -10,8 +10,8 @@ import { Skeleton } from 'antd'; import PropTypes from 'prop-types'; import { useRouter } from 'next/router'; import T from '@components/Text'; -import { CustomCard } from '../styled/repos'; -import If from '../If/index'; +import If from '@components/If'; +import { CustomCard } from '@components/styled/repos'; const RepoList = (props) => { const { reposData, loading, repoName } = props; @@ -19,7 +19,7 @@ const RepoList = (props) => { const items = get(reposData, 'items', []); const totalCount = get(reposData, 'totalCount', 0); - const BlockText = (props) => ; + const BlockText = (blockTextProps) => ; return ( diff --git a/app/components/Text/index.js b/app/components/Text/index.js index d25bd60..3926204 100644 --- a/app/components/Text/index.js +++ b/app/components/Text/index.js @@ -17,6 +17,17 @@ const StyledText = styled.span` ${(props) => props.fontweight}; ${(props) => props.styles}; `; + +/** + * A component for displaying text + * @param {string} id - The id of the text to display + * @param {string} text - The text to display + * @param {object} values - The values to pass to the text + * @param {string} color - The color of the text + * @param {string} fontWeight - The font weight of the text + * @param {string} fontSize - The font size of the text + * @param {string} display - The display type of the text + */ function Text({ id = 'default', text, values = {}, children, color, fontWeight, fontSize, ...props }) { return ( diff --git a/app/components/Title/index.js b/app/components/Title/index.js index a3f3704..04eddfe 100644 --- a/app/components/Title/index.js +++ b/app/components/Title/index.js @@ -8,9 +8,17 @@ import React from 'react'; import PropTypes from 'prop-types'; import { Row, Skeleton } from 'antd'; import { StarOutlined } from '@ant-design/icons'; -import Text from '@app/components/Text/index'; +import Text from '@app/components/Text'; import fonts from '@app/themes/fonts'; +/** + * The title of the info container + * @param {object} props The component props + * @param {string} props.name The name of the repo + * @param {boolean} props.loading Whether the data is loading + * @param {number} props.stargazersCount The number of stargazers + * @returns {JSX.Element} The title of the info container + */ function Title(props) { const { name, loading, stargazersCount } = props; const headingStyle = fonts.style.heading(); diff --git a/app/configureStore.js b/app/configureStore.js index 7c693ca..c24b819 100644 --- a/app/configureStore.js +++ b/app/configureStore.js @@ -8,6 +8,12 @@ import { createWrapper } from 'next-redux-wrapper'; import createReducer from './reducers'; +/** + * + * @param {object} initialState The initial state + * @returns {object} The store + * + */ export default function configureStore(initialState = {}) { let composeEnhancers = compose; const reduxSagaMonitorOptions = {}; diff --git a/app/containers/Info/index.js b/app/containers/Info/index.js index 6ee9d4b..17f6dca 100644 --- a/app/containers/Info/index.js +++ b/app/containers/Info/index.js @@ -22,6 +22,16 @@ import { infoCreators } from './reducer'; import saga from './saga'; import { selectInfoData, selectInfoLoading } from './selectors'; +/** + * The Info container + * @param {object} props The component props + * @param {object} props.details The details of the repo + * @param {object} props.params The params from the route + * @param {boolean} props.loading Whether the data is loading + * @param {function} props.dispatchRequestInfo The function to request the info + * @param {object} props.fallBackDetails The details to fall back on + * @returns {JSX.Element} The Info container + */ export function Info({ details, params, loading, dispatchRequestInfo, fallBackDetails }) { const router = useRouter(); const { query } = router; @@ -74,6 +84,11 @@ const mapStateToProps = createStructuredSelector({ fallBackDetails: selectInfoData() }); +/** + * The mapDispatchToProps + * @param {function} dispatch The dispatch function + * @returns {object} The props + */ function mapDispatchToProps(dispatch) { return { dispatchRequestInfo: (repo, owner) => dispatch(infoCreators.requestInfo(repo, owner)) diff --git a/app/containers/Info/saga.js b/app/containers/Info/saga.js index 5865a0b..fd22122 100644 --- a/app/containers/Info/saga.js +++ b/app/containers/Info/saga.js @@ -3,6 +3,13 @@ import { getRepo } from '@services/info'; import { ERRORS } from '@app/utils/constants'; import { infoTypes, infoCreators, INFO_PAYLOAD } from './reducer'; +/** + * Request info from the API + * @param {object} action + * @param {string} action[INFO_PAYLOAD.REPO] - The name of the repository + * @param {string} action[INFO_PAYLOAD.OWNER] - The owner of the repository + * @returns {object} - The response from the API + */ export function* requestInfo(action) { try { if (!action[INFO_PAYLOAD.REPO] || !action[INFO_PAYLOAD.OWNER]) { @@ -16,6 +23,10 @@ export function* requestInfo(action) { } } +/** + * The root of the info saga + * @returns {void} + */ export default function* appSaga() { yield takeLatest(infoTypes.REQUEST_INFO, requestInfo); } diff --git a/app/containers/Repos/index.js b/app/containers/Repos/index.js index e6ba357..86599ee 100644 --- a/app/containers/Repos/index.js +++ b/app/containers/Repos/index.js @@ -6,7 +6,7 @@ import Recommended from '@app/components/Recommended'; import { Container } from '@app/components/styled'; import ErrorState from '@components/ErrorState'; -import RepoList from '@components/RepoList/index'; +import RepoList from '@components/RepoList'; import { CustomCard, YouAreAwesome } from '@components/styled/repos'; import T from '@components/Text'; import { fonts } from '@themes'; @@ -25,6 +25,19 @@ import { reposActionCreators } from './reducer'; import saga from './saga'; import { selectReposData, selectReposError, selectReposSearchKey } from './selectors'; +/** + * The Repos container + * @param {object} props The component props + * @param {object} props.intl The intl object + * @param {string} props.searchKey The search key + * @param {object} props.repos The repos data + * @param {string} props.error The error message + * @param {boolean} props.loading Whether the data is loading + * @param {object} props.recommendations The list of recommendations + * @param {function} props.dispatchGetGithubRepos The function to get the github repos + * @param {function} props.dispatchClearGithubRepos The function to clear the github repos + * @returns {JSX.Element} The Repos container + */ export function Repos({ intl, repos, @@ -111,6 +124,11 @@ const mapStateToProps = createStructuredSelector({ searchKey: selectReposSearchKey() }); +/** + * The mapDispatchToProps + * @param {function} dispatch The dispatch function + * @returns {object} The props + */ function mapDispatchToProps(dispatch) { const { requestGetGithubRepos, clearGithubRepos } = reposActionCreators; return { diff --git a/app/containers/Repos/saga.js b/app/containers/Repos/saga.js index c0c51fe..c74f29e 100644 --- a/app/containers/Repos/saga.js +++ b/app/containers/Repos/saga.js @@ -5,6 +5,12 @@ import { reposActionTypes, reposActionCreators, REPOS_PAYLOAD } from './reducer' const { REQUEST_GET_GITHUB_REPOS } = reposActionTypes; const { successGetGithubRepos, failureGetGithubRepos } = reposActionCreators; +/** + * Get the github repos + * @param {object} action + * @param {string} action[REPOS_PAYLOAD.SEARCH_KEY] - The search key + * @returns {object} - The response from the API + */ export function* getGithubRepos(action) { const response = yield call(getRepos, action[REPOS_PAYLOAD.SEARCH_KEY]); const { data, ok } = response; @@ -15,6 +21,11 @@ export function* getGithubRepos(action) { } } +/** + * The root of the repos saga + * @returns {void} + * @yields {object} - The response from the API + */ export default function* appSaga() { yield takeLatest(REQUEST_GET_GITHUB_REPOS, getGithubRepos); } diff --git a/app/global-styles.js b/app/global-styles.js index 15b2639..45ea3c1 100644 --- a/app/global-styles.js +++ b/app/global-styles.js @@ -1,5 +1,5 @@ import { css } from '@emotion/react'; -import { colors } from './themes/index'; +import { colors } from '@themes'; const globalStyle = css` html, diff --git a/app/reducers.js b/app/reducers.js index 7592723..eb74141 100644 --- a/app/reducers.js +++ b/app/reducers.js @@ -10,6 +10,9 @@ import info from './containers/Info/reducer'; enableAllPlugins(); +/** + * Merges the main reducer with the router state and dynamically injected reducers + */ export default function createReducer(injectedReducer = {}) { const rootReducer = combineReducers({ ...injectedReducer, diff --git a/app/utils/index.js b/app/utils/index.js index ca6520b..1ee08a5 100644 --- a/app/utils/index.js +++ b/app/utils/index.js @@ -1,6 +1,11 @@ import pickBy from 'lodash/pickBy'; import { screenSizes } from '@themes/media'; +/** + * Get query string value + * @param {array} keys - The keys to get the value of + * @returns {object} - The query string value + */ export function getQueryStringValue(keys) { const queryString = {}; try { @@ -36,9 +41,8 @@ export const setDeviceType = (width = document.body.clientWidth) => { return 'mobile'; } else if (width >= screenSizes.tablet && width < screenSizes.desktop) { return 'tablet'; - } else { - return 'desktop'; } + return 'desktop'; }; export const getDeviceType = (device) => (device || setDeviceType()).toUpperCase(); diff --git a/app/utils/sagaInjectors.js b/app/utils/sagaInjectors.js index 4fab1a5..01df7ba 100644 --- a/app/utils/sagaInjectors.js +++ b/app/utils/sagaInjectors.js @@ -17,7 +17,36 @@ const checkDescriptor = (descriptor) => { invariant(conformsTo(descriptor, shape), '(app/utils...) injectSaga: Expected a valid saga descriptor'); }; +/** + * Validate the saga, mode and key + * @param {object} descriptor The saga descriptor + * @param {string} key The saga key + * @param {object} saga The saga + */ export function injectSagaFactory(store, isValid) { + const updateHasSagaInDevelopment = (hasSaga, key, saga) => { + const oldDescriptor = store.injectedSagas[key]; + // enable hot reloading of daemon and once-till-unmount sagas + if (hasSaga && oldDescriptor.saga !== saga) { + oldDescriptor.task.cancel(); + return false; + } + return hasSaga; + }; + + const updateStoreInjectors = (newDescriptor, saga, key, args) => { + store.injectedSagas[key] = { + ...newDescriptor, + task: store.runSaga(saga, args) + }; + }; + + const checkAndUpdateStoreInjectors = (hasSaga, key, newDescriptor, args) => { + if (!hasSaga || (hasSaga && newDescriptor.mode !== DAEMON && newDescriptor.mode !== ONCE_TILL_UNMOUNT)) { + updateStoreInjectors(newDescriptor, newDescriptor.saga, key, args); + } + }; + return function injectSaga(key, descriptor = {}, args) { if (!isValid) { checkStore(store); @@ -27,7 +56,6 @@ export function injectSagaFactory(store, isValid) { ...descriptor, mode: descriptor.mode || DAEMON }; - const { saga, mode } = newDescriptor; checkKey(key); checkDescriptor(newDescriptor); @@ -35,26 +63,35 @@ export function injectSagaFactory(store, isValid) { let hasSaga = Reflect.has(store.injectedSagas, key); if (process.env.NODE_ENV !== 'production') { - const oldDescriptor = store.injectedSagas[key]; - // enable hot reloading of daemon and once-till-unmount sagas - if (hasSaga && oldDescriptor.saga !== saga) { - oldDescriptor.task.cancel(); - hasSaga = false; - } + hasSaga = updateHasSagaInDevelopment(hasSaga, key, newDescriptor.saga); } - if (!hasSaga || (hasSaga && mode !== DAEMON && mode !== ONCE_TILL_UNMOUNT)) { - /* eslint-disable no-param-reassign */ - store.injectedSagas[key] = { - ...newDescriptor, - task: store.runSaga(saga, args) - }; - /* eslint-enable no-param-reassign */ - } + checkAndUpdateStoreInjectors(hasSaga, key, newDescriptor, args); + + return key; }; } +/** + * Eject the saga + * @param {string} key The saga key + * @param {object} store The redux store + * @param {boolean} isValid If the store is valid + */ export function ejectSagaFactory(store, isValid) { + /** + * Clean up the store + * @param {string} key The saga key + * @returns {void} + */ + function updateStoreSaga(key) { + // Clean up in production; in development we need `descriptor.saga` for hot reloading + if (process.env.NODE_ENV === 'production') { + // Need some value to be able to detect `ONCE_TILL_UNMOUNT` sagas in `injectSaga` + store.injectedSagas[key] = 'done'; // eslint-disable-line no-param-reassign + } + } + return function ejectSaga(key) { if (!isValid) { checkStore(store); @@ -64,18 +101,20 @@ export function ejectSagaFactory(store, isValid) { if (Reflect.has(store.injectedSagas, key)) { const descriptor = store.injectedSagas[key]; - if (descriptor.mode && descriptor.mode !== DAEMON) { - descriptor.task.cancel(); - // Clean up in production; in development we need `descriptor.saga` for hot reloading - if (process.env.NODE_ENV === 'production') { - // Need some value to be able to detect `ONCE_TILL_UNMOUNT` sagas in `injectSaga` - store.injectedSagas[key] = 'done'; // eslint-disable-line no-param-reassign - } + if (descriptor.mode && descriptor.mode === DAEMON) { + return; } + + descriptor.task.cancel(); + updateStoreSaga(key); } }; } +/** + * Get the injectors + * @param {object} store The redux store + */ export default function getInjectors(store) { checkStore(store); return { diff --git a/package.json b/package.json index cc1e74c..cd904b6 100644 --- a/package.json +++ b/package.json @@ -102,6 +102,7 @@ "eslint-config-standard": "17.0.0", "eslint-import-resolver-alias": "^1.1.2", "eslint-import-resolver-webpack": "0.13.2", + "eslint-plugin-immutable": "github:alichherawalla/eslint-plugin-immutable.git#6af48f5498ca1912b618c16bceab8c5464a92f1c", "eslint-plugin-import": "^2.26.0", "eslint-plugin-jest": "^26.4.5", "eslint-plugin-jsx-a11y": "^6.5.1", diff --git a/pages/_app.js b/pages/_app.js index a952d30..78698c3 100644 --- a/pages/_app.js +++ b/pages/_app.js @@ -1,5 +1,5 @@ +import React from 'react'; import { IntlProvider } from 'react-intl'; -import App from 'next/app'; import { ThemeProvider } from 'styled-components'; import colors from '@themes/colors'; import globalStyle from '@app/global-styles'; @@ -12,27 +12,23 @@ const theme = { colors }; -class MyApp extends App { - static getInitialProps = async ({ Component, ctx }) => { - return { - pageProps: { - ...(Component.getInitialProps ? await Component.getInitialProps(ctx) : {}), - pathname: ctx.pathname - } - }; - }; +const MyApp = ({ Component, pageProps }) => { + return ( + + + + + + + ); +}; - render() { - const { Component, pageProps } = this.props; - return ( - - - - - - - ); +MyApp.getInitialProps = async ({ Component, ctx }) => { + let pageProps = {}; + if (Component.getInitialProps) { + pageProps = await Component.getInitialProps(ctx); } -} + return { pageProps }; +}; export default wrapper.withRedux(MyApp); diff --git a/pages/_document.js b/pages/_document.js index c30311e..87b06b2 100644 --- a/pages/_document.js +++ b/pages/_document.js @@ -2,50 +2,52 @@ import Document, { Head, Html, Main, NextScript } from 'next/document'; import Script from 'next/script'; import { ServerStyleSheet } from 'styled-components'; -export default class MyDocument extends Document { - static async getInitialProps(ctx) { - const sheet = new ServerStyleSheet(); - const originalRenderPage = ctx.renderPage; - try { - ctx.renderPage = () => - originalRenderPage({ - enhanceApp: (App) => (props) => sheet.collectStyles() - }); +const MyDocument = ({ localeDataScript, locale, styles }) => { + // Polyfill Intl API for older browsers + const polyfill = `https://cdn.polyfill.io/v3/polyfill.min.js?features=Intl.~locale.${locale}`; - const initialProps = await Document.getInitialProps(ctx); - return { - ...initialProps, - styles: ( - <> - {initialProps.styles} - {sheet.getStyleElement()} - - ) - }; - } finally { - sheet.seal(); - } - } + return ( + + + +
+