Skip to content

Commit 93d4870

Browse files
committed
feat: allow editing of answers
closes #5
1 parent 29a0580 commit 93d4870

File tree

8 files changed

+242
-26
lines changed

8 files changed

+242
-26
lines changed

plugins/qeta-backend/src/database/DatabaseQetaStore.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -562,6 +562,24 @@ export class DatabaseQetaStore implements QetaStore {
562562
return this.getAnswer(answers[0].id);
563563
}
564564

565+
async updateAnswer(
566+
user_ref: string,
567+
questionId: number,
568+
answerId: number,
569+
answer: string,
570+
): Promise<MaybeAnswer> {
571+
const rows = await this.db('answers')
572+
.where('answers.id', '=', answerId)
573+
.where('answers.questionId', '=', questionId)
574+
.where('answers.author', '=', user_ref)
575+
.update({ content: answer, updatedBy: user_ref, updated: new Date() });
576+
577+
if (!rows) {
578+
return null;
579+
}
580+
return this.getAnswer(answerId);
581+
}
582+
565583
async getAnswer(answerId: number): Promise<MaybeAnswer> {
566584
const answers = await this.getAnswerBaseQuery().where('id', '=', answerId);
567585
return this.mapAnswer(answers[0], true);

plugins/qeta-backend/src/database/QetaStore.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,20 @@ export interface QetaStore {
156156
answer: string,
157157
): Promise<MaybeAnswer>;
158158

159+
/**
160+
* Update answer to a question
161+
* @param user_ref user name of the user updating the answer
162+
* @param questionId question id
163+
* @param answerId answer id
164+
* @param answer answer content
165+
*/
166+
updateAnswer(
167+
user_ref: string,
168+
questionId: number,
169+
answerId: number,
170+
answer: string,
171+
): Promise<MaybeAnswer>;
172+
159173
/** Get answer by id
160174
* @param answerId answer id
161175
*/

plugins/qeta-backend/src/service/router.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ describe('createRouter', () => {
6666
markAnswerIncorrect: jest.fn(),
6767
getTags: jest.fn(),
6868
updateQuestion: jest.fn(),
69+
updateAnswer: jest.fn(),
6970
};
7071

7172
const getIdentityMock = jest

plugins/qeta-backend/src/service/router.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,58 @@ export async function createRouter({
300300
response.send(answer);
301301
});
302302

303+
// POST /questions/:id/answers/:answerId
304+
router.post(`/questions/:id/answers/:answerId`, async (request, response) => {
305+
// Validation
306+
const validateRequestBody = ajv.compile(PostAnswerSchema);
307+
if (!validateRequestBody(request.body)) {
308+
response
309+
.status(400)
310+
.send({ errors: validateRequestBody.errors, type: 'body' });
311+
return;
312+
}
313+
314+
const username = await getUsername(request);
315+
// Act
316+
const answer = await database.updateAnswer(
317+
username,
318+
Number.parseInt(request.params.id, 10),
319+
Number.parseInt(request.params.answerId, 10),
320+
request.body.answer,
321+
);
322+
323+
if (!answer) {
324+
response.sendStatus(404);
325+
return;
326+
}
327+
328+
mapAdditionalFields(username, answer);
329+
330+
// Response
331+
response.status(201);
332+
response.send(answer);
333+
});
334+
335+
// GET /questions/:id/answers/:answerId
336+
router.get(`/questions/:id/answers/:answerId`, async (request, response) => {
337+
// Validation
338+
// Act
339+
const username = await getUsername(request);
340+
const answer = await database.getAnswer(
341+
Number.parseInt(request.params.answerId, 10),
342+
);
343+
344+
if (answer === null) {
345+
response.sendStatus(404);
346+
return;
347+
}
348+
349+
mapAdditionalFields(username, answer);
350+
351+
// Response
352+
response.send(answer);
353+
});
354+
303355
// DELETE /questions/:id/answers/:answerId
304356
router.delete(
305357
'/questions/:id/answers/:answerId',

plugins/qeta/src/api/QetaApi.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,4 +49,11 @@ export interface QetaApi {
4949
id: string,
5050
question: QuestionRequest,
5151
): Promise<QuestionResponse>;
52+
53+
updateAnswer(id: number, answer: AnswerRequest): Promise<AnswerResponseBody>;
54+
55+
getAnswer(
56+
questionId: string | number | undefined,
57+
id: string | number | undefined,
58+
): Promise<AnswerResponseBody>;
5259
}

plugins/qeta/src/api/QetaClient.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,4 +282,44 @@ export class QetaClient implements QetaApi {
282282

283283
return data;
284284
}
285+
286+
async updateAnswer(
287+
id: number,
288+
answer: AnswerRequest,
289+
): Promise<AnswerResponseBody> {
290+
const response = await this.fetchApi.fetch(
291+
`${this.baseUrl}/api/qeta/questions/${answer.questionId}/answers/${id}`,
292+
{
293+
method: 'POST',
294+
body: JSON.stringify({ answer: answer.answer }),
295+
headers: { 'Content-Type': 'application/json' },
296+
},
297+
);
298+
const data = (await response.json()) as AnswerResponseBody;
299+
300+
if ('errors' in data) {
301+
throw new QetaError('Failed to fetch', data.errors);
302+
}
303+
304+
return data;
305+
}
306+
307+
async getAnswer(
308+
questionId: string | number | undefined,
309+
id: string | number | undefined,
310+
): Promise<AnswerResponseBody> {
311+
if (!questionId || !id) {
312+
throw new QetaError('Invalid id provided', undefined);
313+
}
314+
const response = await this.fetchApi.fetch(
315+
`${this.baseUrl}/api/qeta/questions/${questionId}/answers/${id}`,
316+
);
317+
const data = (await response.json()) as AnswerResponseBody;
318+
319+
if ('errors' in data) {
320+
throw new QetaError('Failed to fetch', data.errors);
321+
}
322+
323+
return data;
324+
}
285325
}

plugins/qeta/src/components/QuestionPage/AnswerCard.tsx

Lines changed: 66 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ import { VoteButtons } from './VoteButtons';
1313
import { useStyles } from '../../utils/hooks';
1414
import { formatUsername } from '../../utils/utils';
1515
import { DeleteModal } from '../DeleteModal/DeleteModal';
16+
import { AnswerForm } from './AnswerForm';
17+
// @ts-ignore
18+
import RelativeTime from 'react-relative-time';
1619

1720
export const AnswerCard = (props: {
1821
answer: AnswerResponse;
@@ -21,39 +24,80 @@ export const AnswerCard = (props: {
2124
const { answer, question } = props;
2225
const styles = useStyles();
2326

27+
const [editMode, setEditMode] = React.useState(false);
28+
const [answerEntity, setAnswerEntity] = React.useState(answer);
29+
2430
const [deleteModalOpen, setDeleteModalOpen] = React.useState(false);
2531
const handleDeleteModalOpen = () => setDeleteModalOpen(true);
2632
const handleDeleteModalClose = () => setDeleteModalOpen(false);
2733

34+
const onAnswerEdit = (a: AnswerResponse) => {
35+
setEditMode(false);
36+
setAnswerEntity(a);
37+
};
38+
2839
return (
2940
<Card id={`a${answer.id}`}>
3041
<CardContent>
3142
<Grid container spacing={0}>
3243
<Grid item className={styles.questionCardVote}>
33-
<VoteButtons entity={answer} question={question} />
44+
<VoteButtons entity={answerEntity} question={question} />
3445
</Grid>
3546
<Grid item>
36-
<Typography variant="body1" gutterBottom>
37-
<MarkdownContent content={answer.content} dialect="gfm" />
38-
</Typography>
39-
<Box>
40-
By{' '}
41-
<Link href={`/qeta/users/${answer.author}`}>
42-
{formatUsername(answer.author)}
43-
</Link>
44-
</Box>
45-
{answer.own && (
46-
<Box className={styles.questionCardActions}>
47-
<Link underline="none" href="#" onClick={handleDeleteModalOpen}>
48-
Delete
49-
</Link>
50-
<DeleteModal
51-
open={deleteModalOpen}
52-
onClose={handleDeleteModalClose}
53-
entity={answer}
54-
question={question}
55-
/>
56-
</Box>
47+
{editMode ? (
48+
<AnswerForm
49+
question={question}
50+
onPost={onAnswerEdit}
51+
id={answerEntity.id}
52+
/>
53+
) : (
54+
<>
55+
<Typography variant="body1" gutterBottom>
56+
<MarkdownContent
57+
content={answerEntity.content}
58+
dialect="gfm"
59+
/>
60+
</Typography>
61+
<Box>
62+
<Typography variant="caption" gutterBottom>
63+
By{' '}
64+
<Link href={`/qeta/users/${answerEntity.author}`}>
65+
{formatUsername(answerEntity.author)}
66+
</Link>{' '}
67+
<RelativeTime value={answerEntity.created} />
68+
{answerEntity.updated && (
69+
<>
70+
{' '}
71+
(updated <RelativeTime value={answerEntity.updated} />)
72+
</>
73+
)}
74+
</Typography>
75+
</Box>
76+
{answerEntity.own && (
77+
<Box className={styles.questionCardActions}>
78+
<Link
79+
underline="none"
80+
href="#"
81+
onClick={handleDeleteModalOpen}
82+
>
83+
Delete
84+
</Link>
85+
<Link
86+
underline="none"
87+
href="#"
88+
onClick={() => setEditMode(true)}
89+
>
90+
Edit
91+
</Link>
92+
<DeleteModal
93+
open={deleteModalOpen}
94+
onClose={handleDeleteModalClose}
95+
entity={answerEntity}
96+
question={question}
97+
/>
98+
</Box>
99+
)}
100+
</>
57101
)}
58102
</Grid>
59103
</Grid>

plugins/qeta/src/components/QuestionPage/AnswerForm.tsx

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { WarningPanel } from '@backstage/core-components';
22
import { Button, Typography } from '@material-ui/core';
3-
import React from 'react';
3+
import React, { useEffect } from 'react';
44
import { useApi } from '@backstage/core-plugin-api';
55
import {
66
AnswerRequest,
@@ -12,11 +12,17 @@ import { useStyles } from '../../utils/hooks';
1212
import { Controller, useForm } from 'react-hook-form';
1313
import { MarkdownEditor } from '../MarkdownEditor/MarkdownEditor';
1414

15+
const getDefaultValues = (questionId: number) => {
16+
return { questionId, answer: '' };
17+
};
18+
1519
export const AnswerForm = (props: {
1620
question: QuestionResponse;
1721
onPost: (answer: AnswerResponse) => void;
22+
id?: number;
1823
}) => {
19-
const { question, onPost } = props;
24+
const { question, onPost, id } = props;
25+
const [values, setValues] = React.useState(getDefaultValues(question.id));
2026
const [error, setError] = React.useState(false);
2127
const qetaApi = useApi(qetaApiRef);
2228
const styles = useStyles();
@@ -26,9 +32,27 @@ export const AnswerForm = (props: {
2632
control,
2733
formState: { errors },
2834
reset,
29-
} = useForm<AnswerRequest>();
35+
} = useForm<AnswerRequest>({
36+
values,
37+
defaultValues: getDefaultValues(question.id),
38+
});
3039

3140
const postAnswer = (data: AnswerRequest) => {
41+
if (id) {
42+
qetaApi
43+
.updateAnswer(id, { questionId: question.id, answer: data.answer })
44+
.then(a => {
45+
if (!a || !('id' in a)) {
46+
setError(true);
47+
return;
48+
}
49+
reset();
50+
onPost(a);
51+
})
52+
.catch(_e => setError(true));
53+
return;
54+
}
55+
3256
qetaApi
3357
.postAnswer({ questionId: question.id, answer: data.answer })
3458
.then(a => {
@@ -42,6 +66,22 @@ export const AnswerForm = (props: {
4266
.catch(_e => setError(true));
4367
};
4468

69+
useEffect(() => {
70+
if (id) {
71+
qetaApi.getAnswer(question.id, id).then(a => {
72+
if ('content' in a) {
73+
setValues({ questionId: question.id, answer: a.content });
74+
} else {
75+
setError(true);
76+
}
77+
});
78+
}
79+
}, [id, question, qetaApi]);
80+
81+
useEffect(() => {
82+
reset(values);
83+
}, [values, reset]);
84+
4585
return (
4686
<form onSubmit={handleSubmit(postAnswer)}>
4787
<Typography variant="h6">Your answer</Typography>
@@ -63,7 +103,7 @@ export const AnswerForm = (props: {
63103
name="answer"
64104
/>
65105
<Button variant="contained" type="submit" className={styles.postButton}>
66-
Post
106+
{id ? 'Save' : 'Post'}
67107
</Button>
68108
</form>
69109
);

0 commit comments

Comments
 (0)