Skip to content

feat: more task editing #128

Merged
merged 76 commits into from
May 2, 2026
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
76 commits
Select commit Hold shift + click to select a range
75f4131
allow editing
Apr 23, 2026
a3b6f5c
add missing pages
Apr 23, 2026
b8b4c77
update fetchJson to accept empty json
Apr 23, 2026
a09df87
fix remove and approve pupils
Apr 23, 2026
b5d5ee4
show already added teachers
Apr 23, 2026
fd4da95
add task page with creation, deleting and editing
Apr 23, 2026
32f837c
remove comments
Apr 23, 2026
77b5e97
pull from main
Apr 24, 2026
dbfef3a
add edit and create task page
Apr 24, 2026
77b7fc9
add pending page
Apr 24, 2026
4d58f07
move store to model, update classroom table
Apr 24, 2026
c3f1f94
add confirmation modal
Apr 24, 2026
bacb60c
add option to delete classroom
Apr 24, 2026
b2134c7
remove +
Apr 24, 2026
eb8f3be
i18n
Apr 24, 2026
0924f13
i18n
Apr 24, 2026
3686fcd
update task view and creation
Apr 24, 2026
3e74af8
move task related classroom components to own folder
Apr 24, 2026
bf25d07
update tests
Apr 27, 2026
83324b9
update task view
Apr 27, 2026
dfbd1ef
update branch
Apr 27, 2026
b394459
update task view to match stops better
Apr 27, 2026
8dcd5c1
update task creation and editing
Apr 27, 2026
bcd880c
styling
Apr 27, 2026
3c3872e
update task view
Apr 27, 2026
b32e9f3
route to classroom details
Apr 27, 2026
9fcd05b
allow seeing unpublished tasks
Apr 28, 2026
c846ab1
update join routing to determine path based on membership
Apr 28, 2026
a4a2d6f
add option to publish and delete tasks
Apr 28, 2026
86e63a4
add task creation and updating to task store
Apr 28, 2026
05588d3
remove unused file
Apr 28, 2026
0ce327b
i18n
Apr 28, 2026
49ad2f3
Merge branch 'main' into classroom-control
marikola Apr 28, 2026
ed3ad86
reload en.ts
Apr 28, 2026
85846a8
update routing
Apr 28, 2026
c97c56e
update routing
Apr 28, 2026
9e4e45d
add notebook back to pupiltable
Apr 28, 2026
32ed411
add notebook back to pupiltable
Apr 28, 2026
1a1603e
fix test
Apr 28, 2026
0e50fbf
fix build errors
Apr 28, 2026
c2ae907
fix routing for join page
Apr 28, 2026
9c27b8e
i18n
Apr 29, 2026
933e7dc
remove double route
Apr 29, 2026
4509afe
route to task slug
Apr 29, 2026
9d4fd84
fix add teacher
Apr 29, 2026
b7f860f
update branch
Apr 29, 2026
886f722
fix merge breaks
Apr 29, 2026
46a52b5
formatting
Apr 29, 2026
e2535c1
update branch
Apr 30, 2026
a4573a7
formatting
Apr 30, 2026
261530d
change id to classroomId to match rest
Apr 30, 2026
2c698c9
comment
Apr 30, 2026
685fdbd
remove comment
Apr 30, 2026
ae89b3a
Merge branch 'main' into classroom-control
marikola Apr 30, 2026
4f418da
change id to classroomId
Apr 30, 2026
284ab3d
remove double add teacher logic
Apr 30, 2026
ec6a435
fixes
Apr 30, 2026
2767dce
Merge branch 'main' into classroom-control
marikola Apr 30, 2026
e342536
update names
Apr 30, 2026
184885f
Merge remote-tracking branch 'origin/classroom-control' into classroo…
Apr 30, 2026
1e86807
fix test
Apr 30, 2026
778be66
format
Apr 30, 2026
89b12aa
add options for different types of tasks
eilifhl Apr 30, 2026
8d3778a
add back targets
Apr 30, 2026
00aa31a
format
Apr 30, 2026
726c516
add memebrshipStatus where missing
Apr 30, 2026
2204449
add image uploading
eilifhl Apr 30, 2026
7da270a
Merge branch 'classroom-control' of git.ntnu.no:idatt2106-v26-04/fron…
eilifhl Apr 30, 2026
f716d23
format
eilifhl Apr 30, 2026
c12ae8a
lint
eilifhl Apr 30, 2026
70fcec8
Merge branch 'main' of git.ntnu.no:idatt2106-v26-04/frontend into fea…
eilifhl May 1, 2026
6a14e28
hide imageselect tasks
eilifhl May 2, 2026
09275e7
readd things left to find
eilifhl May 2, 2026
a0eb412
avoid navigating withoiut slug
eilifhl May 2, 2026
122639a
clear task item metadata when changing type
eilifhl May 2, 2026
d042284
increment difficulty if new stop already has difficulty
eilifhl May 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 106 additions & 3 deletions src/classrooms/components/tasks/TaskItemsEditor.vue
Original file line number Diff line number Diff line change
@@ -1,18 +1,69 @@
<script setup lang="ts">
import type { TaskItemResponse, TaskType } from '@/stops/api/stops.api.ts'
import { computed, reactive } from 'vue'
import { useAuthStore } from '@/auth/model/auth.store'
import type { TaskType } from '@/stops/api/stops.api.ts'
import type { TaskEditorItemDraft } from '@/classrooms/components/tasks/model/task-editor.types'
import TaskOptionEditor from '@/classrooms/components/tasks/TaskOptionEditor.vue'
import { requestUploadTaskMedia } from '@/stops/api/stops.api.ts'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const authStore = useAuthStore()
defineProps<{
items: TaskItemResponse[]
const props = defineProps<{
items: TaskEditorItemDraft[]
classroomId: number
taskType: TaskType
}>()
const emit = defineEmits<{
remove: [index: number]
}>()
const uploadingByItemId = reactive<Record<number, boolean>>({})
const uploadErrorByItemId = reactive<Record<number, string | null>>({})
const allowedUploadTypes = 'image/jpeg,image/png,image/gif,image/webp'
const isImageTask = computed(
() =>
props.taskType === 'IMAGE_ASSESSMENT' || props.taskType === 'IMAGE_SELECT',
)
async function onFileSelected(item: TaskEditorItemDraft, event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0] ?? null
uploadErrorByItemId[item.id] = null
if (!file) return
if (!authStore.authorizationHeader) {
uploadErrorByItemId[item.id] = t('newTask.uploadAuth')
input.value = ''
return
}
uploadingByItemId[item.id] = true
try {
const result = await requestUploadTaskMedia(
props.classroomId,
authStore.authorizationHeader,
file,
)
item.mediaUrl = result.url
} catch (e) {
uploadErrorByItemId[item.id] =
e instanceof Error ? e.message : t('newTask.uploadFail')
} finally {
uploadingByItemId[item.id] = false
input.value = ''
}
}
function clearImage(item: TaskEditorItemDraft) {
item.mediaUrl = null
uploadErrorByItemId[item.id] = null
}
</script>

