Skip to content

feat: classroom control #69

Merged
merged 65 commits into from
Apr 30, 2026
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
65 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
8d3778a
add back targets
Apr 30, 2026
00aa31a
format
Apr 30, 2026
726c516
add memebrshipStatus where missing
Apr 30, 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
2 changes: 2 additions & 0 deletions src/app/__tests__/AppShell.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ describe('AppShell', () => {
currentLevel: 1,
currentClassroomId: 1,
hasCompletedOnboarding: true,
membershipStatus: 'ACTIVE',
},
})

Expand All @@ -262,6 +263,7 @@ describe('AppShell', () => {
currentLevel: 1,
currentClassroomId: 1,
hasCompletedOnboarding: true,
membershipStatus: 'ACTIVE',
},
})

Expand Down
5 changes: 5 additions & 0 deletions src/app/router/__tests__/auth-routing.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ function authenticatedUser(
currentLevel: 1,
currentClassroomId: null,
hasCompletedOnboarding: true,
membershipStatus: null,
}
: null,
teacherProfile: null,
Expand Down Expand Up @@ -209,6 +210,7 @@ describe('authenticated routing', () => {
displayName: '',
totalXp: 0,
currentLevel: 0,
membershipStatus: 'ACTIVE',
hasCompletedOnboarding: true,
},
})
Expand All @@ -232,6 +234,7 @@ describe('authenticated routing', () => {
currentLevel: 1,
currentClassroomId: null,
hasCompletedOnboarding: false,
membershipStatus: null,
},
})

Expand All @@ -254,6 +257,7 @@ describe('authenticated routing', () => {
currentLevel: 1,
currentClassroomId: 101,
hasCompletedOnboarding: false,
membershipStatus: 'ACTIVE',
},
})

Expand All @@ -276,6 +280,7 @@ describe('authenticated routing', () => {
currentLevel: 1,
currentClassroomId: 101,
hasCompletedOnboarding: false,
membershipStatus: 'ACTIVE',
},
})

Expand Down
88 changes: 40 additions & 48 deletions src/app/router/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,13 @@ export function createAppRouter(
return true
}

const user = authStore.user
const portalType = authStore.portalType

const hasCompletedOnboarding =
authStore.user?.pupilProfile?.hasCompletedOnboarding
const classroomId = authStore.user?.pupilProfile?.currentClassroomId
const membershipStatus = user?.pupilProfile?.membershipStatus ?? null
const hasClassroom = classroomId !== null && classroomId !== undefined
const isGuestOnly = to.matched.some((record) => record.meta.guestOnly)
if (routeAccess === 'public' && isGuestOnly) {
Expand All @@ -97,66 +101,54 @@ export function createAppRouter(
})
}

