diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3b918a1976..d794153a6a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -32,7 +32,7 @@ repos: rev: 5.10.1 hooks: - id: isort - args: ['--profile', 'black', '--filter-files'] + args: ["--profile", "black", "--filter-files"] exclude: ^.*\b(migrations)\b.*$ # Pythonic checks. - repo: https://github.com/PyCQA/flake8 @@ -41,7 +41,7 @@ repos: - id: flake8 exclude: docs|migrations|node_modules|revengine/settings additional_dependencies: - - 'flake8-logging-format' + - "flake8-logging-format" # # Pylint code checks. # # "local" because pylint needs all packages to dynamically import. # - repo: local diff --git a/spa/.storybook/preview-body.html b/spa/.storybook/preview-body.html new file mode 100644 index 0000000000..3f507a93b5 --- /dev/null +++ b/spa/.storybook/preview-body.html @@ -0,0 +1,3 @@ + + + diff --git a/spa/cypress/e2e/08-contributor.cy.js b/spa/cypress/e2e/08-contributor.cy.js index d5668c0dc9..611cfbb29f 100644 --- a/spa/cypress/e2e/08-contributor.cy.js +++ b/spa/cypress/e2e/08-contributor.cy.js @@ -63,7 +63,7 @@ describe('Contributor portal', () => { it('should display a list of contributions', () => { cy.getByTestId('donations-table'); // DonationsTable is well tested elsewhere... - cy.get('td > p > span').should('have.length', 20); + cy.get('tbody tr').should('have.length', 10); cy.get('li > button[aria-label="page 1"]').should('exist'); cy.get('li > button[aria-label="Go to page 2"]').should('exist'); // ... though here we should see different column headers diff --git a/spa/cypress/fixtures/donations/18-results.json b/spa/cypress/fixtures/donations/18-results.json index b36bc836f4..dac2106c54 100644 --- a/spa/cypress/fixtures/donations/18-results.json +++ b/spa/cypress/fixtures/donations/18-results.json @@ -8,6 +8,8 @@ "currency": "usd", "reason": "", "interval": "one_time", + "is_cancelable": false, + "is_modifiable": false, "payment_provider_used": "Stripe", "payment_provider_data": null, "provider_payment_id": null, @@ -34,6 +36,8 @@ "currency": "usd", "reason": "", "interval": "one_time", + "is_cancelable": false, + "is_modifiable": false, "payment_provider_used": "Stripe", "payment_provider_data": null, "provider_payment_id": null, @@ -60,6 +64,8 @@ "currency": "usd", "reason": "", "interval": "one_time", + "is_cancelable": false, + "is_modifiable": false, "payment_provider_used": "Stripe", "payment_provider_data": null, "provider_payment_id": null, @@ -86,6 +92,8 @@ "currency": "usd", "reason": "", "interval": "month", + "is_cancelable": true, + "is_modifiable": true, "payment_provider_used": "Stripe", "payment_provider_data": null, "provider_payment_id": null, @@ -113,6 +121,8 @@ "currency": "usd", "reason": "", "interval": "month", + "is_cancelable": true, + "is_modifiable": true, "payment_provider_used": "Stripe", "payment_provider_data": null, "provider_payment_id": null, @@ -140,6 +150,8 @@ "currency": "usd", "reason": "", "interval": "month", + "is_cancelable": true, + "is_modifiable": true, "payment_provider_used": "Stripe", "payment_provider_data": null, "provider_payment_id": null, @@ -167,6 +179,8 @@ "currency": "usd", "reason": "", "interval": "month", + "is_cancelable": true, + "is_modifiable": true, "payment_provider_used": "Stripe", "payment_provider_data": null, "provider_payment_id": null, @@ -194,6 +208,8 @@ "currency": "usd", "reason": "", "interval": "one_time", + "is_cancelable": true, + "is_modifiable": true, "payment_provider_used": "Stripe", "payment_provider_data": null, "provider_payment_id": null, @@ -220,6 +236,8 @@ "currency": "usd", "reason": "", "interval": "one_time", + "is_cancelable": false, + "is_modifiable": false, "payment_provider_used": "Stripe", "payment_provider_data": null, "provider_payment_id": null, @@ -246,6 +264,8 @@ "currency": "usd", "reason": "", "interval": "one_time", + "is_cancelable": false, + "is_modifiable": false, "payment_provider_used": "Stripe", "payment_provider_data": null, "provider_payment_id": null, @@ -272,6 +292,8 @@ "currency": "usd", "reason": "", "interval": "one_time", + "is_cancelable": false, + "is_modifiable": false, "payment_provider_used": "Stripe", "payment_provider_data": null, "provider_payment_id": null, @@ -298,6 +320,8 @@ "currency": "usd", "reason": "", "interval": "one_time", + "is_cancelable": false, + "is_modifiable": false, "payment_provider_used": "Stripe", "payment_provider_data": null, "provider_payment_id": null, @@ -324,6 +348,8 @@ "currency": "usd", "reason": "", "interval": "year", + "is_cancelable": true, + "is_modifiable": true, "payment_provider_used": "Stripe", "payment_provider_data": null, "provider_payment_id": null, @@ -351,6 +377,8 @@ "currency": "usd", "reason": "", "interval": "year", + "is_cancelable": true, + "is_modifiable": true, "payment_provider_used": "Stripe", "payment_provider_data": null, "provider_payment_id": null, @@ -378,6 +406,8 @@ "currency": "usd", "reason": "", "interval": "year", + "is_cancelable": true, + "is_modifiable": true, "payment_provider_used": "Stripe", "payment_provider_data": null, "provider_payment_id": null, @@ -405,6 +435,8 @@ "currency": "usd", "reason": "", "interval": "one_time", + "is_cancelable": false, + "is_modifiable": false, "payment_provider_used": "Stripe", "payment_provider_data": null, "provider_payment_id": null, @@ -431,6 +463,8 @@ "currency": "usd", "reason": "", "interval": "one_time", + "is_cancelable": false, + "is_modifiable": false, "payment_provider_used": "Stripe", "payment_provider_data": null, "provider_payment_id": null, @@ -457,6 +491,8 @@ "currency": "usd", "reason": "", "interval": "year", + "is_cancelable": true, + "is_modifiable": true, "payment_provider_used": "Stripe", "payment_provider_data": null, "provider_payment_id": null, diff --git a/spa/package.json b/spa/package.json index 0346151ae0..bded748f5b 100644 --- a/spa/package.json +++ b/spa/package.json @@ -87,7 +87,7 @@ "collectCoverageFrom": [ "**/*.[jt]s?(x)", "!**/node_modules/**", - "!**/*.stories.js", + "!**/*.stories.[jt]sx", "!**/*.styled.js" ], "transformIgnorePatterns": [ diff --git a/spa/src/components/base/Pagination/Pagination.stories.tsx b/spa/src/components/base/Pagination/Pagination.stories.tsx new file mode 100644 index 0000000000..cca8b2540f --- /dev/null +++ b/spa/src/components/base/Pagination/Pagination.stories.tsx @@ -0,0 +1,14 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import Pagination from './Pagination'; +export default { + argTypes: { + count: { control: 'number', defaultValue: 5 }, + page: { control: 'number', defaultValue: 2 } + }, + component: Pagination, + title: 'Base/Pagination' +} as ComponentMeta; + +const Template: ComponentStory = (props) => ; + +export const Default = Template.bind({}); diff --git a/spa/src/components/base/Pagination/Pagination.test.tsx b/spa/src/components/base/Pagination/Pagination.test.tsx new file mode 100644 index 0000000000..e8df81741c --- /dev/null +++ b/spa/src/components/base/Pagination/Pagination.test.tsx @@ -0,0 +1,20 @@ +import { axe } from 'jest-axe'; +import { render, screen } from 'test-utils'; +import Pagination, { PaginationProps } from './Pagination'; + +function tree(props?: PaginationProps) { + return render(); +} + +describe('Pagination', () => { + it('renders a navigation', () => { + tree(); + expect(screen.getByRole('navigation')).toBeVisible(); + }); + + it('is accessible', async () => { + const { container } = tree(); + + expect(await axe(container)).toHaveNoViolations(); + }); +}); diff --git a/spa/src/components/base/Pagination/Pagination.tsx b/spa/src/components/base/Pagination/Pagination.tsx new file mode 100644 index 0000000000..21ef32b14f --- /dev/null +++ b/spa/src/components/base/Pagination/Pagination.tsx @@ -0,0 +1,19 @@ +import { Pagination as MuiPagination, PaginationProps as MuiPaginationProps } from '@material-ui/lab'; +import styled from 'styled-components'; + +export const Pagination = styled(MuiPagination)` + && { + align-items: center; + display: flex; + justify-content: center; + + button.Mui-selected { + background-color: #6fd1ec; + border-radius: 4px; + font-weight: 700; + } + } +`; + +export type PaginationProps = MuiPaginationProps; +export default Pagination; diff --git a/spa/src/components/base/Table/Table.stories.tsx b/spa/src/components/base/Table/Table.stories.tsx new file mode 100644 index 0000000000..66bcf759c5 --- /dev/null +++ b/spa/src/components/base/Table/Table.stories.tsx @@ -0,0 +1,41 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import Table from './Table'; +import TableBody from './TableBody'; +import TableCell from './TableCell'; +import TableHead from './TableHead'; +import TableRow from './TableRow'; +export default { + component: Table, + title: 'Base/Table' +} as ComponentMeta; + +const Template: ComponentStory = (props) => ( + + + + ID + Color + Cost + + + + + 1 + Red + $1.99 + + + 2 + Green + $4.99 + + + 3 + Blue + $0.99 + + +
+); + +export const Unsortable = Template.bind({}); diff --git a/spa/src/components/base/Table/Table.test.tsx b/spa/src/components/base/Table/Table.test.tsx new file mode 100644 index 0000000000..9e3f95acbe --- /dev/null +++ b/spa/src/components/base/Table/Table.test.tsx @@ -0,0 +1,66 @@ +import { axe } from 'jest-axe'; +import { render, screen } from 'test-utils'; +import Table from './Table'; +import TableBody from './TableBody'; +import TableCell from './TableCell'; +import TableHead from './TableHead'; +import TableRow from './TableRow'; + +// This tests all components in this directory since they should be used +// together. + +function tree() { + return render( + + + + ID + Color + + + + + 1 + Red + + + 2 + Green + + + 3 + Blue + + +
+ ); +} + +describe('Table', () => { + it('renders a table', () => { + tree(); + expect(screen.getByRole('table')).toBeVisible(); + }); + + it('renders headers', () => { + tree(); + expect(screen.getByRole('columnheader', { name: 'ID' })).toBeVisible(); + expect(screen.getByRole('columnheader', { name: 'Color' })).toBeVisible(); + }); + + it('renders cells', () => { + tree(); + expect(screen.getByRole('cell', { name: '1' })).toBeVisible(); + expect(screen.getByRole('cell', { name: '2' })).toBeVisible(); + expect(screen.getByRole('cell', { name: '3' })).toBeVisible(); + expect(screen.getByRole('cell', { name: 'Red' })).toBeVisible(); + expect(screen.getByRole('cell', { name: 'Green' })).toBeVisible(); + expect(screen.getByRole('cell', { name: 'Blue' })).toBeVisible(); + }); + + it('is accessible', async () => { + const { container } = tree(); + + expect(await axe(container)).toHaveNoViolations(); + }); +}); diff --git a/spa/src/components/base/Table/Table.tsx b/spa/src/components/base/Table/Table.tsx new file mode 100644 index 0000000000..8f01c8533a --- /dev/null +++ b/spa/src/components/base/Table/Table.tsx @@ -0,0 +1,5 @@ +import { Table as MuiTable, TableProps as MuiTableProps } from '@material-ui/core'; + +export const Table = MuiTable; +export type TableProps = MuiTableProps; +export default Table; diff --git a/spa/src/components/base/Table/TableBody.tsx b/spa/src/components/base/Table/TableBody.tsx new file mode 100644 index 0000000000..fdfcdbee6b --- /dev/null +++ b/spa/src/components/base/Table/TableBody.tsx @@ -0,0 +1,5 @@ +import { TableBody as MuiTableBody, TableBodyProps as MuiTableBodyProps } from '@material-ui/core'; + +export const TableBody = MuiTableBody; +export type TableBodyProps = MuiTableBodyProps; +export default TableBody; diff --git a/spa/src/components/base/Table/TableCell.tsx b/spa/src/components/base/Table/TableCell.tsx new file mode 100644 index 0000000000..0e971557b1 --- /dev/null +++ b/spa/src/components/base/Table/TableCell.tsx @@ -0,0 +1,12 @@ +import { TableCell as MuiTableCell, TableCellProps as MuiTableCellProps } from '@material-ui/core'; +import styled from 'styled-components'; + +export const TableCell = styled(MuiTableCell)` + && { + border: none; + font: 16px Roboto, sans-serif; + } +`; + +export type TableCellProps = MuiTableCellProps; +export default TableCell; diff --git a/spa/src/components/base/Table/TableContainer.tsx b/spa/src/components/base/Table/TableContainer.tsx new file mode 100644 index 0000000000..5c3f817ed6 --- /dev/null +++ b/spa/src/components/base/Table/TableContainer.tsx @@ -0,0 +1,5 @@ +import { TableContainer as MuiTableContainer, TableContainerProps as MuiTableContainerProps } from '@material-ui/core'; + +export const TableContainer = MuiTableContainer; +export type TableContainerProps = MuiTableContainerProps; +export default TableContainer; diff --git a/spa/src/components/base/Table/TableHead.tsx b/spa/src/components/base/Table/TableHead.tsx new file mode 100644 index 0000000000..82f19f4358 --- /dev/null +++ b/spa/src/components/base/Table/TableHead.tsx @@ -0,0 +1,13 @@ +import { TableHead as MuiTableHead, TableHeadProps as MuiTableHeadProps } from '@material-ui/core'; +import styled from 'styled-components'; + +export const TableHead = styled(MuiTableHead)` + && th { + background-color: #6fd1ec; + font-size: 14px; + font-weight: bold; + } +`; + +export type TableHeadProps = MuiTableHeadProps; +export default TableHead; diff --git a/spa/src/components/base/Table/TableRow.tsx b/spa/src/components/base/Table/TableRow.tsx new file mode 100644 index 0000000000..27c0ecd635 --- /dev/null +++ b/spa/src/components/base/Table/TableRow.tsx @@ -0,0 +1,20 @@ +import { TableRow as MuiTableRow, TableRowProps as MuiTableRowProps } from '@material-ui/core'; +import styled from 'styled-components'; + +export const TableRow = styled(MuiTableRow)` + && { + border: none; + } + + &&:hover, + &&:nth-child(odd):hover { + background-color: #bcd3f5; + } + + &&:nth-child(odd) { + background-color: #f1f1f1; + } +`; + +export type TableRowProps = MuiTableRowProps; +export default TableRow; diff --git a/spa/src/components/base/Table/index.ts b/spa/src/components/base/Table/index.ts new file mode 100644 index 0000000000..e2e284de4b --- /dev/null +++ b/spa/src/components/base/Table/index.ts @@ -0,0 +1,6 @@ +export * from './Table'; +export * from './TableBody'; +export * from './TableCell'; +export * from './TableContainer'; +export * from './TableHead'; +export * from './TableRow'; diff --git a/spa/src/components/base/index.ts b/spa/src/components/base/index.ts index dba0c75089..88c8c684d2 100644 --- a/spa/src/components/base/index.ts +++ b/spa/src/components/base/index.ts @@ -5,4 +5,6 @@ export * from './Select'; export * from './Stepper'; export * from './TextField/TextField'; export * from './MenuItem/MenuItem'; +export * from './Pagination/Pagination'; +export * from './Table'; export * from './Tooltip/Tooltip'; diff --git a/spa/src/components/common/PaymentStatus/PaymentStatus.stories.tsx b/spa/src/components/common/PaymentStatus/PaymentStatus.stories.tsx new file mode 100644 index 0000000000..9b6f0f74ef --- /dev/null +++ b/spa/src/components/common/PaymentStatus/PaymentStatus.stories.tsx @@ -0,0 +1,18 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import PaymentStatus from './PaymentStatus'; + +export default { + argTypes: { + status: { + control: 'select', + options: ['canceled', 'failed', 'flagged', 'paid', 'processing', 'rejected'], + defaultValue: 'paid' + } + }, + component: PaymentStatus, + title: 'Common/PaymentStatus' +} as ComponentMeta; + +const Template: ComponentStory = (props) => ; + +export const Default = Template.bind({}); diff --git a/spa/src/components/common/PaymentStatus/PaymentStatus.styled.ts b/spa/src/components/common/PaymentStatus/PaymentStatus.styled.ts new file mode 100644 index 0000000000..b732d4e275 --- /dev/null +++ b/spa/src/components/common/PaymentStatus/PaymentStatus.styled.ts @@ -0,0 +1,25 @@ +import { PaymentStatus } from 'constants/paymentStatus'; +import styled, { DefaultTheme } from 'styled-components'; + +const statusColors: Record = { + processing: 'processing', + failed: 'failed', + flagged: 'transparent', + paid: 'done', + rejected: 'transparent', + canceled: 'warning' +}; + +const italicizedStatuses: PaymentStatus[] = ['canceled', 'processing']; + +export const StatusText = styled('span')<{ status: PaymentStatus }>` + margin-left: 1rem; + font-size: 14px; + font-style: ${({ status }) => (status in italicizedStatuses ? 'italic' : 'normal')}; + padding: 0.2rem 0.8rem; + color: ${(props) => props.theme.colors.black}; + border-radius: ${(props) => props.theme.muiBorderRadius.md}; + line-height: 1.2; + background-color: ${({ status, theme }) => + status in statusColors ? theme.colors.status[statusColors[status] as keyof typeof theme.colors.status] : 'inherit'}; +`; diff --git a/spa/src/components/common/PaymentStatus/PaymentStatus.test.tsx b/spa/src/components/common/PaymentStatus/PaymentStatus.test.tsx new file mode 100644 index 0000000000..fd9f74088e --- /dev/null +++ b/spa/src/components/common/PaymentStatus/PaymentStatus.test.tsx @@ -0,0 +1,28 @@ +import { PaymentStatus as PaymentStatusType } from 'constants/paymentStatus'; +import { axe } from 'jest-axe'; +import { render, screen } from 'test-utils'; +import PaymentStatus, { PaymentStatusProps } from './PaymentStatus'; + +function tree(props?: Partial) { + return render(); +} + +describe('PaymentStatus', () => { + it.each([ + ['canceled', 'Canceled'], + ['failed', 'Failed'], + ['flagged', 'Flagged'], + ['paid', 'Paid'], + ['processing', 'Processing'], + ['rejected', 'Rejected'] + ])('displays a %s status as %p', (status, displayValue) => { + tree({ status: status as PaymentStatusType }); + expect(screen.getByText(displayValue)).toBeVisible(); + }); + + it('is accessible', async () => { + const { container } = tree(); + + expect(await axe(container)).toHaveNoViolations(); + }); +}); diff --git a/spa/src/components/common/PaymentStatus/PaymentStatus.tsx b/spa/src/components/common/PaymentStatus/PaymentStatus.tsx new file mode 100644 index 0000000000..4c76fa9645 --- /dev/null +++ b/spa/src/components/common/PaymentStatus/PaymentStatus.tsx @@ -0,0 +1,19 @@ +import { PaymentStatus as PaymentStatusType } from 'constants/paymentStatus'; +import PropTypes, { InferProps } from 'prop-types'; +import toTitleCase from 'utilities/toTitleCase'; +import { StatusText } from './PaymentStatus.styled'; + +const PaymentStatusPropTypes = { + status: PropTypes.string.isRequired +}; + +export interface PaymentStatusProps extends InferProps { + status: PaymentStatusType; +} + +export function PaymentStatus({ status }: PaymentStatusProps) { + return {toTitleCase(status)}; +} + +PaymentStatus.propTypes = PaymentStatusPropTypes; +export default PaymentStatus; diff --git a/spa/src/components/common/PaymentStatus/index.ts b/spa/src/components/common/PaymentStatus/index.ts new file mode 100644 index 0000000000..082f482eab --- /dev/null +++ b/spa/src/components/common/PaymentStatus/index.ts @@ -0,0 +1 @@ +export * from './PaymentStatus'; diff --git a/spa/src/components/common/ValueOrPlaceholder/ValueOrPlaceholder.stories.tsx b/spa/src/components/common/ValueOrPlaceholder/ValueOrPlaceholder.stories.tsx new file mode 100644 index 0000000000..9fb2bea57f --- /dev/null +++ b/spa/src/components/common/ValueOrPlaceholder/ValueOrPlaceholder.stories.tsx @@ -0,0 +1,18 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import ValueOrPlaceholder from './ValueOrPlaceholder'; + +export default { + component: ValueOrPlaceholder, + title: 'Common/ValueOrPlaceholder' +} as ComponentMeta; + +const Template: ComponentStory = (props) => ( + <> +

+ Change the value prop to see the result below. +

+ Children are shown + +); + +export const Default = Template.bind({}); diff --git a/spa/src/components/common/ValueOrPlaceholder/ValueOrPlaceholder.test.tsx b/spa/src/components/common/ValueOrPlaceholder/ValueOrPlaceholder.test.tsx new file mode 100644 index 0000000000..1169ca0122 --- /dev/null +++ b/spa/src/components/common/ValueOrPlaceholder/ValueOrPlaceholder.test.tsx @@ -0,0 +1,31 @@ +import { axe } from 'jest-axe'; +import { render, screen } from 'test-utils'; +import ValueOrPlaceholder, { ValueOrPlaceholderProps } from './ValueOrPlaceholder'; + +function tree(props?: Partial) { + return render(children); +} + +describe('ValueOrPlaceholder', () => { + it.each([[1], ['text'], ['0'], [' '], [true]])('renders children when value is %p', (value) => { + tree({ value }); + expect(screen.getByText('children')).toBeVisible(); + }); + + it.each([[0], [''], [false], [null], [undefined]])("doesn't render children when value is %p", (value) => { + tree({ value }); + expect(screen.queryByText('children')).not.toBeInTheDocument(); + }); + + it('is accessible when children are not rendered', async () => { + const { container } = tree({ value: false }); + + expect(await axe(container)).toHaveNoViolations(); + }); + + it('is accessible when children are rendered', async () => { + const { container } = tree({ value: true }); + + expect(await axe(container)).toHaveNoViolations(); + }); +}); diff --git a/spa/src/components/common/ValueOrPlaceholder/ValueOrPlaceholder.tsx b/spa/src/components/common/ValueOrPlaceholder/ValueOrPlaceholder.tsx new file mode 100644 index 0000000000..99c6fd453f --- /dev/null +++ b/spa/src/components/common/ValueOrPlaceholder/ValueOrPlaceholder.tsx @@ -0,0 +1,23 @@ +import PropTypes, { InferProps } from 'prop-types'; +import { NO_VALUE } from 'constants/textConstants'; + +const ValueOrPlaceholderPropTypes = { + children: PropTypes.node.isRequired, + value: PropTypes.any +}; + +export type ValueOrPlaceholderProps = InferProps; + +/** + * Shows children or a placeholder string depending on the value prop. + */ +export function ValueOrPlaceholder({ children, value }: ValueOrPlaceholderProps) { + if (!!value) { + return <>{children}; + } + + return <>{NO_VALUE}; +} + +ValueOrPlaceholder.propTypes = ValueOrPlaceholderPropTypes; +export default ValueOrPlaceholder; diff --git a/spa/src/components/common/ValueOrPlaceholder/index.ts b/spa/src/components/common/ValueOrPlaceholder/index.ts new file mode 100644 index 0000000000..89362c6754 --- /dev/null +++ b/spa/src/components/common/ValueOrPlaceholder/index.ts @@ -0,0 +1 @@ +export * from './ValueOrPlaceholder'; diff --git a/spa/src/components/contributor/contributorDashboard/CancelRecurringButton.stories.tsx b/spa/src/components/contributor/contributorDashboard/CancelRecurringButton.stories.tsx index 171eca391d..73cad52bf0 100644 --- a/spa/src/components/contributor/contributorDashboard/CancelRecurringButton.stories.tsx +++ b/spa/src/components/contributor/contributorDashboard/CancelRecurringButton.stories.tsx @@ -3,7 +3,7 @@ import CancelRecurringButton from './CancelRecurringButton'; export default { component: CancelRecurringButton, - title: 'Contributor Dashboard/CancelRecurringButton' + title: 'Contributor/CancelRecurringButton' } as ComponentMeta; const Template: ComponentStory = (props) => ; @@ -14,18 +14,17 @@ Default.args = { id: 'mock-id', amount: 12345, card_brand: 'visa', - contributor: 123, - contributor_email: 'someone@fundjournalism.org', created: '', - currency: 'usd', + credit_card_expiration_date: 'mock-cc-expiration-date', interval: 'month', + is_cancelable: true, + is_modifiable: true, + last_payment_date: 'mock-last-payment-date', last4: 1234, - modified: '', - organization: 123, - payment_provider_used: 'stripe', - payment_provider_data: {}, + payment_type: 'mock-payment-type', + provider_customer_id: 'mock-customer-id', revenue_program: 'mock-rp-slug', - reason: 'mock-reason-for-contribution', - status: 'paid' + status: 'paid', + stripe_account_id: 'mock-account-id' } }; diff --git a/spa/src/components/contributor/contributorDashboard/CancelRecurringButton.test.tsx b/spa/src/components/contributor/contributorDashboard/CancelRecurringButton.test.tsx index 9cae7e1889..03a8054003 100644 --- a/spa/src/components/contributor/contributorDashboard/CancelRecurringButton.test.tsx +++ b/spa/src/components/contributor/contributorDashboard/CancelRecurringButton.test.tsx @@ -1,27 +1,25 @@ import userEvent from '@testing-library/user-event'; -import { ContributionInterval } from 'constants/contributionIntervals'; -import { PaymentStatus } from 'constants/paymentStatus'; +import { ContributorContribution } from 'hooks/useContributorContributionList'; import { axe } from 'jest-axe'; -import { render, screen, user } from 'test-utils'; +import { render, screen } from 'test-utils'; import { CancelRecurringButton, CancelRecurringButtonProps } from './CancelRecurringButton'; -const mockContribution = { +const mockContribution: ContributorContribution = { id: 'mock-id', amount: 12345, card_brand: 'visa', - contributor: 1, - contributor_email: 'mock-contributor-email', created: 'mock-created', - currency: 'mock-currency', - interval: 'month' as ContributionInterval, + interval: 'month', last4: 1234, - modified: 'mock-modified', - organization: 1, - payment_provider_data: {}, - payment_provider_used: 'mock-payment-provider', revenue_program: 'mock-rp', - reason: 'mock-reason', - status: 'paid' as PaymentStatus + status: 'paid', + credit_card_expiration_date: 'mock-cc-expiration', + is_cancelable: false, + is_modifiable: false, + last_payment_date: 'mock-last-payment-date', + payment_type: 'mock-payment-type', + provider_customer_id: 'mock-customer-id', + stripe_account_id: 'mock-account-id' }; describe('CancelRecurringButton', () => { diff --git a/spa/src/components/contributor/contributorDashboard/CancelRecurringButton.tsx b/spa/src/components/contributor/contributorDashboard/CancelRecurringButton.tsx index b27643cd86..a155f69969 100644 --- a/spa/src/components/contributor/contributorDashboard/CancelRecurringButton.tsx +++ b/spa/src/components/contributor/contributorDashboard/CancelRecurringButton.tsx @@ -1,49 +1,20 @@ import CancelOutlinedIcon from '@material-ui/icons/CancelOutlined'; import ReportOutlined from '@material-ui/icons/ReportOutlined'; import PropTypes, { InferProps } from 'prop-types'; -import { ContributionInterval } from 'constants/contributionIntervals'; -import { PaymentStatus } from 'constants/paymentStatus'; import useModal from 'hooks/useModal'; import { ModalHeader, TableButton } from './CancelRecurringButton.styled'; import { Button, Modal, ModalContent, ModalFooter } from 'components/base'; import formatCurrencyAmount from 'utilities/formatCurrencyAmount'; +import { ContributorContribution } from 'hooks/useContributorContributionList'; const CancelRecurringButtonPropTypes = { contribution: PropTypes.object.isRequired, onCancel: PropTypes.func.isRequired }; -// This is a temporary home for this type. -// TODO in DEV-489: move to hook - -interface Contribution { - id: string; - amount: number; - bad_actor_score?: unknown; - bad_actor_response?: unknown; - card_brand: string; - contributor: number; - contributor_email: string; - created: string; - currency: string; - flagged_date?: string; - interval: ContributionInterval; - last4: number; - modified: string; - organization: number; - payment_provider_used: string; - payment_provider_data: unknown; - provider_customer_id?: string; - provider_payment_id?: string; - provider_payment_method_id?: string; - revenue_program: string; - reason: string; - status?: PaymentStatus; -} - export interface CancelRecurringButtonProps extends InferProps { - contribution: Contribution; - onCancel: (contribution: Contribution) => void; + contribution: ContributorContribution; + onCancel: (contribution: ContributorContribution) => void; } export function CancelRecurringButton({ contribution, onCancel }: CancelRecurringButtonProps) { diff --git a/spa/src/components/contributor/contributorDashboard/ContributionPaymentMethod.stories.tsx b/spa/src/components/contributor/contributorDashboard/ContributionPaymentMethod.stories.tsx new file mode 100644 index 0000000000..3426013eac --- /dev/null +++ b/spa/src/components/contributor/contributorDashboard/ContributionPaymentMethod.stories.tsx @@ -0,0 +1,40 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { ContributorContribution } from 'hooks/useContributorContributionList'; +import ContributionPaymentMethod from './ContributionPaymentMethod'; + +export default { + component: ContributionPaymentMethod, + title: 'Contributor/ContributionPaymentMethod' +} as ComponentMeta; + +const testContribution: ContributorContribution = { + amount: 123, + card_brand: 'visa', + created: 'mock-created-date', + credit_card_expiration_date: '12/34', + id: 'mock-id', + interval: 'one_time', + is_cancelable: false, + is_modifiable: false, + last4: 1234, + last_payment_date: 'mock-last-payment-date', + payment_type: 'card', + provider_customer_id: 'mock-provider-id', + revenue_program: 'mock-rp-slug', + stripe_account_id: 'mock-account-id', + status: 'paid' +}; + +const Template: ComponentStory = (props) => ; + +export const OneTime = Template.bind({}); + +OneTime.args = { contribution: testContribution }; + +export const Monthly = Template.bind({}); + +Monthly.args = { contribution: { ...testContribution, is_cancelable: true, is_modifiable: true, interval: 'month' } }; + +export const Yearly = Template.bind({}); + +Yearly.args = { contribution: { ...testContribution, is_cancelable: true, is_modifiable: true, interval: 'year' } }; diff --git a/spa/src/components/contributor/contributorDashboard/ContributionPaymentMethod.styled.ts b/spa/src/components/contributor/contributorDashboard/ContributionPaymentMethod.styled.ts new file mode 100644 index 0000000000..ed1d1b15e0 --- /dev/null +++ b/spa/src/components/contributor/contributorDashboard/ContributionPaymentMethod.styled.ts @@ -0,0 +1,24 @@ +import { IconButton } from '@material-ui/core'; +import styled from 'styled-components'; + +export const CardIcon = styled('img')` + height: 30px; + margin-right: 0.8em; + max-width: 45px; +`; + +export const EditButton = styled(IconButton)` + svg { + color: #999; + } +`; + +export const LastFour = styled('span')` + color: #999; +`; + +export const Root = styled('div')` + align-items: center; + justify-content: space-between; + display: flex; +`; diff --git a/spa/src/components/contributor/contributorDashboard/ContributionPaymentMethod.test.tsx b/spa/src/components/contributor/contributorDashboard/ContributionPaymentMethod.test.tsx new file mode 100644 index 0000000000..f4c62cb640 --- /dev/null +++ b/spa/src/components/contributor/contributorDashboard/ContributionPaymentMethod.test.tsx @@ -0,0 +1,121 @@ +import userEvent from '@testing-library/user-event'; +import { PaymentStatus } from 'constants/paymentStatus'; +import { CardBrand, ContributorContribution } from 'hooks/useContributorContributionList'; +import { axe } from 'jest-axe'; +import { render, screen } from 'test-utils'; +import ContributionPaymentMethod, { ContributionPaymentMethodProps } from './ContributionPaymentMethod'; + +jest.mock('./EditRecurringPaymentModal'); + +const testContribution: ContributorContribution = { + amount: 123, + card_brand: 'visa', + created: 'mock-created-date', + credit_card_expiration_date: 'mock-expiration-date', + id: 'mock-id', + interval: 'month', + is_cancelable: true, + is_modifiable: true, + last4: 1234, + last_payment_date: 'mock-last-payment-date', + payment_type: 'mock-payment-type', + provider_customer_id: 'mock-provider-id', + revenue_program: 'mock-rp-slug', + stripe_account_id: 'mock-account-id', + status: 'paid' +}; + +function tree(props?: Partial) { + return render(); +} + +function getEditDialog() { + return screen.queryByTestId('mock-edit-recurring-payment-modal'); +} + +describe('ContributionPaymentMethod', () => { + it("doesn't initially show the edit dialog", () => { + tree(); + expect(getEditDialog()).toHaveAttribute('data-is-open', 'false'); + }); + + it('displays the last four digits of the card used', () => { + tree({ contribution: { ...testContribution, last4: 9876 } }); + + // There are also placeholder dots + + expect(screen.getByText('9876', { exact: false })).toBeVisible(); + }); + + it.each([ + ['amex', 'Amex'], + ['discover', 'Discover'], + ['mastercard', 'Mastercard'], + ['visa', 'Visa'] + ])('displays an image for a %s card', (card_brand, expectedText) => { + tree({ contribution: { ...testContribution, card_brand: card_brand as CardBrand } }); + expect(screen.getByRole('img', { name: expectedText })).toBeVisible(); + }); + + it("doesn't display a card image if the card brand is unknown", () => { + tree({ contribution: { ...testContribution, card_brand: '????' } as any }); + expect(screen.queryByRole('img')).not.toBeInTheDocument(); + }); + + it("doesn't allow editing if the disabled prop is true", () => { + tree({ disabled: true }); + expect(screen.getByRole('button')).toBeDisabled(); + }); + + it("doesn't allow editing if the contribution's is_modifiable property is false", () => { + tree({ contribution: { ...testContribution, is_modifiable: false } }); + expect(screen.getByRole('button')).toBeDisabled(); + }); + + it.each([[undefined], ['canceled'], ['processing']])( + "doesn't allow editing if the contribution's status is %p", + (status) => { + tree({ contribution: { ...testContribution, status: status as PaymentStatus } }); + expect(screen.getByRole('button')).toBeDisabled(); + } + ); + + it('opens the edit dialog with the contribution when the last four digits are clicked', () => { + tree(); + expect(getEditDialog()).toHaveAttribute('data-is-open', 'false'); + userEvent.click(screen.getAllByRole('button')[0]); + expect(getEditDialog()).toHaveAttribute('data-is-open', 'true'); + }); + + it('opens the edit dialog with the contribution when the edit button is clicked', () => { + tree(); + expect(getEditDialog()).toHaveAttribute('data-is-open', 'false'); + userEvent.click(screen.getAllByRole('button')[1]); + expect(getEditDialog()).toHaveAttribute('data-is-open', 'true'); + }); + + it('closes the edit dialog when the user closes it', () => { + tree(); + userEvent.click(screen.getAllByRole('button')[0]); + expect(getEditDialog()).toHaveAttribute('data-is-open', 'true'); + userEvent.click(screen.getByRole('button', { name: 'closeModal' })); + expect(getEditDialog()).toHaveAttribute('data-is-open', 'false'); + }); + + it('calls the onUpdateComplete props when the user finishes editing in the dialog', () => { + const onUpdateComplete = jest.fn(); + + tree({ onUpdateComplete }); + userEvent.click(screen.getAllByRole('button')[0]); + expect(getEditDialog()).toHaveAttribute('data-is-open', 'true'); + expect(onUpdateComplete).not.toBeCalled(); + userEvent.click(screen.getByRole('button', { name: 'onComplete' })); + expect(onUpdateComplete).toBeCalledTimes(1); + }); + + it('is accessible', async () => { + const { container } = tree(); + + expect(await axe(container)).toHaveNoViolations(); + }); +}); diff --git a/spa/src/components/contributor/contributorDashboard/ContributionPaymentMethod.tsx b/spa/src/components/contributor/contributorDashboard/ContributionPaymentMethod.tsx new file mode 100644 index 0000000000..ff5859f3ba --- /dev/null +++ b/spa/src/components/contributor/contributorDashboard/ContributionPaymentMethod.tsx @@ -0,0 +1,88 @@ +import { ButtonBase } from '@material-ui/core'; +import CreateOutlinedIcon from '@material-ui/icons/CreateOutlined'; +import PropTypes from 'prop-types'; +import visa from 'assets/icons/visa_icon.svg'; +import mastercard from 'assets/icons/mastercard_icon.svg'; +import amex from 'assets/icons/amex_icon.svg'; +import discover from 'assets/icons/discover_icon.svg'; +import { PaymentStatus } from 'constants/paymentStatus'; +import { ContributorContribution } from 'hooks/useContributorContributionList'; +import useModal from 'hooks/useModal'; +import toTitleCase from 'utilities/toTitleCase'; +import EditRecurringPaymentModal from './EditRecurringPaymentModal'; +import { CardIcon, EditButton, LastFour, Root } from './ContributionPaymentMethod.styled'; + +const cardIcons = { amex, discover, visa, mastercard }; +const disabledStatuses: PaymentStatus[] = ['canceled', 'processing']; + +const ContributionPaymentMethodPropTypes = { + contribution: PropTypes.object.isRequired, + disabled: PropTypes.bool, + onUpdateComplete: PropTypes.func +}; + +// If the component is not disabled, the onUpdateComplete prop is required. + +interface DisabledContributionPaymentMethodProps { + contribution: ContributorContribution; + disabled: true; +} + +interface EnabledContributionPaymentMethodProps { + contribution: ContributorContribution; + disabled?: false; + onUpdateComplete: () => void; +} + +export type ContributionPaymentMethodProps = + | DisabledContributionPaymentMethodProps + | EnabledContributionPaymentMethodProps; + +export function ContributionPaymentMethod(props: ContributionPaymentMethodProps) { + const { contribution, disabled: disabledByParent } = props; + const { handleClose, handleOpen, open } = useModal(); + const disabled = + disabledByParent || + !contribution.status || + disabledStatuses.includes(contribution.status) || + !contribution.is_modifiable; + + // Test IDs are for Cypress compatibility. + + return ( + + + {contribution.card_brand in cardIcons && ( + + )} + •••• {contribution.last4} + + {!disabled && ( + <> + + + + + + )} + + ); +} + +ContributionPaymentMethod.propTypes = ContributionPaymentMethodPropTypes; + +export default ContributionPaymentMethod; diff --git a/spa/src/components/contributor/contributorDashboard/ContributionTableRow.stories.tsx b/spa/src/components/contributor/contributorDashboard/ContributionTableRow.stories.tsx new file mode 100644 index 0000000000..1e1158af29 --- /dev/null +++ b/spa/src/components/contributor/contributorDashboard/ContributionTableRow.stories.tsx @@ -0,0 +1,40 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { ContributorContribution } from 'hooks/useContributorContributionList'; +import ContributionTableRow from './ContributionTableRow'; + +export default { + component: ContributionTableRow, + title: 'Contributor/ContributionTableRow' +} as ComponentMeta; + +const testContribution: ContributorContribution = { + id: 'mock-id', + amount: 12345, + card_brand: 'visa', + created: new Date().toISOString(), + credit_card_expiration_date: 'mock-cc-expiration-date', + interval: 'one_time', + is_cancelable: false, + is_modifiable: false, + last_payment_date: new Date().toISOString(), + last4: 1234, + payment_type: 'mock-payment-type', + provider_customer_id: 'mock-customer-id', + revenue_program: 'mock-rp-slug', + status: 'paid', + stripe_account_id: 'mock-account-id' +}; + +const Template: ComponentStory = (props) => ; + +export const OneTime = Template.bind({}); + +OneTime.args = { contribution: testContribution }; + +export const Monthly = Template.bind({}); + +Monthly.args = { contribution: { ...testContribution, interval: 'month', is_cancelable: true, is_modifiable: true } }; + +export const Yearly = Template.bind({}); + +Yearly.args = { contribution: { ...testContribution, interval: 'year', is_cancelable: true, is_modifiable: true } }; diff --git a/spa/src/components/contributor/contributorDashboard/ContributionTableRow.styled.ts b/spa/src/components/contributor/contributorDashboard/ContributionTableRow.styled.ts new file mode 100644 index 0000000000..d5b008a7ac --- /dev/null +++ b/spa/src/components/contributor/contributorDashboard/ContributionTableRow.styled.ts @@ -0,0 +1,5 @@ +import styled from 'styled-components'; + +export const Time = styled.span` + margin-left: 1rem; +`; diff --git a/spa/src/components/contributor/contributorDashboard/ContributionTableRow.test.tsx b/spa/src/components/contributor/contributorDashboard/ContributionTableRow.test.tsx new file mode 100644 index 0000000000..87e88fb90c --- /dev/null +++ b/spa/src/components/contributor/contributorDashboard/ContributionTableRow.test.tsx @@ -0,0 +1,141 @@ +import userEvent from '@testing-library/user-event'; +import { ContributionInterval } from 'constants/contributionIntervals'; +import { NO_VALUE } from 'constants/textConstants'; +import { ContributorContribution } from 'hooks/useContributorContributionList'; +import { axe } from 'jest-axe'; +import { render, screen } from 'test-utils'; +import ContributionTableRow, { ContributionTableRowProps } from './ContributionTableRow'; + +jest.mock('./CancelRecurringButton'); +jest.mock('./ContributionPaymentMethod'); + +const mockContribution: ContributorContribution = { + id: 'mock-id', + amount: 12345, + card_brand: 'visa', + created: new Date().toISOString(), + interval: 'month', + last4: 1234, + revenue_program: 'mock-rp', + status: 'paid', + credit_card_expiration_date: 'mock-cc-expiration', + is_cancelable: false, + is_modifiable: false, + last_payment_date: new Date().toISOString(), + payment_type: 'mock-payment-type', + provider_customer_id: 'mock-customer-id', + stripe_account_id: 'mock-account-id' +}; + +function tree(props?: Partial) { + return render( + + + + +
+ ); +} + +describe('ContributionTableRow', () => { + it('renders a table row', () => { + tree(); + expect(screen.getByRole('row')).toBeVisible(); + }); + + it('displays a cell showing when the contribution was created', () => { + tree({ contribution: { ...mockContribution, created: new Date('1/2/34 5:23 PM').toISOString() } }); + expect(screen.getByTestId('created-cell')).toHaveTextContent('01/2/2034 05:23 PM'); + }); + + it('displays a cell showing a placeholder if the contribution has no creation date', () => { + tree({ contribution: { ...mockContribution, created: undefined } as any }); + expect(screen.getByTestId('created-cell')).toHaveTextContent(NO_VALUE); + }); + + it('displays a cell showing the contribution amount', () => { + tree({ contribution: { ...mockContribution, amount: 123456 } }); + expect(screen.getByTestId('amount-cell')).toHaveTextContent('$1,234.56'); + }); + + it('displays a cell showing a placeholder if the contribution has no amount', () => { + tree({ contribution: { ...mockContribution, amount: undefined } as any }); + expect(screen.getByTestId('amount-cell')).toHaveTextContent(NO_VALUE); + }); + + it.each([ + ['one_time', 'One time'], + ['month', 'Monthly'], + ['year', 'Yearly'] + ])('displays a cell showing the contribution interval for %s', (interval, displayValue) => { + tree({ contribution: { ...mockContribution, interval: interval as ContributionInterval } }); + expect(screen.getByTestId('interval-cell')).toHaveTextContent(displayValue); + }); + + it('displays a cell showing a placeholder if the contribution has no interval', () => { + tree({ contribution: { ...mockContribution, interval: undefined } as any }); + expect(screen.getByTestId('interval-cell')).toHaveTextContent(NO_VALUE); + }); + + it('displays a cell showing the last payment date for the contribution', () => { + tree({ contribution: { ...mockContribution, last_payment_date: new Date('1/2/34 5:23 PM').toISOString() } }); + expect(screen.getByTestId('last-payment-cell')).toHaveTextContent('01/2/2034 05:23 PM'); + }); + + it('displays a cell showing a placeholder if the contribution has no last payment date', () => { + tree({ contribution: { ...mockContribution, last_payment_date: undefined } as any }); + expect(screen.getByTestId('last-payment-cell')).toHaveTextContent(NO_VALUE); + }); + + it('displays the payment method', () => { + tree({ contribution: { ...mockContribution, id: 'payment-method-test' } }); + expect(screen.getByTestId('mock-contribution-payment-method')).toHaveAttribute( + 'data-contribution-id', + 'payment-method-test' + ); + }); + + it('calls the onUpdateRecurringComplete prop with the contribution when the payment method is edited', () => { + const onUpdateRecurringComplete = jest.fn(); + + tree({ onUpdateRecurringComplete }); + expect(onUpdateRecurringComplete).not.toBeCalled(); + userEvent.click(screen.getByText('onUpdateComplete')); + expect(onUpdateRecurringComplete.mock.calls).toEqual([[mockContribution]]); + }); + + it('displays a cell showing the status of the contribution', () => { + tree({ contribution: { ...mockContribution, status: 'failed' } }); + expect(screen.getByTestId('status-cell')).toHaveTextContent('Failed'); + }); + + it('displays an empty cell if the contribution has no status', () => { + tree({ contribution: { ...mockContribution, status: undefined } }); + expect(screen.getByTestId('status-cell')).toHaveTextContent(''); + }); + + it('displays a button to cancel the contribution', () => { + tree({ contribution: { ...mockContribution, id: 'cancel-test' } }); + expect(screen.getByTestId('mock-cancel-recurring-button')).toHaveAttribute('data-contribution-id', 'cancel-test'); + }); + + it('calls the onCancelRecurring prop with the contribution when the payment method is edited', () => { + const onCancelRecurring = jest.fn(); + + tree({ onCancelRecurring }); + expect(onCancelRecurring).not.toBeCalled(); + userEvent.click(screen.getByText('onCancelRecurring')); + expect(onCancelRecurring.mock.calls).toEqual([[mockContribution]]); + }); + + it('is accessible', async () => { + const { container } = tree(); + + expect(await axe(container)).toHaveNoViolations(); + }); +}); diff --git a/spa/src/components/contributor/contributorDashboard/ContributionTableRow.tsx b/spa/src/components/contributor/contributorDashboard/ContributionTableRow.tsx new file mode 100644 index 0000000000..37678deb57 --- /dev/null +++ b/spa/src/components/contributor/contributorDashboard/ContributionTableRow.tsx @@ -0,0 +1,71 @@ +import { TableCell, TableRow } from 'components/base'; +import { ContributorContribution } from 'hooks/useContributorContributionList'; +import PropTypes, { InferProps } from 'prop-types'; +import formatCurrencyAmount from 'utilities/formatCurrencyAmount'; +import formatDatetimeForDisplay from 'utilities/formatDatetimeForDisplay'; +import { getFrequencyAdjective } from 'utilities/parseFrequency'; +import CancelRecurringButton from './CancelRecurringButton'; +import ContributionPaymentMethod from './ContributionPaymentMethod'; +import { PaymentStatus } from 'components/common/PaymentStatus'; +import { ValueOrPlaceholder } from 'components/common/ValueOrPlaceholder'; +import { Time } from './ContributionTableRow.styled'; + +const ContributionTableRowPropTypes = { + contribution: PropTypes.object.isRequired, + onCancelRecurring: PropTypes.func.isRequired, + onUpdateRecurringComplete: PropTypes.func.isRequired +}; + +export interface ContributionTableRowProps extends InferProps { + contribution: ContributorContribution; + onCancelRecurring: (contribution: ContributorContribution) => Promise; + onUpdateRecurringComplete: (contribution: ContributorContribution) => void; +} + +export function ContributionTableRow({ + contribution, + onCancelRecurring, + onUpdateRecurringComplete +}: ContributionTableRowProps) { + // Data attributes on the row are for Cypress compatibility. + + return ( + + + + {formatDatetimeForDisplay(contribution.created)}{' '} + + + + + {formatCurrencyAmount(contribution.amount)} + + + + {getFrequencyAdjective(contribution.interval)} + + + + + {formatDatetimeForDisplay(contribution.last_payment_date)}{' '} + + + + + onUpdateRecurringComplete(contribution)} + /> + + + {contribution.status && } + + + + + + ); +} + +ContributionTableRow.propTypes = ContributionTableRowPropTypes; +export default ContributionTableRow; diff --git a/spa/src/components/contributor/contributorDashboard/ContributionsTable.test.tsx b/spa/src/components/contributor/contributorDashboard/ContributionsTable.test.tsx new file mode 100644 index 0000000000..c56db49865 --- /dev/null +++ b/spa/src/components/contributor/contributorDashboard/ContributionsTable.test.tsx @@ -0,0 +1,186 @@ +import { axe } from 'jest-axe'; +import { useAlert } from 'react-alert'; +import { render, screen, within } from 'test-utils'; +import useContributorContributionList, { ContributorContribution } from 'hooks/useContributorContributionList'; +import ContributionsTable, { ContributionsTableProps } from './ContributionsTable'; +import userEvent from '@testing-library/user-event'; + +jest.mock('react-alert', () => ({ + ...jest.requireActual('react-alert'), + useAlert: jest.fn() +})); +jest.mock('elements/GlobalLoading'); +jest.mock('hooks/useContributorContributionList'); +jest.mock('./ContributionTableRow'); + +const mockContributions: ContributorContribution[] = [ + { + id: 'mock-id-1', + amount: 12345, + card_brand: 'visa', + created: new Date().toISOString(), + interval: 'month', + last4: 1234, + revenue_program: 'mock-rp-1', + status: 'paid', + credit_card_expiration_date: 'mock-cc-expiration-1', + is_cancelable: false, + is_modifiable: false, + last_payment_date: new Date().toISOString(), + payment_type: 'mock-payment-type-1', + provider_customer_id: 'mock-customer-id-1', + stripe_account_id: 'mock-account-id-1' + }, + { + id: 'mock-id-2', + amount: 12345, + card_brand: 'visa', + created: new Date().toISOString(), + interval: 'month', + last4: 1234, + revenue_program: 'mock-rp-2', + status: 'paid', + credit_card_expiration_date: 'mock-cc-expiration-2', + is_cancelable: false, + is_modifiable: false, + last_payment_date: new Date().toISOString(), + payment_type: 'mock-payment-type-2', + provider_customer_id: 'mock-customer-id-2', + stripe_account_id: 'mock-account-id-2' + } +]; + +function tree(props?: Partial) { + return render(); +} + +describe('ContributionsTable', () => { + const useAlertMock = useAlert as jest.Mock; + const useContributorContributionsMock = useContributorContributionList as jest.Mock; + + beforeEach(() => { + useAlertMock.mockReturnValue({ error: jest.fn() }); + useContributorContributionsMock.mockReturnValue({ + cancelRecurringContribution: jest.fn(), + contributions: mockContributions, + isError: false, + isLoading: false, + refetch: jest.fn(), + total: mockContributions.length + }); + }); + + it('initally fetches the first page of contributions with the revenue program and page size provided', () => { + tree({ rowsPerPage: 3, rpSlug: 'test-slug' }); + expect(useContributorContributionsMock.mock.calls).toEqual([ + [ + { + page: 1, + page_size: 3, + rp: 'test-slug' + } + ] + ]); + }); + + it('shows a loading status while contributions are loading', () => { + useContributorContributionsMock.mockReturnValue({ isLoading: true }); + tree(); + expect(screen.getByTestId('mock-global-loading')).toBeInTheDocument(); + }); + + it('shows an alert if contributions fail to load', () => { + const error = jest.fn(); + + useAlertMock.mockReturnValue({ error }); + useContributorContributionsMock.mockReturnValue({ contributions: [], isError: true }); + tree(); + expect(error.mock.calls).toEqual([['We encountered an issue and have been notified. Please try again.']]); + }); + + describe('After loading contributions', () => { + it('shows table headers', () => { + tree(); + + for (const name of [ + 'Date', + 'Amount', + 'Frequency', + 'Receipt date', + 'Payment method', + 'Payment status', + 'Cancel' + ]) { + expect(screen.getByRole('columnheader', { name })).toBeVisible(); + } + }); + + it('shows a table row for each contribution', () => { + tree(); + + const rows = screen.getAllByTestId('mock-contribution-table-row'); + + expect(rows.length).toEqual(mockContributions.length); + expect(rows[0]).toHaveAttribute('data-contribution-id', mockContributions[0].id); + expect(rows[1]).toHaveAttribute('data-contribution-id', mockContributions[1].id); + }); + + it('shows a message if there are no contributions', () => { + useContributorContributionsMock.mockReturnValue({ contributions: [] }); + tree(); + expect(screen.getByText('0 contributions to show.')).toBeVisible(); + expect(screen.queryByRole('table')).not.toBeInTheDocument(); + }); + + it('shows buttons to paginate through results', () => { + useContributorContributionsMock.mockReturnValue({ contributions: mockContributions, total: 20 }); + tree(); + + const pagination = screen.getByLabelText('pagination navigation'); + + expect(pagination).toBeVisible(); + + // These have different labels because we're currently on page 1. + + expect(within(pagination).getByRole('button', { name: 'page 1' })).toBeVisible(); + expect(within(pagination).getByRole('button', { name: 'Go to page 2' })).toBeVisible(); + }); + + it('fetches a new page when the user chooses a new page', () => { + useContributorContributionsMock.mockReturnValue({ contributions: mockContributions, total: 20 }); + tree(); + useContributorContributionsMock.mockClear(); + userEvent.click(screen.getByRole('button', { name: 'Go to page 2' })); + expect(useContributorContributionsMock.mock.calls).toEqual([[{ page: 2, page_size: 10, rp: 'mock-rp-slug' }]]); + }); + + it('refetches contributions after a payment method is updated', () => { + const refetch = jest.fn(); + + useContributorContributionsMock.mockReturnValue({ refetch, contributions: mockContributions }); + tree(); + expect(refetch).not.toBeCalled(); + userEvent.click(screen.getAllByText('onUpdateRecurringComplete')[0]); + expect(refetch).toBeCalledTimes(1); + }); + + it('cancels a recurring contribution when requested by a row', () => { + const cancelRecurringContribution = jest.fn(); + + useContributorContributionsMock.mockReturnValue({ + cancelRecurringContribution, + contributions: mockContributions + }); + tree(); + expect(cancelRecurringContribution).not.toBeCalled(); + userEvent.click(screen.getAllByText('onCancelRecurring')[1]); + expect(cancelRecurringContribution.mock.calls).toEqual([[mockContributions[1]]]); + }); + }); + + it('is accessible', async () => { + const { container } = tree(); + + expect(await axe(container)).toHaveNoViolations(); + }); +}); diff --git a/spa/src/components/contributor/contributorDashboard/ContributionsTable.tsx b/spa/src/components/contributor/contributorDashboard/ContributionsTable.tsx new file mode 100644 index 0000000000..7d858397be --- /dev/null +++ b/spa/src/components/contributor/contributorDashboard/ContributionsTable.tsx @@ -0,0 +1,83 @@ +import PropTypes, { InferProps } from 'prop-types'; +import { useEffect, useMemo, useState } from 'react'; +import { useAlert } from 'react-alert'; +import { Pagination, Table, TableBody, TableCell, TableContainer, TableHead, TableRow } from 'components/base'; +import GlobalLoading from 'elements/GlobalLoading'; +import useContributorContributionList from 'hooks/useContributorContributionList'; +import { ContributionTableRow } from './ContributionTableRow'; +import { GENERIC_ERROR } from 'constants/textConstants'; + +const ContributionsTablePropTypes = { + rowsPerPage: PropTypes.number, + rpSlug: PropTypes.string.isRequired +}; + +export type ContributionsTableProps = InferProps; + +export function ContributionsTable({ rowsPerPage = 10, rpSlug }: ContributionsTableProps) { + const alert = useAlert(); + const [page, setPage] = useState(1); + const { cancelRecurringContribution, contributions, isLoading, isError, refetch, total } = + useContributorContributionList({ + page, + page_size: rowsPerPage!, + rp: rpSlug + }); + const pageCount = useMemo(() => { + if (!total) { + return 0; + } + + return Math.ceil(total / rowsPerPage!); + }, [rowsPerPage, total]); + + useEffect(() => { + if (isError) { + alert.error(GENERIC_ERROR); + } + }, [alert, isError]); + + if (isLoading) { + return ; + } + + if (contributions.length === 0) { + return

0 contributions to show.

; + } + + // Test IDs are here for compatibility with existing Cypress tests. + + return ( + <> + + + + + Date + Amount + Frequency + Receipt date + Payment method + Payment status + Cancel + + + + {contributions.map((contribution) => ( + refetch()} + /> + ))} + +
+
+ setPage(page)} page={page} /> + + ); +} + +ContributionsTable.propTypes = ContributionsTablePropTypes; +export default ContributionsTable; diff --git a/spa/src/components/contributor/contributorDashboard/ContributorDashboard.js b/spa/src/components/contributor/contributorDashboard/ContributorDashboard.js deleted file mode 100644 index e2f0cafa92..0000000000 --- a/spa/src/components/contributor/contributorDashboard/ContributorDashboard.js +++ /dev/null @@ -1,258 +0,0 @@ -import { useState, useMemo, createContext, useContext, useCallback } from 'react'; -import CreateOutlinedIcon from '@material-ui/icons/CreateOutlined'; -import * as S from './ContributorDashboard.styled'; - -// Assets -import visa from 'assets/icons/visa_icon.svg'; -import mastercard from 'assets/icons/mastercard_icon.svg'; -import amex from 'assets/icons/amex_icon.svg'; -import discover from 'assets/icons/discover_icon.svg'; - -import { useAlert } from 'react-alert'; - -// Context -import { NO_VALUE } from 'constants/textConstants'; - -// Analytics -import { useConfigureAnalytics } from 'components/analytics'; - -import useSubdomain from 'hooks/useSubdomain'; - -// Utils -import toTitleCase from 'utilities/toTitleCase'; -import { getFrequencyAdjective } from 'utilities/parseFrequency'; -import formatDatetimeForDisplay from 'utilities/formatDatetimeForDisplay'; -import formatCurrencyAmount from 'utilities/formatCurrencyAmount'; - -// AJAX -import axios, { AuthenticationError } from 'ajax/axios'; -import { CONTRIBUTIONS, SUBSCRIPTIONS } from 'ajax/endpoints'; - -// Children -import CancelRecurringButton from './CancelRecurringButton'; -import ContributorTokenExpiredModal from 'components/contributor/contributorDashboard/ContributorTokenExpiredModal'; -import DonationsTable from 'components/donations/DonationsTable'; -import EditRecurringPaymentModal from 'components/contributor/contributorDashboard/EditRecurringPaymentModal'; -import GlobalLoading from 'elements/GlobalLoading'; -import { PAYMENT_STATUS } from 'constants/paymentStatus'; -import { CONTRIBUTION_INTERVALS } from 'constants/contributionIntervals'; -import HeaderSection from 'components/common/HeaderSection'; - -const ContributorDashboardContext = createContext(); - -function ContributorDashboard() { - const alert = useAlert(); - - // State - const [loading, setLoading] = useState(false); - const [tokenExpired, setTokenExpired] = useState(false); - const [contriubtions, setContributions] = useState([]); - const [selectedContribution, setSelectedContribution] = useState(); - const [refetch, setRefetch] = useState(false); - const [pageIndex, setPageIndex] = useState(0); - const subdomain = useSubdomain(); - - // Analytics setup - useConfigureAnalytics(); - - const handlePageChange = (newPageIndex) => { - setPageIndex(newPageIndex); - }; - - const fetchDonations = useCallback(async (params, { onSuccess, onFailure }) => { - try { - const query_params = { ...params, rp: subdomain }; - const response = await axios.get(CONTRIBUTIONS, { params: { ...query_params } }); - onSuccess(response); - } catch (e) { - if (e instanceof AuthenticationError || e?.response?.status === 403) { - setTokenExpired(true); - } else { - onFailure(e); - } - } - }, []); - - const handleEditRecurringPayment = (contribution) => { - setSelectedContribution(contribution); - }; - - const cancelContribution = useCallback( - async (contribution) => { - setLoading(true); - try { - await axios.delete(`${SUBSCRIPTIONS}${contribution.subscription_id}/`, { - data: { revenue_program_slug: contribution.revenue_program } - }); - alert.info( - 'Recurring contribution has been canceled. No more payments will be made. Changes may not appear here immediately.', - { timeout: 8000 } - ); - } catch (e) { - alert.error( - 'We were unable to cancel this recurring contribution. Please try again later. We have been notified of the problem.' - ); - } finally { - setLoading(false); - } - }, - [alert] - ); - - const handleCancelContribution = useCallback( - (contribution) => { - cancelContribution(contribution); - }, - [cancelContribution] - ); - - const getRowIsDisabled = (row) => { - const contribution = row.original; - const disabledStatuses = [PAYMENT_STATUS.CANCELED, PAYMENT_STATUS.PROCESSING]; - return disabledStatuses.includes(contribution.status); - }; - - const columns = useMemo( - () => [ - { - Header: 'Date', - accessor: 'created', - Cell: (props) => (props.value ? : NO_VALUE) - }, - { - Header: 'Amount', - accessor: 'amount', - Cell: (props) => (props.value ? formatCurrencyAmount(props.value) : NO_VALUE) - }, - { - Header: 'Frequency', - accessor: 'interval', - Cell: (props) => (props.value ? getFrequencyAdjective(props.value) : NO_VALUE) - }, - { - Header: 'Receipt date', - accessor: 'last_payment_date', - Cell: (props) => (props.value ? : NO_VALUE) - }, - { - Header: 'Payment method', - accessor: 'last4', - Cell: (props) => ( - - ), - disableSortBy: true - }, - { - Header: 'Payment status', - accessor: 'status', - Cell: (props) => - }, - { - id: 'cancel', - Header: 'Cancel', - disableSortBy: true, - Cell: (props) => - } - ], - [handleCancelContribution] - ); - - return ( - - <> - - - - - {tokenExpired && } - {selectedContribution && ( - setSelectedContribution(null)} - contribution={selectedContribution} - onComplete={() => setRefetch(true)} - /> - )} - {loading && } - - - ); -} - -export const useContributorDashboardContext = () => useContext(ContributorDashboardContext); - -export default ContributorDashboard; - -export function StatusCellIcon({ status, showText = false, size = 'lg' }) { - return ( - - - {toTitleCase(status)} - - - ); -} - -export function PaymentMethodCell({ contribution, handlePaymentClick }) { - if (!contribution.card_brand && !contribution.last4) return '?'; - - const canInteract = !!handlePaymentClick && contribution.interval !== CONTRIBUTION_INTERVALS.ONE_TIME; - - return ( - (canInteract ? handlePaymentClick(contribution) : {})} - data-testid="payment-method" - > - - {contribution.card_brand && ( - - )} - {contribution.last4 && •••• {contribution.last4}} - - {canInteract && ( - - - - )} - - ); -} - -function getCardBrandIcon(brand) { - switch (brand) { - case 'visa': - return visa; - case 'mastercard': - return mastercard; - case 'amex': - return amex; - case 'discover': - return discover; - - default: - return null; - } -} - -function FormatDateTime({ value }) { - return ( -

