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 && (