add QuestionsPage

This commit is contained in:
Dawid Wysokiński 2021-03-13 16:10:31 +01:00
parent 19d43b96ba
commit 7b7aef9a1f
10 changed files with 532 additions and 0 deletions

View File

@ -3,6 +3,7 @@ export enum Route {
UsersPage = '/users',
ProfessionsPage = '/professions',
QualificationsPage = '/qualifications',
QuestionsPage = '/questions',
}
export const PUBLIC_ROUTES = [Route.SignInPage];

View File

@ -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() {
<RRDRoute exact path={Route.QualificationsPage}>
<QualificationsPage />
</RRDRoute>
<RRDRoute exact path={Route.QuestionsPage}>
<QuestionsPage />
</RRDRoute>
</Switch>
</AppLayout>
);

View File

@ -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<any, MutationCreateQuestionArgs>(
MUTATION_CREATE_QUESTION,
{ ignoreResults: true }
);
const [updateQuestionMutation] = useMutation<any, MutationUpdateQuestionArgs>(
MUTATION_UPDATE_QUESTION,
{ ignoreResults: true }
);
const [deleteQuestionsMutation] = useMutation<
any,
MutationDeleteQuestionsArgs
>(MUTATION_DELETE_QUESTIONS, { ignoreResults: true });
const [dialogType, setDialogType] = useState<DialogType>(DialogType.None);
const [questionBeingEdited, setQuestionBeingEdited] = useState<
Maybe<Question>
>(null);
const [selectedQuestions, setSelectedQuestions] = useState<Question[]>([]);
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 (
<Container>
<Paper>
<TableToolbar
search={search}
onClickCreateQuestion={() => {
setQuestionBeingEdited(null);
setDialogType(DialogType.Create);
}}
onChangeSearchValue={val => {
setQueryParams({ page: 0, search: val });
}}
/>
<Table
selection
columns={COLUMNS}
data={questions}
selected={selectedQuestions}
onSelect={handleSelect}
actions={[
{
icon: row => {
return (
<IconButton
onClick={() => {
setQuestionBeingEdited(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
}
question={questionBeingEdited}
onSubmit={handleFormDialogSubmit}
onClose={() => setDialogType(DialogType.None)}
/>
<Snackbar
open={selectedQuestions.length > 0}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
message={`Wybrane pytania: ${selectedQuestions.length}`}
action={
<>
<Button onClick={handleDeleteQuestions} color="secondary">
Usuń
</Button>
<Button color="secondary" onClick={() => setSelectedQuestions([])}>
Anuluj
</Button>
</>
}
/>
</Container>
);
};
export default QuestionsPage;

View File

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

View File

@ -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<DialogProps, 'open'> {
question?: Maybe<Question>;
onClose: () => void;
onSubmit: (input: QuestionInput) => Promise<boolean> | boolean;
}
const FormDialog = ({ open, onClose, question, onSubmit }: FormDialogProps) => {
const editMode = Boolean(question);
const {
register,
handleSubmit,
errors,
formState: { isSubmitting },
} = useForm<QuestionInput>({});
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 (
<Dialog
open={open}
onClose={isSubmitting ? undefined : onClose}
fullWidth
maxWidth="xs"
>
<form onSubmit={handleSubmit(_onSubmit)}>
<DialogTitle>
{editMode ? 'Edycja pytania' : 'Tworzenie pytania'}
</DialogTitle>
<DialogContent className={classes.dialogContent}>
<TextField
fullWidth
label="Treść pytania"
name="name"
defaultValue={question?.content}
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.content}
helperText={errors.content?.message ?? ''}
/>
</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;
onClickCreateQuestion: () => void;
}
const TableToolbar = ({
search,
onChangeSearchValue,
onClickCreateQuestion,
}: 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 pytanie">
<IconButton onClick={onClickCreateQuestion}>
<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,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<Question>[] = [
{
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 = '',
}

View File

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

View File

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