Skip to content

Commit

Permalink
feat: classroom control (#69)
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 back targets

* format

* add memebrshipStatus where missing

---------

Co-authored-by: Maria <145002050+marikolafs@users.noreply.github.com>
  • Loading branch information
marikola and Maria authored Apr 30, 2026
1 parent fc79444 commit 36bf196
Show file tree
Hide file tree
Showing 46 changed files with 2,033 additions and 215 deletions.
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

0 comments on commit 36bf196

Please sign in to comment.