├── front ├── .gitignore ├── .editorconfig ├── src │ ├── components │ │ ├── Loading │ │ │ └── index.tsx │ │ ├── CommentDisplay │ │ │ └── index.tsx │ │ ├── CreationData │ │ │ └── index.tsx │ │ ├── IssueStatusSelector │ │ │ ├── components │ │ │ │ └── IssueRadio.tsx │ │ │ └── index.tsx │ │ ├── HighlightedText │ │ │ └── index.tsx │ │ ├── IssueDisplay │ │ │ └── index.tsx │ │ └── IssueItem │ │ │ └── index.tsx │ ├── index.html │ ├── queries │ │ ├── issueListSearchDataQuery.ts │ │ ├── issueDetailsQuery.ts │ │ ├── listIssuesCommentsQuery.ts │ │ └── listIssuesQuery.ts │ ├── settings.ts │ ├── utils │ │ ├── runIterationUtilCompletion.ts │ │ ├── getCommentsSearcher │ │ │ ├── index.ts │ │ │ └── searchCommentsOnIssue.ts │ │ ├── getIssueSearcher │ │ │ ├── searchIssuesOnRepo.ts │ │ │ └── index.ts │ │ ├── getClient.ts │ │ ├── makeQueryIterator.ts │ │ └── makeQueryIterator.test.ts │ ├── presententionalComponents │ │ ├── GlobalStyle.ts │ │ └── index.ts │ ├── index.tsx │ ├── containers │ │ ├── IssueDetails │ │ │ └── index.tsx │ │ └── IssuesList │ │ │ └── index.tsx │ └── types.ts ├── .prettierrc.js ├── jest.config.js ├── .babelrc ├── README.md ├── tsconfig.json ├── .eslintrc.js ├── webpack.config.js ├── tools │ ├── queries.ts │ ├── saveIssuesAsJson.ts │ └── saveCommentsAsJson.ts └── package.json └── docker-compose.yml /front/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | src/assets -------------------------------------------------------------------------------- /front/.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | tab_width = 2 -------------------------------------------------------------------------------- /front/src/components/Loading/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export function Loading() { 4 | return
Loading...
; 5 | } 6 | -------------------------------------------------------------------------------- /front/.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: true, 3 | trailingComma: 'all', 4 | singleQuote: true, 5 | printWidth: 120, 6 | tabWidth: 2, 7 | }; 8 | -------------------------------------------------------------------------------- /front/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Issue Broswer 6 | 7 | 8 |
9 | 10 | 11 | -------------------------------------------------------------------------------- /front/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ["/src"], 3 | transform: { 4 | "^.+\\.tsx?$": "ts-jest" 5 | }, 6 | testRegex: "(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$", 7 | moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"] 8 | }; 9 | -------------------------------------------------------------------------------- /front/src/queries/issueListSearchDataQuery.ts: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag'; 2 | 3 | export const issueListSearchDataQuery = gql` 4 | query { 5 | searchTerm @client { 6 | value 7 | } 8 | searchStatus @client { 9 | value 10 | } 11 | } 12 | `; 13 | -------------------------------------------------------------------------------- /front/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/env", "@babel/typescript", "@babel/react"], 3 | "plugins": [ 4 | "@babel/proposal-class-properties", 5 | "@babel/proposal-object-rest-spread", 6 | "@babel/transform-runtime", 7 | "@babel/plugin-syntax-dynamic-import" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: "3" 3 | services: 4 | front: 5 | image: node:11.10.0 6 | hostname: front 7 | ports: 8 | - "8080:8080" 9 | volumes: 10 | - ./front:/etc/front 11 | working_dir: /etc/front 12 | # command: "npm run demo" 13 | command: "sh -c 'while sleep 3600; do :; done'" 14 | -------------------------------------------------------------------------------- /front/src/settings.ts: -------------------------------------------------------------------------------- 1 | import { Settings as SettingsType } from './types'; 2 | 3 | export const settings: SettingsType = { 4 | graphqlUrl: 'https://api.github.com/graphql', 5 | limit: 10, 6 | apiSearchLimit: 100, 7 | token: process.env.GITHUB_TOKEN as string, 8 | repositoryOwner: 'facebook', 9 | repositoryName: 'react', 10 | }; 11 | -------------------------------------------------------------------------------- /front/src/utils/runIterationUtilCompletion.ts: -------------------------------------------------------------------------------- 1 | import { cursorType } from '../types'; 2 | 3 | export async function runIterationUtilCompletion( 4 | iterator: IterableIterator>, 5 | ): Promise { 6 | let { done, value } = iterator.next(); 7 | let resp = await value; 8 | while (!done) { 9 | ({ done, value } = iterator.next(resp)); 10 | resp = await value; 11 | } 12 | return resp as T[]; 13 | } 14 | -------------------------------------------------------------------------------- /front/src/components/CommentDisplay/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { CommentOnIssue } from '../../types'; 3 | import { CreationData } from '../CreationData'; 4 | 5 | export function CommentDisplay({ data }: { data: CommentOnIssue }) { 6 | return ( 7 |
8 | 9 |
10 |
11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /front/src/components/CreationData/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import moment from 'moment'; 3 | import { IssueCreationDataProps } from '../../types'; 4 | import { Text } from '../../presententionalComponents'; 5 | export function CreationData({ author, createdAt }: IssueCreationDataProps) { 6 | return ( 7 |

8 | By {author.login} at {moment(createdAt).format('DD/MM/YYYY HH:mm')} 9 |

10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /front/README.md: -------------------------------------------------------------------------------- 1 | # React-Webpack-TypeScript-Babel 2 | 3 | > This is sample repository demonstrates how to use React, Webpack, TypeScript and Babel 4 | 5 | ## Starting the development server 6 | 7 | ```shell 8 | npm start 9 | ``` 10 | 11 | ## Building the `bundle` 12 | 13 | ```shell 14 | npm run build 15 | ``` 16 | 17 | ## Type-Checking the repo 18 | 19 | ```shell 20 | npm run type-check 21 | ``` 22 | 23 | And to run in --watch mode: 24 | 25 | ```shell 26 | npm run type-check:watch 27 | ``` 28 | -------------------------------------------------------------------------------- /front/src/queries/issueDetailsQuery.ts: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag'; 2 | 3 | export const issueDetailsQuery = gql` 4 | query($repositoryOwner: String!, $repositoryName: String!, $number: Int!) { 5 | repository(owner: $repositoryOwner, name: $repositoryName) { 6 | issue(number: $number) { 7 | id 8 | closed 9 | number 10 | title 11 | bodyHTML 12 | author { 13 | login 14 | } 15 | createdAt 16 | } 17 | } 18 | } 19 | `; 20 | -------------------------------------------------------------------------------- /front/src/components/IssueStatusSelector/components/IssueRadio.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { IssueRadioProps } from '../../../types'; 3 | 4 | export function IssueRadio({ children, status, selectedValue, name, onChange }: IssueRadioProps) { 5 | return ( 6 | <> 7 | onChange(status)} 14 | /> 15 | 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /front/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "noFallthroughCasesInSwitch": true, 5 | "noUnusedParameters": true, 6 | "noImplicitReturns": true, 7 | "moduleResolution": "node", 8 | "esModuleInterop": true, 9 | "noUnusedLocals": true, 10 | "noImplicitAny": true, 11 | "target": "es2018", 12 | "module": "esnext", 13 | "strict": true, 14 | "jsx": "react", 15 | "skipLibCheck": true, 16 | "resolveJsonModule": true 17 | }, 18 | "include": ["src/**/*"], 19 | "exclude": ["node_modules", "dist"] 20 | } 21 | -------------------------------------------------------------------------------- /front/src/components/HighlightedText/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Hilightable } from '../../presententionalComponents'; 4 | 5 | export function HighlightedText({ term, children }: { term?: string | null; children: string }) { 6 | if (!term) { 7 | return <>{children}; 8 | } 9 | const parts = children.split(new RegExp(`(${term})`, 'gi')); 10 | return ( 11 | 12 | {' '} 13 | {parts.map((part, i) => ( 14 | 15 | {part} 16 | 17 | ))}{' '} 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /front/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | extends: [ 4 | 'plugin:@typescript-eslint/recommended', 5 | 'prettier/@typescript-eslint', 6 | 'plugin:prettier/recommended', 7 | 'plugin:react/recommended', 8 | ], 9 | parserOptions: { 10 | ecmaVersion: 2018, 11 | sourceType: 'module', 12 | }, 13 | rules: { 14 | '@typescript-eslint/no-explicit-any': 0, 15 | '@typescript-eslint/explicit-function-return-type': 0, 16 | }, 17 | settings: { 18 | 'import/resolver': { 19 | webpack: { 20 | config: './webpack.config.js', 21 | }, 22 | }, 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /front/src/queries/listIssuesCommentsQuery.ts: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag'; 2 | 3 | export const listIssuesCommentsQuery = gql` 4 | query($repositoryOwner: String!, $repositoryName: String!, $issueNumber: Int!, $limit: Int!, $cursor: String) { 5 | repository(owner: $repositoryOwner, name: $repositoryName) { 6 | issue(number: $issueNumber) { 7 | comments(first: $limit, after: $cursor) { 8 | edges { 9 | node { 10 | id 11 | author { 12 | login 13 | } 14 | bodyHTML 15 | body 16 | createdAt 17 | } 18 | cursor 19 | } 20 | } 21 | } 22 | } 23 | } 24 | `; 25 | -------------------------------------------------------------------------------- /front/src/queries/listIssuesQuery.ts: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag'; 2 | 3 | export const listIssuesQuery = gql` 4 | query($repositoryOwner: String!, $repositoryName: String!, $cursor: String, $limit: Int!, $filterBy: IssueFilters) { 5 | repository(owner: $repositoryOwner, name: $repositoryName) { 6 | issues(last: $limit, before: $cursor, filterBy: $filterBy) { 7 | edges { 8 | node { 9 | id 10 | closed 11 | number 12 | title 13 | body 14 | bodyHTML 15 | author { 16 | login 17 | } 18 | createdAt 19 | } 20 | cursor 21 | } 22 | } 23 | } 24 | } 25 | `; 26 | -------------------------------------------------------------------------------- /front/src/components/IssueDisplay/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { IssueDisplayProps } from '../../types'; 3 | import { CreationData } from '../CreationData'; 4 | import { CommentDisplay } from '../CommentDisplay'; 5 | import { TextHeader } from '../../presententionalComponents'; 6 | 7 | export function IssueDisplay({ data, comments }: IssueDisplayProps) { 8 | return ( 9 | <> 10 |

{data.title}

11 | 12 |
13 | Comments 14 | {comments && comments.map(comment => )} 15 | 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /front/src/components/IssueStatusSelector/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { IssueStatus, IssueStatusSelectorProps } from '../../types'; 3 | import { IssueRadio } from './components/IssueRadio'; 4 | 5 | export function IssueStatusSelector({ value, onChange }: IssueStatusSelectorProps) { 6 | const commonProps = { 7 | selectedValue: value, 8 | onChange: onChange, 9 | name: 'issueStatus', 10 | }; 11 | return ( 12 | <> 13 | 14 | All Status 15 | 16 | 17 | Only Open 18 | 19 | 20 | Only Closed 21 | 22 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /front/src/presententionalComponents/GlobalStyle.ts: -------------------------------------------------------------------------------- 1 | import { createGlobalStyle } from 'styled-components'; 2 | 3 | export const GlobalStyle = createGlobalStyle` 4 | body { 5 | margin: 0; 6 | height: 100%; 7 | } 8 | ol, ul { 9 | list-style: none; 10 | } 11 | html { 12 | box-sizing: border-box; 13 | height: 100%; 14 | } 15 | *, *:before, *:after { 16 | box-sizing: inherit; 17 | } 18 | #root { 19 | height: 100%; 20 | } 21 | button { 22 | border: none; 23 | margin: 0; 24 | padding: 0; 25 | width: auto; 26 | overflow: visible; 27 | background: transparent; 28 | color: inherit; 29 | font: inherit; 30 | text-align: inherit; 31 | &:hover { 32 | cursor: pointer; 33 | border-bottom: 1px solid inherit; 34 | } 35 | } 36 | *:focus { 37 | outline: none; 38 | } 39 | `; 40 | -------------------------------------------------------------------------------- /front/src/utils/getCommentsSearcher/index.ts: -------------------------------------------------------------------------------- 1 | import { CommentOnIssue, Settings, SearchCommentsOnIssueParams } from '../../types'; 2 | import { ApolloClient } from 'apollo-client'; 3 | import { makeQueryIterator } from '../makeQueryIterator'; 4 | import { runIterationUtilCompletion } from '../runIterationUtilCompletion'; 5 | import { searchCommentsOnIssue } from './searchCommentsOnIssue'; 6 | 7 | export function getCommentsSearcher(client: ApolloClient, settings: Settings) { 8 | return async (issueNumber: number): Promise => { 9 | try { 10 | const searchParams = { client, settings, issueNumber }; 11 | const iter = makeQueryIterator({ 12 | searchParams, 13 | executeSearch: searchCommentsOnIssue, 14 | }); 15 | return runIterationUtilCompletion(iter); 16 | } catch (err) { 17 | return []; 18 | } 19 | }; 20 | } 21 | -------------------------------------------------------------------------------- /front/src/components/IssueItem/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link, Pre, IssueItemContainer, StausBadge, TextLine } from '../../presententionalComponents'; 3 | import { IssueItemProps, IssueStatus } from '../../types'; 4 | import { HighlightedText as HT } from '../HighlightedText'; 5 | import { CreationData } from '../CreationData'; 6 | 7 | export function IssueItem({ number, title, closed, body, searchTerm, author, createdAt }: IssueItemProps) { 8 | return ( 9 | 10 | 11 | 12 | {closed ? 'Closed' : 'Open'} 13 | 14 | 15 | {' '} 16 | {number}{' '} 17 | {' '} 18 | - {title} 19 | 20 | 21 | 22 |
23 |         {body}
24 |       
25 |
26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /front/src/utils/getIssueSearcher/searchIssuesOnRepo.ts: -------------------------------------------------------------------------------- 1 | import { Issue, IssueQueryResponse, IssueStatus, SearchIssuesOnRepoParams } from '../../types'; 2 | import { listIssuesQuery } from '../../queries/listIssuesQuery'; 3 | 4 | export async function searchIssuesOnRepo({ 5 | client, 6 | settings, 7 | status, 8 | cursor, 9 | }: SearchIssuesOnRepoParams): Promise<[Issue[], string | null]> { 10 | const { repositoryOwner, repositoryName, apiSearchLimit: limit } = settings; 11 | const variables: any = { 12 | repositoryName, 13 | repositoryOwner, 14 | limit, 15 | cursor, 16 | }; 17 | if (status !== IssueStatus.Both) { 18 | variables.filterBy = { states: status }; 19 | } 20 | const { 21 | data: { 22 | repository: { 23 | issues: { edges }, 24 | }, 25 | }, 26 | }: IssueQueryResponse = await client.query({ 27 | query: listIssuesQuery, 28 | variables, 29 | errorPolicy: 'all', 30 | }); 31 | if (!edges.length) { 32 | return [[], null]; 33 | } 34 | const lastCursor = edges[0].cursor; 35 | const issues = edges.map(({ node }) => node); 36 | return [issues, lastCursor]; 37 | } 38 | -------------------------------------------------------------------------------- /front/src/utils/getCommentsSearcher/searchCommentsOnIssue.ts: -------------------------------------------------------------------------------- 1 | import { CommentOnIssue, ComentQueryResponse, SearchCommentsOnIssueParams } from '../../types'; 2 | import { listIssuesCommentsQuery as query } from '../../queries/listIssuesCommentsQuery'; 3 | 4 | export async function searchCommentsOnIssue({ 5 | cursor, 6 | settings, 7 | issueNumber, 8 | client, 9 | }: SearchCommentsOnIssueParams): Promise<[CommentOnIssue[], string | null]> { 10 | const { repositoryOwner, repositoryName, apiSearchLimit: limit } = settings; 11 | const variables = { 12 | repositoryName, 13 | repositoryOwner, 14 | limit, 15 | issueNumber, 16 | cursor, 17 | }; 18 | const { 19 | data: { 20 | repository: { 21 | issue: { 22 | comments: { edges }, 23 | }, 24 | }, 25 | }, 26 | }: ComentQueryResponse = await client.query({ 27 | query, 28 | variables, 29 | errorPolicy: 'all', 30 | }); 31 | if (!edges.length) { 32 | return [[], null]; 33 | } 34 | let lastCursor = null; 35 | if (edges.length) { 36 | lastCursor = edges[edges.length - 1].cursor; 37 | } 38 | const comments = edges.map(({ node }) => node); 39 | return [comments, lastCursor]; 40 | } 41 | -------------------------------------------------------------------------------- /front/webpack.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | const webpack = require('webpack'); 3 | const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin'); 4 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 5 | const path = require('path'); 6 | const APP_PATH = path.resolve(__dirname, 'src'); 7 | 8 | module.exports = { 9 | entry: APP_PATH, 10 | 11 | output: { 12 | filename: 'bundle.js', 13 | path: path.resolve(__dirname, 'dist'), 14 | publicPath: '/', 15 | }, 16 | 17 | resolve: { 18 | extensions: ['.ts', '.tsx', '.js', '.json'], 19 | }, 20 | 21 | module: { 22 | rules: [{ test: /\.(ts|js)x?$/, loader: 'babel-loader', exclude: /node_modules/ }], 23 | }, 24 | 25 | plugins: [ 26 | new HtmlWebpackPlugin({ 27 | inject: true, 28 | template: path.join(APP_PATH, 'index.html'), 29 | favicon: false, 30 | }), 31 | new webpack.DefinePlugin({ 32 | 'process.env': { 33 | NODE_ENV: JSON.stringify(process.env.NODE_ENV), 34 | GITHUB_TOKEN: JSON.stringify(process.env.GITHUB_TOKEN), 35 | }, 36 | }), 37 | new ForkTsCheckerWebpackPlugin(), 38 | ], 39 | devServer: { 40 | historyApiFallback: true, 41 | hot: true, 42 | }, 43 | }; 44 | -------------------------------------------------------------------------------- /front/src/utils/getClient.ts: -------------------------------------------------------------------------------- 1 | import { ApolloClient } from 'apollo-client'; 2 | import { ApolloLink } from 'apollo-link'; 3 | import { setContext } from 'apollo-link-context'; 4 | import { createHttpLink } from 'apollo-link-http'; 5 | import { InMemoryCache, NormalizedCacheObject } from 'apollo-cache-inmemory'; 6 | import { persistCache } from 'apollo-cache-persist'; 7 | 8 | import { Settings } from '../types'; 9 | import { PersistentStorage, PersistedData } from 'apollo-cache-persist/types'; 10 | 11 | let clientInstance: null | ApolloClient = null; 12 | 13 | export async function getClient({ graphqlUrl, token }: Settings) { 14 | if (clientInstance) { 15 | return clientInstance; 16 | } 17 | const httpLink = createHttpLink({ 18 | uri: graphqlUrl, 19 | }); 20 | const authLink = setContext((_, { headers }) => { 21 | return { 22 | headers: { 23 | ...headers, 24 | authorization: `Bearer ${token}`, 25 | }, 26 | }; 27 | }); 28 | 29 | const cache = new InMemoryCache(); 30 | 31 | clientInstance = new ApolloClient({ 32 | link: ApolloLink.from([authLink, httpLink]), 33 | cache, 34 | }); 35 | const storage = window.localStorage as PersistentStorage>; 36 | 37 | await persistCache({ cache, storage }); 38 | return clientInstance; 39 | } 40 | -------------------------------------------------------------------------------- /front/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from 'react-dom'; 3 | import { BrowserRouter as Router, Route } from 'react-router-dom'; 4 | import { ApolloProvider } from 'react-apollo'; 5 | import { ThemeProvider } from 'styled-components'; 6 | import { getClient } from './utils/getClient'; 7 | import { settings } from './settings'; 8 | import { IssuesList } from './containers/IssuesList'; 9 | import { IssueDetails } from './containers/IssueDetails'; 10 | import { GlobalStyle, MainContainer, InnerContainer, Header, Link, theme } from './presententionalComponents'; 11 | 12 | getClient(settings).then(client => { 13 | function App() { 14 | return ( 15 | <> 16 | 17 | 18 | 19 | 20 | 21 |
22 | Issue browser 23 | 26 |
27 | 28 | 29 | 30 | 31 |
32 |
33 |
34 |
35 | 36 | ); 37 | } 38 | 39 | const rootElement = document.getElementById('root'); 40 | render(, rootElement); 41 | }); 42 | -------------------------------------------------------------------------------- /front/tools/queries.ts: -------------------------------------------------------------------------------- 1 | const issuesQuery = ` 2 | query ($repositoryOwner: String!, $repositoryName: String!, $cursor: String, $limit: Int!, $filterBy: IssueFilters) { 3 | repository(owner: $repositoryOwner, name: $repositoryName) { 4 | issues(last: $limit, before: $cursor, filterBy: $filterBy) { 5 | edges { 6 | node { 7 | id 8 | closed 9 | number 10 | title 11 | body 12 | bodyHTML 13 | author { 14 | login 15 | __typename 16 | } 17 | createdAt 18 | __typename 19 | } 20 | cursor 21 | __typename 22 | } 23 | __typename 24 | } 25 | __typename 26 | } 27 | } 28 | `; 29 | 30 | const commentsQuery = ` 31 | query ($repositoryOwner: String!, $repositoryName: String!, $issueNumber: Int!, $limit: Int!, $cursor: String) { 32 | repository(owner: $repositoryOwner, name: $repositoryName) { 33 | issue(number: $issueNumber) { 34 | comments(first: $limit, after: $cursor) { 35 | edges { 36 | node { 37 | id 38 | author { 39 | login 40 | __typename 41 | } 42 | bodyHTML 43 | body 44 | createdAt 45 | __typename 46 | } 47 | cursor 48 | __typename 49 | } 50 | __typename 51 | } 52 | __typename 53 | } 54 | __typename 55 | } 56 | } 57 | 58 | `; 59 | module.exports = { 60 | issuesQuery, 61 | commentsQuery, 62 | }; 63 | -------------------------------------------------------------------------------- /front/src/containers/IssueDetails/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { Query } from 'react-apollo'; 3 | import { withApollo } from 'react-apollo'; 4 | import { getCommentsSearcher } from '../../utils/getCommentsSearcher'; 5 | 6 | import { issueDetailsQuery as query } from '../../queries/issueDetailsQuery'; 7 | import { settings } from '../../settings'; 8 | 9 | import { IssueDisplay } from '../../components/IssueDisplay'; 10 | import { Loading } from '../../components/Loading'; 11 | 12 | import { IssueDetailsProps, IssueDetailsResponse, CommentOnIssue } from '../../types'; 13 | 14 | function BaseIssueDetails({ match, client }: IssueDetailsProps) { 15 | const [issueComments, setIssueComments] = useState([]); 16 | 17 | const number = parseInt(match.params.issueNumber, 10); 18 | const searchComments = getCommentsSearcher(client, settings); 19 | 20 | const executeSearchComents = async (): Promise => { 21 | const comments = await searchComments(number); 22 | setIssueComments(comments); 23 | }; 24 | useEffect((): void => { 25 | executeSearchComents(); 26 | }, []); 27 | const { repositoryOwner, repositoryName } = settings; 28 | const variables = { repositoryName, repositoryOwner, number }; 29 | 30 | return ( 31 | 32 | {({ loading, data, error }: IssueDetailsResponse) => { 33 | if (loading) { 34 | return ; 35 | } 36 | if (error || !data) { 37 | console.log({ error }); 38 | return <>{'Data not found'}; 39 | } 40 | return ; 41 | }} 42 | 43 | ); 44 | } 45 | export const IssueDetails = withApollo(BaseIssueDetails); 46 | -------------------------------------------------------------------------------- /front/src/utils/makeQueryIterator.ts: -------------------------------------------------------------------------------- 1 | import { cursorType, WithCursor } from '../types'; 2 | 3 | export type dataSearcher = (params: SearchType) => T[] | Promise<[T[], cursorType]>; 4 | export type responseFilter = (item: any) => boolean; 5 | export type completitionChecker = (filteredData: any[], currentData?: any[]) => boolean; 6 | 7 | // eslint-disable-next-line @typescript-eslint/class-name-casing 8 | export interface makeQueryIteratorParams { 9 | searchParams: SearchType; 10 | executeSearch: dataSearcher; 11 | filterData?: responseFilter; 12 | isDone?: completitionChecker; 13 | } 14 | /** 15 | * Makes a query iterator that execute the given query 16 | * until no items or when isDone says is time to stop 17 | */ 18 | export function* makeQueryIterator({ 19 | searchParams, 20 | executeSearch, 21 | filterData, 22 | isDone, 23 | }: makeQueryIteratorParams): IterableIterator> { 24 | let response: ItemType[] = []; 25 | let [items, cursor] = yield executeSearch(searchParams); 26 | if (filterData) { 27 | response.unshift(...items.filter(filterData)); 28 | } else { 29 | response = [...items]; 30 | } 31 | const mustContinue = () => { 32 | let ret = items.length > 0; 33 | if (ret && isDone) { 34 | ret = !isDone(response, items); 35 | } 36 | return ret; 37 | }; 38 | while (mustContinue()) { 39 | [items, cursor] = yield executeSearch({ ...searchParams, cursor }); 40 | let dataToAdd = items; 41 | if (dataToAdd && dataToAdd.length) { 42 | if (filterData) { 43 | dataToAdd = items.filter(filterData); 44 | } 45 | response.push(...dataToAdd); 46 | } 47 | } 48 | return response; 49 | } 50 | -------------------------------------------------------------------------------- /front/tools/saveIssuesAsJson.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | const { promisify } = require('util'); 5 | const request = require('request'); 6 | const { issuesQuery } = require('./queries'); 7 | const requestPromise = promisify(request); 8 | 9 | async function saveIssues() { 10 | console.log('Saving the issues as json'); 11 | 12 | const issuesFile = path.resolve(__dirname, '../src/assets/issues.json'); 13 | fs.truncateSync(issuesFile); 14 | const issuesStream = fs.createWriteStream(issuesFile); 15 | issuesStream.write(Buffer.from('{"offlineIssues":[')); 16 | const options = { 17 | uri: 'https://api.github.com/graphql', 18 | headers: { 19 | authorization: `Bearer ${process.env.GITHUB_TOKEN}`, 20 | 'User-Agent': 21 | 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.86 Safari/537.36', 22 | }, 23 | method: 'POST', 24 | json: { 25 | query: issuesQuery, 26 | variables: { repositoryName: 'react', repositoryOwner: 'facebook', limit: 100, cursor: null }, 27 | }, 28 | }; 29 | let edges = null; 30 | let firstIssue = true; 31 | let cursor = null; 32 | const promiseWrite = promisify(issuesStream.write.bind(issuesStream)); 33 | do { 34 | if (cursor) { 35 | options.json.variables.cursor = cursor; 36 | } 37 | console.log(`Requesting with cursor ${cursor}`); 38 | const response = await requestPromise(options); 39 | if (!response.body.data.repository) continue; 40 | ({ edges } = response.body.data.repository.issues); 41 | console.log('HERE and edges ', edges.length); 42 | if (edges.length) { 43 | ({ cursor } = edges[0]); 44 | for (const edge of edges) { 45 | let data = JSON.stringify(edge.node, null, 2); 46 | if (firstIssue) { 47 | firstIssue = false; 48 | } else { 49 | data = `,${data}`; 50 | } 51 | console.log(`Starting write of ${edge.node.number}`); 52 | await promiseWrite(Buffer.from(data)); 53 | } 54 | } 55 | } while (edges && edges.length); 56 | issuesStream.end(Buffer.from(']}')); 57 | } 58 | saveIssues() 59 | .then(() => { 60 | console.log('Saved issues!'); 61 | }) 62 | .catch(err => { 63 | console.error(err); 64 | }); 65 | -------------------------------------------------------------------------------- /front/src/utils/getIssueSearcher/index.ts: -------------------------------------------------------------------------------- 1 | import { Issue, Settings, IssueStatus, SearchIssuesOnRepoParams } from '../../types'; 2 | import { ApolloClient } from 'apollo-client'; 3 | import { makeQueryIterator } from '../makeQueryIterator'; 4 | import { runIterationUtilCompletion } from '../runIterationUtilCompletion'; 5 | import { searchIssuesOnRepo } from './searchIssuesOnRepo'; 6 | 7 | export function getIssueSearcher(client: ApolloClient, settings: Settings) { 8 | return async (status = IssueStatus.Both, searchTerm?: string): Promise => { 9 | const DEBUG_OFFLINE = true; 10 | const { limit } = settings; 11 | const filterIssue = (issue: Issue) => { 12 | if (!searchTerm) { 13 | return true; 14 | } 15 | const match = searchTerm.trim().toLowerCase(); 16 | return issue.title.toLowerCase().indexOf(match) > 0 || issue.body.toLowerCase().indexOf(match) > 0; 17 | }; 18 | const stopIssueQuerying = (response: any[]) => { 19 | return response.length >= limit; 20 | }; 21 | const searchParams = { client, settings, searchTerm, status }; 22 | try { 23 | if (DEBUG_OFFLINE) { 24 | throw new Error(); 25 | } 26 | const iter = makeQueryIterator({ 27 | searchParams, 28 | executeSearch: searchIssuesOnRepo, 29 | filterData: filterIssue, 30 | isDone: stopIssueQuerying, 31 | }); 32 | const response = await runIterationUtilCompletion(iter); 33 | return response.slice(0, limit); 34 | } catch (err) { 35 | if (err.message.startsWith('Network error:') || DEBUG_OFFLINE) { 36 | const assetModule: any = await import('../../assets/issues.json'); 37 | const offlineIssues = assetModule.default.offlineIssues as Issue[]; 38 | console.log(`searching offline issues ${{ status, searchTerm }}`); 39 | const response: Issue[] = []; 40 | offlineIssues.every(issue => { 41 | const isDesiredStatus = 42 | status === IssueStatus.Both || 43 | (status === IssueStatus.Open && !issue.closed) || 44 | (status === IssueStatus.Closed && issue.closed); 45 | 46 | if (isDesiredStatus && filterIssue(issue)) { 47 | response.push(issue); 48 | } 49 | return !stopIssueQuerying(response); 50 | }); 51 | return response; 52 | } 53 | throw err; 54 | } 55 | }; 56 | } 57 | -------------------------------------------------------------------------------- /front/src/containers/IssuesList/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from 'react'; 2 | import { ApolloClient } from 'apollo-client'; 3 | 4 | import { withApollo } from 'react-apollo'; 5 | 6 | import { getIssueSearcher } from '../../utils/getIssueSearcher'; 7 | import { settings } from '../../settings'; 8 | import { Issue, IssueStatus, IssueSearcher } from '../../types'; 9 | import { IssueStatusSelector } from '../../components/IssueStatusSelector'; 10 | import { Loading } from '../../components/Loading'; 11 | import { IssueItem } from '../../components/IssueItem'; 12 | import { Button, Box, Input, TextHeader } from '../../presententionalComponents'; 13 | 14 | function BaseIssuesList({ client }: { client: ApolloClient }) { 15 | const [searchTerm, setSearchTerm] = useState(''); 16 | const [isLoading, setLoading] = useState(true); 17 | const [appliedSearchTerm, setAppliedSearchTerm] = useState(null); 18 | const [issuesList, setIssuesList] = useState([]); 19 | const [issueStatus, setIssueStatus] = useState(IssueStatus.Both); 20 | 21 | const issueSearcherRef = useRef(); 22 | 23 | useEffect(() => { 24 | issueSearcherRef.current = getIssueSearcher(client, settings); 25 | }, []); 26 | 27 | const onSearch = async () => { 28 | const issueSearcher = issueSearcherRef.current; 29 | if (!issueSearcher) { 30 | return; 31 | } 32 | setLoading(true); 33 | const issues = await issueSearcher(issueStatus, searchTerm); 34 | setAppliedSearchTerm(searchTerm); 35 | setIssuesList(issues); 36 | setLoading(false); 37 | }; 38 | const onStatusChange = (status: IssueStatus) => { 39 | setIssueStatus(status); 40 | }; 41 | useEffect(() => { 42 | onSearch(); 43 | }, [issueStatus]); 44 | const handleSubmit = (e: React.FormEvent) => { 45 | e.preventDefault(); 46 | onSearch(); 47 | }; 48 | if (isLoading) { 49 | return ; 50 | } 51 | return ( 52 |
53 | 54 | setSearchTerm(newSearchTerm)} 59 | /> 60 | 61 | 64 | 65 | 66 | The issues: 67 | {issuesList.map(issue => ( 68 | 69 | ))} 70 | 71 | ); 72 | } 73 | 74 | export const IssuesList = withApollo(BaseIssuesList); 75 | -------------------------------------------------------------------------------- /front/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "project", 3 | "version": "0.0.1", 4 | "description": "", 5 | "scripts": { 6 | "type-check": "tsc --skipLibCheck --noEmit", 7 | "type-check:watch": "npm run type-check -- --watch", 8 | "build": "webpack --progress --colors --mode=production", 9 | "start": "webpack-dev-server --progress --colors --mode=development --host 0.0.0.0 --configwebpack.config.js ", 10 | "test": "jest", 11 | "lint": "eslint src --ext js,jsx,ts,tsx", 12 | "saveIssues": "ts-node ./tools/saveIssuesAsJson", 13 | "saveComments": "ts-node ./tools/saveCommentsAsJson" 14 | }, 15 | "author": { 16 | "name": "Fabio Oliveira Costa", 17 | "email": "fabiocostadev@gmail.com" 18 | }, 19 | "license": "MIT", 20 | "devDependencies": { 21 | "@babel/core": "7.4.0", 22 | "@babel/plugin-proposal-class-properties": "7.4.0", 23 | "@babel/plugin-proposal-object-rest-spread": "7.4.0", 24 | "@babel/plugin-syntax-dynamic-import": "^7.2.0", 25 | "@babel/preset-env": "7.4.2", 26 | "@babel/preset-react": "7.0.0", 27 | "@babel/preset-typescript": "7.3.3", 28 | "@types/jest": "^24.0.11", 29 | "@types/request": "^2.48.1", 30 | "@types/styled-components": "^4.1.14", 31 | "@types/styled-system": "^4.1.0", 32 | "@typescript-eslint/eslint-plugin": "^1.6.0", 33 | "@typescript-eslint/parser": "^1.6.0", 34 | "babel-loader": "8.0.5", 35 | "eslint": "^5.16.0", 36 | "eslint-config-prettier": "^4.1.0", 37 | "eslint-plugin-prettier": "^3.0.1", 38 | "eslint-plugin-react": "^7.12.4", 39 | "fork-ts-checker-webpack-plugin": "1.0.0", 40 | "html-webpack-plugin": "3.2.0", 41 | "jest": "^24.7.1", 42 | "prettier": "^1.16.4", 43 | "request": "^2.88.0", 44 | "ts-jest": "^24.0.2", 45 | "ts-node": "^8.0.3", 46 | "typescript": "3.3.4000", 47 | "webpack": "4.29.6", 48 | "webpack-cli": "3.3.0", 49 | "webpack-dev-server": "3.2.1" 50 | }, 51 | "dependencies": { 52 | "@types/graphql": "^14.2.0", 53 | "@types/react": "16.8.8", 54 | "@types/react-dom": "16.8.3", 55 | "@types/react-router-dom": "^4.3.1", 56 | "apollo-cache-inmemory": "^1.5.1", 57 | "apollo-cache-persist": "^0.1.1", 58 | "apollo-client": "^2.5.1", 59 | "apollo-link": "^1.2.11", 60 | "apollo-link-context": "^1.0.17", 61 | "apollo-link-http": "^1.5.14", 62 | "apollo-link-state": "^0.4.2", 63 | "graphql": "^14.2.1", 64 | "graphql-tag": "^2.10.1", 65 | "moment": "^2.24.0", 66 | "react": "^16.8.5", 67 | "react-apollo": "^2.5.4", 68 | "react-dom": "^16.8.5", 69 | "react-router-dom": "^5.0.0", 70 | "react-scripts": "^2.1.8", 71 | "styled-components": "^4.2.0", 72 | "styled-system": "^4.1.0" 73 | }, 74 | "resolutions": { 75 | "terser": "3.14.1" 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /front/tools/saveCommentsAsJson.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | const { promisify } = require('util'); 5 | const request = require('request'); 6 | const { commentsQuery } = require('./queries'); 7 | const requestPromise = promisify(request); 8 | const issuesData = require('../src/assets/issues.json'); 9 | 10 | async function saveComents() { 11 | console.log('Saving the issues as json'); 12 | 13 | const options = { 14 | uri: 'https://api.github.com/graphql', 15 | headers: { 16 | authorization: `Bearer ${process.env.GITHUB_TOKEN}`, 17 | 'User-Agent': 18 | 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.86 Safari/537.36', 19 | }, 20 | method: 'POST', 21 | json: { 22 | query: commentsQuery, 23 | variables: { repositoryName: 'react', repositoryOwner: 'facebook', limit: 100, cursor: null, issueNumber: 0 }, 24 | }, 25 | }; 26 | let edges = null; 27 | for (const { number } of issuesData.offlineIssues) { 28 | let cursor = null; 29 | options.json.variables.cursor = null; 30 | options.json.variables.issueNumber = parseInt(number, 10); 31 | const commentsFile = path.resolve(__dirname, `../src/assets/commentsFor${number}.json`); 32 | fs.openSync(commentsFile, 'w'); 33 | console.log(`Should have created ${commentsFile}`); 34 | const commentStream = fs.createWriteStream(commentsFile); 35 | commentStream.write(Buffer.from('{"offlineComments":[')); 36 | const promiseWrite = promisify(commentStream.write.bind(commentStream)); 37 | console.log(`Getting comments for issue ${number}`); 38 | do { 39 | if (cursor) { 40 | options.json.variables.cursor = cursor; 41 | } 42 | let firstComment = true; 43 | console.log(`Requesting with variables ${JSON.stringify(options.json.variables)}`); 44 | const response = await requestPromise(options); 45 | if (!response.body.data.repository) continue; 46 | ({ edges } = response.body.data.repository.issue.comments); 47 | if (edges.length) { 48 | ({ cursor } = edges[edges.length - 1]); 49 | for (const edge of edges) { 50 | let data = JSON.stringify(edge.node, null, 2); 51 | if (firstComment) { 52 | firstComment = false; 53 | } else { 54 | data = `,${data}`; 55 | } 56 | 57 | await promiseWrite(Buffer.from(data)); 58 | } 59 | } else { 60 | cursor = null; 61 | } 62 | console.log('SHOULD WRITE ', edges.length, ' on ', number); 63 | } while (edges && edges.length); 64 | commentStream.write(Buffer.from(']}')); 65 | commentStream.end(); 66 | } 67 | } 68 | saveComents() 69 | .then(() => { 70 | console.log('Saved issues!'); 71 | }) 72 | .catch(err => { 73 | console.error(err); 74 | }); 75 | -------------------------------------------------------------------------------- /front/src/types.ts: -------------------------------------------------------------------------------- 1 | import { ApolloClient } from 'apollo-client'; 2 | import { QueryProps, QueryResult } from 'react-apollo'; 3 | import { match } from 'react-router-dom'; 4 | 5 | export enum IssueStatus { 6 | Open = 'OPEN', 7 | Closed = 'CLOSED', 8 | Both = 'BOTH', 9 | } 10 | export type cursorType = string | null; 11 | export interface WithCursor { 12 | cursor?: cursorType; 13 | } 14 | 15 | export interface Settings { 16 | graphqlUrl: string; 17 | limit: number; 18 | apiSearchLimit: number; 19 | token: string; 20 | repositoryOwner: string; 21 | repositoryName: string; 22 | } 23 | export interface Issue { 24 | title: string; 25 | bodyHTML: string; 26 | body: string; 27 | closed: boolean; 28 | id: string; 29 | number: number; 30 | createdAt: string; 31 | author: { 32 | login: string; 33 | }; 34 | } 35 | 36 | interface Edge { 37 | node: T; 38 | cursor: string; 39 | } 40 | 41 | export interface IssueQueryResponse { 42 | data: { 43 | repository: { 44 | issues: { 45 | edges: Edge[]; 46 | totalCount: number; 47 | }; 48 | }; 49 | }; 50 | } 51 | 52 | export interface CommentOnIssue { 53 | id: string; 54 | author: { 55 | login: string; 56 | }; 57 | createdAt: string; 58 | bodyHTML: string; 59 | } 60 | export interface ComentQueryResponse { 61 | data: { 62 | repository: { 63 | issue: { 64 | comments: { 65 | edges: Edge[]; 66 | }; 67 | }; 68 | }; 69 | }; 70 | } 71 | interface IssueDetailsData { 72 | repository: { 73 | issue: Issue; 74 | }; 75 | } 76 | interface IssueDetailsVariables { 77 | repositoryOwner: string; 78 | repositoryName: string; 79 | number: number; 80 | } 81 | export type IssueDetailsResponse = QueryResult; 82 | 83 | interface IssueSearchData { 84 | searchTerm: { 85 | value: string; 86 | }; 87 | searchStatus: { 88 | value: IssueStatus; 89 | }; 90 | } 91 | export type IssueListSearchDataResponse = QueryResult; 92 | 93 | export interface SearchIssueParams { 94 | client: ApolloClient; 95 | settings: Settings; 96 | searchTerm?: string; 97 | status: IssueStatus; 98 | } 99 | export interface SearchIssuesOnRepoParams { 100 | settings: SearchIssueParams['settings']; 101 | client: SearchIssueParams['client']; 102 | status: SearchIssueParams['status']; 103 | cursor?: string; 104 | } 105 | 106 | export interface SearchCommentsOnIssueParams { 107 | cursor?: string; 108 | settings: Settings; 109 | issueNumber: number; 110 | client: ApolloClient; 111 | } 112 | 113 | export type HandleIssueChange = (newStatus: IssueStatus) => any; 114 | export interface IssueStatusSelectorProps { 115 | value: IssueStatus; 116 | onChange: HandleIssueChange; 117 | } 118 | 119 | export interface IssueItemProps extends Issue { 120 | searchTerm?: string | null; 121 | } 122 | 123 | export interface IssueDetailsQueryProps { 124 | children: QueryProps['children']; 125 | repositoryOwner: string; 126 | repositoryName: string; 127 | number: number; 128 | } 129 | 130 | export interface IssueDetailsProps { 131 | match: match<{ issueNumber: string }>; 132 | client: ApolloClient; 133 | } 134 | 135 | export interface IssueDisplayProps { 136 | data: Issue; 137 | comments?: CommentOnIssue[]; 138 | } 139 | 140 | export interface IssueCreationDataProps { 141 | author: Issue['author']; 142 | createdAt: Issue['createdAt']; 143 | } 144 | 145 | export interface IssueRadioProps { 146 | children: React.ReactNode; 147 | status: IssueStatus; 148 | selectedValue: IssueStatus; 149 | name: string; 150 | onChange: HandleIssueChange; 151 | } 152 | 153 | export type IssueSearcher = (status?: IssueStatus, searchTerm?: string | undefined) => Promise; 154 | -------------------------------------------------------------------------------- /front/src/utils/makeQueryIterator.test.ts: -------------------------------------------------------------------------------- 1 | import { cursorType } from '../types'; 2 | 3 | import { makeQueryIterator } from './makeQueryIterator'; 4 | import { runIterationUtilCompletion } from './runIterationUtilCompletion'; 5 | 6 | interface MockItem { 7 | value: any; 8 | } 9 | interface MockSearchType { 10 | cursor?: cursorType; 11 | param: any; 12 | } 13 | const defaultSearchParams = { param: Symbol('MockParam') }; 14 | const mockCursor = 'mockCursor'; 15 | 16 | const defaultExpectedValue: MockItem[] = [...Array(30).keys()].map((value: number) => ({ 17 | value, 18 | })); 19 | const iterationsResponse = [ 20 | defaultExpectedValue.slice(0, 10), 21 | defaultExpectedValue.slice(10, 20), 22 | defaultExpectedValue.slice(20, 30), 23 | ]; 24 | const defaultExpectedCalls = iterationsResponse.length + 1; 25 | type executSearchRepsonse = [MockItem[], cursorType]; 26 | const defaultExecuteSearch = jest.fn(); 27 | 28 | describe('makeQueryIterator', () => { 29 | beforeEach(() => { 30 | defaultExecuteSearch.mockReset(); 31 | iterationsResponse.forEach(response => { 32 | defaultExecuteSearch.mockImplementationOnce(() => 33 | Promise.resolve([response, mockCursor] as executSearchRepsonse), 34 | ); 35 | }); 36 | defaultExecuteSearch.mockImplementationOnce(() => Promise.resolve([[], null])); 37 | }); 38 | it('Return all data until empty if not filtered', async () => { 39 | const iter = makeQueryIterator({ 40 | searchParams: defaultSearchParams, 41 | executeSearch: defaultExecuteSearch, 42 | }); 43 | const result = await runIterationUtilCompletion(iter); 44 | expect(defaultExecuteSearch).toHaveBeenCalledTimes(defaultExpectedCalls); 45 | expect(result).toEqual(defaultExpectedValue); 46 | }); 47 | it('Return only filtered data if filter', async () => { 48 | const filterData = (item: MockItem) => item.value % 2 === 0; 49 | const iter = makeQueryIterator({ 50 | searchParams: defaultSearchParams, 51 | executeSearch: defaultExecuteSearch, 52 | filterData, 53 | }); 54 | const result = await runIterationUtilCompletion(iter); 55 | expect(defaultExecuteSearch).toHaveBeenCalledTimes(defaultExpectedCalls); 56 | expect(result).toEqual(defaultExpectedValue.filter(filterData)); 57 | }); 58 | it('Fails if execute search fails', async () => { 59 | const mockError = new Error('mockError'); 60 | const executeSearch = jest 61 | .fn() 62 | .mockImplementationOnce(() => Promise.resolve([iterationsResponse[0], mockCursor] as executSearchRepsonse)) 63 | .mockImplementationOnce(() => Promise.reject(mockError)); 64 | const iter = makeQueryIterator({ 65 | searchParams: defaultSearchParams, 66 | executeSearch, 67 | }); 68 | let thrownError = null; 69 | try { 70 | await runIterationUtilCompletion(iter); 71 | } catch (err) { 72 | thrownError = err; 73 | } 74 | expect(executeSearch).toHaveBeenCalledTimes(2); 75 | expect(thrownError).toEqual(mockError); 76 | }); 77 | it('Consider isDone function to stop iteration', async () => { 78 | const executeSearch = jest 79 | .fn() 80 | .mockImplementationOnce(() => Promise.resolve([iterationsResponse[0], mockCursor] as executSearchRepsonse)) 81 | .mockImplementationOnce(() => Promise.resolve([iterationsResponse[1], mockCursor] as executSearchRepsonse)); 82 | const isDone = jest 83 | .fn() 84 | .mockImplementationOnce(() => false) 85 | .mockImplementationOnce(() => true); 86 | const iter = makeQueryIterator({ 87 | searchParams: defaultSearchParams, 88 | executeSearch, 89 | isDone, 90 | }); 91 | const result = await runIterationUtilCompletion(iter); 92 | expect(executeSearch).toHaveBeenCalledTimes(2); 93 | expect(result).toEqual([...iterationsResponse[0], ...iterationsResponse[1]]); 94 | }); 95 | }); 96 | -------------------------------------------------------------------------------- /front/src/presententionalComponents/index.ts: -------------------------------------------------------------------------------- 1 | import { IssueStatus } from '../types'; 2 | 3 | import styled, { StyledComponent } from 'styled-components'; 4 | import { 5 | space, 6 | color, 7 | fontSize, 8 | borders, 9 | width, 10 | WidthProps, 11 | SpaceProps, 12 | FontSizeProps, 13 | BorderProps, 14 | ColorProps, 15 | } from 'styled-system'; 16 | import { Link as BaseLink } from 'react-router-dom'; 17 | export { GlobalStyle } from './GlobalStyle'; 18 | 19 | export const theme = { 20 | fontSizes: [12, 14, 16, 24, 32, 48, 64, 96, 128], 21 | space: [0, '0.5rem', '1rem', '1.5rem', '3rem', '5rem'], 22 | colors: { 23 | text: '#717173', 24 | white: '#fff', 25 | primary: '#E30613', 26 | secondary: '#009FE3', 27 | highlight: '#FFFF00', 28 | error: '#ff0000', 29 | black: '#000', 30 | default: 'inherit', 31 | [IssueStatus.Closed]: '#cb2431', 32 | [IssueStatus.Open]: '#2cbe4e', 33 | }, 34 | borderWidths: [1, 2, '0.5em', '1em', '1.5em'], 35 | }; 36 | type SpaceColorProps = SpaceProps & ColorProps; 37 | type BoxProps = WidthProps & FontSizeProps & SpaceColorProps; 38 | 39 | export const Box = styled.div` 40 | ${space} 41 | ${width} 42 | ${fontSize} 43 | ${color} 44 | `; 45 | /* 46 | is not assignable to type 'IntrinsicAttributes & 47 | Pick, HTMLInputElement> 50 | */ 51 | export const Input = styled.input` 52 | ${space} 53 | ${width} 54 | ${fontSize} 55 | ${color} 56 | `; 57 | Input.defaultProps = { 58 | fontSize: 2, 59 | p: 1, 60 | }; 61 | export const BaseButton: StyledComponent = styled.button` 62 | ${space} 63 | ${width} 64 | ${fontSize} 65 | ${color} 66 | &:disabled { 67 | cursor: not-allowed; 68 | } 69 | & + ${() => BaseButton} { 70 | margin-left: ${({ theme: { space } }) => space[2]}; 71 | } 72 | `; 73 | 74 | export const Button = styled(BaseButton)` 75 | ${borders} 76 | &:hover:enabled { 77 | color: ${({ theme: { colors } }) => colors.white}; 78 | background-color: ${({ theme: { colors } }) => colors.secondary}; 79 | } 80 | &:disabled { 81 | color: ${({ theme: { colors } }) => colors.muted}; 82 | border-color: ${({ theme: { colors } }) => colors.muted}; 83 | } 84 | `; 85 | Button.defaultProps = { 86 | borderWidth: 1, 87 | pl: 1, 88 | pr: 1, 89 | color: 'white', 90 | borderStyle: 'solid', 91 | borderColor: 'primary', 92 | bg: 'primary', 93 | }; 94 | export const Menu = styled.nav``; 95 | 96 | export const MainContainer = styled.main` 97 | ${color} 98 | ${fontSize} 99 | font-family: verdana, sans-serif; 100 | line-height: 1.5; 101 | height: 100%; 102 | width: 100% 103 | display: grid; 104 | grid-template-areas: 105 | "header header header " 106 | "left main right" 107 | "footer footer footer"; 108 | grid-template-rows: auto 1fr 5vh; 109 | grid-template-columns: 1fr 8fr 1fr; 110 | `; 111 | MainContainer.defaultProps = { 112 | fontSize: 3, 113 | bg: 'white', 114 | }; 115 | 116 | export const InnerContainer = styled.div` 117 | ${space} 118 | ${color} 119 | grid-area: main; 120 | `; 121 | InnerContainer.defaultProps = { 122 | pt: 3, 123 | pb: 3, 124 | pl: 5, 125 | pr: 5, 126 | color: 'text', 127 | }; 128 | 129 | const getHilightProps = (props: { theme: any; highlighted: boolean }) => { 130 | if (props.highlighted) { 131 | return ` 132 | font-weight: bold; 133 | background-color: ${props.theme.colors.highlight}; 134 | color: ${props.theme.colors.black}; 135 | `; 136 | } 137 | return ''; 138 | }; 139 | export const Hilightable = styled.span` 140 | ${getHilightProps} 141 | `; 142 | type TextProps = SpaceColorProps & FontSizeProps; 143 | 144 | export const Header = styled.header` 145 | ${space} 146 | ${color} 147 | grid-area: header; 148 | `; 149 | Header.defaultProps = { 150 | pt: 1, 151 | pb: 1, 152 | pl: 4, 153 | pr: 4, 154 | bg: 'secondary', 155 | color: 'white', 156 | }; 157 | 158 | export const Text = styled.span` 159 | ${space} 160 | ${fontSize} 161 | ${color} 162 | `; 163 | export const TextLine = styled.p` 164 | ${space} 165 | ${fontSize} 166 | ${color} 167 | `; 168 | 169 | export const TextHeader = styled.h3` 170 | ${space} 171 | ${fontSize} 172 | ${color} 173 | `; 174 | TextHeader.defaultProps = { 175 | fontSize: 4, 176 | mt: 3, 177 | pt: 2, 178 | pb: 2, 179 | mb: 3, 180 | color: 'primary', 181 | }; 182 | 183 | export const Link = styled(BaseLink)` 184 | ${color} 185 | ${fontSize} 186 | text-decoration: underline; 187 | & + & { 188 | margin-left: ${({ theme: { space } }) => space[1]}; 189 | } 190 | `; 191 | Link.defaultProps = { color: 'default' }; 192 | 193 | export const Pre = styled.pre` 194 | white-space: pre-wrap; 195 | word-wrap: break-word; 196 | `; 197 | 198 | export const IssueItemContainer = styled(Box)` 199 | width: 100%; 200 | height: auto; 201 | overflow: hidden; 202 | `; 203 | 204 | export const StausBadge = styled(Text)` 205 | font-weight: bold; 206 | `; 207 | 208 | StausBadge.defaultProps = { 209 | color: 'white', 210 | p: 1, 211 | }; 212 | --------------------------------------------------------------------------------