This commit is contained in:
2020-12-25 12:26:47 +03:00
commit 569e90b529
45 changed files with 18723 additions and 0 deletions

10
.eslintignore Normal file
View File

@ -0,0 +1,10 @@
/node_modules
/dist
/out
.vscode
.idea
/src/reportWebVitals.ts
/src/setupTests.ts
webpack.config.js
jest.config.js
babel.config.js

148
.eslintrc.json Normal file
View File

@ -0,0 +1,148 @@
{
"env": {
"browser": true,
"es2021": true,
"jest/globals": true
},
"extends": [
"eslint:recommended",
"plugin:react/recommended",
"plugin:@typescript-eslint/recommended",
"prettier",
"prettier/react",
"plugin:import/errors",
"plugin:import/warnings",
"plugin:jest/recommended",
"plugin:react-hooks/recommended"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaFeatures": {
"tsx": true
},
"ecmaVersion": 12,
"sourceType": "module"
},
"plugins": ["react", "@typescript-eslint", "prettier", "jest"],
"rules": {
"react/jsx-filename-extension": [
1,
{
"extensions": [".ts", ".tsx"]
}
],
"react-hooks/exhaustive-deps": [
"warn",
{
"additionalHooks": "(useTypedQuery|useTypedParams)"
}
],
"jsx-a11y/label-has-associated-control": 0,
"eqeqeq": "error",
"import/extensions": 0,
"react/prop-types": 0,
"no-underscore-dangle": 0,
"import/imports-first": ["error", "absolute-first"],
"import/prefer-default-export": 0,
"import/no-unresolved": 0,
"import/newline-after-import": "error",
"react/jsx-props-no-spreading": 0,
"class-methods-use-this": 0,
"react/prefer-stateless-function": 0,
"react/jsx-fragments": 0,
"react/no-array-index-key": 0,
"react/destructuring-assignment": 0,
"no-console": [
"warn",
{
"allow": ["warn", "error"]
}
],
"semi": "warn",
"quotes": ["warn", "single"],
"array-callback-return": [
"warn",
{
"allowImplicit": true,
"checkForEach": true
}
],
"no-trailing-spaces": "warn",
"no-unused-vars": "warn",
"default-case": "warn",
"default-param-last": "warn",
"no-alert": "warn",
"no-constructor-return": "warn",
"no-else-return": "warn",
"no-empty-function": "warn",
"no-multi-spaces": "warn",
"no-multi-str": "warn",
"no-new": "warn",
"no-param-reassign": "warn",
"no-sequences": "warn",
"no-useless-concat": "warn",
"prefer-promise-reject-errors": "warn",
"require-await": "warn",
"wrap-iife": ["warn", "inside"],
"yoda": "warn",
"no-shadow": "warn",
"@typescript-eslint/no-use-before-define": "off",
"@typescript-eslint/no-namespace": "off",
"@typescript-eslint/explicit-module-boundary-types": "off",
"array-bracket-spacing": ["warn", "never"],
"block-spacing": ["warn", "never"],
"brace-style": ["warn", "1tbs", {"allowSingleLine": true}],
"capitalized-comments": ["warn"],
"comma-dangle": ["warn", "only-multiline"],
"comma-spacing": ["warn", {"before": false, "after": true}],
"computed-property-spacing": ["warn", "never"],
"eol-last": ["warn", "always"],
"func-call-spacing": ["warn", "never"],
"keyword-spacing": ["warn", {"before": true}],
"line-comment-position": ["warn", {"position": "above"}],
"lines-between-class-members": ["warn", "always"],
"max-len": [
"warn",
{
"code": 120,
"ignoreComments": true,
"ignoreUrls": true,
"ignoreStrings": true,
"ignoreTemplateLiterals": true,
"ignoreRegExpLiterals": true
}
],
"multiline-comment-style": ["warn", "starred-block"],
"new-cap": "warn",
"new-parens": "warn",
"newline-per-chained-call": ["warn", {"ignoreChainWithDepth": 3}],
"no-bitwise": "warn",
"no-inline-comments": "warn",
"no-lonely-if": "warn",
"no-multi-assign": "warn",
"no-multiple-empty-lines": ["warn", {"max": 1}],
"no-nested-ternary": "warn",
"no-plusplus": "warn",
"object-curly-spacing": ["warn", "never"],
"object-property-newline": ["warn", {"allowAllPropertiesOnSameLine": true}],
"key-spacing": ["warn", {"beforeColon": false, "afterColon": true}],
"space-before-blocks": "warn",
"space-before-function-paren": ["warn", {
"anonymous": "always",
"named": "never",
"asyncArrow": "always"
}],
"space-in-parens": ["warn", "never"],
"space-infix-ops": "warn",
"arrow-parens": ["warn", "as-needed"],
"arrow-spacing": "warn",
"no-duplicate-imports": "warn",
"no-useless-computed-key": "warn",
"no-useless-constructor": "warn",
"no-var": "warn",
"prefer-const": "warn",
"prefer-rest-params": "warn",
"prefer-template": "warn",
"template-curly-spacing": "warn"
}
}

