Skip to content

Commit

Permalink
Merge pull request #56 from leung018/mc-15-edit-question-set-part-3
Browse files Browse the repository at this point in the history
Mc 15 edit question set part 3
  • Loading branch information
leung018 authored Jun 10, 2024
2 parents 8e667e0 + b0807ce commit 78b6652
Show file tree
Hide file tree
Showing 7 changed files with 150 additions and 42 deletions.
11 changes: 11 additions & 0 deletions cypress/e2e/spec.cy.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,17 @@ describe('End to end tests', () => {
cy.contains('Valid Question Set Name')
})

it('should able to modify existing question set', () => {
createQuestionSet({ cy })

cy.contains('Edit').click()
cy.contains('Question Set Name').type('Edited name')
cy.contains('Save').click()

assertIsInHomePage(cy)
cy.contains('Edited name')
})

it('should allow navigate to home page after submitted quiz', () => {
createQuestionSet({ cy })

Expand Down
14 changes: 11 additions & 3 deletions src/app/components/editor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,11 @@ class UIServiceInteractor {
}).getModifyingPageElement(questionSetId),
)

const questionSetNameInput = screen.getByLabelText(
const questionSetNameInput = screen.queryByLabelText(
'Question Set Name:',
) as HTMLInputElement
this.setQuestionSetName(questionSetNameInput.value)
) as HTMLInputElement | null
if (questionSetNameInput)
this.setQuestionSetName(questionSetNameInput.value)

return this
}
Expand Down Expand Up @@ -659,6 +660,13 @@ describe('QuestionSetEditor', () => {
expect(screen.queryByDisplayValue('I will be removed')).toBeNull()
})

it("should render not found when modify a question set that doesn't exist", () => {
const interactor = new UIServiceInteractor()
interactor.renderModifyingPage('unknown_question_set_id')

expect(screen.getByText('404')).toBeInTheDocument()
})

