add QuestionsPage
This commit is contained in:
parent
19d43b96ba
commit
7b7aef9a1f
|
@ -3,6 +3,7 @@ export enum Route {
|
||||||
UsersPage = '/users',
|
UsersPage = '/users',
|
||||||
ProfessionsPage = '/professions',
|
ProfessionsPage = '/professions',
|
||||||
QualificationsPage = '/qualifications',
|
QualificationsPage = '/qualifications',
|
||||||
|
QuestionsPage = '/questions',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PUBLIC_ROUTES = [Route.SignInPage];
|
export const PUBLIC_ROUTES = [Route.SignInPage];
|
||||||
|
|
|
@ -4,6 +4,7 @@ import AppLayout from 'common/AppLayout/AppLayout';
|
||||||
import UsersPage from './UsersPage/UsersPage';
|
import UsersPage from './UsersPage/UsersPage';
|
||||||
import ProfessionsPage from './ProfessionsPage/ProfessionsPage';
|
import ProfessionsPage from './ProfessionsPage/ProfessionsPage';
|
||||||
import QualificationsPage from './QualificationsPage/QualificationsPage';
|
import QualificationsPage from './QualificationsPage/QualificationsPage';
|
||||||
|
import QuestionsPage from './QuestionsPage/QuestionsPage';
|
||||||
|
|
||||||
function AdminRoutes() {
|
function AdminRoutes() {
|
||||||
return (
|
return (
|
||||||
|
@ -18,6 +19,9 @@ function AdminRoutes() {
|
||||||
<RRDRoute exact path={Route.QualificationsPage}>
|
<RRDRoute exact path={Route.QualificationsPage}>
|
||||||
<QualificationsPage />
|
<QualificationsPage />
|
||||||
</RRDRoute>
|
</RRDRoute>
|
||||||
|
<RRDRoute exact path={Route.QuestionsPage}>
|
||||||
|
<QuestionsPage />
|
||||||
|
</RRDRoute>
|
||||||
</Switch>
|
</Switch>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -0,0 +1 @@
|
||||||
|
export const MAX_NAME_LENGTH = 100;
|
|
@ -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;
|
|
@ -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 = '',
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
Reference in New Issue