25
.gitignore vendored Normal file
View File

@ -0,0 +1,25 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.idea
# testing
/coverage
# production
/build
/dist
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

11
.prettierrc Normal file
View File

@ -0,0 +1,11 @@
{
"printWidth": 120,
"tabWidth": 4,
"useTabs": false,
"semi": true,
"singleQuote": true,
"trailingComma": "none",
"bracketSpacing": false,
"jsxBracketSameLine": false,
"arrowParens": "avoid"
}

19
README.md Normal file
View File

@ -0,0 +1,19 @@
Install App dependencies:
```
npm i
```
---
Start App and open page in your browser:
```
npm start
```
---
Start App without open:
```
npm run dev
```
---
Build your App:
```
npm run build
```

12
babel.config.js Normal file
View File

@ -0,0 +1,12 @@
module.exports = {
presets: [
[
'@babel/preset-env',
{
targets: {node: 'current'}
},
],
'@babel/preset-typescript'
],
};

189
jest.config.js Normal file
View File

@ -0,0 +1,189 @@
// For a detailed explanation regarding each configuration property, visit:
// https://jestjs.io/docs/en/configuration.html
module.exports = {
// All imported modules in your tests should be mocked automatically
// automock: false,
// Stop running tests after `n` failures
// bail: 0,
// The directory where Jest should store its cached dependency information
// cacheDirectory: "/private/var/folders/95/rl6ym4qj7vg7y7myqqlvjzzn4q2cvt/T/jest_kdlda2",
// Automatically clear mock calls and instances between every test
clearMocks: true,
// Indicates whether the coverage information should be collected while executing the test
// collectCoverage: false,
// An array of glob patterns indicating a set of files for which coverage information should be collected
// collectCoverageFrom: undefined,
// The directory where Jest should output its coverage files
// coverageDirectory: undefined,
// An array of regexp pattern strings used to skip coverage collection
// coveragePathIgnorePatterns: [
// "/node_modules/"
// ],
// Indicates which provider should be used to instrument code for coverage
// coverageProvider: "babel",
// A list of reporter names that Jest uses when writing coverage reports
// coverageReporters: [
// "json",
// "text",
// "lcov",
// "clover"
// ],
// An object that configures minimum threshold enforcement for coverage results
// coverageThreshold: undefined,
// A path to a custom dependency extractor
// dependencyExtractor: undefined,
// Make calling deprecated APIs throw helpful error messages
// errorOnDeprecated: false,
// Force coverage collection from ignored files using an array of glob patterns
// forceCoverageMatch: [],
// A path to a module which exports an async function that is triggered once before all test suites
// globalSetup: undefined,
// A path to a module which exports an async function that is triggered once after all test suites
// globalTeardown: undefined,
// A set of global variables that need to be available in all test environments
// globals: {},
// The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers.
// maxWorkers: "50%",
// An array of directory names to be searched recursively up from the requiring module's location
// moduleDirectories: [
// "node_modules"
// ],
// An array of file extensions your modules use
// moduleFileExtensions: [
// "js",
// "json",
// "jsx",
// "ts",
// "tsx",
// "node"
// ],
// A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module
// moduleNameMapper: {},
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
// modulePathIgnorePatterns: [],
// Activates notifications for test results
// notify: false,
// An enum that specifies notification mode. Requires { notify: true }
// notifyMode: "failure-change",
// A preset that is used as a base for Jest's configuration
// preset: undefined,
// Run tests from one or more projects
// projects: undefined,
// Use this configuration option to add custom reporters to Jest
// reporters: undefined,
// Automatically reset mock state between every test
// resetMocks: false,
// Reset the module registry before running each individual test
// resetModules: false,
// A path to a custom resolver
// resolver: undefined,
// Automatically restore mock state between every test
// restoreMocks: false,
// The root directory that Jest should scan for tests and modules within
// rootDir: undefined,
// A list of paths to directories that Jest should use to search for files in
// roots: [
// "<rootDir>"
// ],
// Allows you to use a custom runner instead of Jest's default test runner
// runner: "jest-runner",
// The paths to modules that run some code to configure or set up the testing environment before each test
// setupFiles: [],
// A list of paths to modules that run some code to configure or set up the testing framework before each test
// setupFilesAfterEnv: [],
// A list of paths to snapshot serializer modules Jest should use for snapshot testing
// snapshotSerializers: [],
// The test environment that will be used for testing
// testEnvironment: "jest-environment-jsdom",
// Options that will be passed to the testEnvironment
// testEnvironmentOptions: {},
// Adds a location field to test results
// testLocationInResults: false,
// The glob patterns Jest uses to detect test files
// testMatch: [
// "**/__tests__/**/*.[jt]s?(x)",
// "**/?(*.)+(spec|test).[tj]s?(x)"
// ],
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
// testPathIgnorePatterns: [
// "/node_modules/"
// ],
// The regexp pattern or array of patterns that Jest uses to detect test files
// testRegex: [],
// This option allows the use of a custom results processor
// testResultsProcessor: undefined,
// This option allows use of a custom test runner
// testRunner: "jasmine2",
// This option sets the URL for the jsdom environment. It is reflected in properties such as location.href
// testURL: "http://localhost",
// Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout"
// timers: "real",
// A map from regular expressions to paths to transformers
// transform: undefined,
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
// transformIgnorePatterns: [
// "/node_modules/"
// ],
// An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
// unmockedModulePathPatterns: undefined,
// Indicates whether each individual test should be reported during the run
// verbose: undefined,
// An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
// watchPathIgnorePatterns: [],
// Whether to use watchman for file crawling
// watchman: true,
};

