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

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);
};