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 @@

    @@ -277,7 +277,7 @@

    - - + @@ -221,7 +221,7 @@