diff --git a/backend/apps/mentorship/api/internal/nodes/export_types.py b/backend/apps/mentorship/api/internal/nodes/export_types.py new file mode 100644 index 0000000000..7331e5567f --- /dev/null +++ b/backend/apps/mentorship/api/internal/nodes/export_types.py @@ -0,0 +1,21 @@ +"""GraphQL types for export functionality.""" + +import strawberry + + +@strawberry.enum +class ExportFormatEnum(strawberry.Enum): + """Supported export formats for issue data.""" + + CSV = "csv" + JSON = "json" + + +@strawberry.type +class ExportResult: + """Result of an export operation containing the file content and metadata.""" + + content: str + filename: str + mime_type: str + count: int diff --git a/backend/apps/mentorship/api/internal/nodes/module.py b/backend/apps/mentorship/api/internal/nodes/module.py index 0a224089d3..32bf24fb5c 100644 --- a/backend/apps/mentorship/api/internal/nodes/module.py +++ b/backend/apps/mentorship/api/internal/nodes/module.py @@ -11,10 +11,18 @@ from apps.github.models.pull_request import PullRequest from apps.github.models.user import User from apps.mentorship.api.internal.nodes.enum import ExperienceLevelEnum +from apps.mentorship.api.internal.nodes.export_types import ExportFormatEnum, ExportResult from apps.mentorship.api.internal.nodes.mentor import MentorNode from apps.mentorship.api.internal.nodes.program import ProgramNode from apps.mentorship.models.issue_user_interest import IssueUserInterest from apps.mentorship.models.task import Task +from apps.mentorship.utils.export import ( + MAX_EXPORT_LIMIT, + ExportFormat, + generate_export_filename, + serialize_issues_to_csv, + serialize_issues_to_json, +) @strawberry.type @@ -171,6 +179,55 @@ def recent_pull_requests(self, limit: int = 5) -> list[PullRequestNode]: .order_by("-created_at")[:limit] ) + @strawberry.field + def export_issues( + self, + format: ExportFormatEnum, + label: str | None = None, + limit: int = MAX_EXPORT_LIMIT, + ) -> ExportResult: + """Export issues in CSV or JSON format. + + Args: + format: Export format (CSV or JSON). + label: Optional label filter. + limit: Maximum number of issues to export (default: 1000). + + Returns: + ExportResult with content, filename, and MIME type. + """ + # Enforce maximum limit + effective_limit = min(limit, MAX_EXPORT_LIMIT) + + # Reuse existing issue query logic + queryset = self.issues.select_related("repository", "author").prefetch_related( + "assignees", "labels" + ) + + if label and label != "all": + queryset = queryset.filter(labels__name=label) + + issues = list(queryset.order_by("-updated_at")[:effective_limit]) + + # Serialize based on format + if format == ExportFormatEnum.CSV: + content = serialize_issues_to_csv(issues) + mime_type = "text/csv; charset=utf-8" + export_format = ExportFormat.CSV + else: + content = serialize_issues_to_json(issues, self.key) + mime_type = "application/json; charset=utf-8" + export_format = ExportFormat.JSON + + filename = generate_export_filename(self.key, export_format) + + return ExportResult( + content=content, + filename=filename, + mime_type=mime_type, + count=len(issues), + ) + @strawberry.input class CreateModuleInput: diff --git a/backend/apps/mentorship/api/internal/queries/program.py b/backend/apps/mentorship/api/internal/queries/program.py index fb272e8165..cbcb41e520 100644 --- a/backend/apps/mentorship/api/internal/queries/program.py +++ b/backend/apps/mentorship/api/internal/queries/program.py @@ -37,6 +37,8 @@ def my_programs( search: str = "", page: int = 1, limit: int = 24, + sort_by: str = "default", + order: str = "desc", ) -> PaginatedPrograms: """Get paginated programs where the current user is admin or mentor.""" user = info.context.request.user @@ -63,7 +65,15 @@ def my_programs( page = max(1, min(page, total_pages)) offset = (page - 1) * limit - paginated_programs = queryset.order_by("-nest_created_at")[offset : offset + limit] + sort_field_map = { + "name": "name", + "started_at": "started_at", + "ended_at": "ended_at", + "default": "nest_created_at", + } + sort_field = sort_field_map.get(sort_by, "nest_created_at") + order_prefix = "" if order == "asc" else "-" + paginated_programs = queryset.order_by(f"{order_prefix}{sort_field}")[offset : offset + limit] results = [] mentor_id = mentor.id diff --git a/backend/apps/mentorship/utils/__init__.py b/backend/apps/mentorship/utils/__init__.py new file mode 100644 index 0000000000..a4179a5b9a --- /dev/null +++ b/backend/apps/mentorship/utils/__init__.py @@ -0,0 +1,9 @@ +"""Mentorship utility modules.""" + +from apps.mentorship.utils.export import ExportFormat, serialize_issues_to_csv, serialize_issues_to_json + +__all__ = [ + "ExportFormat", + "serialize_issues_to_csv", + "serialize_issues_to_json", +] diff --git a/backend/apps/mentorship/utils/export.py b/backend/apps/mentorship/utils/export.py new file mode 100644 index 0000000000..1f887a8eb4 --- /dev/null +++ b/backend/apps/mentorship/utils/export.py @@ -0,0 +1,187 @@ +"""Export utilities for mentorship issues. + +This module provides serialization functions for exporting issue data +in CSV and JSON formats with proper escaping and formatting. +""" + +import csv +import json +from datetime import date, datetime +from enum import Enum +from io import StringIO + + +class ExportFormat(str, Enum): + """Supported export formats.""" + + CSV = "csv" + JSON = "json" + + +# Fields to export from issues +EXPORT_FIELDS = [ + "number", + "title", + "state", + "labels", + "assignees", + "author", + "created_at", + "url", + "repository_name", +] + +# Maximum issues per export request (prevents memory issues) +MAX_EXPORT_LIMIT = 1000 + + +def _format_datetime(dt: datetime | None) -> str: + """Format datetime to ISO string or empty string if None.""" + if dt is None: + return "" + return dt.isoformat() + + +def _format_list(items: list | None, separator: str = "; ") -> str: + """Format a list to a separated string.""" + if not items: + return "" + return separator.join(str(item) for item in items) + + +def _issue_to_dict(issue) -> dict: + """Convert an issue object to a dictionary for export. + + Args: + issue: Issue model instance with related data prefetched. + + Returns: + Dictionary containing export-ready issue data. + """ + # Get assignee logins + assignees = [] + if hasattr(issue, "assignees"): + assignees = [a.login for a in issue.assignees.all() if hasattr(a, "login")] + + # Get label names + labels = [] + if hasattr(issue, "labels"): + labels = [label.name for label in issue.labels.all() if hasattr(label, "name")] + + # Get author login + author = "" + if hasattr(issue, "author") and issue.author: + author = issue.author.login if hasattr(issue.author, "login") else str(issue.author) + + # Get repository name + repository_name = "" + if hasattr(issue, "repository") and issue.repository: + repository_name = issue.repository.name if hasattr(issue.repository, "name") else "" + + return { + "number": issue.number, + "title": issue.title, + "state": issue.state, + "labels": labels, + "assignees": assignees, + "author": author, + "created_at": getattr(issue, "created_at", None), + "url": getattr(issue, "url", ""), + "repository_name": repository_name, + } + + +def serialize_issues_to_csv(issues: list) -> str: + """Serialize issues to CSV format. + + Args: + issues: List of Issue model instances. + + Returns: + CSV string with UTF-8 BOM for Excel compatibility. + """ + output = StringIO() + + # CSV headers + headers = [ + "Number", + "Title", + "State", + "Labels", + "Assignees", + "Author", + "Created At", + "URL", + "Repository", + ] + + writer = csv.writer(output, quoting=csv.QUOTE_ALL) + writer.writerow(headers) + + for issue in issues: + data = _issue_to_dict(issue) + writer.writerow([ + data["number"], + data["title"], + data["state"], + _format_list(data["labels"]), + _format_list(data["assignees"]), + data["author"], + _format_datetime(data["created_at"]), + data["url"], + data["repository_name"], + ]) + + # Add UTF-8 BOM for Excel compatibility + return "\ufeff" + output.getvalue() + + +def serialize_issues_to_json(issues: list, module_key: str = "") -> str: + """Serialize issues to JSON format. + + Args: + issues: List of Issue model instances. + module_key: Optional module key for metadata. + + Returns: + JSON string with metadata and issues array. + """ + issues_data = [] + + for issue in issues: + data = _issue_to_dict(issue) + issues_data.append({ + "number": data["number"], + "title": data["title"], + "state": data["state"], + "labels": data["labels"], + "assignees": data["assignees"], + "author": data["author"], + "createdAt": _format_datetime(data["created_at"]), + "url": data["url"], + "repositoryName": data["repository_name"], + }) + + result = { + "exportedAt": date.today().isoformat(), + "moduleKey": module_key, + "count": len(issues_data), + "issues": issues_data, + } + + return json.dumps(result, indent=2, ensure_ascii=False) + + +def generate_export_filename(module_key: str, export_format: ExportFormat) -> str: + """Generate a filename for the export. + + Args: + module_key: The module key for identification. + export_format: The export format (CSV or JSON). + + Returns: + Filename string with date stamp. + """ + date_str = date.today().strftime("%Y-%m-%d") + extension = export_format.value + return f"nest-{module_key}-issues-{date_str}.{extension}" diff --git a/backend/tests/apps/mentorship/__init__.py b/backend/tests/apps/mentorship/__init__.py new file mode 100644 index 0000000000..4dd44875c1 --- /dev/null +++ b/backend/tests/apps/mentorship/__init__.py @@ -0,0 +1 @@ +"""Test package for mentorship app.""" diff --git a/backend/tests/apps/mentorship/utils/__init__.py b/backend/tests/apps/mentorship/utils/__init__.py new file mode 100644 index 0000000000..ab9f24c05c --- /dev/null +++ b/backend/tests/apps/mentorship/utils/__init__.py @@ -0,0 +1 @@ +"""Test package for mentorship utils.""" diff --git a/backend/tests/apps/mentorship/utils/export_test.py b/backend/tests/apps/mentorship/utils/export_test.py new file mode 100644 index 0000000000..580836e475 --- /dev/null +++ b/backend/tests/apps/mentorship/utils/export_test.py @@ -0,0 +1,248 @@ +"""Tests for mentorship export utilities.""" + +import json +from datetime import date, datetime +from unittest.mock import MagicMock + +import pytest + +from apps.mentorship.utils.export import ( + MAX_EXPORT_LIMIT, + ExportFormat, + generate_export_filename, + serialize_issues_to_csv, + serialize_issues_to_json, +) + + +class MockLabel: + """Mock label for testing.""" + + def __init__(self, name: str): + self.name = name + + +class MockUser: + """Mock user for testing.""" + + def __init__(self, login: str): + self.login = login + + +class MockRepository: + """Mock repository for testing.""" + + def __init__(self, name: str): + self.name = name + + +class MockIssue: + """Mock issue for testing export serialization.""" + + def __init__( + self, + number: int, + title: str, + state: str = "open", + labels: list[str] | None = None, + assignees: list[str] | None = None, + author_login: str = "testuser", + created_at: datetime | None = None, + url: str = "", + repository_name: str = "test-repo", + ): + self.number = number + self.title = title + self.state = state + self.url = url + self.created_at = created_at or datetime(2024, 1, 15, 10, 30, 0) + + # Mock labels + self._labels = [MockLabel(name) for name in (labels or [])] + + # Mock assignees + self._assignees = [MockUser(login) for login in (assignees or [])] + + # Mock author + self.author = MockUser(author_login) if author_login else None + + # Mock repository + self.repository = MockRepository(repository_name) + + @property + def labels(self): + """Return mock labels manager.""" + mock = MagicMock() + mock.all.return_value = self._labels + return mock + + @property + def assignees(self): + """Return mock assignees manager.""" + mock = MagicMock() + mock.all.return_value = self._assignees + return mock + + +class TestSerializeIssuesToCsv: + """Tests for CSV serialization.""" + + def test_empty_list(self): + """Should return CSV with headers only for empty list.""" + result = serialize_issues_to_csv([]) + + # Should have BOM and headers + assert result.startswith("\ufeff") + assert "Number" in result + assert "Title" in result + assert "State" in result + + def test_single_issue(self): + """Should serialize a single issue correctly.""" + issues = [ + MockIssue( + number=123, + title="Test Issue", + state="open", + labels=["bug"], + assignees=["user1"], + ) + ] + + result = serialize_issues_to_csv(issues) + + assert "123" in result + assert "Test Issue" in result + assert "open" in result + assert "bug" in result + assert "user1" in result + + def test_special_characters_escaped(self): + """Should properly escape special characters in CSV.""" + issues = [ + MockIssue( + number=1, + title='Issue with "quotes" and, commas', + state="open", + ) + ] + + result = serialize_issues_to_csv(issues) + + # Title with special chars should be quoted + assert '"Issue with ""quotes"" and, commas"' in result + + def test_multiple_labels_joined(self): + """Should join multiple labels with semicolons.""" + issues = [ + MockIssue( + number=1, + title="Test", + labels=["bug", "feature", "help wanted"], + ) + ] + + result = serialize_issues_to_csv(issues) + + assert "bug; feature; help wanted" in result + + def test_newline_in_title(self): + """Should handle newlines in title.""" + issues = [ + MockIssue( + number=1, + title="Line1\nLine2", + ) + ] + + result = serialize_issues_to_csv(issues) + + # CSV should quote the field with newline + assert '"Line1\nLine2"' in result + + +class TestSerializeIssuesToJson: + """Tests for JSON serialization.""" + + def test_empty_list(self): + """Should return valid JSON with empty issues array.""" + result = serialize_issues_to_json([], module_key="test-module") + data = json.loads(result) + + assert data["count"] == 0 + assert data["issues"] == [] + assert data["moduleKey"] == "test-module" + assert "exportedAt" in data + + def test_single_issue(self): + """Should serialize a single issue correctly.""" + issues = [ + MockIssue( + number=456, + title="JSON Test", + state="closed", + labels=["enhancement"], + assignees=["contributor"], + author_login="author1", + ) + ] + + result = serialize_issues_to_json(issues, module_key="my-module") + data = json.loads(result) + + assert data["count"] == 1 + assert len(data["issues"]) == 1 + + issue = data["issues"][0] + assert issue["number"] == 456 + assert issue["title"] == "JSON Test" + assert issue["state"] == "closed" + assert issue["labels"] == ["enhancement"] + assert issue["assignees"] == ["contributor"] + assert issue["author"] == "author1" + + def test_preserves_arrays(self): + """Should preserve arrays for labels and assignees.""" + issues = [ + MockIssue( + number=1, + title="Test", + labels=["a", "b", "c"], + assignees=["x", "y"], + ) + ] + + result = serialize_issues_to_json(issues) + data = json.loads(result) + + assert isinstance(data["issues"][0]["labels"], list) + assert isinstance(data["issues"][0]["assignees"], list) + assert len(data["issues"][0]["labels"]) == 3 + assert len(data["issues"][0]["assignees"]) == 2 + + +class TestGenerateExportFilename: + """Tests for filename generation.""" + + def test_csv_filename(self): + """Should generate CSV filename with date.""" + result = generate_export_filename("test-module", ExportFormat.CSV) + + today = date.today().strftime("%Y-%m-%d") + assert result == f"nest-test-module-issues-{today}.csv" + + def test_json_filename(self): + """Should generate JSON filename with date.""" + result = generate_export_filename("my-project", ExportFormat.JSON) + + today = date.today().strftime("%Y-%m-%d") + assert result == f"nest-my-project-issues-{today}.json" + + +class TestExportLimits: + """Tests for export limits.""" + + def test_max_export_limit_defined(self): + """Should have a defined maximum export limit.""" + assert MAX_EXPORT_LIMIT == 1000 + assert MAX_EXPORT_LIMIT > 0 diff --git a/frontend/__tests__/a11y/pages/MyMentorship.a11y.test.tsx b/frontend/__tests__/a11y/pages/MyMentorship.a11y.test.tsx index 1bef8fbd9b..5f36b5b405 100644 --- a/frontend/__tests__/a11y/pages/MyMentorship.a11y.test.tsx +++ b/frontend/__tests__/a11y/pages/MyMentorship.a11y.test.tsx @@ -1,4 +1,5 @@ import { useQuery } from '@apollo/client/react' +import { waitFor } from '@testing-library/react' import { axe } from 'jest-axe' import { render } from 'wrappers/testUtil' import MyMentorshipPage from 'app/my/mentorship/page' @@ -33,14 +34,17 @@ jest.mock('hooks/useUpdateProgramStatus', () => ({ describe('MyMentorshipPage', () => { it('should have no accessibility violations', async () => { - ;(useQuery as unknown as jest.Mock).mockReturnValue({ + ; (useQuery as unknown as jest.Mock).mockReturnValue({ data: mockProgramData, loading: false, error: null, }) const { container } = render() - const results = await axe(container) - expect(results).toHaveNoViolations() + + await waitFor(async () => { + const results = await axe(container) + expect(results).toHaveNoViolations() + }) }) }) diff --git a/frontend/__tests__/unit/pages/CreateModule.test.tsx b/frontend/__tests__/unit/pages/CreateModule.test.tsx index 79279a8f59..775c5e8424 100644 --- a/frontend/__tests__/unit/pages/CreateModule.test.tsx +++ b/frontend/__tests__/unit/pages/CreateModule.test.tsx @@ -38,88 +38,92 @@ describe('CreateModulePage', () => { }) beforeEach(() => { - ;(useRouter as jest.Mock).mockReturnValue({ push: mockPush, replace: mockReplace }) - ;(useParams as jest.Mock).mockReturnValue({ programKey: 'test-program' }) - ;(useApolloClient as jest.Mock).mockReturnValue({ - query: mockQuery, - }) + ; (useRouter as jest.Mock).mockReturnValue({ push: mockPush, replace: mockReplace }) + ; (useParams as jest.Mock).mockReturnValue({ programKey: 'test-program' }) + ; (useApolloClient as jest.Mock).mockReturnValue({ + query: mockQuery, + }) }) afterEach(() => { jest.clearAllMocks() }) - it('submits the form and navigates to programs page', async () => { - const user = userEvent.setup() - - ;(useSession as jest.Mock).mockReturnValue({ - data: { user: { login: 'admin-user' } }, - status: 'authenticated', - }) - ;(useQuery as unknown as jest.Mock).mockReturnValue({ - data: { - getProgram: { - admins: [{ login: 'admin-user' }], - }, - }, - loading: false, - }) - ;(useMutation as unknown as jest.Mock).mockReturnValue([ - mockCreateModule.mockResolvedValue({ - data: { - createModule: { - key: 'my-test-module', + it( + 'submits the form and navigates to programs page', + async () => { + const user = userEvent.setup() + + ; (useSession as jest.Mock).mockReturnValue({ + data: { user: { login: 'admin-user' } }, + status: 'authenticated', + }) + ; (useQuery as unknown as jest.Mock).mockReturnValue({ + data: { + getProgram: { + admins: [{ login: 'admin-user' }], + }, }, + loading: false, + }) + ; (useMutation as unknown as jest.Mock).mockReturnValue([ + mockCreateModule.mockResolvedValue({ + data: { + createModule: { + key: 'my-test-module', + }, + }, + }), + { loading: false }, + ]) + + render() + + // Fill all inputs + await user.type(screen.getByLabelText('Name'), 'My Test Module') + await user.type(screen.getByLabelText(/Description/i), 'This is a test module') + await user.type(screen.getByLabelText(/Start Date/i), '2025-07-15') + await user.type(screen.getByLabelText(/End Date/i), '2025-08-15') + await user.type(screen.getByLabelText(/Domains/i), 'AI, ML') + await user.type(screen.getByLabelText(/Tags/i), 'react, graphql') + + const projectInput = await waitFor(() => { + return screen.getByPlaceholderText('Start typing project name...') + }) + + await user.type(projectInput, 'Aw') + + await waitFor( + () => { + expect(mockQuery).toHaveBeenCalled() }, - }), - { loading: false }, - ]) - - render() - - // Fill all inputs - await user.type(screen.getByLabelText('Name'), 'My Test Module') - await user.type(screen.getByLabelText(/Description/i), 'This is a test module') - await user.type(screen.getByLabelText(/Start Date/i), '2025-07-15') - await user.type(screen.getByLabelText(/End Date/i), '2025-08-15') - await user.type(screen.getByLabelText(/Domains/i), 'AI, ML') - await user.type(screen.getByLabelText(/Tags/i), 'react, graphql') - - const projectInput = await waitFor(() => { - return screen.getByPlaceholderText('Start typing project name...') - }) - - await user.type(projectInput, 'Aw') - - await waitFor( - () => { - expect(mockQuery).toHaveBeenCalled() - }, - { timeout: 2000 } - ) - - const projectOption = await waitFor( - () => { - return ( - screen.queryByRole('option', { name: /Awesome Project/i }) || - screen.queryByText('Awesome Project') || - document.querySelector('[data-key="123"]') - ) - }, - { timeout: 2000 } - ) - - if (projectOption) { - await user.click(projectOption) - } else { - await user.type(projectInput, '{ArrowDown}{Enter}') - } - - await user.click(screen.getByRole('button', { name: /Create Module/i })) - - await waitFor(() => { - expect(mockCreateModule).toHaveBeenCalled() - expect(mockPush).toHaveBeenCalledWith('/my/mentorship/programs/test-program') - }) - }) + { timeout: 2000 } + ) + + const projectOption = await waitFor( + () => { + return ( + screen.queryByRole('option', { name: /Awesome Project/i }) || + screen.queryByText('Awesome Project') || + document.querySelector('[data-key="123"]') + ) + }, + { timeout: 2000 } + ) + + if (projectOption) { + await user.click(projectOption) + } else { + await user.type(projectInput, '{ArrowDown}{Enter}') + } + + await user.click(screen.getByRole('button', { name: /Create Module/i })) + + await waitFor(() => { + expect(mockCreateModule).toHaveBeenCalled() + expect(mockPush).toHaveBeenCalledWith('/my/mentorship/programs/test-program') + }) + }, + 15000 + ) }) diff --git a/frontend/__tests__/unit/pages/MyMentorship.test.tsx b/frontend/__tests__/unit/pages/MyMentorship.test.tsx index 38da7d95bb..ea083570bf 100644 --- a/frontend/__tests__/unit/pages/MyMentorship.test.tsx +++ b/frontend/__tests__/unit/pages/MyMentorship.test.tsx @@ -44,7 +44,7 @@ const mockAddToast = addToast as jest.Mock beforeEach(() => { jest.clearAllMocks() - ;(useRouterMock as jest.Mock).mockReturnValue({ push: mockPush }) + ; (useRouterMock as jest.Mock).mockReturnValue({ push: mockPush }) }) const mockProgramData = { @@ -69,7 +69,7 @@ const mockProgramData = { describe('MyMentorshipPage', () => { it('shows loading while checking access', () => { - ;(mockUseSession as jest.Mock).mockReturnValue({ + ; (mockUseSession as jest.Mock).mockReturnValue({ data: null, status: 'loading', }) @@ -80,7 +80,7 @@ describe('MyMentorshipPage', () => { }) it('shows access denied if user is not project leader', async () => { - ;(mockUseSession as jest.Mock).mockReturnValue({ + ; (mockUseSession as jest.Mock).mockReturnValue({ data: { user: { name: 'user 1', @@ -100,7 +100,7 @@ describe('MyMentorshipPage', () => { }) it('renders mentorship programs if user is leader', async () => { - ;(mockUseSession as jest.Mock).mockReturnValue({ + ; (mockUseSession as jest.Mock).mockReturnValue({ data: { user: { name: 'User', @@ -124,8 +124,32 @@ describe('MyMentorshipPage', () => { expect(await screen.findByText('Test Program')).toBeInTheDocument() }) + it('renders SortBy component with sort options', async () => { + ; (mockUseSession as jest.Mock).mockReturnValue({ + data: { + user: { + name: 'User', + email: 'leader@example.com', + login: 'user', + isLeader: true, + }, + expires: '2099-01-01T00:00:00.000Z', + }, + status: 'authenticated', + }) + + mockUseQuery.mockReturnValue({ + data: mockProgramData, + loading: false, + error: undefined, + }) + + render() + expect(await screen.findByText('Sort By :')).toBeInTheDocument() + }) + it('shows empty state when no programs found', async () => { - ;(mockUseSession as jest.Mock).mockReturnValue({ + ; (mockUseSession as jest.Mock).mockReturnValue({ data: { user: { name: 'User', @@ -149,7 +173,7 @@ describe('MyMentorshipPage', () => { }) it('navigates to create page on clicking create button', async () => { - ;(mockUseSession as jest.Mock).mockReturnValue({ + ; (mockUseSession as jest.Mock).mockReturnValue({ data: { user: { name: 'User', @@ -179,7 +203,7 @@ describe('MyMentorshipPage', () => { }) it('shows an error toast when GraphQL query fails', async () => { - ;(mockUseSession as jest.Mock).mockReturnValue({ + ; (mockUseSession as jest.Mock).mockReturnValue({ data: { user: { name: 'User', diff --git a/frontend/__tests__/unit/utils/exportUtils.test.ts b/frontend/__tests__/unit/utils/exportUtils.test.ts new file mode 100644 index 0000000000..7432d48933 --- /dev/null +++ b/frontend/__tests__/unit/utils/exportUtils.test.ts @@ -0,0 +1,129 @@ +import { + buildExportQuery, + downloadFile, + getExportErrorMessage, + parseExportResponse, +} from 'utils/exportUtils' + +// Mock window APIs for download testing +const mockCreateObjectURL = jest.fn(() => 'blob:mock-url') +const mockRevokeObjectURL = jest.fn() +const mockAppendChild = jest.fn() +const mockRemoveChild = jest.fn() +const mockClick = jest.fn() + +beforeEach(() => { + jest.clearAllMocks() + global.URL.createObjectURL = mockCreateObjectURL + global.URL.revokeObjectURL = mockRevokeObjectURL + document.body.appendChild = mockAppendChild + document.body.removeChild = mockRemoveChild +}) + +describe('buildExportQuery', () => { + it('should build query with CSV format', () => { + const query = buildExportQuery({ + programKey: 'program-1', + moduleKey: 'module-1', + format: 'CSV', + }) + + expect(query).toContain('programKey: "program-1"') + expect(query).toContain('moduleKey: "module-1"') + expect(query).toContain('format: CSV') + expect(query).not.toContain('label:') + }) + + it('should build query with JSON format and label', () => { + const query = buildExportQuery({ + programKey: 'program-1', + moduleKey: 'module-1', + format: 'JSON', + label: 'bug', + }) + + expect(query).toContain('format: JSON') + expect(query).toContain('label: "bug"') + }) + + it('should skip label if value is "all"', () => { + const query = buildExportQuery({ + programKey: 'p', + moduleKey: 'm', + format: 'CSV', + label: 'all', + }) + + expect(query).not.toContain('label:') + }) +}) + +describe('parseExportResponse', () => { + it('should extract export result from valid response', () => { + const data = { + getModule: { + exportIssues: { + content: 'csv-content', + filename: 'export.csv', + mimeType: 'text/csv', + count: 10, + }, + }, + } + + const result = parseExportResponse(data) + + expect(result).toEqual({ + content: 'csv-content', + filename: 'export.csv', + mimeType: 'text/csv', + count: 10, + }) + }) + + it('should return null for invalid response', () => { + expect(parseExportResponse(null)).toBeNull() + expect(parseExportResponse({})).toBeNull() + expect(parseExportResponse({ getModule: null })).toBeNull() + expect(parseExportResponse({ getModule: {} })).toBeNull() + }) +}) + +describe('getExportErrorMessage', () => { + it('should return error message for Error instance', () => { + const error = new Error('Test error') + expect(getExportErrorMessage(error)).toBe('Test error') + }) + + it('should return network error message', () => { + const error = new Error('network failed') + expect(getExportErrorMessage(error)).toContain('Network error') + }) + + it('should return timeout error message', () => { + const error = new Error('request timeout') + expect(getExportErrorMessage(error)).toContain('timed out') + }) + + it('should return default message for unknown error', () => { + expect(getExportErrorMessage('unknown')).toContain('unexpected error') + }) +}) + +describe('downloadFile', () => { + it('should create blob and trigger download', () => { + const mockLink = { + href: '', + download: '', + click: mockClick, + } + jest.spyOn(document, 'createElement').mockReturnValue(mockLink as any) + + downloadFile('test-content', 'test.csv', 'text/csv') + + expect(mockCreateObjectURL).toHaveBeenCalled() + expect(mockLink.download).toBe('test.csv') + expect(mockClick).toHaveBeenCalled() + expect(mockRevokeObjectURL).toHaveBeenCalled() + }) +}) diff --git a/frontend/src/app/my/mentorship/page.tsx b/frontend/src/app/my/mentorship/page.tsx index b4fd332747..c27136eefb 100644 --- a/frontend/src/app/my/mentorship/page.tsx +++ b/frontend/src/app/my/mentorship/page.tsx @@ -12,10 +12,12 @@ import { GetMyProgramsDocument } from 'types/__generated__/programsQueries.gener import type { ExtendedSession } from 'types/auth' import type { Program } from 'types/mentorship' +import { sortOptionsMentorshipPrograms } from 'utils/sortingOptions' import ActionButton from 'components/ActionButton' import LoadingSpinner from 'components/LoadingSpinner' import ProgramCard from 'components/ProgramCard' import SearchPageLayout from 'components/SearchPageLayout' +import SortBy from 'components/SortBy' const MyMentorshipPage: React.FC = () => { const router = useRouter() @@ -27,12 +29,16 @@ const MyMentorshipPage: React.FC = () => { const initialQuery = searchParams.get('q') || '' const initialPage = Number.parseInt(searchParams.get('page') || '1', 10) + const initialSortBy = searchParams.get('sortBy') || 'default' + const initialOrder = searchParams.get('order') || 'desc' const [searchQuery, setSearchQuery] = useState(initialQuery) const [debouncedQuery, setDebouncedQuery] = useState(initialQuery) const [page, setPage] = useState(initialPage) const [programs, setPrograms] = useState([]) const [totalPages, setTotalPages] = useState(1) + const [sortBy, setSortBy] = useState(initialSortBy) + const [order, setOrder] = useState(initialOrder) const debounceSearch = useMemo(() => debounce((q) => setDebouncedQuery(q), 400), []) @@ -45,18 +51,20 @@ const MyMentorshipPage: React.FC = () => { const params = new URLSearchParams() if (searchQuery) params.set('q', searchQuery) if (page > 1) params.set('page', String(page)) + if (sortBy && sortBy !== 'default') params.set('sortBy', sortBy) + if (order && order !== 'desc') params.set('order', order) const nextUrl = params.toString() ? `?${params}` : globalThis.location.pathname if (globalThis.location.search !== `?${params}`) { router.push(nextUrl, { scroll: false }) } - }, [searchQuery, page, router]) + }, [searchQuery, page, sortBy, order, router]) const { data: programData, loading: loadingPrograms, error, } = useQuery(GetMyProgramsDocument, { - variables: { search: debouncedQuery, page, limit: 24 }, + variables: { search: debouncedQuery, page, limit: 24, sortBy, order }, fetchPolicy: 'cache-and-network', errorPolicy: 'all', }) @@ -83,6 +91,16 @@ const MyMentorshipPage: React.FC = () => { const handleCreate = () => router.push('/my/mentorship/programs/create') + const handleSortChange = (value: string) => { + setSortBy(value) + setPage(1) + } + + const handleOrderChange = (value: string) => { + setOrder(value) + setPage(1) + } + if (!userName) { return } @@ -127,6 +145,15 @@ const MyMentorshipPage: React.FC = () => { searchQuery={searchQuery} searchPlaceholder="Search your programs" indexName="my-programs" + sortChildren={ + + } >
{programs.length === 0 ? ( diff --git a/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/issues/page.tsx b/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/issues/page.tsx index 21934208c5..c7180a8dc8 100644 --- a/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/issues/page.tsx +++ b/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/issues/page.tsx @@ -1,14 +1,22 @@ 'use client' -import { useQuery } from '@apollo/client/react' +import { useLazyQuery, useQuery } from '@apollo/client/react' import { Select, SelectItem } from '@heroui/select' +import { addToast } from '@heroui/toast' import { useParams, useRouter, useSearchParams } from 'next/navigation' import { useCallback, useEffect, useMemo, useState } from 'react' import { ErrorDisplay, handleAppError } from 'app/global-error' -import { GetModuleIssuesDocument } from 'types/__generated__/moduleQueries.generated' +import ExportButton, { type ExportFormat } from 'components/ExportButton' import IssuesTable, { type IssueRow } from 'components/IssuesTable' import LoadingSpinner from 'components/LoadingSpinner' import Pagination from 'components/Pagination' +import { GetModuleIssuesDocument } from 'types/__generated__/moduleQueries.generated' +import { + buildExportQuery, + downloadFile, + getExportErrorMessage, + parseExportResponse, +} from 'utils/exportUtils' const ITEMS_PER_PAGE = 20 const LABEL_ALL = 'all' @@ -59,9 +67,9 @@ const IssuesPage = () => { } const labels = new Set() - ;(moduleData?.issues || []).forEach((i) => - (i.labels || []).forEach((l: string) => labels.add(l)) - ) + ; (moduleData?.issues || []).forEach((i) => + (i.labels || []).forEach((l: string) => labels.add(l)) + ) return Array.from(labels).sort((a, b) => a.localeCompare(b)) }, [moduleData]) @@ -90,6 +98,59 @@ const IssuesPage = () => { [router, programKey, moduleKey] ) + const handleExport = useCallback( + async (format: ExportFormat) => { + try { + const query = buildExportQuery({ + programKey, + moduleKey, + format, + label: selectedLabel !== LABEL_ALL ? selectedLabel : null, + }) + + const response = await fetch('/api/graphql', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ query }), + }) + + if (!response.ok) { + throw new Error('Export request failed') + } + + const result = await response.json() + + if (result.errors?.length) { + throw new Error(result.errors[0]?.message || 'Export failed') + } + + const exportResult = parseExportResponse(result.data) + + if (!exportResult) { + throw new Error('Invalid export response') + } + + downloadFile(exportResult.content, exportResult.filename, exportResult.mimeType) + + addToast({ + title: 'Export Complete', + description: `Successfully exported ${exportResult.count} issues as ${format}`, + color: 'success', + }) + } catch (error) { + const message = getExportErrorMessage(error) + addToast({ + title: 'Export Failed', + description: message, + color: 'danger', + }) + console.error('Export failed:', error) + } + }, + [programKey, moduleKey, selectedLabel] + ) + if (loading) return if (!moduleData) return @@ -129,23 +190,27 @@ const IssuesPage = () => { ))}
+ - - - - {/* Pagination Controls */} - + + + + ) } diff --git a/frontend/src/components/ExportButton.tsx b/frontend/src/components/ExportButton.tsx new file mode 100644 index 0000000000..63c9db1128 --- /dev/null +++ b/frontend/src/components/ExportButton.tsx @@ -0,0 +1,73 @@ +'use client' + +import { Button } from '@heroui/button' +import { Dropdown, DropdownItem, DropdownMenu, DropdownTrigger } from '@heroui/react' +import type React from 'react' +import { useState } from 'react' +import { FiDownload } from 'react-icons/fi' + +export type ExportFormat = 'CSV' | 'JSON' + +interface ExportButtonProps { + onExport: (format: ExportFormat) => Promise + isDisabled?: boolean + className?: string +} + +/** + * Export button component with format selection dropdown. + * Shows loading state during export and handles format selection. + */ +const ExportButton: React.FC = ({ onExport, isDisabled = false, className }) => { + const [isExporting, setIsExporting] = useState(false) + + const handleExport = async (format: ExportFormat) => { + if (isExporting || isDisabled) return + + setIsExporting(true) + try { + await onExport(format) + } finally { + setIsExporting(false) + } + } + + return ( + + + + + handleExport(key as ExportFormat)} + disabledKeys={isExporting ? ['CSV', 'JSON'] : []} + > + 📊} + > + Export as CSV + + 📄} + > + Export as JSON + + + + ) +} + +export default ExportButton diff --git a/frontend/src/server/queries/programsQueries.ts b/frontend/src/server/queries/programsQueries.ts index 265ce7b71e..1b4f0e2f85 100644 --- a/frontend/src/server/queries/programsQueries.ts +++ b/frontend/src/server/queries/programsQueries.ts @@ -1,8 +1,8 @@ import { gql } from '@apollo/client' export const GET_MY_PROGRAMS = gql` - query GetMyPrograms($search: String, $page: Int, $limit: Int) { - myPrograms(search: $search, page: $page, limit: $limit) { + query GetMyPrograms($search: String, $page: Int, $limit: Int, $sortBy: String, $order: String) { + myPrograms(search: $search, page: $page, limit: $limit, sortBy: $sortBy, order: $order) { currentPage totalPages programs { diff --git a/frontend/src/types/__generated__/programsQueries.generated.ts b/frontend/src/types/__generated__/programsQueries.generated.ts index d9a2162bad..1054289bd9 100644 --- a/frontend/src/types/__generated__/programsQueries.generated.ts +++ b/frontend/src/types/__generated__/programsQueries.generated.ts @@ -5,6 +5,8 @@ export type GetMyProgramsQueryVariables = Types.Exact<{ search?: Types.InputMaybe; page?: Types.InputMaybe; limit?: Types.InputMaybe; + sortBy?: Types.InputMaybe; + order?: Types.InputMaybe; }>; @@ -32,7 +34,7 @@ export type GetProgramAdminDetailsQueryVariables = Types.Exact<{ export type GetProgramAdminDetailsQuery = { getProgram: { __typename: 'ProgramNode', id: string, key: string, name: string, startedAt: any, endedAt: any, admins: Array<{ __typename: 'MentorNode', id: string, login: string, name: string, avatarUrl: string }> | null } | null }; -export const GetMyProgramsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetMyPrograms"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"search"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"page"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"limit"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"myPrograms"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"search"},"value":{"kind":"Variable","name":{"kind":"Name","value":"search"}}},{"kind":"Argument","name":{"kind":"Name","value":"page"},"value":{"kind":"Variable","name":{"kind":"Name","value":"page"}}},{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"Variable","name":{"kind":"Name","value":"limit"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"currentPage"}},{"kind":"Field","name":{"kind":"Name","value":"totalPages"}},{"kind":"Field","name":{"kind":"Name","value":"programs"},"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":"status"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"startedAt"}},{"kind":"Field","name":{"kind":"Name","value":"endedAt"}},{"kind":"Field","name":{"kind":"Name","value":"userRole"}}]}}]}}]}}]} as unknown as DocumentNode; -export const GetProgramDetailsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetProgramDetails"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"programKey"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getProgram"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"programKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"programKey"}}}],"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":"status"}},{"kind":"Field","name":{"kind":"Name","value":"menteesLimit"}},{"kind":"Field","name":{"kind":"Name","value":"experienceLevels"}},{"kind":"Field","name":{"kind":"Name","value":"startedAt"}},{"kind":"Field","name":{"kind":"Name","value":"endedAt"}},{"kind":"Field","name":{"kind":"Name","value":"domains"}},{"kind":"Field","name":{"kind":"Name","value":"tags"}},{"kind":"Field","name":{"kind":"Name","value":"admins"},"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 GetProgramAndModulesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetProgramAndModules"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"programKey"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getProgram"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"programKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"programKey"}}}],"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":"status"}},{"kind":"Field","name":{"kind":"Name","value":"menteesLimit"}},{"kind":"Field","name":{"kind":"Name","value":"experienceLevels"}},{"kind":"Field","name":{"kind":"Name","value":"startedAt"}},{"kind":"Field","name":{"kind":"Name","value":"endedAt"}},{"kind":"Field","name":{"kind":"Name","value":"domains"}},{"kind":"Field","name":{"kind":"Name","value":"tags"}},{"kind":"Field","name":{"kind":"Name","value":"admins"},"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":"recentMilestones"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"state"}},{"kind":"Field","name":{"kind":"Name","value":"openIssuesCount"}},{"kind":"Field","name":{"kind":"Name","value":"closedIssuesCount"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"repositoryName"}},{"kind":"Field","name":{"kind":"Name","value":"organizationName"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"author"},"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":"getProgramModules"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"programKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"programKey"}}}],"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":"domains"}},{"kind":"Field","name":{"kind":"Name","value":"tags"}},{"kind":"Field","name":{"kind":"Name","value":"labels"}},{"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 GetProgramAdminDetailsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetProgramAdminDetails"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"programKey"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getProgram"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"programKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"programKey"}}}],"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":"startedAt"}},{"kind":"Field","name":{"kind":"Name","value":"endedAt"}},{"kind":"Field","name":{"kind":"Name","value":"admins"},"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; \ No newline at end of file +export const GetMyProgramsDocument = { "kind": "Document", "definitions": [{ "kind": "OperationDefinition", "operation": "query", "name": { "kind": "Name", "value": "GetMyPrograms" }, "variableDefinitions": [{ "kind": "VariableDefinition", "variable": { "kind": "Variable", "name": { "kind": "Name", "value": "search" } }, "type": { "kind": "NamedType", "name": { "kind": "Name", "value": "String" } } }, { "kind": "VariableDefinition", "variable": { "kind": "Variable", "name": { "kind": "Name", "value": "page" } }, "type": { "kind": "NamedType", "name": { "kind": "Name", "value": "Int" } } }, { "kind": "VariableDefinition", "variable": { "kind": "Variable", "name": { "kind": "Name", "value": "limit" } }, "type": { "kind": "NamedType", "name": { "kind": "Name", "value": "Int" } } }, { "kind": "VariableDefinition", "variable": { "kind": "Variable", "name": { "kind": "Name", "value": "sortBy" } }, "type": { "kind": "NamedType", "name": { "kind": "Name", "value": "String" } } }, { "kind": "VariableDefinition", "variable": { "kind": "Variable", "name": { "kind": "Name", "value": "order" } }, "type": { "kind": "NamedType", "name": { "kind": "Name", "value": "String" } } }], "selectionSet": { "kind": "SelectionSet", "selections": [{ "kind": "Field", "name": { "kind": "Name", "value": "myPrograms" }, "arguments": [{ "kind": "Argument", "name": { "kind": "Name", "value": "search" }, "value": { "kind": "Variable", "name": { "kind": "Name", "value": "search" } } }, { "kind": "Argument", "name": { "kind": "Name", "value": "page" }, "value": { "kind": "Variable", "name": { "kind": "Name", "value": "page" } } }, { "kind": "Argument", "name": { "kind": "Name", "value": "limit" }, "value": { "kind": "Variable", "name": { "kind": "Name", "value": "limit" } } }, { "kind": "Argument", "name": { "kind": "Name", "value": "sortBy" }, "value": { "kind": "Variable", "name": { "kind": "Name", "value": "sortBy" } } }, { "kind": "Argument", "name": { "kind": "Name", "value": "order" }, "value": { "kind": "Variable", "name": { "kind": "Name", "value": "order" } } }], "selectionSet": { "kind": "SelectionSet", "selections": [{ "kind": "Field", "name": { "kind": "Name", "value": "currentPage" } }, { "kind": "Field", "name": { "kind": "Name", "value": "totalPages" } }, { "kind": "Field", "name": { "kind": "Name", "value": "programs" }, "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": "status" } }, { "kind": "Field", "name": { "kind": "Name", "value": "description" } }, { "kind": "Field", "name": { "kind": "Name", "value": "startedAt" } }, { "kind": "Field", "name": { "kind": "Name", "value": "endedAt" } }, { "kind": "Field", "name": { "kind": "Name", "value": "userRole" } }] } }] } }] } }] } as unknown as DocumentNode; +export const GetProgramDetailsDocument = { "kind": "Document", "definitions": [{ "kind": "OperationDefinition", "operation": "query", "name": { "kind": "Name", "value": "GetProgramDetails" }, "variableDefinitions": [{ "kind": "VariableDefinition", "variable": { "kind": "Variable", "name": { "kind": "Name", "value": "programKey" } }, "type": { "kind": "NonNullType", "type": { "kind": "NamedType", "name": { "kind": "Name", "value": "String" } } } }], "selectionSet": { "kind": "SelectionSet", "selections": [{ "kind": "Field", "name": { "kind": "Name", "value": "getProgram" }, "arguments": [{ "kind": "Argument", "name": { "kind": "Name", "value": "programKey" }, "value": { "kind": "Variable", "name": { "kind": "Name", "value": "programKey" } } }], "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": "status" } }, { "kind": "Field", "name": { "kind": "Name", "value": "menteesLimit" } }, { "kind": "Field", "name": { "kind": "Name", "value": "experienceLevels" } }, { "kind": "Field", "name": { "kind": "Name", "value": "startedAt" } }, { "kind": "Field", "name": { "kind": "Name", "value": "endedAt" } }, { "kind": "Field", "name": { "kind": "Name", "value": "domains" } }, { "kind": "Field", "name": { "kind": "Name", "value": "tags" } }, { "kind": "Field", "name": { "kind": "Name", "value": "admins" }, "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 GetProgramAndModulesDocument = { "kind": "Document", "definitions": [{ "kind": "OperationDefinition", "operation": "query", "name": { "kind": "Name", "value": "GetProgramAndModules" }, "variableDefinitions": [{ "kind": "VariableDefinition", "variable": { "kind": "Variable", "name": { "kind": "Name", "value": "programKey" } }, "type": { "kind": "NonNullType", "type": { "kind": "NamedType", "name": { "kind": "Name", "value": "String" } } } }], "selectionSet": { "kind": "SelectionSet", "selections": [{ "kind": "Field", "name": { "kind": "Name", "value": "getProgram" }, "arguments": [{ "kind": "Argument", "name": { "kind": "Name", "value": "programKey" }, "value": { "kind": "Variable", "name": { "kind": "Name", "value": "programKey" } } }], "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": "status" } }, { "kind": "Field", "name": { "kind": "Name", "value": "menteesLimit" } }, { "kind": "Field", "name": { "kind": "Name", "value": "experienceLevels" } }, { "kind": "Field", "name": { "kind": "Name", "value": "startedAt" } }, { "kind": "Field", "name": { "kind": "Name", "value": "endedAt" } }, { "kind": "Field", "name": { "kind": "Name", "value": "domains" } }, { "kind": "Field", "name": { "kind": "Name", "value": "tags" } }, { "kind": "Field", "name": { "kind": "Name", "value": "admins" }, "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": "recentMilestones" }, "selectionSet": { "kind": "SelectionSet", "selections": [{ "kind": "Field", "name": { "kind": "Name", "value": "id" } }, { "kind": "Field", "name": { "kind": "Name", "value": "title" } }, { "kind": "Field", "name": { "kind": "Name", "value": "state" } }, { "kind": "Field", "name": { "kind": "Name", "value": "openIssuesCount" } }, { "kind": "Field", "name": { "kind": "Name", "value": "closedIssuesCount" } }, { "kind": "Field", "name": { "kind": "Name", "value": "createdAt" } }, { "kind": "Field", "name": { "kind": "Name", "value": "repositoryName" } }, { "kind": "Field", "name": { "kind": "Name", "value": "organizationName" } }, { "kind": "Field", "name": { "kind": "Name", "value": "url" } }, { "kind": "Field", "name": { "kind": "Name", "value": "author" }, "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": "getProgramModules" }, "arguments": [{ "kind": "Argument", "name": { "kind": "Name", "value": "programKey" }, "value": { "kind": "Variable", "name": { "kind": "Name", "value": "programKey" } } }], "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": "domains" } }, { "kind": "Field", "name": { "kind": "Name", "value": "tags" } }, { "kind": "Field", "name": { "kind": "Name", "value": "labels" } }, { "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 GetProgramAdminDetailsDocument = { "kind": "Document", "definitions": [{ "kind": "OperationDefinition", "operation": "query", "name": { "kind": "Name", "value": "GetProgramAdminDetails" }, "variableDefinitions": [{ "kind": "VariableDefinition", "variable": { "kind": "Variable", "name": { "kind": "Name", "value": "programKey" } }, "type": { "kind": "NonNullType", "type": { "kind": "NamedType", "name": { "kind": "Name", "value": "String" } } } }], "selectionSet": { "kind": "SelectionSet", "selections": [{ "kind": "Field", "name": { "kind": "Name", "value": "getProgram" }, "arguments": [{ "kind": "Argument", "name": { "kind": "Name", "value": "programKey" }, "value": { "kind": "Variable", "name": { "kind": "Name", "value": "programKey" } } }], "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": "startedAt" } }, { "kind": "Field", "name": { "kind": "Name", "value": "endedAt" } }, { "kind": "Field", "name": { "kind": "Name", "value": "admins" }, "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; \ No newline at end of file diff --git a/frontend/src/utils/exportUtils.ts b/frontend/src/utils/exportUtils.ts new file mode 100644 index 0000000000..8496ca7343 --- /dev/null +++ b/frontend/src/utils/exportUtils.ts @@ -0,0 +1,103 @@ +/** + * Export utilities for downloading issue data in CSV/JSON formats. + * + * This module provides functions to trigger GraphQL export queries + * and download the resulting data as files. + */ + +export type ExportFormat = 'CSV' | 'JSON' + +export interface ExportOptions { + programKey: string + moduleKey: string + format: ExportFormat + label?: string | null +} + +export interface ExportResult { + content: string + filename: string + mimeType: string + count: number +} + +/** + * Triggers a file download in the browser. + * + * @param content - The file content as a string + * @param filename - The name for the downloaded file + * @param mimeType - The MIME type of the file + */ +export function downloadFile(content: string, filename: string, mimeType: string): void { + const blob = new Blob([content], { type: mimeType }) + const url = window.URL.createObjectURL(blob) + + const link = document.createElement('a') + link.href = url + link.download = filename + document.body.appendChild(link) + link.click() + + // Cleanup + document.body.removeChild(link) + window.URL.revokeObjectURL(url) +} + +/** + * Builds the GraphQL query for exporting issues. + * + * @param options - Export options including format and filters + * @returns GraphQL query string + */ +export function buildExportQuery(options: ExportOptions): string { + const { programKey, moduleKey, format, label } = options + const labelArg = label && label !== 'all' ? `, label: "${label}"` : '' + + return ` + query ExportModuleIssues { + getModule(programKey: "${programKey}", moduleKey: "${moduleKey}") { + exportIssues(format: ${format}${labelArg}) { + content + filename + mimeType + count + } + } + } + ` +} + +/** + * Parse the GraphQL response to extract export result. + * + * @param data - GraphQL response data + * @returns ExportResult or null if not found + */ +export function parseExportResponse(data: unknown): ExportResult | null { + if (!data || typeof data !== 'object') return null + + const responseData = data as { getModule?: { exportIssues?: ExportResult } } + + if (!responseData.getModule?.exportIssues) return null + + return responseData.getModule.exportIssues +} + +/** + * Get user-friendly error message for export failures. + * + * @param error - The error object + * @returns Human-readable error message + */ +export function getExportErrorMessage(error: unknown): string { + if (error instanceof Error) { + if (error.message.includes('network')) { + return 'Network error. Please check your connection and try again.' + } + if (error.message.includes('timeout')) { + return 'Export timed out. Try exporting fewer issues.' + } + return error.message + } + return 'An unexpected error occurred during export.' +} diff --git a/frontend/src/utils/sortingOptions.ts b/frontend/src/utils/sortingOptions.ts index 4acc11b877..9261b20e7e 100644 --- a/frontend/src/utils/sortingOptions.ts +++ b/frontend/src/utils/sortingOptions.ts @@ -7,3 +7,11 @@ export const sortOptionsProject = [ { label: 'Name', key: 'name' }, { label: 'Stars', key: 'stars_count' }, ] + +export const sortOptionsMentorshipPrograms = [ + { label: 'Newest', key: 'default' }, + { label: 'Name', key: 'name' }, + { label: 'Start Date', key: 'started_at' }, + { label: 'End Date', key: 'ended_at' }, +] +