- {formatDatetimeForDisplay(value)} {formatDatetimeForDisplay(value, true)} -

- ); -} diff --git a/spa/src/components/contributor/contributorDashboard/ContributorDashboard.styled.js b/spa/src/components/contributor/contributorDashboard/ContributorDashboard.styled.js deleted file mode 100644 index 832aa53d54..0000000000 --- a/spa/src/components/contributor/contributorDashboard/ContributorDashboard.styled.js +++ /dev/null @@ -1,87 +0,0 @@ -import { IconButton } from '@material-ui/core'; -import styled from 'styled-components'; - -import { PAYMENT_STATUS } from 'constants/paymentStatus'; - -export const ContributorDashboard = styled.main` - height: 100%; - display: flex; - flex-direction: column; - font-family: ${(props) => props.theme.systemFont}; - padding: 3rem 4.5rem; - gap: 3rem; - background: ${(props) => props.theme.colors.cstm_mainBackground}; - @media (${(props) => props.theme.breakpoints.phoneOnly}) { - padding: 1.5rem 1rem; - } -`; - -export const StatusCellWrapper = styled.div` - display: flex; - flex-direction: row; - align-items: center; -`; - -export const StatusText = styled.p` - margin-left: 1rem; - font-size: ${(props) => (props.size === 'sm' ? '11px' : '14px')}; - padding: 0.2rem 0.8rem; - color: ${(props) => props.theme.colors.black}; - border-radius: ${(props) => props.theme.muiBorderRadius.md}; - line-height: 1.2; - ${(props) => - ({ - [PAYMENT_STATUS.PROCESSING]: ` - background-color: ${props.theme.colors.status.processing}; - font-style: italic; - `, - [PAYMENT_STATUS.FAILED]: ` - background-color: ${props.theme.colors.status.failed}; - `, - [PAYMENT_STATUS.PAID]: ` - background-color: ${props.theme.colors.status.done}; - `, - [PAYMENT_STATUS.CANCELED]: ` - background-color: ${props.theme.colors.status.warning}; - font-style: italic; - `, - [PAYMENT_STATUS.FLAGGED]: '', - [PAYMENT_STATUS.REJECTED]: '' - }[props.status])} -`; - -export const EditButton = styled(IconButton)` - && { - color: ${(props) => props.theme.colors.muiGrey[300]}; - } -`; - -export const Time = styled.span` - margin-left: 1rem; -`; - -export const PaymentMethodCell = styled.div` - display: flex; - flex-direction: row; - align-items: center; - justify-content: space-between; - cursor: ${(props) => (props.interactive ? 'pointer' : 'default')}; -`; - -export const PaymentCardInfoWrapper = styled.div` - display: flex; - flex-direction: row; - align-items: center; -`; - -export const BrandIcon = styled.img` - max-width: 45px; - height: 30px; - margin-right: 0.8rem; -`; - -export const Last4 = styled.p` - margin: 0; - color: ${(props) => props.theme.colors.grey[2]}; - white-space: nowrap; -`; diff --git a/spa/src/components/contributor/contributorDashboard/ContributorDashboard.styled.ts b/spa/src/components/contributor/contributorDashboard/ContributorDashboard.styled.ts new file mode 100644 index 0000000000..d9d9933dfa --- /dev/null +++ b/spa/src/components/contributor/contributorDashboard/ContributorDashboard.styled.ts @@ -0,0 +1,14 @@ +import styled from 'styled-components'; + +export const Root = styled.main` + height: 100%; + display: flex; + flex-direction: column; + font-family: ${(props) => props.theme.systemFont}; + padding: 3rem 4.5rem; + gap: 3rem; + background: ${(props) => props.theme.colors.cstm_mainBackground}; + @media (${(props) => props.theme.breakpoints.phoneOnly}) { + padding: 1.5rem 1rem; + } +`; diff --git a/spa/src/components/contributor/contributorDashboard/ContributorDashboard.test.tsx b/spa/src/components/contributor/contributorDashboard/ContributorDashboard.test.tsx new file mode 100644 index 0000000000..bedf4c849b --- /dev/null +++ b/spa/src/components/contributor/contributorDashboard/ContributorDashboard.test.tsx @@ -0,0 +1,63 @@ +import { axe } from 'jest-axe'; +import { render, screen } from 'test-utils'; +import useSubdomain from 'hooks/useSubdomain'; +import ContributorDashboard from './ContributorDashboard'; +import { useConfigureAnalytics } from 'components/analytics'; +import userEvent from '@testing-library/user-event'; + +jest.mock('components/analytics'); +jest.mock('hooks/useSubdomain'); +jest.mock('./ContributionsTable'); +jest.mock('./ContributorTokenExpiredModal'); + +function tree() { + return render(); +} + +describe('ContributorDashboard', () => { + const useConfigureAnalyticsMock = useConfigureAnalytics as jest.Mock; + const useSubdomainMock = useSubdomain as jest.Mock; + + beforeEach(() => useSubdomainMock.mockReturnValue('mock-subdomain')); + + it('configures analytics', () => { + tree(); + expect(useConfigureAnalyticsMock).toBeCalledTimes(1); + }); + + it('displays a heading', () => { + tree(); + expect(screen.getByRole('heading', { name: 'Your Contributions' })).toBeVisible(); + }); + + it('displays explanatory text', () => { + tree(); + expect(screen.getByText('Changes made may not be reflected immediately.')).toBeVisible(); + }); + + it('displays a contributions table using the subdomain as the revenue program slug', () => { + useSubdomainMock.mockReturnValue('test-subdomain'); + tree(); + + const table = screen.getByTestId('mock-contributions-table'); + + expect(table).toBeVisible(); + expect(table).toHaveAttribute('data-rp-slug', 'test-subdomain'); + }); + + it('shows a token expiration dialog if a child signals the token has expired', () => { + tree(); + expect(screen.queryByTestId('mock-contributor-token-expired-modal')).not.toBeInTheDocument(); + userEvent.click(screen.getByText('setTokenExpired')); + + const modal = screen.getByTestId('mock-contributor-token-expired-modal'); + + expect(modal).toHaveAttribute('data-is-open', 'true'); + }); + + it('is accessible', async () => { + const { container } = tree(); + + expect(await axe(container)).toHaveNoViolations(); + }); +}); diff --git a/spa/src/components/contributor/contributorDashboard/ContributorDashboard.tsx b/spa/src/components/contributor/contributorDashboard/ContributorDashboard.tsx new file mode 100644 index 0000000000..a14845d733 --- /dev/null +++ b/spa/src/components/contributor/contributorDashboard/ContributorDashboard.tsx @@ -0,0 +1,40 @@ +import { createContext, Dispatch, SetStateAction, useContext, useState } from 'react'; +import { useConfigureAnalytics } from 'components/analytics'; +import HeaderSection from 'components/common/HeaderSection'; +import useSubdomain from 'hooks/useSubdomain'; +import ContributorTokenExpiredModal from './ContributorTokenExpiredModal'; +import ContributionsTable from './ContributionsTable'; +import { Root } from './ContributorDashboard.styled'; + +const ContributorDashboardContext = createContext<{ + tokenExpired: boolean; + setTokenExpired: Dispatch>; +}>({ + setTokenExpired: () => { + throw new Error('This context must be used inside a element.'); + }, + tokenExpired: false +}); + +export const useContributorDashboardContext = () => useContext(ContributorDashboardContext); + +function ContributorDashboard() { + const [tokenExpired, setTokenExpired] = useState(false); + const subdomain = useSubdomain(); + + useConfigureAnalytics(); + + return ( + + <> + + + + + {tokenExpired && } + + + ); +} + +export default ContributorDashboard; diff --git a/spa/src/components/contributor/contributorDashboard/EditRecurringPaymentModal.js b/spa/src/components/contributor/contributorDashboard/EditRecurringPaymentModal.js index 56a5b0337c..5e8ae269b4 100644 --- a/spa/src/components/contributor/contributorDashboard/EditRecurringPaymentModal.js +++ b/spa/src/components/contributor/contributorDashboard/EditRecurringPaymentModal.js @@ -30,9 +30,9 @@ import { HUB_STRIPE_API_PUB_KEY } from 'appSettings'; // Children import Modal from 'elements/modal/Modal'; -import { PaymentMethodCell } from 'components/contributor/contributorDashboard/ContributorDashboard'; import Button from 'elements/buttons/Button'; import GlobalLoading from 'elements/GlobalLoading'; +import ContributionPaymentMethod from './ContributionPaymentMethod'; function EditRecurringPaymentModal({ isOpen, closeModal, contribution, onComplete }) { const stripe = useRef(loadStripe(HUB_STRIPE_API_PUB_KEY, { stripeAccount: contribution.stripe_account_id })); @@ -77,7 +77,7 @@ function EditRecurringPaymentModal({ isOpen, closeModal, contribution, onComplet Interval: {getFrequencyAdjective(contribution.interval)} - Payment method: + Payment method: {stripe && stripe.current && ( diff --git a/spa/src/components/contributor/contributorDashboard/__mocks__/CancelRecurringButton.tsx b/spa/src/components/contributor/contributorDashboard/__mocks__/CancelRecurringButton.tsx new file mode 100644 index 0000000000..d6c15ac173 --- /dev/null +++ b/spa/src/components/contributor/contributorDashboard/__mocks__/CancelRecurringButton.tsx @@ -0,0 +1,13 @@ +export function CancelRecurringButton({ contribution, onCancel }: any) { + return ( + + ); +} + +export default CancelRecurringButton; diff --git a/spa/src/components/contributor/contributorDashboard/__mocks__/ContributionPaymentMethod.tsx b/spa/src/components/contributor/contributorDashboard/__mocks__/ContributionPaymentMethod.tsx new file mode 100644 index 0000000000..00bfc757a7 --- /dev/null +++ b/spa/src/components/contributor/contributorDashboard/__mocks__/ContributionPaymentMethod.tsx @@ -0,0 +1,13 @@ +export function ContributionPaymentMethod({ contribution, onUpdateComplete }: any) { + return ( + + ); +} + +export default ContributionPaymentMethod; diff --git a/spa/src/components/contributor/contributorDashboard/__mocks__/ContributionTableRow.tsx b/spa/src/components/contributor/contributorDashboard/__mocks__/ContributionTableRow.tsx new file mode 100644 index 0000000000..7c75989dce --- /dev/null +++ b/spa/src/components/contributor/contributorDashboard/__mocks__/ContributionTableRow.tsx @@ -0,0 +1,14 @@ +export function ContributionTableRow({ contribution, onCancelRecurring, onUpdateRecurringComplete }: any) { + return ( + + + + + + + + + ); +} + +export default ContributionTableRow; diff --git a/spa/src/components/contributor/contributorDashboard/__mocks__/ContributionsTable.tsx b/spa/src/components/contributor/contributorDashboard/__mocks__/ContributionsTable.tsx new file mode 100644 index 0000000000..db8046a0a3 --- /dev/null +++ b/spa/src/components/contributor/contributorDashboard/__mocks__/ContributionsTable.tsx @@ -0,0 +1,17 @@ +import { useContributorDashboardContext } from '../ContributorDashboard'; + +// We need something to test the context, and can't insert children manually +// into ContributorDashboard, so we do it here. + +export function ContributionsTable({ rpSlug }: { rpSlug: string }) { + const { setTokenExpired } = useContributorDashboardContext(); + + return ( + <> +
+ + + ); +} + +export default ContributionsTable; diff --git a/spa/src/components/contributor/contributorDashboard/__mocks__/ContributorTokenExpiredModal.js b/spa/src/components/contributor/contributorDashboard/__mocks__/ContributorTokenExpiredModal.js new file mode 100644 index 0000000000..428c493063 --- /dev/null +++ b/spa/src/components/contributor/contributorDashboard/__mocks__/ContributorTokenExpiredModal.js @@ -0,0 +1,5 @@ +function ContributorTokenExpiredModal(props) { + return
; +} + +export default ContributorTokenExpiredModal; diff --git a/spa/src/components/contributor/contributorDashboard/__mocks__/EditRecurringPaymentModal.js b/spa/src/components/contributor/contributorDashboard/__mocks__/EditRecurringPaymentModal.js new file mode 100644 index 0000000000..0e2ea8acc9 --- /dev/null +++ b/spa/src/components/contributor/contributorDashboard/__mocks__/EditRecurringPaymentModal.js @@ -0,0 +1,10 @@ +function EditRecurringPaymentModal(props) { + return ( +
+ + +
+ ); +} + +export default EditRecurringPaymentModal; diff --git a/spa/src/components/donations/DonationDetail.js b/spa/src/components/donations/DonationDetail.js index 91e13e4eb9..1be29a6b18 100644 --- a/spa/src/components/donations/DonationDetail.js +++ b/spa/src/components/donations/DonationDetail.js @@ -18,7 +18,7 @@ import { GENERIC_ERROR, NO_VALUE } from 'constants/textConstants'; import Button from 'elements/buttons/Button'; import { faBan, faCheck } from '@fortawesome/free-solid-svg-icons'; import { getFrequencyAdjective } from 'utilities/parseFrequency'; -import { StatusCellIcon } from 'components/contributor/contributorDashboard/ContributorDashboard'; +import { PaymentStatus } from 'components/common/PaymentStatus'; import PageTitle from 'elements/PageTitle'; function DonationDetail() { @@ -142,9 +142,7 @@ function DonationDetail() {
Status
-
- -
+
{status && }
Donor
diff --git a/spa/src/components/donations/Donations.js b/spa/src/components/donations/Donations.js index d2e76edf14..6a9534bc03 100644 --- a/spa/src/components/donations/Donations.js +++ b/spa/src/components/donations/Donations.js @@ -21,7 +21,7 @@ import formatDatetimeForDisplay from 'utilities/formatDatetimeForDisplay'; // Children import Banner from 'components/common/Banner'; import Hero from 'components/common/Hero'; -import { StatusCellIcon } from 'components/contributor/contributorDashboard/ContributorDashboard'; +import { PaymentStatus } from 'components/common/PaymentStatus'; import DashboardSection from 'components/dashboard/DashboardSection'; import DonationDetail from 'components/donations/DonationDetail'; import DonationsTable from 'components/donations/DonationsTable'; @@ -132,7 +132,7 @@ const Donations = () => { { Header: 'Payment status', accessor: 'status', - Cell: (props) => + Cell: (props) => } ], [] diff --git a/spa/src/elements/__mocks__/GlobalLoading.js b/spa/src/elements/__mocks__/GlobalLoading.js new file mode 100644 index 0000000000..babb3aee76 --- /dev/null +++ b/spa/src/elements/__mocks__/GlobalLoading.js @@ -0,0 +1,3 @@ +export default function GlobalLoading() { + return
; +} diff --git a/spa/src/hooks/useContributorContributionList.test.ts b/spa/src/hooks/useContributorContributionList.test.ts new file mode 100644 index 0000000000..48de01f451 --- /dev/null +++ b/spa/src/hooks/useContributorContributionList.test.ts @@ -0,0 +1,278 @@ +import { renderHook } from '@testing-library/react-hooks'; +import Axios from 'ajax/axios'; +import MockAdapter from 'axios-mock-adapter'; +import { useAlert } from 'react-alert'; +import { TestQueryClientProvider } from 'test-utils'; +import useContributorContributionList, { + FetchContributorsContributionsResponse +} from './useContributorContributionList'; + +jest.mock('react-alert'); + +const mockGetResponse: FetchContributorsContributionsResponse = { + count: 1, + results: [ + { + amount: 123, + card_brand: 'amex', + created: 'mock-created-1', + credit_card_expiration_date: 'mock-cc-expiration-1', + id: 'mock-id-1', + interval: 'month', + is_cancelable: true, + is_modifiable: true, + last4: 1234, + last_payment_date: 'mock-last-payment-1', + payment_type: 'mock-payment-type-1', + provider_customer_id: 'mock-customer-1', + revenue_program: 'mock-rp-1', + status: 'paid', + stripe_account_id: 'mock-account-1', + subscription_id: 'mock-sub-id' + }, + { + amount: 456, + card_brand: 'visa', + created: 'mock-created-2', + credit_card_expiration_date: 'mock-cc-expiration-2', + id: 'mock-id-2', + interval: 'one_time', + is_cancelable: false, + is_modifiable: false, + last4: 5678, + last_payment_date: 'mock-last-payment-2', + payment_type: 'mock-payment-type-2', + provider_customer_id: 'mock-customer-2', + revenue_program: 'mock-rp-2', + status: 'paid', + stripe_account_id: 'mock-account-2' + } + ] +}; + +describe('useContributorContributionList', () => { + const axiosMock = new MockAdapter(Axios); + const useAlertMock = useAlert as jest.Mock; + + beforeEach(() => { + axiosMock.onGet('contributions/').reply(200, mockGetResponse); + useAlertMock.mockReturnValue({ error: jest.fn(), info: jest.fn() }); + }); + afterEach(() => axiosMock.reset()); + afterAll(() => axiosMock.restore()); + + it('fetches contributions from contributions/ using query params provided', async () => { + const { result, waitFor } = renderHook( + () => useContributorContributionList({ page: 3, page_size: 99, rp: 'test-rp' }), + { + wrapper: TestQueryClientProvider + } + ); + + await waitFor(() => expect(axiosMock.history.get.length).toBe(1)); + expect(axiosMock.history.get[0]).toEqual( + expect.objectContaining({ + url: 'contributions/', + params: { ordering: expect.any(String), page: 3, page_size: 99, rp: 'test-rp' } + }) + ); + expect(result.current.contributions).toEqual(mockGetResponse.results); + expect(result.error).toBeUndefined(); + }); + + it("sets the ordering query param to '-created,contributor_email' by default", async () => { + const { waitFor } = renderHook(() => useContributorContributionList(), { + wrapper: TestQueryClientProvider + }); + + await waitFor(() => expect(axiosMock.history.get.length).toBe(1)); + expect(axiosMock.history.get[0].params.ordering).toBe('-created,contributor_email'); + }); + + it('allows overriding the ordering query param', async () => { + const { waitFor } = renderHook(() => useContributorContributionList({ ordering: 'test-ordering' }), { + wrapper: TestQueryClientProvider + }); + + await waitFor(() => expect(axiosMock.history.get.length).toBe(1)); + expect(axiosMock.history.get[0].params.ordering).toBe('test-ordering'); + }); + + describe('While fetching contributions', () => { + // These wait for Promise.resolve() to allow component updates to happen + // after the fetch completes. + + it('returns a loading status', async () => { + const { result, waitForNextUpdate } = renderHook(() => useContributorContributionList(), { + wrapper: TestQueryClientProvider + }); + + expect(result.current.isFetching).toBe(true); + expect(result.current.isLoading).toBe(true); + await waitForNextUpdate(); + }); + + it('returns an empty array of contributions', async () => { + const { result, waitForNextUpdate } = renderHook(() => useContributorContributionList(), { + wrapper: TestQueryClientProvider + }); + + expect(result.current.contributions).toEqual([]); + await waitForNextUpdate(); + }); + }); + + describe('When fetching contributions fails', () => { + let errorSpy: jest.SpyInstance; + + beforeEach(() => { + axiosMock.onGet('contributions/').networkError(); + errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + afterEach(() => errorSpy.mockRestore()); + + it('returns an error status', async () => { + const { result, waitForNextUpdate } = renderHook(() => useContributorContributionList(), { + wrapper: TestQueryClientProvider + }); + + await waitForNextUpdate(); + expect(result.current.isError).toBe(true); + }); + + it('returns an empty array of contributions', async () => { + const { result, waitForNextUpdate } = renderHook(() => useContributorContributionList(), { + wrapper: TestQueryClientProvider + }); + + await waitForNextUpdate(); + expect(result.current.contributions).toEqual([]); + }); + }); + + it('refetches contributions using the same query params when the refetch function is called', async () => { + const { result, waitFor } = renderHook( + () => useContributorContributionList({ ordering: 'test-ordering', rp: 'test-rp-for-refetch' }), + { + wrapper: TestQueryClientProvider + } + ); + + await waitFor(() => expect(axiosMock.history.get.length).toBe(1)); + result.current.refetch(); + await waitFor(() => expect(axiosMock.history.get.length).toBe(2)); + expect(axiosMock.history.get[1]).toEqual( + expect.objectContaining({ + url: 'contributions/', + params: { ordering: 'test-ordering', rp: 'test-rp-for-refetch' } + }) + ); + }); + + describe('cancelRecurringContribution', () => { + it('sends a DELETE request to /subscriptions', async () => { + axiosMock.onDelete(`subscriptions/${mockGetResponse.results[0].subscription_id}/`).reply(204); + + const { result, waitForNextUpdate } = renderHook(() => useContributorContributionList(), { + wrapper: TestQueryClientProvider + }); + + await waitForNextUpdate(); + expect(axiosMock.history.delete.length).toBe(0); + await result.current.cancelRecurringContribution(result.current.contributions[0]); + expect(axiosMock.history.delete.length).toBe(1); + await waitForNextUpdate(); + }); + + describe('If the DELETE request succeeds', () => { + beforeEach(() => axiosMock.onDelete(`subscriptions/${mockGetResponse.results[0].subscription_id}/`).reply(204)); + + it('displays an info alert to the user', async () => { + const info = jest.fn(); + + useAlertMock.mockReturnValue({ info }); + + const { result, waitForNextUpdate } = renderHook(() => useContributorContributionList(), { + wrapper: TestQueryClientProvider + }); + + await waitForNextUpdate(); + expect(info).not.toBeCalled(); + await result.current.cancelRecurringContribution(result.current.contributions[0]); + expect(info).toBeCalledTimes(1); + await waitForNextUpdate(); + }); + + it('removes the contribution from the list', async () => { + const { result, waitForNextUpdate } = renderHook(() => useContributorContributionList(), { + wrapper: TestQueryClientProvider + }); + + await waitForNextUpdate(); + await result.current.cancelRecurringContribution(result.current.contributions[0]); + await waitForNextUpdate(); + expect(result.current.contributions).toEqual([mockGetResponse.results[1]]); + }); + }); + + describe('If the DELETE fails', () => { + let errorSpy: jest.SpyInstance; + + beforeEach(() => { + axiosMock.onDelete(`subscriptions/${mockGetResponse.results[0].subscription_id}/`).networkError(); + errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => errorSpy.mockRestore()); + + it('logs the error to the console', async () => { + const { result, waitForNextUpdate } = renderHook(() => useContributorContributionList(), { + wrapper: TestQueryClientProvider + }); + + await waitForNextUpdate(); + expect(errorSpy).not.toBeCalled(); + await result.current.cancelRecurringContribution(result.current.contributions[0]); + expect(errorSpy).toBeCalledTimes(2); // Axios also logs an error + }); + + it('displays an error alert to the user', async () => { + const error = jest.fn(); + + useAlertMock.mockReturnValue({ error }); + const { result, waitForNextUpdate } = renderHook(() => useContributorContributionList(), { + wrapper: TestQueryClientProvider + }); + + await waitForNextUpdate(); + expect(error).not.toBeCalled(); + await result.current.cancelRecurringContribution(result.current.contributions[0]); + expect(error).toBeCalledTimes(1); + }); + + it("doesn't change the contribution list", async () => { + const { result, waitForNextUpdate } = renderHook(() => useContributorContributionList(), { + wrapper: TestQueryClientProvider + }); + + await waitForNextUpdate(); + await result.current.cancelRecurringContribution(result.current.contributions[0]); + await waitForNextUpdate(); + expect(result.current.contributions).toEqual(mockGetResponse.results); + }); + }); + + it("logs an error and doesn't make a DELETE request if the contribution is not cancellable", async () => { + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + const { result, waitForNextUpdate } = renderHook(() => useContributorContributionList(), { + wrapper: TestQueryClientProvider + }); + + await waitForNextUpdate(); + result.current.cancelRecurringContribution(result.current.contributions[1]); + await Promise.resolve(); + expect(axiosMock.history.delete).toEqual([]); + errorSpy.mockRestore(); + }); + }); +}); diff --git a/spa/src/hooks/useContributorContributionList.ts b/spa/src/hooks/useContributorContributionList.ts new file mode 100644 index 0000000000..1d550a559b --- /dev/null +++ b/spa/src/hooks/useContributorContributionList.ts @@ -0,0 +1,205 @@ +import { useMutation, useQuery, useQueryClient, UseQueryResult } from '@tanstack/react-query'; +import { useCallback, useMemo } from 'react'; +import { useAlert } from 'react-alert'; +import axios from 'ajax/axios'; +import { CONTRIBUTIONS, SUBSCRIPTIONS } from 'ajax/endpoints'; +import { ContributionInterval } from 'constants/contributionIntervals'; +import { PaymentStatus } from 'constants/paymentStatus'; + +export type CardBrand = 'amex' | 'diners' | 'discover' | 'jcb' | 'mastercard' | 'unionpay' | 'visa' | 'unknown'; + +/** + * A single contribution returned from the API for the person who made it. + * **Contributions retrieved by an org or hub admin have a different shape.** + */ +export interface ContributorContribution { + /** + * Amount **in cents** of the contribution. + */ + amount: number; + /** + * What payment card was used on the contribution. + */ + card_brand: CardBrand; + /** + * Timestamp of when the contribution was created. + */ + created: string; + /** + * When the credit card used for the contribution will expire. + * @example "4/2024" + */ + credit_card_expiration_date: string; + /** + * Internal ID for the contribution. + */ + id: string; + /** + * How often the contribution is being made. + */ + interval: ContributionInterval; + /** + * Can the contribution be cancelled? + */ + is_cancelable: boolean; + /** + * Can the contribution be modified? (e.g. to change its payment method) + */ + is_modifiable: boolean; + /** + * Last four digits of the payment card used on the contribution. + */ + last4: number; + /** + * Timestamp of when the last payment occurred related + */ + last_payment_date: string; + /** + * How was the payment made? + */ + payment_type: string; + /** + * Internal ID of the customer in the payment processor, e.g. Stripe. + */ + provider_customer_id: string; + /** + * Slug of the revenue program that was contributed to. + */ + revenue_program: string; + /** + * Processing status of the payment. + */ + status?: PaymentStatus; + /** + * Stripe account ID that received the contribution. + */ + stripe_account_id: string; + /** + * Stripe subscription ID, if this is a recurring contribution. + */ + subscription_id?: string; +} + +export interface UseContributionListQueryParams { + ordering?: string; + page?: number; + page_size?: number; + rp?: string; +} + +export interface FetchContributorsContributionsResponse { + count: number; + next?: string; + previous?: string; + results: ContributorContribution[]; +} + +async function fetchContributions(queryParams?: UseContributionListQueryParams) { + const { data } = await axios.get(CONTRIBUTIONS, { params: queryParams }); + + return { count: data.count, results: data.results }; +} + +export interface UseContributorContributionListResult { + cancelRecurringContribution: (contribution: ContributorContribution) => Promise; + contributions: ContributorContribution[]; + isError: UseQueryResult['isError']; + isFetching: UseQueryResult['isFetching']; + isLoading: UseQueryResult['isLoading']; + refetch: UseQueryResult['refetch']; + total: number; +} + +/** + * Manages contribution data for the logged-in contributor user. **This returns + * different data than what what an org or Hub admin would receive.** + */ +export function useContributorContributionList( + queryParams?: UseContributionListQueryParams +): UseContributorContributionListResult { + const alert = useAlert(); + const queryClient = useQueryClient(); + const mergedParams = useMemo(() => ({ ordering: '-created,contributor_email', ...queryParams }), [queryParams]); + + // Our query is keyed on query params. This is important because uses of this + // hook with different query params will see different data, and cancelling a + // contribution won't appear to do anything as it's updating a different query + // under the covers. + // + // We use keepPreviousData so that switching pages is smoother. + + const { data, isError, isFetching, isLoading, refetch } = useQuery( + ['contributorContributions', mergedParams], + () => fetchContributions(mergedParams), + { keepPreviousData: true } + ); + + // Basic API request to cancel a recurring subscription. + + const cancelRecurringMutation = useMutation((contribution: ContributorContribution) => + axios.delete(`${SUBSCRIPTIONS}${contribution.subscription_id}/`, { + data: { revenue_program_slug: contribution.revenue_program } + }) + ); + + // This wrapper function presents a simple API for cancelling a contribution + // _and_ locally removes the contribution from the query result. The reason + // why we have this is that we don't yet have backend work done to properly + // update the local contributor cache when one is cancelled. (It gets updated + // in the backend when the user next logs in.) + // + // This means that if the user refreshes their browser or React Query decides + // to invalidate the cache, the deleting contribution reappears--but there's + // not much we can do about that here. + + const cancelRecurringContribution = useCallback( + async (contribution: ContributorContribution) => { + try { + if (!contribution.is_cancelable) { + throw new Error('This contribution is not cancelable'); + } + + await cancelRecurringMutation.mutateAsync(contribution); + queryClient.setQueryData(['contributorContributions', mergedParams], (old: unknown) => { + const oldData = old as FetchContributorsContributionsResponse; + + // If there is no data or it seems to be the wrong shape, do nothing. + // It seems like this only happens if there's a problem with the + // mutation itself. + + if (!Array.isArray(oldData?.results)) { + return old; + } + + // Remove the contribution we just cancelled from existing data. + + return { ...oldData, results: oldData.results.filter(({ id }) => id !== contribution.id) }; + }); + alert.info( + 'Recurring contribution has been canceled. No more payments will be made. Changes may not appear here immediately.', + { timeout: 8000 } + ); + } catch (error) { + // Log it for Sentry and tell the user. + + console.error(error); + alert.error( + 'We were unable to cancel this recurring contribution. Please try again later. We have been notified of the problem.' + ); + } + }, + [alert, cancelRecurringMutation, mergedParams, queryClient] + ); + + return { + cancelRecurringContribution, + contributions: data?.results ?? [], + isError, + isFetching, + isLoading, + refetch, + total: data?.count ?? 0 + }; +} + +export default useContributorContributionList; diff --git a/spa/src/styles/defaultTheme.d.ts b/spa/src/styles/defaultTheme.d.ts index 6fe00017f7..170e873d20 100644 --- a/spa/src/styles/defaultTheme.d.ts +++ b/spa/src/styles/defaultTheme.d.ts @@ -8,6 +8,7 @@ import 'styled-components'; declare module 'styled-components' { export interface DefaultTheme { colors: { + cstm_mainBackground?: string; primary: string; primaryLight: string; secondary: string; diff --git a/spa/src/test-utils.tsx b/spa/src/test-utils.tsx index bcf33d3e8b..1c48089526 100644 --- a/spa/src/test-utils.tsx +++ b/spa/src/test-utils.tsx @@ -15,6 +15,7 @@ import { AnalyticsContextWrapper } from './components/analytics/AnalyticsContext // Routing import { BrowserRouter } from 'react-router-dom'; +import { ReactChild } from 'react'; export * from '@testing-library/react'; export * as user from '@testing-library/user-event'; @@ -36,6 +37,29 @@ function TestProviders({ children }: { children?: React.ReactNode }) { ); } +/** + * A wrapper component for testing hooks that use react-query. This creates a + * new query client for each usage--e.g. avoids caching results from a previous + * test--that also instantly fails if fetching fails instead of retrying. + */ +export function TestQueryClientProvider({ children }: { children: ReactChild }) { + return ( + + {children} + + ); +} + export const render = (ui: React.ReactElement, options?: Omit) => { return rtl.render(ui, { wrapper: (props) =>