<template>
Expand Down Expand Up @@ -52,6 +103,58 @@ const emit = defineEmits<{
/>
</div>

<div
v-if="isImageTask"
class="space-y-3 rounded-xl border bg-slate-50 p-4"
>
<div class="flex items-center justify-between gap-3">
<p class="text-xs font-semibold uppercase text-gray-500">
{{ t('newTask.image') }}
</p>
<label
class="inline-flex cursor-pointer items-center rounded-lg border border-blue-200 bg-white px-3 py-2 text-sm font-medium text-blue-700 hover:bg-blue-50"
>
<input
type="file"
class="hidden"
:accept="allowedUploadTypes"
:disabled="uploadingByItemId[item.id]"
@change="onFileSelected(item, $event)"
/>
{{
uploadingByItemId[item.id]
? t('newTask.uploading')
: t('newTask.uploadImage')
}}
</label>
</div>

<input
v-model="item.mediaUrl"
:placeholder="t('newTask.imageUrl')"
class="input"
/>

<p v-if="uploadErrorByItemId[item.id]" class="text-sm text-red-600">
{{ uploadErrorByItemId[item.id] }}
</p>

<div v-if="item.mediaUrl" class="space-y-3">
<img
:src="item.mediaUrl"
alt=""
class="max-h-72 w-full rounded-xl border border-slate-200 object-contain bg-white"
/>

<button
type="button"
class="text-sm font-medium text-red-600 hover:text-red-700"
@click="clearImage(item)"
>
{{ t('newTask.removeImage') }}
</button>
</div>
</div>
<div class="pt-2">
<TaskOptionEditor v-if="item.options" v-model="item.options" />
</div>
Expand Down
119 changes: 80 additions & 39 deletions src/classrooms/components/tasks/TaskMetaEditor.vue
Original file line number Diff line number Diff line change
@@ -1,26 +1,37 @@
<script setup lang="ts">
import type { TaskResponse } from '@/stops/api/stops.api.ts'
import { computed } from 'vue'
import type { TaskEditorDraft } from '@/classrooms/components/tasks/model/task-editor.types'
import {
changeDraftTaskType,
createEmptyTaskDraft,
} from '@/classrooms/components/tasks/model/task-editor.utils'
import type { TaskType } from '@/stops/api/stops.api'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const form = defineModel<TaskResponse>({
default: () => ({
id: 0,
classroomId: 0,
stopId: 0,
createdByTeacherId: 0,
title: { en: '', nb: '' },
introText: { en: '', nb: '' },
difficultyLevel: 1,
taskType: 'GENERIC',
passingRule: 'ALL_CORRECT',
passingScore: null,
maxScore: 1,
published: false,
items: [],
}),
const form = defineModel<TaskEditorDraft>({
default: () => createEmptyTaskDraft(0),
})
const taskTypeOptions: Array<{ value: TaskType; label: string }> = [
{ value: 'NEWS_COMPARISON', label: 'newTask.news' },
{ value: 'IMAGE_ASSESSMENT', label: 'newTask.imageAssessment' },
{ value: 'PASSWORD_EVALUATION', label: 'newTask.passwordEvaluation' },
{ value: 'SOCIAL_SCENARIO', label: 'newTask.socialScenario' },
]
const selectedTaskType = computed({
get: () => form.value.taskType,
set: (taskType: TaskType) => {
form.value = changeDraftTaskType(form.value, taskType)
},
})
const localizedFieldClasses =
'w-full rounded-xl border border-slate-300 bg-slate-50 px-4 py-3 text-lg text-slate-900 shadow-sm transition focus:border-blue-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-blue-100'
const localizedTextareaClasses = `${localizedFieldClasses} min-h-36 resize-y leading-relaxed`
</script>

<template>
Expand All @@ -29,37 +40,67 @@ const form = defineModel<TaskResponse>({

<div class="space-y-2">
<p class="text-xs uppercase text-gray-500">Title</p>
<input
v-model="form.title.en"
:placeholder="t('newTask.et')"
class="input"
/>
<input
v-model="form.title.nb"
:placeholder="t('newTask.nt')"
class="input"
/>
<div class="grid gap-3 lg:grid-cols-2">
<label class="space-y-2">
<span class="text-sm font-medium text-slate-600">
{{ t('app.languages.en') }}
</span>
<input
v-model="form.title.en"
:placeholder="t('newTask.et')"
:class="localizedFieldClasses"
/>
</label>
<label class="space-y-2">
<span class="text-sm font-medium text-slate-600">
{{ t('app.languages.nb') }}
</span>
<input
v-model="form.title.nb"
:placeholder="t('newTask.nt')"
:class="localizedFieldClasses"
/>
</label>
</div>
</div>

<div class="space-y-2">
<p class="text-xs uppercase text-gray-500">Introduction</p>
<textarea
v-model="form.introText.en"
:placeholder="t('newTask.ei')"
class="input"
/>
<textarea
v-model="form.introText.nb"
:placeholder="t('newTask.ni')"
class="input"
/>
<div class="grid gap-3 lg:grid-cols-2">
<label class="space-y-2">
<span class="text-sm font-medium text-slate-600">
{{ t('app.languages.en') }}
</span>
<textarea
v-model="form.introText.en"
:placeholder="t('newTask.ei')"
:class="localizedTextareaClasses"
/>
</label>
<label class="space-y-2">
<span class="text-sm font-medium text-slate-600">
{{ t('app.languages.nb') }}
</span>
<textarea
v-model="form.introText.nb"
:placeholder="t('newTask.ni')"
:class="localizedTextareaClasses"
/>
</label>
</div>
</div>

<div class="grid grid-cols-2 gap-3">
<div class="space-y-2">
<p class="text-xs uppercase text-gray-500">Type</p>
<select v-model="form.taskType" class="input">
<option value="NEWS_COMPARISON">{{ t('newTask.news') }}</option>
<select v-model="selectedTaskType" class="input">
<option
v-for="option in taskTypeOptions"
:key="option.value"
:value="option.value"
>
{{ t(option.label) }}
</option>
</select>
</div>

Expand Down
6 changes: 3 additions & 3 deletions src/classrooms/components/tasks/TaskOptionEditor.vue
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
<script setup lang="ts">
import type { TaskOptionResponse } from '@/stops/api/stops.api.ts'
import type { TaskEditorOptionDraft } from '@/classrooms/components/tasks/model/task-editor.types'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const options = defineModel<TaskOptionResponse[]>({
const options = defineModel<TaskEditorOptionDraft[]>({
default: () => [],
})
function ensureExplanation(opt: TaskOptionResponse) {
function ensureExplanation(opt: TaskEditorOptionDraft) {
if (!opt.explanationText) {
opt.explanationText = { en: '', nb: '' }
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { describe, expect, it } from 'vitest'

import { taskEditorDraftToTaskForm } from '@/classrooms/components/tasks/model/task-editor.mappers'
import type { NewsComparisonDraft } from '@/classrooms/components/tasks/model/task-editor.types'
import { changeDraftTaskType } from '@/classrooms/components/tasks/model/task-editor.utils'

function createNewsComparisonDraft(): NewsComparisonDraft {
return {
id: 1,
classroomId: 2,
stopId: 3,
createdByTeacherId: 4,
title: { en: 'Title', nb: 'Tittel' },
introText: { en: '', nb: '' },
difficultyLevel: 1,
taskType: 'NEWS_COMPARISON',
passingRule: 'ALL_CORRECT',
passingScore: null,
maxScore: 1,
published: false,
items: [
{
id: 5,
itemOrder: 1,
promptText: { en: 'Prompt', nb: 'Ledetekst' },
mediaUrl: null,
metadataJson: JSON.stringify({
articles: [
{
optionOrder: 1,
domain: 'example.test',
headline: { en: 'Old', nb: 'Gammel' },
body: null,
mediaUrl: null,
},
],
}),
options: [],
},
],
parsedMetadata: {
articlesByItemOrder: {
1: [
{
optionOrder: 1,
domain: 'example.test',
headline: { en: 'Old', nb: 'Gammel' },
body: null,
mediaUrl: null,
},
],
},
},
}
}

describe('changeDraftTaskType', () => {
it('clears item metadata when changing task type', () => {
const draft = changeDraftTaskType(
createNewsComparisonDraft(),
'PASSWORD_EVALUATION',
)

expect(draft.items[0]?.metadataJson).toBeNull()
expect(taskEditorDraftToTaskForm(draft).items[0]?.metadataJson).toBeNull()
})

it('preserves item metadata when keeping the same task type', () => {
const original = createNewsComparisonDraft()
const draft = changeDraftTaskType(original, 'NEWS_COMPARISON')

expect(draft.items[0]?.metadataJson).toBe(original.items[0]?.metadataJson)
})
})
Loading