add ProfessionsPage

This commit is contained in:
Dawid Wysokiński 2021-03-12 13:19:22 +01:00
parent 6173c952ce
commit 09651c51ab
11 changed files with 523 additions and 1 deletions

View File

@ -2,6 +2,7 @@ export const ROUTE = {
SIGN_IN_PAGE: '/',
DASHBOARD_PAGE: '/dashboard',
USERS_PAGE: '/users',
PROFESSIONS_PAGE: '/professions',
};
export const PUBLIC_ROUTES = [ROUTE.SIGN_IN_PAGE];

View File

@ -3,6 +3,7 @@ import { ROUTE } from '../config/routing';
import AppLayout from 'common/AppLayout/AppLayout';
import DashboardPage from './DashboardPage/DashboardPage';
import UsersPage from './UsersPage/UsersPage';
import ProfessionsPage from './ProfessionsPage/ProfessionsPage';
function AdminRoutes() {
return (
@ -13,6 +14,9 @@ function AdminRoutes() {
</Route>
<Route exact path={ROUTE.USERS_PAGE}>
<UsersPage />
</Route>{' '}
<Route exact path={ROUTE.PROFESSIONS_PAGE}>
<ProfessionsPage />
</Route>
</Switch>
</AppLayout>

View File

@ -0,0 +1,229 @@
import { useState } from 'react';
import { ApolloError, useMutation } from '@apollo/client';
import { useUpdateEffect } from 'react-use';
import {
NumberParam,
StringParam,
useQueryParams,
withDefault,
} from 'use-query-params';
import { useSnackbar } from 'notistack';
import SortParam, { decodeSort } from 'libs/serialize-query-params/SortParam';
import useProfessions from './ProfessionsPage.useProfessions';
import { validateRowsPerPage } from 'common/Table/helpers';
import {
MUTATION_CREATE_PROFESSION,
MUTATION_UPDATE_PROFESSION,
MUTATION_DELETE_PROFESSIONS,
} from './mutations';
import { COLUMNS, DEFAULT_SORT, DialogType } from './constants';
import {
Maybe,
MutationCreateProfessionArgs,
MutationDeleteProfessionsArgs,
MutationUpdateProfessionArgs,
Profession,
ProfessionInput,
} from 'libs/graphql/types';
import {
Button,
Container,
IconButton,
Paper,
Snackbar,
} from '@material-ui/core';
import { Edit as EditIcon } from '@material-ui/icons';
import Table from 'common/Table/Table';
import TableToolbar from './components/TableToolbar/TableToolbar';
import FormDialog from './components/FormDialog/FormDialog';
const ProfessionsPage = () => {
const [createProfessionMutation] = useMutation<
any,
MutationCreateProfessionArgs
>(MUTATION_CREATE_PROFESSION, { ignoreResults: true });
const [updateProfessionMutation] = useMutation<
any,
MutationUpdateProfessionArgs
>(MUTATION_UPDATE_PROFESSION, { ignoreResults: true });
const [deleteProfessionsMutation] = useMutation<
any,
MutationDeleteProfessionsArgs
>(MUTATION_DELETE_PROFESSIONS, { ignoreResults: true });
const [dialogType, setDialogType] = useState<DialogType>(DialogType.None);
const [professionBeingEdited, setProfessionBeingEdited] = useState<
Maybe<Profession>
>(null);
const [selectedProfessions, setSelectedProfessions] = useState<Profession[]>(
[]
);
const snackbar = useSnackbar();
const [{ page, sort, search, ...rest }, setQueryParams] = useQueryParams({
limit: NumberParam,
page: withDefault(NumberParam, 0),
sort: withDefault(SortParam, DEFAULT_SORT),
search: withDefault(StringParam, ''),
});
const limit = validateRowsPerPage(rest.limit);
const { professions, total, loading, refetch } = useProfessions(
page,
limit,
sort.toString(),
search
);
useUpdateEffect(() => {
if (selectedProfessions.length > 0) {
setSelectedProfessions([]);
}
}, [professions]);
const handleFormDialogSubmit = async (input: ProfessionInput) => {
try {
if (dialogType === DialogType.Create) {
await createProfessionMutation({ variables: { input } });
} else {
await updateProfessionMutation({
variables: { input, id: professionBeingEdited?.id ?? -1 },
});
}
await refetch();
snackbar.enqueueSnackbar(
dialogType === DialogType.Create
? 'Pomyślnie utworzono zawód.'
: 'Zapisano zmiany.',
{ variant: 'success' }
);
return true;
} catch (e) {
snackbar.enqueueSnackbar(
e instanceof ApolloError && e.graphQLErrors.length > 0
? e.graphQLErrors[0].message
: e.message,
{ variant: 'error' }
);
}
return false;
};
const handleDeleteProfessions = async () => {
if (!window.confirm('Czy na pewno chcesz usunąć wybranych zawody?')) {
return;
}
try {
const ids = selectedProfessions.map(profession => profession.id);
await deleteProfessionsMutation({ variables: { ids } });
await refetch();
snackbar.enqueueSnackbar(`Usuwanie przebiegło pomyślnie.`, {
variant: 'success',
});
} catch (e) {
snackbar.enqueueSnackbar(
e instanceof ApolloError && e.graphQLErrors.length > 0
? e.graphQLErrors[0].message
: e.message,
{ variant: 'error' }
);
}
};
const handleSelect = (checked: boolean, items: Profession[]) => {
setSelectedProfessions(prevState =>
checked
? [...prevState, ...items]
: prevState.filter(
item => !items.some(otherItem => otherItem.id === item.id)
)
);
};
return (
<Container>
<Paper>
<TableToolbar
search={search}
onClickCreateProfession={() => {
setProfessionBeingEdited(null);
setDialogType(DialogType.Create);
}}
onChangeSearchValue={val => {
setQueryParams({ page: 0, search: val });
}}
/>
<Table
selection
columns={COLUMNS}
data={professions}
selected={selectedProfessions}
onSelect={handleSelect}
actions={[
{
icon: row => {
return (
<IconButton
onClick={() => {
setProfessionBeingEdited(row);
setDialogType(DialogType.Edit);
}}
>
<EditIcon />
</IconButton>
);
},
tooltip: 'Edytuj',
},
]}
loading={loading}
orderBy={sort.orderBy}
orderDirection={sort.orderDirection}
onRequestSort={(orderBy, orderDirection) => {
setQueryParams({
page: 0,
sort: decodeSort(orderBy + ' ' + orderDirection),
});
}}
footerProps={{
count: total,
page,
onChangePage: page => {
setQueryParams({ page });
},
onChangeRowsPerPage: limit => {
setQueryParams({ page: 0, limit });
},
rowsPerPage: limit,
}}
/>
</Paper>
<FormDialog
open={
dialogType === DialogType.Create || dialogType === DialogType.Edit
}
profession={professionBeingEdited as ProfessionInput}
onSubmit={handleFormDialogSubmit}
onClose={() => setDialogType(DialogType.None)}
/>
<Snackbar
open={selectedProfessions.length > 0}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
message={`Wybrane zawody: ${selectedProfessions.length}`}
action={
<>
<Button onClick={handleDeleteProfessions} color="secondary">
Usuń
</Button>
<Button
color="secondary"
onClick={() => setSelectedProfessions([])}
>
Anuluj
</Button>
</>
}
/>
</Container>
);
};
export default ProfessionsPage;

View File

@ -0,0 +1,36 @@
import { useQuery } from '@apollo/client';
import { QUERY_PROFESSIONS } from './queries';
import { Query, QueryProfessionsArgs } from 'libs/graphql/types';
const useProfessions = (
page: number,
limit: number,
sort: string,
search: string
) => {
const { data, loading, refetch } = useQuery<
Pick<Query, 'professions'>,
QueryProfessionsArgs
>(QUERY_PROFESSIONS, {
fetchPolicy: 'cache-and-network',
variables: {
offset: page * limit,
sort: [sort],
limit,
filter: {
nameIEQ: '%' + search + '%',
},
},
});
return {
professions: data?.professions.items ?? [],
get loading() {
return this.professions.length === 0 && loading;
},
total: data?.professions.total ?? 0,
refetch,
};
};
export default useProfessions;

View File

@ -0,0 +1,118 @@
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { pick } from 'lodash';
import { MAX_NAME_LENGTH } from './constants';
import { ProfessionInput } from 'libs/graphql/types';
import { makeStyles } from '@material-ui/core/styles';
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogProps,
DialogTitle,
TextField,
} from '@material-ui/core';
export interface FormDialogProps extends Pick<DialogProps, 'open'> {
profession?: ProfessionInput;
onClose: () => void;
onSubmit: (input: ProfessionInput) => Promise<boolean> | boolean;
}
const FormDialog = ({
open,
onClose,
profession,
onSubmit,
}: FormDialogProps) => {
const editMode = Boolean(profession);
const { register, handleSubmit, errors } = useForm<ProfessionInput>({});
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
const classes = useStyles();
const _onSubmit = async (data: ProfessionInput) => {
setIsSubmitting(true);
const filtered = editMode
? pick(
data,
Object.keys(data).filter(key => data[key as keyof ProfessionInput])
)
: data;
const success = await onSubmit(filtered);
setIsSubmitting(false);
if (success) {
onClose();
}
};
return (
<Dialog
open={open}
onClose={isSubmitting ? undefined : onClose}
fullWidth
maxWidth="xs"
>
<form onSubmit={handleSubmit(_onSubmit)}>
<DialogTitle>
{editMode ? 'Edycja zawodu' : 'Tworzenie zawodu'}
</DialogTitle>
<DialogContent className={classes.dialogContent}>
<TextField
fullWidth
label="Nazwa zawodu"
name="name"
defaultValue={profession?.name}
inputRef={register({
required: 'Te pole jest wymagane.',
maxLength: {
value: MAX_NAME_LENGTH,
message: `Maksymalna długość nazwy zawodu to ${MAX_NAME_LENGTH} znaki.`,
},
})}
error={!!errors.name}
helperText={errors.name ? errors.name.message : ''}
/>
<TextField
fullWidth
label="Opis"
name="description"
defaultValue={profession?.description}
inputRef={register}
multiline
/>
</DialogContent>
<DialogActions>
<Button
color="secondary"
type="button"
variant="contained"
onClick={onClose}
disabled={isSubmitting}
>
Anuluj
</Button>
<Button
type="submit"
color="primary"
variant="contained"
disabled={isSubmitting}
>
{editMode ? 'Zapisz' : 'Utwórz'}
</Button>
</DialogActions>
</form>
</Dialog>
);
};
const useStyles = makeStyles(theme => ({
dialogContent: {
'& > *:not(:last-child)': {
marginBottom: theme.spacing(1),
},
},
}));
export default FormDialog;

View File

@ -0,0 +1 @@
export const MAX_NAME_LENGTH = 100;

View File

@ -0,0 +1,60 @@
import { useState } from 'react';
import { useDebounce } from 'react-use';
import { makeStyles } from '@material-ui/core/styles';
import { IconButton, Tooltip } from '@material-ui/core';
import { Add as AddIcon } from '@material-ui/icons';
import BaseTableToolbar from 'common/Table/TableToolbar';
import SearchInput from 'common/Form/SearchInput';
export interface TableToolbarProps {
search: string;
onChangeSearchValue: (search: string) => void;
onClickCreateProfession: () => void;
}
const TableToolbar = ({
search,
onChangeSearchValue,
onClickCreateProfession,
}: TableToolbarProps) => {
const classes = useStyles();
const [_search, setSearch] = useState<string>(search);
useDebounce(
() => {
onChangeSearchValue(_search);
},
500,
[_search]
);
return (
<BaseTableToolbar className={classes.toolbar}>
<SearchInput
value={_search}
onChange={e => {
setSearch(e.target.value);
}}
onResetValue={() => {
setSearch('');
}}
/>
<Tooltip title="Utwórz zawód">
<IconButton onClick={onClickCreateProfession}>
<AddIcon />
</IconButton>
</Tooltip>
</BaseTableToolbar>
);
};
const useStyles = makeStyles(theme => ({
toolbar: {
justifyContent: 'flex-end',
'& > *:not(:last-child)': {
marginRight: theme.spacing(0.5),
},
},
}));
export default TableToolbar;

View File

@ -0,0 +1,28 @@
import { decodeSort } from 'libs/serialize-query-params/SortParam';
import { Column } from 'common/Table/types';
import { Profession } from 'libs/graphql/types';
export const DEFAULT_SORT = decodeSort('id DESC');
export const COLUMNS: Column<Profession>[] = [
{
field: 'id',
sortable: true,
label: 'ID',
},
{
field: 'name',
sortable: true,
label: 'Nazwa',
},
{
field: 'createdAt',
sortable: true,
label: 'Data utworzenia',
type: 'datetime',
},
];
export enum DialogType {
Create = 'create',
Edit = 'edit',
None = '',
}

View File

@ -0,0 +1,25 @@
import { gql } from '@apollo/client';
export const MUTATION_CREATE_PROFESSION = gql`
mutation createProfession($input: ProfessionInput!) {
createProfession(input: $input) {
id
}
}
`;
export const MUTATION_UPDATE_PROFESSION = gql`
mutation updateProfession($id: ID!, $input: ProfessionInput!) {
updateProfession(id: $id, input: $input) {
id
}
}
`;
export const MUTATION_DELETE_PROFESSIONS = gql`
mutation deleteProfessions($ids: [ID!]!) {
deleteProfessions(ids: $ids) {
id
}
}
`;

View File

@ -0,0 +1,20 @@
import { gql } from '@apollo/client';
export const QUERY_PROFESSIONS = gql`
query professions(
$offset: Int
$limit: Int
$filter: ProfessionFilter
$sort: [String!]
) {
professions(offset: $offset, limit: $limit, filter: $filter, sort: $sort) {
total
items {
id
name
description
createdAt
}
}
}
`;

View File

@ -1,3 +1,4 @@
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { pick } from 'lodash';
import isEmail from 'validator/es/lib/isEmail';
@ -27,7 +28,6 @@ import {
RadioGroup,
TextField,
} from '@material-ui/core';
import { useState } from 'react';
export interface FormDialogProps extends Pick<DialogProps, 'open'> {
user?: UserInput;