This commit is contained in:
2021-06-12 17:48:26 +03:00
commit 3e68914c92
56 changed files with 26153 additions and 0 deletions

11
.eslintignore Normal file
View File

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

159
.eslintrc.json Normal file
View File

@ -0,0 +1,159 @@
{
"env": {
"browser": true,
"es2021": true,
"jest/globals": true
},
"settings": {
"react": {
"version": "detect"
}
},
"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": "(useEqualMemo|useStream)"
}
],
"jsx-a11y/label-has-associated-control": 0,
"eqeqeq": "error",
"import/extensions": 0,
"react/prop-types": 0,
"no-underscore-dangle": 0,
"import/imports-first": ["warn", "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/jsx-key": "warn",
"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",
"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": "off",
"@typescript-eslint/no-use-before-define": "off",
"@typescript-eslint/no-shadow": "warn",
"@typescript-eslint/no-namespace": "off",
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/no-unused-vars": "warn",
"@typescript-eslint/no-explicit-any": "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"
}
}

27
.gitignore vendored Normal file
View File

@ -0,0 +1,27 @@
# 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*
tsconfig.dev.json

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"
}

12
babel.config.js Normal file
View File

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

190
jest.config.js Normal file
View File

@ -0,0 +1,190 @@
const aliases = require('./scripts/create-symlinks/config.json');
// 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: Object.entries(aliases).reduce((acc, [key, aliasPath]) => ({
...acc,
[`^${key}(.*)$`]: `<rootDir>/${aliasPath}$1`,
}), {}),
// 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__/**/*.(j|t)s?(x)', '**/?(*.)+(spec|test).(j|t)s?(x)'],
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
testPathIgnorePatterns: [
'(.*)/dist'
],
// 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,
};

24609
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

78
package.json Normal file
View File

