Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Sortable issues table, improved issues table heading #1266

Merged
merged 7 commits into from
Nov 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
198 changes: 198 additions & 0 deletions client/components/SortableIssuesTable/index.jsx
Original file line number Diff line number Diff line change
@@ -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 = () => (
<thead>
<tr>
{[
{ 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 }) => (
<SortableTableHeader
key={field}
title={title}
active={activeSort === field}
onSort={handleSort(field)}
data-test={`sort-${field.toLowerCase()}`}
/>
))}
</tr>
</thead>
);

const renderTableBody = () => (
<tbody>
{sortedAndFilteredIssues.map(issue => (
<tr
key={issue.link}
data-test="issue-row"
data-status={issue.isOpen ? 'open' : 'closed'}
>
<td>
<a
target="_blank"
rel="noreferrer"
href={`https://github.com/${issue.author}`}
>
{issue.author}
</a>
</td>
<td>
<a target="_blank" rel="noreferrer" href={issue.link}>
{issue.title}
</a>
</td>
<td data-test="issue-status">{issue.isOpen ? 'Open' : 'Closed'}</td>
<td>{issue.at?.name ?? 'AT not specified'}</td>
<td>{dates.convertDateToString(issue.createdAt, 'MMM D, YYYY')}</td>
<td>
{!issue.closedAt ? (
<NoneText>N/A</NoneText>
) : (
dates.convertDateToString(issue.closedAt, 'MMM D, YYYY')
)}
</td>
</tr>
))}
</tbody>
);

return (
<>
<h2 id="github-issues">
GitHub Issues ({issueStats.openIssues} open, {issueStats.closedIssues}
&nbsp;closed)
</h2>
<FilterButtons
filterLabel="Filter"
filterAriaLabel="Filter GitHub issues"
filterOptions={FILTER_OPTIONS}
activeFilter={activeFilter}
onFilterChange={setActiveFilter}
/>
{!sortedAndFilteredIssues.length ? (
<ThemeTableUnavailable aria-labelledby="github-issues">
No GitHub Issues
</ThemeTableUnavailable>
) : (
<ThemeTable
bordered
aria-labelledby="github-issues"
data-test="issues-table"
>
{renderTableHeader()}
{renderTableBody()}
</ThemeTable>
)}
</>
);
};

SortableIssuesTable.propTypes = {
issues: PropTypes.arrayOf(IssuePropType).isRequired
};

export default SortableIssuesTable;
58 changes: 3 additions & 55 deletions client/components/TestPlanVersionsPage/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 {
Expand All @@ -40,7 +40,7 @@ const DisclosureComponent = styled(DisclosureComponentUnstyled)`
}
`;

const NoneText = styled.span`
export const NoneText = styled.span`
font-style: italic;
color: #6a7989;
`;
Expand Down Expand Up @@ -390,59 +390,7 @@ const TestPlanVersionsPage = () => {
<PageSpacer />
</>
)}
<ThemeTableHeader id="github-issues">GitHub Issues</ThemeTableHeader>
{!issues.length ? (
<ThemeTableUnavailable aria-labelledby="github-issues">
No GitHub Issues
</ThemeTableUnavailable>
) : (
<ThemeTable bordered responsive aria-labelledby="github-issues">
<thead>
<tr>
<th>Author</th>
<th>Issue</th>
<th>Status</th>
<th>AT</th>
<th>Created On</th>
<th>Closed On</th>
</tr>
</thead>
<tbody>
{issues.map(issue => {
return (
<tr key={issue.link}>
<td>
<a
target="_blank"
rel="noreferrer"
href={`https://github.com/${issue.author}`}
>
{issue.author}
</a>
</td>
<td>
<a target="_blank" rel="noreferrer" href={issue.link}>
{issue.title}
</a>
</td>
<td>{issue.isOpen ? 'Open' : 'Closed'}</td>
<td>{issue.at?.name ?? 'AT not specified'}</td>
<td>
{dates.convertDateToString(issue.createdAt, 'MMM D, YYYY')}
</td>
<td>
{!issue.closedAt ? (
<NoneText>N/A</NoneText>
) : (
dates.convertDateToString(issue.closedAt, 'MMM D, YYYY')
)}
</td>
</tr>
);
})}
</tbody>
</ThemeTable>
)}
<SortableIssuesTable issues={issues} />
<PageSpacer />
<ThemeTableHeader id="timeline-for-all-versions">
Timeline for All Versions
Expand Down
1 change: 1 addition & 0 deletions client/components/common/FilterButtons/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ const FilterButtons = ({
return (
<li key={value}>
<StyledFilterButton
data-test={`filter-${value.toLowerCase()}`}
variant="secondary"
aria-pressed={isActive}
active={isActive}
Expand Down
34 changes: 16 additions & 18 deletions client/components/common/SortableTableHeader/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,40 +10,38 @@ import {
import { useAriaLiveRegion } from '../../providers/AriaLiveRegionProvider';

const SortableTableHeaderWrapper = styled.th`
position: relative;
padding: 0;
background: #e9ebee;
padding: 0 !important;
height: 100%;
`;

const SortableTableHeaderButton = styled(Button)`
background: #e9ebee;
background: transparent;
border: none;
color: black;
font-size: 1rem;
padding: 0;
font-weight: 700;
text-align: left;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
width: 100%;
min-height: 100%;
display: flex;
justify-content: space-between;
align-items: flex-end;
padding: 8px 12px;
padding: 0.5rem;
border-radius: 0;
z-index: 0;
display: flex;
margin: 0;

// Force the button to stretch
position: relative;
height: stretch;
height: -webkit-fill-available;
height: -moz-available;

&:hover,
&:focus {
background: #e9ebee;
z-index: 1;
color: #0b60ab;
background-color: var(--bs-table-hover-bg);
}

&:hover {
border: none;
}
`;

const InactiveIcon = styled(FontAwesomeIcon)`
Expand Down
Loading
Loading