Skip to content

Commit 228a6c8

Browse files
committed
feat(frontend): add question cloning
Also, refactor scrolling/highlighting behavior into `useHighlightOnInsert` composable. Closes: #238
1 parent 2b80d98 commit 228a6c8

File tree

12 files changed

+206
-51
lines changed

12 files changed

+206
-51
lines changed

frontend/src/components/attempt/AttemptList.vue

Lines changed: 9 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,12 @@
1212
<AttemptCard
1313
v-for="[attemptId, attemptData] in Object.entries(attempts)"
1414
:class="['attempt-card', { highlight: highlightedIds.has(attemptId) }]"
15-
:id="`attempt-${questionId}-${attemptId}`"
15+
:id="generateHtmlId(attemptId)"
1616
:key="attemptId"
1717
:question-id="questionId"
1818
:attempt-id="attemptId"
1919
:attempt-data="attemptData"
20-
@cloned="handleAttemptCloned"
20+
@cloned="handleNewItem"
2121
/>
2222
<BAlert v-if="attemptCount === 0" :model-value="true" class="mb-0" variant="info"
2323
>This question has no attempts yet.</BAlert
@@ -26,54 +26,21 @@
2626
</template>
2727

2828
<script lang="ts" setup>
29-
import { computed, nextTick, onMounted, ref, watch } from 'vue'
29+
import { computed } from 'vue'
3030
31+
import { useHighlightOnInsert } from '@/composables/common'
3132
import { useAttemptListQuery } from '@/queries'
3233
3334
const { questionId } = defineProps<{ questionId: string }>()
3435
3536
const { asyncStatus, error, data: listData } = useAttemptListQuery(questionId)
3637
38+
const generateHtmlId = (attemptId: string) => `attempt-${questionId}-${attemptId}`
39+
3740
const attempts = computed(() => listData.value ?? {})
3841
const attemptCount = computed(() => Object.keys(attempts.value).length)
39-
40-
const pendingClones = ref<Set<string>>(new Set())
41-
const highlightedIds = ref<Set<string>>(new Set())
42-
43-
onMounted(() => {
44-
// Handle clones coming in via route navigation
45-
const hightlightId = history.state?.highlightAttemptId
46-
if (typeof hightlightId === 'string') {
47-
pendingClones.value.add(hightlightId)
48-
}
49-
})
50-
51-
const handleAttemptCloned = (newAttemptId: string) => {
52-
pendingClones.value.add(newAttemptId)
53-
}
54-
55-
// Watch for pending clones to appear
56-
watch(attempts, async (newAttempts, oldAttempts) => {
57-
for (const attemptId of pendingClones.value) {
58-
if (newAttempts[attemptId] && !oldAttempts?.[attemptId]) {
59-
pendingClones.value.delete(attemptId)
60-
await nextTick()
61-
const el = document.getElementById(`attempt-${questionId}-${attemptId}`)
62-
if (el) {
63-
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
64-
setTimeout(() => {
65-
highlightedIds.value.add(attemptId)
66-
el.addEventListener(
67-
'animationend',
68-
() => {
69-
highlightedIds.value.delete(attemptId)
70-
},
71-
{ once: true },
72-
)
73-
}, 600)
74-
}
75-
}
76-
}
42+
const { handleNewItem, highlightedIds } = useHighlightOnInsert(attempts, generateHtmlId, {
43+
historyStateKey: 'highlightAttemptId',
7744
})
7845
</script>
7946

@@ -86,7 +53,7 @@ watch(attempts, async (newAttempts, oldAttempts) => {
8653
}
8754
8855
&.highlight {
89-
animation: highlight-pulse 1500ms ease-in-out;
56+
@include highlight-pulse;
9057
}
9158
}
9259
</style>

frontend/src/components/question/QuestionCard.vue

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,9 @@
1818
<pre v-if="data">{{ data }}</pre>
1919
</BCardText>
2020
<ButtonGroup>
21-
<!-- TODO: Implement clone -->
22-
<IconButton :icon-component="IMdiContentCopy" variant="secondary" size="sm">Clone</IconButton>
21+
<IconButton @click="cloneClick" :icon-component="IMdiContentCopy" variant="secondary" size="sm"
22+
>Clone</IconButton
23+
>
2324
<!-- TODO: Implement export -->
2425
<IconButton :icon-component="IMdiExport" variant="secondary" size="sm">Export</IconButton>
2526
<IconButton @click="deleteQuestion" :icon-component="IMdiDelete" variant="danger" size="sm"
@@ -54,7 +55,7 @@ import { storeToRefs } from 'pinia'
5455
import { computed } from 'vue'
5556
import { useLink } from 'vue-router'
5657
57-
import { useDeleteQuestion } from '@/composables/question'
58+
import { useCloneQuestion, useDeleteQuestion } from '@/composables/question'
5859
import useAppStateStore from '@/stores/useAppStateStore'
5960
import { isDetailedServerError } from '@/types'
6061
import type { DetailedServerError, OptionsFormData } from '@/types'
@@ -65,11 +66,23 @@ const { data, questionId } = defineProps<{
6566
error?: DetailedServerError
6667
}>()
6768
69+
const emit = defineEmits<{
70+
cloned: [newQuestionId: string]
71+
}>()
72+
6873
const questionLocation = { name: 'question', params: { questionId } } as const
6974
7075
const { colorMode } = storeToRefs(useAppStateStore())
7176
const deleteQuestion = useDeleteQuestion(questionId)
77+
const cloneQuestion = useCloneQuestion(questionId)
7278
const { isActive: isCurrentPreviewActive } = useLink({ to: questionLocation })
7379
7480
const cardVariant = computed(() => (colorMode.value === 'dark' ? 'dark' : 'light'))
81+
82+
const cloneClick = async () => {
83+
const newQuestionId = await cloneQuestion()
84+
if (newQuestionId) {
85+
emit('cloned', newQuestionId)
86+
}
87+
}
7588
</script>

frontend/src/components/question/QuestionList.vue

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,13 @@
1212
<InvalidQuestionStateError v-if="hasInvalidStates" class="mb-3" />
1313
<QuestionCard
1414
v-for="[questionId, question] in Object.entries(questions)"
15-
class="question-card"
16-
:id="`question-${questionId}`"
15+
:class="['question-card', { highlight: highlightedIds.has(questionId) }]"
16+
:id="generateHtmlId(questionId)"
1717
:key="questionId"
1818
:questionId="questionId"
1919
:data="question.data"
2020
:error="question.error"
21+
@cloned="handleNewItem"
2122
/>
2223
<BAlert v-if="questionCount === 0" :model-value="true" class="mb-0" variant="info"
2324
>This package has no questions yet.</BAlert
@@ -28,12 +29,15 @@
2829
<script lang="ts" setup>
2930
import { computed } from 'vue'
3031
32+
import { useHighlightOnInsert } from '@/composables/common'
3133
import { useQuestionStatesQuery } from '@/queries'
3234
import { isDetailedServerError } from '@/types'
3335
import type { DetailedServerError, OptionsFormData } from '@/types'
3436
3537
const { asyncStatus, error, data } = useQuestionStatesQuery()
3638
39+
const generateHtmlId = (questionId: string) => `question-${questionId}`
40+
3741
const questions = computed<Record<string, { data?: OptionsFormData; error?: DetailedServerError }>>(() => {
3842
if (data.value === undefined) {
3943
return {}
@@ -47,6 +51,10 @@ const questions = computed<Record<string, { data?: OptionsFormData; error?: Deta
4751
4852
const questionCount = computed(() => Object.keys(questions.value).length)
4953
const hasInvalidStates = computed(() => Object.values(questions.value).some((q) => q.error))
54+
55+
const { handleNewItem, highlightedIds } = useHighlightOnInsert(questions, generateHtmlId, {
56+
historyStateKey: 'highlightQuestionId',
57+
})
5058
</script>
5159

5260
<style lang="scss" scoped>
@@ -56,5 +64,9 @@ const hasInvalidStates = computed(() => Object.values(questions.value).some((q)
5664
&:last-of-type {
5765
margin-bottom: 0;
5866
}
67+
68+
&.highlight {
69+
@include highlight-pulse;
70+
}
5971
}
6072
</style>

frontend/src/composables/common/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@
55
*/
66

77
export { default as useConfirmModal } from './useConfirmModal'
8+
export { default as useHighlightOnInsert } from './useHighlightOnInsert'
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/*
2+
* This file is part of the QuestionPy SDK. (https://questionpy.org)
3+
* The QuestionPy SDK is free software released under terms of the MIT license. See LICENSE.md.
4+
* (c) Technische Universität Berlin, innoCampus <[email protected]>
5+
*/
6+
7+
import { nextTick, onMounted, type Ref, ref, watch } from 'vue'
8+
9+
/**
10+
* Composable for scrolling to and temporarily highlighting items when they are added or referenced in history.
11+
*
12+
* The composable tracks items by ID, scrolls them into view, and exposes which IDs are currently highlighted.
13+
*
14+
* @template T - Type of the items in the collection.
15+
* @param items Reactive object mapping IDs to items.
16+
* @param idGenerator Function to generate the HTML element ID from an item ID.
17+
* @param options Optional configuration:
18+
* - `historyStateKey`: Key in `history.state` to check for an initial item to highlight (default: `'highlightId'`).
19+
* - `scrollBehavior`: Scroll options for `scrollIntoView` (default: `{ behavior: 'smooth', block: 'center' }`).
20+
* - `highlightDelay`: Time to wait before highlighting to compensate for scrolling delay.
21+
* @returns Object with:
22+
* - `handleNewItem(id: string)`: Add an ID to be scrolled to and highlighted when it appears.
23+
* - `highlightedIds`: Reactive set of IDs currently highlighted.
24+
*/
25+
function useHighlightOnInsert<T>(
26+
items: Ref<Record<string, T>>,
27+
idGenerator: (id: string) => string,
28+
options: {
29+
historyStateKey?: string
30+
scrollBehavior?: ScrollIntoViewOptions
31+
highlightDelay?: 600
32+
} = {},
33+
) {
34+
// Default options
35+
const {
36+
historyStateKey = 'highlightId',
37+
scrollBehavior = {
38+
behavior: 'smooth',
39+
block: 'center',
40+
},
41+
} = options
42+
43+
const pendingIds = ref<Set<string>>(new Set())
44+
const highlightedIds = ref<Set<string>>(new Set())
45+
46+
// Handle highlighting after navigation
47+
onMounted(() => {
48+
const highlightId = history.state?.[historyStateKey]
49+
if (typeof highlightId === 'string') {
50+
pendingIds.value.add(highlightId)
51+
}
52+
})
53+
54+
// Watch for pending items to appear in the list
55+
watch(items, async (newItems, oldItems) => {
56+
for (const id of pendingIds.value) {
57+
if (newItems[id] && !oldItems?.[id]) {
58+
pendingIds.value.delete(id)
59+
await nextTick()
60+
61+
const el = document.getElementById(idGenerator(id))
62+
if (el) {
63+
el.scrollIntoView(scrollBehavior)
64+
// Unfortunately, scrollIntoView does not return a Promise
65+
setTimeout(() => {
66+
highlightedIds.value.add(id)
67+
el.addEventListener(
68+
'animationend',
69+
() => {
70+
highlightedIds.value.delete(id)
71+
},
72+
{ once: true },
73+
)
74+
}, options.highlightDelay)
75+
}
76+
}
77+
}
78+
})
79+
80+
return {
81+
handleNewItem: (id: string) => {
82+
pendingIds.value.add(id)
83+
},
84+
highlightedIds,
85+
}
86+
}
87+
88+
export default useHighlightOnInsert

frontend/src/composables/question/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
* (c) Technische Universität Berlin, innoCampus <[email protected]>
55
*/
66

7+
export { default as useCloneQuestion } from './useCloneQuestion'
78
export { default as useCreateQuestion } from './useCreateQuestion'
89
export { default as useDeleteAllQuestions } from './useDeleteAllQuestions'
910
export { default as useDeleteQuestion } from './useDeleteQuestion'
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/*
2+
* This file is part of the QuestionPy SDK. (https://questionpy.org)
3+
* The QuestionPy SDK is free software released under terms of the MIT license. See LICENSE.md.
4+
* (c) Technische Universität Berlin, innoCampus <[email protected]>
5+
*/
6+
7+
import { generateId } from '@/composables/composableUtils'
8+
import { usePostQuestionCloneMutation } from '@/queries'
9+
import useAppStateStore from '@/stores/useAppStateStore'
10+
11+
/**
12+
* Composable that returns a function to clone a question.
13+
*
14+
* @param questionId The ID of the question to clone.
15+
* @returns The ID of the new question.
16+
*/
17+
function useCloneQuestion(questionId: string) {
18+
const newQuestionId = generateId()
19+
const { mutateAsync } = usePostQuestionCloneMutation(questionId, newQuestionId)
20+
const { setError } = useAppStateStore()
21+
22+
return async () => {
23+
try {
24+
await mutateAsync()
25+
return newQuestionId
26+
} catch (err) {
27+
setError(err)
28+
}
29+
}
30+
}
31+
export default useCloneQuestion

frontend/src/pages/question/[questionId]/index.vue

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,13 @@
88
<IconButton :to="{ name: 'index' }" :icon-component="IMdiArrowLeft" class="ps-0 mb-2" variant="link"
99
>Back to Package Preview</IconButton
1010
>
11-
<QuestionCard class="mb-3" :question-id="params.questionId" :data="formData?.data" :error="detailedServerError" />
11+
<QuestionCard
12+
class="mb-3"
13+
:question-id="params.questionId"
14+
:data="formData?.data"
15+
:error="detailedServerError"
16+
@cloned="handleAttemptCloned"
17+
/>
1218
<ButtonGroup class="mb-4">
1319
<IconButton :icon-component="IMdiImport" variant="link" @click="importAttempt">Import attempt</IconButton>
1420
<IconButton :icon-component="IMdiAdd" @click="createAttempt" variant="primary">New attempt</IconButton>
@@ -27,7 +33,7 @@ import { useCreateAttempt } from '@/composables/attempt'
2733
import { FetchError, useOptionsFormDataQuery } from '@/queries'
2834
import type { DetailedServerError } from '@/types'
2935
30-
const route = useRouter()
36+
const router = useRouter()
3137
const { params } = useRoute('question')
3238
const { data: formData, error } = useOptionsFormDataQuery(params.questionId)
3339
const createAttempt = useCreateAttempt(params.questionId)
@@ -37,7 +43,7 @@ watch(
3743
formData,
3844
(value) => {
3945
if (value?.is_new) {
40-
route.replace({ name: 'index' })
46+
router.replace({ name: 'index' })
4147
}
4248
},
4349
{ immediate: true },
@@ -52,6 +58,14 @@ const detailedServerError = computed(() =>
5258
? ({ error: error.value.message, details: error.value.details } satisfies DetailedServerError)
5359
: undefined,
5460
)
61+
62+
const handleAttemptCloned = async (questionId: string) => {
63+
await router.push({
64+
name: 'index',
65+
// Tell QuestionList to scrollTo/highlight the new question
66+
state: { highlightQuestionId: questionId },
67+
})
68+
}
5569
</script>
5670

5771
<route lang="json">

frontend/src/queries/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,5 +20,6 @@ export {
2020
useOptionsFormDataQuery,
2121
useOptionsFormDefinitionQuery,
2222
usePostOptionsFormDataMutation,
23+
usePostQuestionCloneMutation,
2324
useQuestionStatesQuery,
2425
} from './question'

0 commit comments

Comments
 (0)