Skip to content

Commit

Permalink
feat: more task editing (#128)
Browse files Browse the repository at this point in the history
* allow editing

* add missing pages

* update fetchJson to accept empty json

* fix remove and approve pupils

* show already added teachers

* add task page with creation, deleting and editing

* remove comments

* add edit and create task page

* add pending page

* move store to model, update classroom table

* add confirmation modal

* add option to delete classroom

* remove +

* i18n

* i18n

* update task view and creation

* move task related classroom components to own folder

* update tests

* update task view

* update task view to match stops better

* update task creation and editing

* styling

* update task view

* route to classroom details

* allow seeing unpublished tasks

* update join routing to determine path based on membership

* add option to publish and delete tasks

* add task creation and updating to task store

* remove unused file

* i18n

* reload en.ts

* update routing

* update routing

* add notebook back to pupiltable

* add notebook back to pupiltable

* fix test

* fix build errors

* fix routing for join page

* i18n

* remove double route

* route to task slug

* fix add teacher

* fix merge breaks

* formatting

* formatting

* change id to classroomId to match rest

* comment

* remove comment

* change id to classroomId

* remove double add teacher logic

* fixes

* update names

* fix test

* format

* add options for different types of tasks

* add back targets

* format

* add memebrshipStatus where missing

* add image uploading

* format

* lint

* hide imageselect tasks

* readd things left to find

* avoid navigating withoiut slug

* clear task item metadata when changing type

* increment difficulty if new stop already has difficulty

---------

Co-authored-by: Maria <145002050+marikolafs@users.noreply.github.com>
Co-authored-by: Maria Kristin Olafsdottir <marikola@stud.ntnu.no>
  • Loading branch information
3 people authored May 2, 2026
1 parent efc80df commit 7ccd56a
Show file tree
Hide file tree
Showing 15 changed files with 1,152 additions and 430 deletions.
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

0 comments on commit 7ccd56a

Please sign in to comment.