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 (
+
+ );
+};
+
+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
+ }
+ }
+ }
+`;