diff --git a/client/components/SortableIssuesTable/index.jsx b/client/components/SortableIssuesTable/index.jsx
new file mode 100644
index 000000000..3ed20a2a7
--- /dev/null
+++ b/client/components/SortableIssuesTable/index.jsx
@@ -0,0 +1,198 @@
+import React, { useMemo, useState } from 'react';
+import { ThemeTable, ThemeTableUnavailable } from '../common/ThemeTable';
+import { dates } from 'shared';
+import { NoneText } from '../TestPlanVersionsPage';
+import SortableTableHeader, {
+ TABLE_SORT_ORDERS
+} from '../common/SortableTableHeader';
+import FilterButtons from '../common/FilterButtons';
+import { IssuePropType } from '../common/proptypes';
+import PropTypes from 'prop-types';
+
+const FILTER_OPTIONS = {
+ OPEN: 'Open',
+ CLOSED: 'Closed',
+ ALL: 'All'
+};
+
+const SORT_FIELDS = {
+ AUTHOR: 'author',
+ TITLE: 'title',
+ STATUS: 'status',
+ AT: 'at',
+ CREATED_AT: 'createdAt',
+ CLOSED_AT: 'closedAt'
+};
+
+const SortableIssuesTable = ({ issues }) => {
+ const [activeSort, setActiveSort] = useState(SORT_FIELDS.STATUS);
+ const [sortOrder, setSortOrder] = useState(TABLE_SORT_ORDERS.ASC);
+ const [activeFilter, setActiveFilter] = useState('OPEN');
+
+ const issueStats = useMemo(() => {
+ const openIssues = issues.filter(issue => issue.isOpen).length;
+ const closedIssues = issues.length - openIssues;
+ return { openIssues, closedIssues };
+ }, [issues]);
+
+ // Helper function to get sortable value from issue
+ const getSortableValue = (issue, sortField) => {
+ switch (sortField) {
+ case SORT_FIELDS.AUTHOR:
+ return issue.author;
+ case SORT_FIELDS.TITLE:
+ return issue.title;
+ case SORT_FIELDS.AT:
+ return issue.at?.name ?? '';
+ case SORT_FIELDS.CREATED_AT:
+ return new Date(issue.createdAt);
+ case SORT_FIELDS.CLOSED_AT:
+ return issue.closedAt ? new Date(issue.closedAt) : new Date(0);
+ default:
+ return '';
+ }
+ };
+
+ const compareByStatus = (a, b) => {
+ if (a.isOpen !== b.isOpen) {
+ if (sortOrder === TABLE_SORT_ORDERS.ASC) {
+ return a.isOpen ? -1 : 1; // Open first for ascending
+ }
+ return a.isOpen ? 1 : -1; // Closed first for descending
+ }
+ // If status is the same, sort by date created (newest first)
+ return new Date(b.createdAt) - new Date(a.createdAt);
+ };
+
+ const compareValues = (aValue, bValue) => {
+ return sortOrder === TABLE_SORT_ORDERS.ASC
+ ? aValue < bValue
+ ? -1
+ : 1
+ : aValue > bValue
+ ? -1
+ : 1;
+ };
+
+ const sortedAndFilteredIssues = useMemo(() => {
+ // Filter issues
+ const filtered =
+ activeFilter === 'ALL'
+ ? issues
+ : issues.filter(issue => issue.isOpen === (activeFilter === 'OPEN'));
+
+ // Sort issues
+ return filtered.sort((a, b) => {
+ // Special handling for status sorting
+ if (activeSort === SORT_FIELDS.STATUS) {
+ return compareByStatus(a, b);
+ }
+
+ // Normal sorting for other fields
+ const aValue = getSortableValue(a, activeSort);
+ const bValue = getSortableValue(b, activeSort);
+ return compareValues(aValue, bValue);
+ });
+ }, [issues, activeSort, sortOrder, activeFilter]);
+
+ const handleSort = column => newSortOrder => {
+ setActiveSort(column);
+ setSortOrder(newSortOrder);
+ };
+
+ const renderTableHeader = () => (
+
+
+ {[
+ { field: SORT_FIELDS.AUTHOR, title: 'Author' },
+ { field: SORT_FIELDS.TITLE, title: 'Issue' },
+ { field: SORT_FIELDS.STATUS, title: 'Status' },
+ { field: SORT_FIELDS.AT, title: 'Assistive Technology' },
+ { field: SORT_FIELDS.CREATED_AT, title: 'Created On' },
+ { field: SORT_FIELDS.CLOSED_AT, title: 'Closed On' }
+ ].map(({ field, title }) => (
+
+ ))}
+
+
+ );
+
+ const renderTableBody = () => (
+
+ {sortedAndFilteredIssues.map(issue => (
+
+
+
+ {issue.author}
+
+ |
+
+
+ {issue.title}
+
+ |
+ {issue.isOpen ? 'Open' : 'Closed'} |
+ {issue.at?.name ?? 'AT not specified'} |
+ {dates.convertDateToString(issue.createdAt, 'MMM D, YYYY')} |
+
+ {!issue.closedAt ? (
+ N/A
+ ) : (
+ dates.convertDateToString(issue.closedAt, 'MMM D, YYYY')
+ )}
+ |
+
+ ))}
+
+ );
+
+ return (
+ <>
+
+ GitHub Issues ({issueStats.openIssues} open, {issueStats.closedIssues}
+ closed)
+
+
+ {!sortedAndFilteredIssues.length ? (
+
+ No GitHub Issues
+
+ ) : (
+
+ {renderTableHeader()}
+ {renderTableBody()}
+
+ )}
+ >
+ );
+};
+
+SortableIssuesTable.propTypes = {
+ issues: PropTypes.arrayOf(IssuePropType).isRequired
+};
+
+export default SortableIssuesTable;
diff --git a/client/components/TestPlanVersionsPage/index.jsx b/client/components/TestPlanVersionsPage/index.jsx
index 0dce14740..e64c62940 100644
--- a/client/components/TestPlanVersionsPage/index.jsx
+++ b/client/components/TestPlanVersionsPage/index.jsx
@@ -7,7 +7,6 @@ import { Helmet } from 'react-helmet';
import { Container } from 'react-bootstrap';
import {
ThemeTable,
- ThemeTableUnavailable,
ThemeTableHeaderH3 as UnstyledThemeTableHeader
} from '../common/ThemeTable';
import VersionString from '../common/VersionString';
@@ -22,6 +21,7 @@ import {
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import DisclosureComponentUnstyled from '../common/DisclosureComponent';
import useForceUpdate from '../../hooks/useForceUpdate';
+import SortableIssuesTable from '../SortableIssuesTable';
const DisclosureContainer = styled.div`
.timeline-for-version-table {
@@ -40,7 +40,7 @@ const DisclosureComponent = styled(DisclosureComponentUnstyled)`
}
`;
-const NoneText = styled.span`
+export const NoneText = styled.span`
font-style: italic;
color: #6a7989;
`;
@@ -390,59 +390,7 @@ const TestPlanVersionsPage = () => {
>
)}
- GitHub Issues
- {!issues.length ? (
-
- No GitHub Issues
-
- ) : (
-
-
-
- Author |
- Issue |
- Status |
- AT |
- Created On |
- Closed On |
-
-
-
- {issues.map(issue => {
- return (
-
-
-
- {issue.author}
-
- |
-
-
- {issue.title}
-
- |
- {issue.isOpen ? 'Open' : 'Closed'} |
- {issue.at?.name ?? 'AT not specified'} |
-
- {dates.convertDateToString(issue.createdAt, 'MMM D, YYYY')}
- |
-
- {!issue.closedAt ? (
- N/A
- ) : (
- dates.convertDateToString(issue.closedAt, 'MMM D, YYYY')
- )}
- |
-
- );
- })}
-
-
- )}
+
Timeline for All Versions
diff --git a/client/components/common/FilterButtons/index.jsx b/client/components/common/FilterButtons/index.jsx
index d9b35ea3c..84a9d9c7d 100644
--- a/client/components/common/FilterButtons/index.jsx
+++ b/client/components/common/FilterButtons/index.jsx
@@ -42,6 +42,7 @@ const FilterButtons = ({
return (
{
);
});
});
+
+describe('Issues table interactions', () => {
+ const TEST_URL = '/data-management/alert';
+
+ it('displays correct issue counts in heading', async () => {
+ await getPage({ role: false, url: TEST_URL }, async page => {
+ await page.waitForSelector('[data-test="issues-table"]');
+ const headingText = await page.$eval(
+ 'h2#github-issues',
+ el => el.textContent
+ );
+ expect(headingText.replace(/\s+/g, ' ').trim()).toBe(
+ 'GitHub Issues (1 open, 1 closed)'
+ );
+ });
+ });
+
+ it('filters issues correctly', async () => {
+ await getPage({ role: false, url: TEST_URL }, async page => {
+ await page.waitForSelector('[data-test="issues-table"]');
+
+ // Check initial state (Open filter should be active by default)
+ const isOpenPressed = await page.$eval('[data-test="filter-open"]', el =>
+ el.getAttribute('aria-pressed')
+ );
+ expect(isOpenPressed).toBe('true');
+
+ // Verify only open issues are shown initially
+ let visibleIssues = await page.$$eval(
+ '[data-test="issue-row"]',
+ rows => rows.length
+ );
+ let firstRowStatus = await page.$eval('[data-test="issue-row"]', row =>
+ row.getAttribute('data-status')
+ );
+ expect(visibleIssues).toBe(1);
+ expect(firstRowStatus).toBe('open');
+
+ // Click "Closed" filter
+ await page.click('[data-test="filter-closed"]');
+
+ // Verify only closed issues are shown
+ visibleIssues = await page.$$eval(
+ '[data-test="issue-row"]',
+ rows => rows.length
+ );
+ firstRowStatus = await page.$eval('[data-test="issue-row"]', row =>
+ row.getAttribute('data-status')
+ );
+ expect(visibleIssues).toBe(1);
+ expect(firstRowStatus).toBe('closed');
+
+ // Click "All" filter
+ await page.click('[data-test="filter-all"]');
+
+ // Verify all issues are shown
+ visibleIssues = await page.$$eval(
+ '[data-test="issue-row"]',
+ rows => rows.length
+ );
+ expect(visibleIssues).toBe(2);
+ });
+ });
+
+ it('sorts issues by different columns', async () => {
+ await getPage({ role: false, url: TEST_URL }, async page => {
+ await page.waitForSelector('[data-test="issues-table"]');
+
+ // Click "All" filter first so we can see all issues
+ await page.click('[data-test="filter-all"]');
+ await page.waitForSelector('[data-test="issue-row"]');
+
+ // Initial sort is by Status (ascending)
+ let firstRowStatus = await page.$eval(
+ '[data-test="issue-row"]:first-child [data-test="issue-status"]',
+ el => el.textContent.trim()
+ );
+ expect(firstRowStatus).toBe('Open');
+
+ // Click Status header to reverse sort
+ await page.click('th[role="columnheader"] button::-p-text(Status)');
+
+ // Wait for the table to update after sorting
+ await page.waitForSelector('[data-test="issue-row"]');
+
+ firstRowStatus = await page.$eval(
+ '[data-test="issue-row"]:first-child [data-test="issue-status"]',
+ el => el.textContent.trim()
+ );
+ expect(firstRowStatus).toBe('Closed');
+
+ // Test sorting by Author
+ await page.click('th[role="columnheader"] button::-p-text(Author)');
+
+ // Wait for the table to update after sorting
+ await page.waitForSelector('[data-test="issue-row"]');
+
+ const firstRowAuthor = await page.$eval(
+ '[data-test="issue-row"]:first-child td:first-child',
+ el => el.textContent.trim()
+ );
+ expect(firstRowAuthor).toBe('alflennik');
+ });
+ });
+
+ it('maintains filter state when sorting changes', async () => {
+ await getPage({ role: false, url: TEST_URL }, async page => {
+ await page.waitForSelector('[data-test="issues-table"]');
+
+ // Select "Open" filter (should already be selected by default)
+ await page.click('[data-test="filter-open"]');
+
+ // Sort by Author
+ await page.click('th[role="columnheader"] button::-p-text(Author)');
+
+ // Verify only open issues are still shown
+ const visibleIssues = await page.$$eval(
+ '[data-test="issue-row"]',
+ rows => ({
+ length: rows.length,
+ status: rows[0].getAttribute('data-status')
+ })
+ );
+ expect(visibleIssues.length).toBe(1);
+ expect(visibleIssues.status).toBe('open');
+ });
+ });
+});
diff --git a/client/tests/e2e/TestReview.e2e.test.js b/client/tests/e2e/TestReview.e2e.test.js
index 0f7bab857..8a6101666 100644
--- a/client/tests/e2e/TestReview.e2e.test.js
+++ b/client/tests/e2e/TestReview.e2e.test.js
@@ -35,7 +35,7 @@ describe('Test Review page', () => {
const popupPage = pages[pages.length - 1];
// Check for 'Run Test Setup' button
- await popupPage.waitForSelector('button ::-p-text(Run Test Setup)');
+ await popupPage.waitForSelector('button.button-run-test-setup');
};
it('renders page for review page before test format v2', async () => {
diff --git a/client/tests/e2e/snapshots/saved/_data-management.html b/client/tests/e2e/snapshots/saved/_data-management.html
index 31435f1b7..7625457c5 100644
--- a/client/tests/e2e/snapshots/saved/_data-management.html
+++ b/client/tests/e2e/snapshots/saved/_data-management.html
@@ -210,10 +210,10 @@