diff --git a/codewit/api/src/controllers/course.ts b/codewit/api/src/controllers/course.ts index 6d5b4a58..581a4670 100644 --- a/codewit/api/src/controllers/course.ts +++ b/codewit/api/src/controllers/course.ts @@ -12,6 +12,7 @@ import { CourseModules, Language, Module, + ModuleResources, Demo, Resource, UserDemoCompletion, @@ -223,7 +224,10 @@ async function getAllCourses(): Promise { { association: Course.associations.instructors }, { association: Course.associations.roster }, ], - order: [[Course.associations.modules, CourseModules, 'ordering', 'ASC']], + order: [ + [Course.associations.modules, CourseModules, 'ordering', 'ASC'], + [Course.associations.modules, Module.associations.resources, ModuleResources, 'ordering', 'ASC'], + ], }); return formatCourseResponse(courses); } @@ -241,7 +245,10 @@ async function getTeacherCourses(teacherId: string): Promise { { association: Course.associations.instructors, where: { googleId: teacherId } }, { association: Course.associations.roster }, ], - order: [[Course.associations.modules, CourseModules, 'ordering', 'ASC']], + order: [ + [Course.associations.modules, CourseModules, 'ordering', 'ASC'], + [Course.associations.modules, Module.associations.resources, ModuleResources, 'ordering', 'ASC'], + ], }); return formatCourseResponse(courses); @@ -259,7 +266,10 @@ async function getStudentCourses(studentId: string): Promise { { association: Course.associations.instructors }, { association: Course.associations.roster, where: { googleId: studentId } }, ], - order: [[Course.associations.modules, CourseModules, 'ordering', 'ASC']], + order: [ + [Course.associations.modules, CourseModules, 'ordering', 'ASC'], + [Course.associations.modules, Module.associations.resources, ModuleResources, 'ordering', 'ASC'], + ], }); return formatCourseResponse(courses, true); @@ -277,7 +287,10 @@ async function getStudentCoursesByUid(userUid: number): Promise ({ + moduleUid: module.uid, + resourceUid, + ordering, + })), + { transaction }, + ); + await module.reload({ + include: [Language, Demo, Resource], + order: [[Module.associations.resources, ModuleResources, 'ordering', 'ASC']], + transaction, + }); return formatModuleResponse(module); }); @@ -40,6 +51,7 @@ async function createModule( async function getModule(uid: number): Promise { const module = await Module.findByPk(uid, { include: [Language, Demo, Resource], + order: [[Module.associations.resources, ModuleResources, 'ordering', 'ASC']], }); return formatModuleResponse(module); @@ -72,7 +84,15 @@ async function updateModule( await module.setLanguage(languageInstance, { transaction }); } if (resources) { - await module.setResources(resources, { transaction }); + await ModuleResources.destroy({ where: { moduleUid: module.uid }, transaction }); + await ModuleResources.bulkCreate( + resources.map((resourceUid, ordering) => ({ + moduleUid: module.uid, + resourceUid, + ordering, + })), + { transaction }, + ); } await module.save({ transaction }); @@ -94,6 +114,7 @@ async function updateModule( await module.reload({ include: [Language, Demo, Resource], + order: [[Module.associations.resources, ModuleResources, 'ordering', 'ASC']], transaction, }); @@ -104,6 +125,7 @@ async function updateModule( async function getModules(): Promise { const modules = await Module.findAll({ include: [Language, Demo, Resource], + order: [[Module.associations.resources, ModuleResources, 'ordering', 'ASC']], }); return formatModuleResponse(modules); @@ -112,6 +134,7 @@ async function getModules(): Promise { async function deleteModule(uid: number): Promise { const module = await Module.findByPk(uid, { include: [Language, Demo, Resource], + order: [[Module.associations.resources, ModuleResources, 'ordering', 'ASC']], }); if (!module) { return null; diff --git a/codewit/api/src/migrations/20260820120000-add-module-resource-ordering.js b/codewit/api/src/migrations/20260820120000-add-module-resource-ordering.js new file mode 100644 index 00000000..dc9a986f --- /dev/null +++ b/codewit/api/src/migrations/20260820120000-add-module-resource-ordering.js @@ -0,0 +1,37 @@ +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + up: (queryInterface, Sequelize) => queryInterface.sequelize.transaction(async transaction => { + await queryInterface.addColumn( + 'ModuleResources', + 'ordering', + { + type: Sequelize.INTEGER, + allowNull: false, + defaultValue: 0, + }, + { transaction }, + ); + + await queryInterface.sequelize.query( + ` + with ordered_resources as ( + select "moduleUid" as module_uid, + "resourceUid" as resource_uid, + row_number() over ( + partition by "moduleUid" + order by "createdAt", "resourceUid" + ) - 1 as ordering + from "ModuleResources" + ) + update "ModuleResources" + set ordering = ordered_resources.ordering + from ordered_resources + where "ModuleResources"."moduleUid" = ordered_resources.module_uid + and "ModuleResources"."resourceUid" = ordered_resources.resource_uid`, + { type: Sequelize.QueryTypes.RAW, transaction }, + ); + }), + down: (queryInterface, Sequelize) => queryInterface.sequelize.transaction(async transaction => { + await queryInterface.removeColumn('ModuleResources', 'ordering', { transaction }); + }), +}; diff --git a/codewit/api/src/models/index.ts b/codewit/api/src/models/index.ts index e37affb7..0f44e34c 100644 --- a/codewit/api/src/models/index.ts +++ b/codewit/api/src/models/index.ts @@ -14,6 +14,7 @@ import { UserExerciseCompletion } from './userExerciseCompletion'; import { UserModuleCompletion } from './userModuleCompletion'; import { DemoExercises } from './demoExercises'; import { ModuleDemos } from './moduleDemos'; +import { ModuleResources } from './moduleResources'; require('dotenv').config(); @@ -60,6 +61,7 @@ const sequelize = new Sequelize({ UserModuleCompletion, DemoExercises, ModuleDemos, + ModuleResources, ].forEach((model) => model.initialize(sequelize)); Demo.belongsToMany(Exercise, { through: DemoExercises }); @@ -96,8 +98,8 @@ Language.hasMany(Exercise, { foreignKey: 'languageUid' }); Demo.belongsToMany(Module, { through: ModuleDemos }); Module.belongsToMany(Demo, { through: ModuleDemos }); -Resource.belongsToMany(Module, { through: 'ModuleResources' }); -Module.belongsToMany(Resource, { through: 'ModuleResources' }); +Resource.belongsToMany(Module, { through: ModuleResources }); +Module.belongsToMany(Resource, { through: ModuleResources }); Module.belongsTo(Language); Language.hasMany(Module); @@ -175,6 +177,7 @@ export { Course, Module, ModuleDemos, + ModuleResources, CourseModules, CourseRegistration, Resource, diff --git a/codewit/api/src/models/moduleResources.ts b/codewit/api/src/models/moduleResources.ts new file mode 100644 index 00000000..82e17749 --- /dev/null +++ b/codewit/api/src/models/moduleResources.ts @@ -0,0 +1,46 @@ +import { + DataTypes, + InferAttributes, + InferCreationAttributes, + Model, + Sequelize, +} from 'sequelize'; + +class ModuleResources extends Model< + InferAttributes, + InferCreationAttributes +> { + declare moduleUid: number; + declare resourceUid: number; + declare ordering: number; + + static initialize(sequelize: Sequelize) { + this.init( + { + moduleUid: { + type: DataTypes.INTEGER, + primaryKey: true, + references: { model: 'modules', key: 'uid' }, + }, + resourceUid: { + type: DataTypes.INTEGER, + primaryKey: true, + references: { model: 'resources', key: 'uid' }, + }, + ordering: { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 0, + }, + }, + { + sequelize, + modelName: 'ModuleResources', + tableName: 'ModuleResources', + timestamps: true, + }, + ); + } +} + +export { ModuleResources }; diff --git a/codewit/api/src/routes/course.ts b/codewit/api/src/routes/course.ts index 206ec67f..d7dee558 100644 --- a/codewit/api/src/routes/course.ts +++ b/codewit/api/src/routes/course.ts @@ -29,6 +29,7 @@ import { CourseRegistration, Resource, Module, + ModuleResources, } from '../models'; import { asyncHandle } from "../middleware/catch"; import { } from "../models"; @@ -275,7 +276,10 @@ courseRouter.get('/:uid', asyncHandle(async (req, res) => { { association: Course.associations.instructors, where: { uid: req.user.uid } }, { association: Course.associations.roster }, ], - order: [[Course.associations.modules, CourseModules, 'ordering', 'ASC']], + order: [ + [Course.associations.modules, CourseModules, 'ordering', 'ASC'], + [Course.associations.modules, Module.associations.resources, ModuleResources, 'ordering', 'ASC'], + ], }); let result = formatCourseResponse(course, true); diff --git a/codewit/api/src/routes/demo.ts b/codewit/api/src/routes/demo.ts index 4f06d78c..4c517529 100644 --- a/codewit/api/src/routes/demo.ts +++ b/codewit/api/src/routes/demo.ts @@ -22,7 +22,7 @@ import { DemoAttempt } from "@codewit/interfaces"; import { fromZodError } from 'zod-validation-error'; import { checkAdmin } from '../middleware/auth'; import { asyncHandle } from '../middleware/catch'; -import { Attempt, Demo, DemoTags, Language, sequelize, Tag, UserExerciseCompletion } from '../models'; +import { Attempt, Demo, DemoExercises, DemoTags, Language, sequelize, Tag, UserExerciseCompletion } from '../models'; import { Op, QueryTypes } from 'sequelize'; const demoRouter = Router(); @@ -84,7 +84,10 @@ demoRouter.get("/:uid/attempt", asyncHandle(async (req, res) => { Tag, Language ], - order: [[Tag, DemoTags, "ordering", "ASC"]] + order: [ + [Tag, DemoTags, "ordering", "ASC"], + [Demo.associations.exercises, DemoExercises, "order", "ASC"], + ] }); if (demo_record == null) { @@ -166,7 +169,8 @@ demoRouter.get("/:uid/attempt", asyncHandle(async (req, res) => { mod_resc."moduleUid" = $1 left join "ResourceLikes" as resc_likes on resources.uid = resc_likes."resourceUid" and - resc_likes."userUid" = $2`, + resc_likes."userUid" = $2 + order by mod_resc.ordering asc`, { type: QueryTypes.SELECT, bind: [maybe_module_id, req.user.uid] diff --git a/codewit/client-e2e/src/e2e/app.cy.ts b/codewit/client-e2e/src/e2e/app.cy.ts index 39b28dbe..6e33e6fa 100644 --- a/codewit/client-e2e/src/e2e/app.cy.ts +++ b/codewit/client-e2e/src/e2e/app.cy.ts @@ -613,9 +613,17 @@ describe("Module creations functionality", () => { statusCode: 200, body: [] }).as('getModules'); + cy.intercept('GET', '/resources', { + statusCode: 200, + body: [ + { uid: 1, title: 'First resource', url: 'https://example.com/first', source: 'Example', likes: 0 }, + { uid: 2, title: 'Second resource', url: 'https://example.com/second', source: 'Example', likes: 0 }, + ], + }).as('getResources'); cy.visit('/create/module'); cy.wait('@getUserInfo'); cy.wait('@getModules'); + cy.wait('@getResources'); }) it('should render successfully', () => { @@ -642,6 +650,30 @@ describe("Module creations functionality", () => { getSubmitButton().click(); cy.wait('@createModule'); }) + + it('adds, reorders, and removes selected resources before saving', () => { + cy.intercept('POST', '/modules', (req) => { + expect(req.body).to.deep.equal({ + language: 'cpp', + resources: [2], + topic: 'operation', + }); + }).as('createOrderedModule'); + + cy.contains('Create Module').click(); + getTopicSelect().type('operation{enter}'); + getLanguageSelect().type('cpp{enter}'); + cy.get('#resource-select').type('First resource{enter}'); + cy.get('#resource-select').type('Second resource{enter}'); + + cy.get('[data-testid="selected-resources"]').should('contain.text', 'First resource'); + cy.get('[data-testid="selected-resources"]').should('contain.text', 'Second resource'); + cy.get('[aria-label="Drag Second resource"]').focus().type('{space}{uparrow}{space}'); + cy.get('[aria-label="Remove First resource"]').click(); + + getSubmitButton().click(); + cy.wait('@createOrderedModule'); + }); }) diff --git a/codewit/client/src/components/form/ResourceSelect.tsx b/codewit/client/src/components/form/ResourceSelect.tsx index b3603a31..c10dee44 100644 --- a/codewit/client/src/components/form/ResourceSelect.tsx +++ b/codewit/client/src/components/form/ResourceSelect.tsx @@ -1,43 +1,139 @@ -// codewit/client/src/components/form/ResourceSelect.tsx -import Select, { MultiValue } from 'react-select'; -import { SelectedTag, Resource } from '@codewit/interfaces'; -import { SelectStyles } from '../../utils/styles'; - -interface ResourceSelectProps { - resourceOptions: SelectedTag[]; - selectedResources: Resource[]; - handleResourceChange: (selectedOptions: MultiValue) => void; -} - -const ResourceSelect = ({ - resourceOptions, - selectedResources, - handleResourceChange -}: ResourceSelectProps) => { - return ( -
- - { + if (option != null) { + onAddResource(option.value); + } + }} + options={availableResources} + isSearchable + placeholder="Search resources" + styles={SelectStyles} + /> + + +
+ {selectedResources.map(resource => ( + onRemoveResource(resource.value)} + /> + ))} +
+
+
+
+ ); +}; + +export default ResourceSelect; diff --git a/codewit/client/src/pages/ModuleForm.tsx b/codewit/client/src/pages/ModuleForm.tsx index cf2a751d..2813a36c 100644 --- a/codewit/client/src/pages/ModuleForm.tsx +++ b/codewit/client/src/pages/ModuleForm.tsx @@ -1,6 +1,5 @@ // codewit/client/src/pages/ModuleForm.tsx import React, { useState, useEffect } from "react"; -import Select, { MultiValue } from "react-select"; import LanguageSelect from "../components/form/LanguageSelect"; import TopicSelect from "../components/form/TagSelect"; import ResourceSelect from "../components/form/ResourceSelect"; @@ -8,7 +7,7 @@ import CreateButton from "../components/form/CreateButton"; import ReusableTable, { Column } from "../components/form/ReusableTable"; import ReusableModal from "../components/form/ReusableModal"; import { toast } from "react-toastify"; -import { SelectedTag, Module } from "@codewit/interfaces"; +import { SelectedTag, Module, ModuleDraft } from "@codewit/interfaces"; import { isFormValid } from "../utils/formValidationUtils"; import { useFetchResources } from "../hooks/useResource"; import { @@ -18,8 +17,6 @@ import { usePatchModule, } from "../hooks/useModule"; -type ModuleDraft = Omit; - const ModuleForm = (): JSX.Element => { const { data: existingResources } = useFetchResources(); const { data: existingModules, setData: setExistingModules } = useFetchModules(); @@ -48,10 +45,29 @@ const ModuleForm = (): JSX.Element => { setResourceOptions(options); }, [existingResources]); - const handleResourceChange = (selectedOptions: MultiValue) => { - const resources = selectedOptions.map((option) => option.value); - // @ts-ignore - setFormData((prev) => ({ ...prev, resources })); + const addResource = (resourceId: number) => { + setFormData((prev) => ( + prev.resources.includes(resourceId) + ? prev + : { ...prev, resources: [...prev.resources, resourceId] } + )); + }; + + const moveResource = (fromIndex: number, toIndex: number) => { + setFormData((prev) => { + const resources = [...prev.resources]; + const [resource] = resources.splice(fromIndex, 1); + resources.splice(toIndex, 0, resource); + + return { ...prev, resources }; + }); + }; + + const removeResource = (resourceId: number) => { + setFormData((prev) => ({ + ...prev, + resources: prev.resources.filter(id => id !== resourceId), + })); }; const handleTopicSelect = (topics: SelectedTag | SelectedTag[]) => { @@ -64,8 +80,7 @@ const ModuleForm = (): JSX.Element => { ...module, language: module.language, topic: module.topic, - // @ts-ignore - resources: module.resources.map((resource) => resource.uid), + resources: module.resources.flatMap(resource => resource.uid == null ? [] : [resource.uid]), }); setIsEditing(true); setModalOpen(true); @@ -188,8 +203,10 @@ const ModuleForm = (): JSX.Element => { @@ -197,4 +214,4 @@ const ModuleForm = (): JSX.Element => { ); }; -export default ModuleForm; \ No newline at end of file +export default ModuleForm; diff --git a/codewit/lib/shared/interfaces/src/lib/interfaces.ts b/codewit/lib/shared/interfaces/src/lib/interfaces.ts index 9fa38d5e..d5dc4dcc 100644 --- a/codewit/lib/shared/interfaces/src/lib/interfaces.ts +++ b/codewit/lib/shared/interfaces/src/lib/interfaces.ts @@ -247,7 +247,9 @@ interface AttemptResult { // Payload sent when creating a NEW exercise (no uid yet) export type ExerciseInput = Omit; -export type ModuleDraft = Omit; +export type ModuleDraft = Omit & { + resources: number[]; +}; export type { AttemptDTO, diff --git a/codewit/lib/shared/validations/src/lib/module.spec.ts b/codewit/lib/shared/validations/src/lib/module.spec.ts new file mode 100644 index 00000000..81f594d9 --- /dev/null +++ b/codewit/lib/shared/validations/src/lib/module.spec.ts @@ -0,0 +1,19 @@ +import { createModuleSchema, updateModuleSchema } from './module'; + +describe('module resource validation', () => { + it('rejects duplicate resource IDs when creating a module', () => { + const result = createModuleSchema.safeParse({ + topic: 'operation', + language: 'cpp', + resources: [1, 1], + }); + + expect(result.success).toBe(false); + }); + + it('rejects duplicate resource IDs when updating a module', () => { + const result = updateModuleSchema.safeParse({ resources: [2, 2] }); + + expect(result.success).toBe(false); + }); +}); diff --git a/codewit/lib/shared/validations/src/lib/module.ts b/codewit/lib/shared/validations/src/lib/module.ts index ce062cf5..d753fbb1 100644 --- a/codewit/lib/shared/validations/src/lib/module.ts +++ b/codewit/lib/shared/validations/src/lib/module.ts @@ -1,12 +1,17 @@ import { z } from 'zod'; import { validateTopic } from './topic'; +const uniqueResourceIds = (resources: number[]) => + new Set(resources).size === resources.length; + const createModuleSchema = z.object({ topic: z .string() .refine((t) => validateTopic(t), { message: 'Invalid topic' }), language: z.string(), - resources: z.number().array(), + resources: z.number().array().refine(uniqueResourceIds, { + message: 'Resources must not contain duplicate IDs', + }), }); const updateModuleSchema = z.object({ @@ -15,7 +20,9 @@ const updateModuleSchema = z.object({ .refine((t) => validateTopic(t), { message: 'Invalid topic' }) .optional(), language: z.string().optional(), - resources: z.number().array().optional(), + resources: z.number().array().refine(uniqueResourceIds, { + message: 'Resources must not contain duplicate IDs', + }).optional(), }); export { createModuleSchema, updateModuleSchema };