├── .gitignore ├── next-env.d.ts ├── src ├── types │ └── api.ts ├── components │ ├── HOC │ │ ├── index.ts │ │ ├── Context.ts │ │ ├── Provider.tsx │ │ └── Injector.tsx │ ├── Loader │ │ ├── index.tsx │ │ └── styled.ts │ └── List │ │ ├── types.ts │ │ ├── index.tsx │ │ ├── service │ │ └── ListService.ts │ │ └── model │ │ ├── Pagination.ts │ │ └── ListModel.ts ├── scenes │ └── Main │ │ ├── constants.ts │ │ ├── service │ │ ├── LoadBooks.ts │ │ └── MainPage.ts │ │ ├── di.container.ts │ │ ├── components │ │ ├── Book.tsx │ │ └── styled.ts │ │ └── index.tsx ├── utils │ └── index.ts └── stores │ ├── default.container.ts │ └── services │ ├── Fetch.ts │ └── mocks.ts ├── .prettierrc ├── .babelrc ├── pages ├── _app.tsx ├── _document.tsx └── index.tsx ├── next.config.js ├── tsconfig.json ├── package.json └── .eslintrc.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .next/ 3 | coverage/ 4 | public/ -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /src/types/api.ts: -------------------------------------------------------------------------------- 1 | export interface IBook { 2 | id: number; 3 | name: string; 4 | author: string; 5 | } 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 4, 3 | "singleQuote": true, 4 | "trailingComma": "es5", 5 | "printWidth": 100 6 | } -------------------------------------------------------------------------------- /src/components/HOC/index.ts: -------------------------------------------------------------------------------- 1 | export { default as withProvider } from './Provider'; 2 | export { Dependence, diInject } from './Injector'; 3 | -------------------------------------------------------------------------------- /src/scenes/Main/constants.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | booksListModelName: Symbol.for('BooksListModelName'), 3 | ordersListModelName: Symbol.for('OrdersListModelName'), 4 | }; 5 | -------------------------------------------------------------------------------- /src/components/HOC/Context.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { interfaces } from 'inversify'; 4 | 5 | const context = React.createContext(null); 6 | 7 | export default context; 8 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | import { ComponentType } from 'react'; 2 | 3 | export function getDisplayName(WrappedComponent: ComponentType) { 4 | return WrappedComponent.displayName || WrappedComponent.name || 'Component'; 5 | } 6 | -------------------------------------------------------------------------------- /src/components/Loader/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { LoaderElement } from './styled'; 4 | 5 | function Loader() { 6 | return Loading...; 7 | } 8 | 9 | export default Loader; 10 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "next/babel" 4 | ], 5 | "plugins": [ 6 | ["babel-plugin-styled-components", { "ssr": true, "displayName": true, "preprocess": false }], 7 | ["@babel/plugin-proposal-decorators", { "legacy": true }], 8 | ["@babel/plugin-proposal-class-properties", { "loose": true }], 9 | "babel-plugin-parameter-decorator" 10 | ] 11 | } -------------------------------------------------------------------------------- /src/stores/default.container.ts: -------------------------------------------------------------------------------- 1 | import { Container, interfaces } from 'inversify'; 2 | 3 | import ListService from 'common-components/List/service/ListService'; 4 | 5 | import FetchService from './services/Fetch'; 6 | 7 | const container: interfaces.Container = new Container(); 8 | 9 | container.bind(FetchService.diKey).to(FetchService).inSingletonScope(); 10 | container.bind(ListService.diKey).to(ListService); 11 | 12 | export default container; 13 | -------------------------------------------------------------------------------- /src/components/List/types.ts: -------------------------------------------------------------------------------- 1 | import { ComponentClass, FunctionComponent } from 'react'; 2 | 3 | export type ItemsWrap = { 4 | promise: Promise; 5 | }; 6 | 7 | export type ItemIndexer = { 8 | [index: string]: any; 9 | }; 10 | 11 | export interface IExternalService { 12 | getItems(loadPageNumber: number): ItemsWrap | null; 13 | } 14 | 15 | export type RowRenderComponentProps = { 16 | item: T; 17 | index: number; 18 | }; 19 | 20 | export type RowRenderComponentType = 21 | | ComponentClass> 22 | | FunctionComponent>; 23 | -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { configure } from 'mobx'; 3 | import { AppProps } from 'next/app'; 4 | 5 | import 'reflect-metadata'; 6 | import { useStaticRendering } from 'mobx-react'; 7 | 8 | import { withProvider } from 'common-components/HOC'; 9 | 10 | import defaultContainer from 'stores/default.container'; 11 | 12 | configure({ enforceActions: 'observed' }); 13 | useStaticRendering(typeof window === 'undefined'); 14 | 15 | function App({ Component, pageProps }: AppProps) { 16 | return ; 17 | } 18 | 19 | const wrapped = withProvider(App, defaultContainer); 20 | 21 | export default wrapped; 22 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign */ 2 | const path = require('path'); 3 | 4 | module.exports = { 5 | webpack(config) { 6 | config.resolve.alias['common-components'] = path.join(__dirname, 'src/components'); 7 | config.resolve.alias['global-styles'] = path.join(__dirname, 'src/styles'); 8 | config.resolve.alias['global-types'] = path.join(__dirname, 'src/types'); 9 | config.resolve.alias.stores = path.join(__dirname, 'src/stores'); 10 | config.resolve.alias.services = path.join(__dirname, 'src/services'); 11 | config.resolve.alias['main-scene'] = path.join(__dirname, 'src/scenes/Main'); 12 | return config; 13 | } 14 | }; -------------------------------------------------------------------------------- /src/stores/services/Fetch.ts: -------------------------------------------------------------------------------- 1 | import { injectable } from 'inversify'; 2 | 3 | import { IBook } from 'global-types/api'; 4 | 5 | import { allBooks } from './mocks'; 6 | 7 | @injectable() 8 | class FetchService { 9 | public static diKey = Symbol.for('FetchServiceKey'); 10 | 11 | public async getBooks({ page }: { page: number }) { 12 | const books: IBook[] = await new Promise((resolve) => { 13 | setTimeout(() => { 14 | const result = allBooks.length > page ? allBooks[page] : []; 15 | resolve(result); 16 | }, 1000); 17 | }); 18 | 19 | return books; 20 | } 21 | } 22 | 23 | export default FetchService; 24 | -------------------------------------------------------------------------------- /src/scenes/Main/service/LoadBooks.ts: -------------------------------------------------------------------------------- 1 | import { inject, injectable } from 'inversify'; 2 | 3 | import { IExternalService } from 'common-components/List/types'; 4 | 5 | import FetchService from 'stores/services/Fetch'; 6 | 7 | import { IBook } from 'global-types/api'; 8 | 9 | @injectable() 10 | class LoadBooksService implements IExternalService { 11 | private fetchService: FetchService; 12 | 13 | public constructor(@inject(FetchService.diKey) fetchService: FetchService) { 14 | this.fetchService = fetchService; 15 | } 16 | 17 | public getItems(loadPageNumber: number) { 18 | const books = this.fetchService.getBooks({ 19 | page: loadPageNumber, 20 | }); 21 | 22 | return { 23 | promise: books, 24 | }; 25 | } 26 | } 27 | 28 | export default LoadBooksService; 29 | -------------------------------------------------------------------------------- /src/components/List/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { observer } from 'mobx-react'; 3 | 4 | import ListModel from './model/ListModel'; 5 | import ListService from './service/ListService'; 6 | 7 | import { ItemIndexer, RowRenderComponentType } from './types'; 8 | 9 | type Props = { 10 | idKey: string; 11 | model: ListModel; 12 | service: ListService; 13 | rowRenderComponent: RowRenderComponentType; 14 | }; 15 | 16 | function List({ idKey, model, rowRenderComponent }: Props) { 17 | const RowComponent = rowRenderComponent; 18 | 19 | return ( 20 | <> 21 | {model.pageItems.map((item, index) => ( 22 | 23 | ))} 24 | 25 | ); 26 | } 27 | 28 | export default observer(List); 29 | -------------------------------------------------------------------------------- /src/scenes/Main/di.container.ts: -------------------------------------------------------------------------------- 1 | import { Container, interfaces } from 'inversify'; 2 | 3 | import { IBook } from 'global-types/api'; 4 | 5 | import ListModel from 'common-components/List/model/ListModel'; 6 | import ListService, { 7 | ListServiceExternalServiceKey, 8 | } from 'common-components/List/service/ListService'; 9 | 10 | import constants from './constants'; 11 | 12 | import MainPageService from './service/MainPage'; 13 | import LoadBooksService from './service/LoadBooks'; 14 | 15 | const container: interfaces.Container = new Container(); 16 | 17 | container.bind>(constants.booksListModelName).to(ListModel).inSingletonScope(); 18 | container.bind(ListService.diKey).to(ListService); 19 | 20 | container.bind(ListServiceExternalServiceKey).to(LoadBooksService); 21 | 22 | container.bind(MainPageService.diKey).to(MainPageService).inSingletonScope(); 23 | 24 | export default container; 25 | -------------------------------------------------------------------------------- /src/scenes/Main/components/Book.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { RowRenderComponentProps } from 'common-components/List/types'; 4 | import { diInject, Dependence } from 'common-components/HOC'; 5 | 6 | import ListModel from 'common-components/List/model/ListModel'; 7 | 8 | import constants from 'main-scene/constants'; 9 | 10 | import { IBook } from 'global-types/api'; 11 | 12 | import { BookWrapper, Info, Author, Name } from './styled'; 13 | 14 | type Props = { 15 | model: ListModel; 16 | } & RowRenderComponentProps; 17 | 18 | function Book({ item }: Props) { 19 | return ( 20 | 21 | 22 | {item.name} 23 | {item.author} 24 | 25 | 26 | ); 27 | } 28 | 29 | const injected = diInject(Book, { 30 | model: new Dependence(constants.booksListModelName), 31 | }); 32 | 33 | export default injected; 34 | -------------------------------------------------------------------------------- /pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Document, { DocumentContext } from 'next/document'; 3 | import { ServerStyleSheet } from 'styled-components'; 4 | 5 | export default class MyDocument extends Document { 6 | public static async getInitialProps(ctx: DocumentContext) { 7 | const sheet = new ServerStyleSheet(); 8 | const originalRenderPage = ctx.renderPage; 9 | 10 | try { 11 | ctx.renderPage = () => 12 | originalRenderPage({ 13 | enhanceApp: (App) => (props) => sheet.collectStyles(), 14 | }); 15 | 16 | const initialProps = await Document.getInitialProps(ctx); 17 | 18 | return { 19 | ...initialProps, 20 | styles: ( 21 | <> 22 | {initialProps.styles} 23 | {sheet.getStyleElement()} 24 | 25 | ), 26 | }; 27 | } finally { 28 | sheet.seal(); 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/scenes/Main/service/MainPage.ts: -------------------------------------------------------------------------------- 1 | import { inject, injectable } from 'inversify'; 2 | 3 | import FetchService from 'stores/services/Fetch'; 4 | 5 | import ListModel from 'common-components/List/model/ListModel'; 6 | 7 | import { IBook } from 'global-types/api'; 8 | 9 | import constants from '../constants'; 10 | 11 | @injectable() 12 | class MainPageService { 13 | public static diKey = Symbol.for('MainPageServiceKey'); 14 | 15 | private fetchService: FetchService; 16 | 17 | private bookListModel: ListModel; 18 | 19 | public constructor( 20 | @inject(FetchService.diKey) fetchService: FetchService, 21 | @inject(constants.booksListModelName) bookListModel: ListModel 22 | ) { 23 | this.fetchService = fetchService; 24 | this.bookListModel = bookListModel; 25 | } 26 | 27 | public async loadBooks() { 28 | this.bookListModel.setIsLoading(true); 29 | 30 | const books = await this.fetchService.getBooks({ page: 1 }); 31 | 32 | this.bookListModel.setPageItems(books, 0); 33 | 34 | this.bookListModel.setIsLoading(false); 35 | } 36 | } 37 | 38 | export default MainPageService; 39 | -------------------------------------------------------------------------------- /src/stores/services/mocks.ts: -------------------------------------------------------------------------------- 1 | import { IBook } from 'global-types/api'; 2 | 3 | export const allBooks: IBook[][] = [ 4 | [ 5 | { 6 | id: 1, 7 | name: "Harry Potter and the Philosopher's Stone", 8 | author: ' J.K. Rowling', 9 | }, 10 | { 11 | id: 2, 12 | name: 'Harry Potter and the Chamber of Secrets', 13 | author: ' J.K. Rowling', 14 | }, 15 | { 16 | id: 3, 17 | name: 'Harry Potter and the Prisoner of Azkaban', 18 | author: ' J.K. Rowling', 19 | }, 20 | ], 21 | [ 22 | { 23 | id: 4, 24 | name: 'Harry Potter and the Goblet of Fire', 25 | author: ' J.K. Rowling', 26 | }, 27 | { 28 | id: 5, 29 | name: 'Harry Potter and the Order of the Phoenix', 30 | author: ' J.K. Rowling', 31 | }, 32 | { 33 | id: 6, 34 | name: 'Harry Potter and the Half-Blood Prince', 35 | author: ' J.K. Rowling', 36 | }, 37 | ], 38 | [ 39 | { 40 | id: 7, 41 | name: 'Harry Potter and the Deathly Hallows', 42 | author: ' J.K. Rowling', 43 | }, 44 | ], 45 | ]; 46 | -------------------------------------------------------------------------------- /src/scenes/Main/components/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const Header = styled.h2({ 4 | display: 'flex', 5 | color: '#fff', 6 | padding: '16px 8px', 7 | backgroundColor: '#333', 8 | }); 9 | 10 | export const ListWrapper = styled.div({ 11 | display: 'flex', 12 | flexDirection: 'column', 13 | flex: '1 1 auto', 14 | backgroundColor: '#333', 15 | padding: '16px 8px', 16 | }); 17 | 18 | export const BookWrapper = styled.div({ 19 | display: 'flex', 20 | backgroundColor: '#fff', 21 | alignItems: 'flex-start', 22 | justifyContent: 'center', 23 | flexDirection: 'row', 24 | margin: '8px 0', 25 | padding: '16px', 26 | borderRadius: '5px', 27 | }); 28 | 29 | export const Info = styled.div({ 30 | display: 'flex', 31 | alignItems: 'flex-start', 32 | justifyContent: 'center', 33 | flexDirection: 'column', 34 | }); 35 | 36 | export const Author = styled.div({ 37 | color: '#333', 38 | display: 'flex', 39 | fontSize: 15, 40 | alignItems: 'center', 41 | justifyContent: 'center', 42 | padding: '8px 0', 43 | }); 44 | 45 | export const Name = styled.div({ 46 | color: '#333', 47 | fontSize: 21, 48 | display: 'flex', 49 | alignItems: 'center', 50 | justifyContent: 'center', 51 | }); 52 | -------------------------------------------------------------------------------- /pages/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import 'reflect-metadata'; 4 | 5 | import { diInject, Dependence, withProvider } from 'common-components/HOC'; 6 | import ListModel from 'common-components/List/model/ListModel'; 7 | 8 | import Main from 'main-scene/index'; 9 | import container from 'main-scene/di.container'; 10 | import constants from 'main-scene/constants'; 11 | 12 | import defaultContainer from 'stores/default.container'; 13 | import FetchService from 'stores/services/Fetch'; 14 | 15 | import { IBook } from 'global-types/api'; 16 | 17 | type Props = { 18 | books: IBook[]; 19 | booksListModel: ListModel; 20 | }; 21 | 22 | function Index({ books, booksListModel }: Props) { 23 | booksListModel.setPageItems(books, 0); 24 | 25 | return
; 26 | } 27 | 28 | const injected = diInject(Index, { 29 | booksListModel: new Dependence(constants.booksListModelName), 30 | }); 31 | 32 | const wrapped = withProvider(injected, container); 33 | 34 | export const getStaticProps = async () => { 35 | const fetchService = defaultContainer.get(FetchService.diKey); 36 | 37 | const books: IBook[] = await fetchService.getBooks({ page: 0 }); 38 | 39 | return { 40 | props: { 41 | books, 42 | }, 43 | }; 44 | }; 45 | 46 | export default wrapped; 47 | -------------------------------------------------------------------------------- /src/components/List/service/ListService.ts: -------------------------------------------------------------------------------- 1 | import { inject, injectable } from 'inversify'; 2 | 3 | import ListModel from '../model/ListModel'; 4 | 5 | import { IExternalService } from '../types'; 6 | 7 | export const ListServiceExternalServiceKey = Symbol.for('ListServiceExternalServiceKey'); 8 | 9 | @injectable() 10 | class ListService { 11 | public static diKey = Symbol.for('ListServiceKey'); 12 | 13 | private externalService: IExternalService; 14 | 15 | public constructor( 16 | @inject(ListServiceExternalServiceKey) externalService: IExternalService 17 | ) { 18 | this.externalService = externalService; 19 | } 20 | 21 | public async loadPage(model: ListModel, loadPage: number) { 22 | const { pagination } = model; 23 | 24 | model.clearIndexes(); 25 | 26 | if (model.getPageItems(loadPage).length) { 27 | pagination.setPage(loadPage); 28 | 29 | return; 30 | } 31 | 32 | model.setIsLoading(true); 33 | 34 | const wrap = this.externalService.getItems(loadPage); 35 | 36 | if (!wrap) return; 37 | 38 | const promise = await wrap.promise; 39 | 40 | model.tableRequestId = undefined; 41 | pagination.setPage(loadPage); 42 | model.setPageItems(promise, loadPage); 43 | model.setIsLoading(false); 44 | } 45 | } 46 | 47 | export default ListService; 48 | -------------------------------------------------------------------------------- /src/components/Loader/styled.ts: -------------------------------------------------------------------------------- 1 | import styled, { keyframes } from 'styled-components'; 2 | 3 | const breatheAnimation = keyframes` 4 | 0% { transform: rotate(360deg); } 5 | 100%: { transform: rotate(0deg); } 6 | `; 7 | 8 | export const LoaderElement = styled.div` 9 | border-radius: 50%; 10 | color: #ffdd2d; 11 | font-size: 11px; 12 | text-indent: -99999em; 13 | margin: 55px auto; 14 | position: relative; 15 | width: 10em; 16 | height: 10em; 17 | box-shadow: inset 0 0 0 1em; 18 | transform: translateZ(0); 19 | 20 | &:before { 21 | position: absolute; 22 | content: ''; 23 | width: 5.2em; 24 | height: 10.2em; 25 | background: #fff; 26 | border-radius: 10.2em 0 0 10.2em; 27 | top: -0.1em; 28 | left: -0.1em; 29 | transform-origin: 5.1em 5.1em; 30 | animation-name: ${breatheAnimation}; 31 | animation-delay: 1.5s; 32 | animation-duration: 2s; 33 | animation-iteration-count: infinite; 34 | } 35 | 36 | &:after { 37 | position: absolute; 38 | content: ''; 39 | width: 5.2em; 40 | height: 10.2em; 41 | background: #fff; 42 | border-radius: 0 10.2em 10.2em 0; 43 | top: -0.1em; 44 | left: 4.9em; 45 | transform-origin: 0.1em 5.1em; 46 | animation-name: ${breatheAnimation}; 47 | animation-duration: 2s; 48 | animation-iteration-count: infinite; 49 | } 50 | `; 51 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": [ 3 | "node_modules" 4 | ], 5 | "compileOnSave": true, 6 | "compilerOptions": { 7 | "baseUrl": ".", 8 | "paths": { 9 | "common-components/*": [ 10 | "./src/components/*" 11 | ], 12 | "services/*": [ 13 | "./src/services/*" 14 | ], 15 | "stores/*": [ 16 | "./src/stores/*" 17 | ], 18 | "global-styles/*": [ 19 | "./src/styles/*" 20 | ], 21 | "global-types/*": [ 22 | "./src/types/*" 23 | ], 24 | "main-scene/*": [ 25 | "./src/scenes/Main/*" 26 | ], 27 | "utils/*": [ 28 | "./src/utils/*" 29 | ] 30 | }, 31 | "target": "ESNext", 32 | "module": "esnext", 33 | "sourceMap": true, 34 | "jsx": "preserve", 35 | "removeComments": true, 36 | "noEmit": true, 37 | "strict": true, 38 | "noImplicitAny": true, 39 | "strictNullChecks": true, 40 | "strictFunctionTypes": true, 41 | "strictPropertyInitialization": true, 42 | "noImplicitThis": true, 43 | "noUnusedLocals": true, 44 | "noUnusedParameters": true, 45 | "noImplicitReturns": true, 46 | "esModuleInterop": true, 47 | "experimentalDecorators": true, 48 | "isolatedModules": true, 49 | "resolveJsonModule": true, 50 | "lib": [ 51 | "dom", 52 | "dom.iterable", 53 | "esnext" 54 | ], 55 | "allowJs": true, 56 | "skipLibCheck": true, 57 | "forceConsistentCasingInFileNames": true, 58 | "moduleResolution": "node" 59 | }, 60 | "include": [ 61 | "next-env.d.ts", 62 | "**/*.ts", 63 | "**/*.tsx", 64 | "**/*.svg", 65 | ] 66 | } 67 | -------------------------------------------------------------------------------- /src/components/HOC/Provider.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode, JSXElementConstructor, Component, ComponentClass } from 'react'; 2 | import { interfaces } from 'inversify'; 3 | 4 | import { getDisplayName } from 'utils/index'; 5 | 6 | import Context from './Context'; 7 | 8 | type Props = { 9 | container: interfaces.Container; 10 | children: ReactNode; 11 | }; 12 | 13 | function DiProvider({ container, children }: Props) { 14 | return {children}; 15 | } 16 | 17 | function withProvider( 18 | component: JSXElementConstructor

& C, 19 | container: interfaces.Container 20 | ) { 21 | type Props = JSX.LibraryManagedAttributes; 22 | 23 | class ProviderWrap extends Component { 24 | // eslint-disable-next-line react/static-property-placement 25 | public static contextType = Context; 26 | 27 | // eslint-disable-next-line react/static-property-placement 28 | public static displayName = `diProvider(${getDisplayName(component)})`; 29 | 30 | public constructor(props: Props, context?: interfaces.Container) { 31 | super(props); 32 | 33 | this.context = context; 34 | 35 | if (this.context) { 36 | container.parent = this.context; 37 | } 38 | } 39 | 40 | public render() { 41 | const WrappedComponent = component; 42 | 43 | return ( 44 | 45 | 46 | 47 | ); 48 | } 49 | } 50 | 51 | return ProviderWrap as ComponentClass; 52 | } 53 | 54 | export default withProvider; 55 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend-live-2020", 3 | "version": "1.0.0", 4 | "description": "React DI example", 5 | "main": "index.ts", 6 | "scripts": { 7 | "dev": "next", 8 | "build": "next build", 9 | "start": "next start", 10 | "typescript": "tsc -b", 11 | "typescript:watch": "tsc --watch", 12 | "lint": "eslint --ext .ts,.tsx src/ --fix" 13 | }, 14 | "author": "TQM Team", 15 | "license": "ISC", 16 | "dependencies": { 17 | "babel-plugin-parameter-decorator": "^1.0.16", 18 | "inversify": "^5.0.1", 19 | "mobx": "^5.15.6", 20 | "mobx-react": "^6.3.0", 21 | "next": "^9.5.3", 22 | "react": "^16.13.1", 23 | "react-dom": "^16.13.1", 24 | "reflect-metadata": "^0.1.13", 25 | "styled-components": "^5.2.0" 26 | }, 27 | "devDependencies": { 28 | "@babel/plugin-proposal-class-properties": "^7.10.1", 29 | "@babel/plugin-proposal-decorators": "^7.10.5", 30 | "@svgr/webpack": "^5.4.0", 31 | "@types/node": "^14.6.4", 32 | "@types/react": "^16.9.49", 33 | "@types/react-dom": "^16.9.8", 34 | "@types/react-router": "^5.1.8", 35 | "@types/react-router-dom": "^5.1.5", 36 | "@types/styled-components": "^5.1.3", 37 | "@typescript-eslint/eslint-plugin": "^3.10.1", 38 | "@typescript-eslint/parser": "^3.10.1", 39 | "babel-eslint": "^10.1.0", 40 | "babel-plugin-styled-components": "^1.11.1", 41 | "eslint": "^7.8.1", 42 | "eslint-config-airbnb": "^18.2.0", 43 | "eslint-config-prettier": "^6.11.0", 44 | "eslint-import-resolver-alias": "^1.1.2", 45 | "eslint-plugin-import": "^2.22.0", 46 | "eslint-plugin-jsx-a11y": "^6.3.1", 47 | "eslint-plugin-prettier": "^3.1.4", 48 | "eslint-plugin-react": "^7.20.6", 49 | "prettier": "^2.1.1", 50 | "typescript": "^3.9.7" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/scenes/Main/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from 'react'; 2 | 3 | import { diInject, Dependence } from 'common-components/HOC'; 4 | import List from 'common-components/List'; 5 | import ListModel from 'common-components/List/model/ListModel'; 6 | import ListService from 'common-components/List/service/ListService'; 7 | import Loader from 'common-components/Loader'; 8 | 9 | import { IBook } from 'global-types/api'; 10 | 11 | import Book from './components/Book'; 12 | import { ListWrapper, Header } from './components/styled'; 13 | 14 | import constants from './constants'; 15 | 16 | import MainPageService from './service/MainPage'; 17 | 18 | type Props = { 19 | booksListModel: ListModel; 20 | listService: ListService; 21 | service: MainPageService; 22 | }; 23 | 24 | function Main({ listService, booksListModel }: Props) { 25 | const handleClick = useCallback(() => { 26 | listService.loadPage(booksListModel, booksListModel.pagination.page + 1); 27 | }, [listService, booksListModel]); 28 | console.log(booksListModel.selectedItem); 29 | if (booksListModel.isLoading) return ; 30 | 31 | return ( 32 |

33 |
Books
34 | 35 | 41 | 42 | 43 |
44 | ); 45 | } 46 | 47 | const injected = diInject(Main, { 48 | booksListModel: new Dependence(constants.booksListModelName), 49 | listService: new Dependence(ListService), 50 | service: new Dependence(MainPageService), 51 | }); 52 | 53 | export default injected; 54 | -------------------------------------------------------------------------------- /src/components/List/model/Pagination.ts: -------------------------------------------------------------------------------- 1 | import { observable, action, computed } from 'mobx'; 2 | 3 | class Pagination { 4 | @observable 5 | public totalCount?: number; 6 | 7 | @observable 8 | public page = 0; 9 | 10 | @computed 11 | public get visible() { 12 | return !!this.normalizedCount && this.normalizedCount > this.itemsPerPage; 13 | } 14 | 15 | @computed 16 | public get pagesCount() { 17 | if (this.normalizedCount) { 18 | const pages = this.normalizedCount / this.itemsPerPage; 19 | return pages < 1 ? 1 : Math.ceil(pages); 20 | } 21 | return 1; 22 | } 23 | 24 | @computed 25 | public get normalizedCount() { 26 | if (this.totalCount) { 27 | const itemsLength = this.itemsLengthGetter(); 28 | 29 | if (itemsLength < this.itemsPerPage) { 30 | return itemsLength; 31 | } 32 | 33 | return this.totalCount; 34 | } 35 | 36 | return this.totalCount; 37 | } 38 | 39 | public padding = 1; 40 | 41 | public itemsPerPage = 3; 42 | 43 | public tableCountRequestId?: number; 44 | 45 | public itemsLengthGetter: () => number; 46 | 47 | @observable 48 | public isExactCount = false; 49 | 50 | public constructor(itemsLengthGetter: () => number) { 51 | this.itemsLengthGetter = itemsLengthGetter; 52 | } 53 | 54 | @action 55 | public changeTotalCount(value: number) { 56 | this.totalCount = value; 57 | } 58 | 59 | @action 60 | public setPage(value: number) { 61 | this.page = value; 62 | } 63 | 64 | @action.bound 65 | public clear() { 66 | this.page = 1; 67 | this.totalCount = undefined; 68 | this.tableCountRequestId = undefined; 69 | this.isExactCount = false; 70 | } 71 | } 72 | 73 | export default Pagination; 74 | -------------------------------------------------------------------------------- /src/components/List/model/ListModel.ts: -------------------------------------------------------------------------------- 1 | import { injectable } from 'inversify'; 2 | import { observable, action, computed } from 'mobx'; 3 | 4 | import Pagination from './Pagination'; 5 | 6 | @injectable() 7 | class ListModel { 8 | public static diKey = Symbol.for('ListModelKey'); 9 | 10 | @observable 11 | public items: { 12 | [index: number]: T[]; 13 | } = {}; 14 | 15 | @observable 16 | public hoveredIndex = -1; 17 | 18 | @observable 19 | public highlightIndexes: number[] = []; 20 | 21 | @observable 22 | public selectedIndex: number | null = null; 23 | 24 | @observable 25 | public isLoading = false; 26 | 27 | public tableRequestId?: number; 28 | 29 | public pagination: Pagination; 30 | 31 | @computed 32 | public get pageItems() { 33 | return this.getPageItems(this.pagination.page); 34 | } 35 | 36 | @computed 37 | public get selectedItem() { 38 | const items = this.pageItems; 39 | 40 | return this.selectedIndex != null ? items[this.selectedIndex] : null; 41 | } 42 | 43 | @computed 44 | public get itemsLength() { 45 | let length = 0; 46 | 47 | Object.values(this.items).forEach((k) => { 48 | length += k.length; 49 | }); 50 | 51 | return length; 52 | } 53 | 54 | @computed 55 | public get notFound() { 56 | return !this.pageItems.length && !this.isLoading; 57 | } 58 | 59 | public constructor() { 60 | this.pagination = new Pagination(() => this.itemsLength); 61 | } 62 | 63 | @action 64 | public setIsLoading(value: boolean) { 65 | this.isLoading = value; 66 | } 67 | 68 | public getPageItems(page: number) { 69 | return this.items[page] || []; 70 | } 71 | 72 | @action 73 | public setPageItems(items: T[], pageIndex: number) { 74 | this.items[pageIndex] = items; 75 | } 76 | 77 | @action 78 | public addHighlightIndex(index: number) { 79 | if (this.highlightIndexes.some((x) => x === index)) return; 80 | 81 | this.highlightIndexes.push(index); 82 | } 83 | 84 | @action 85 | public removeHighlightIndex(index: number) { 86 | const arrayIndex = this.highlightIndexes.findIndex((x) => x === index); 87 | 88 | if (arrayIndex > -1) { 89 | this.highlightIndexes.splice(arrayIndex, 1); 90 | } 91 | } 92 | 93 | @action 94 | public setSelectedIndex(index: number | null) { 95 | this.selectedIndex = index; 96 | } 97 | 98 | @action 99 | public setHoveredIndex(index: number) { 100 | this.hoveredIndex = index; 101 | } 102 | 103 | @action 104 | public clearIndexes() { 105 | this.highlightIndexes = []; 106 | this.selectedIndex = null; 107 | this.hoveredIndex = -1; 108 | } 109 | 110 | @action 111 | public clear() { 112 | this.items = {}; 113 | this.hoveredIndex = -1; 114 | this.highlightIndexes = []; 115 | this.selectedIndex = null; 116 | this.isLoading = false; 117 | 118 | this.pagination.clear(); 119 | } 120 | } 121 | 122 | export default ListModel; 123 | -------------------------------------------------------------------------------- /src/components/HOC/Injector.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-classes-per-file */ 2 | import React, { Component, JSXElementConstructor, ComponentClass } from 'react'; 3 | import { observer } from 'mobx-react'; 4 | 5 | import { getDisplayName } from 'utils/index'; 6 | 7 | import Context from './Context'; 8 | 9 | type DiKey = string | symbol; 10 | 11 | type Class = { new (...args: any[]): T; diKey: DiKey }; 12 | 13 | type Options = { 14 | name?: symbol; 15 | tagKey?: symbol; 16 | tagValue?: symbol; 17 | all?: boolean; 18 | transformation?: (clazz: T) => any; 19 | }; 20 | 21 | type InjectParams = { 22 | diKey: DiKey; 23 | options?: { 24 | name?: symbol; 25 | tagKey?: symbol; 26 | tagValue?: symbol; 27 | all?: boolean; 28 | }; 29 | }; 30 | 31 | export class Dependence { 32 | private clazzOrKey: Class | DiKey; 33 | 34 | private options?: Options; 35 | 36 | public constructor(clazzOrKey: Class | DiKey, options?: Options) { 37 | this.clazzOrKey = clazzOrKey; 38 | this.options = options; 39 | } 40 | 41 | public build() { 42 | return { 43 | clazzOrKey: this.clazzOrKey, 44 | options: this.options, 45 | }; 46 | } 47 | } 48 | 49 | export function diInject

( 50 | component: JSXElementConstructor

& C, 51 | dependencies: Record> 52 | ) { 53 | type Props = JSX.LibraryManagedAttributes>; 54 | 55 | const displayName = getDisplayName(component); 56 | const WrappedComponent = observer(component); 57 | 58 | class DiInjectClass extends Component { 59 | // eslint-disable-next-line react/static-property-placement 60 | public static contextType = Context; 61 | 62 | public static wrappedComponent = component; 63 | 64 | // eslint-disable-next-line react/static-property-placement 65 | public static displayName = `diInject(${displayName})`; 66 | 67 | private resolve = (inject: InjectParams) => { 68 | const opt = inject; 69 | const { context } = this; 70 | 71 | if (!opt.diKey) { 72 | throw new Error('There is no static diKey in model class'); 73 | } 74 | 75 | if (!opt.options) { 76 | return context.get(opt.diKey); 77 | } 78 | 79 | if ( 80 | (opt.options.tagKey && !opt.options.tagValue) || 81 | (!opt.options.tagKey && opt.options.tagValue) 82 | ) { 83 | throw new Error(`tagKey or tagValue empty for ${displayName} `); 84 | } 85 | 86 | if (!opt.options.tagKey && !opt.options.name) { 87 | if (!opt.options.all) { 88 | return context.get(opt.diKey); 89 | } 90 | return context.getAll(opt.diKey); 91 | } 92 | if (opt.options.name) { 93 | if (!opt.options.all) { 94 | return context.getNamed(opt.diKey, opt.options.name); 95 | } 96 | return context.getAllNamed(opt.diKey, opt.options.name); 97 | } 98 | if (opt.options.tagKey && opt.options.tagValue) { 99 | if (!opt.options.all) { 100 | return context.getTagged(opt.diKey, opt.options.tagKey, opt.options.tagValue); 101 | } 102 | return context.getAllTagged(opt.diKey, opt.options.tagKey, opt.options.tagValue); 103 | } 104 | 105 | return context.get(opt.diKey); 106 | }; 107 | 108 | private inject = () => { 109 | if (!this.context) { 110 | throw new Error(`di container not found for ${displayName}`); 111 | } 112 | 113 | const result: Record = {} as Record; 114 | 115 | (Object.keys(dependencies) as (keyof I)[]).forEach((key) => { 116 | const obj = dependencies[key]; 117 | 118 | const deps = obj.build(); 119 | 120 | const injectedParams: InjectParams = { 121 | diKey: 122 | typeof deps.clazzOrKey === 'symbol' || typeof deps.clazzOrKey === 'string' 123 | ? deps.clazzOrKey 124 | : deps.clazzOrKey.diKey, 125 | options: { 126 | name: deps.options?.name, 127 | tagKey: deps.options?.tagKey, 128 | tagValue: deps.options?.tagValue, 129 | all: deps.options?.all, 130 | }, 131 | }; 132 | 133 | const instance = this.resolve(injectedParams); 134 | 135 | result[key] = 136 | deps.options && deps.options.transformation 137 | ? deps.options.transformation(instance) 138 | : instance; 139 | }); 140 | 141 | return result; 142 | }; 143 | 144 | public render() { 145 | const injections = this.inject(); 146 | 147 | return ; 148 | } 149 | } 150 | 151 | return observer(DiInjectClass) as ComponentClass & { 152 | wrappedComponent: JSXElementConstructor

& C; 153 | }; 154 | } 155 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | 'eslint:recommended', 4 | 'airbnb', 5 | 'prettier', 6 | 'prettier/react', 7 | 'prettier/standard', 8 | 'prettier/@typescript-eslint', 9 | ], 10 | parserOptions: { 11 | ecmaVersion: 2018, 12 | sourceType: 'module', 13 | ecmaFeatures: { 14 | impliedStrict: true, 15 | }, 16 | }, 17 | overrides: [ 18 | { 19 | files: ['**/*.ts', '**/*.tsx'], 20 | parser: '@typescript-eslint/parser', 21 | parserOptions: { 22 | sourceType: 'module', 23 | project: './tsconfig.json', 24 | }, 25 | plugins: ['prettier', 'react', 'jsx-a11y', 'import', '@typescript-eslint'], 26 | rules: { 27 | 'import/extensions': [0, 'never', { jsx: 'never', js: 'never' }], 28 | 'react/jsx-filename-extension': [2, { extensions: ['.tsx', '.ts'] }], 29 | 'react/jsx-wrap-multilines': [ 30 | 2, 31 | { 32 | declaration: true, 33 | assignment: true, 34 | return: true, 35 | }, 36 | ], 37 | 'import/order': [ 38 | 'error', 39 | { 40 | groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index'], 41 | 'newlines-between': 'always-and-inside-groups', 42 | }, 43 | ], 44 | quotes: ['error', 'single', 'avoid-escape'], 45 | 'prettier/prettier': [2], 46 | 'import/no-cycle': [0], 47 | 'no-param-reassign': [0], 48 | 'react/prefer-stateless-function': [0], 49 | 'react/forbid-prop-types': [0], 50 | 'no-confusing-arrow': [0], 51 | 'no-mixed-operators': [0], 52 | 'consistent-return': [0], 53 | 'jsx-a11y/anchor-has-content': [0], 54 | 'class-methods-use-this': [0], 55 | 'no-console': [0], 56 | 'no-bitwise': [0], 57 | 'jsx-a11y/no-static-element-interactions': [0], 58 | 'jsx-a11y/no-autofocus': [0], 59 | 'linebreak-style': [0], 60 | 'jsx-a11y/img-has-alt': [0], 61 | 'jsx-a11y/anchor-is-valid': [0], 62 | 'jsx-a11y/no-noninteractive-element-interactions': [0], 63 | 'eol-last': [0], 64 | 'react/prop-types': [0], 65 | 'jsx-a11y/label-has-for': [0], 66 | 'jsx-a11y/click-events-have-key-events': [0], 67 | 'react/default-props-match-prop-types': [0], 68 | 'react/require-default-props': [0], 69 | 'react/no-unused-prop-types': [0], 70 | 'no-unused-vars': [0], 71 | 'no-undef': [0], 72 | 'import/no-extraneous-dependencies': [0], 73 | 'jsx-a11y/no-noninteractive-tabindex': [0], 74 | 'react/button-has-type': [0], 75 | 'getter-return': 'off', 76 | 'no-dupe-args': 'off', 77 | 'no-dupe-keys': 'off', 78 | 'no-unreachable': 'off', 79 | 'valid-typeof': 'off', 80 | 'no-const-assign': 'off', 81 | 'no-new-symbol': 'off', 82 | 'no-this-before-super': 'off', 83 | 'no-dupe-class-members': 'off', 84 | 'no-redeclare': 'off', 85 | 'import/prefer-default-export': 'off', 86 | '@typescript-eslint/adjacent-overload-signatures': 'error', 87 | '@typescript-eslint/array-type': 'error', 88 | '@typescript-eslint/ban-types': 'error', 89 | '@typescript-eslint/camelcase': 'off', 90 | '@typescript-eslint/class-name-casing': 'off', 91 | '@typescript-eslint/explicit-member-accessibility': 'error', 92 | '@typescript-eslint/interface-name-prefix': 'off', 93 | '@typescript-eslint/member-delimiter-style': 'error', 94 | '@typescript-eslint/no-angle-bracket-type-assertion': 'off', 95 | '@typescript-eslint/no-array-constructor': 'error', 96 | '@typescript-eslint/no-empty-interface': 'error', 97 | '@typescript-eslint/no-inferrable-types': 'error', 98 | '@typescript-eslint/no-parameter-properties': 'error', 99 | '@typescript-eslint/no-triple-slash-reference': 'off', 100 | '@typescript-eslint/no-unused-vars': 'warn', 101 | '@typescript-eslint/no-use-before-define': 'error', 102 | '@typescript-eslint/no-var-requires': 'error', 103 | 'react/jsx-props-no-spreading': 'off', 104 | }, 105 | settings: { 106 | 'import/resolver': { 107 | node: { 108 | extensions: ['.js', '.jsx', '.ts', '.tsx'], 109 | }, 110 | alias: { 111 | map: [ 112 | ['common-components', './src/components'], 113 | ['pages', './pages'], 114 | ['services', './src/services'], 115 | ['main-scene', './src/scenes/Main'], 116 | ['stores', './src/stores'], 117 | ['global-styles', './src/styles'], 118 | ['global-types', './src/types'], 119 | ['utils', './src/utils'], 120 | ], 121 | extensions: ['.ts', '.js', '.tsx', '.json'] 122 | } 123 | }, 124 | }, 125 | }, 126 | ], 127 | }; --------------------------------------------------------------------------------