17353
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

63
package.json Normal file
View File

@ -0,0 +1,63 @@
{
"name": "tracker",
"version": "0.1.0",
"private": true,
"dependencies": {
"@devexperts/remote-data-ts": "^2.0.4",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
"@most/adapter": "^1.0.0",
"@most/core": "^1.6.1",
"@most/scheduler": "^1.3.0",
"@most/types": "^1.1.0",
"@types/uuid": "^8.3.0",
"axios": "^0.21.0",
"file-loader": "^6.2.0",
"fp-ts": "^2.8.5",
"lodash": "^4.17.20",
"react": "^17.0.1",
"react-dom": "^17.0.1",
"react-router-dom": "^5.2.0",
"react-scripts": "4.0.0",
"ts-loader": "^8.0.7",
"typescript": "^4.0.3"
},
"scripts": {
"start": "webpack serve",
"dev": "webpack serve --open false",
"build": "webpack --mode=production",
"eslint": "eslint -c .eslintrc.json src --fix",
"tsc": "tsc --p ./tsconfig.json",
"lint": "npm run eslint && npm run tsc",
"test": "jest"
},
"devDependencies": {
"@babel/core": "^7.12.3",
"@babel/preset-env": "^7.12.1",
"@babel/preset-typescript": "^7.12.1",
"@types/jest": "^26.0.15",
"@types/lodash": "^4.14.165",
"@types/node": "^12.19.1",
"@types/react": "^16.9.53",
"@types/react-dom": "^16.9.8",
"@types/react-router-dom": "^5.1.6",
"@typescript-eslint/eslint-plugin": "^4.6.0",
"@typescript-eslint/parser": "^4.6.0",
"babel-jest": "^26.6.1",
"clean-webpack-plugin": "^3.0.0",
"css-loader": "^5.0.0",
"eslint": "^7.12.1",
"eslint-config-prettier": "^6.15.0",
"eslint-plugin-prettier": "^3.1.4",
"eslint-plugin-react": "^7.21.5",
"eslint-plugin-react-hooks": "^4.2.0",
"html-webpack-plugin": "^4.5.0",
"mini-css-extract-plugin": "^1.2.1",
"prettier": "^2.1.2",
"sass": "^1.28.0",
"sass-loader": "^10.0.4",
"webpack": "^5.3.2",
"webpack-cli": "^4.1.0",
"webpack-dev-server": "^3.11.0"
}
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

18
public/index.html Normal file
View File

@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<link rel="icon" href="./favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created by cool programmers"
/>
<title>Tracker App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>

View File

@ -0,0 +1,17 @@
html {
height: 100%;
}
body {
height: 100%;
margin: 0;
}
#root {
height: 100%;
}
a {
color: inherit;
text-decoration: none;
}

View File

