Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
39 changes: 39 additions & 0 deletions routes/create/__tests__/integration/folders.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,45 @@ describe('Folder Management API Integration Tests', () => {
expect(response.body.success).toBe(false);
expect(response.body.error.message).toContain('already exists');
});

test('should link, change and unlink the Canvas course without renaming', async () => {
await request(app).put(`/api/folders/${folderId}`).send({ canvasCourseId: '42' }).expect(200);
let folderInDb = await Folder.findById(folderId);
expect(folderInDb.canvasCourseId).toBe('42');
expect(folderInDb.name).toBe('Original Name');

await request(app).put(`/api/folders/${folderId}`).send({ canvasCourseId: '77' }).expect(200);
folderInDb = await Folder.findById(folderId);
expect(folderInDb.canvasCourseId).toBe('77');

await request(app).put(`/api/folders/${folderId}`).send({ canvasCourseId: null }).expect(200);
folderInDb = await Folder.findById(folderId);
expect(folderInDb.canvasCourseId).toBeNull();
});

test('should keep the Canvas link when only the name changes', async () => {
await Folder.updateOne({ _id: folderId }, { canvasCourseId: '42' });
await request(app).put(`/api/folders/${folderId}`).send({ name: 'Renamed' }).expect(200);
const folderInDb = await Folder.findById(folderId);
expect(folderInDb.canvasCourseId).toBe('42');
});

test('should accept a numeric Canvas course ID as Canvas returns it', async () => {
await request(app).put(`/api/folders/${folderId}`).send({ canvasCourseId: 42 }).expect(200);
const folderInDb = await Folder.findById(folderId);
expect(folderInDb.canvasCourseId).toBe('42');
});

test('should reject an invalid Canvas course ID', async () => {
for (const canvasCourseId of ['', { id: 42 }, 4.2]) {
const response = await request(app)
.put(`/api/folders/${folderId}`)
.send({ canvasCourseId })
.expect(400);

expect(response.body.success).toBe(false);
}
});
});

