Skip to content

Commit

Permalink
enhance(apps/frontend-manage): block deletion of answer collection en…
Browse files Browse the repository at this point in the history
…tries used as solutions (#4439)
  • Loading branch information
sjschlapbach authored Jan 7, 2025
1 parent 4f500c1 commit 6cb6918
Show file tree
Hide file tree
Showing 15 changed files with 189 additions and 50 deletions.
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { AnswerCollection } from '@klicker-uzh/graphql/dist/ops'
import { H3, Modal, Toast } from '@uzh-bf/design-system'
import { H3, Modal, Toast, UserNotification } from '@uzh-bf/design-system'
import { useTranslations } from 'next-intl'
import { useState } from 'react'
import AddAnswerCollectionEntry from './AddAnswerCollectionEntry'
Expand Down Expand Up @@ -27,22 +27,32 @@ function AnswerCollectionEditModal({
onClose()
}}
title={t('manage.resources.answerCollection', { name: collection.name })}
dataCloseButton={{ cy: 'close-answer-collection-edit-modal' }}
escapeDisabled
>
<AnswerCollectionMetaForm
collection={collection}
setSuccessToast={setSuccessToast}
/>
<div className="mt-3 flex flex-col gap-2">
<div className="mt-3 flex flex-col gap-1">
<H3 className={{ root: 'mb-0' }}>
{t('manage.resources.answerOptions')}
</H3>
{collection.entries?.some(
(entry) => (entry.numSolutionUsages ?? 0) > 0
) ? (
<UserNotification
message={t('manage.resources.answerOptionUsedAsSolution')}
type="warning"
className={{ root: 'mb-2' }}
/>
) : null}
{collection.entries!.map((entry, ix) => (
<AnswerCollectionOption
key={`collection-entry-${entry.id}`}
id={entry.id}
value={entry.value}
entry={entry}
index={ix}
last={ix === collection.entries!.length - 1}
collectionId={collection.id}
deletionDisabled={collection.entries!.length <= 2}
editDisabled={optionsEditingDisabled}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { faClock, faHandPointer } from '@fortawesome/free-regular-svg-icons'
import { faUserGroup } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import {
AnswerCollection,
Expand Down Expand Up @@ -91,9 +92,17 @@ function AnswerCollectionItem({
) : null}
</div>
{editable ? (
<div className="flex flex-row items-center gap-1.5 self-end text-sm">
<FontAwesomeIcon icon={faHandPointer} />
<div>{t('manage.resources.clickToViewEdit')}</div>
<div className="flex h-full flex-col items-end gap-0.5 self-end text-sm">
{(collection.numSharedUsers ?? 0) > 0 ? (
<div className="flex flex-row items-center gap-1.5">
{collection.numSharedUsers ?? 0}
<FontAwesomeIcon icon={faUserGroup} />
</div>
) : null}
<div className="flex flex-row items-center gap-1.5">
<FontAwesomeIcon icon={faHandPointer} />
<div>{t('manage.resources.clickToViewEdit')}</div>
</div>
</div>
) : accessGranted ? (
<div className="text-primary-100 flex flex-row items-center gap-2">
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { useMutation } from '@apollo/client'
import { faTrashCan } from '@fortawesome/free-regular-svg-icons'
import { faPencil, faSave } from '@fortawesome/free-solid-svg-icons'
import { faPencil, faSave, faWarning } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import {
AnswerCollectionEntry,
DeleteAnswerCollectionEntryDocument,
EditAnswerCollectionEntryDocument,
GetAnswerCollectionsDocument,
Expand All @@ -13,17 +14,17 @@ import { Dispatch, SetStateAction, useState } from 'react'
import { twMerge } from 'tailwind-merge'

function AnswerCollectionOption({
id,
value,
entry,
index,
last,
collectionId,
deletionDisabled,
editDisabled,
setEditDisabled,
}: {
id: number
value: string
entry: AnswerCollectionEntry
index: number
last: boolean
collectionId: number
deletionDisabled?: boolean
editDisabled: boolean
Expand All @@ -36,21 +37,28 @@ function AnswerCollectionOption({
const [deleteAnswerCollectionEntry] = useMutation(
DeleteAnswerCollectionEntryDocument
)
const deletionNotAllowed =
deletionDisabled || (entry.numSolutionUsages ?? 0) > 0

return (
<div className="flex w-full flex-row items-center gap-1">
<div
className={twMerge(
'flex w-full flex-row items-center gap-1 border-b pb-1',
last && '!border-b-0'
)}
>
<Button
className={{
root: twMerge(
'h-8 w-8 items-center justify-center border border-red-600',
!deletionDisabled && 'hover:border-red-600 hover:text-red-600'
!deletionNotAllowed && 'hover:border-red-600 hover:text-red-600'
),
}}
disabled={deletionDisabled}
data={{ cy: `delete-answer-option-${index}` }}
disabled={deletionNotAllowed}
data={{ cy: `delete-answer-option-${entry.value}` }}
onClick={async () => {
await deleteAnswerCollectionEntry({
variables: { id },
variables: { id: entry.id },
update: (cache, { data }) => {
if (!data?.deleteAnswerCollectionEntry) return

Expand All @@ -75,7 +83,7 @@ function AnswerCollectionOption({
return {
...collection,
entries: collection.entries?.filter(
(entry) => entry.id !== id
(e) => e.id !== entry.id
),
}
}
Expand All @@ -101,24 +109,24 @@ function AnswerCollectionOption({
setEditDisabled(true)
}}
disabled={editDisabled}
data={{ cy: `edit-answer-option-${index}` }}
data={{ cy: `edit-answer-option-${entry.value}` }}
>
<FontAwesomeIcon icon={faPencil} />
</Button>
) : null}
<div
className={twMerge('w-full', !editMode && 'ml-2')}
data-cy={`answer-option-${index}`}
data-cy={`answer-option-${entry.value}`}
>
{editMode ? (
<Formik
initialValues={{ value }}
initialValues={{ value: entry.value }}
onSubmit={async (values, { setSubmitting }) => {
setSubmitting(true)

if (value !== values.value) {
if (entry.value !== values.value) {
await editAnswerCollectionEntry({
variables: { id, value: values.value },
variables: { id: entry.id, value: values.value },
update: (cache, { data }) => {
if (!data?.editAnswerCollectionEntry) return

Expand All @@ -144,12 +152,12 @@ function AnswerCollectionOption({
if (collection.id === collectionId) {
return {
...collection,
entries: collection.entries?.map((entry) => {
if (entry.id === id) {
return { ...entry, value: values.value }
entries: collection.entries?.map((e) => {
if (e.id === entry.id) {
return { ...e, value: values.value }
}

return entry
return e
}),
}
}
Expand Down Expand Up @@ -193,9 +201,12 @@ function AnswerCollectionOption({
)}
</Formik>
) : (
value
entry.value
)}
</div>
{(entry.numSolutionUsages ?? 0) > 0 ? (
<FontAwesomeIcon icon={faWarning} className="text-orange-500" />
) : null}
</div>
)
}
Expand Down
76 changes: 76 additions & 0 deletions cypress/cypress/e2e/D-questions-workflow.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,12 +71,14 @@ const SEExplanation = 'Selection Question Explanation'
const SECollection = 'Private Collection (Vegetables)' // from seed (access otherwise is tested in resources workflow)
const SEInputs = 2
const SESolutions = ['Cabbage', 'Cucumber']
const SESolutionsNotChosen = ['Artichoke', 'Broccoli', 'Dill', 'Carrot']
const SETitleEdited = 'Selection Question Title Edited'
const SEContentEdited = 'Selection Question Text Edited'
const SEExplanationEdited = 'Selection Question Explanation Edited'
const SECollectionEdited = 'Public Collection (Fruits)' // from seed
const SEInputsEdited = 1
const SESolutionsEdited = ['Apple', 'Banana']
const SESolutionsNotChosenEdited = ['Cherry', 'Date', 'Elderberry']

describe('Create different types of elements (with and without sample solution) and edit them', () => {
beforeEach(() => {
Expand Down Expand Up @@ -1134,6 +1136,26 @@ describe('Create different types of elements (with and without sample solution)
cy.get('[data-cy="close-question-modal"]').click()
})

it('Check that all options of the answer collection can be edited', () => {
cy.loginLecturer()
cy.get('[data-cy="resources"]').click()
cy.get(`[data-cy="answer-collection-${SECollection}"]`).click()

SESolutions.forEach((sol) => {
cy.get(`[data-cy="delete-answer-option-${sol}"]`).should(
'not.be.disabled'
)
cy.get(`[data-cy="edit-answer-option-${sol}"]`).should('not.be.disabled')
})
SESolutionsNotChosen.forEach((sol) => {
cy.get(`[data-cy="delete-answer-option-${sol}"]`).should(
'not.be.disabled'
)
cy.get(`[data-cy="edit-answer-option-${sol}"]`).should('not.be.disabled')
})
cy.get("[data-cy='close-answer-collection-edit-modal']").click()
})

it('Add a sample solution to the created selection question', () => {
cy.get(`[data-cy="edit-question-${SETitle}"]`).click()
cy.get('[data-cy="insert-question-title"]').should('have.value', SETitle)
Expand Down Expand Up @@ -1162,6 +1184,27 @@ describe('Create different types of elements (with and without sample solution)
cy.get('[data-cy="close-question-modal"]').click()
})

it('Check that the options that are used as a solution cannot be deleted anymore', () => {
cy.loginLecturer()
cy.get('[data-cy="resources"]').click()
cy.get(`[data-cy="answer-collection-${SECollection}"]`).click()
cy.findByText(messages.manage.resources.answerOptionUsedAsSolution).should(
'exist'
)

SESolutions.forEach((sol) => {
cy.get(`[data-cy="delete-answer-option-${sol}"]`).should('be.disabled')
cy.get(`[data-cy="edit-answer-option-${sol}"]`).should('not.be.disabled')
})
SESolutionsNotChosen.forEach((sol) => {
cy.get(`[data-cy="delete-answer-option-${sol}"]`).should(
'not.be.disabled'
)
cy.get(`[data-cy="edit-answer-option-${sol}"]`).should('not.be.disabled')
})
cy.get("[data-cy='close-answer-collection-edit-modal']").click()
})

it('Edit the selection question and change the answer collection (including new sample solutions)', () => {
cy.get(`[data-cy="edit-question-${SETitle}"]`).click()
cy.get('[data-cy="insert-question-title"]')
Expand Down Expand Up @@ -1215,6 +1258,39 @@ describe('Create different types of elements (with and without sample solution)
})
})

it('Check that only answer options not used as solutions can be deleted', () => {
cy.loginLecturer()
cy.get('[data-cy="resources"]').click()
cy.get(`[data-cy="answer-collection-${SECollection}"]`).click()

SESolutions.forEach((sol) => {
cy.get(`[data-cy="delete-answer-option-${sol}"]`).should(
'not.be.disabled'
)
cy.get(`[data-cy="edit-answer-option-${sol}"]`).should('not.be.disabled')
})
SESolutionsNotChosen.forEach((sol) => {
cy.get(`[data-cy="delete-answer-option-${sol}"]`).should(
'not.be.disabled'
)
cy.get(`[data-cy="edit-answer-option-${sol}"]`).should('not.be.disabled')
})
cy.get("[data-cy='close-answer-collection-edit-modal']").click()

cy.get(`[data-cy="answer-collection-${SECollectionEdited}"]`).click()
SESolutionsEdited.forEach((sol) => {
cy.get(`[data-cy="delete-answer-option-${sol}"]`).should('be.disabled')
cy.get(`[data-cy="edit-answer-option-${sol}"]`).should('not.be.disabled')
})
SESolutionsNotChosenEdited.forEach((sol) => {
cy.get(`[data-cy="delete-answer-option-${sol}"]`).should(
'not.be.disabled'
)
cy.get(`[data-cy="edit-answer-option-${sol}"]`).should('not.be.disabled')
})
cy.get("[data-cy='close-answer-collection-edit-modal']").click()
})

it('Create a new question, duplicates it and then deletes the duplicate again', () => {
const randomNumber = uuid()
const questionTitle = 'A Single Choice ' + randomNumber
Expand Down
30 changes: 13 additions & 17 deletions cypress/cypress/e2e/K-resources-workflow.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,32 +171,28 @@ describe('Create, edit and share answer collections', () => {
.contains(privateDescriptionNew)
cy.get('[data-cy="save-changes-answer-collection"]').click()

privateItems.forEach((value, ix) => {
cy.get(`[data-cy="answer-option-${ix}"]`).contains(value)
privateItems.forEach((value) => {
cy.get(`[data-cy="answer-option-${value}"]`).contains(value)
})
privateItems.forEach((value, ix) => {
cy.get(`[data-cy="edit-answer-option-${ix}"]`).click()
cy.get(`[data-cy="edit-answer-option-${value}"]`).click()
cy.get(`[data-cy="edit-answer-option-input"]`).should('have.value', value)
cy.get(`[data-cy="edit-answer-option-input"]`)
.clear()
.type(privateItemsNew[ix])
cy.get(`[data-cy="save-edit-answer-option"]`).click()
cy.get(`[data-cy="answer-option-${ix}"]`).contains(privateItemsNew[ix])
cy.get(`[data-cy="answer-option-${privateItemsNew[ix]}"]`).contains(
privateItemsNew[ix]
)
})
cy.get(
`[data-cy="delete-answer-option-${privateItemsNew.length - 1}"]`
).click()
cy.get(`[data-cy="answer-option-${privateItemsNew.length - 1}"]`).should(
'not.exist'
)

const lastElement = privateItemsNew[privateItemsNew.length - 1]
cy.get(`[data-cy="delete-answer-option-${lastElement}"]`).click()
cy.get(`[data-cy="answer-option-${lastElement}"]`).should('not.exist')
cy.get(`[data-cy="add-answer-option"]`).click()
cy.get(`[data-cy="input-new-answer-option"]`).type(
privateItemsNew[privateItemsNew.length - 1]
)
cy.get(`[data-cy="input-new-answer-option"]`).type(lastElement)
cy.get(`[data-cy="save-new-answer-option"]`).click()
cy.get(`[data-cy="answer-option-${privateItemsNew.length - 1}"]`).contains(
privateItemsNew[privateItemsNew.length - 1]
)
cy.get(`[data-cy="answer-option-${lastElement}"]`).contains(lastElement)
})

it('Verify that the changes to the private answer catalogue persist', () => {
Expand All @@ -212,7 +208,7 @@ describe('Create, edit and share answer collections', () => {
.realClick()
.contains(privateDescriptionNew)
privateItemsNew.forEach((value, ix) => {
cy.get(`[data-cy="answer-option-${ix}"]`).contains(value)
cy.get(`[data-cy="answer-option-${value}"]`).contains(value)
})
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ query GetAnswerCollections {
entries {
id
value
numSolutionUsages
}
}
sharedCollections {
Expand Down
12 changes: 12 additions & 0 deletions packages/graphql/src/ops.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -962,6 +962,18 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "numSolutionUsages",
"description": null,
"args": [],
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "value",
"description": null,
Expand Down
Loading

0 comments on commit 6cb6918

Please sign in to comment.