@ -0,0 +1,29 @@
import React, {memo} from 'react';
import {BrowserRouter, Route, Switch} from 'react-router-dom';
import mainPageRouter from '../../../pages/main/routing';
import queuesPageRouter from '../../../pages/queues/routing';
import tasksPageRouter from '../../../pages/tasks/routing';
import authResponsePageRouter from '../../../pages/auth-response/routing';
import NotFoundPage from '../../../pages/not-found/components/page/Page';
import TopMenu from '../top-menu/TopMenu';
import './Page.scss';
const Page: React.FC = () => {
return (
<BrowserRouter>
<TopMenu />
<Switch>
{mainPageRouter}
{queuesPageRouter}
{tasksPageRouter}
{authResponsePageRouter}
<Route>
<NotFoundPage />
</Route>
</Switch>
</BrowserRouter>
);
};
export default memo(Page);

View File

@ -0,0 +1,30 @@
import {List, ListItem as MaterialListItem, ListItemIcon, ListItemText} from '@material-ui/core';
import React, {memo} from 'react';
import {Link} from 'react-router-dom';
import InboxIcon from '@material-ui/icons/MoveToInbox';
import {ListItem} from '../../../common/types';
type Props = {
list: ListItem[];
};
const MenuList: React.FC<Props> = ({list}) => {
return (
<List>
{list.map(({title, url}) => (
<Link to={url} key={url}>
<MaterialListItem button key={url}>
<ListItemIcon>
<InboxIcon />
</ListItemIcon>
<ListItemText primary={title} />
</MaterialListItem>
</Link>
))}
</List>
);
};
export default memo(MenuList);

View File

@ -0,0 +1,59 @@
import React, {memo} from 'react';
import {createStyles, makeStyles, Theme} from '@material-ui/core/styles';
import AppBar from '@material-ui/core/AppBar';
import Toolbar from '@material-ui/core/Toolbar';
import Typography from '@material-ui/core/Typography';
import IconButton from '@material-ui/core/IconButton';
import MenuIcon from '@material-ui/icons/Menu';
import {Divider, Drawer} from '@material-ui/core';
import {useToggle} from '../../../common/hooks/useToggle';
import {MENU} from '../../../common/consts';
import MenuList from './MenuList';
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
flexGrow: 1,
},
menuButton: {
marginRight: theme.spacing(2),
},
title: {
flexGrow: 1,
},
}),
);
const TopMenu: React.FC = () => {
const classes = useStyles();
const [isToggle, handleToggle] = useToggle();
return (
<div className={classes.root}>
<AppBar position="static">
<Toolbar>
<IconButton edge="start" className={classes.menuButton} color="inherit" aria-label="menu" onClick={handleToggle}>
<MenuIcon />
</IconButton>
<Typography variant="h6" className={classes.title}>
Tracker App
</Typography>
</Toolbar>
</AppBar>
<Drawer anchor="top" open={isToggle} onClose={handleToggle}>
<div
role="presentation"
onClick={handleToggle}
onKeyDown={handleToggle}
>
<MenuList list={MENU.COMMON} />
<Divider />
<MenuList list={MENU.PERSONAL} />
</div>
</Drawer>
</div>
);
};
export default memo(TopMenu);

View File

@ -0,0 +1,7 @@
import {numberToString} from '../utils';
describe('test numberToString', () => {
it('success convert', () => {
expect(numberToString(56)).toBe('56');
});
});

View File

@ -0,0 +1,70 @@
import {makeStorageApi} from './StorageApi';
type Region = {
name: string;
subject_number: number;
gibdd_codes: Array<number>;
};
type ResponseRegions = {
regions: Array<Region>;
}
const api = makeStorageApi<ResponseRegions>({
key: 'russian_regions',
hook: '26502372-6bc4-4cdf-bbcc-41b3b71cb386',
description: 'Регионы России',
service_name: 'geo_services',
});
export const regionsApi = {
request: async (): Promise<Region[]> => {
const {value: {regions}} = await api.request();
return regions;
},
find: async (name: string): Promise<Undefinable<Region>> => {
const regions = await regionsApi.request();
return regions.find(region => region.name === name);
},
create: async (newRegion: Region): Promise<Region> => {
const regions = await regionsApi.request();
const findedRegion = regions.find(region => region.name === newRegion.name);
if (findedRegion) {
throw new Error(`Город с именем "${newRegion.name}" уже существует`);
}
await api.update({
regions: [
...regions,
newRegion,
],
});
return newRegion;
},
update: async (updatedRegion: Region): Promise<Region> => {
const regions = await regionsApi.request();
const findedIndex = regions.findIndex(region => region.name === updatedRegion.name);
if (findedIndex === -1) {
throw new Error(`Город с именем "${updatedRegion.name}" не найден`);
}
await api.update({
regions: regions.map((region, index) => {
if (findedIndex === index) {
return updatedRegion;
}
return region;
}),
});
return updatedRegion;
},
remove: async (name: string): Promise<string> => {
const regions = await regionsApi.request();
const findedIndex = regions.findIndex(region => region.name === name);
if (findedIndex === -1) {
throw new Error(`Город с именем "${name}" не найден`);
}
await api.update({
regions: regions.filter(region => region.name === name),
});
return name;
}
};