it('should modifying page load the contents in original question set', () => {
const questionSetRepo = LocalStorageQuestionSetRepo.createNull()
const questionSet = new QuestionSet({
Expand Down
117 changes: 85 additions & 32 deletions src/app/components/editor.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
'use client'

import { useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import {
QuestionSetRepo,
LocalStorageQuestionSetRepo,
UpsertQuestionSetError,
GetQuestionSetError,
} from '../../repo/question_set'
import { Question, QuestionSet } from '../../model/question_set'
import {
Expand All @@ -13,6 +14,8 @@ import {
MultipleChoiceError,
} from '../../model/mc'
import { useRouter } from 'next/navigation'
import Error from 'next/error'
import LoadingSpinner from './loading'

export class QuestionSetEditorAriaLabel {
// If update of labels in this class, may need also to update e2e test
Expand Down Expand Up @@ -64,8 +67,14 @@ export class QuestionSetEditorAriaLabel {
}
}

interface OperationResult {
error?: string
type OperationResult<T = undefined> = SuccessResult<T> | FailureResult

interface SuccessResult<T> {
result: T
}

interface FailureResult {
error: string
}

export class QuestionSetEditorUIService {
Expand Down Expand Up @@ -98,17 +107,18 @@ export class QuestionSetEditorUIService {
return (
<QuestionSetEditor
saveQuestionSet={this.saveQuestionSet}
questionSet={new QuestionSet({ name: '', questions: [] })}
fetchQuestionSet={() => new QuestionSet({ name: '', questions: [] })}
/>
)
}

getModifyingPageElement(questionSetId: string) {
const questionSet = this.questionSetRepo.getQuestionSetById(questionSetId)
return (
<QuestionSetEditor
saveQuestionSet={this.saveQuestionSet}
questionSet={questionSet}
fetchQuestionSet={() =>
this.questionSetRepo.getQuestionSetById(questionSetId)
}
/>
)
}
Expand All @@ -126,7 +136,9 @@ export class QuestionSetEditorUIService {
}
throw e
}
return {}
return {
result: undefined,
}
}
}

Expand Down Expand Up @@ -199,19 +211,42 @@ const mapMultipleChoiceToChoiceInputs = (mc: MultipleChoice): ChoiceInput[] => {
}

function QuestionSetEditor({
questionSet,
saveQuestionSet,
fetchQuestionSet,
}: {
questionSet: QuestionSet
fetchQuestionSet: () => QuestionSet
saveQuestionSet: (questionSet: QuestionSet) => OperationResult
}) {
const router = useRouter()
const [isLoading, setLoading] = useState(true)
const [isNotFound, setNotFound] = useState(false)

const [questionSetInput, setQuestionSetInput] = useState<QuestionSetInput>(
mapQuestionSetToQuestionSetInput(questionSet),
)
const questionSetIdRef = useRef<string>('')

const [questionSetInput, setQuestionSetInput] = useState<QuestionSetInput>({
name: '',
questions: [],
})
const [errorMessage, setErrorMessage] = useState<string | null>(null)

useEffect(() => {
try {
const questionSet = fetchQuestionSet()
questionSetIdRef.current = questionSet.id
setQuestionSetInput(mapQuestionSetToQuestionSetInput(questionSet))
setLoading(false)
} catch (e) {
if (
e instanceof GetQuestionSetError &&
e.cause.code === 'QUESTION_SET_NOT_FOUND'
) {
setNotFound(true)
return
}
throw e
}
}, [fetchQuestionSet])

const handleQuestionUpdate = (
questionId: number,
questionUpdater: (oldQuestion: QuestionInput) => QuestionInput,
Expand All @@ -237,49 +272,59 @@ function QuestionSetEditor({
}

const handleSaveClick = () => {
const { error } = saveChanges()
if (error) {
setErrorMessage(error)
const response = saveChanges()
if ('error' in response) {
setErrorMessage(response.error)
return
}

router.push('/')
}

const saveChanges = (): OperationResult => {
const response = buildQuestionSet(questionSetInput)
if ('error' in response) {
return response
}
return saveQuestionSet(response.result)
}

const buildQuestionSet = (
questionSetInput: QuestionSetInput,
): OperationResult<QuestionSet> => {
if (questionSetInput.name === '') {
return { error: "Question set name can't be empty" }
}

// build Questions
const questions: Question[] = []

for (let i = 0; i < questionSetInput.questions.length; i++) {
const questionInput = questionSetInput.questions[i]
const questionNumber = i + 1

const { error, question } = mapQuestionInputToQuestion(
questionInput,
questionNumber,
)
if (error) {
return { error }
const response = buildQuestion(questionInput, questionNumber)
if ('error' in response) {
return {
error: response.error,
}
}

questions.push(question!)
questions.push(response.result)
}

questionSet.name = questionSetInput.name
questionSet.questions = questions

return saveQuestionSet(questionSet)
return {
result: new QuestionSet({
name: questionSetInput.name,
questions,
id: questionSetIdRef.current,
}),
}
}

const mapQuestionInputToQuestion = (
const buildQuestion = (
input: QuestionInput,
questionNumber: number,
): OperationResult & {
question?: Question
} => {
): OperationResult<Question> => {
if (input.description === '') {
return {
error: `Question ${questionNumber}: description can't be empty`,
Expand All @@ -304,7 +349,7 @@ function QuestionSetEditor({
try {
const mc = mcBuilder.build()
return {
question: {
result: {
description: input.description,
mc,
},
Expand All @@ -324,6 +369,14 @@ function QuestionSetEditor({
}
}

if (isNotFound) {
return <Error statusCode={404} />
}

if (isLoading) {
return <LoadingSpinner />
}

return (
<div className="container mx-auto p-4">
<form>
Expand Down
11 changes: 8 additions & 3 deletions src/app/components/home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,14 @@ function HomePage({
>
Take Quiz
</button>
{
// TODO: Add Edit feature. (See the git blame of this line to see the original edit button code)
}
<button
className="ml-2 px-4 py-2 bg-green-500 text-white rounded hover:bg-green-700 transition-colors"
onClick={() => {
router.push(`/edit?id=${set.id}`)
}}
>
Edit
</button>
</div>
</div>
</li>
Expand Down
23 changes: 21 additions & 2 deletions src/app/edit/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,26 @@
'use client'
import { useSearchParams } from 'next/navigation'
import { QuestionSetEditorUIService } from '../components/editor'
import { Suspense } from 'react'

export default function QuestionSetEditorPage() {
// TODO: If id is passed in query param, getModifyingPageElement instead
return QuestionSetEditorUIService.create().getCreationPageElement()
// TODO: Suspense is needed when useSearchParams is used. Mark this in README or use util function to remind this info.
return (
<Suspense>
<MyQuestionSetEditorPage />
</Suspense>
)
}

function MyQuestionSetEditorPage() {
const searchParams = useSearchParams()
const id = searchParams.get('id')

const service = QuestionSetEditorUIService.create()

if (!id) {
return service.getCreationPageElement()
}

return service.getModifyingPageElement(id)
}
12 changes: 10 additions & 2 deletions src/model/question_set.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,18 @@ export class QuestionSet {

readonly id: string

constructor({ name, questions }: { name: string; questions: Question[] }) {
constructor({
name,
questions,
id = uuidv4(),
}: {
name: string
questions: Question[]
id?: string
}) {
this.name = name
this.questions = questions
this.id = uuidv4()
this.id = id
}
}

Expand Down
4 changes: 4 additions & 0 deletions src/repo/question_set.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import { QuestionSet } from '../model/question_set'
import { CustomBaseError } from '../utils/err'
import { LocalStorageOperator } from '../utils/local_storage'

/**
* For React components that utilize this interface, ensure that calls to this interface are wrapped in useEffect.
* This precaution helps prevent errors such as `ReferenceError: localStorage is not defined`, which can occur if the localStorage version of this repository is accessed.
*/
export interface QuestionSetRepo {
/**
* @throws {UpsertQuestionSetError}
Expand Down

0 comments on commit 78b6652

Please sign in to comment.