if (!canAccessRoute(to, authStore.portalType)) {
return resolveAuthenticatedLanding({
portalType: authStore.portalType,
hasCompletedOnboarding,
hasClassroom,
})
const isJoinPage = to.name === 'pupil-join-classroom'
const isPendingPage = to.name === 'pupil-pending-approval'
const isOnboardingPage = to.name === 'pupil-onboarding'

if (portalType === 'PUPIL') {
if (membershipStatus === null || membershipStatus === undefined) {
if (!isJoinPage) {
return { name: 'pupil-join-classroom' }
}
return true
}
}

if (
authStore.portalType === 'PUPIL' &&
!hasClassroom &&
to.name !== 'pupil-join-classroom'
) {
return { name: 'pupil-join-classroom' }
if (membershipStatus === 'PENDING') {
if (!isPendingPage) {
return { name: 'pupil-pending-approval' }
}
return true
}

if (
authStore.portalType === 'PUPIL' &&
hasCompletedOnboarding === true &&
to.name === 'pupil-onboarding'
) {
if (membershipStatus === 'ACTIVE') {
if (!hasCompletedOnboarding) {
if (!isOnboardingPage) {
return { name: 'pupil-onboarding' }
}
return true
}

if (isJoinPage || isPendingPage || isOnboardingPage) {
return { name: 'pupil-home' }
}
}
if (!canAccessRoute(to, portalType)) {
return resolveAuthenticatedLanding({
portalType: authStore.portalType,
portalType,
hasCompletedOnboarding,
hasClassroom,
hasClassroom: !!classroomId,
})
}

if (
authStore.portalType === 'PUPIL' &&
hasCompletedOnboarding === false &&
hasClassroom &&
!to.matched.some((record) => record.meta.allowIncompleteOnboarding)
) {
return resolveAuthenticatedLanding({
portalType: authStore.portalType,
const isGuestOnlyRoute = to.matched.some((r) => r.meta.guestOnly)
if (routeAccess === 'public' && isGuestOnlyRoute) {
return resolvePostLoginLocation(router, to.query.redirect, {
portalType,
hasCompletedOnboarding,
hasClassroom,
hasClassroom: !!classroomId,
})
}

if (
authStore.isAuthenticated &&
authStore.portalType === 'PUPIL' &&
hasCompletedOnboarding !== false
) {
const user = authStore.user
if (!user) return true

const isJoinPage = to.name === 'pupil-join-classroom'

if (!hasClassroom && !isJoinPage) {
return { name: 'pupil-join-classroom' }
}

if (hasClassroom && isJoinPage) {
return { name: 'pupil-home' }
}
}

return true
})

Expand Down
12 changes: 11 additions & 1 deletion src/auth/api/auth.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,17 @@ export async function fetchJson<T>(
return undefined as T
}

return (await response.json()) as T
const text = await response.text()

if (!text) {
return undefined as T
}

try {
return JSON.parse(text) as T
} catch {
throw new Error('invalid response from server')
}
}

export function requestCsrfToken() {
Expand Down
1 change: 1 addition & 0 deletions src/auth/model/auth.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export interface PupilProfile {
totalXp: number
currentLevel: number
currentClassroomId: number | null
membershipStatus: string | null
hasCompletedOnboarding: boolean
}

Expand Down
1 change: 1 addition & 0 deletions src/auth/pages/__tests__/login-pages.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ describe('login form validation', () => {
displayName: '',
totalXp: 0,
currentLevel: 0,
membershipStatus: 'ACTIVE',
hasCompletedOnboarding: true,
},
teacherProfile: null,
Expand Down
1 change: 1 addition & 0 deletions src/avatar/components/__tests__/PupilAgentCard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ describe('PupilAgentCard', () => {
currentLevel: 1,
currentClassroomId: 1,
hasCompletedOnboarding: true,
membershipStatus: 'ACTIVE',
},
teacherProfile: null,
}
Expand Down
2 changes: 2 additions & 0 deletions src/avatar/model/__tests__/avatar.store.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ describe('useAvatarStore', () => {
currentLevel: 1,
currentClassroomId: 2,
hasCompletedOnboarding: true,
membershipStatus: 'ACTIVE',
},
teacherProfile: null,
}
Expand Down Expand Up @@ -222,6 +223,7 @@ describe('useAvatarStore', () => {
currentLevel: 1,
currentClassroomId: 2,
hasCompletedOnboarding: true,
membershipStatus: 'ACTIVE',
},
teacherProfile: null,
}
Expand Down
44 changes: 33 additions & 11 deletions src/classrooms/_tests_/classrooms.store.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'

import { useClassroomsStore } from '@/classrooms/store/classrooms.store.ts'
import { useClassroomsStore } from '@/classrooms/model/classrooms.store.ts'
import { useAuthStore } from '@/auth/model/auth.store.ts'

import * as api from '@/classrooms/api/classrooms.api'
Expand All @@ -15,6 +15,7 @@ describe('classrooms store', () => {
function mockAuth() {
const auth = useAuthStore()
auth.accessToken = 'token'
auth.fetchCurrentUserProfile = vi.fn()
auth.user = {
subject: 'teacher-1',
portalType: 'TEACHER',
Expand All @@ -39,6 +40,9 @@ describe('classrooms store', () => {
ownerTeacherId: 1,
teacherEmails: [],
joinCode: 'ABC123',
pupilCount: 0,
pendingPupilCount: 0,
teacherCount: 1,
},
])

Expand All @@ -61,6 +65,9 @@ describe('classrooms store', () => {
ownerTeacherId: 1,
teacherEmails: [],
joinCode: 'XYZ321',
pupilCount: 0,
pendingPupilCount: 0,
teacherCount: 1,
})

const store = useClassroomsStore()
Expand All @@ -86,6 +93,9 @@ describe('classrooms store', () => {
ownerTeacherId: 1,
teacherEmails: [],
joinCode: 'ABC123',
pupilCount: 0,
pendingPupilCount: 0,
teacherCount: 1,
})

const store = useClassroomsStore()
Expand All @@ -101,11 +111,25 @@ describe('classrooms store', () => {

vi.spyOn(api, 'requestGetClassrooms').mockRejectedValue(new Error('fail'))

const spy = vi.spyOn(api, 'requestJoinClassroom').mockResolvedValue({
classroomId: 1,
title: 'Joined',
description: '',
teacherEmails: [],
joinCode: 'ABC123',
pupilCount: 0,
pendingPupilCount: 0,
teacherCount: 1,
status: 'ACTIVE',
ownerTeacherId: 1,
})

const store = useClassroomsStore()

await store.fetchClassrooms()
const result = await store.joinClassroom('ABC123')

expect(store.errorMessage).toBe('fail')
expect(spy).toHaveBeenCalled()
expect(result.title).toBe('Joined')
})

it('handles join classroom error', async () => {
Expand All @@ -131,6 +155,9 @@ describe('classrooms store', () => {
ownerTeacherId: 1,
teacherEmails: [],
joinCode: 'A',
pupilCount: 0,
pendingPupilCount: 0,
teacherCount: 1,
},
])
.mockResolvedValueOnce([
Expand All @@ -142,15 +169,10 @@ describe('classrooms store', () => {
ownerTeacherId: 1,
teacherEmails: [],
joinCode: 'B',
pupilCount: 0,
pendingPupilCount: 0,
teacherCount: 1,
},
])

const store = useClassroomsStore()

await store.fetchClassrooms()
await store.fetchClassrooms()

expect(store.classrooms.length).toBe(1)
expect(store.classrooms[0]!.title).toBe('New')
})
})
2 changes: 2 additions & 0 deletions src/classrooms/_tests_/pupil-routing.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,15 @@ describe('join classroom routing', () => {
totalXp: 0,
currentLevel: 1,
currentClassroomId: 1,
membershipStatus: 'ACTIVE',
hasCompletedOnboarding: true,
}
: {
displayName: 'Test',
totalXp: 0,
currentLevel: 1,
currentClassroomId: null,
membershipStatus: null,
hasCompletedOnboarding: true,
},
teacherProfile: null,
Expand Down
Loading