describe('DELETE /api/folders/:id', () => {
Expand Down
11 changes: 7 additions & 4 deletions routes/create/controllers/folderController.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ router.get('/', authenticateToken, attachUser, asyncHandler(async (req, res) =>
* Create new folder with auto-generated quizzes
*/
router.post('/', authenticateToken, validateCreateFolder, asyncHandler(async (req, res) => {
const { name, quizCount = 1, canvasCourseId = null, canvasModuleId = null } = req.body;
const { name, quizCount = 1, canvasCourseId = null } = req.body;
const userId = req.user.id;
console.log('➕ POST /folders - Creating folder:', name, 'with', quizCount, 'quizzes for user:', userId);

Expand Down Expand Up @@ -66,8 +66,7 @@ router.post('/', authenticateToken, validateCreateFolder, asyncHandler(async (re
const folder = new Folder({
name: name.trim(),
instructor: userId,
canvasCourseId,
canvasModuleId
canvasCourseId
});

await folder.save();
Expand Down Expand Up @@ -144,7 +143,7 @@ router.get('/:id', authenticateToken, validateMongoId, asyncHandler(async (req,

/**
* PUT /api/folders/:id
* Update folder name
* Update folder name and/or linked Canvas course (null unlinks)
*/
router.put('/:id', authenticateToken, validateUpdateFolder, asyncHandler(async (req, res) => {
const folderId = req.params.id;
Expand Down Expand Up @@ -177,6 +176,10 @@ router.put('/:id', authenticateToken, validateUpdateFolder, asyncHandler(async (
folder.name = name.trim();
}

if (Object.prototype.hasOwnProperty.call(req.body, 'canvasCourseId')) {
folder.canvasCourseId = req.body.canvasCourseId || null;
}

await folder.updateStats();

return successResponse(res, { folder }, 'Folder updated successfully');
Expand Down
8 changes: 4 additions & 4 deletions routes/create/controllers/quizController.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ router.get('/:id', authenticateToken, validateMongoId, asyncHandler(async (req,
const userId = req.user.id;

const quiz = await Quiz.findOne({ _id: quizId, createdBy: userId })
.populate('folder', 'name')
.populate('folder', 'name canvasCourseId')
.populate({
path: 'materials',
select: 'name type processingStatus fileSize createdAt'
Expand Down Expand Up @@ -146,7 +146,7 @@ router.put('/:id/review', authenticateToken, validateMongoId, asyncHandler(async
...freeQuestionMutation(new Date()),
'questionGenerationLease.token': { $exists: false }
}, { $set: { 'progress.reviewCompleted': true }, $inc: { __v: 1 } }, { new: true })
.populate('folder', 'name')
.populate('folder', 'name canvasCourseId')
.populate('materials', 'name type processingStatus fileSize createdAt')
.populate({ path: 'learningObjectives', select: 'text order generationMetadata createdAt', options: { sort: { order: 1 } } })
.populate({ path: 'questions', select: 'type difficulty questionText reviewStatus order createdAt', options: { sort: { order: 1 } } })
Expand Down Expand Up @@ -212,7 +212,7 @@ router.put('/:id', authenticateToken, validateUpdateQuiz, asyncHandler(async (re
});

await quiz.save();
await quiz.populate('folder', 'name');
await quiz.populate('folder', 'name canvasCourseId');

return successResponse(res, { quiz }, 'Quiz updated successfully');
}));
Expand Down Expand Up @@ -319,7 +319,7 @@ router.put('/:id/materials', authenticateToken, validateAssignMaterials, asyncHa
);

const updatedQuiz = await Quiz.findById(quizId)
.populate('folder', 'name')
.populate('folder', 'name canvasCourseId')
.populate('materials', 'name type processingStatus');

return successResponse(res, { quiz: updatedQuiz }, 'Materials assigned successfully');
Expand Down
10 changes: 10 additions & 0 deletions routes/create/middleware/validator.js
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,16 @@ export const validateUpdateFolder = [
.trim()
.isLength({ min: 1, max: 200 })
.withMessage('Folder name must be between 1 and 200 characters'),

// Canvas returns numeric course IDs; store them as strings like the Folder schema.
body('canvasCourseId')
.optional({ nullable: true })
.custom(value => typeof value === 'string' || Number.isInteger(value))
.withMessage('Canvas course ID must be a string, an integer or null')
.bail()
.customSanitizer(value => String(value).trim())
.isLength({ min: 1, max: 64 })
.withMessage('Canvas course ID must be between 1 and 64 characters'),

handleValidationErrors
];
Expand Down
1 change: 1 addition & 0 deletions routes/create/models/Folder.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const folderSchema = new mongoose.Schema({

// Canvas integration (optional — linked Canvas course and module)
canvasCourseId: { type: String, default: null },
// Legacy: set by old course creation; no longer written or read.
canvasModuleId: { type: String, default: null },

// Folder Statistics (for dashboard)
Expand Down
31 changes: 30 additions & 1 deletion src/components/CanvasMaterialImportModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import CanvasMaterialImportModal from './CanvasMaterialImportModal';
const mocks = vi.hoisted(() => ({ getConfig: vi.fn(), getAuthStatus: vi.fn(), getImportCourses: vi.fn(), getImportMaterials: vi.fn(), importMaterials: vi.fn(), getConnectUrl: vi.fn() }));
vi.mock('../services/api', () => ({ canvasApi: mocks }));
const folderMocks = vi.hoisted(() => ({ setLinkedCanvasCourse: vi.fn() }));
vi.mock('../services/api', () => ({ canvasApi: mocks, foldersApi: folderMocks }));
const resources = [
{ id: '4', resourceType: 'file', name: 'Lecture.pdf', materialType: 'pdf', supported: true },
{ id: '1', resourceType: 'page', name: 'Lesson page', materialType: 'text', supported: true },
Expand Down Expand Up @@ -65,6 +66,34 @@ describe('Canvas material import', () => {
expect(screen.getByText('No materials could be listed. Review the access messages above.')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Reconnect Canvas' })).toBeInTheDocument();
});
it('preselects the linked Canvas course and loads its materials', async () => {
render(<CanvasMaterialImportModal isOpen folderId="folder" linkedCanvasCourseId="2" onClose={vi.fn()} />);
await screen.findByText('Lecture.pdf');
expect(screen.getByRole('combobox', { name: 'Canvas course' })).toHaveValue('2');
expect(mocks.getImportMaterials).toHaveBeenCalledWith('folder', '2');
});
it('saves a newly chosen course as the linked course', async () => {
folderMocks.setLinkedCanvasCourse.mockResolvedValue({ folder: {} });
const onLinked = vi.fn();
render(<CanvasMaterialImportModal isOpen folderId="folder" onLinkedCanvasCourseChange={onLinked} onClose={vi.fn()} />);
await screen.findByRole('combobox', { name: 'Canvas course' });
expect(screen.queryByRole('button', { name: 'Set as linked course' })).not.toBeInTheDocument();
fireEvent.change(screen.getByRole('combobox'), { target: { value: '2' } });
fireEvent.click(await screen.findByRole('button', { name: 'Set as linked course' }));
await waitFor(() => expect(onLinked).toHaveBeenCalledWith('2'));
expect(folderMocks.setLinkedCanvasCourse).toHaveBeenCalledWith('folder', '2');
});
it('does not offer to link the course that is already linked', async () => {
render(<CanvasMaterialImportModal isOpen folderId="folder" linkedCanvasCourseId="2" onLinkedCanvasCourseChange={vi.fn()} onClose={vi.fn()} />);
await screen.findByText('Lecture.pdf');
expect(screen.queryByRole('button', { name: 'Set as linked course' })).not.toBeInTheDocument();
});
it('falls back to the course list when the linked course is not available', async () => {
render(<CanvasMaterialImportModal isOpen folderId="folder" linkedCanvasCourseId="99" onClose={vi.fn()} />);
await screen.findByText(/linked to this course isn't available/);
expect(screen.getByRole('combobox', { name: 'Canvas course' })).toHaveValue('');
expect(mocks.getImportMaterials).not.toHaveBeenCalled();
});
it('explains unavailable configuration and handles blocked sign-in popups', async () => {
mocks.getAuthStatus.mockResolvedValue({ data: { connected: false } });
vi.spyOn(window, 'open').mockReturnValue(null);
Expand Down
33 changes: 29 additions & 4 deletions src/components/CanvasMaterialImportModal.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
import { useEffect, useRef, useState } from 'react';
import { canvasApi, CanvasImportResource, CanvasImportReport } from '../services/api';
import { canvasApi, foldersApi, CanvasImportResource, CanvasImportReport } from '../services/api';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog';
import '../styles/components/CanvasMaterialImportModal.css';

interface Props {
isOpen: boolean;
folderId: string;
/** Canvas course linked to this CREATE course; preselected when the user can still access it. */
linkedCanvasCourseId?: string | null;
/** Called after the user saves a different Canvas course as this course's link. */
onLinkedCanvasCourseChange?: (canvasCourseId: string) => void;
onClose: () => void;
onImported?: () => void | Promise<void>;
}
const key = (resource: Pick<CanvasImportResource, 'id' | 'resourceType'>) => `${resource.resourceType}:${resource.id}`;

export default function CanvasMaterialImportModal({ isOpen, folderId, onClose, onImported }: Props) {
export default function CanvasMaterialImportModal({ isOpen, folderId, linkedCanvasCourseId, onLinkedCanvasCourseChange, onClose, onImported }: Props) {
const [state, setState] = useState<'loading' | 'unavailable' | 'connect' | 'ready'>('loading');
const [courses, setCourses] = useState<Array<{ id: string; name: string; courseCode: string }>>([]);
const [courseId, setCourseId] = useState('');
Expand All @@ -23,6 +27,8 @@ export default function CanvasMaterialImportModal({ isOpen, folderId, onClose, o
const [importing, setImporting] = useState(false);
const [error, setError] = useState('');
const [warnings, setWarnings] = useState<string[]>([]);
const [linkedUnavailable, setLinkedUnavailable] = useState(false);
const [linking, setLinking] = useState(false);
const [limit, setLimit] = useState(10);
const [maxBytes, setMaxBytes] = useState(50 * 1024 * 1024);
const [report, setReport] = useState<CanvasImportReport | null>(null);
Expand All @@ -41,8 +47,12 @@ export default function CanvasMaterialImportModal({ isOpen, folderId, onClose, o
if (!auth.data?.connected) { setState('connect'); return; }
const result = await canvasApi.getImportCourses(folderId);
if (current !== requestId.current) return;
setCourses(result.data?.courses || []); setState('ready');
const available = result.data?.courses || [];
setCourses(available); setState('ready');
setCourseId(''); setResources([]); setSelected(new Set()); setReport(null);
const linked = linkedCanvasCourseId ? available.find(course => String(course.id) === String(linkedCanvasCourseId)) : undefined;
setLinkedUnavailable(!!linkedCanvasCourseId && !linked);
if (linked) void loadMaterials(linked.id);
} catch (err) {
if (current === requestId.current) { setError(err instanceof Error ? err.message : 'Could not load Canvas. Try again.'); setState('connect'); }
}
Expand Down Expand Up @@ -87,6 +97,16 @@ export default function CanvasMaterialImportModal({ isOpen, folderId, onClose, o
} catch (err) { if (current === requestId.current) setError(err instanceof Error ? err.message : 'Could not load course materials.'); }
finally { if (current === requestId.current) setLoadingMaterials(false); }
}
async function linkSelectedCourse() {
if (!courseId || linking) return;
setLinking(true); setError('');
try {
await foldersApi.setLinkedCanvasCourse(folderId, courseId);
setLinkedUnavailable(false);
onLinkedCanvasCourseChange?.(courseId);
} catch (err) { setError(err instanceof Error ? err.message : 'Could not save the linked Canvas course.'); }
finally { setLinking(false); }
}
async function importSelected() {
if (!selected.size || importing) return;
setImporting(true); setError('');
Expand Down Expand Up @@ -123,9 +143,14 @@ export default function CanvasMaterialImportModal({ isOpen, folderId, onClose, o
<label className="canvas-import-field">Canvas course
<select className="input" value={courseId} disabled={busy} onChange={event => void loadMaterials(event.target.value)}>
<option value="">Choose a teaching course</option>
{courses.map(course => <option key={course.id} value={course.id}>{course.name}</option>)}
{courses.map(course => <option key={course.id} value={course.id}>{course.name}{String(course.id) === String(linkedCanvasCourseId) ? ' (linked)' : ''}</option>)}
</select>
</label>
{linkedUnavailable && <p className="canvas-import-notice">The Canvas course linked to this course isn't available to your account. Choose another course.</p>}
{courseId && String(courseId) !== String(linkedCanvasCourseId ?? '') && onLinkedCanvasCourseChange && <div className="canvas-import-tools">
<button className="btn btn-outline" disabled={busy || linking} onClick={() => void linkSelectedCourse()}>{linking ? 'Saving…' : 'Set as linked course'}</button>
<span className="canvas-import-hint">Imports and exports for this course will start with this Canvas course.</span>
</div>}
{!courses.length && <p>No teaching courses found. This import currently requires a Teacher enrollment in Canvas.</p>}
{warnings.map((warning, index) => <p key={index} role="alert" className="canvas-import-notice">{warning}</p>)}
{(warnings.length > 0 || error) && <div className="canvas-import-tools">
Expand Down
6 changes: 5 additions & 1 deletion src/components/CourseView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ interface CourseData {
id: string;
name: string;
quizzes: QuizData[];
canvasCourseId?: string | null;
}

const CourseView = () => {
Expand Down Expand Up @@ -200,7 +201,8 @@ const CourseView = () => {
status: typeof quiz === 'string' ? undefined : quiz.status,
progress: typeof quiz === 'string' ? undefined : quiz.progress,
updatedAt: typeof quiz === 'string' ? undefined : quiz.updatedAt
})) || []
})) || [],
canvasCourseId: folderResponse.folder.canvasCourseId
};

setCourse(course);
Expand Down Expand Up @@ -654,6 +656,8 @@ const CourseView = () => {
</div>
<MaterialUpload
folderId={courseId}
linkedCanvasCourseId={course?.canvasCourseId}
onLinkedCanvasCourseChange={canvasCourseId => setCourse(prev => prev ? { ...prev, canvasCourseId } : prev)}
materials={materials.map(m => ({
id: m._id,
name: m.name,
Expand Down
20 changes: 19 additions & 1 deletion src/components/CreateCourseModal.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import CreateCourseModal from './CreateCourseModal';
import { canvasApi } from '../services/api';
Expand All @@ -15,6 +15,8 @@ vi.mock('../services/api', () => ({
canvasApi: {
getConfig: vi.fn().mockResolvedValue({ data: { enabled: false } }),
getAuthStatus: vi.fn(),
getCourses: vi.fn(),
createModule: vi.fn(),
},
}));

Expand Down Expand Up @@ -54,5 +56,21 @@ describe('CreateCourseModal workflow', () => {
fireEvent.click(screen.getByRole('button', { name: /Next/ }));
expect(screen.getByRole('button', { name: 'Connect to Canvas' })).toBeInTheDocument();
});
it('links a Canvas course without creating a Canvas module', async () => {
vi.mocked(canvasApi.getConfig).mockResolvedValueOnce({ data: { enabled: true } } as never);
vi.mocked(canvasApi.getAuthStatus).mockResolvedValueOnce({ data: { connected: true } } as never);
vi.mocked(canvasApi.getCourses).mockResolvedValueOnce({ data: { courses: [{ id: '42', name: 'Physics 101', courseCode: 'PHYS' }] } } as never);
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(<CreateCourseModal isOpen onClose={vi.fn()} onSubmit={onSubmit} />);
await screen.findByRole('tab', { name: /^Canvas/ });
fireEvent.change(screen.getByLabelText('Course Name'), { target: { value: 'Physics' } });
fireEvent.click(screen.getByRole('button', { name: /Next/ }));
fireEvent.click(screen.getByRole('button', { name: /Next/ }));
fireEvent.click(await screen.findByText('Physics 101'));
expect(screen.queryByText(/will be created/)).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Create Course' }));
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith('Physics', [], '42'));
expect(canvasApi.createModule).not.toHaveBeenCalled();
});

});
Loading
Loading