#11 Implement AddQuestion

Also introduce notistack/snackbar to handle async error/success messages
This commit is contained in:
Christoph Lienhard 2020-12-29 13:02:50 +01:00
parent dd2f414f00
commit 7370a7c493
Signed by: christoph.lienhard
GPG key ID: 6B98870DDC270884
7 changed files with 112 additions and 64 deletions

View file

@ -9603,6 +9603,16 @@
"sort-keys": "^1.0.0"
}
},
"notistack": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/notistack/-/notistack-1.0.3.tgz",
"integrity": "sha512-bRGF/eg2qNQ8BwagPLkHiqrz+W00PYtGY5Xl33I0Of1BTm7arksZO1JxssPTlti0qw127CxuWxm637ipn0eZ9g==",
"dev": true,
"requires": {
"clsx": "^1.1.0",
"hoist-non-react-statics": "^3.3.0"
}
},
"npm-run-path": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-2.0.2.tgz",

View file

@ -13,7 +13,8 @@
"react-dom": "^16.13.1",
"react-router-dom": "^5.2.0",
"react-scripts": "^3.4.4",
"typescript": "^3.8"
"typescript": "^3.8",
"notistack": "^1.0.3"
},
"devDependencies": {
"@testing-library/jest-dom": "^5.11.4",

View file

@ -27,21 +27,17 @@ export const ADD_QUESTION = gql`
mutation AddQuestion($text: String!, $description: String, $categoryRowId: Int) {
createQuestion(input: {question: {text: $text, categoryRowId: $categoryRowId, description: $description}}) {
question {
id
text
description
categoryByCategoryRowId {
id
rowId
title
}
...BasicQuestionFragment
}
}
}
${BasicQuestionFragment}
`
export interface AddQuestionResponse {
createQuestion?: BasicQuestionResponse
createQuestion?: {
question: BasicQuestionResponse
}
}
export interface AddQuestionVariables {
@ -51,24 +47,20 @@ export interface AddQuestionVariables {
}
export const DELETE_QUESTION = gql`
mutation AddQuestion($text: String!, $description: String, $categoryRowId: Int) {
createQuestion(input: {question: {text: $text, categoryRowId: $categoryRowId, description: $description}}) {
mutation DeleteQuestion($id: ID!) {
deleteQuestion(input: { id: $id }) {
question {
id
text
description
categoryByCategoryRowId {
id
rowId
title
}
...BasicQuestionFragment
}
}
}
${BasicQuestionFragment}
`
export interface DeleteQuestionResponse {
deleteQuestion?: BasicQuestionResponse
deleteQuestion?: {
question: BasicQuestionResponse
}
}
export interface DeleteQuestionVariables {

View file

@ -49,5 +49,3 @@ export interface GetAllQuestionsResponse {
nodes: Array<BasicQuestionResponse>
}
}

View file

@ -1,14 +1,26 @@
import {Paper, Snackbar, Typography} from "@material-ui/core";
import {Paper, Typography} from "@material-ui/core";
import React, {useState} from "react";
import {makeStyles} from "@material-ui/core/styles";
import {useMutation, useQuery} from "@apollo/client";
import AddCard from "./AddCard";
import AccordionWithEdit from "./AccordionWithEdit";
import {GET_ALL_QUESTIONS, GetAllQuestionsResponse, BasicQuestionResponse} from "../backend/queries/question";
import {
BasicQuestionFragment,
BasicQuestionResponse,
GET_ALL_QUESTIONS,
GetAllQuestionsResponse
} from "../backend/queries/question";
import ChangeQuestionDialog, {ChangeQuestionDialogContent} from "./ChangeQuestionDialog";
import {GET_ALL_CATEGORIES, GetAllCategoriesResponse} from "../backend/queries/category";
import {EDIT_QUESTION, EditQuestionResponse, EditQuestionVariables} from "../backend/mutations/question";
import {Alert, Color} from "@material-ui/lab";
import {
ADD_QUESTION,
AddQuestionResponse,
AddQuestionVariables,
EDIT_QUESTION,
EditQuestionResponse,
EditQuestionVariables
} from "../backend/mutations/question";
import {useSnackbar} from 'notistack';
const useStyles = makeStyles((theme) => ({
root: {
@ -25,22 +37,53 @@ const emptyChangeQuestionDialog: ChangeQuestionDialogContent = {
categoryId: null,
}
interface SnackbarProps {
open: boolean,
message?: string,
severity?: Color,
}
export default function QuestionList() {
const [dialogOpen, setDialogOpen] = useState(false);
const [dialogTitle, setDialogTitle] = useState("");
const [dialogConfirmButtonText, setDialogConfirmButtonText] = useState("");
const [dialogContent, setDialogContent] = useState(emptyChangeQuestionDialog);
const [snackbarProps, setSnackbarProps] = useState<SnackbarProps>({open: false});
const { enqueueSnackbar } = useSnackbar();
const questions = useQuery<GetAllQuestionsResponse, null>(GET_ALL_QUESTIONS).data?.allQuestions.nodes;
const categories = useQuery<GetAllCategoriesResponse, null>(GET_ALL_CATEGORIES).data?.allCategories.nodes;
const [editQuestion, {loading: editLoading}] = useMutation<EditQuestionResponse, EditQuestionVariables>(EDIT_QUESTION, {
onError: (e) => enqueueSnackbar(`Ein Fehler ist aufgetreten: ${e.message}`, { variant: "error"}),
onCompleted: (response) => {
if (response.updateQuestion) {
enqueueSnackbar("Frage erfolgreich geändert.", { variant: "success"})
setDialogOpen(false);
} else {
enqueueSnackbar("Ein Fehler ist aufgetreten, versuche es erneut.", { variant: "error"})
}
}
});
const [addQuestion, {loading: addLoading}] = useMutation<AddQuestionResponse, AddQuestionVariables>(ADD_QUESTION, {
onError: (e) => enqueueSnackbar(`Ein Fehler ist aufgetreten: ${e.message}`, { variant: "error"}),
onCompleted: (response) => {
if (response.createQuestion) {
enqueueSnackbar("Frage erfolgreich hinzugefügt.", { variant: "success"})
setDialogOpen(false);
} else {
enqueueSnackbar("Ein Fehler ist aufgetreten, versuche es erneut.", { variant: "error"})
}
},
update: (cache, { data }) => {
cache.modify({
fields: {
allQuestions(existingQuestions = { nodes: []}) {
const newQuestionRef = cache.writeFragment<BasicQuestionResponse | undefined>({
data: data?.createQuestion?.question,
fragment: BasicQuestionFragment,
fragmentName: "BasicQuestionFragment",
});
return {nodes: [...existingQuestions.nodes, newQuestionRef]};
}
}
});
}
});
const classes = useStyles();
const [editQuestion, {loading}] = useMutation<EditQuestionResponse, EditQuestionVariables>(EDIT_QUESTION);
const loading = editLoading || addLoading;
const handleAddClick = () => {
setDialogTitle("Neue Frage erstellen");
@ -69,29 +112,27 @@ export default function QuestionList() {
setDialogContent(content)
}
const handleConfirmButtonClick = async () => {
const handleConfirmButtonClick = () => {
if (dialogContent.id !== "") {
const response = await editQuestion({
editQuestion({
variables: {
id: dialogContent.id,
text: dialogContent.title,
description: dialogContent.details,
categoryRowId: dialogContent.categoryId
categoryRowId: dialogContent.categoryId,
}
})
} else {
addQuestion({
variables: {
text: dialogContent.title,
description: dialogContent.details,
categoryRowId: dialogContent.categoryId,
}
})
if (response.data?.updateQuestion) {
setSnackbarProps({
message: "Frage erfolgreich geändert",
open: true,
severity: 'success',
})
setDialogOpen(false);
}
}
};
const handleSnackbarClose = () => setSnackbarProps({...snackbarProps, open: false})
return (
<Paper className={classes.root}>
<Typography component={"h2"} variant="h6" color="primary" gutterBottom>Fragen</Typography>
@ -117,14 +158,6 @@ export default function QuestionList() {
handleConfirmButtonClick={handleConfirmButtonClick}
handleClose={() => setDialogOpen(false)}
/>
<Snackbar
anchorOrigin={{vertical: 'bottom', horizontal: "right"}}
open={snackbarProps.open}
onClose={handleSnackbarClose}
autoHideDuration={6000}
>
<Alert onClose={handleSnackbarClose} severity={snackbarProps.severity}>{snackbarProps.message}</Alert>
</Snackbar>
</Paper>
)
}

View file

@ -6,11 +6,14 @@ import * as serviceWorker from './serviceWorker';
import {ApolloProvider} from "@apollo/client";
import {client} from "./backend/helper";
import {BrowserRouter as Router} from "react-router-dom";
import {SnackbarProvider} from "notistack";
ReactDOM.render(
<ApolloProvider client={client}>
<Router>
<App/>
<SnackbarProvider maxSnack={3}>
<App/>
</SnackbarProvider>
</Router>
</ApolloProvider>,
document.getElementById('root')

View file

@ -3,20 +3,31 @@ import {render, screen} from '@testing-library/react'
import {MockedProvider} from '@apollo/client/testing';
import {MemoryRouter} from 'react-router-dom';
import App from "../App";
import {SnackbarProvider} from "notistack";
const renderAppAtUrl = (path: string) => render(
<MockedProvider>
<MemoryRouter initialEntries={[path]}>
<SnackbarProvider>
<App/>
</SnackbarProvider>
</MemoryRouter>
</MockedProvider>
);
beforeEach(() => localStorage.clear())
describe('The root path /', () => {
test('renders user\'s home page if they are logged in',() => {
test('renders user\'s home page if they are logged in', () => {
localStorage.setItem("token", "asdfasdfasdf")
render(<MockedProvider><MemoryRouter initialEntries={['/']}><App/></MemoryRouter></MockedProvider>);
renderAppAtUrl("/");
expect(() => screen.getByLabelText(/current user/)).not.toThrow()
});
test('redirects to login page if user not logged in', () => {
render(<MockedProvider><MemoryRouter initialEntries={['/']}><App/></MemoryRouter></MockedProvider>);
renderAppAtUrl("/");
const emailField = screen.getByRole('textbox', {name: 'Email Address'});
const passwordField = screen.getByLabelText(/Password/);
@ -27,7 +38,7 @@ describe('The root path /', () => {
describe('The /login path', () => {
test('renders the signin page if the user is not logged in', () => {
render(<MockedProvider><MemoryRouter initialEntries={['/login']}><App/></MemoryRouter></MockedProvider>);
renderAppAtUrl("/login");
const emailField = screen.getByRole('textbox', {name: 'Email Address'});
const passwordField = screen.getByLabelText(/Password/);
@ -37,7 +48,7 @@ describe('The /login path', () => {
test('redirects to root / and the user\'s home page if the user is logged in', () => {
localStorage.setItem("token", "asdfasdfasdf")
render(<MockedProvider><MemoryRouter initialEntries={['/login']}><App/></MemoryRouter></MockedProvider>);
renderAppAtUrl("/login");
expect(() => screen.getByLabelText(/current user/)).not.toThrow()
});
@ -45,7 +56,7 @@ describe('The /login path', () => {
describe('The /signup path', () => {
test('renders the signup page if the user is not logged in', () => {
render(<MockedProvider><MemoryRouter initialEntries={['/signup']}><App/></MemoryRouter></MockedProvider>);
renderAppAtUrl("/signup");
expect(() => screen.getByRole('textbox', {name: 'Email Address'})).not.toThrow()
expect(() => screen.getByLabelText(/Password/)).not.toThrow()
@ -55,7 +66,7 @@ describe('The /signup path', () => {
test('redirects to root / and the user\'s home page if the user is logged in', () => {
localStorage.setItem("token", "asdfasdfasdf")
render(<MockedProvider><MemoryRouter initialEntries={['/signup']}><App/></MemoryRouter></MockedProvider>);
renderAppAtUrl("/signup");
expect(() => screen.getByLabelText(/current user/)).not.toThrow()
});