View File

@ -0,0 +1,44 @@
import {http} from '../infrastructure/Http';
type QueryRequest = {
hook: string;
}
type ResponseData<T> = {
key: string;
value: T;
description: string;
service_name: string;
author: string;
};
type RequestData<T> = Omit<ResponseData<T>, 'author'>;
type ApiConfig<T> = Omit<ResponseData<T>, 'value' | 'author'> & {
hook: string;
};
type Api<T> = {
request: () => Promise<ResponseData<T>>;
update: (updateValue: T) => Promise<ResponseData<T>>;
};
const ROOT_URL = 'https://api.storage.vigdorov.ru/store';
export const makeStorageApi = <T>({key, hook, ...body}: ApiConfig<T>): Api<T> => {
const config = {params: {hook}};
return {
request: async (): Promise<ResponseData<T>> => {
const {data} = await http.get<QueryRequest, ResponseData<T>>(`${ROOT_URL}/${key}`, config);
return data;
},
update: async (updateValue: T): Promise<ResponseData<T>> => {
const {data} = await http.put<QueryRequest, RequestData<T>, ResponseData<T>>(ROOT_URL, {
...body,
key,
value: updateValue,
}, config);
return data;
},
};
};

1
src/common/comon.d.ts vendored Normal file
View File

@ -0,0 +1 @@
type Undefinable<T> = T | undefined;

32
src/common/consts.ts Normal file
View File

@ -0,0 +1,32 @@
import {ListItem} from './types';
export const ROUTES = {
MAIN: '/',
QUEUES: '/queues',
TASKS: '/tasks',
SETTINGS: '/settings',
AUTH_RESPONSE: '/auth-response',
};
export const MENU: Record<string, ListItem[]> = {
COMMON: [
{
title: 'Главная',
url: ROUTES.MAIN,
},
{
title: 'Очереди',
url: ROUTES.QUEUES,
},
{
title: 'Задачи',
url: ROUTES.TASKS,
},
],
PERSONAL: [
{
title: 'Настройки',
url: ROUTES.SETTINGS,
},
]
};

View File

@ -0,0 +1,16 @@
import isEqual from 'lodash/isEqual';
import {DependencyList, useEffect, useState} from 'react';
const emptyDependency: DependencyList = [];
export function useEqualMemo<T>(func: () => T, args: DependencyList = emptyDependency): T {
const [memoized, memo] = useState<T>(func());
useEffect(() => {
const data = func();
if (!isEqual(memoized, data)) {
memo(data);
}
}, [memoized, func, args]);
return memoized;
}

View File

@ -0,0 +1,28 @@
import {parse, ParsedUrlQuery} from 'querystring';
import {useMemo} from 'react';
import {useLocation} from 'react-router-dom';
type QueryParser<T> = (value?: string | string[]) => Undefinable<T>;
export type QueryParsers<T> = Partial<{[K in keyof T]: QueryParser<T[K]>}>;
export function useQuery(): ParsedUrlQuery;
export function useQuery<T extends {[name: string]: unknown}>(queryParsers: QueryParsers<T>): Partial<T>;
export function useQuery<T extends {[name: string]: unknown}>(
queryParsers?: QueryParsers<T>
): ParsedUrlQuery | Partial<T> {
const {search} = useLocation();
return useMemo(() => {
const query = parse(search.slice(1));
return queryParsers ? Object.keys(query).reduce<Partial<T>>((memo, key) => {
if (key in queryParsers) {
const parser = queryParsers[key];
return {
...memo,
[key]: parser?.(query[key]),
};
}
return memo;
}, {}) : query;
}, [search, queryParsers]);
}

View File

@ -0,0 +1,10 @@
import {useCallback, useState} from 'react';
export const useToggle = (initValue = false): [boolean, () => void] => {
const [isToggle, onToggle] = useState(initValue);
const handleToggle = useCallback(() => onToggle(state => !state), [onToggle]);
return [
isToggle,
handleToggle,
];
};

View File