@ -0,0 +1,78 @@
{
"name": "crypto-bot-frontend",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"symlinks": "node scripts/create-symlinks/index",
"start": "webpack serve",
"init": "npm run clean && npm i && npm run symlinks",
"dev": "webpack serve --open false",
"build": "webpack --mode=production",
"eslint": "eslint -c .eslintrc.json src --fix",
"tsc": "tsc --p ./tsconfig.json",
"test": "jest",
"prune": "node scripts/find-unused-code",
"checks": "npm run eslint && npm run tsc && npm run prune && npm run test && npm run build",
"clean": "rm -rf node_modules && rm -rf build"
},
"repository": {
"type": "git",
"url": "https://gitlab.vigdorov.ru/cryptobot/crypto-bot-frontend"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@reatom/core": "^1.1.5",
"@reatom/react": "^1.1.5",
"antd": "^4.16.2",
"axios": "^0.21.0",
"convert-layout": "^0.8.2",
"date-fns": "^2.16.1",
"fp-ts": "^2.8.5",
"history": "^5.0.0",
"lodash": "^4.17.20",
"react": "^17.0.1",
"react-dom": "^17.0.1",
"react-router-dom": "^5.2.0",
"ts-loader": "^8.0.7",
"typescript": "^4.0.3"
},
"devDependencies": {
"@babel/core": "^7.12.3",
"@babel/preset-env": "^7.12.1",
"@babel/preset-typescript": "^7.12.1",
"@types/convert-layout": "^0.5.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",
"@types/uuid": "^8.3.0",
"@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-import": "^2.22.1",
"eslint-plugin-jest": "^24.1.3",
"eslint-plugin-prettier": "^3.1.4",
"eslint-plugin-react": "^7.21.5",
"eslint-plugin-react-hooks": "^4.2.0",
"file-loader": "^6.2.0",
"html-webpack-plugin": "^5.0.0-beta.1",
"mini-css-extract-plugin": "^1.2.1",
"prettier": "^2.1.2",
"react-scripts": "^4.0.1",
"sass": "^1.28.0",
"sass-loader": "^10.0.4",
"ts-prune": "^0.8.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>Crypto bot: dev</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,14 @@
{
"_api": "src/core/api",
"_blocks": "src/core/blocks",
"_consts": "src/core/consts",
"_hooks": "src/core/hooks",
"_hoks": "src/core/hoks",
"_services": "src/core/services",
"_types": "src/core/types",
"_utils": "src/core/utils",
"_infrastructure": "src/core/infrastructure",
"_enums": "src/core/enums",
"_referers": "src/core/referers",
"_pages": "src/pages"
}

View File

@ -0,0 +1,50 @@
const fs = require('fs');
const path = require('path');
const aliases = require('./config.json');
const tsconfig = require('../../tsconfig.json');
const CURRENT_FOLDER = process.cwd();
function createTsConfigDev() {
const tsConfigDev = {
...tsconfig,
compilerOptions: {
...tsconfig.compilerOptions,
paths: Object.entries(aliases).reduce((acc, [key, value]) => ({
...acc,
[`${key}/*`]: [`./${value}/*`],
}), {}),
}
};
fs.writeFileSync(path.resolve('tsconfig.dev.json'), JSON.stringify(tsConfigDev, null, 4));
}
function createSymlinks() {
if (!fs.existsSync('./node_modules')) {
fs.mkdirSync('node_modules');
}
try {
for (const alias in aliases) {
const folder = aliases[alias];
const pathInPackage = path.resolve(CURRENT_FOLDER, folder);
if (!fs.existsSync(folder)) {
continue;
}
const symlinkPath = path.resolve(`./node_modules/${alias}`);
if (fs.existsSync(symlinkPath)) {
continue;
}
fs.symlinkSync(pathInPackage, symlinkPath);
console.info(`${symlinkPath} --> ${pathInPackage}`);
}
} catch (e) {
console.info(`${e}`);
}
}
createSymlinks();
createTsConfigDev();

View File

@ -0,0 +1,5 @@
module.exports = {
ignore: [
'src/core',
],
};

View File

@ -0,0 +1,15 @@
const runner = require("ts-prune/lib/runner");
const {ignore} = require('./ignore-files');
const error = [];
runner.run({ project: 'tsconfig.dev.json' }, (text) => {
if (ignore.every(ign => !text.includes(ign))) {
error.push(text);
}
});
setTimeout(() => {
if (error.length) {
throw new Error(`Присутствует не используемый код: \n${error.join('\n')}`);
}
}, 0);

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,42 @@
import React, {Fragment, memo} from 'react';
import {Route, Switch} from 'react-router-dom';
import {Container, createStyles, makeStyles} from '@material-ui/core';
import {createStore} from '@reatom/core';
import {context} from '@reatom/react';
import mainPageRouter from '_pages/main/routing';
import NotFoundPage from '_pages/not-found/components/page';
import './Page.scss';
const useStyles = makeStyles(() =>
createStyles({
container: {
height: '100hv',
display: 'flex',
flexDirection: 'column',
},
}),
);
const Page: React.FC = () => {
const classes = useStyles();
const store = createStore();
return (
<Fragment>
<div className={classes.container}>
<context.Provider value={store}>
<Container>
<Switch>
{mainPageRouter}
<Route>
<NotFoundPage />
</Route>
</Switch>
</Container>
</context.Provider>
</div>
</Fragment>
);
};
export default memo(Page);

View File

@ -0,0 +1 @@
export {default} from './Page';

17
src/app/index.tsx Normal file
View File

@ -0,0 +1,17 @@
import React from 'react';
import ReactDOM from 'react-dom';
import {HashRouter} from 'react-router-dom';
import App from './components/page';
ReactDOM.render(
/*
* Выключаем стрикт мод, пока не починят
* https://github.com/mui-org/material-ui/issues/13394
*/
// <React.StrictMode>
<HashRouter >
<App />
</HashRouter>,
// </React.StrictMode>
document.getElementById('root')
);

View File

@ -0,0 +1,3 @@
export const ROUTES = {
MAIN: '/'
};

3
src/core/dts/comon.d.ts vendored Normal file
View File

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

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,10 @@
import {useMemo} from 'react';
import {useParams as useReactParams} from 'react-router-dom';
import {getParamsFromUrl} from '_utils/getParamFromUrl';
import {QueryParsers} from '_utils/getQueryFromUrl';
export function useParams<T extends Record<string, unknown>>(paramParsers: QueryParsers<T>) {
const params = useReactParams<Record<keyof T, string>>();
return useMemo(() => getParamsFromUrl(paramParsers, params), [params, paramParsers]);
}

View File

@ -0,0 +1,9 @@
import {useMemo} from 'react';
import {useLocation} from 'react-router-dom';
import {getQueryFromUrl, QueryParsers} from '_utils/getQueryFromUrl';
export function useQuery<T extends Record<string, unknown>>(queryParsers: QueryParsers<T>): T {
const {search} = useLocation();
return useMemo(() => getQueryFromUrl(queryParsers, search), [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,51 @@
import axios, {AxiosRequestConfig} from 'axios';
enum Method {
Get = 'get',
Delete = 'delete',
Head = 'head',
Options = 'options',
Post = 'post',
Put = 'put',
Patch = 'patch',
}
type RequestConfig<Q, B> = Omit<AxiosRequestConfig, 'params' | 'data'> & {
params?: Q;
data?: B;
};
const requestMiddleware = async <Q, B, R>(config: RequestConfig<Q, B>): Promise<R> => {
const axiosResponse = await axios.request<R>(config);
// Добавить обработку ошибок
return axiosResponse.data;
};
const request = <Q, B, R>(config: RequestConfig<Q, B>) => requestMiddleware<Q, B, R>(config);
const requestWithoutBody = (method: Method) => <Q, R>(url: string, query?: Q) => {
return request<Q, never, R>({
method,
url,
params: query,
});
};
const requestWithBody = (method: Method) => <Q, B, R>(url: string, query?: Q, body?: B) => {
return request<Q, B, R>({
method,
url,
params: query,
data: body,
});
};
export const http = {
get: requestWithoutBody(Method.Get),
delete: requestWithoutBody(Method.Delete),
head: requestWithoutBody(Method.Head),
options: requestWithoutBody(Method.Options),
post: requestWithBody(Method.Post),
put: requestWithBody(Method.Put),
patch: requestWithBody(Method.Patch),
};

View File

@ -0,0 +1,7 @@
import {declareAction, declareAtom} from '@reatom/core';
export const changeNameAction = declareAction<string>();
export const nameAtom = declareAtom('', on => [
on(changeNameAction, (_state, payload) => payload),
]);

View File

@ -0,0 +1,54 @@
import {noop} from 'lodash';
import {isEmptyObject, isNotEmpty, isObject} from '../common';
describe('isObject', () => {
it('Должен вернуть true', () => {
expect(isObject({})).toBeTruthy();
expect(isObject({go: 8})).toBeTruthy();
expect(isObject(Object.create(null))).toBeTruthy();
expect(isObject(Object.create({}))).toBeTruthy();
});
it('Должен вернуть false', () => {
expect(isObject(null)).toBeFalsy();
expect(isObject([])).toBeFalsy();
expect(isObject(NaN)).toBeFalsy();
expect(isObject(noop)).toBeFalsy();
expect(isObject(0)).toBeFalsy();
expect(isObject('')).toBeFalsy();
});
});
describe('isEmptyObject', () => {
it('Должен вернуть true', () => {
expect(isEmptyObject({})).toBeTruthy();
expect(isEmptyObject(Object.create(null))).toBeTruthy();
});
it('Должен вернуть false', () => {
expect(isEmptyObject({g: 'g'})).toBeFalsy();
expect(isEmptyObject({g: undefined})).toBeFalsy();
});
});
describe('isNotEmpty', () => {
it('Должен вернуть true', () => {
expect(isNotEmpty(['3'])).toBeTruthy();
expect(isNotEmpty({f: 'f'})).toBeTruthy();
expect(isNotEmpty({f: undefined})).toBeTruthy();
expect(isNotEmpty(0)).toBeTruthy();
expect(isNotEmpty(12)).toBeTruthy();
expect(isNotEmpty('fd')).toBeTruthy();
expect(isNotEmpty('0')).toBeTruthy();
});
it('Должен вернуть false', () => {
expect(isNotEmpty([])).toBeFalsy();
expect(isNotEmpty({})).toBeFalsy();
expect(isNotEmpty('')).toBeFalsy();
expect(isNotEmpty(' ')).toBeFalsy();
expect(isNotEmpty()).toBeFalsy();
expect(isNotEmpty(null)).toBeFalsy();
expect(isNotEmpty(NaN)).toBeFalsy();
});
});

View File

@ -0,0 +1,48 @@
import {isNaN, isNumber, isString} from 'lodash';
export function isNullable<T>(value: Nullable<T>): value is (null | undefined) {
return value === null || value === undefined;
}
export function isNotNullable<T>(value: Nullable<T>): value is NonNullable<T> {
return !isNullable(value);
}
export function isObject<T>(value: Nullable<T>): value is T {
return (
isNotNullable(value)
&& !Array.isArray(value)
&& !isNaN(value)
&& typeof value === 'object'
);
}
export const isEmptyObject = <T>(value: Nullable<T>): boolean => (
!Object.keys(value ?? {}).length
);
export const isNotEmpty = <T>(value?: Nullable<T>): value is T => {
if (isString(value)) {
return !!value?.trim();
}
if (Array.isArray(value)) {
return !!value.length;
}
if (isNaN(value)) {
return false;
}
if (isNumber(value)) {
return true;
}
if (isObject(value)) {
return !isEmptyObject(value);
}
return isNotNullable(value);
};
export const isEmpty = (value: unknown) => !isNotEmpty(value);

View File

@ -0,0 +1,21 @@
export const makeLocalStorageService = <T>(init: T, stateName: string) => {
if (!localStorage.getItem(stateName)) {
localStorage.setItem(stateName, JSON.stringify(init));
}
return {
set: (updatedState: T) => {
localStorage.setItem(stateName, JSON.stringify(updatedState));
return updatedState;
},
get: (): T => {
const stringValue = localStorage.getItem(stateName) || '';
try {
return JSON.parse(stringValue);
} catch (e) {
return init;
}
},
};
};

View File

@ -0,0 +1,27 @@
import {getParamsFromUrl} from '../getParamFromUrl';
import {QueryParsers} from '../getQueryFromUrl';
import {booleanParser, numberParser, stringParser} from '../queryParsers';
describe('getParamsFromUrl', () => {
type Params = {
name?: string;
id?: number;
isNew: boolean;
};
it('Получение параметров', () => {
const parsers: QueryParsers<Params> = {
name: stringParser(),
id: numberParser(),
isNew: booleanParser(false),
};
expect(getParamsFromUrl(parsers, {olo: 't'})).toEqual({isNew: false});
expect(getParamsFromUrl(parsers, {name: 't'})).toEqual({name: 't', isNew: false});
expect(getParamsFromUrl(parsers, {
name: 't',
id: '6',
pageType: 'tags',
isNew: 'true',
})).toEqual({name: 't', id: 6, isNew: true});
});
});

View File

@ -0,0 +1,28 @@
import {getQueryFromUrl, QueryParsers} from '../getQueryFromUrl';
import {arrayParser, booleanParser, numberParser, stringParser} from '../queryParsers';
describe('getQueryFromUrl', () => {
type Query = {
name?: string;
id?: number;
isNew: boolean;
colors: string[];
};
it('Получение параметров', () => {
const parsers: QueryParsers<Query> = {
name: stringParser(),
id: numberParser(),
isNew: booleanParser(false),
colors: arrayParser([]),
};
expect(getQueryFromUrl(parsers, '?olo=2')).toEqual({isNew: false, colors: []});
expect(getQueryFromUrl(parsers, '?olo=2&name=t')).toEqual({name: 't', isNew: false, colors: []});
expect(getQueryFromUrl(parsers, '?name=t&id=6&pageType=tags&isNew=true&colors=red&colors=blue')).toEqual({
name: 't',
id: 6,
isNew: true,
colors: ['red', 'blue'],
});
});
});

View File

@ -0,0 +1,31 @@
import {jsonParse} from '../jsonParse';
describe('jsonParse', () => {
it('Должен вернуть значение', () => {
expect(jsonParse('{}')).toEqual({});
expect(jsonParse('null')).toBeNull();
expect(jsonParse('0')).toBe(0);
expect(jsonParse(' 1 ')).toBe(1);
});
it('Должен вернуть значение при наличии дефолта', () => {
expect(jsonParse('{}', {str: 9})).toEqual({});
expect(jsonParse('null', {str: 9})).toBeNull();
expect(jsonParse('0', {str: 9})).toBe(0);
expect(jsonParse(' 1 ', {str: 9})).toBe(1);
});
it('Должен вернуть undefined для не корректных значений', () => {
expect(jsonParse()).toBeUndefined();
expect(jsonParse('')).toBeUndefined();
expect(jsonParse(' ')).toBeUndefined();
expect(jsonParse('{"9')).toBeUndefined();
});
it('Должен вернуть дефолтное значение', () => {
expect(jsonParse(undefined, 'to')).toBe('to');
expect(jsonParse('', 'to')).toBe('to');
expect(jsonParse(' ', 'to')).toBe('to');
expect(jsonParse('./6dh', 9)).toBe(9);
});
});

View File

@ -0,0 +1,25 @@
import {toNumber} from '../parsers';
describe('toNumber', () => {
it('Возвращает число', () => {
expect(toNumber(0)).toBe(0);
expect(toNumber(' 0 ')).toBe(0);
expect(toNumber('0')).toBe(0);
expect(toNumber('56')).toBe(56);
expect(toNumber(' 56 ')).toBe(56);
expect(toNumber(' 5.6 ')).toBe(5.6);
expect(toNumber(' .9 ')).toBe(0.9);
expect(toNumber(1.4)).toBe(1.4);
expect(toNumber(.4)).toBe(0.4);
});
it('Возвращает undefined', () => {
expect(toNumber(' g')).toBeUndefined();
expect(toNumber(null)).toBeUndefined();
expect(toNumber(undefined)).toBeUndefined();
expect(toNumber({})).toBeUndefined();
expect(toNumber([])).toBeUndefined();
expect(toNumber(' 4 5 ')).toBeUndefined();
expect(toNumber(' 4 .5 ')).toBeUndefined();
});
});

View File

@ -0,0 +1,43 @@
import {performTextSearch} from '../performTextSearch';
describe('performTextSearch', () => {
it('Проверка отфильтрованных значений', () => {
const items = [
{name: 'Иван', age: 6, color: 'red'},
{name: 'Сергей', age: 12, color: 'white'},
{name: 'John', age: 33, color: 'black'},
{name: 'James', age: 43, color: 'yellow'},
];
expect(performTextSearch(items, 'ива', ['name'])).toEqual([
{name: 'Иван', age: 6, color: 'red'},
]);
expect(performTextSearch(items, 'cth', ['name'])).toEqual([
{name: 'Сергей', age: 12, color: 'white'},
]);
expect(performTextSearch(items, 'utq', ['name'])).toEqual([
{name: 'Сергей', age: 12, color: 'white'},
]);
expect(performTextSearch(items, 'о', ['name'])).toEqual([
{name: 'John', age: 33, color: 'black'},
{name: 'James', age: 43, color: 'yellow'},
]);
expect(performTextSearch(items, 'e', ['name', 'color'])).toEqual([
{name: 'Иван', age: 6, color: 'red'},
{name: 'Сергей', age: 12, color: 'white'},
{name: 'James', age: 43, color: 'yellow'},
]);
expect(performTextSearch(items, '3', ['age'])).toEqual([
{name: 'John', age: 33, color: 'black'},
{name: 'James', age: 43, color: 'yellow'},
]);
expect(performTextSearch(items, '', ['age'])).toEqual([
{name: 'Иван', age: 6, color: 'red'},
{name: 'Сергей', age: 12, color: 'white'},
{name: 'John', age: 33, color: 'black'},
{name: 'James', age: 43, color: 'yellow'},
]);
expect(performTextSearch(items, 'sdfsse23', ['age'])).toEqual([]);
expect(performTextSearch([], 'sdfsse23', ['age'])).toEqual([]);
});
});

View File

@ -0,0 +1,98 @@
import {arrayParser, booleanParser, numberParser, stringParser} from '../queryParsers';
describe('stringParser', () => {
it('Вернет значение', () => {
expect(stringParser()('trt')).toBe('trt');
});
it('Вернет первое значение массива', () => {
expect(stringParser()(['trt', 'ggt'])).toBe('trt');
});
it('Вернет undefined', () => {
expect(stringParser()()).toBeUndefined();
expect(stringParser()([])).toBeUndefined();
});
it('Вернет дефолт', () => {
expect(stringParser('def')()).toBe('def');
expect(stringParser('def')([])).toBe('def');
});
it('Вернет значение даже при наличии дефолта', () => {
expect(stringParser('def')(['trt'])).toBe('trt');
expect(stringParser('def')('trt')).toBe('trt');
});
});
describe('numberParser', () => {
it('Вернет значение', () => {
expect(numberParser()('45')).toBe(45);
});
it('Вернет первое значение массива', () => {
expect(numberParser()(['45', '44'])).toBe(45);
});
it('Вернет undefined', () => {
expect(numberParser()()).toBeUndefined();
expect(numberParser()([])).toBeUndefined();
});
it('Вернет дефолт', () => {
expect(numberParser(33)()).toBe(33);
expect(numberParser(33)([])).toBe(33);
});
it('Вернет значение даже при наличии дефолта', () => {
expect(numberParser(33)(['45'])).toBe(45);
expect(numberParser(33)('45')).toBe(45);
});
});
describe('booleanParser', () => {
it('Вернет значение', () => {
expect(booleanParser()('true')).toBe(true);
expect(booleanParser()('false')).toBe(false);
});
it('Вернет первое значение массива', () => {
expect(booleanParser()(['true', 'false'])).toBe(true);
});
it('Вернет undefined', () => {
expect(booleanParser()()).toBeUndefined();
expect(booleanParser()([])).toBeUndefined();
});
it('Вернет дефолт', () => {
expect(booleanParser(true)()).toBe(true);
expect(booleanParser(false)([])).toBe(false);
});
it('Вернет значение даже при наличии дефолта', () => {
expect(booleanParser(false)(['true'])).toBe(true);
expect(booleanParser(false)('true')).toBe(true);
});
});
describe('arrayParser', () => {
it('Вернет значение', () => {
expect(arrayParser()('rtr')).toEqual(['rtr']);
expect(arrayParser()(['rtr', 'rtr'])).toEqual(['rtr', 'rtr']);
expect(arrayParser()([])).toEqual([]);
});
it('Вернет undefined', () => {
expect(arrayParser()()).toBeUndefined();
});
it('Вернет дефолт', () => {
expect(arrayParser(['def'])()).toEqual(['def']);
});
it('Вернет значение даже при наличии дефолта', () => {
expect(arrayParser(['def'])('rtr')).toEqual(['rtr']);
expect(arrayParser(['def'])(['rtr'])).toEqual(['rtr']);
});
});

View File

@ -0,0 +1,16 @@
import {toArray} from '../toArray';
describe('toArray', () => {
it('Должен вернуть пустой массив', () => {
expect(toArray(undefined)).toEqual([]);
expect(toArray([])).toEqual([]);
});
it('Должен вернуть массив', () => {
expect(toArray('hji')).toEqual(['hji']);
expect(toArray(null)).toEqual([null]);
expect(toArray(0)).toEqual([0]);
expect(toArray([0, null, 'gh'])).toEqual([0, null, 'gh']);
expect(toArray([0, [null], 'gh'])).toEqual([0, [null], 'gh']);
});
});

View File

@ -0,0 +1,26 @@
import {toRequestParamValue} from '../toRequestParamValue';
describe('toRequestParamValue', () => {
it('Возвращает простые значения', () => {
expect(toRequestParamValue('trt')).toBe('trt');
expect(toRequestParamValue(0)).toBe(0);
expect(toRequestParamValue(false)).toBe(false);
});
it('Простые пустые значения возвращают undefined', () => {
expect(toRequestParamValue('')).toBeUndefined();
expect(toRequestParamValue(null)).toBeUndefined();
expect(toRequestParamValue(undefined)).toBeUndefined();
});
it('Возвращает заполненные объекты', () => {
expect(toRequestParamValue({foo: undefined})).toMatchObject({foo: undefined});
expect(toRequestParamValue({foo: 'bar'})).toMatchObject({foo: 'bar'});
expect(toRequestParamValue(['rtt'])).toEqual(['rtt']);
});
it('Пустые объекты возвращают undefined', () => {
expect(toRequestParamValue({})).toBeUndefined();
expect(toRequestParamValue([])).toBeUndefined();
});
});

View File

@ -0,0 +1,15 @@
import {QueryParsers} from './getQueryFromUrl';
export const getParamsFromUrl = <T extends Record<string, unknown>>(
paramParsers: QueryParsers<T>,
params: Record<string, string>
) => {
return Object.keys(paramParsers).reduce<T>((memo, key) => {
const parser = paramParsers[key];
return {
...memo,
[key]: parser?.(params[key]),
};
}, {} as T);
};

View File

@ -0,0 +1,19 @@
import {decode} from 'querystring';
export type QueryParser<T> = (value?: string | string[]) => T;
export type QueryParsers<T> = {[K in keyof T]: QueryParser<T[K]>};
export const getQueryFromUrl = <T extends Record<string, unknown>>(queryParsers: QueryParsers<T>, search?: string) => {
const query = decode((search || location.search).slice(1));
return Object.keys(queryParsers).reduce<T>((memo, key) => {
if (key in queryParsers) {
const parser = queryParsers[key];
return {
...memo,
[key]: parser?.(query[key]),
};
}
return memo;
}, {} as T);
};

View File

@ -0,0 +1,10 @@
export const jsonParse = <T>(str?: string, defaultValue?: T): Undefinable<T> => {
const trimStr = str?.trim();
try {
const parsedValue = JSON.parse(trimStr ?? '');
return parsedValue === undefined ? defaultValue : parsedValue;
} catch (e) {
return defaultValue;
}
};

View File

@ -0,0 +1,3 @@
export const jsonStringify = <T>(obj: T, space = 4): string => (
JSON.stringify(obj, null, space)
);

View File

@ -0,0 +1,3 @@
export const objectEntries = <T extends Record<string, unknown>, R extends keyof T>(obj: T) => (
Object.entries(obj) as Array<[R, T[R]]>
);

View File

@ -0,0 +1,3 @@
export const objectKeys = <T extends Record<string, unknown>>(obj: T) => (
Object.keys(obj) as Array<keyof T>
);

View File

@ -0,0 +1,8 @@
import {isNumber, isString} from 'lodash';
export const toNumber = (value: unknown): Undefinable<number> => {
if (isNumber(value) || isString(value)) {
const prepareValue = Number(value);
return Number.isNaN(prepareValue) ? undefined : prepareValue;
}
};

View File

@ -0,0 +1,29 @@
import ru from 'convert-layout/ru';
import {isEmpty, isNotEmpty} from '_referers/common';
export function performTextSearch<T, K extends keyof T>(items: T[], searchText: string, searchProperties: K[]) {
if (isEmpty(items) || isEmpty(searchText)) {
return items;
}
const query = searchText.toLowerCase();
const queryToEn = ru.toEn(query);
const queryToRu = ru.fromEn(query);
const hasQuery = (itemText: string) => {
const text = itemText.toLowerCase();
/**
* Т.к. convert-layout заменяет не все символы верно,
* ищем так же по первоначальной строке
* https://github.com/ai/convert-layout/issues/22
*/
return text.includes(query) || text.includes(queryToEn) || text.includes(queryToRu);
};
return items.filter(item => searchProperties.some(property => {
const propertyValue = item[property];
const text = isNotEmpty(propertyValue) ? `${propertyValue}` : undefined;
return text && hasQuery(text);
}));
}

View File

@ -0,0 +1,53 @@
import {head} from 'lodash';
import {QueryParser} from './getQueryFromUrl';
import {toNumber} from './parsers';
export function stringParser<T extends string>(): QueryParser<Undefinable<T>>;
export function stringParser<T extends string>(defaultValue: T): QueryParser<T>;
export function stringParser<T extends string>(defaultValue?: T) {
return (val?: string | string[]) => {
const value = Array.isArray(val) ? head(val) : val;
return value ?? defaultValue;
};
}
export function numberParser(): QueryParser<Undefinable<number>>;
export function numberParser(defaultValue?: number): QueryParser<number>;
export function numberParser(defaultValue?: number) {
return (val?: string | string[]) => {
const value = Array.isArray(val) ? head(val) : val;
return toNumber(value) ?? defaultValue;
};
}
export function booleanParser(): QueryParser<Undefinable<boolean>>;
export function booleanParser(defaultValue: boolean): QueryParser<boolean>;
export function booleanParser(defaultValue?: boolean) {
return (val?: string | string[]) => {
const value = Array.isArray(val) ? head(val) : val;
if (value === 'true') {
return true;
}
if (value === 'false') {
return false;
}
return defaultValue;
};
}
export function arrayParser<T extends string>(): QueryParser<Undefinable<T[]>>;
export function arrayParser<T extends string>(defaultValue: T[]): QueryParser<T[]>;
export function arrayParser<T extends string>(defaultValue?: T[]) {
return (val?: string | string[]) => {
if (Array.isArray(val)) {
return val;
}
return val === undefined ? defaultValue : [val];
};
}

View File

@ -0,0 +1,9 @@
export const toArray = <T>(value?: T | T[]): T[] => {
if (Array.isArray(value)) {
return value;
}
return [
...(value !== undefined ? [value] : [])
];
};

View File

@ -0,0 +1,7 @@
import {isNotEmpty} from '_referers/common';
export function toRequestParamValue<T>(val: T): T;
export function toRequestParamValue<T>(val?: T): Undefinable<T>;
export function toRequestParamValue<T>(val?: T) {
return isNotEmpty(val) ? val : undefined;
}

View File

@ -0,0 +1,33 @@
type Options = {
download?: boolean | string;
target?: '_self' | '_blank';
};
const DEFAULT_OPTIONS = {
target: '_self',
download: false,
} as const;
/**
* Использование этой функции требуется для открытия ссылок в новых
* вкладках из методов сервиса. Внутри компонентов его не используем.
*/
export const triggerLink = (link: string, options?: Options) => {
const finalOptions = {
...DEFAULT_OPTIONS,
...options
};
const a = document.createElement('a');
a.href = link;
a.target = finalOptions.target;
if (finalOptions.download === true) {
a.download = 'yes';
} else if (typeof finalOptions.download === 'string') {
a.download = finalOptions.download;
}
a.dispatchEvent(new MouseEvent('click'));
document.removeChild(a);
};

View File

@ -0,0 +1,21 @@
import React, {memo} from 'react';
import {changeNameAction, nameAtom} from '_infrastructure/atom/exampleAtom';
import {useAction, useAtom} from '@reatom/react';
const MainPage: React.FC = () => {
const name = useAtom(nameAtom);
const handleChangeName = useAction(e => changeNameAction(e.currentTarget.value));
return (
<div>
<div>main page</div>
<form>
<label htmlFor="name">Enter your name: </label>
<input id="name" value={name} onChange={handleChangeName} />
</form>
</div>
);
};
export default memo(MainPage);

View File

@ -0,0 +1 @@
export {default} from './Page';

View File

@ -0,0 +1,8 @@
import React from 'react';
import {Route} from 'react-router-dom';
import {ROUTES} from '_consts/common';
import Page from './components/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 @@
export {default} from './Page';

27
tsconfig.json Normal file
View File

@ -0,0 +1,27 @@
{
"compilerOptions": {
"baseUrl": ".",
"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",
"paths": {
"*": ["*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx"]
}

92
webpack.config.js Normal file
View File

@ -0,0 +1,92 @@
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const {CleanWebpackPlugin} = require('clean-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const webpack = require('webpack');
const aliases = require('./scripts/create-symlinks/config.json');
module.exports = {
mode: 'development',
entry: {
app: {
import: './src/app/index.tsx',
dependOn: [
'rct',
],
},
'rct': ['react', 'react-dom', 'react-router-dom'],
},
output: {
path: path.resolve(__dirname, 'build'),
filename: '[fullhash].[name].js',
},
devServer: {
contentBase: './build',
historyApiFallback: true,
compress: true,
open: true,
port: 3189,
},
resolve: {
extensions: ['.tsx', '.ts', '.js'],
alias: Object.entries(aliases).reduce((acc, [key, aliasPath]) => ({
...acc,
[key]: path.resolve(__dirname, `${aliasPath}/`),
}), {}),
},
optimization: {
splitChunks: {
chunks: 'all',
cacheGroups: {
defaultVendors: {
test: /[\\/]node_modules[\\/]/,
enforce: true,
},
},
},
},
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',
],
},
],
},
plugins: [
new HtmlWebpackPlugin({
template: './public/index.html',
filename: 'index.html',
favicon: './public/favicon.ico'
}),
new MiniCssExtractPlugin({
filename: '[fullhash].[name].css',
}),
new CleanWebpackPlugin(),
],
};