diff --git a/src/app/components/page/Page.tsx b/src/app/components/page/Page.tsx index e305b45..35937e5 100644 --- a/src/app/components/page/Page.tsx +++ b/src/app/components/page/Page.tsx @@ -1,6 +1,5 @@ import React, {memo} from 'react'; import {Route, Switch} from 'react-router-dom'; -import {createStore} from '@reatom/core'; import {context} from '@reatom/react'; import mainPageRouter from '_pages/main/routing'; import usersPageRouter from '_pages/users/routing'; @@ -12,6 +11,8 @@ import NotFoundPage from '_pages/not-found/components/page'; import MainLayout from '../main-layout'; import jss from 'jss'; import preset from 'jss-preset-default'; +import {store} from '../../../core/infrastructure/atom/store'; +import ConnectedRouter from '../../../core/blocks/connected-router/ConnectedRouter'; jss.setup(preset()); @@ -33,23 +34,23 @@ const styles = { jss.createStyleSheet(styles).attach(); const Page: React.FC = () => { - const store = createStore(); - return ( - - - {mainPageRouter} - {usersPageRouter} - {actionsPageRouter} - {conditionsPageRouter} - {graphsPageRouter} - {currenciesPageRouter} - - - - - + + + + {mainPageRouter} + {usersPageRouter} + {actionsPageRouter} + {conditionsPageRouter} + {graphsPageRouter} + {currenciesPageRouter} + + + + + + ); }; diff --git a/src/core/api/CrudAPI.ts b/src/core/api/CrudAPI.ts index 6babbbc..d50ac2e 100644 --- a/src/core/api/CrudAPI.ts +++ b/src/core/api/CrudAPI.ts @@ -21,6 +21,7 @@ type RequestEntities = { type EntityWithId = T & { id: string; }; +type EntityWithoutId = Omit; type ResponseEntities = { data: EntityWithId[]; @@ -63,8 +64,8 @@ export class CrudAPI { return http.get(`${this.endpoint}/${id}`); } - create = (entity: T): Promise> => { - return http.post>(this.endpoint, undefined, entity); + create = (entity: EntityWithoutId): Promise> => { + return http.post, EntityWithId>(this.endpoint, undefined, entity); } update = (id: string, entity: T): Promise> => { diff --git a/src/core/blocks/connected-router/ConnectedRouter.tsx b/src/core/blocks/connected-router/ConnectedRouter.tsx new file mode 100644 index 0000000..0ee011c --- /dev/null +++ b/src/core/blocks/connected-router/ConnectedRouter.tsx @@ -0,0 +1,22 @@ +import {useAtom} from '@reatom/react'; +import React, {FC, Fragment, memo, PropsWithChildren, useEffect} from 'react'; +import {useHistory} from 'react-router'; +import {routerAtom} from '../../infrastructure/atom/routerAtom'; + +const ConnectedRouter: FC> = ({children}) => { + const history = useHistory(); + + const route = useAtom(routerAtom); + + useEffect(() => { + history.push(route); + }, [history, route]); + + return ( + + {children} + + ); +}; + +export default memo(ConnectedRouter); diff --git a/src/core/blocks/connected-router/index.ts b/src/core/blocks/connected-router/index.ts new file mode 100644 index 0000000..baf8733 --- /dev/null +++ b/src/core/blocks/connected-router/index.ts @@ -0,0 +1 @@ +export * from './ConnectedRouter'; diff --git a/src/core/consts/common.ts b/src/core/consts/common.ts index f60b9fa..d7a4aa1 100644 --- a/src/core/consts/common.ts +++ b/src/core/consts/common.ts @@ -8,5 +8,9 @@ export const ROUTES = { }; export const ENDPOINT = { - USERS: 'http://vigdorov.ru:3011/users', + USERS: 'https://localhost:3189/api/users', + ACTIONS: 'https://localhost:3189/api/bot/actions', + CONDITIONS: 'https://localhost:3189/api/bot/conditions', + GRAPHS: 'https://localhost:3189/api/bot/graphs', + CURRENCIES: 'https://localhost:3189/api/bot/currencies', }; diff --git a/src/core/infrastructure/atom/routerAtom.ts b/src/core/infrastructure/atom/routerAtom.ts new file mode 100644 index 0000000..cfa1fd5 --- /dev/null +++ b/src/core/infrastructure/atom/routerAtom.ts @@ -0,0 +1,17 @@ +import {declareAction, declareAtom} from '@reatom/core'; +import {createHashHistory} from 'history'; +import {store} from './store'; + +const {location: {pathname, search}} = createHashHistory(); + +const routerAction = declareAction(); + +export const routerAtom = declareAtom(`${pathname}${search}`, on => [ + on(routerAction, (_state, payload) => payload), +]); + +export const bindedActions = { + routerAction: (route: string) => { + store.dispatch(routerAction(route)); + }, +}; diff --git a/src/core/infrastructure/atom/store.ts b/src/core/infrastructure/atom/store.ts new file mode 100644 index 0000000..c348e53 --- /dev/null +++ b/src/core/infrastructure/atom/store.ts @@ -0,0 +1,3 @@ +import {createStore} from '@reatom/core'; + +export const store = createStore(); diff --git a/src/core/infrastructure/atom/usersAtom.ts b/src/core/infrastructure/atom/usersAtom.ts new file mode 100644 index 0000000..6631586 --- /dev/null +++ b/src/core/infrastructure/atom/usersAtom.ts @@ -0,0 +1,38 @@ +import {declareAction, declareAtom} from '@reatom/core'; +import {User} from '../../../pages/users/types'; +import {store} from './store'; + +export type FieldData = { + name: keyof User | Array; + value?: any; + touched?: boolean; + validating?: boolean; + errors?: string[]; +}; + +const INIT_USERS: User[] = []; +export const INIT_USER: FieldData[] = [ + {name: 'id', value: ''}, + {name: 'login', value: ''}, + {name: 'password', value: ''}, +]; +export const loadUsersAction = declareAction(); + +export const usersAtom = declareAtom(INIT_USERS, on => [ + on(loadUsersAction, (_state, payload) => payload), +]); + +export const loadUserForm = declareAction(); + +export const userFormAtom = declareAtom(INIT_USER, on => [ + on(loadUserForm, (_state, payload) => payload), +]); + +export const bindedActions = { + loadUsersAction: (users: User[]) => { + store.dispatch(loadUsersAction(users)); + }, + loadUserForm: (fieldData: FieldData[]) => { + store.dispatch(loadUserForm(fieldData)); + } +}; diff --git a/src/core/services/RouterService.ts b/src/core/services/RouterService.ts new file mode 100644 index 0000000..994fa37 --- /dev/null +++ b/src/core/services/RouterService.ts @@ -0,0 +1,34 @@ +import {decode, encode, ParsedUrlQuery} from 'querystring'; +import {bindedActions} from '../infrastructure/atom/routerAtom'; +import {isNotEmpty} from '../referers/common'; + +type PushQueryOptions = { + reset?: boolean; + shouldRefresh?: boolean; +}; + +const getQuery = () => decode(window.location.search.slice(1)); + +class RouterService { + push(route: string) { + bindedActions.routerAction(route); + } + + pushWithQuery(path: string, query: ParsedUrlQuery, options?: PushQueryOptions) { + const currentQuery = getQuery(); + + const finalQuery = encode({ + ...(!options?.reset ? { + ...currentQuery, + } : {}), + ...(options?.shouldRefresh ? { + __timestamp: Date.now(), + } : {}), + ...query, + }); + + this.push([path, finalQuery].filter(isNotEmpty).join('?')); + } +} + +export const routerService = new RouterService(); diff --git a/src/core/types/EntityModes.ts b/src/core/types/EntityModes.ts new file mode 100644 index 0000000..852cd68 --- /dev/null +++ b/src/core/types/EntityModes.ts @@ -0,0 +1,6 @@ +export enum EntityMode { + Create = 'create', + Show = 'show', + Copy = 'copy', + Edit = 'edit', +} diff --git a/src/pages/actions/api/ActionsAPI.ts b/src/pages/actions/api/ActionsAPI.ts new file mode 100644 index 0000000..48fc5b4 --- /dev/null +++ b/src/pages/actions/api/ActionsAPI.ts @@ -0,0 +1,4 @@ +import {ENDPOINT} from '_consts/common'; +import {CrudAPI} from '../../../core/api/CrudAPI'; + +export const actionsAPI = new CrudAPI(ENDPOINT.ACTIONS); diff --git a/src/pages/actions/components/page/Page.tsx b/src/pages/actions/components/page/Page.tsx index 9a4d51a..ac66ddf 100644 --- a/src/pages/actions/components/page/Page.tsx +++ b/src/pages/actions/components/page/Page.tsx @@ -1,4 +1,7 @@ import React, {FC, memo} from 'react'; +import {actionsAPI} from '../../api/ActionsAPI'; + +actionsAPI.request(); const Page: FC = () => { return ( diff --git a/src/pages/users/api/UsersAPI.ts b/src/pages/users/api/UsersAPI.ts index df16d7e..6c2149d 100644 --- a/src/pages/users/api/UsersAPI.ts +++ b/src/pages/users/api/UsersAPI.ts @@ -1,4 +1,5 @@ import {ENDPOINT} from '_consts/common'; import {CrudAPI} from '../../../core/api/CrudAPI'; +import {User} from '../types'; -export const usersAPI = new CrudAPI(ENDPOINT.USERS); +export const usersAPI = new CrudAPI(ENDPOINT.USERS); diff --git a/src/pages/users/components/page/Page.tsx b/src/pages/users/components/page/Page.tsx index 101757b..bba6adf 100644 --- a/src/pages/users/components/page/Page.tsx +++ b/src/pages/users/components/page/Page.tsx @@ -1,11 +1,41 @@ +import {Button, Layout} from 'antd'; import React, {FC, memo} from 'react'; -import {usersAPI} from '../../api/UsersAPI'; +import {createUseStyles} from 'react-jss'; +import {ROUTES} from '../../../../core/consts/common'; +import {routerService} from '../../../../core/services/RouterService'; +import {EntityMode} from '../../../../core/types/EntityModes'; +import UserSidebar from '../user-sidebar/UserSidebar'; +import UsersTable from '../users-table/UsersTable'; -usersAPI.request(); +const useStyles = createUseStyles({ + header: { + backgroundColor: '#fff', + } +}); + +const handleClickNewUser = () => { + routerService.pushWithQuery(ROUTES.USERS, { + mode: EntityMode.Create, + }); +}; const Page: FC = () => { + const classes = useStyles(); return ( -
users
+ + + + + + + + + ); }; diff --git a/src/pages/users/components/user-sidebar/UserSidebar.tsx b/src/pages/users/components/user-sidebar/UserSidebar.tsx new file mode 100644 index 0000000..4c88a71 --- /dev/null +++ b/src/pages/users/components/user-sidebar/UserSidebar.tsx @@ -0,0 +1,201 @@ +import {useAtom} from '@reatom/react'; +import {Button, Drawer, Form, Input} from 'antd'; +import React, {FC, Fragment, memo, useCallback, useEffect, useMemo} from 'react'; +import {createUseStyles} from 'react-jss'; +import {ROUTES} from '../../../../core/consts/common'; +import {useQuery} from '../../../../core/hooks/useQuery'; +import {bindedActions, FieldData, userFormAtom} from '../../../../core/infrastructure/atom/usersAtom'; +import {isNotEmpty} from '../../../../core/referers/common'; +import {routerService} from '../../../../core/services/RouterService'; +import {EntityMode} from '../../../../core/types/EntityModes'; +import {usersService} from '../../services/UsersServices'; +import {User} from '../../types'; +import {queryParsers} from '../../utils'; + +const AVAILABLE_CLOSE_MODES = [EntityMode.Show]; +const DISABLED_FORM_MODES = [EntityMode.Show]; +const SHOW_ID_MODES = [EntityMode.Show]; + +const useStyles = createUseStyles({ + button: { + marginRight: '8px', + } +}); + +const handleClose = () => { + routerService.push(ROUTES.USERS); +}; + +const onFieldsChange = (_: FieldData[], allFields: FieldData[]) => { + bindedActions.loadUserForm(allFields); +}; + +const UserSidebar: FC = () => { + const {mode, id} = useQuery(queryParsers); + const fields = useAtom(userFormAtom); + const classes = useStyles(); + + useEffect(() => { + usersService.loadUser(id); + }, [id, mode]); + + const disabled = useMemo(() => !mode || DISABLED_FORM_MODES.includes(mode), [mode]); + + const handleCreate = useCallback(() => { + const user = fields.reduce((acc, {name, value}) => { + if (Array.isArray(name)) { + if (isNotEmpty(name[0])) { + acc[name[0]] = value; + } + } else { + acc[name] = value; + } + return acc; + }, {id: '', login: '', password: ''}); + usersService.createUser(user); + }, [fields]); + + const handleCopy = useCallback(() => { + if (id) { + routerService.pushWithQuery(ROUTES.USERS, { + id, + mode: EntityMode.Copy, + }); + } + }, [id]); + + const handleEdit = useCallback(() => { + if (id) { + routerService.pushWithQuery(ROUTES.USERS, { + id, + mode: EntityMode.Edit, + }); + } + }, [id]); + + const handleDelete = useCallback(() => { + usersService.removeUser(id); + }, [id]); + + const handleBackdrop = useCallback(() => { + if (mode && AVAILABLE_CLOSE_MODES.includes(mode)) { + handleClose(); + } + }, [mode]); + + const title = useMemo(() => { + switch (mode) { + case EntityMode.Create: + return 'Creating a user'; + case EntityMode.Copy: + return `Coping user "${id}"`; + case EntityMode.Edit: + return `Editing user "${id}"`; + case EntityMode.Show: + return `Viewing user "${id}"`; + default: + return `Mode "${mode}" not supported for user form`; + } + }, [mode, id]); + + const primaryButton = useMemo(() => { + switch (mode) { + case EntityMode.Create: + case EntityMode.Copy: + return ( + + ); + case EntityMode.Edit: + return ( + + ); + case EntityMode.Show: + return ( + + ); + default: + return null; + } + }, [mode, classes, handleEdit, handleCreate]); + + const renderFooter = useMemo(() => { + return ( +
+ {primaryButton} + {mode === EntityMode.Show && ( + + + + + )} + +
+ ); + }, [primaryButton, mode, classes, handleCopy, handleDelete]); + + return ( + +
+ + + + + + + {mode && SHOW_ID_MODES.includes(mode) && ( + + + + )} +
+
+ ); +}; + +export default memo(UserSidebar); diff --git a/src/pages/users/components/user-sidebar/index.ts b/src/pages/users/components/user-sidebar/index.ts new file mode 100644 index 0000000..6246a56 --- /dev/null +++ b/src/pages/users/components/user-sidebar/index.ts @@ -0,0 +1 @@ +export * from './UserSidebar'; diff --git a/src/pages/users/components/users-table/UsersTable.tsx b/src/pages/users/components/users-table/UsersTable.tsx new file mode 100644 index 0000000..4de42f0 --- /dev/null +++ b/src/pages/users/components/users-table/UsersTable.tsx @@ -0,0 +1,57 @@ +import {useAtom} from '@reatom/react'; +import {Table} from 'antd'; +import {head} from 'lodash'; +import React, {FC, memo, useEffect, useMemo} from 'react'; +import {ROUTES} from '../../../../core/consts/common'; +import {usersAtom} from '../../../../core/infrastructure/atom/usersAtom'; +import {routerService} from '../../../../core/services/RouterService'; +import {EntityMode} from '../../../../core/types/EntityModes'; +import {objectKeys} from '../../../../core/utils/objectKeys'; +import {usersService} from '../../services/UsersServices'; +import {User} from '../../types'; + +const onRow = (user: User) => ({ + onClick: () => { + routerService.pushWithQuery(ROUTES.USERS, { + id: user.id, + mode: EntityMode.Show, + }); + }, +}); + +const UsersTable: FC = () => { + const users = useAtom(usersAtom); + + useEffect(() => { + usersService.loadUsers(); + }, []); + + const columns = useMemo(() => { + return objectKeys(head(users) ?? {}).map(field => { + return { + title: field, + dataIndex: field, + key: field, + }; + }); + }, [users]); + + const dataSource = useMemo(() => { + return users.map(user => { + return { + ...user, + key: user.id, + }; + }); + }, [users]); + + return ( + + ); +}; + +export default memo(UsersTable); diff --git a/src/pages/users/components/users-table/index.ts b/src/pages/users/components/users-table/index.ts new file mode 100644 index 0000000..0cd9b9d --- /dev/null +++ b/src/pages/users/components/users-table/index.ts @@ -0,0 +1 @@ +export * from './UsersTable'; diff --git a/src/pages/users/services/UsersServices.ts b/src/pages/users/services/UsersServices.ts new file mode 100644 index 0000000..6cb377b --- /dev/null +++ b/src/pages/users/services/UsersServices.ts @@ -0,0 +1,56 @@ +import {ROUTES} from '../../../core/consts/common'; +import {bindedActions, FieldData, INIT_USER} from '../../../core/infrastructure/atom/usersAtom'; +import {routerService} from '../../../core/services/RouterService'; +import {objectEntries} from '../../../core/utils/objectEntries'; +import {usersAPI} from '../api/UsersAPI'; +import {User} from '../types'; + +class UsersService { + loadUsers() { + return usersAPI + .request() + .then(({data}) => { + bindedActions.loadUsersAction(data); + }); + } + + loadUser(id?: string) { + if (id) { + usersAPI + .find(id) + .then(user => { + const fieldData = objectEntries(user).reduce((acc, [name, value]) => { + acc.push({name, value}); + return acc; + }, []); + bindedActions.loadUserForm(fieldData); + }); + } + bindedActions.loadUserForm(INIT_USER); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + createUser({id, ...user}: User) { + usersAPI + .create(user) + .then(() => { + this.loadUsers().then(() => { + routerService.push(ROUTES.USERS); + }); + }); + } + + removeUser(id?: string) { + if (id) { + usersAPI + .remove(id) + .then(() => { + this.loadUsers().then(() => { + routerService.push(ROUTES.USERS); + }); + }); + } + } +} + +export const usersService = new UsersService(); diff --git a/src/pages/users/types.ts b/src/pages/users/types.ts new file mode 100644 index 0000000..9b4dd00 --- /dev/null +++ b/src/pages/users/types.ts @@ -0,0 +1,12 @@ +import {EntityMode} from '../../core/types/EntityModes'; + +export type User = { + id: string; + login: string; + password: string; +}; + +export type QueryParams = { + id?: string; + mode?: EntityMode; +}; diff --git a/src/pages/users/utils.ts b/src/pages/users/utils.ts new file mode 100644 index 0000000..202631b --- /dev/null +++ b/src/pages/users/utils.ts @@ -0,0 +1,8 @@ +import {QueryParsers} from '../../core/utils/getQueryFromUrl'; +import {stringParser} from '../../core/utils/queryParsers'; +import {QueryParams} from './types'; + +export const queryParsers: QueryParsers = { + id: stringParser(), + mode: stringParser(), +}; diff --git a/webpack.config.js b/webpack.config.js index 44eb4cb..b7abd54 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -26,6 +26,19 @@ module.exports = { compress: true, open: true, port: 3189, + http2: true, + proxy: { + '/api/users': { + target: 'http://vigdorov.ru:3011', + pathRewrite: { '^/api': '' }, + secure: false, + }, + '/api/bot': { + target: 'http://vigdorov.ru:3012', + pathRewrite: { '^/api/bot': '' }, + secure: false, + }, + }, }, resolve: { extensions: ['.tsx', '.ts', '.js'],