@ -0,0 +1,53 @@
import axios, {AxiosRequestConfig, AxiosResponse} from 'axios';
type RequestConfig<Q> = Omit<AxiosRequestConfig, 'params'> & {
params: Q;
};
export const http = {
get: <Query, Response>(
url: string,
config: RequestConfig<Query>
): Promise<AxiosResponse<Response>> => {
return axios.get<Response>(url, config);
},
delete: <Query, Response>(
url: string,
config: RequestConfig<Query>
): Promise<AxiosResponse<Response>> => {
return axios.delete<Response>(url, config);
},
head: <Query, Response>(
url: string,
config: RequestConfig<Query>
): Promise<AxiosResponse<Response>> => {
return axios.head<Response>(url, config);
},
options: <Query, Response>(
url: string,
config: RequestConfig<Query>
): Promise<AxiosResponse<Response>> => {
return axios.options<Response>(url, config);
},
post: <Query, Body, Response>(
url: string,
body: Body,
config: RequestConfig<Query>
): Promise<AxiosResponse<Response>> => {
return axios.post<Response>(url, body, config);
},
put: <Query, Body, Response>(
url: string,
body: Body,
config: RequestConfig<Query>
): Promise<AxiosResponse<Response>> => {
return axios.post<Response>(url, body, config);
},
patch: <Query, Body, Response>(
url: string,
body: Body,
config: RequestConfig<Query>
): Promise<AxiosResponse<Response>> => {
return axios.post<Response>(url, body, config);
},
};

4
src/common/types.ts Normal file
View File

@ -0,0 +1,4 @@
export type ListItem = {
title: string;
url: string;
};

1
src/common/utils.ts Normal file
View File

@ -0,0 +1 @@
export const numberToString = (num: number): string => num.toString();

11
src/index.tsx Normal file
View File

@ -0,0 +1,11 @@
import React from 'react';
import ReactDOM from 'react-dom';
import App from './app/components/page/Page';
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById('root')
);

View File

@ -0,0 +1,23 @@
import {parse} from 'querystring';
import React, {memo} from 'react';
import {QueryParsers, useQuery} from '../../../../common/hooks/useQuery';
import {QueryResponse, QueryResponseError} from '../../types';
type Person = {
name: string;
age: number;
};
const parsers: QueryParsers<Person> = {
name: name => name ? name.toString() : '',
age: age => age ? Number(age) : undefined,
};
const AuthResponsePage: React.FC = () => {
const query = useQuery(parsers);
return (
<div>Auth Page</div>
);
};
export default memo(AuthResponsePage);

View File

@ -0,0 +1,13 @@
import React from 'react';
import {Route} from 'react-router-dom';
import {ROUTES} from '../../common/consts';
import Page from './components/page/Page';
export default (
<Route
component={Page}
path={ROUTES.AUTH_RESPONSE}
exact
/>
);

View File

@ -0,0 +1,98 @@
export type QueryRequest = {
/**
* При запросе токена следует указать значение «token»
*/
response_type: string;
/**
* Идентификатор приложения. Доступен в свойствах приложения
*/
client_id: string;
/**
* Уникальный идентификатор устройства, для которого запрашивается токен. Чтобы обеспечить
* уникальность, достаточно один раз сгенерировать UUID и использовать его при каждом запросе
* нового токена с данного устройства.
*
* Идентификатор должен быть не короче 6 символов и не длиннее 50. Допускается использовать
* только печатаемые ASCII-символы (с кодами от 32 до 126).
*/
device_id?: string;
/**
* Имя устройства, которое следует показывать пользователям. Не длиннее 100 символов.
*/
device_name?: string;
/**
* URL, на который нужно перенаправить пользователя после того, как он разрешил или отказал приложению
* в доступе. По умолчанию используется первый Callback URI, указанный в настройках приложения.
* В значении параметра допустимо указывать только те адреса, которые перечислены в настройках
* приложения. Если совпадение неточное, параметр игнорируется.
*/
redirect_uri?: string;
/**
* Явное указание аккаунта, для которого запрашивается токен. В значении параметра можно передавать логин
* аккаунта на Яндексе, а также адрес Яндекс.Почты или Яндекс.Почты для домена.
*/
login_hint?: string;
/**
* Список необходимых приложению в данный момент прав доступа, разделенных пробелом. Права должны
* запрашиваться из перечня, определенного при регистрации приложения. Если параметры scope
* и optional_scope не переданы, то токен будет выдан с правами, указанными при регистрации приложения.
*/
scope?: string;
/**
* Если параметры scope и optional_scope не переданы, то токен будет выдан с правами,
* указанными при регистрации приложения.
*/
optional_scope?: string;
/**
* Признак того, что у пользователя обязательно нужно запросить разрешение на доступ
* к аккаунту (даже если пользователь уже разрешил доступ данному приложению).
* Получив этот параметр, Яндекс.OAuth предложит пользователю разрешить доступ приложению
* и выбрать нужный аккаунт Яндекса.
*/
force_confirm?: 'yes' | true | 1;
/**
* Строка состояния, которую Яндекс.OAuth возвращает без изменения.
* Максимальная допустимая длина строки — 1024 символа.
*/
state?: string;
/**
* Признак облегченной верстки (без стандартной навигации Яндекса) для страницы разрешения доступа.
* Облегченную верстку стоит запрашивать, например, если страницу разрешения нужно отобразить
* в маленьком всплывающем окне.
*/
display?: 'popup';
};
export type QueryResponse = {
/**
* OAuth-токен с запрошенными правами или с правами, указанными при регистрации приложения.
*/
access_token: string;
/**
* Время жизни токена в секундах.
*/
expires_in: string;
/**
* Тип выданного токена. Всегда принимает значение «bearer».
*/
token_type: 'bearer';
/**
* Значение параметра state из исходного запроса, если этот параметр был передан.
*/
state?: string;
};
export type QueryResponseError = {
/**
* Код ошибки
*/
error: 'access_denied' | 'unauthorized_client';
/**
* Описание ошибки
*/
error_description: string;
/**
* Значение параметра state из исходного запроса, если этот параметр был передан.
*/
state?: string;
};

