diff --git a/backend/apps/mentorship/api/internal/mutations/module.py b/backend/apps/mentorship/api/internal/mutations/module.py index 97e9ecc94f..d687987f2e 100644 --- a/backend/apps/mentorship/api/internal/mutations/module.py +++ b/backend/apps/mentorship/api/internal/mutations/module.py @@ -12,6 +12,7 @@ from apps.mentorship.api.internal.nodes.module import ( CreateModuleInput, ModuleNode, + ReorderModulesInput, UpdateModuleInput, ) from apps.mentorship.models import Mentor, Module, Program @@ -453,3 +454,46 @@ def delete_module( module.delete() return f"Module '{module_name}' has been deleted successfully." + + @strawberry.mutation(permission_classes=[IsAuthenticated]) + @transaction.atomic + def reorder_modules( + self, info: strawberry.Info, input_data: ReorderModulesInput + ) -> list[ModuleNode]: + """Reorder modules within a program. User must be a program admin.""" + user = info.context.request.user + + try: + program = Program.objects.get(key=input_data.program_key) + except Program.DoesNotExist as e: + msg = f"Program with key '{input_data.program_key}' not found." + raise ObjectDoesNotExist(msg) from e + + if not program.admins.filter(nest_user=user).exists(): + raise PermissionDenied + + if len(set(input_data.module_keys)) != len(input_data.module_keys): + msg = "Duplicate module keys are not allowed." + raise ValidationError(msg) + + modules_query = Module.objects.filter(program=program, key__in=input_data.module_keys) + + if modules_query.count() != len(input_data.module_keys): + msg = "Provided module keys do not match the program's modules." + raise ValidationError(msg) + + modules = list(modules_query.select_for_update()) + + key_to_order = {key: idx for idx, key in enumerate(input_data.module_keys)} + + for module in modules: + module.order = key_to_order[module.key] + + Module.objects.bulk_update(modules, ["order"]) + + return ( + Module.objects.filter(program=program) + .select_related("program", "project") + .prefetch_related("mentors__github_user") + .order_by("order", "started_at") + ) diff --git a/backend/apps/mentorship/api/internal/nodes/module.py b/backend/apps/mentorship/api/internal/nodes/module.py index ead2c53f37..143cfe16ff 100644 --- a/backend/apps/mentorship/api/internal/nodes/module.py +++ b/backend/apps/mentorship/api/internal/nodes/module.py @@ -34,6 +34,7 @@ class ModuleNode: ended_at: datetime experience_level: ExperienceLevelEnum labels: list[str] | None = None + order: int = 0 program: ProgramNode | None = None project_id: strawberry.ID | None = None started_at: datetime @@ -222,3 +223,11 @@ class UpdateModuleInput: project_name: str started_at: datetime tags: list[str] = strawberry.field(default_factory=list) + + +@strawberry.input +class ReorderModulesInput: + """Input for reordering modules within a program.""" + + program_key: str + module_keys: list[str] diff --git a/backend/apps/mentorship/api/internal/queries/module.py b/backend/apps/mentorship/api/internal/queries/module.py index aed931a37f..71cc4ca4cd 100644 --- a/backend/apps/mentorship/api/internal/queries/module.py +++ b/backend/apps/mentorship/api/internal/queries/module.py @@ -21,7 +21,7 @@ def get_program_modules(self, program_key: str) -> list[ModuleNode]: Module.objects.filter(program__key=program_key) .select_related("program", "project") .prefetch_related("mentors__github_user") - .order_by("started_at") + .order_by("order", "started_at") ) @strawberry.field @@ -31,7 +31,7 @@ def get_project_modules(self, project_key: str) -> list[ModuleNode]: Module.objects.filter(project__key=project_key) .select_related("program", "project") .prefetch_related("mentors__github_user") - .order_by("started_at") + .order_by("order", "started_at") ) @strawberry.field diff --git a/backend/apps/mentorship/migrations/0008_module_order.py b/backend/apps/mentorship/migrations/0008_module_order.py new file mode 100644 index 0000000000..357d41268b --- /dev/null +++ b/backend/apps/mentorship/migrations/0008_module_order.py @@ -0,0 +1,26 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("mentorship", "0007_alter_mentor_github_user_alter_mentor_nest_user_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="module", + name="order", + field=models.PositiveSmallIntegerField( + default=0, + help_text="Display order of the module within its program.", + verbose_name="Order", + ), + ), + migrations.AlterModelOptions( + name="module", + options={ + "ordering": ["order", "started_at"], + "verbose_name_plural": "Modules", + }, + ), + ] diff --git a/backend/apps/mentorship/models/module.py b/backend/apps/mentorship/models/module.py index 109bfb9d70..13b5076a59 100644 --- a/backend/apps/mentorship/models/module.py +++ b/backend/apps/mentorship/models/module.py @@ -24,6 +24,7 @@ class Meta: """Model options.""" db_table = "mentorship_modules" + ordering = ["order", "started_at"] verbose_name_plural = "Modules" constraints = [ models.UniqueConstraint( @@ -47,6 +48,11 @@ class Meta: blank=True, default="", ) + order = models.PositiveSmallIntegerField( + default=0, + verbose_name="Order", + help_text="Display order of the module within its program.", + ) # FKs. labels = models.JSONField( @@ -97,5 +103,13 @@ def save(self, *args, **kwargs): self.started_at = self.started_at or self.program.started_at self.ended_at = self.ended_at or self.program.ended_at + if not self.pk and self.program: + max_order = ( + Module.objects.filter(program=self.program) + .aggregate(max_order=models.Max("order")) + .get("max_order") + ) + self.order = (max_order or 0) + 1 + self.key = slugify(self.name) super().save(*args, **kwargs) diff --git a/backend/tests/apps/mentorship/api/internal/mutations/module_mutation_test.py b/backend/tests/apps/mentorship/api/internal/mutations/module_mutation_test.py index ec26e9636e..a92673f6e8 100644 --- a/backend/tests/apps/mentorship/api/internal/mutations/module_mutation_test.py +++ b/backend/tests/apps/mentorship/api/internal/mutations/module_mutation_test.py @@ -1278,3 +1278,211 @@ def test_update_module_removes_old_experience_level( assert "advanced" in mock_mod.program.experience_levels mock_mod.program.save.assert_called_once_with(update_fields=["experience_levels"]) + + +class TestModuleMutationDeleteModule: + """Tests for ModuleMutation.delete_module.""" + + def _make_info(self, user): + info = MagicMock() + info.context.request.user = user + return info + + @patch("apps.mentorship.api.internal.mutations.module.Module") + def test_delete_module_success_removes_unique_experience_level(self, mock_module): + """Test successful deletion removes experience level when no other module uses it.""" + user = MagicMock() + info = self._make_info(user) + + mock_mod = MagicMock() + mock_mod.name = "Test Module" + mock_mod.experience_level = "intermediate" + mock_mod.program.admins.filter.return_value.exists.return_value = True + mock_mod.program.experience_levels = ["beginner", "intermediate"] + + mock_module.objects.select_related.return_value.get.return_value = mock_mod + mock_module.DoesNotExist = ObjectDoesNotExist + mock_module.objects.filter.return_value.exclude.return_value.exists.return_value = False + + mutation = ModuleMutation() + result = mutation.delete_module(info, program_key="prog-1", module_key="mod-1") + + assert result == "Module 'Test Module' has been deleted successfully." + mock_mod.delete.assert_called_once() + mock_mod.program.save.assert_called_once_with(update_fields=["experience_levels"]) + assert "intermediate" not in mock_mod.program.experience_levels + + @patch("apps.mentorship.api.internal.mutations.module.Module") + def test_delete_module_success_keeps_shared_experience_level(self, mock_module): + """Test successful deletion keeps experience level when other modules use it.""" + user = MagicMock() + info = self._make_info(user) + + mock_mod = MagicMock() + mock_mod.name = "Test Module" + mock_mod.experience_level = "beginner" + mock_mod.program.admins.filter.return_value.exists.return_value = True + mock_mod.program.experience_levels = ["beginner", "advanced"] + + mock_module.objects.select_related.return_value.get.return_value = mock_mod + mock_module.DoesNotExist = ObjectDoesNotExist + mock_module.objects.filter.return_value.exclude.return_value.exists.return_value = True + + mutation = ModuleMutation() + result = mutation.delete_module(info, program_key="prog-1", module_key="mod-1") + + assert result == "Module 'Test Module' has been deleted successfully." + mock_mod.delete.assert_called_once() + assert "beginner" in mock_mod.program.experience_levels + + @patch("apps.mentorship.api.internal.mutations.module.Module") + def test_delete_module_not_found(self, mock_module): + """Test ObjectDoesNotExist when module is not found.""" + user = MagicMock() + info = self._make_info(user) + + mock_module.DoesNotExist = ObjectDoesNotExist + mock_module.objects.select_related.return_value.get.side_effect = ObjectDoesNotExist( + "not found" + ) + + mutation = ModuleMutation() + with pytest.raises(ObjectDoesNotExist): + mutation.delete_module(info, program_key="prog-1", module_key="mod-1") + + @patch("apps.mentorship.api.internal.mutations.module.Module") + def test_delete_module_not_admin(self, mock_module): + """Test PermissionDenied when user is not a program admin.""" + user = MagicMock() + info = self._make_info(user) + + mock_mod = MagicMock() + mock_mod.program.admins.filter.return_value.exists.return_value = False + + mock_module.objects.select_related.return_value.get.return_value = mock_mod + mock_module.DoesNotExist = ObjectDoesNotExist + + mutation = ModuleMutation() + with pytest.raises(PermissionDenied): + mutation.delete_module(info, program_key="prog-1", module_key="mod-1") + + +class TestModuleMutationReorderModules: + """Tests for ModuleMutation.reorder_modules.""" + + def _make_info(self, user): + info = MagicMock() + info.context.request.user = user + return info + + def _make_input_data(self, **overrides): + defaults = { + "program_key": "test-program", + "module_keys": ["mod-b", "mod-a", "mod-c"], + } + defaults.update(overrides) + return MagicMock(**defaults) + + @patch("apps.mentorship.api.internal.mutations.module.Module") + @patch("apps.mentorship.api.internal.mutations.module.Program") + def test_reorder_modules_success(self, mock_program, mock_module): + """Test successful module reordering by admin.""" + user = MagicMock() + info = self._make_info(user) + input_data = self._make_input_data() + + mock_prog = MagicMock() + mock_program.objects.get.return_value = mock_prog + mock_prog.admins.filter.return_value.exists.return_value = True + + mod_a = MagicMock() + mod_a.key = "mod-a" + mod_b = MagicMock() + mod_b.key = "mod-b" + mod_c = MagicMock() + mod_c.key = "mod-c" + + mock_module.objects.filter.return_value.count.return_value = 3 + mock_module.objects.filter.return_value.select_for_update.return_value = [ + mod_a, + mod_b, + mod_c, + ] + + mock_result_qs = MagicMock() + mock_qs = mock_module.objects.filter.return_value.select_related.return_value + mock_qs.prefetch_related.return_value.order_by.return_value = mock_result_qs + + mutation = ModuleMutation() + mutation.reorder_modules(info, input_data) + + assert mod_b.order == 0 + assert mod_a.order == 1 + assert mod_c.order == 2 + mock_module.objects.bulk_update.assert_called_once() + + @patch("apps.mentorship.api.internal.mutations.module.Program") + def test_reorder_modules_not_admin(self, mock_program): + """Test PermissionDenied when user is not a program admin.""" + user = MagicMock() + info = self._make_info(user) + input_data = self._make_input_data() + + mock_prog = MagicMock() + mock_program.objects.get.return_value = mock_prog + mock_prog.admins.filter.return_value.exists.return_value = False + + mutation = ModuleMutation() + with pytest.raises(PermissionDenied): + mutation.reorder_modules(info, input_data) + + @patch("apps.mentorship.api.internal.mutations.module.Program") + def test_reorder_modules_program_not_found(self, mock_program): + """Test ObjectDoesNotExist when program not found.""" + user = MagicMock() + info = self._make_info(user) + input_data = self._make_input_data() + + mock_program.DoesNotExist = ObjectDoesNotExist + mock_program.objects.get.side_effect = ObjectDoesNotExist("not found") + + mutation = ModuleMutation() + with pytest.raises(ObjectDoesNotExist): + mutation.reorder_modules(info, input_data) + + @patch("apps.mentorship.api.internal.mutations.module.Program") + def test_reorder_modules_duplicate_keys(self, mock_program): + """Test ValidationError when duplicate module keys are provided.""" + user = MagicMock() + info = self._make_info(user) + input_data = self._make_input_data(module_keys=["key-a", "key-a", "key-c"]) + + mock_prog = MagicMock() + mock_program.objects.get.return_value = mock_prog + mock_prog.admins.filter.return_value.exists.return_value = True + + mutation = ModuleMutation() + with pytest.raises(ValidationError, match=r"Duplicate module keys are not allowed."): + mutation.reorder_modules(info, input_data) + + @patch("apps.mentorship.api.internal.mutations.module.Module") + @patch("apps.mentorship.api.internal.mutations.module.Program") + def test_reorder_modules_mismatched_keys(self, mock_program, mock_module): + """Test ValidationError when provided keys do not match the program's modules.""" + user = MagicMock() + info = self._make_info(user) + input_data = self._make_input_data(module_keys=["key-a", "key-b"]) + + mock_prog = MagicMock() + mock_program.objects.get.return_value = mock_prog + mock_prog.admins.filter.return_value.exists.return_value = True + + mod_a = MagicMock(key="key-a") + mock_module.objects.filter.return_value.count.return_value = 1 + mock_module.objects.filter.return_value.select_for_update.return_value = [mod_a] + + mutation = ModuleMutation() + with pytest.raises( + ValidationError, match=r"Provided module keys do not match the program's modules." + ): + mutation.reorder_modules(info, input_data) diff --git a/backend/tests/apps/mentorship/model/module_test.py b/backend/tests/apps/mentorship/model/module_test.py index 1b269687f9..8b24459e1b 100644 --- a/backend/tests/apps/mentorship/model/module_test.py +++ b/backend/tests/apps/mentorship/model/module_test.py @@ -183,3 +183,94 @@ def test_save_without_program(self, mock_super_save): assert mock_module.key == "orphan-module" mock_super_save.assert_called_once() + + def test_order_field_default(self): + """Test that order field defaults to 0.""" + mock_module = MagicMock(spec=Module) + mock_module.order = Module._meta.get_field("order").default + assert mock_module.order == 0 + + @patch("apps.common.models.TimestampedModel.save") + @patch("apps.mentorship.models.Module.objects") + def test_save_auto_assigns_order_for_new_module(self, mock_objects, mock_super_save): + """Test that new modules get order = max(existing) + 1.""" + mock_module = MagicMock(spec=Module) + mock_module.pk = None + mock_module.name = "New Module" + mock_module.program = MagicMock( + started_at=django.utils.timezone.datetime( + 2024, 1, 1, tzinfo=django.utils.timezone.UTC + ), + ended_at=django.utils.timezone.datetime( + 2024, 12, 31, tzinfo=django.utils.timezone.UTC + ), + ) + mock_module.started_at = django.utils.timezone.datetime( + 2024, 1, 1, tzinfo=django.utils.timezone.UTC + ) + mock_module.ended_at = django.utils.timezone.datetime( + 2024, 12, 31, tzinfo=django.utils.timezone.UTC + ) + + mock_objects.filter.return_value.aggregate.return_value = {"max_order": 3} + + Module.save(mock_module) + + assert mock_module.order == 4 + mock_super_save.assert_called_once() + + @patch("apps.common.models.TimestampedModel.save") + @patch("apps.mentorship.models.Module.objects") + def test_save_auto_assigns_order_1_for_first_module(self, mock_objects, mock_super_save): + """Test that the first module in a program gets order = 1.""" + mock_module = MagicMock(spec=Module) + mock_module.pk = None + mock_module.name = "First Module" + mock_module.program = MagicMock( + started_at=django.utils.timezone.datetime( + 2024, 1, 1, tzinfo=django.utils.timezone.UTC + ), + ended_at=django.utils.timezone.datetime( + 2024, 12, 31, tzinfo=django.utils.timezone.UTC + ), + ) + mock_module.started_at = django.utils.timezone.datetime( + 2024, 1, 1, tzinfo=django.utils.timezone.UTC + ) + mock_module.ended_at = django.utils.timezone.datetime( + 2024, 12, 31, tzinfo=django.utils.timezone.UTC + ) + + mock_objects.filter.return_value.aggregate.return_value = {"max_order": None} + + Module.save(mock_module) + + assert mock_module.order == 1 + mock_super_save.assert_called_once() + + @patch("apps.common.models.TimestampedModel.save") + def test_save_does_not_change_order_for_existing_module(self, mock_super_save): + """Test that existing modules keep their order on save.""" + mock_module = MagicMock(spec=Module) + mock_module.pk = 42 + mock_module.name = "Existing Module" + mock_module.order = 5 + mock_module.program = MagicMock( + started_at=django.utils.timezone.datetime( + 2024, 1, 1, tzinfo=django.utils.timezone.UTC + ), + ended_at=django.utils.timezone.datetime( + 2024, 12, 31, tzinfo=django.utils.timezone.UTC + ), + ) + mock_module.started_at = django.utils.timezone.datetime( + 2024, 1, 1, tzinfo=django.utils.timezone.UTC + ) + mock_module.ended_at = django.utils.timezone.datetime( + 2024, 12, 31, tzinfo=django.utils.timezone.UTC + ) + + Module.save(mock_module) + + assert mock_module.order == 5 + mock_super_save.assert_called_once() diff --git a/frontend/__tests__/a11y/components/CardDetailsPage.a11y.test.tsx b/frontend/__tests__/a11y/components/CardDetailsPage.a11y.test.tsx index 6874bbf525..d7b7a90953 100644 --- a/frontend/__tests__/a11y/components/CardDetailsPage.a11y.test.tsx +++ b/frontend/__tests__/a11y/components/CardDetailsPage.a11y.test.tsx @@ -70,6 +70,10 @@ jest.mock('next-auth/react', () => ({ useSession: jest.fn(() => ({ data: null, status: 'unauthenticated' })), })) +jest.mock('@apollo/client/react', () => ({ + useMutation: jest.fn(() => [jest.fn()]), +})) + const mockHealthMetricsData = [ { ageDays: 365, diff --git a/frontend/__tests__/a11y/components/ModuleCard.a11y.test.tsx b/frontend/__tests__/a11y/components/ModuleCard.a11y.test.tsx index 5914e4b4c2..7d1a89c381 100644 --- a/frontend/__tests__/a11y/components/ModuleCard.a11y.test.tsx +++ b/frontend/__tests__/a11y/components/ModuleCard.a11y.test.tsx @@ -3,6 +3,9 @@ import { axe } from 'jest-axe' import { useTheme } from 'next-themes' import ModuleCard from 'components/ModuleCard' +jest.mock('@apollo/client/react', () => ({ + useMutation: jest.fn(() => [jest.fn()]), +})) const mockModules = [ { id: '1', diff --git a/frontend/__tests__/unit/components/CardDetailsPage.test.tsx b/frontend/__tests__/unit/components/CardDetailsPage.test.tsx index a59eaab5ff..2025546935 100644 --- a/frontend/__tests__/unit/components/CardDetailsPage.test.tsx +++ b/frontend/__tests__/unit/components/CardDetailsPage.test.tsx @@ -526,11 +526,13 @@ jest.mock('components/ModuleCard', () => ({ modules, accessLevel: _accessLevel, admins: _admins, + programKey: _programKey, ...props }: { modules: unknown[] accessLevel: string admins?: unknown[] + programKey?: string [key: string]: unknown }) => (
diff --git a/frontend/__tests__/unit/components/ModuleCard.test.tsx b/frontend/__tests__/unit/components/ModuleCard.test.tsx index 4d78e825dd..2151056d3d 100644 --- a/frontend/__tests__/unit/components/ModuleCard.test.tsx +++ b/frontend/__tests__/unit/components/ModuleCard.test.tsx @@ -2,13 +2,79 @@ * @file Complete unit tests for the ModuleCard component * Targeting 90-95% code coverage. */ -import { render, screen, fireEvent } from '@testing-library/react' +import { render, screen, fireEvent, act, waitFor } from '@testing-library/react' import '@testing-library/jest-dom' import React from 'react' import { ExperienceLevelEnum } from 'types/__generated__/graphql' import type { Module } from 'types/mentorship' import ModuleCard, { getSimpleDuration } from 'components/ModuleCard' +// Mock @dnd-kit +let capturedOnDragEnd: + | ((event: { active: { id: string }; over: { id: string } | null }) => void) + | null = null +jest.mock('@dnd-kit/core', () => ({ + DndContext: ({ + children, + onDragEnd, + }: { + children: React.ReactNode + onDragEnd?: (event: { active: { id: string }; over: { id: string } | null }) => void + }) => { + capturedOnDragEnd = onDragEnd || null + return
{children}
+ }, + closestCenter: jest.fn(), + useSensor: jest.fn(() => ({})), + useSensors: jest.fn(() => []), + // eslint-disable-next-line @typescript-eslint/naming-convention + KeyboardSensor: jest.fn(), + // eslint-disable-next-line @typescript-eslint/naming-convention + PointerSensor: jest.fn(), +})) + +let mockIsDragging = false +jest.mock('@dnd-kit/sortable', () => ({ + SortableContext: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + arrayMove: jest.fn((items: unknown[], oldIndex: number, newIndex: number) => { + const result = [...items] + const [removed] = result.splice(oldIndex, 1) + result.splice(newIndex, 0, removed) + return result + }), + sortableKeyboardCoordinates: jest.fn(), + rectSortingStrategy: jest.fn(), + useSortable: () => ({ + attributes: { role: 'button', tabIndex: 0, 'aria-roledescription': 'sortable' }, + listeners: { onPointerDown: jest.fn() }, + setNodeRef: jest.fn(), + transform: null, + transition: null, + isDragging: mockIsDragging, + }), +})) + +jest.mock('@dnd-kit/utilities', () => ({ + // eslint-disable-next-line @typescript-eslint/naming-convention + CSS: { Transform: { toString: () => null } }, +})) + +const mockAddToast = jest.fn() +const mockReorderModules = jest.fn(() => Promise.resolve({ data: {} })) +jest.mock('@apollo/client/react', () => ({ + useMutation: () => [mockReorderModules], +})) + +jest.mock('server/mutations/moduleMutations', () => ({ + REORDER_MODULES: 'REORDER_MODULES_MOCK', +})) + +jest.mock('@heroui/toast', () => ({ + addToast: (...args: unknown[]) => mockAddToast(...args), +})) + // Mock next/navigation const mockPathname = jest.fn() jest.mock('next/navigation', () => ({ @@ -72,6 +138,9 @@ jest.mock('react-icons/fa6', () => ({ FaChevronUp: (props: React.SVGProps) => ( ), + FaGripVertical: (props: React.SVGProps) => ( + + ), FaTurnUp: (props: React.SVGProps) => , FaCalendar: (props: React.SVGProps) => ( @@ -702,6 +771,207 @@ describe('ModuleCard', () => { expect(moduleElements.length).toBe(2) }) }) + + describe('Drag and Drop for Admins', () => { + it('renders drag handles for admin with multiple modules', () => { + const modules = [ + createMockModule({ key: 'mod1', name: 'Module 1' }), + createMockModule({ key: 'mod2', name: 'Module 2' }), + ] + + render() + + const gripIcons = screen.getAllByTestId('grip-vertical') + expect(gripIcons.length).toBe(2) + expect(screen.getByTestId('dnd-context')).toBeInTheDocument() + expect(screen.getByTestId('sortable-context')).toBeInTheDocument() + }) + + it('does not render drag handles for non-admin users', () => { + const modules = [ + createMockModule({ key: 'mod1', name: 'Module 1' }), + createMockModule({ key: 'mod2', name: 'Module 2' }), + ] + + render() + + expect(screen.queryAllByTestId('grip-vertical')).toHaveLength(0) + expect(screen.queryByTestId('dnd-context')).not.toBeInTheDocument() + }) + + it('does not render drag handles for single module even as admin', () => { + const modules = [createMockModule()] + + render() + + expect(screen.queryAllByTestId('grip-vertical')).toHaveLength(0) + expect(screen.getByTestId('single-module-card')).toBeInTheDocument() + }) + + it('calls reorderModules when drag ends with different position', async () => { + const modules = [ + createMockModule({ key: 'mod1', name: 'Module 1' }), + createMockModule({ key: 'mod2', name: 'Module 2' }), + ] + + render() + + await act(async () => { + capturedOnDragEnd?.({ active: { id: 'mod1' }, over: { id: 'mod2' } }) + }) + + expect(mockReorderModules).toHaveBeenCalledWith( + expect.objectContaining({ + variables: { + input: { + programKey: 'test-program', + moduleKeys: ['mod2', 'mod1'], + }, + }, + }) + ) + }) + + it('does not call reorderModules when dragged to same position', async () => { + mockReorderModules.mockClear() + const modules = [ + createMockModule({ key: 'mod1', name: 'Module 1' }), + createMockModule({ key: 'mod2', name: 'Module 2' }), + ] + + render() + + await act(async () => { + capturedOnDragEnd?.({ active: { id: 'mod1' }, over: { id: 'mod1' } }) + }) + + expect(mockReorderModules).not.toHaveBeenCalled() + }) + + it('does not call reorderModules when over is null', async () => { + mockReorderModules.mockClear() + const modules = [ + createMockModule({ key: 'mod1', name: 'Module 1' }), + createMockModule({ key: 'mod2', name: 'Module 2' }), + ] + + render() + + await act(async () => { + capturedOnDragEnd?.({ active: { id: 'mod1' }, over: null }) + }) + + expect(mockReorderModules).not.toHaveBeenCalled() + }) + + it('does not call reorderModules when index not found', async () => { + mockReorderModules.mockClear() + const modules = [ + createMockModule({ key: 'mod1', name: 'Module 1' }), + createMockModule({ key: 'mod2', name: 'Module 2' }), + ] + + render() + + await act(async () => { + capturedOnDragEnd?.({ active: { id: 'nonexistent' }, over: { id: 'mod2' } }) + }) + + expect(mockReorderModules).not.toHaveBeenCalled() + }) + + it('shows error toast when reorder mutation fails', async () => { + mockReorderModules.mockImplementationOnce(() => Promise.reject(new Error('fail'))) + const modules = [ + createMockModule({ key: 'mod1', name: 'Module 1' }), + createMockModule({ key: 'mod2', name: 'Module 2' }), + ] + + render() + + await act(async () => { + capturedOnDragEnd?.({ active: { id: 'mod1' }, over: { id: 'mod2' } }) + }) + + await waitFor(() => { + expect(mockAddToast).toHaveBeenCalledWith( + expect.objectContaining({ + color: 'danger', + title: 'Reorder Failed', + }) + ) + }) + }) + + it('does not reorder when already saving', async () => { + mockReorderModules.mockClear() + mockReorderModules.mockImplementationOnce(() => new Promise(() => {})) + const modules = [ + createMockModule({ key: 'mod1', name: 'Module 1' }), + createMockModule({ key: 'mod2', name: 'Module 2' }), + createMockModule({ key: 'mod3', name: 'Module 3' }), + ] + + render() + await act(async () => { + capturedOnDragEnd?.({ active: { id: 'mod1' }, over: { id: 'mod2' } }) + }) + + expect(mockReorderModules).toHaveBeenCalledTimes(1) + + mockReorderModules.mockClear() + await act(async () => { + capturedOnDragEnd?.({ active: { id: 'mod2' }, over: { id: 'mod3' } }) + }) + + expect(mockReorderModules).not.toHaveBeenCalled() + }) + + it('does not call mutation when programKey is not provided', async () => { + mockReorderModules.mockClear() + const modules = [ + createMockModule({ key: 'mod1', name: 'Module 1' }), + createMockModule({ key: 'mod2', name: 'Module 2' }), + ] + + render() + + await act(async () => { + capturedOnDragEnd?.({ active: { id: 'mod1' }, over: { id: 'mod2' } }) + }) + + expect(mockReorderModules).not.toHaveBeenCalled() + }) + + it('uses module id as fallback when key is empty', async () => { + mockReorderModules.mockClear() + const modules = [ + createMockModule({ key: '', id: 'id1', name: 'Module 1' }), + createMockModule({ key: '', id: 'id2', name: 'Module 2' }), + ] + + render() + + await act(async () => { + capturedOnDragEnd?.({ active: { id: 'id1' }, over: { id: 'id2' } }) + }) + + expect(mockReorderModules).toHaveBeenCalled() + }) + + it('applies reduced opacity when dragging', () => { + mockIsDragging = true + const modules = [ + createMockModule({ key: 'mod1', name: 'Module 1' }), + createMockModule({ key: 'mod2', name: 'Module 2' }), + ] + + render() + + expect(screen.getByTestId('dnd-context')).toBeInTheDocument() + mockIsDragging = false + }) + }) }) describe('getSimpleDuration', () => { diff --git a/frontend/package.json b/frontend/package.json index 86180b084c..c4e624968e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,6 +19,9 @@ }, "dependencies": { "@apollo/client": "^4.1.6", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@graphql-typed-document-node/core": "^3.2.0", "@heroui/button": "^2.2.31", "@heroui/modal": "^2.2.28", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 590d1c48b6..283e3de5d3 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -15,6 +15,15 @@ importers: '@apollo/client': specifier: ^4.1.6 version: 4.1.6(graphql-ws@6.0.7(graphql@16.13.0)(ws@8.19.0))(graphql@16.13.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rxjs@7.8.2) + '@dnd-kit/core': + specifier: ^6.3.1 + version: 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@dnd-kit/sortable': + specifier: ^10.0.0 + version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) + '@dnd-kit/utilities': + specifier: ^3.2.2 + version: 3.2.2(react@19.2.4) '@graphql-typed-document-node/core': specifier: ^3.2.0 version: 3.2.0(graphql@16.13.0) @@ -600,6 +609,28 @@ packages: resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} engines: {node: '>=20.19.0'} + '@dnd-kit/accessibility@3.1.1': + resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} + peerDependencies: + react: '>=16.8.0' + + '@dnd-kit/core@6.3.1': + resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@dnd-kit/sortable@10.0.0': + resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==} + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: '>=16.8.0' + + '@dnd-kit/utilities@3.2.2': + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} + peerDependencies: + react: '>=16.8.0' + '@emnapi/core@1.8.1': resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} @@ -8320,6 +8351,31 @@ snapshots: '@csstools/css-tokenizer@4.0.0': {} + '@dnd-kit/accessibility@3.1.1(react@19.2.4)': + dependencies: + react: 19.2.4 + tslib: 2.8.1 + + '@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@dnd-kit/accessibility': 3.1.1(react@19.2.4) + '@dnd-kit/utilities': 3.2.2(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + tslib: 2.8.1 + + '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@dnd-kit/utilities': 3.2.2(react@19.2.4) + react: 19.2.4 + tslib: 2.8.1 + + '@dnd-kit/utilities@3.2.2(react@19.2.4)': + dependencies: + react: 19.2.4 + tslib: 2.8.1 + '@emnapi/core@1.8.1': dependencies: '@emnapi/wasi-threads': 1.1.0 @@ -8867,14 +8923,14 @@ snapshots: '@graphql-tools/optimize@2.0.0(graphql@16.13.0)': dependencies: graphql: 16.13.0 - tslib: 2.6.3 + tslib: 2.8.1 '@graphql-tools/relay-operation-optimizer@7.0.27(graphql@16.13.0)': dependencies: '@ardatan/relay-compiler': 12.0.3(graphql@16.13.0) '@graphql-tools/utils': 11.0.0(graphql@16.13.0) graphql: 16.13.0 - tslib: 2.6.3 + tslib: 2.8.1 transitivePeerDependencies: - encoding @@ -13279,7 +13335,7 @@ snapshots: camel-case@4.1.2: dependencies: pascal-case: 3.1.2 - tslib: 2.6.3 + tslib: 2.8.1 camelcase@5.3.1: {} @@ -13290,7 +13346,7 @@ snapshots: capital-case@1.0.4: dependencies: no-case: 3.0.4 - tslib: 2.6.3 + tslib: 2.8.1 upper-case-first: 2.0.2 chalk@2.4.2: @@ -13330,7 +13386,7 @@ snapshots: path-case: 3.0.4 sentence-case: 3.0.4 snake-case: 3.0.4 - tslib: 2.6.3 + tslib: 2.8.1 char-regex@1.0.2: {} @@ -13475,7 +13531,7 @@ snapshots: constant-case@3.0.4: dependencies: no-case: 3.0.4 - tslib: 2.6.3 + tslib: 2.8.1 upper-case: 2.0.2 content-disposition@0.5.4: @@ -13682,7 +13738,7 @@ snapshots: dot-case@3.0.4: dependencies: no-case: 3.0.4 - tslib: 2.6.3 + tslib: 2.8.1 dot-prop@5.3.0: dependencies: @@ -14513,7 +14569,7 @@ snapshots: header-case@2.0.4: dependencies: capital-case: 1.0.4 - tslib: 2.6.3 + tslib: 2.8.1 hermes-estree@0.25.1: {} @@ -14762,7 +14818,7 @@ snapshots: is-lower-case@2.0.2: dependencies: - tslib: 2.6.3 + tslib: 2.8.1 is-map@2.0.3: {} @@ -14827,7 +14883,7 @@ snapshots: is-upper-case@2.0.2: dependencies: - tslib: 2.6.3 + tslib: 2.8.1 is-weakmap@2.0.2: {} @@ -15570,11 +15626,11 @@ snapshots: lower-case-first@2.0.2: dependencies: - tslib: 2.6.3 + tslib: 2.8.1 lower-case@2.0.2: dependencies: - tslib: 2.6.3 + tslib: 2.8.1 lru-cache@10.4.3: {} @@ -15779,7 +15835,7 @@ snapshots: no-case@3.0.4: dependencies: lower-case: 2.0.2 - tslib: 2.6.3 + tslib: 2.8.1 node-domexception@1.0.0: {} @@ -15971,7 +16027,7 @@ snapshots: param-case@3.0.4: dependencies: dot-case: 3.0.4 - tslib: 2.6.3 + tslib: 2.8.1 parent-module@1.0.1: dependencies: @@ -16005,12 +16061,12 @@ snapshots: pascal-case@3.1.2: dependencies: no-case: 3.0.4 - tslib: 2.6.3 + tslib: 2.8.1 path-case@3.0.4: dependencies: dot-case: 3.0.4 - tslib: 2.6.3 + tslib: 2.8.1 path-exists@4.0.0: {} @@ -16525,7 +16581,7 @@ snapshots: sentence-case@3.0.4: dependencies: no-case: 3.0.4 - tslib: 2.6.3 + tslib: 2.8.1 upper-case-first: 2.0.2 serialize-javascript@6.0.2: @@ -16661,7 +16717,7 @@ snapshots: snake-case@3.0.4: dependencies: dot-case: 3.0.4 - tslib: 2.6.3 + tslib: 2.8.1 socks-proxy-agent@8.0.5: dependencies: @@ -16698,7 +16754,7 @@ snapshots: sponge-case@1.0.1: dependencies: - tslib: 2.6.3 + tslib: 2.8.1 sprintf-js@1.0.3: {} @@ -16858,7 +16914,7 @@ snapshots: swap-case@2.0.2: dependencies: - tslib: 2.6.3 + tslib: 2.8.1 symbol-tree@3.2.4: {} @@ -16974,7 +17030,7 @@ snapshots: title-case@3.0.3: dependencies: - tslib: 2.6.3 + tslib: 2.8.1 tldts-core@6.1.86: {} @@ -17222,11 +17278,11 @@ snapshots: upper-case-first@2.0.2: dependencies: - tslib: 2.6.3 + tslib: 2.8.1 upper-case@2.0.2: dependencies: - tslib: 2.6.3 + tslib: 2.8.1 uri-js@4.4.1: dependencies: diff --git a/frontend/src/components/CardDetailsPage.tsx b/frontend/src/components/CardDetailsPage.tsx index d9a8fbaa17..cd82201acd 100644 --- a/frontend/src/components/CardDetailsPage.tsx +++ b/frontend/src/components/CardDetailsPage.tsx @@ -425,11 +425,21 @@ const DetailsCard = ({ <> {modulesList.length === 1 ? (
- +
) : ( }> - + )} diff --git a/frontend/src/components/ModuleCard.tsx b/frontend/src/components/ModuleCard.tsx index 7a11201fba..376e1bbefe 100644 --- a/frontend/src/components/ModuleCard.tsx +++ b/frontend/src/components/ModuleCard.tsx @@ -1,10 +1,38 @@ +import { useMutation } from '@apollo/client/react' +import { + closestCenter, + DndContext, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, +} from '@dnd-kit/core' +import type { DragEndEvent } from '@dnd-kit/core' +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + useSortable, + rectSortingStrategy, +} from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' +import { addToast } from '@heroui/toast' import { capitalize } from 'lodash' import Image from 'next/image' import Link from 'next/link' import { usePathname } from 'next/navigation' import type React from 'react' -import { useState } from 'react' -import { FaChevronDown, FaChevronUp, FaTurnUp, FaCalendar, FaHourglassHalf } from 'react-icons/fa6' +import { useCallback, useEffect, useState } from 'react' +import { + FaChevronDown, + FaChevronUp, + FaGripVertical, + FaTurnUp, + FaCalendar, + FaHourglassHalf, +} from 'react-icons/fa6' +import { REORDER_MODULES } from 'server/mutations/moduleMutations' +import { GetProgramAndModulesDocument } from 'types/__generated__/programsQueries.generated' import type { Module } from 'types/mentorship' import { formatDate } from 'utils/dateFormatter' import { TextInfoItem } from 'components/InfoItem' @@ -15,16 +43,84 @@ interface ModuleCardProps { modules: Module[] accessLevel?: string admins?: { login: string }[] + programKey?: string } -const ModuleCard = ({ modules, accessLevel, admins }: ModuleCardProps) => { +const ModuleCard = ({ modules, accessLevel, admins, programKey }: ModuleCardProps) => { const [showAllModule, setShowAllModule] = useState(false) + const [orderedModules, setOrderedModules] = useState(modules) + const [isSaving, setIsSaving] = useState(false) + const isAdmin = accessLevel === 'admin' + + useEffect(() => { + setOrderedModules((prev) => { + const moduleMap = new Map(modules.map((m) => [m.key || m.id, m])) + const updated = prev + .map((m) => moduleMap.get(m.key || m.id)) + .filter((m): m is Module => m !== undefined) + const existingIds = new Set(prev.map((m) => m.key || m.id)) + const added = modules.filter((m) => !existingIds.has(m.key || m.id)) + return [...updated, ...added] + }) + }, [modules]) + + const [reorderModules] = useMutation(REORDER_MODULES) + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: 8 }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ) + + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + const { active, over } = event + if (!over || active.id === over.id) return + if (isSaving) return + + const previousOrder = [...orderedModules] + const oldIndex = orderedModules.findIndex((m) => (m.key || m.id) === active.id) + const newIndex = orderedModules.findIndex((m) => (m.key || m.id) === over.id) + if (oldIndex === -1 || newIndex === -1) return + + const newOrder = arrayMove(orderedModules, oldIndex, newIndex) + setOrderedModules(newOrder) + + if (programKey) { + setIsSaving(true) + reorderModules({ + variables: { + input: { + programKey, + moduleKeys: newOrder.map((m) => m.key), + }, + }, + refetchQueries: [{ query: GetProgramAndModulesDocument, variables: { programKey } }], + }) + .catch(() => { + addToast({ + color: 'danger', + description: 'Failed to save module order.', + timeout: 3000, + title: 'Reorder Failed', + variant: 'solid', + }) + setOrderedModules(previousOrder) + }) + .finally(() => setIsSaving(false)) + } + }, + [programKey, reorderModules, isSaving, orderedModules] + ) if (modules.length === 1) { return } - const displayedModule = showAllModule ? modules : modules.slice(0, 4) + const displayedModules = showAllModule ? orderedModules : orderedModules.slice(0, 4) const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { @@ -33,14 +129,33 @@ const ModuleCard = ({ modules, accessLevel, admins }: ModuleCardProps) => { } } + const moduleGrid = ( +
+ {displayedModules.map((module) => { + return isAdmin ? ( + + ) : ( + + ) + })} +
+ ) + return (
-
- {displayedModule.map((module) => { - return - })} -
- {modules.length > 4 && ( + {isAdmin ? ( + + m.key || m.id)} + strategy={rectSortingStrategy} + > + {moduleGrid} + + + ) : ( + moduleGrid + )} + {orderedModules.length > 4 && (