Skip to content

Commit

Permalink
[EASI-4554] GRB participation needed banner (#2739)
Browse files Browse the repository at this point in the history
* GRB participation banner

* GetSystemIntakesWithReviewRequested query

* Intakes table

* Add requesterComponent to query

* Table formatting

* Table pagination

* Unit tests

* Unit test typo

* Hide component behind flag

* Mock data for table sorting

* Fix project name column sorting

* GRB date default sorting

* Unit test for sorting function

* Fix mock data

* Divider
  • Loading branch information
aterstriep authored Aug 6, 2024
1 parent 5eb6b74 commit e667d3b
Show file tree
Hide file tree
Showing 9 changed files with 560 additions and 1 deletion.
127 changes: 127 additions & 0 deletions src/components/GrbParticipationNeeded/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {
GetSystemIntakesWithReviewRequestedDocument,
GetSystemIntakesWithReviewRequestedQuery,
SystemIntakeWithReviewRequestedFragment
} from 'gql/gen/graphql';
import { DateTime } from 'luxon';

import { systemIntakesWithReviewRequested } from 'data/mock/systemIntake';
import { MockedQuery } from 'types/util';
import { formatDateLocal } from 'utils/date';
import { getPersonNameAndComponentAcronym } from 'utils/getPersonNameAndComponent';
import VerboseMockedProvider from 'utils/testing/VerboseMockedProvider';

import GrbParticipationNeeded, { sortGrbDates } from '.';

const mockSystemIntakes = systemIntakesWithReviewRequested;

const getSystemIntakesWithReviewRequestedQuery = (
systemIntakes: SystemIntakeWithReviewRequestedFragment[]
): MockedQuery<GetSystemIntakesWithReviewRequestedQuery> => ({
request: {
query: GetSystemIntakesWithReviewRequestedDocument,
variables: {}
},
result: {
data: {
__typename: 'Query',
systemIntakesWithReviewRequested: systemIntakes
}
}
});

describe('GRB participation needed', () => {
it('renders component if user is GRB reviewer', async () => {
render(
<VerboseMockedProvider
mocks={[getSystemIntakesWithReviewRequestedQuery(mockSystemIntakes)]}
>
<GrbParticipationNeeded />
</VerboseMockedProvider>
);

expect(
await screen.findByRole('heading', { name: 'GRB participation needed' })
);
});

it('does not render component if user is not a GRB reviewer', () => {
render(
<VerboseMockedProvider
mocks={[getSystemIntakesWithReviewRequestedQuery([])]}
>
<GrbParticipationNeeded />
</VerboseMockedProvider>
);

expect(
screen.queryByRole('heading', { name: 'GRB participation needed' })
).toBeNull();
});

it('formats system intake for table', async () => {
render(
<MemoryRouter>
<VerboseMockedProvider
mocks={[getSystemIntakesWithReviewRequestedQuery(mockSystemIntakes)]}
>
<GrbParticipationNeeded />
</VerboseMockedProvider>
</MemoryRouter>
);

const testIntake = mockSystemIntakes[0];

userEvent.click(
await screen.findByRole('button', { name: 'Show GRB reviews' })
);

expect(
screen.getByRole('link', { name: testIntake.requestName! })
).toHaveAttribute(
'href',
`/governance-review-board/${testIntake.id}/grb-review`
);

expect(
screen.getByText(
getPersonNameAndComponentAcronym(
testIntake.requesterName!,
testIntake.requesterComponent
)
)
).toBeInTheDocument();

expect(
screen.getByText(formatDateLocal(testIntake.grbDate!, 'MM/dd/yyyy'))
).toBeInTheDocument();
});

// Check that sorting function used in table correctly sorts GRB dates
it('sorts GRB dates', () => {
const currentYear = DateTime.local().year;

const dates = mockSystemIntakes.map(value => value.grbDate) as Array<
string | null
>;

// sorted dates using function, reversed to get desc order to match table
const sortedDates = dates.sort(sortGrbDates).reverse();

const expectedSortOrder = [
null,
null,
`${currentYear + 1}-06-09T03:11:24.478056Z`,
`${currentYear + 2}-10-02T03:11:24.478056Z`,
'2024-03-29T03:11:24.478056Z',
'2023-01-18T03:11:24.478056Z',
'2020-10-08T03:11:24.478056Z'
];

sortedDates.forEach((date, index) => date === expectedSortOrder[index]);
});
});
245 changes: 245 additions & 0 deletions src/components/GrbParticipationNeeded/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
import React, { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Column, usePagination, useSortBy, useTable } from 'react-table';
import {
Button,
IconGroups,
IconVisibilityOff,
IconVisiblity,
Table
} from '@trussworks/react-uswds';
import {
SystemIntakeWithReviewRequestedFragment,
useGetSystemIntakesWithReviewRequestedQuery
} from 'gql/gen/graphql';

import UswdsReactLink from 'components/LinkWrapper';
import Divider from 'components/shared/Divider';
import IconButton from 'components/shared/IconButton';
import TablePagination from 'components/TablePagination';
import { formatDateLocal, isDateInPast } from 'utils/date';
import { getPersonNameAndComponentAcronym } from 'utils/getPersonNameAndComponent';
import {
currentTableSortDescription,
getColumnSortStatus,
getHeaderSortIcon
} from 'utils/tableSort';

/**
* Sort GRB dates
*
* No date set - order first,
* Future dates - closest dates first,
* Past dates - order last
*/
export const sortGrbDates = (
grbDateA: string | null,
grbDateB: string | null
) => {
// Sort null dates first
if (grbDateA === null) return 1;
if (grbDateB === null) return -1;

// Sort past dates
if (isDateInPast(grbDateA) || isDateInPast(grbDateB)) {
return grbDateA < grbDateB ? -1 : 1;
}

// Sort future dates
if (grbDateA === grbDateB) return 0;
return grbDateA > grbDateB ? -1 : 1;
};

/**
* GRB Participation Needed alert box with table of system intakes
*
* Only shows if GRB review has been requested from user on at least one intake
*/
const GrbParticipationNeeded = () => {
const { t } = useTranslation('grbReview');

// Toggles GRB reviews table
const [showGrbReviews, setShowGrbReviews] = useState<boolean>(false);

const { data, loading } = useGetSystemIntakesWithReviewRequestedQuery();

const systemIntakes = data?.systemIntakesWithReviewRequested || [];

const columns = useMemo<
Column<SystemIntakeWithReviewRequestedFragment>[]
>(() => {
return [
{
Header: t<string>('intake:fields.projectName'),
accessor: 'requestName',
Cell: cell => {
const { row, value } = cell;

return (
<UswdsReactLink
to={`/governance-review-board/${row.original.id}/grb-review`}
>
{value}
</UswdsReactLink>
);
}
},
{
Header: t<string>('intake:fields.requester'),
accessor: ({ requesterName, requesterComponent }) =>
requesterName
? getPersonNameAndComponentAcronym(
requesterName,
requesterComponent
)
: ''
},
{
Header: t<string>('homepage.grbDate'),
accessor: 'grbDate',
Cell: ({ value }) =>
value
? formatDateLocal(value, 'MM/dd/yyyy')
: t<string>('homepage.noDateSet'),
sortType: (rowA, rowB) =>
sortGrbDates(rowA.values.grbDate, rowB.values.grbDate)
}
];
}, [t]);

const table = useTable(
{
columns,
data: systemIntakes,
autoResetSortBy: false,
autoResetPage: true,
initialState: {
sortBy: useMemo(() => [{ id: 'grbDate', desc: true }], []),
pageIndex: 0,
pageSize: 5
}
},
useSortBy,
usePagination
);

const {
getTableBodyProps,
getTableProps,
headerGroups,
prepareRow,
page,
rows
} = table;

// Only show if user has been requested as reviewer
if (loading || systemIntakes.length === 0) return null;

return (
<>
<div className="bg-primary-lighter padding-4">
<div className="display-flex flex-align-start flex-justify">
<h2 className="margin-y-0 margin-right-2">
{t('homepage.participationNeeded')}
</h2>
<IconGroups size={4} className="text-primary" />
</div>

<p className="line-height-body-5 margin-top-1 margin-bottom-3">
{t('homepage.participationNeededText')}
</p>

{/* Toggle GRB reviews button */}
<IconButton
onClick={() => setShowGrbReviews(!showGrbReviews)}
icon={showGrbReviews ? <IconVisibilityOff /> : <IconVisiblity />}
type="button"
unstyled
>
{showGrbReviews
? t('homepage.hideGrbReviews')
: t('homepage.showGrbReviews')}
</IconButton>

{showGrbReviews && (
<div className="margin-top-4 margin-bottom-neg-2">
<Table bordered={false} fullWidth {...getTableProps()}>
<thead>
{headerGroups.map(headerGroup => (
<tr {...headerGroup.getHeaderGroupProps()}>
{headerGroup.headers.map((column, index) => (
<th
{...column.getHeaderProps(
column.getSortByToggleProps()
)}
aria-sort={getColumnSortStatus(column)}
scope="col"
className="border-bottom-2px bg-primary-lighter"
>
<Button
type="button"
unstyled
className="width-full display-flex"
{...column.getSortByToggleProps()}
>
<div className="flex-fill text-no-wrap">
{column.render('Header')}
</div>
<div className="position-relative width-205 margin-left-05">
{getHeaderSortIcon(column)}
</div>
</Button>
</th>
))}
</tr>
))}
</thead>
<tbody {...getTableBodyProps()}>
{page.map(row => {
prepareRow(row);
return (
<tr {...row.getRowProps()}>
{row.cells.map((cell, index) => {
return (
<td
{...cell.getCellProps()}
className="bg-primary-lighter"
>
{cell.render('Cell')}
</td>
);
})}
</tr>
);
})}
</tbody>
</Table>

{rows.length > 0 && (
<>
<TablePagination
{...table}
pageIndex={table.state.pageIndex}
pageSize={table.state.pageSize}
page={[]}
className="desktop:grid-col-fill desktop:padding-bottom-0"
/>

<div
className="usa-sr-only usa-table__announcement-region"
aria-live="polite"
>
{currentTableSortDescription(headerGroups[0])}
</div>
</>
)}
</div>
)}
</div>

<Divider className="margin-top-6" />
</>
);
};

export default GrbParticipationNeeded;
Loading

0 comments on commit e667d3b

Please sign in to comment.