View File

@ -0,0 +1,32 @@
import React, {FC, memo} from 'react';
import {chain, fromPromise, map} from '@most/core';
import {pipe} from 'fp-ts/lib/pipeable';
import {useStream} from '../../../../utils/useStream';
import {list$} from '../../../../services/service1';
const promise1: (id: number) => Promise<string> = (id: number) => new Promise(res => {
setTimeout(() => res(`${id} 123123`), 6000);
});
const getStreamFromPromise = (id: number) => fromPromise(promise1(id));
const ComponentStream: FC = () => {
const data = useStream(
pipe(
list$,
map(arr => {
return arr.length;
}),
chain(id => getStreamFromPromise(id))
),
''
);
return (
<div>
<div>{data}</div>
</div>
);
};
export default memo(ComponentStream);

View File

@ -0,0 +1,19 @@
import React, {memo} from 'react';
import {AuthService} from '../../../../services/AuthService';
import {useStream} from '../../../../utils/useStream';
import ComponentStream from '../component-stream/ComponentStream';
const MainPage: React.FC = () => {
const {isAuth} = useStream(AuthService.state$, AuthService.initState);
const toggle = () => AuthService.handleChangeAuth(!isAuth);
return (
<div>
Main Page
Auth: {isAuth ? 'yes' : 'no'}
<button onClick={toggle}>click</button>
<ComponentStream />
</div>
);
};
export default memo(MainPage);

View File

@ -0,0 +1,9 @@
import React from 'react';
import {Route} from 'react-router-dom';
import {ROUTES} from '../../common/consts';
import Page from './components/page/Page';
export default (
<Route component={Page} path={ROUTES.MAIN} exact />
);

View File

@ -0,0 +1,9 @@
import React, {memo} from 'react';
const NotFoundPage: React.FC = () => {
return (
<div>404: Not Found Page</div>
);
};
export default memo(NotFoundPage);

View File

@ -0,0 +1,13 @@
import React, {memo} from 'react';
import QueueTable from '../queue-table/QueueTable';
const QueuesPage: React.FC = () => {
return (
<div>
<div>Queues Page</div>
<QueueTable />
</div>
);
};
export default memo(QueuesPage);

View File

