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/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/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/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 (
+
+
+ }
+ className={className}
+ aria-label="Export issues"
+ >
+ Export
+
+
+ handleExport(key as ExportFormat)}
+ disabledKeys={isExporting ? ['CSV', 'JSON'] : []}
+ >
+ 📊}
+ >
+ Export as CSV
+
+ 📄}
+ >
+ Export as JSON
+
+
+
+ )
+}
+
+export default ExportButton
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.'
+}