From 09651c51ab8291c9f56d0443532102e7d7caa59d Mon Sep 17 00:00:00 2001 From: Kichiyaki Date: Fri, 12 Mar 2021 13:19:22 +0100 Subject: [PATCH] add ProfessionsPage --- src/config/routing.ts | 1 + src/features/AdminRoutes.tsx | 4 + .../ProfessionsPage/ProfessionsPage.tsx | 229 ++++++++++++++++++ .../ProfessionsPage.useProfessions.ts | 36 +++ .../components/FormDialog/FormDialog.tsx | 118 +++++++++ .../components/FormDialog/constants.ts | 1 + .../components/TableToolbar/TableToolbar.tsx | 60 +++++ src/features/ProfessionsPage/constants.ts | 28 +++ src/features/ProfessionsPage/mutations.ts | 25 ++ src/features/ProfessionsPage/queries.ts | 20 ++ .../components/FormDialog/FormDialog.tsx | 2 +- 11 files changed, 523 insertions(+), 1 deletion(-) create mode 100644 src/features/ProfessionsPage/ProfessionsPage.tsx create mode 100644 src/features/ProfessionsPage/ProfessionsPage.useProfessions.ts create mode 100644 src/features/ProfessionsPage/components/FormDialog/FormDialog.tsx create mode 100644 src/features/ProfessionsPage/components/FormDialog/constants.ts create mode 100644 src/features/ProfessionsPage/components/TableToolbar/TableToolbar.tsx create mode 100644 src/features/ProfessionsPage/constants.ts create mode 100644 src/features/ProfessionsPage/mutations.ts create mode 100644 src/features/ProfessionsPage/queries.ts diff --git a/src/config/routing.ts b/src/config/routing.ts index 5bd86ad..5710bf1 100644 --- a/src/config/routing.ts +++ b/src/config/routing.ts @@ -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]; diff --git a/src/features/AdminRoutes.tsx b/src/features/AdminRoutes.tsx index 7d7751a..d50be3b 100644 --- a/src/features/AdminRoutes.tsx +++ b/src/features/AdminRoutes.tsx @@ -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() { + {' '} + + diff --git a/src/features/ProfessionsPage/ProfessionsPage.tsx b/src/features/ProfessionsPage/ProfessionsPage.tsx new file mode 100644 index 0000000..42acb36 --- /dev/null +++ b/src/features/ProfessionsPage/ProfessionsPage.tsx @@ -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.None); + const [professionBeingEdited, setProfessionBeingEdited] = useState< + Maybe + >(null); + const [selectedProfessions, setSelectedProfessions] = useState( + [] + ); + 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 ( + + + { + setProfessionBeingEdited(null); + setDialogType(DialogType.Create); + }} + onChangeSearchValue={val => { + setQueryParams({ page: 0, search: val }); + }} + /> + { + return ( + { + setProfessionBeingEdited(row); + setDialogType(DialogType.Edit); + }} + > + + + ); + }, + 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, + }} + /> + + setDialogType(DialogType.None)} + /> + 0} + anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} + message={`Wybrane zawody: ${selectedProfessions.length}`} + action={ + <> + + + + } + /> + + ); +}; + +export default ProfessionsPage; diff --git a/src/features/ProfessionsPage/ProfessionsPage.useProfessions.ts b/src/features/ProfessionsPage/ProfessionsPage.useProfessions.ts new file mode 100644 index 0000000..261652e --- /dev/null +++ b/src/features/ProfessionsPage/ProfessionsPage.useProfessions.ts @@ -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, + 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; diff --git a/src/features/ProfessionsPage/components/FormDialog/FormDialog.tsx b/src/features/ProfessionsPage/components/FormDialog/FormDialog.tsx new file mode 100644 index 0000000..37b405c --- /dev/null +++ b/src/features/ProfessionsPage/components/FormDialog/FormDialog.tsx @@ -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 { + profession?: ProfessionInput; + onClose: () => void; + onSubmit: (input: ProfessionInput) => Promise | boolean; +} + +const FormDialog = ({ + open, + onClose, + profession, + onSubmit, +}: FormDialogProps) => { + const editMode = Boolean(profession); + const { register, handleSubmit, errors } = useForm({}); + const [isSubmitting, setIsSubmitting] = useState(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 ( + +
+ + {editMode ? 'Edycja zawodu' : 'Tworzenie zawodu'} + + + + + + + + + + +
+ ); +}; + +const useStyles = makeStyles(theme => ({ + dialogContent: { + '& > *:not(:last-child)': { + marginBottom: theme.spacing(1), + }, + }, +})); + +export default FormDialog; diff --git a/src/features/ProfessionsPage/components/FormDialog/constants.ts b/src/features/ProfessionsPage/components/FormDialog/constants.ts new file mode 100644 index 0000000..8c617f4 --- /dev/null +++ b/src/features/ProfessionsPage/components/FormDialog/constants.ts @@ -0,0 +1 @@ +export const MAX_NAME_LENGTH = 100; diff --git a/src/features/ProfessionsPage/components/TableToolbar/TableToolbar.tsx b/src/features/ProfessionsPage/components/TableToolbar/TableToolbar.tsx new file mode 100644 index 0000000..5142323 --- /dev/null +++ b/src/features/ProfessionsPage/components/TableToolbar/TableToolbar.tsx @@ -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(search); + useDebounce( + () => { + onChangeSearchValue(_search); + }, + 500, + [_search] + ); + + return ( + + { + setSearch(e.target.value); + }} + onResetValue={() => { + setSearch(''); + }} + /> + + + + + + + ); +}; + +const useStyles = makeStyles(theme => ({ + toolbar: { + justifyContent: 'flex-end', + '& > *:not(:last-child)': { + marginRight: theme.spacing(0.5), + }, + }, +})); + +export default TableToolbar; diff --git a/src/features/ProfessionsPage/constants.ts b/src/features/ProfessionsPage/constants.ts new file mode 100644 index 0000000..dd77ee2 --- /dev/null +++ b/src/features/ProfessionsPage/constants.ts @@ -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[] = [ + { + 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 = '', +} diff --git a/src/features/ProfessionsPage/mutations.ts b/src/features/ProfessionsPage/mutations.ts new file mode 100644 index 0000000..a1ae599 --- /dev/null +++ b/src/features/ProfessionsPage/mutations.ts @@ -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 + } + } +`; diff --git a/src/features/ProfessionsPage/queries.ts b/src/features/ProfessionsPage/queries.ts new file mode 100644 index 0000000..338d516 --- /dev/null +++ b/src/features/ProfessionsPage/queries.ts @@ -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 + } + } + } +`; diff --git a/src/features/UsersPage/components/FormDialog/FormDialog.tsx b/src/features/UsersPage/components/FormDialog/FormDialog.tsx index 32b49d8..cff4768 100644 --- a/src/features/UsersPage/components/FormDialog/FormDialog.tsx +++ b/src/features/UsersPage/components/FormDialog/FormDialog.tsx @@ -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 { user?: UserInput;