diff --git a/backend/apps/common/extensions.py b/backend/apps/common/extensions.py index 21e9373950..9554f7902d 100644 --- a/backend/apps/common/extensions.py +++ b/backend/apps/common/extensions.py @@ -11,6 +11,25 @@ from strawberry.schema import Schema from strawberry.utils.str_converters import to_camel_case +CACHE_VERSION_KEY = "graphql:cache_version" + + +def get_cache_version() -> int: + """Get the current cache version.""" + version = cache.get(CACHE_VERSION_KEY) + if version is None: + cache.set(CACHE_VERSION_KEY, 1, timeout=None) + return 1 + return version + + +def bump_cache_version() -> None: + """Bump the cache version.""" + try: + cache.incr(CACHE_VERSION_KEY) + except ValueError: + cache.set(CACHE_VERSION_KEY, 2) + @lru_cache(maxsize=1) def get_protected_fields(schema: Schema) -> tuple[str, ...]: @@ -46,7 +65,10 @@ def generate_key(self, field_name: str, field_args: dict) -> str: str: The unique cache key. """ - key = f"{field_name}:{json.dumps(field_args, sort_keys=True)}" + version = get_cache_version() + key = ( + f"{field_name}:{json.dumps({'args': field_args, 'version': version}, sort_keys=True)}" + ) return ( f"{settings.GRAPHQL_RESOLVER_CACHE_PREFIX}-{hashlib.sha256(key.encode()).hexdigest()}" ) @@ -65,3 +87,12 @@ def resolve(self, _next, root, info, *args, **kwargs): lambda: _next(root, info, *args, **kwargs), settings.GRAPHQL_RESOLVER_CACHE_TIME_SECONDS, ) + + +class CacheInvalidationExtension(SchemaExtension): + """CacheInvalidationExtension class.""" + + def on_execute(self): + """Invalidate cache on mutation.""" + if str(self.execution_context.operation_type) == "OperationType.MUTATION": + bump_cache_version() diff --git a/backend/apps/mentorship/api/internal/mutations/module.py b/backend/apps/mentorship/api/internal/mutations/module.py index f83ab80668..e172635737 100644 --- a/backend/apps/mentorship/api/internal/mutations/module.py +++ b/backend/apps/mentorship/api/internal/mutations/module.py @@ -12,8 +12,10 @@ from apps.mentorship.api.internal.nodes.module import ( CreateModuleInput, ModuleNode, + SetModuleOrderInput, UpdateModuleInput, ) +from apps.mentorship.api.internal.nodes.program import ProgramNode from apps.mentorship.models import Mentor, Module, Program from apps.mentorship.models.issue_user_interest import IssueUserInterest from apps.mentorship.models.task import Task @@ -75,9 +77,10 @@ def create_module(self, info: strawberry.Info, input_data: CreateModuleInput) -> user = info.context.request.user try: - program = Program.objects.get(key=input_data.program_key) + program = Program.objects.select_for_update().get(key=input_data.program_key) project = Project.objects.get(id=input_data.project_id) creator_as_mentor = Mentor.objects.get(nest_user=user) + new_position = program.modules.count() except (Program.DoesNotExist, Project.DoesNotExist) as e: msg = f"{e.__class__.__name__} matching query does not exist." raise ObjectDoesNotExist(msg) from e @@ -106,6 +109,7 @@ def create_module(self, info: strawberry.Info, input_data: CreateModuleInput) -> tags=input_data.tags, program=program, project=project, + position=new_position, ) if module.experience_level not in program.experience_levels: @@ -397,5 +401,60 @@ def update_module(self, info: strawberry.Info, input_data: UpdateModuleInput) -> module.program.experience_levels.remove(old_experience_level) module.program.save(update_fields=["experience_levels"]) - return module + + @strawberry.mutation(permission_classes=[IsAuthenticated]) + @transaction.atomic + def set_module_order( + self, info: strawberry.Info, input_data: SetModuleOrderInput + ) -> ProgramNode: + """Set the order of modules within a program. User must be an admin of the program.""" + user = info.context.request.user + try: + program = Program.objects.select_for_update().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 + + try: + admin = Mentor.objects.get(nest_user=user) + except Mentor.DoesNotExist as err: + msg = "You must be a mentor to update a program." + logger.warning( + "User '%s' is not a mentor and cannot update programs.", + user.username, + exc_info=True, + ) + raise PermissionDenied(msg) from err + + if not program.admins.filter(id=admin.id).exists(): + msg = "You must be an admin of this program to update it." + logger.warning( + "Permission denied for user '%s' to update program '%s'.", + user.username, + program.key, + ) + raise PermissionDenied(msg) + + existing_modules = Module.objects.filter(program=program) + existing_module_keys = {m.key for m in existing_modules} + + if len(input_data.module_keys) != len(set(input_data.module_keys)): + raise ValidationError(message="Duplicate module keys are not allowed in the ordering.") + + if set(input_data.module_keys) != existing_module_keys: + raise ValidationError( + message="All modules in the program must be included in the ordering." + ) + + modules_by_key = {m.key: m for m in existing_modules} + modules_to_update = [] + + for index, module_key in enumerate(input_data.module_keys): + module = modules_by_key[module_key] + module.position = index + modules_to_update.append(module) + + Module.objects.bulk_update(modules_to_update, ["position"]) + + return program diff --git a/backend/apps/mentorship/api/internal/nodes/module.py b/backend/apps/mentorship/api/internal/nodes/module.py index 8ccbdf46f1..caf58f2b6d 100644 --- a/backend/apps/mentorship/api/internal/nodes/module.py +++ b/backend/apps/mentorship/api/internal/nodes/module.py @@ -194,3 +194,11 @@ class UpdateModuleInput: project_name: str started_at: datetime tags: list[str] = strawberry.field(default_factory=list) + + +@strawberry.input +class SetModuleOrderInput: + """Input for setting the order of 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 8fec753752..fcf0508e71 100644 --- a/backend/apps/mentorship/api/internal/queries/module.py +++ b/backend/apps/mentorship/api/internal/queries/module.py @@ -22,7 +22,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("position", "started_at") ) @strawberry.field diff --git a/backend/apps/mentorship/migrations/0007_module_position.py b/backend/apps/mentorship/migrations/0007_module_position.py new file mode 100644 index 0000000000..53fd5c2bf9 --- /dev/null +++ b/backend/apps/mentorship/migrations/0007_module_position.py @@ -0,0 +1,17 @@ +# Generated by Django 6.0 on 2025-12-29 08:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("mentorship", "0006_alter_menteemodule_ended_at"), + ] + + operations = [ + migrations.AddField( + model_name="module", + name="position", + field=models.IntegerField(default=0, verbose_name="Position"), + ), + ] diff --git a/backend/apps/mentorship/models/module.py b/backend/apps/mentorship/models/module.py index 27fae9a8d6..c89754de4a 100644 --- a/backend/apps/mentorship/models/module.py +++ b/backend/apps/mentorship/models/module.py @@ -45,6 +45,10 @@ class Meta: blank=True, default="", ) + position = models.IntegerField( + verbose_name="Position", + default=0, + ) # FKs. labels = models.JSONField( diff --git a/backend/settings/graphql.py b/backend/settings/graphql.py index edb830c1ff..74bab2d202 100644 --- a/backend/settings/graphql.py +++ b/backend/settings/graphql.py @@ -4,7 +4,7 @@ from apps.api.internal.mutations import ApiMutations from apps.api.internal.queries import ApiKeyQueries -from apps.common.extensions import CacheExtension +from apps.common.extensions import CacheExtension, CacheInvalidationExtension from apps.github.api.internal.queries import GithubQuery from apps.mentorship.api.internal.mutations import ( ModuleMutation, @@ -41,4 +41,6 @@ class Query( """Schema queries.""" -schema = strawberry.Schema(mutation=Mutation, query=Query, extensions=[CacheExtension]) +schema = strawberry.Schema( + mutation=Mutation, query=Query, extensions=[CacheInvalidationExtension, CacheExtension] +) diff --git a/backend/tests/apps/common/extensions_test.py b/backend/tests/apps/common/extensions_test.py index 3b7bb1c982..8420f2e0ee 100644 --- a/backend/tests/apps/common/extensions_test.py +++ b/backend/tests/apps/common/extensions_test.py @@ -143,6 +143,7 @@ def test_skips_protected_fields(self, extension, mock_info, mock_next): @patch("apps.common.extensions.cache") def test_returns_cached_result_on_hit(self, mock_cache, extension, mock_info, mock_next): """Test that cached result is returned on cache hit.""" + mock_cache.get.return_value = 1 cached_result = {"name": "Cached OWASP"} mock_cache.get_or_set.return_value = cached_result @@ -155,6 +156,7 @@ def test_returns_cached_result_on_hit(self, mock_cache, extension, mock_info, mo @patch("apps.common.extensions.cache") def test_caches_result_on_miss(self, mock_cache, extension, mock_info, mock_next): """Test that result is cached on cache miss.""" + mock_cache.get.return_value = 1 mock_cache.get_or_set.side_effect = lambda _key, default, _timeout: default() extension.resolve(mock_next, None, mock_info, key="germany") diff --git a/docker-compose/local/compose.yaml b/docker-compose/local/compose.yaml index 5ed83ff803..93f4ffd7df 100644 --- a/docker-compose/local/compose.yaml +++ b/docker-compose/local/compose.yaml @@ -66,7 +66,7 @@ services: networks: - nest-network volumes: - - db-data:/var/lib/postgresql/data + - db-data-kart-u-3016:/var/lib/postgresql/data docs: container_name: nest-docs @@ -143,7 +143,7 @@ networks: volumes: backend-venv: cache-data: - db-data: + db-data-kart-u-3016: docs-venv: frontend-next: frontend-node-modules: diff --git a/frontend/__tests__/unit/components/ModuleCard.test.tsx b/frontend/__tests__/unit/components/ModuleCard.test.tsx new file mode 100644 index 0000000000..344cc7ae25 --- /dev/null +++ b/frontend/__tests__/unit/components/ModuleCard.test.tsx @@ -0,0 +1,207 @@ +import { render, screen, fireEvent } from '@testing-library/react' +import React from 'react' +import { ExperienceLevelEnum } from 'types/__generated__/graphql' +import { Module } from 'types/mentorship' +import ModuleCard, { getSimpleDuration } from 'components/ModuleCard' + +// Mock necessary modules +jest.mock('next/navigation', () => ({ + usePathname: () => '/mentorship/123', +})) +jest.mock('components/SingleModuleCard', () => ({ + __esModule: true, + default: ({ module }: { module: Module }) => ( +
{module.name}
+ ), +})) +jest.mock('components/TruncatedText', () => ({ + TruncatedText: ({ text }: { text: string }) => {text}, +})) + +jest.mock('@dnd-kit/core', () => ({ + DndContext: ({ children }: { children: React.ReactNode }) =>
{children}
, + DragOverlay: ({ children }: { children: React.ReactNode }) =>
{children}
, + closestCorners: jest.fn(), +})) +jest.mock('@dnd-kit/sortable', () => ({ + SortableContext: ({ children }: { children: React.ReactNode }) =>
{children}
, + arrayMove: jest.fn((items, oldIndex, newIndex) => { + const newItems = [...items] + const [removed] = newItems.splice(oldIndex, 1) + newItems.splice(newIndex, 0, removed) + return newItems + }), + rectSortingStrategy: jest.fn(), + useSortable: () => ({ + setNodeRef: jest.fn(), + transform: null, + transition: null, + attributes: {}, + listeners: {}, + isDragging: false, + }), +})) + +const mockModules: Module[] = [ + { + id: '1', + key: 'module-1', + name: 'Module One', + description: '', + experienceLevel: ExperienceLevelEnum.Beginner, + startedAt: '2023-01-01T00:00:00Z', + endedAt: '2023-01-20T00:00:00Z', + labels: ['label1'], + mentors: [], + }, + { + id: '2', + key: 'module-2', + name: 'Module Two', + description: '', + experienceLevel: ExperienceLevelEnum.Intermediate, + startedAt: '2023-02-01T00:00:00Z', + endedAt: '2023-02-28T00:00:00Z', + labels: ['label2'], + mentors: [], + }, + { + id: '3', + key: 'module-3', + name: 'Module Three', + description: '', + experienceLevel: ExperienceLevelEnum.Advanced, + startedAt: '2023-03-01T00:00:00Z', + endedAt: '2023-03-15T00:00:00Z', + labels: [], + mentors: [], + }, + { + id: '4', + key: 'module-4', + name: 'Module Four', + description: '', + experienceLevel: ExperienceLevelEnum.Beginner, + startedAt: '2023-04-01T00:00:00Z', + endedAt: '2023-04-30T00:00:00Z', + labels: ['label4'], + mentors: [], + }, + { + id: '5', + key: 'module-5', + name: 'Module Five', + description: '', + experienceLevel: ExperienceLevelEnum.Expert, + startedAt: '2023-05-01T00:00:00Z', + endedAt: '2023-05-10T00:00:00Z', + labels: ['label5'], + mentors: [], + }, +] + +describe('getSimpleDuration', () => { + it('returns "N/A" if start or end dates are missing', () => { + expect(getSimpleDuration('', '')).toBe('N/A') + expect(getSimpleDuration('2023-01-01', '')).toBe('N/A') + expect(getSimpleDuration('', '2023-01-01')).toBe('N/A') + }) + + it('returns "Invalid duration" for invalid dates', () => { + expect(getSimpleDuration('invalid', '2023-01-01')).toBe('Invalid duration') + expect(getSimpleDuration('2023-01-01', 'invalid')).toBe('Invalid duration') + }) + + it('calculates duration in weeks correctly', () => { + // 20 days -> 3 weeks (ceil(20/7)) + expect(getSimpleDuration('2023-01-01T00:00:00Z', '2023-01-21T00:00:00Z')).toBe('3 weeks') + // 7 days -> 1 week (ceil(7/7)) + expect(getSimpleDuration('2023-01-01T00:00:00Z', '2023-01-08T00:00:00Z')).toBe('1 week') + // 1 day -> 1 week (ceil(1/7)) + expect(getSimpleDuration('2023-01-01T00:00:00Z', '2023-01-02T00:00:00Z')).toBe('1 week') + // Dates as timestamps (seconds) + expect(getSimpleDuration(1672531200, 1674259200)).toBe('3 weeks') // Jan 1 2023 to Jan 21 2023 + }) +}) + +describe('ModuleCard', () => { + it('renders SingleModuleCard if only one module is provided', () => { + render() + expect(screen.getByTestId('single-module-card')).toHaveTextContent('Module One') + }) + + it('renders multiple module cards when more than one module is provided (up to 4 initially)', () => { + render() // 3 modules + expect(screen.getByText('Module One')).toBeInTheDocument() + expect(screen.getByText('Module Two')).toBeInTheDocument() + expect(screen.getByText('Module Three')).toBeInTheDocument() + expect(screen.queryByText('Show more')).not.toBeInTheDocument() + }) + + it('renders "Show more" button and only first 4 modules if more than 4 modules exist', () => { + render() // 5 modules + expect(screen.getByText('Module One')).toBeInTheDocument() + expect(screen.getByText('Module Two')).toBeInTheDocument() + expect(screen.getByText('Module Three')).toBeInTheDocument() + expect(screen.getByText('Module Four')).toBeInTheDocument() + expect(screen.queryByText('Module Five')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: /Show more/i })).toBeInTheDocument() + }) + + it('shows all modules when "Show more" is clicked, and "Show less" button appears', () => { + render() + fireEvent.click(screen.getByRole('button', { name: /Show more/i })) + + expect(screen.getByText('Module Five')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /Show less/i })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /Show more/i })).not.toBeInTheDocument() + }) + + it('hides extra modules when "Show less" is clicked', () => { + render() + fireEvent.click(screen.getByRole('button', { name: /Show more/i })) + fireEvent.click(screen.getByRole('button', { name: /Show less/i })) + + expect(screen.queryByText('Module Five')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: /Show more/i })).toBeInTheDocument() + }) + + it('shows "Customize order" button for admin', () => { + render() + expect(screen.getByRole('button', { name: /Customize order/i })).toBeInTheDocument() + }) + + it('does not show "Customize order" button for non-admin', () => { + render() + expect(screen.queryByRole('button', { name: /Customize order/i })).not.toBeInTheDocument() + }) + + it('switches to reordering mode when "Customize order" is clicked', () => { + render() + fireEvent.click(screen.getByRole('button', { name: /Customize order/i })) + + expect(screen.getByRole('button', { name: /Save order/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /Cancel/i })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /Customize order/i })).not.toBeInTheDocument() + }) + + it('switches out of reordering mode when "Cancel" is clicked', () => { + render() + fireEvent.click(screen.getByRole('button', { name: /Customize order/i })) + fireEvent.click(screen.getByRole('button', { name: /Cancel/i })) + + expect(screen.getByRole('button', { name: /Customize order/i })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /Save order/i })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: /Cancel/i })).not.toBeInTheDocument() + }) + + it('calls setModuleOrder and exits reordering mode when "Save order" is clicked', () => { + const setModuleOrder = jest.fn() + render() + fireEvent.click(screen.getByRole('button', { name: /Customize order/i })) + fireEvent.click(screen.getByRole('button', { name: /Save order/i })) + + expect(setModuleOrder).toHaveBeenCalledWith(mockModules) + expect(screen.getByRole('button', { name: /Customize order/i })).toBeInTheDocument() + }) +}) diff --git a/frontend/package.json b/frontend/package.json index b375e02a71..026defaabf 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,6 +18,9 @@ }, "dependencies": { "@apollo/client": "^4.0.11", + "@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.29", "@heroui/modal": "^2.2.26", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 2168179980..68173711eb 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -11,6 +11,15 @@ importers: '@apollo/client': specifier: ^4.0.11 version: 4.0.11(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.18.3))(graphql@16.12.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rxjs@7.8.2) + '@dnd-kit/core': + specifier: ^6.3.1 + version: 6.3.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@dnd-kit/sortable': + specifier: ^10.0.0 + version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) + '@dnd-kit/utilities': + specifier: ^3.2.2 + version: 3.2.2(react@19.2.3) '@graphql-typed-document-node/core': specifier: ^3.2.0 version: 3.2.0(graphql@16.12.0) @@ -545,6 +554,28 @@ packages: resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} engines: {node: '>=18'} + '@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.7.1': resolution: {integrity: sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==} @@ -7837,6 +7868,31 @@ snapshots: '@csstools/css-tokenizer@3.0.4': {} + '@dnd-kit/accessibility@3.1.1(react@19.2.3)': + dependencies: + react: 19.2.3 + tslib: 2.8.1 + + '@dnd-kit/core@6.3.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@dnd-kit/accessibility': 3.1.1(react@19.2.3) + '@dnd-kit/utilities': 3.2.2(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + tslib: 2.8.1 + + '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@dnd-kit/utilities': 3.2.2(react@19.2.3) + react: 19.2.3 + tslib: 2.8.1 + + '@dnd-kit/utilities@3.2.2(react@19.2.3)': + dependencies: + react: 19.2.3 + tslib: 2.8.1 + '@emnapi/core@1.7.1': dependencies: '@emnapi/wasi-threads': 1.1.0 @@ -8375,14 +8431,14 @@ snapshots: '@graphql-tools/optimize@2.0.0(graphql@16.12.0)': dependencies: graphql: 16.12.0 - tslib: 2.6.3 + tslib: 2.8.1 '@graphql-tools/relay-operation-optimizer@7.0.26(graphql@16.12.0)': dependencies: '@ardatan/relay-compiler': 12.0.3(graphql@16.12.0) '@graphql-tools/utils': 10.11.0(graphql@16.12.0) graphql: 16.12.0 - tslib: 2.6.3 + tslib: 2.8.1 transitivePeerDependencies: - encoding @@ -12470,7 +12526,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: {} @@ -12481,7 +12537,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: @@ -12521,7 +12577,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: {} @@ -12680,7 +12736,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: @@ -12868,7 +12924,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: @@ -13711,7 +13767,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: {} @@ -13960,7 +14016,7 @@ snapshots: is-lower-case@2.0.2: dependencies: - tslib: 2.6.3 + tslib: 2.8.1 is-map@2.0.3: {} @@ -14025,7 +14081,7 @@ snapshots: is-upper-case@2.0.2: dependencies: - tslib: 2.6.3 + tslib: 2.8.1 is-weakmap@2.0.2: {} @@ -14732,11 +14788,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: {} @@ -14933,7 +14989,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: {} @@ -15118,7 +15174,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: @@ -15148,12 +15204,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: {} @@ -15625,7 +15681,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: @@ -15761,7 +15817,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: @@ -15798,7 +15854,7 @@ snapshots: sponge-case@1.0.1: dependencies: - tslib: 2.6.3 + tslib: 2.8.1 sprintf-js@1.0.3: {} @@ -15964,7 +16020,7 @@ snapshots: swap-case@2.0.2: dependencies: - tslib: 2.6.3 + tslib: 2.8.1 symbol-tree@3.2.4: {} @@ -16064,7 +16120,7 @@ snapshots: title-case@3.0.3: dependencies: - tslib: 2.6.3 + tslib: 2.8.1 tldts-core@6.1.86: {} @@ -16303,11 +16359,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/app/my/mentorship/programs/[programKey]/page.tsx b/frontend/src/app/my/mentorship/programs/[programKey]/page.tsx index e25859a092..e5cfc44000 100644 --- a/frontend/src/app/my/mentorship/programs/[programKey]/page.tsx +++ b/frontend/src/app/my/mentorship/programs/[programKey]/page.tsx @@ -7,6 +7,7 @@ import { useSession } from 'next-auth/react' import { useEffect, useMemo, useState } from 'react' import { ErrorDisplay, handleAppError } from 'app/global-error' import { ProgramStatusEnum } from 'types/__generated__/graphql' +import { SetModuleOrderDocument } from 'types/__generated__/moduleMutations.generated' import { UpdateProgramStatusDocument } from 'types/__generated__/programsMutations.generated' import { GetProgramAndModulesDocument } from 'types/__generated__/programsQueries.generated' import type { ExtendedSession } from 'types/auth' @@ -29,6 +30,8 @@ const ProgramDetailsPage = () => { onError: handleAppError, }) + const [updateOrder] = useMutation(SetModuleOrderDocument) + const { data, loading: isQueryLoading } = useQuery(GetProgramAndModulesDocument, { variables: { programKey }, skip: !programKey, @@ -81,7 +84,31 @@ const ProgramDetailsPage = () => { handleAppError(err) } } - + const setModuleOrder = async (moduleOrder: Module[]) => { + if (!program || !isAdmin) { + addToast({ + title: 'Permission Denied', + description: 'Only admins can update order of modules.', + variant: 'solid', + color: 'danger', + timeout: 3000, + }) + return + } + const moduleKeys = moduleOrder.map((m) => m.key) + const prevModuleOrder = modules + try { + const input = { + programKey: programKey, + moduleKeys: moduleKeys, + } + setModules(moduleOrder) + await updateOrder({ variables: { input } }) + } catch (err) { + setModules(prevModuleOrder) + handleAppError(err) + } + } useEffect(() => { if (data?.getProgram) { setProgram(data.getProgram) @@ -126,6 +153,7 @@ const ProgramDetailsPage = () => { summary={program.description} tags={program.tags} title={program.name} + setModuleOrder={setModuleOrder} type="program" /> ) diff --git a/frontend/src/components/CardDetailsPage.tsx b/frontend/src/components/CardDetailsPage.tsx index fae64ca9ac..53987c3d66 100644 --- a/frontend/src/components/CardDetailsPage.tsx +++ b/frontend/src/components/CardDetailsPage.tsx @@ -48,6 +48,7 @@ const DetailsCard = ({ entityLeaders, labels, modules, + setModuleOrder, mentors, mentees, admins, @@ -312,7 +313,12 @@ const DetailsCard = ({ icon={FaFolderOpen} title={} > - + )} {IS_PROJECT_HEALTH_ENABLED && type === 'project' && healthMetricsData.length > 0 && ( diff --git a/frontend/src/components/ModuleCard.tsx b/frontend/src/components/ModuleCard.tsx index 87de94edf9..cb60d8f959 100644 --- a/frontend/src/components/ModuleCard.tsx +++ b/frontend/src/components/ModuleCard.tsx @@ -1,9 +1,27 @@ +import { + DndContext, + DragOverlay, + closestCorners, + DragStartEvent, + DragEndEvent, + UniqueIdentifier, +} from '@dnd-kit/core' +import { SortableContext, arrayMove, rectSortingStrategy, useSortable } from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' +import { Button } from '@heroui/button' import upperFirst from 'lodash/upperFirst' 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 { + FaChevronDown, + FaChevronUp, + FaTurnUp, + FaCalendar, + FaHourglassHalf, + FaGripVertical, +} from 'react-icons/fa6' import type { Module } from 'types/mentorship' import { formatDate } from 'utils/dateFormatter' import { TextInfoItem } from 'components/InfoItem' @@ -15,16 +33,24 @@ interface ModuleCardProps { modules: Module[] accessLevel?: string admins?: { login: string }[] + setModuleOrder?: (order: Module[]) => void } -const ModuleCard = ({ modules, accessLevel, admins }: ModuleCardProps) => { +const ModuleCard = ({ modules, accessLevel, admins, setModuleOrder }: ModuleCardProps) => { const [showAllModule, setShowAllModule] = useState(false) + const [isReordering, setIsReordering] = useState(false) + const [draftModules, setDraftModules] = useState(null) + const [activeId, setActiveId] = useState(null) + + const handleDragStart = (event: DragStartEvent) => { + setActiveId(event.active.id) + } if (modules.length === 1) { return } - - const displayedModule = showAllModule ? modules : modules.slice(0, 4) + const currentModules = isReordering ? draftModules! : modules + const displayedModule = showAllModule ? currentModules : currentModules.slice(0, 4) const isAdmin = accessLevel === 'admin' const handleKeyDown = (e: React.KeyboardEvent) => { @@ -33,14 +59,93 @@ const ModuleCard = ({ modules, accessLevel, admins }: ModuleCardProps) => { setShowAllModule(!showAllModule) } } + const startReorder = () => { + setDraftModules(modules) + setIsReordering(true) + } + const cancelReorder = () => { + setDraftModules(null) + setIsReordering(false) + setActiveId(null) + } + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event + if (!over || active.id === over.id) return + + setDraftModules((items) => { + if (!items) return items + const oldIndex = items.findIndex((m) => m.id === active.id) + const newIndex = items.findIndex((m) => m.id === over.id) + return arrayMove(items, oldIndex, newIndex) + }) + setActiveId(null) + } + const saveReorder = () => { + if (setModuleOrder && draftModules) { + setModuleOrder(draftModules) + } + setIsReordering(false) + setDraftModules(null) + } return (
-
- {displayedModule.map((module) => { - return - })} -
+ {isAdmin && ( +
+ {isReordering ? ( + <> + + + + ) : ( + + )} +
+ )} + + m.id)} strategy={rectSortingStrategy}> +
+ {displayedModule.map((module) => { + return ( + + ) + })} +
+
+ + {activeId && currentModules.some((m) => m.id === activeId) ? ( + m.id === activeId)} + isAdmin={isAdmin} + isReordering={isReordering} + isOverlay + /> + ) : null} + +
{modules.length > 4 && (
+ )} ; + programKey: Scalars['String']['input']; +}; + export type SnapshotNode = Node & { __typename?: 'SnapshotNode'; createdAt: Scalars['DateTime']['output']; diff --git a/frontend/src/types/__generated__/moduleMutations.generated.ts b/frontend/src/types/__generated__/moduleMutations.generated.ts index 66530d7c0c..df4a67cd70 100644 --- a/frontend/src/types/__generated__/moduleMutations.generated.ts +++ b/frontend/src/types/__generated__/moduleMutations.generated.ts @@ -15,6 +15,14 @@ export type CreateModuleMutationVariables = Types.Exact<{ export type CreateModuleMutation = { createModule: { __typename: 'ModuleNode', description: string, domains: Array | null, endedAt: any, experienceLevel: Types.ExperienceLevelEnum, id: string, key: string, labels: Array | null, name: string, projectId: string | null, startedAt: any, tags: Array | null, mentors: Array<{ __typename: 'MentorNode', avatarUrl: string, id: string, login: string, name: string }>, mentees: Array<{ __typename: 'UserNode', avatarUrl: string, id: string, login: string, name: string }> } }; +export type SetModuleOrderMutationVariables = Types.Exact<{ + input: Types.SetModuleOrderInput; +}>; + + +export type SetModuleOrderMutation = { setModuleOrder: { __typename: 'ProgramNode', id: string } }; + export const UpdateModuleDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateModule"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateModuleInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateModule"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"inputData"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"experienceLevel"}},{"kind":"Field","name":{"kind":"Name","value":"startedAt"}},{"kind":"Field","name":{"kind":"Name","value":"endedAt"}},{"kind":"Field","name":{"kind":"Name","value":"tags"}},{"kind":"Field","name":{"kind":"Name","value":"domains"}},{"kind":"Field","name":{"kind":"Name","value":"labels"}},{"kind":"Field","name":{"kind":"Name","value":"projectId"}},{"kind":"Field","name":{"kind":"Name","value":"mentors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}}]}},{"kind":"Field","name":{"kind":"Name","value":"mentees"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}}]}}]}}]}}]} as unknown as DocumentNode; -export const CreateModuleDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateModule"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CreateModuleInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createModule"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"inputData"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"domains"}},{"kind":"Field","name":{"kind":"Name","value":"endedAt"}},{"kind":"Field","name":{"kind":"Name","value":"experienceLevel"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"labels"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"projectId"}},{"kind":"Field","name":{"kind":"Name","value":"startedAt"}},{"kind":"Field","name":{"kind":"Name","value":"tags"}},{"kind":"Field","name":{"kind":"Name","value":"mentors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"mentees"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file +export const CreateModuleDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateModule"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CreateModuleInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createModule"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"inputData"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"domains"}},{"kind":"Field","name":{"kind":"Name","value":"endedAt"}},{"kind":"Field","name":{"kind":"Name","value":"experienceLevel"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"labels"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"projectId"}},{"kind":"Field","name":{"kind":"Name","value":"startedAt"}},{"kind":"Field","name":{"kind":"Name","value":"tags"}},{"kind":"Field","name":{"kind":"Name","value":"mentors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"mentees"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]} as unknown as DocumentNode; +export const SetModuleOrderDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SetModuleOrder"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"SetModuleOrderInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"setModuleOrder"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"inputData"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file diff --git a/frontend/src/types/card.ts b/frontend/src/types/card.ts index 787c66e4a0..d9cc57dbb2 100644 --- a/frontend/src/types/card.ts +++ b/frontend/src/types/card.ts @@ -68,6 +68,7 @@ export interface DetailsCardProps { recentReleases?: Release[] repositories?: RepositoryCardProps[] modules?: Module[] + setModuleOrder?: (order: Module[]) => void showAvatar?: boolean socialLinks?: string[] stats?: Stats[]