add users table, user form, show\create\delete user, router service

This commit is contained in:
2021-06-20 01:45:52 +03:00
parent ab0ecb1b3e
commit 5d2ecf2f0c
22 changed files with 537 additions and 23 deletions

View File

@ -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 (
<context.Provider value={store}>
<MainLayout>
<Switch>
{mainPageRouter}
{usersPageRouter}
{actionsPageRouter}
{conditionsPageRouter}
{graphsPageRouter}
{currenciesPageRouter}
<Route>
<NotFoundPage />
</Route>
</Switch>
</MainLayout>
<ConnectedRouter>
<MainLayout>
<Switch>
{mainPageRouter}
{usersPageRouter}
{actionsPageRouter}
{conditionsPageRouter}
{graphsPageRouter}
{currenciesPageRouter}
<Route>
<NotFoundPage />
</Route>
</Switch>
</MainLayout>
</ConnectedRouter>
</context.Provider>
);
};

View File

@ -21,6 +21,7 @@ type RequestEntities<T> = {
type EntityWithId<T> = T & {
id: string;
};
type EntityWithoutId<T> = Omit<T, 'id'>;
type ResponseEntities<T> = {
data: EntityWithId<T>[];
@ -63,8 +64,8 @@ export class CrudAPI<T> {
return http.get<never, T>(`${this.endpoint}/${id}`);
}
create = (entity: T): Promise<EntityWithId<T>> => {
return http.post<never, T, EntityWithId<T>>(this.endpoint, undefined, entity);
create = (entity: EntityWithoutId<T>): Promise<EntityWithId<T>> => {
return http.post<never, EntityWithoutId<T>, EntityWithId<T>>(this.endpoint, undefined, entity);
}
update = (id: string, entity: T): Promise<EntityWithId<T>> => {

View File

@ -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<PropsWithChildren<unknown>> = ({children}) => {
const history = useHistory();
const route = useAtom(routerAtom);
useEffect(() => {
history.push(route);
}, [history, route]);
return (
<Fragment>
{children}
</Fragment>
);
};
export default memo(ConnectedRouter);

View File

@ -0,0 +1 @@
export * from './ConnectedRouter';

View File

@ -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',
};

View File

@ -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<string>();
export const routerAtom = declareAtom(`${pathname}${search}`, on => [
on(routerAction, (_state, payload) => payload),
]);
export const bindedActions = {
routerAction: (route: string) => {
store.dispatch(routerAction(route));
},
};

View File

@ -0,0 +1,3 @@
import {createStore} from '@reatom/core';
export const store = createStore();

View File

@ -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<keyof User>;
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<User[]>();
export const usersAtom = declareAtom(INIT_USERS, on => [
on(loadUsersAction, (_state, payload) => payload),
]);
export const loadUserForm = declareAction<FieldData[]>();
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));
}
};

View File

@ -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();

View File

@ -0,0 +1,6 @@
export enum EntityMode {
Create = 'create',
Show = 'show',
Copy = 'copy',
Edit = 'edit',
}

View File

@ -0,0 +1,4 @@
import {ENDPOINT} from '_consts/common';
import {CrudAPI} from '../../../core/api/CrudAPI';
export const actionsAPI = new CrudAPI(ENDPOINT.ACTIONS);

View File

@ -1,4 +1,7 @@
import React, {FC, memo} from 'react';
import {actionsAPI} from '../../api/ActionsAPI';
actionsAPI.request();
const Page: FC = () => {
return (

View File

@ -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<User>(ENDPOINT.USERS);

View File

@ -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 (
<div>users</div>
<Layout>
<Layout.Header className={classes.header}>
<Button
type="primary"
onClick={handleClickNewUser}
>
New user
</Button>
</Layout.Header>
<Layout.Content>
<UsersTable />
<UserSidebar />
</Layout.Content>
</Layout>
);
};

View File

@ -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<User>((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 (
<Button
className={classes.button}
onClick={handleCreate}
type="primary"
>
Create
</Button>
);
case EntityMode.Edit:
return (
<Button
className={classes.button}
type="primary"
>
Save
</Button>
);
case EntityMode.Show:
return (
<Button
className={classes.button}
onClick={handleEdit}
type="primary"
>
Edit
</Button>
);
default:
return null;
}
}, [mode, classes, handleEdit, handleCreate]);
const renderFooter = useMemo(() => {
return (
<div>
{primaryButton}
{mode === EntityMode.Show && (
<Fragment>
<Button
className={classes.button}
onClick={handleCopy}
>
Copy
</Button>
<Button
className={classes.button}
onClick={handleDelete}
>
Delete
</Button>
</Fragment>
)}
<Button onClick={handleClose}>Cancel</Button>
</div>
);
}, [primaryButton, mode, classes, handleCopy, handleDelete]);
return (
<Drawer
visible={!!mode}
closable={false}
onClose={handleBackdrop}
width="600"
title={title}
footer={renderFooter}
>
<Form
fields={fields}
onFieldsChange={onFieldsChange as any}
>
<Form.Item
label="Login"
name="login"
>
<Input disabled={disabled} />
</Form.Item>
<Form.Item
label="Password"
name="password"
>
<Input disabled={disabled} />
</Form.Item>
{mode && SHOW_ID_MODES.includes(mode) && (
<Form.Item
label="ID"
name="id"
>
<Input disabled={disabled} />
</Form.Item>
)}
</Form>
</Drawer>
);
};
export default memo(UserSidebar);

View File

@ -0,0 +1 @@
export * from './UserSidebar';

View File

@ -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 (
<Table
columns={columns}
dataSource={dataSource}
onRow={onRow}
/>
);
};
export default memo(UsersTable);

View File

@ -0,0 +1 @@
export * from './UsersTable';

View File

@ -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<FieldData[]>((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();

12
src/pages/users/types.ts Normal file
View File

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

8
src/pages/users/utils.ts Normal file
View File

@ -0,0 +1,8 @@
import {QueryParsers} from '../../core/utils/getQueryFromUrl';
import {stringParser} from '../../core/utils/queryParsers';
import {QueryParams} from './types';
export const queryParsers: QueryParsers<QueryParams> = {
id: stringParser(),
mode: stringParser(),
};

View File

@ -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'],