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)}{' '}
+ {formatDatetimeForDisplay(contribution.created, true)}
+
+
+
+ {formatCurrencyAmount(contribution.amount)}
+
+
+
+ {getFrequencyAdjective(contribution.interval)}
+
+
+
+
+ {formatDatetimeForDisplay(contribution.last_payment_date)}{' '}
+ {formatDatetimeForDisplay(contribution.last_payment_date, true)}
+
+
+
+ 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 (
+ onCancel(contribution)}
+ >
+ onCancelRecurring
+
+ );
+}
+
+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 (
+ onUpdateComplete(contribution)}
+ >
+ onUpdateComplete
+
+ );
+}
+
+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 (
+
+
+ onCancelRecurring(contribution)}>onCancelRecurring
+
+
+ onUpdateRecurringComplete(contribution)}>onUpdateRecurringComplete
+
+
+ );
+}
+
+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 (
+ <>
+
+ setTokenExpired(true)}>setTokenExpired
+ >
+ );
+}
+
+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 (
+
+ closeModal
+ onComplete
+
+ );
+}
+
+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) =>