From 7b7aef9a1fa3ea2f80ff7d1698b5794646ae39b7 Mon Sep 17 00:00:00 2001 From: Kichiyaki Date: Sat, 13 Mar 2021 16:10:31 +0100 Subject: [PATCH] add QuestionsPage --- src/config/routing.ts | 1 + src/features/AdminRoutes.tsx | 4 + src/features/QuestionsPage/QuestionsPage.tsx | 224 ++++++++++++++++++ .../QuestionsPage.useQuestions.ts | 36 +++ .../components/FormDialog/FormDialog.tsx | 107 +++++++++ .../components/FormDialog/constants.ts | 1 + .../components/TableToolbar/TableToolbar.tsx | 60 +++++ src/features/QuestionsPage/constants.ts | 38 +++ src/features/QuestionsPage/mutations.ts | 25 ++ src/features/QuestionsPage/queries.ts | 36 +++ 10 files changed, 532 insertions(+) create mode 100644 src/features/QuestionsPage/QuestionsPage.tsx create mode 100644 src/features/QuestionsPage/QuestionsPage.useQuestions.ts create mode 100644 src/features/QuestionsPage/components/FormDialog/FormDialog.tsx create mode 100644 src/features/QuestionsPage/components/FormDialog/constants.ts create mode 100644 src/features/QuestionsPage/components/TableToolbar/TableToolbar.tsx create mode 100644 src/features/QuestionsPage/constants.ts create mode 100644 src/features/QuestionsPage/mutations.ts create mode 100644 src/features/QuestionsPage/queries.ts diff --git a/src/config/routing.ts b/src/config/routing.ts index a4746a5..23713b7 100644 --- a/src/config/routing.ts +++ b/src/config/routing.ts @@ -3,6 +3,7 @@ export enum Route { UsersPage = '/users', ProfessionsPage = '/professions', QualificationsPage = '/qualifications', + QuestionsPage = '/questions', } export const PUBLIC_ROUTES = [Route.SignInPage]; diff --git a/src/features/AdminRoutes.tsx b/src/features/AdminRoutes.tsx index e485fda..ea67cf7 100644 --- a/src/features/AdminRoutes.tsx +++ b/src/features/AdminRoutes.tsx @@ -4,6 +4,7 @@ import AppLayout from 'common/AppLayout/AppLayout'; import UsersPage from './UsersPage/UsersPage'; import ProfessionsPage from './ProfessionsPage/ProfessionsPage'; import QualificationsPage from './QualificationsPage/QualificationsPage'; +import QuestionsPage from './QuestionsPage/QuestionsPage'; function AdminRoutes() { return ( @@ -18,6 +19,9 @@ function AdminRoutes() { + + + ); diff --git a/src/features/QuestionsPage/QuestionsPage.tsx b/src/features/QuestionsPage/QuestionsPage.tsx new file mode 100644 index 0000000..d1f6c78 --- /dev/null +++ b/src/features/QuestionsPage/QuestionsPage.tsx @@ -0,0 +1,224 @@ +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 useQuestions from './QuestionsPage.useQuestions'; +import { validateRowsPerPage } from 'common/Table/helpers'; +import { + MUTATION_CREATE_QUESTION, + MUTATION_UPDATE_QUESTION, + MUTATION_DELETE_QUESTIONS, +} from './mutations'; + +import { COLUMNS, DEFAULT_SORT, DialogType } from './constants'; +import { + Maybe, + MutationCreateQuestionArgs, + MutationDeleteQuestionsArgs, + MutationUpdateQuestionArgs, + Question, + QuestionInput, +} 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 QuestionsPage = () => { + const [createQuestionMutation] = useMutation( + MUTATION_CREATE_QUESTION, + { ignoreResults: true } + ); + const [updateQuestionMutation] = useMutation( + MUTATION_UPDATE_QUESTION, + { ignoreResults: true } + ); + const [deleteQuestionsMutation] = useMutation< + any, + MutationDeleteQuestionsArgs + >(MUTATION_DELETE_QUESTIONS, { ignoreResults: true }); + const [dialogType, setDialogType] = useState(DialogType.None); + const [questionBeingEdited, setQuestionBeingEdited] = useState< + Maybe + >(null); + const [selectedQuestions, setSelectedQuestions] = 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 { questions, total, loading, refetch } = useQuestions( + page, + limit, + sort.toString(), + search + ); + + useUpdateEffect(() => { + if (selectedQuestions.length > 0) { + setSelectedQuestions([]); + } + }, [questions]); + + const handleFormDialogSubmit = async (input: QuestionInput) => { + try { + if (dialogType === DialogType.Create) { + await createQuestionMutation({ variables: { input } }); + } else { + await updateQuestionMutation({ + variables: { input, id: questionBeingEdited?.id ?? -1 }, + }); + } + await refetch(); + snackbar.enqueueSnackbar( + dialogType === DialogType.Create + ? 'Pomyślnie utworzono pytanie.' + : '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 handleDeleteQuestions = async () => { + if (!window.confirm('Czy na pewno chcesz usunąć wybrane zawody?')) { + return; + } + try { + const ids = selectedQuestions.map(question => question.id); + await deleteQuestionsMutation({ 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: Question[]) => { + setSelectedQuestions(prevState => + checked + ? [...prevState, ...items] + : prevState.filter( + item => !items.some(otherItem => otherItem.id === item.id) + ) + ); + }; + + return ( + + + { + setQuestionBeingEdited(null); + setDialogType(DialogType.Create); + }} + onChangeSearchValue={val => { + setQueryParams({ page: 0, search: val }); + }} + /> + { + return ( + { + setQuestionBeingEdited(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 pytania: ${selectedQuestions.length}`} + action={ + <> + + + + } + /> + + ); +}; + +export default QuestionsPage; diff --git a/src/features/QuestionsPage/QuestionsPage.useQuestions.ts b/src/features/QuestionsPage/QuestionsPage.useQuestions.ts new file mode 100644 index 0000000..e2f11f6 --- /dev/null +++ b/src/features/QuestionsPage/QuestionsPage.useQuestions.ts @@ -0,0 +1,36 @@ +import { useQuery } from '@apollo/client'; +import { QUERY_QUESTIONS } from './queries'; +import { Query, QueryQuestionsArgs } from 'libs/graphql/types'; + +const useQuestions = ( + page: number, + limit: number, + sort: string, + search: string +) => { + const { data, loading, refetch } = useQuery< + Pick, + QueryQuestionsArgs + >(QUERY_QUESTIONS, { + fetchPolicy: 'cache-and-network', + variables: { + offset: page * limit, + sort: [sort], + limit, + filter: { + contentIEQ: '%' + search + '%', + }, + }, + }); + + return { + questions: data?.questions.items ?? [], + get loading() { + return this.questions.length === 0 && loading; + }, + total: data?.questions.total ?? 0, + refetch, + }; +}; + +export default useQuestions; diff --git a/src/features/QuestionsPage/components/FormDialog/FormDialog.tsx b/src/features/QuestionsPage/components/FormDialog/FormDialog.tsx new file mode 100644 index 0000000..db3ba03 --- /dev/null +++ b/src/features/QuestionsPage/components/FormDialog/FormDialog.tsx @@ -0,0 +1,107 @@ +import { useForm } from 'react-hook-form'; +import { pick } from 'lodash'; +import { MAX_NAME_LENGTH } from './constants'; +import { QuestionInput, Question } from 'libs/graphql/types'; + +import { makeStyles } from '@material-ui/core/styles'; +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogProps, + DialogTitle, + TextField, +} from '@material-ui/core'; +import { Maybe } from 'graphql/jsutils/Maybe'; + +export interface FormDialogProps extends Pick { + question?: Maybe; + onClose: () => void; + onSubmit: (input: QuestionInput) => Promise | boolean; +} + +const FormDialog = ({ open, onClose, question, onSubmit }: FormDialogProps) => { + const editMode = Boolean(question); + const { + register, + handleSubmit, + errors, + formState: { isSubmitting }, + } = useForm({}); + const classes = useStyles(); + + const _onSubmit = async (data: QuestionInput) => { + const filtered = editMode + ? pick( + data, + Object.keys(data).filter(key => data[key as keyof QuestionInput]) + ) + : data; + const success = await onSubmit(filtered); + if (success) { + onClose(); + } + }; + + return ( + +
+ + {editMode ? 'Edycja pytania' : 'Tworzenie pytania'} + + + + + + + + + +
+ ); +}; + +const useStyles = makeStyles(theme => ({ + dialogContent: { + '& > *:not(:last-child)': { + marginBottom: theme.spacing(1), + }, + }, +})); + +export default FormDialog; diff --git a/src/features/QuestionsPage/components/FormDialog/constants.ts b/src/features/QuestionsPage/components/FormDialog/constants.ts new file mode 100644 index 0000000..8c617f4 --- /dev/null +++ b/src/features/QuestionsPage/components/FormDialog/constants.ts @@ -0,0 +1 @@ +export const MAX_NAME_LENGTH = 100; diff --git a/src/features/QuestionsPage/components/TableToolbar/TableToolbar.tsx b/src/features/QuestionsPage/components/TableToolbar/TableToolbar.tsx new file mode 100644 index 0000000..1eccf26 --- /dev/null +++ b/src/features/QuestionsPage/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; + onClickCreateQuestion: () => void; +} + +const TableToolbar = ({ + search, + onChangeSearchValue, + onClickCreateQuestion, +}: 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/QuestionsPage/constants.ts b/src/features/QuestionsPage/constants.ts new file mode 100644 index 0000000..06ecec6 --- /dev/null +++ b/src/features/QuestionsPage/constants.ts @@ -0,0 +1,38 @@ +import { decodeSort } from 'libs/serialize-query-params/SortParam'; +import { Column } from 'common/Table/types'; +import { Question } from 'libs/graphql/types'; + +export const DEFAULT_SORT = decodeSort('id DESC'); +export const COLUMNS: Column[] = [ + { + field: 'id', + sortable: true, + label: 'ID', + }, + { + field: 'content', + sortable: true, + label: 'Treść', + }, + { + field: 'qualification', + sortable: true, + label: 'Kwalifikacja', + valueFormatter: v => { + return `${v.qualification?.code ?? '-'} (ID: ${ + v.qualification?.id ?? 0 + })`; + }, + }, + { + field: 'createdAt', + sortable: true, + label: 'Data utworzenia', + type: 'datetime', + }, +]; +export enum DialogType { + Create = 'create', + Edit = 'edit', + None = '', +} diff --git a/src/features/QuestionsPage/mutations.ts b/src/features/QuestionsPage/mutations.ts new file mode 100644 index 0000000..1ccfaf0 --- /dev/null +++ b/src/features/QuestionsPage/mutations.ts @@ -0,0 +1,25 @@ +import { gql } from '@apollo/client'; + +export const MUTATION_CREATE_QUESTION = gql` + mutation createQuestion($input: QuestionInput!) { + createQuestion(input: $input) { + id + } + } +`; + +export const MUTATION_UPDATE_QUESTION = gql` + mutation updateQuestion($id: ID!, $input: QuestionInput!) { + updateQuestion(id: $id, input: $input) { + id + } + } +`; + +export const MUTATION_DELETE_QUESTIONS = gql` + mutation deleteQuestions($ids: [ID!]!) { + deleteQuestions(ids: $ids) { + id + } + } +`; diff --git a/src/features/QuestionsPage/queries.ts b/src/features/QuestionsPage/queries.ts new file mode 100644 index 0000000..6982ba4 --- /dev/null +++ b/src/features/QuestionsPage/queries.ts @@ -0,0 +1,36 @@ +import { gql } from '@apollo/client'; + +export const QUERY_QUESTIONS = gql` + query questions( + $offset: Int + $limit: Int + $filter: QuestionFilter + $sort: [String!] + ) { + questions(offset: $offset, limit: $limit, filter: $filter, sort: $sort) { + total + items { + id + content + image + from + answerA + answerAImage + answerB + answerBImage + answerC + answerCImage + answerD + answerDImage + explanation + correctAnswer + qualification { + id + code + } + createdAt + updatedAt + } + } + } +`;