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 ( + + + + + 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.' +}