@ -0,0 +1,37 @@
import {Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow} from '@material-ui/core';
import React, {memo} from 'react';
const rows = [
'Очередь №1',
'Тестовая очередь',
'Старая очередь',
'Не новая очередь',
'Прошлая очередь',
];
const QueueTable: React.FC = () => {
return (
<TableContainer component={Paper}>
<Table aria-label="simple table">
<TableHead>
<TableRow>
<TableCell component="th"></TableCell>
<TableCell component="th">Название очереди</TableCell>
</TableRow>
</TableHead>
<TableBody>
{rows.map((row, index) => (
<TableRow key={row}>
<TableCell scope="row">
{index + 1}
</TableCell>
<TableCell>{row}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
);
};
export default memo(QueueTable);

View File

@ -0,0 +1,9 @@
import React from 'react';
import {Route} from 'react-router-dom';
import {ROUTES} from '../../common/consts';
import Page from './components/page/Page';
export default (
<Route component={Page} path={ROUTES.QUEUES} exact />
);

View File

@ -0,0 +1,9 @@
import React, {memo} from 'react';
const TasksPage: React.FC = () => {
return (
<div>Tasks Page</div>
);
};
export default memo(TasksPage);

View File

@ -0,0 +1,9 @@
import React from 'react';
import {Route} from 'react-router-dom';
import {ROUTES} from '../../common/consts';
import Page from './components/page/Page';
export default (
<Route component={Page} path={ROUTES.TASKS} exact />
);

View File

@ -0,0 +1,20 @@
import {createAdapter} from '@most/adapter';
import {state} from 'fp-ts/lib/State';
export namespace AuthService {
type State = {
isAuth: boolean;
};
export const initState: State = {
isAuth: false,
};
const [changeState, stream$] = createAdapter<State>();
export const handleChangeAuth = (isAuth: boolean): void => changeState({
...state,
isAuth,
});
export const state$ = stream$;
}

13
src/services/service1.ts Normal file
View File

@ -0,0 +1,13 @@
import {createAdapter} from '@most/adapter';
const arr: Array<number> = [];
let inc = 0;
const [handler, stream$] = createAdapter<Array<number>>();
export const list$ = stream$;
setInterval(() => {
arr.push(inc += 1);
handler(arr);
}, 500);

View File

@ -0,0 +1,26 @@
import React, {ReactNode} from 'react';
import {RemoteData, fold, map} from '@devexperts/remote-data-ts';
import {Stream} from '@most/types';
import * as M from '@most/core';
import {pipe} from 'fp-ts/lib/pipeable';
export const renderAsyncData = <E, A>(
data: RemoteData<E, A>,
renderSuccessData: (successData: A) => ReactNode
): ReactNode => {
return fold<E, A, ReactNode>(
() => <div>Initial</div>,
() => <div>Pending</div>,
error => <div>{`${error}`}</div>,
successData => renderSuccessData(successData),
)(data);
};
export const mapRD = <E, A, R>(mapper: (val: A) => R) => {
return (stream$: Stream<RemoteData<E, A>>): Stream<RemoteData<E, R>> => {
return pipe(
stream$,
M.map(val => map(mapper)(val))
);
};
};

32
src/utils/useStream.ts Normal file
View File

@ -0,0 +1,32 @@
import {useEffect, useState} from 'react';
import {Stream, Sink} from '@most/types';
import {newDefaultScheduler} from '@most/scheduler';
import {pending, RemoteData} from '@devexperts/remote-data-ts';
// eslint-disable-next-line no-empty-function
const emptyFunc = () => {};
export const useStream = <T>(stream$: Stream<T>, defaultValue: T): T => {
const [state, setState] = useState(defaultValue);
useEffect(() => {
const sink: Sink<T> = {
event: (_, val) => {
setState(val);
},
end: emptyFunc,
error: emptyFunc
};
const effect$ = stream$.run(sink, newDefaultScheduler());
return () => {
effect$.dispose();
};
}, []);
return state;
};
export const useStreamRD = <T, E = Error>(stream$: Stream<RemoteData<E, T>>): RemoteData<E, T> => {
return useStream(stream$, pending);
};

26
tsconfig.json Normal file
View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"outDir": "./dist/",
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": false,
"noEmit": false,
"jsx": "react"
},
"include": [
"src/**/*.ts",
"src/**/*.tsx"
]
}

66
webpack.config.js Normal file
View File

@ -0,0 +1,66 @@
const path = require('path');
var HtmlWebpackPlugin = require('html-webpack-plugin');
const {CleanWebpackPlugin} = require('clean-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
module.exports = {
entry: './src/index.tsx',
output: {
path: path.resolve(__dirname, 'build'),
filename: 'index.js',
},
devServer: {
contentBase: './build',
historyApiFallback: true,
compress: true,
open: true,
port: 3189,
},
module: {
rules: [
{
test: /\.[tj]sx?$/,
use: 'ts-loader',
exclude: /node_modules/,
},
{
test: /\.(png|jpe?g|gif|ico)$/i,
use: [
{
loader: 'file-loader',
},
],
},
{
test: /\.(txt|json)$/i,
use: [
{
loader: 'file-loader',
},
],
},
{
test: /\.(sa|sc|c)ss$/,
use: [
MiniCssExtractPlugin.loader,
'css-loader',
'sass-loader',
],
},
],
},
resolve: {
extensions: ['.tsx', '.ts', '.js'],
},
plugins: [
new HtmlWebpackPlugin({
template: './public/index.html',
filename: 'index.html',
favicon: './public/favicon.ico'
}),
new MiniCssExtractPlugin({
filename: '[name].css',
}),
new CleanWebpackPlugin(),
],
};