Untitled
unknown
plain_text
2 years ago
460 kB
8
Indexable
// Code from file collective-frontend/src/modules/Hub/index.jsx import React from 'react'; import { Redirect, Route, useLocation } from 'react-router-dom'; import lazyWithSuspenseAndRetry from 'modules/common/lazyWithSuspenseAndRetry'; const HubHomePage = lazyWithSuspenseAndRetry(() => import('./Home/Page')); /** * This function redirects old urls to the new hub path. * Should be removed when everything is migrated to the correct path on SFDC */ function RedirectToHub() { const location = useLocation(); return <Redirect to={`/hub${location.pathname}`} />; } export default function getHubRoutes() { return [ <Route exact key="hub-home" path="/hub/*" component={HubHomePage} />, <Route exact key="client-info" path="/client-info/*" to={RedirectToHub} />, <Route exact key="transition-plan" path="/transition-plan/*" component={RedirectToHub} />, ]; } // Code from file collective-frontend/src/modules/Hub/ClientInfo/index.jsx import React from 'react'; import { Redirect, Route } from 'react-router-dom'; import lazyWithSuspenseAndRetry from 'modules/common/lazyWithSuspenseAndRetry'; export const ClientInfoSearchPage = lazyWithSuspenseAndRetry(() => import('./Search/Page')); export const ClientInfoDetailsPage = lazyWithSuspenseAndRetry(() => import('./Details/Page')); export default function getHubClientInfoRoutes() { return [ <Route exact key="client-info-search" path="/hub/client-info/search" component={ClientInfoSearchPage} />, <Route exact key="client-info-details" path="/hub/client-info/:id/info" component={ClientInfoDetailsPage} />, ]; } // Code from file collective-frontend/src/modules/Hub/ClientInfo/Details/Page.test.jsx import { fireEvent, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { MemoryRouter } from 'react-router-dom'; import ClientInfoDetails from './Page'; import RootStoreContext from 'modules/common/stores/Root/Context'; import browserHistory from 'modules/common/browserHistory'; const history = browserHistory; jest.mock('moment', () => { return () => jest.requireActual('moment')(); }); class MockLegacyStore { getTransactionInfo = () => jest.fn(); getSplitTreatmentAttributes = () => jest.fn(); fetchBankStatus = () => jest.fn(); getAllStates = () => jest.fn(); startTimeTracker = () => jest.fn(); isEmployee = true; isImpersonator = false; transitionDocs = []; transitionClientInfo = { transition_plan_status: 'scheduled', }; } const mockProps = { match: { params: { action: () => jest.fn(), }, }, }; const renderWithProviders = (legacyStore) => { return render( <RootStoreContext.Provider value={{ legacyStore }}> <MemoryRouter> <ClientInfoDetails match={mockProps.match} history={history} /> </MemoryRouter> </RootStoreContext.Provider> ); }; describe('ClientInfoDetails Page', () => { it('should render established date input when viewing LLC-SC takeover member', async () => { const legacyStore = new MockLegacyStore(); legacyStore.transitionClientInfo.usertype = 'LLC-SC'; renderWithProviders(legacyStore); expect(screen.getByTestId('established-date-input')).toBeInTheDocument(); expect(screen.getByTestId('established-date-submit-btn')).toBeInTheDocument(); }); it('should render established date input when viewing SC-SC takeover member', async () => { const legacyStore = new MockLegacyStore(); legacyStore.transitionClientInfo.usertype = 'SC-SC'; renderWithProviders(legacyStore); expect(screen.getByTestId('established-date-input')).toBeInTheDocument(); expect(screen.getByTestId('established-date-submit-btn')).toBeInTheDocument(); }); it('should NOT render established date input when viewing SP-SC formation member', async () => { const legacyStore = new MockLegacyStore(); legacyStore.transitionClientInfo.usertype = 'SP-SC'; renderWithProviders(legacyStore); expect(screen.queryByTestId('established-date-input')).not.toBeInTheDocument(); expect(screen.queryByTestId('established-date-submit-btn')).not.toBeInTheDocument(); }); it('should render a readonly established date input when transition plan is approved', async () => { const legacyStore = new MockLegacyStore(); legacyStore.transitionClientInfo.usertype = 'LLC-SC'; legacyStore.transitionClientInfo.transition_plan_status = 'approved'; renderWithProviders(legacyStore); expect(screen.getByTestId('established-date-input')).toBeInTheDocument(); expect(screen.queryByTestId('established-date-submit-btn')).not.toBeInTheDocument(); }); it('should show the impersonator button', async () => { const legacyStore = new MockLegacyStore(); legacyStore.isImpersonator = true; renderWithProviders(legacyStore); expect(screen.getByRole('button', { name: /impersonate/i })).toBeInTheDocument(); }); it('should not show the impersonator button', async () => { const legacyStore = new MockLegacyStore(); renderWithProviders(legacyStore); expect(screen.queryByRole('button', { name: /impersonate/i })).not.toBeInTheDocument(); }); }); // Code from file collective-frontend/src/modules/Hub/ClientInfo/Details/ComplianceRemindersViewer/index.js export { default } from './ComplianceRemindersViewer'; // Code from file collective-frontend/src/modules/Hub/ClientInfo/Details/ComplianceRemindersViewer/ComplianceRemindersTable.jsx import * as moment from 'moment'; import React from 'react'; import { Label, Message, Table } from 'semantic-ui-react'; export default class ComplianceRemindersTable extends React.Component { renderStatus = (status) => { let color; switch (status) { case 'pending': color = 'yellow'; break; case 'completed': color = 'green'; break; case 'overdue': color = 'red'; break; case 'unnecessary': color = 'grey'; break; default: break; } return ( <Label color={color} horizontal> {status} </Label> ); }; renderDate = (dateStr) => { const parsedDate = moment(dateStr); return parsedDate.isValid() ? parsedDate.format('MMM Do YYYY') : ''; }; render() { const { reminders } = this.props; if (reminders.length === 0) { return <Message info>This Member does not have any compliance reminders yet!</Message>; } return ( <Table singleLine celled selectable compact="very" size="small"> <Table.Header> <Table.Row> <Table.HeaderCell>Status</Table.HeaderCell> <Table.HeaderCell>State</Table.HeaderCell> <Table.HeaderCell>Report</Table.HeaderCell> <Table.HeaderCell>Next Reminder</Table.HeaderCell> <Table.HeaderCell>Due Date</Table.HeaderCell> </Table.Row> </Table.Header> <Table.Body> {reminders.map((reminder) => ( <Table.Row key={reminder.uuid}> <Table.Cell>{this.renderStatus(reminder.status)}</Table.Cell> <Table.Cell>{reminder.report.state}</Table.Cell> <Table.Cell>{reminder.report.title}</Table.Cell> <Table.Cell>{this.renderDate(reminder.next_reminder_date)}</Table.Cell> <Table.Cell>{this.renderDate(reminder.due_date)}</Table.Cell> </Table.Row> ))} </Table.Body> </Table> ); } } // Code from file collective-frontend/src/modules/Hub/ClientInfo/Details/ComplianceRemindersViewer/ComplianceRemindersViewer.jsx import { observer } from 'mobx-react'; import React from 'react'; import { Loader } from 'semantic-ui-react'; import ComplianceRemindersTable from './ComplianceRemindersTable'; import RootStoreContext from 'modules/common/stores/Root/Context'; class ComplianceRemindersViewer extends React.Component { static contextType = RootStoreContext; constructor(props, context) { super(props, context); this.store = context.legacyStore; this.state = { loading: true, errorLoading: false, reminders: [], }; } componentDidMount() { this.store .fetchComplianceReminders(this.props.memberId) .then((data) => { this.setState({ reminders: data }); }) .catch((e) => { this.setState({ errorLoading: true }); console.error(e); this.store.globalError = 'Error loading compliance reminders!'; }) .finally(() => { this.setState({ loading: false }); }); } render() { const { loading, errorLoading, reminders } = this.state; if (loading) { return <Loader active inline />; } if (errorLoading) { return <p>Something went wrong :(</p>; } return <ComplianceRemindersTable reminders={reminders} />; } } export default observer(ComplianceRemindersViewer); // Code from file collective-frontend/src/modules/Hub/ClientInfo/Details/LoginInfoEditor/index.jsx import React, { useState, useEffect } from 'react'; import { observer } from 'mobx-react'; import { useFormik } from 'formik'; import { Button, FormControlLabel, Checkbox, Alert } from '@mui/material'; import collectiveApi from 'modules/common/collectiveApi'; const LoginInfoEditor = ({ member }) => { const [mfaEnabled, setMfaEnabled] = useState(false); const [canEditMfa, setEditMfa] = useState(false); const [loading, setLoading] = useState(false); const [successMessage, setSuccessMessage] = useState(''); const [errorMessage, setErrorMessage] = useState(''); const userId = member.user_id; const onSubmit = async (values, { resetForm }) => { setErrorMessage(''); setSuccessMessage(''); try { const res = await collectiveApi.put(`rest-auth/mfa/${userId}/`, { enable: values.mfaEnabled, }); if (values.mfaEnabled !== res.data?.is_mfa_enabled) { setErrorMessage('Failed to update MFA status for this user.'); } else { setMfaEnabled(res.data?.is_mfa_enabled); setSuccessMessage('MFA status updated.'); } setEditMfa(res.data?.can_mfa_be_toggled); } catch (error) { setErrorMessage(error.response?.data?.detail); } finally { resetForm(); } }; useEffect(() => { setLoading(true); const fetchMFAStatus = async () => { try { const res = await collectiveApi.get(`rest-auth/mfa/${userId}/`); setMfaEnabled(res.data?.is_mfa_enabled); setEditMfa(res.data?.can_mfa_be_toggled); } catch (error) { setErrorMessage(error.response?.data?.detail); } finally { setLoading(false); } }; fetchMFAStatus(); }, [userId]); const { values, isSubmitting, handleSubmit, setFieldValue } = useFormik({ initialValues: { mfaEnabled }, enableReinitialize: true, onSubmit, }); if (loading) { return null; } return ( <> <h2 id="loginInformation">Login information</h2> {errorMessage && <Alert severity="error">{errorMessage}</Alert>} {successMessage && <Alert severity="success">{successMessage}</Alert>} {!canEditMfa && ( <Alert severity="info"> MFA feature is currently inactive for this user either because MFA has been globally disabled or this user has not completed onboarding yet. </Alert> )} <form onSubmit={handleSubmit}> <FormControlLabel disabled={!canEditMfa} checked={values.mfaEnabled} onChange={() => setFieldValue('mfaEnabled', !values.mfaEnabled)} control={<Checkbox />} label="Require user to complete a multi-factor authentication (MFA) login." /> {mfaEnabled !== values.mfaEnabled && ( <Button type="submit" disabled={isSubmitting}> Update </Button> )} </form> </> ); }; export default observer(LoginInfoEditor); // Code from file collective-frontend/src/modules/Hub/ClientInfo/Details/LoginInfoEditor/index.test.jsx import { render, waitFor, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { configure } from 'mobx'; import MockAxiosAdapter from 'axios-mock-adapter'; import LoginInfoEditor from './index'; import collectiveApi from 'modules/common/collectiveApi'; configure({ safeDescriptors: false }); const mockCollectiveApi = new MockAxiosAdapter(collectiveApi); // Expected order of requests: const setup = (enableMfaEditing = true) => { mockCollectiveApi .onGet('rest-auth/mfa/62/') .reply(200, { is_mfa_enabled: true, can_mfa_be_toggled: enableMfaEditing }); render(<LoginInfoEditor member={{ user_id: 62 }} />); }; describe('LoginInfoEditor - MFA editing', () => { it('Should render correctly', async () => { setup(); const title = await screen.findByText(/Login information/i); expect(title).toBeInTheDocument(); }); it('should have MFA status as true', async () => { setup(); const checkbox = await screen.findByRole('checkbox'); expect(checkbox).toBeChecked(); }); it('Should update MFA status to false', async () => { setup(); mockCollectiveApi.onPut('rest-auth/mfa/62/').reply(200, { is_mfa_enabled: false, can_mfa_be_toggled: true }); const user = userEvent.setup(); const checkbox = await screen.findByRole('checkbox'); await user.click(checkbox); const submitButton = screen.getByRole('button', { name: 'Update' }); expect(submitButton).toBeInTheDocument(); await user.click(submitButton); await waitFor(() => { expect(screen.getByText(/MFA status updated./i)).toBeInTheDocument(); }); }, 20000); it('Should fail to update MFA status', async () => { setup(); mockCollectiveApi.onPut('rest-auth/mfa/62/').reply(400, { detail: 'backend error' }); const user = userEvent.setup(); const checkbox = await screen.findByRole('checkbox'); await user.click(checkbox); const submitButton = screen.getByRole('button', { name: 'Update' }); expect(submitButton).toBeInTheDocument(); await user.click(submitButton); await waitFor(() => { expect(screen.getByText(/backend error/i)).toBeInTheDocument(); }); }, 20000); it('Should not be able to edit MFA', async () => { setup(false); await waitFor(() => { const checkbox = screen.getByRole('checkbox'); expect(checkbox).toBeDisabled(); }); }, 20000); }); // Code from file collective-frontend/src/modules/Hub/ClientInfo/Details/LLCFilingEditor/FormFileUpload.js import { useState } from 'react'; import { observer } from 'mobx-react'; import { Button, Icon, Modal, Segment } from 'semantic-ui-react'; import UploadToFilestack from 'modules/common/UploadToFilestack'; const FormFileUpload = ({ btnText = 'Upload document', onUpload, onRemove, name }) => { const [showModal, setShowModal] = useState(false); const [documentTitle, setDocumentTitle] = useState(''); const [documentUrl, setDocumentUrl] = useState(''); const [documents, setDocuments] = useState([]); const onSuccess = (result) => { setDocuments(result.filesUploaded); setDocumentTitle(result.filesUploaded[0].filename); setDocumentUrl(result.filesUploaded[0].url); if (onUpload) { onUpload(name, result.filesUploaded); } }; const removeUpload = () => { setDocuments([]); if (onRemove) { onRemove(name); } }; const showModalToggle = () => { setShowModal(!showModal); }; const MOBILE_DEVICE = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream; const uploadedRow = documents.map((document) => { return ( <Segment key={document.filename} className="upload-document tw-w-full" clearing textAlign="left"> {document.filename ? document.filename : document['document-title']} {MOBILE_DEVICE ? null : ( <Button icon className="tw-mr-0.5" floated="right" size="mini" onClick={removeUpload}> <Icon name="close" /> </Button> )} {MOBILE_DEVICE ? null : ( <Button icon className="tw-mr-0.5" floated="right" size="mini" onClick={showModalToggle}> <Icon name="eye" /> </Button> )} </Segment> ); }); return ( <> {documents.length === 0 && ( <div className="tw-mb-4"> <UploadToFilestack primary buttonText={btnText} onSuccess={onSuccess} displayMode="dropPane" /> </div> )} {documents && <div className="tw-mb-4">{uploadedRow}</div>} <Modal size="large" className="hyke-modal collective-modal" open={showModal} onClose={showModalToggle} closeIcon> <Modal.Content> <div className="modal-body"> <div> <h3>{documentTitle}</h3> <iframe title={documentTitle} src={documentUrl} style={{ height: '80vh' }} width="100%" frameBorder="1" /> </div> </div> </Modal.Content> </Modal> </> ); }; export default observer(FormFileUpload); // Code from file collective-frontend/src/modules/Hub/ClientInfo/Details/LLCFilingEditor/index.js export { default } from './LLCFilingEditor'; // Code from file collective-frontend/src/modules/Hub/ClientInfo/Details/LLCFilingEditor/LLCFilingEditor.jsx import * as Sentry from '@sentry/react'; import { useFormik } from 'formik'; import { observer } from 'mobx-react'; import { useState } from 'react'; import MaskedInput from 'react-text-mask'; import { Button, Form } from 'semantic-ui-react'; import { array, object, string } from 'yup'; import FormFileUpload from './FormFileUpload'; import collectiveApi from 'modules/common/collectiveApi'; import states from 'modules/common/constants/states'; import { date } from 'modules/common/masks'; import { dateRegex } from 'modules/common/regexes'; const statesOptions = Object.entries(states).map(([key, value]) => ({ key, text: value, value: key })); const initialValues = { establisheddate: '', entitynumber: '', state_of_inc: '', llcFiling: null, }; const validationSchema = object({ establisheddate: string() .label('Established date') .matches(dateRegex, 'Established date is not in the correct format') .required(), entitynumber: string().label('Entity number').required(), state_of_inc: string().label('State of incorporation').length(2).required(), llcFiling: array().min(1), }); const LLCFilingEditor = ({ client, llcformationstatus }) => { const [llcFilingCreated, setLlcFilingCreated] = useState(false); const { id: memberId } = client; const postMemberLlcFiling = async (memberId, postObject) => { try { const { data: success } = await collectiveApi.post(`/llc-filing/${memberId}/`, postObject); setLlcFilingCreated(success); } catch (error) { console.error("Error while creating member's llc filing: ", error); Sentry.captureException(error); throw error; } }; const onSubmit = async (values) => { const postObject = { file_name: values.llcFiling[0].filename, file_link: values.llcFiling[0].url, establisheddate: values.establisheddate, entitynumber: values.entitynumber, state_of_inc: values.state_of_inc, }; await postMemberLlcFiling(memberId, postObject); }; const { values, errors, touched, isValid, isSubmitting, handleChange, handleBlur, handleSubmit, setFieldValue } = useFormik({ initialValues, validationSchema, onSubmit, }); const handleFileUpload = (name, files) => { setFieldValue(name, files); }; const handleFileRemove = (name) => { setFieldValue(name, []); }; const handleSelectChange = (event, data) => { event.target.name = data.name; event.target.value = data.value; handleChange(event); }; const handleSelectBlur = (event, data) => { event.target.name = data.name; event.target.value = data.value; handleBlur(event); }; if (llcformationstatus === 'completed' || llcFilingCreated) { return ( <div className="ui success message"> <div className="header">LLC filing has been successfully completed for this member</div> </div> ); } return ( <Form className="tw-w-60"> <Form.Input label="When is the established date for this member?" error={touched.establisheddate && errors.establisheddate} fluid> <MaskedInput type="text" name="establisheddate" id="establisheddate" data-testid="establisheddate" value={values.establisheddate} onChange={handleChange} onBlur={handleBlur} mask={date} guide={false} /> </Form.Input> <Form.Input label="What is the entity number for this member?" name="entitynumber" id="entitynumber" data-testid="entitynumber" value={values.entitynumber} error={touched.entitynumber && errors.entitynumber} onChange={handleChange} onBlur={handleBlur} fluid /> <Form.Select label="What is the state of inc for this member?" options={statesOptions} data-testid="state_of_inc" name="state_of_inc" id="state_of_inc" value={values.state_of_inc} error={touched.state_of_inc && errors.state_of_inc} onChange={handleSelectChange} onBlur={handleSelectBlur} search fluid /> <div className="field"> <label htmlFor="llcFiling">LLC Filing Document Upload</label> </div> <FormFileUpload name="llcFiling" onUpload={handleFileUpload} onRemove={handleFileRemove} type="LLC Filing" /> <br /> <Button type="submit" disabled={isSubmitting || !isValid} primary size="small" onClick={handleSubmit}> {isSubmitting ? 'Saving...' : 'Save'} </Button> </Form> ); }; export default observer(LLCFilingEditor); // Code from file collective-frontend/src/modules/Hub/ClientInfo/Details/LLCFilingEditor/LLCFilingEditor.test.jsx import { cleanup, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { configure } from 'mobx'; import LLCFilingEditor from '.'; import RootStoreContext from 'modules/common/stores/Root/Context'; import HubStoreContext from 'modules/Hub/common/Store/Context'; configure({ safeDescriptors: false }); class MockLegacyStore { fetchFilestackCredentials = () => ({ api_key: 'api_key_test', policy: 'policy_test', signature: 'signature_test' }); } class MockHubStore { // saveTaxPayment = () => jest.fn(); } class MockClient { id = 1; } const renderWithProviders = (llcformationstatus) => { const legacyStore = new MockLegacyStore(); const hubStore = new MockHubStore(); const client = new MockClient(); return render( <RootStoreContext.Provider value={{ legacyStore }}> <HubStoreContext.Provider value={{ hubStore }}> <LLCFilingEditor client={client} llcformationstatus={llcformationstatus} /> </HubStoreContext.Provider> </RootStoreContext.Provider> ); }; beforeEach(cleanup); describe('LLCFilingEditor', () => { it('should render correctly', async () => { renderWithProviders('inprogress'); const elements = await screen.findAllByText(/When is the established date for this member?/i); expect(elements[0]).toBeInTheDocument(); }); it('should show completed lcc filing message', async () => { renderWithProviders('completed'); const elements = await screen.findAllByText(/LLC filing has been successfully completed for this member/i); expect(elements[0]).toBeInTheDocument(); }); it('should show lcc filing form', async () => { renderWithProviders('inprogress'); const button = screen.getByRole('button', { name: /Save/i, }); const user = userEvent.setup(); const establisheddateInput = screen.getByTestId('establisheddate'); const entitynumberInput = screen.getByTestId('entitynumber'); const stateofincSelect = screen.getByTestId('state_of_inc'); await user.type(establisheddateInput, '12/12/2021'); await user.type(entitynumberInput, '123456'); await user.type(stateofincSelect, 'California'); expect(button).toBeDisabled(); const uploadButton = screen.getByRole('button', { name: /Upload document/i, }); await user.click(uploadButton); }); }); // Code from file collective-frontend/src/modules/Hub/ClientInfo/Details/Page.js import { observer } from 'mobx-react'; import { Component } from 'react'; import { Button, Icon, Input } from 'semantic-ui-react'; import moment from 'moment'; import TimeoutModal from '../../common/TimeoutModal'; import TransitionInput from '../../common/TransitionInput'; import TransitionNavBar from '../../common/TransitionNavBar'; import UploadTransitionDocument from '../../common/UploadDocument'; import UploadParticularDocument from '../../common/UploadParticularDocument'; import ImpersonateButton from '../common/ImpersonateButton'; import BankStatus from './BankStatus'; import ComplianceRemindersViewer from './ComplianceRemindersViewer'; import ForeignRegistrationsEditor from './ForeignRegistrationsEditor'; import TaxPaymentsEditor from './TaxPaymentsEditor'; import LLCFilingEditor from './LLCFilingEditor'; import EINFilingEditor from './EINFilingEditor'; import LoginInfoEditor from './LoginInfoEditor'; import RegenerateFormW9Button from './RegenerateFormW9'; import collectiveApi from 'modules/common/collectiveApi'; import RootStoreContext from 'modules/common/stores/Root/Context'; import AccountablePlan from 'modules/common/AccountablePlan'; class ClientInfoDetailsPage extends Component { static contextType = RootStoreContext; constructor(props, context) { super(props, context); this.state = { client: {}, 'personal-information': true, 'business-information': true, isForm2553Regenerated: false, isForm2553RegenerationError: false, establishedDate: '', savingEstablishedDate: false, }; this.store = context.legacyStore; } componentDidMount() { this.store.redirectedfrompage = `hub/client-info/${this.props.match.params.id}/info`; if (!this.store.isLoginSuccess || !this.store.isEmployee) { this.props.history.push('/login'); } const sfid = this.props.match.params.id; this.fetchClientInfo(sfid); // Get list of states from Backend and populate for later use this.store.getAllStates(); if (!this.store.startedTimeTracking) { this.store.startTimeTracker(); } } fetchClientInfo = async (sfid) => { try { const { data: { data: client, success }, } = await collectiveApi.get(`getclient/`, { params: { sfid } }); if (!success) { throw new Error(); } this.setState({ client, errorMessage: null, }); await this.store.getTransitionClientInfo(client?.sfaccountid); this.setState({ establishedDate: moment(this.store.transitionClientInfo.date_of_incorporation).format('YYYY-MM-DD'), }); this.store.fetchTransitionUserDocuments(client?.email); } catch (error) { console.log('Fetch client info error.', error); this.setState({ errorMessage: 'Unable to find the specified Collective user - Please try searching again or contact Engineering', }); } }; regenerateForm2553 = async () => { try { await collectiveApi.patch(`regenerate-form-2553/`, { email: this.state.client?.email, }); this.setState({ isForm2553Regenerated: true, isForm2553RegenerationError: false, }); } catch (error) { console.log('Regenerate Form 2553 error.', error); this.setState({ isForm2553RegenerationError: true, }); } }; toggleNav = (section) => { const obj = {}; obj[section] = !this.state[section]; section = !this.state[section]; this.setState(obj); }; getOtherDocuments = () => { const docs = []; this.store.transitionDocs.forEach((doc) => { if (!doc['document-title'].includes('Tax')) { docs.push(doc); } }); return ( <> <UploadParticularDocument documentTitle="Annual Report" /> <UploadParticularDocument documentTitle="IRS EIN" /> <UploadParticularDocument documentTitle="Operating Agreement" /> <UploadParticularDocument documentTitle="Business License" /> <UploadParticularDocument documentTitle="Statement of Information" /> <UploadParticularDocument documentTitle="Form 2553" /> <UploadTransitionDocument /> </> ); }; getTakeoverDocs = () => { return ( <> <UploadParticularDocument documentTitle="W9" /> <UploadParticularDocument documentTitle="Articles of Organization" /> <UploadParticularDocument documentTitle="Foreign Registration Document" /> </> ); }; getRegenerateForm2553Button = () => { if (this.state.isForm2553Regenerated) { return ( <div className="ui success message"> <div className="header">Form 2553 was regenerated successfully.</div> </div> ); } return ( <> <Button primary size="small" content="Regenerate" onClick={this.regenerateForm2553} /> {this.state.isForm2553RegenerationError ? ( <div className="ui negative message"> <div className="header">Form 2553 regeneration was unsuccessful.</div> </div> ) : null} </> ); }; getTaxDocuments = () => { return <UploadParticularDocument documentTitle="Tax Return" />; }; handleEstablishedDateChange = (e) => this.setState({ establishedDate: e.target.value }); handleEstablishedDateSave = () => { this.setState({ savingEstablishedDate: true }); this.store.transitionClientInfo.establisheddate = moment(this.state.establishedDate).format('MM/DD/YYYY'); this.store.updateClientInfo('establisheddate').then(() => { this.setState({ savingEstablishedDate: false }); }); }; renderEstablishedDateInput = () => { if (this.store.transitionClientInfo.usertype === 'SP-SC') { // Do not show this input for Formation members return null; } const inputProps = { type: 'date', value: this.state.establishedDate, onChange: this.handleEstablishedDateChange, 'data-testid': 'established-date-input', }; if (this.store.transitionClientInfo.transition_plan_status === 'approved') { // Once the transition plan is approved, this input should be readonly inputProps.disabled = true; } else { inputProps.action = { icon: 'save', size: 'small', onClick: this.handleEstablishedDateSave, loading: this.state.savingEstablishedDate, 'data-testid': 'established-date-submit-btn', }; } return ( <> <p>Established Date</p> <Input {...inputProps} /> </> ); }; render() { return this.store.isEmployee ? ( <> <TransitionNavBar clientName={this.store.transitionClientInfo.fullname} sfid={this.store.transitionClientInfo.sf_id} tpid={this.store.transitionClientInfo.transition_plan_id} clientCompany={this.store.transitionClientInfo.business_name} /> <div className="transition-two-column"> <div id="personalInformation" className="transition-info"> <h1>Personal Information</h1> <p>Full legal name</p> <TransitionInput clientInfo name="fullname" /> <p>What's your full legal address?</p> <TransitionInput clientInfo name="home_address" /> <p>Home Address Number</p> <TransitionInput clientInfo name="home_number" /> <p>Home Address Street</p> <TransitionInput clientInfo name="home_street" /> <p>Home Address Apt/Unit</p> <TransitionInput clientInfo name="home_aptunit" /> <p>Home Address City</p> <TransitionInput clientInfo name="home_city" /> <p>Home Address ZIP Code</p> <TransitionInput clientInfo name="home_zipcode" /> <p>State of residency</p> <TransitionInput clientInfo name="home_state" /> <p>E-mail address</p> <TransitionInput clientInfo name="email" /> <p>What's your phone number?</p> <TransitionInput clientInfo name="phone_number" /> <p>When is your birthday?</p> <TransitionInput clientInfo name="dob" /> <p>US Citizen or permanent resident?</p> <TransitionInput clientInfo name="citizen_or_resident" /> <p>Citizenship</p> <TransitionInput clientInfo name="citizenship" /> <p>Self-service Checkout</p> <TransitionInput clientInfo name="is_ssc_eligible" /> <h2>Personal documents</h2> {this.getOtherDocuments()} {this.state.client?.id && <LoginInfoEditor member={this.state.client} />} <h1 id="organization" style={{ paddingBottom: '24px' }}> Business Information </h1> <h2>Organization</h2> <p>LLC name</p> <TransitionInput clientInfo name="business_name" /> <p>Business name suffix</p> <TransitionInput clientInfo name="inc_suffix" /> <p>Organization Type</p> <TransitionInput clientInfo name="incorporation" /> <p>Business Address</p> <TransitionInput clientInfo name="business_address" placesInput /> <p>Business Address Number</p> <TransitionInput clientInfo name="business_number" /> <p>Business Address Street</p> <TransitionInput clientInfo name="business_street" /> <p>Business Address Apt/Unit</p> <TransitionInput clientInfo name="business_aptunit" /> <p>Business Address City</p> <TransitionInput clientInfo name="business_city" /> <p>Business Address State</p> <TransitionInput clientInfo name="business_state" /> <p>Business Address ZIP Code</p> <TransitionInput clientInfo name="business_zipcode" /> <p>Mail Address</p> <TransitionInput clientInfo name="mail_address" placesInput /> <p>Mail Address Number</p> <TransitionInput clientInfo name="mail_number" /> <p>Mail Address Street</p> <TransitionInput clientInfo name="mail_street" /> <p>Mail Address Apt/Unit</p> <TransitionInput clientInfo name="mail_aptunit" /> <p>Mail Address City</p> <TransitionInput clientInfo name="mail_city" /> <p>Mail Address State</p> <TransitionInput clientInfo name="mail_state" /> <p>Mail Address ZIP Code</p> <TransitionInput clientInfo name="mail_zipcode" /> <p>State of residency</p> <TransitionInput clientInfo name="home_state" /> <p>Live and work in the same state</p> <TransitionInput clientInfo name="business_home_same_state" /> <p>State of business</p> <TransitionInput clientInfo name="state_of_business" /> <p>State of operation</p> <TransitionInput clientInfo name="state_of_operation" /> <p>State of incorporation</p> <TransitionInput clientInfo name="state_of_incorporation" /> <p>LLC Entity number</p> <TransitionInput clientInfo name="entity_number" /> <p>Spouse owns portion of LLC</p> <TransitionInput clientInfo name="spouse_owns_portion" /> <p>Name of spouse</p> <TransitionInput clientInfo name="spouse_name" /> <p>Percentage</p> <TransitionInput clientInfo name="spouse_share" /> <p>Spouse lives at the same home address</p> <TransitionInput clientInfo name="spouse_sameaddress" /> <p>Registered Agent</p> <TransitionInput clientInfo name="registered_agent" /> <p>Regenerate Form W9</p> <RegenerateFormW9Button email={this.state.client.email} /> <h2 id="finances">Organization documents</h2> {this.getTakeoverDocs()} <h2>Finances</h2> <p>Base year</p> <TransitionInput clientInfo name="base_year" /> <p>Expected Revenue</p> <TransitionInput clientInfo name="expected_revenue" /> <p>Expected expenses</p> <TransitionInput clientInfo name="expected_expenses" /> <p>Personal & business expenses separated</p> <TransitionInput clientInfo name="expenses_separated" /> <p id="services">Uses a bookkeeping tool</p> <TransitionInput clientInfo name="bookkeeping_tool" /> <p>Version</p> <TransitionInput clientInfo name="bookkeeping_version" /> <h2>Services</h2> <p>Professional license required</p> <TransitionInput clientInfo name="pro_license" /> <p>Service offered</p> <TransitionInput clientInfo name="service_offered" /> <p>Selling tangible goods</p> <TransitionInput clientInfo name="tangible_goods" /> <h2>Tax</h2> <p>EIN</p> <TransitionInput clientInfo name="ein" /> {this.renderEstablishedDateInput()} <p>Federal Business Name (from CP-575)</p> <TransitionInput clientInfo name="federal_business_name" /> <p>Last time paid CA FTB LLC fee</p> <TransitionInput clientInfo name="ftb_last_paid" /> <p>FTB fee amount</p> <TransitionInput clientInfo name="ftb_last_amount" /> <p>Regenerate Form 2553</p> {this.getRegenerateForm2553Button()} <h2 id="bankInformation">Tax documents</h2> {this.getTaxDocuments()} {this.store.transitionClientInfo.email && ( <> <h2 id="bankInformation">Bank Information</h2> <BankStatus email={this.store.transitionClientInfo.email} /> </> )} {this.state.client.id && ( <> <h2 id="foreignRegistrations">Foreign Registrations</h2> <ForeignRegistrationsEditor memberId={this.state.client.id} /> <h2 id="complianceReminders">Compliance Reminders</h2> <ComplianceRemindersViewer memberId={this.state.client.id} /> <h2 id="taxPayments">Tax Payments</h2> <TaxPaymentsEditor memberId={this.state.client.id} /> <h2 id="llcFiling">LLC Filing</h2> <LLCFilingEditor client={this.state.client} llcformationstatus={this.store.transitionClientInfo?.llcformationstatus} /> <h2 id="llcFiling">EIN Filing</h2> <EINFilingEditor client={this.state.client} einstatus={this.store.transitionClientInfo?.einstatus} /> <h2 id="accountable-plan">Accountable Plan</h2> <AccountablePlan readOnly memberId={this.state.client.id} /> </> )} </div> <div className="transition-sticky-navigation"> {this.store.isImpersonator && ( <div className="tw-mb-2"> <ImpersonateButton userId={this.state.client.id} displayName={this.state.client.fullname} /> </div> )} <div className="sticky-box"> <h2 onClick={() => this.toggleNav('personal-information')}> Personal Information <Icon name={this.state['personal-information'] ? 'angle up' : 'angle down'} /> </h2> {this.state['personal-information'] && ( <a href="#personalInformation">Personal Information</a> )} <h2 onClick={() => this.toggleNav('business-information')}> Business Information{' '} <Icon name={this.state['business-information'] ? 'angle up' : 'angle down'} /> </h2> {this.state['business-information'] && ( <> <a href="#organization">Organization</a> <a href="#finances">Finances</a> <a href="#services">Services</a> <a href="#bankInformation">Bank Information</a> <a href="#foreignRegistrations">Foreign Registrations</a> <a href="#complianceReminders">Compliance Reminders</a> <a href="#taxPayments">Tax Payments</a> <a href="#accountable-plan">Accountable Plan</a> <a href="#llcFiling">LLC Filing</a> <a href="#llcFiling">EIN Filing</a> </> )} </div> </div> </div> {this.store.showTimeoutModal ? <TimeoutModal /> : null} </> ) : ( <h2>You are not authorized to view this page</h2> ); } } export default observer(ClientInfoDetailsPage); // Code from file collective-frontend/src/modules/Hub/ClientInfo/Details/BankStatus.js import React, { Component } from 'react'; import { observer } from 'mobx-react'; import ClickOutHandler from 'react-onclickout'; import RootStoreContext from 'modules/common/stores/Root/Context'; class BankStatus extends Component { static contextType = RootStoreContext; constructor(props, context) { super(props, context); this.store = context.legacyStore; this.state = { hover: false, showBankModal: false }; } componentDidMount() { this.store.fetchBankStatus(this.props.email); } toggleStateProperty = (e, stateProperty) => { e.stopPropagation(); this.setState((previousState) => ({ [stateProperty]: !previousState[stateProperty] })); }; closeBankModal(e) { e.stopPropagation(); if (this.state.showBankModal) { this.setState({ showBankModal: false }); } } render() { return this.store.bank_account_connected ? ( <div className="transition-input"> Connected <ClickOutHandler onClickOut={(e) => this.closeBankModal(e)}> <div className="transition-edit" onClick={(e) => this.toggleStateProperty(e, 'showBankModal')}> Disconnect Bank {this.state.showBankModal && ( <div> <div className="confirm-button" onClick={(e) => this.store.disconnectBank(this.props.email)}> Yes, disconnect </div> <div className="confirm-button" onClick={(e) => this.toggleStateProperty(e, 'showBankModal')}> No, cancel </div> </div> )} </div> {this.store.bank_account_feedback && <p>{this.store.bank_account_feedback}</p>} </ClickOutHandler> </div> ) : ( <div>{this.store.bank_account_feedback ? `${this.store.bank_account_feedback}` : 'Disconnected'}</div> ); } } export default observer(BankStatus); // Code from file collective-frontend/src/modules/Hub/ClientInfo/Details/TaxPaymentsEditor/index.js export { default } from './TaxPaymentsEditor'; // Code from file collective-frontend/src/modules/Hub/ClientInfo/Details/TaxPaymentsEditor/QTEPreviewEmail.jsx import Parser from 'html-react-parser'; import React, { Component } from 'react'; import { Modal, Button } from 'semantic-ui-react'; import { observer } from 'mobx-react'; import RootStoreContext from 'modules/common/stores/Root/Context'; class QTEPreviewEmail extends Component { static contextType = RootStoreContext; constructor(props, context) { super(props, context); this.store = context.legacyStore; this.state = { fetchedQte: false, showModal: false, emailBody: '', emailSubject: '', }; } loadQTE = () => { const { qteId } = this.props; if (this.state.fetchedQte) { this.setState({ showModal: true }); return; } this.store .fetchQTEPreviewEmail(qteId) .then((response) => { this.setState({ emailBody: response.body, emailSubject: response.subject, fetchedQte: true, showModal: true, }); }) .catch((e) => { this.store.globalError = 'error preview qte email'; }); }; render() { return ( <> <Button primary size="tiny" content="Preview E-mail" onClick={this.loadQTE} /> <Modal size="large" className="hyke-modal collective-modal" open={this.state.showModal} onClose={() => this.setState({ showModal: false })} closeIcon> <Modal.Content> <div className="modal-body"> <h2>QTE Preview E-mail</h2> <h4>{this.state.emailSubject}</h4> {Parser(this.state.emailBody)} </div> </Modal.Content> </Modal> </> ); } } export default observer(QTEPreviewEmail); // Code from file collective-frontend/src/modules/Hub/ClientInfo/Details/TaxPaymentsEditor/TaxesPaidForm.jsx import React from 'react'; import { Button } from 'semantic-ui-react'; import { withFormik } from 'formik'; import * as yup from 'yup'; import MoneyInput from 'modules/common/MoneyInput'; const TaxesPaidFormik = ({ values, handleSubmit, handleChange, touched, errors, isSubmitting }) => { return ( <> {errors.message && <p className="error">{errors.message}</p>} <p>Actual Federal Taxes Paid (When different than the amount in QTE):</p> <MoneyInput name="federalTaxesPaid" value={values.federalTaxesPaid} onChange={handleChange} min={0} /> {touched.federalTaxesPaid && errors.federalTaxesPaid && <p className="error">{errors.federalTaxesPaid}</p>} <p>Actual State Taxes Paid (When different than the amount in QTE):</p> <MoneyInput name="stateTaxesPaid" value={values.stateTaxesPaid} onChange={handleChange} min={0} /> {touched.stateTaxesPaid && errors.stateTaxesPaid && <p className="error">{errors.stateTaxesPaid}</p>} {(values.federalTaxesPaid || values.stateTaxesPaid) && ( <> <p> <b>Extra Taxes Paid or previous payments carried over</b> </p> <p>Extra Federal Taxes Paid (If you enter a value here, don't leave the top 2 fields blank):</p> <MoneyInput name="extraFederalPaid" value={values.extraFederalPaid} onChange={handleChange} min={0} /> {touched.extraFederalPaid && errors.extraFederalPaid && ( <p className="error">{errors.extraFederalPaid}</p> )} <p>Extra State Taxes Paid (If you enter a value here, don't leave the top 2 fields blank):</p> <MoneyInput name="extraStatePaid" value={values.extraStatePaid} onChange={handleChange} min={0} /> {touched.extraStatePaid && errors.extraStatePaid && ( <p className="error">{errors.extraStatePaid}</p> )} </> )} <p> <Button type="submit" disabled={isSubmitting} primary size="small" onClick={handleSubmit}> {isSubmitting ? 'Saving...' : 'Save'} </Button> </p> </> ); }; const TaxesPaidForm = withFormik({ mapPropsToValues: ({ federalTaxesPaid, stateTaxesPaid, extraFederalPaid, extraStatePaid }) => { return { federalTaxesPaid, stateTaxesPaid, extraFederalPaid, extraStatePaid }; }, validationSchema: yup.object().shape({ federalTaxesPaid: yup.number().required(), stateTaxesPaid: yup.number().required(), extraFederalPaid: yup .number() .notRequired() .when(['federalTaxesPaid', 'stateTaxesPaid'], { is: (federalTaxesPaid, stateTaxesPaid) => federalTaxesPaid || stateTaxesPaid, then: yup.number().required(), }), extraStatePaid: yup .number() .notRequired() .when(['federalTaxesPaid', 'stateTaxesPaid'], { is: (federalTaxesPaid, stateTaxesPaid) => federalTaxesPaid || stateTaxesPaid, then: yup.number().required(), }), }), handleSubmit: async (values, { props, setFieldError }) => { const response = await props.onSave(values); if (response.error && response.error.federal_tax_amount) { setFieldError('federalTaxesPaid', response.error.federal_tax_amount[0]); } if (response.error && response.error.state_tax_amount) { setFieldError('stateTaxesPaid', response.error.state_tax_amount[0]); } if (response.error && response.error.federal_extra_tax_paid) { setFieldError('extraFederalPaid', response.error.federal_extra_tax_paid[0]); } if (response.error && response.error.state_extra_tax_paid) { setFieldError('extraStatePaid', response.error.state_extra_tax_paid[0]); } if (response.error && response.error.non_field_errors) { setFieldError('message', response.error.non_field_errors[0]); } if (response.error && response.error.message) { setFieldError('message', response.error.message); } }, })(TaxesPaidFormik); export default TaxesPaidForm; // Code from file collective-frontend/src/modules/Hub/ClientInfo/Details/TaxPaymentsEditor/QTEDetails.test.jsx import { render } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import QTEDetails from './QTEDetails'; import RootStoreContext from 'modules/common/stores/Root/Context'; const mockQte = { uuid: '1234', year: 2022, state: 'CA', estimated_federal_tax: 123, estimated_state_tax: 456, sent: 'Yes', }; class MockLegacyStore { saveTaxPayment = () => jest.fn(); updateTaxPayment = () => jest.fn(); } const renderWithProviders = (legacyStore) => { return render( <RootStoreContext.Provider value={{ legacyStore }}> <QTEDetails qte={mockQte} /> </RootStoreContext.Provider> ); }; describe('TaxPayment Form', () => { it('should match snap', async () => { const legacyStore = new MockLegacyStore(); const { asFragment } = renderWithProviders(legacyStore); expect(asFragment().firstChild).toMatchSnapshot(); }); it('should render correctly', async () => { const legacyStore = new MockLegacyStore(); const { findByText } = renderWithProviders(legacyStore); const title = await findByText(/Estimated Federal Tax/i); expect(title).toBeInTheDocument(); }); }); // Code from file collective-frontend/src/modules/Hub/ClientInfo/Details/TaxPaymentsEditor/TaxPaymentsEditor.jsx import { observer } from 'mobx-react'; import React from 'react'; import { Dropdown, Loader } from 'semantic-ui-react'; import moment from 'moment'; import QTEDetails from './QTEDetails'; import TaxesPaidForm from './TaxesPaidForm'; import RootStoreContext from 'modules/common/stores/Root/Context'; class TaxPaymentsEditor extends React.Component { static contextType = RootStoreContext; constructor(props, context) { super(props, context); this.store = context.legacyStore; this.state = { loading: true, errorLoading: false, selectedYear: moment().year(), selectedQuarter: moment().quarter(), qte: null, taxPaymentUUID: null, federalTaxesPaid: '', stateTaxesPaid: '', extraFederalPaid: '', extraStatePaid: '', }; } componentDidMount() { this.fetchData(); } fetchData = () => { const { memberId } = this.props; const { selectedYear, selectedQuarter } = this.state; const p1 = this.store.fetchQuarterlyTaxEstimate(memberId, selectedYear, selectedQuarter, true); const p2 = this.store.fetchTaxPayment(memberId, selectedYear, selectedQuarter); this.setState({ loading: true, errorLoading: false }); Promise.all([p1, p2]) .then(([taxEstimates, taxPayments]) => { const taxEstimate = taxEstimates[0]; const taxPayment = taxPayments[0]; this.setState({ qte: taxEstimate, taxPaymentUUID: taxPayment ? taxPayment.uuid : null, federalTaxesPaid: taxPayment ? taxPayment.federal_tax_amount : '', stateTaxesPaid: taxPayment ? taxPayment.state_tax_amount : '', extraFederalPaid: taxPayment ? taxPayment.federal_extra_tax_paid : 0, extraStatePaid: taxPayment ? taxPayment.state_extra_tax_paid : 0, }); this.store.globalError = ''; }) .catch((e) => { this.setState({ errorLoading: true }); this.store.globalError = 'Error loading tax payments!'; }) .finally(() => { this.setState({ loading: false }); }); }; handleYearChange = (event, data) => { const newValue = data.value; // avoid re-fetching if value did not change if (newValue !== this.state.selectedYear) { this.setState({ selectedYear: newValue }, this.fetchData); } }; handleQuarterChange = (event, data) => { const newValue = data.value; // avoid re-fetching if value did not change if (newValue !== this.state.selectedQuarter) { this.setState({ selectedQuarter: newValue }, this.fetchData); } }; handleSave = async (values) => { const { memberId } = this.props; const { federalTaxesPaid, stateTaxesPaid, extraFederalPaid, extraStatePaid } = values; const { taxPaymentUUID, selectedYear, selectedQuarter } = this.state; if (taxPaymentUUID === null) { // create new record const response = await this.store.saveTaxPayment( memberId, selectedYear, selectedQuarter, federalTaxesPaid, stateTaxesPaid, extraFederalPaid, extraStatePaid ); if (response.uuid) { // save the uuid in state to use it for updating the newly created record on subsequent saves this.setState({ taxPaymentUUID: response.uuid }); } return response; } // update existing record const response = await this.store.updateTaxPayment( taxPaymentUUID, federalTaxesPaid, stateTaxesPaid, extraFederalPaid, extraStatePaid ); return response; }; renderQuarterPicker() { const currentYear = moment().year(); const { selectedYear, selectedQuarter } = this.state; const yearOptions = []; for (let i = 2020; i <= currentYear; i++) { yearOptions.push({ key: i, text: i, value: i }); } const quarterOptions = [ { key: 1, text: 1, value: 1 }, { key: 2, text: 2, value: 2 }, { key: 3, text: 3, value: 3 }, { key: 4, text: 4, value: 4 }, ]; return ( <div id="hub-qte-quarter-picker"> <span> Year: <Dropdown inline selection options={yearOptions} value={selectedYear} onChange={this.handleYearChange} /> </span> <span> Quarter: <Dropdown inline selection options={quarterOptions} value={selectedQuarter} onChange={this.handleQuarterChange} /> </span> </div> ); } render() { const { loading, errorLoading, qte, federalTaxesPaid, stateTaxesPaid, extraFederalPaid, extraStatePaid } = this.state; let content = null; if (loading) { content = <Loader active inline />; } else if (errorLoading) { content = <p>Something went wrong :(</p>; } else { content = ( <> {qte && <QTEDetails qte={qte} />} <h4> This form is used when the member pays an amount different than directed in QTEs. <br /> By default we assume they paid what they were directed to pay. </h4> <TaxesPaidForm federalTaxesPaid={federalTaxesPaid} stateTaxesPaid={stateTaxesPaid} extraFederalPaid={extraFederalPaid} extraStatePaid={extraStatePaid} onSave={this.handleSave} /> </> ); } return ( // we want to always render the quarter picker, but the content below it can vary <> {this.renderQuarterPicker()} {content} </> ); } } export default observer(TaxPaymentsEditor); // Code from file collective-frontend/src/modules/Hub/ClientInfo/Details/TaxPaymentsEditor/index.test.jsx import { render, screen } from '@testing-library/react'; import TaxPaymentsEditor from './TaxPaymentsEditor'; import RootStoreContext from 'modules/common/stores/Root/Context'; jest.mock('moment', () => { const moment = jest.requireActual('moment'); moment.now = () => +new Date('2022-01-18T12:33:37.000Z'); return moment; }); class MockLegacyStore { saveTaxPayment = () => jest.fn(); fetchQuarterlyTaxEstimate = () => jest.fn(); fetchTaxPayment = () => jest.fn(); updateTaxPayment = () => jest.fn(); } const renderWithProviders = (legacyStore) => { return render( <RootStoreContext.Provider value={{ legacyStore }}> <TaxPaymentsEditor /> </RootStoreContext.Provider> ); }; describe('TaxPayment Form', () => { it('should match snap', async () => { const legacyStore = new MockLegacyStore(); const { asFragment } = renderWithProviders(legacyStore); expect(asFragment().firstChild).toMatchSnapshot(); }); it('should render correctly', async () => { const legacyStore = new MockLegacyStore(); const { findByText } = renderWithProviders(legacyStore); const text = await findByText( /This form is used when the member pays an amount different than directed in QTEs/i ); expect(text).toBeInTheDocument(); }); it('extra fields not there on render', async () => { const legacyStore = new MockLegacyStore(); const { queryByLabelText } = renderWithProviders(legacyStore); expect(queryByLabelText(/Extra Federal Taxes Paid/i)).not.toBeInTheDocument(); }); it('extra fields render at the time that they should render', async () => { const legacyStore = new MockLegacyStore(); legacyStore.fetchQuarterlyTaxEstimate = jest.fn().mockReturnValue([{ estimated_federal_tax: 102030 }]); legacyStore.fetchTaxPayment = jest.fn().mockReturnValue([{ federal_tax_amount: 123, state_tax_amount: 456 }]); const { container } = renderWithProviders(legacyStore); const field = await screen.findByText('Actual State Taxes Paid (When different than the amount in QTE):'); expect(field).toBeInTheDocument(); const inputField = container.querySelector('[name="federalTaxesPaid"]'); expect(inputField.value).toMatch('123'); const inputFieldState = container.querySelector('[name="stateTaxesPaid"]'); expect(inputFieldState.value).toMatch('456'); }); }); // Code from file collective-frontend/src/modules/Hub/ClientInfo/Details/TaxPaymentsEditor/TaxesPaidForm.test.jsx import { render } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import TaxesPaidForm from './TaxesPaidForm'; import RootStoreContext from 'modules/common/stores/Root/Context'; class MockLegacyStore { saveTaxPayment = () => jest.fn(); updateTaxPayment = () => jest.fn(); } const renderWithProviders = (legacyStore) => { return render( <RootStoreContext.Provider value={{ legacyStore }}> <TaxesPaidForm /> </RootStoreContext.Provider> ); }; describe('TaxPayment Form', () => { it('should match snap', async () => { const legacyStore = new MockLegacyStore(); const { asFragment } = renderWithProviders(legacyStore); expect(asFragment().firstChild).toMatchSnapshot(); }); it('should render correctly', async () => { const legacyStore = new MockLegacyStore(); const { findByText } = renderWithProviders(legacyStore); const title = await findByText(/Actual Federal Taxes Paid/i); expect(title).toBeInTheDocument(); }); it('extra fields not there on render', async () => { const legacyStore = new MockLegacyStore(); const { queryByLabelText } = renderWithProviders(legacyStore); const user = userEvent.setup(); expect(queryByLabelText(/Extra Federal Taxes Paid/i)).not.toBeInTheDocument(); }); }); // Code from file collective-frontend/src/modules/Hub/ClientInfo/Details/TaxPaymentsEditor/QTEDetails.jsx import React from 'react'; import { List } from 'semantic-ui-react'; import QTEPreviewEmail from './QTEPreviewEmail'; const QTEDetails = ({ qte }) => ( <div id="hub-qte-details"> <List divided size="big"> <List.Item> <List.Description>Period</List.Description> <List.Header> Q{qte.quarter}-{qte.year} </List.Header> </List.Item> <List.Item> <List.Description>State</List.Description> <List.Header>{qte.state}</List.Header> </List.Item> <List.Item> <List.Description>Estimated Federal Tax</List.Description> <List.Header>${qte.estimated_federal_tax}</List.Header> </List.Item> <List.Item> <List.Description>Estimated State Tax</List.Description> <List.Header>${qte.estimated_state_tax}</List.Header> </List.Item> <List.Item> <List.Description>Email Has Been Sent?</List.Description> <List.Header>{qte.sent ? 'Yes' : 'No'}</List.Header> </List.Item> <List.Item> <QTEPreviewEmail qteId={qte.uuid} /> </List.Item> </List> </div> ); export default QTEDetails; // Code from file collective-frontend/src/modules/Hub/ClientInfo/Details/ForeignRegistrationsEditor/ForeignRegistrationsEditor.jsx import { observer } from 'mobx-react'; import React from 'react'; import { Loader } from 'semantic-ui-react'; import ForegTable from './ForegTable'; import RootStoreContext from 'modules/common/stores/Root/Context'; class ForeignRegistrationsEditor extends React.Component { static contextType = RootStoreContext; constructor(props, context) { super(props, context); this.store = context.legacyStore; this.state = { loading: true, errorLoading: false, sourceRecords: [], // the source of truth, straight from the DB, never changes formRecords: [], // whatever data is currently being displayed in the form, can change }; } componentDidMount() { this.store .fetchForeignRegistrations(this.props.memberId) .then((data) => { this.setState({ sourceRecords: data, formRecords: data }); }) .catch((e) => { this.setState({ errorLoading: true }); console.error(e); this.store.globalError = 'Error loading foreign registrations!'; }) .finally(() => { this.setState({ loading: false }); }); } addRecord = () => { // add a new blank record this.setState((previousState) => ({ formRecords: previousState.formRecords.concat({ uuid: new Date().getTime(), // random uuid state: '', entity_id: '', entity_name: '', effective_registration_date: '', }), })); }; removeRecord = (uuid) => { // remove record by uuid this.setState((previousState) => ({ formRecords: previousState.formRecords.filter((record) => record.uuid !== uuid), })); }; resetRecord = (uuid) => { // reset a record to its original state (make it match its corresponding source record) const { formRecords, sourceRecords } = this.state; const sourceRecord = sourceRecords.find((srcRecord) => srcRecord.uuid === uuid); this.setState({ formRecords: formRecords.map((formRecord) => { if (formRecord.uuid === sourceRecord.uuid) { return { ...sourceRecord }; } return formRecord; }), }); }; editRecord = (uuid, changes) => { // modify a record this.setState((previousState) => ({ formRecords: previousState.formRecords.map((record) => { if (record.uuid === uuid) { record = { ...record, ...changes }; } return record; }), })); }; saveRecord = (uuid) => { // persist a record const formIndex = this.state.formRecords.findIndex((record) => record.uuid === uuid); const formRecord = this.state.formRecords[formIndex]; const isNew = this.state.sourceRecords.find((record) => record.uuid === uuid) === undefined; return this.store .saveForeignRegistration(this.props.memberId, formRecord, isNew) .then((savedRecord) => { if (isNew) { // when a new record is saved, we need to: // * add it to sourceRecords // * replace the corresponding formRecord with the newly saved record this.setState((previousState) => ({ sourceRecords: previousState.sourceRecords.concat(savedRecord), formRecords: previousState.formRecords.map((fr, index) => index === formIndex ? savedRecord : fr ), })); } else { // when an existing record is saved, we need to: // * replace the corresponding sourceRecord with the newly saved record this.setState((previousState) => ({ sourceRecords: previousState.sourceRecords.map((sr) => sr.uuid === savedRecord.uuid ? savedRecord : sr ), })); } }) .catch((e) => { console.error(e); this.store.globalError = 'Error saving foreign registration!'; }); }; render() { const { loading, errorLoading, sourceRecords, formRecords } = this.state; if (loading) { return <Loader active inline />; } if (errorLoading) { return <p>Something went wrong :(</p>; } return ( <ForegTable sourceRecords={sourceRecords} formRecords={formRecords} addRecord={this.addRecord} removeRecord={this.removeRecord} resetRecord={this.resetRecord} editRecord={this.editRecord} saveRecord={this.saveRecord} /> ); } } export default observer(ForeignRegistrationsEditor); // Code from file collective-frontend/src/modules/Hub/ClientInfo/Details/ForeignRegistrationsEditor/ForegTable.jsx import React from 'react'; import { Button, Message, Table } from 'semantic-ui-react'; import { isEqual } from 'lodash'; import ForegRow from './ForegRow'; const ForegTable = ({ sourceRecords, formRecords, addRecord, removeRecord, resetRecord, editRecord, saveRecord }) => { if (formRecords.length === 0) { return ( <> <Message info>This Member does not have any foreign registrations yet!</Message> <Button primary size="tiny" content="Add +" onClick={addRecord} /> </> ); } return ( <Table singleLine celled selectable compact="very" size="small"> <Table.Header> <Table.Row> <Table.HeaderCell>State</Table.HeaderCell> <Table.HeaderCell>Entity Name</Table.HeaderCell> <Table.HeaderCell>Entity ID</Table.HeaderCell> <Table.HeaderCell>Reg. Date</Table.HeaderCell> <Table.HeaderCell>Actions</Table.HeaderCell> </Table.Row> </Table.Header> <Table.Body> {formRecords.map((formRecord) => { const sourceRecord = sourceRecords.find((srcRecord) => srcRecord.uuid === formRecord.uuid); const isNew = sourceRecord === undefined; // if there is no corresponding source record, then this is a new record const isDirty = !isNew && !isEqual(formRecord, sourceRecord); return ( <ForegRow key={formRecord.uuid} record={formRecord} source={sourceRecord} isNew={isNew} isDirty={isDirty} removeRecord={removeRecord} resetRecord={resetRecord} editRecord={editRecord} saveRecord={saveRecord} /> ); })} </Table.Body> <Table.Footer> <Table.Row> <Table.HeaderCell colSpan="5"> <Button primary size="tiny" content="Add +" onClick={addRecord} /> </Table.HeaderCell> </Table.Row> </Table.Footer> </Table> ); }; export default ForegTable; // Code from file collective-frontend/src/modules/Hub/ClientInfo/Details/ForeignRegistrationsEditor/index.js export { default } from './ForeignRegistrationsEditor'; // Code from file collective-frontend/src/modules/Hub/ClientInfo/Details/ForeignRegistrationsEditor/ForegRow.jsx import React from 'react'; import { Icon, Loader, Table } from 'semantic-ui-react'; import StatePickerCell from './StatePickerCell'; import InputCell from './InputCell'; export default class ForegRow extends React.Component { constructor(props, context) { super(props, context); this.state = { saving: false }; } handleSave = () => { this.setState({ saving: true }); this.props.saveRecord(this.props.record.uuid).finally(() => { this.setState({ saving: false }); }); }; handleRemove = () => this.props.removeRecord(this.props.record.uuid); handleReset = () => this.props.resetRecord(this.props.record.uuid); handleChange = (key, value) => this.props.editRecord(this.props.record.uuid, { [key]: value }); isStateValid = () => this.props.record.state.length > 0; isEntityNameValid = () => this.props.record.entity_name.length > 0; isEntityIdValid = () => this.props.record.entity_id.length > 0; isEffectiveRegistrationDateValid = () => { const { effective_registration_date } = this.props.record; return effective_registration_date && effective_registration_date.length > 0; }; renderActions = () => { if (this.state.saving) { return <Loader active inline="centered" size="mini" />; } const { isNew, isDirty } = this.props; const actionButtons = []; if ( (isNew || isDirty) && this.isStateValid() && this.isEntityNameValid() && this.isEntityIdValid() && this.isEffectiveRegistrationDateValid() ) { actionButtons.push( <Icon key="save-button" link title="Save" name="save" color="teal" size="large" onClick={this.handleSave} /> ); } if (isNew) { actionButtons.push( <Icon key="remove-button" link title="Remove" name="remove" color="grey" size="large" onClick={this.handleRemove} /> ); } if (isDirty) { actionButtons.push( <Icon key="undo-button" link title="Undo" name="undo" color="grey" size="large" onClick={this.handleReset} /> ); } return actionButtons; }; render() { const { record, isNew, isDirty } = this.props; return ( <Table.Row warning={isNew || isDirty}> <StatePickerCell fieldName="state" value={record.state} onChange={this.handleChange} hasError={!this.isStateValid()} /> <InputCell fieldName="entity_name" type="text" value={record.entity_name} onChange={this.handleChange} hasError={!this.isEntityNameValid()} /> <InputCell fieldName="entity_id" type="text" value={record.entity_id} onChange={this.handleChange} hasError={!this.isEntityIdValid()} /> <InputCell fieldName="effective_registration_date" type="date" value={record.effective_registration_date || ''} onChange={this.handleChange} hasError={!this.isEffectiveRegistrationDateValid()} /> <Table.Cell>{this.renderActions()}</Table.Cell> </Table.Row> ); } } // Code from file collective-frontend/src/modules/Hub/ClientInfo/Details/ForeignRegistrationsEditor/StatePickerCell.jsx import React from 'react'; import { Dropdown, Icon, Table } from 'semantic-ui-react'; import RootStoreContext from 'modules/common/stores/Root/Context'; export default class StatePickerCell extends React.Component { static contextType = RootStoreContext; constructor(props, context) { super(props, context); this.store = context.legacyStore; const stateOptions = [{ text: '---', value: '' }].concat( this.store.allStates.map((state) => ({ text: state.name, value: state.id })) ); this.state = { stateOptions }; } handleChange = (event, data) => this.props.onChange(this.props.fieldName, data.value); render() { const { value, hasError } = this.props; return ( <Table.Cell className="foreg-state-picker" error={hasError}> {hasError && <Icon name="attention" />} <Dropdown search selection placeholder="State" options={this.state.stateOptions} onChange={this.handleChange} value={value} /> </Table.Cell> ); } } // Code from file collective-frontend/src/modules/Hub/ClientInfo/Details/ForeignRegistrationsEditor/InputCell.jsx import React from 'react'; import { Icon, Input, Table } from 'semantic-ui-react'; export default class InputCell extends React.Component { handleChange = (event, data) => this.props.onChange(this.props.fieldName, data.value); render() { const { type, value, hasError } = this.props; return ( <Table.Cell error={hasError}> {hasError && <Icon name="attention" />} <Input transparent size="large" type={type} title={value} value={value} onChange={this.handleChange} /> </Table.Cell> ); } } // Code from file collective-frontend/src/modules/Hub/ClientInfo/Details/RegenerateFormW9/RegenerateFormW9Button.test.jsx import { cleanup, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import MockAxiosAdapter from 'axios-mock-adapter'; import RegenerateFormW9Button from '.'; import collectiveApi from 'modules/common/collectiveApi'; const mockCollectiveApi = new MockAxiosAdapter(collectiveApi); beforeEach(cleanup); describe('RegenerateFormW9Button', () => { it('should match snapshot', async () => { const { asFragment } = render(<RegenerateFormW9Button email="test@email.com" />); expect(asFragment()).toMatchSnapshot(); }); it('should call w9 api', async () => { mockCollectiveApi.onPatch('regenerate-form-w9/').reply(200, {}); render(<RegenerateFormW9Button email="test@email.com" />); const user = userEvent.setup(); const button = screen.getByRole('button', { name: /Regenerate/i, }); await user.click(button); await waitFor(() => { expect(screen.getByText(/Form W9 was regenerated successfully/i)).toBeInTheDocument(); }); }); it('should call w9 api with error', async () => { mockCollectiveApi.onPatch('regenerate-form-w9/').reply(500, {}); render(<RegenerateFormW9Button email="test@email.com" />); const user = userEvent.setup(); const button = screen.getByRole('button', { name: /Regenerate/i, }); await user.click(button); await waitFor(() => { expect(screen.getByText(/Form W9 regeneration was unsuccessful/i)).toBeInTheDocument(); }); }); }); // Code from file collective-frontend/src/modules/Hub/ClientInfo/Details/RegenerateFormW9/RegenerateFormW9Button.jsx import { Button } from '@mui/material'; import { useState } from 'react'; import collectiveApi from 'modules/common/collectiveApi'; const RegenerateFormW9Button = ({ email }) => { const [isFormW9Regenerated, setIsFormW9Regenerated] = useState(false); const [isFormW9RegenerationError, setIsFormW9RegenerationError] = useState(false); const regenerateFormW9 = async () => { try { await collectiveApi.patch(`regenerate-form-w9/`, { email, }); setIsFormW9Regenerated(true); } catch (error) { setIsFormW9RegenerationError(true); } }; if (isFormW9Regenerated) { return ( <div className="ui success message"> <div className="header">Form W9 was regenerated successfully.</div> </div> ); } return ( <> <Button onClick={regenerateFormW9}> Regenerate </Button> {isFormW9RegenerationError && ( <div className="ui negative message"> <div className="header">Form W9 regeneration was unsuccessful.</div> </div> )} </> ); }; export default RegenerateFormW9Button; // Code from file collective-frontend/src/modules/Hub/ClientInfo/Details/RegenerateFormW9/index.js export { default } from './RegenerateFormW9Button'; // Code from file collective-frontend/src/modules/Hub/ClientInfo/Details/EINFilingEditor/index.js export { default } from './EINFilingEditor'; // Code from file collective-frontend/src/modules/Hub/ClientInfo/Details/EINFilingEditor/EINFilingEditor.jsx import * as Sentry from '@sentry/react'; import { useFormik } from 'formik'; import { observer } from 'mobx-react'; import { useState } from 'react'; import { Button, Form } from 'semantic-ui-react'; import { array, object, string } from 'yup'; import FormFileUpload from '../LLCFilingEditor/FormFileUpload'; import collectiveApi from 'modules/common/collectiveApi'; const initialValues = { ein: '', federal_business_name: '', einFiling: null, }; const validationSchema = object({ ein: string().label('Employer Identification Number').required(), federal_business_name: string().label('Federal Business Name').required(), einFiling: array().min(1), }); const EINFilingEditor = ({ client, einstatus }) => { const [einFilingCreated, setEinFilingCreated] = useState(false); const { id: memberId } = client; const postMemberEinFiling = async (memberId, postObject) => { try { const { data: success } = await collectiveApi.post(`/ein-filing/${memberId}/`, postObject); setEinFilingCreated(success); } catch (error) { console.error("Error while creating member's ein filing: ", error); Sentry.captureException(error); throw error; } }; const onSubmit = async (values) => { const postObject = { file_name: values.einFiling[0].filename, file_link: values.einFiling[0].url, ein: values.ein, federal_business_name: values.federal_business_name, }; await postMemberEinFiling(memberId, postObject); }; const { values, errors, touched, isValid, isSubmitting, handleChange, handleBlur, handleSubmit, setFieldValue } = useFormik({ initialValues, validationSchema, onSubmit, }); const handleFileUpload = (name, files) => { setFieldValue(name, files); }; const handleFileRemove = (name) => { setFieldValue(name, []); }; if (einstatus === 'completed' || einFilingCreated) { return ( <div className="ui success message"> <div className="header">EIN filing has been successfully completed for this member</div> </div> ); } return ( <Form className="tw-w-60"> <Form.Input label="What is the Employer Identification Number for this member?" name="ein" id="ein" data-testid="ein" value={values.ein} error={touched.ein && errors.ein} onChange={handleChange} onBlur={handleBlur} fluid /> <Form.Input label="What is the Federal Business Name for this member?" name="federal_business_name" id="federal_business_name" data-testid="federal_business_name" value={values.federal_business_name} error={touched.federal_business_name && errors.federal_business_name} onChange={handleChange} onBlur={handleBlur} fluid /> <div className="field"> <label htmlFor="einFiling">EIN Filing Document Upload</label> </div> <FormFileUpload name="einFiling" onUpload={handleFileUpload} onRemove={handleFileRemove} type="EIN Filing" /> <br /> <Button type="submit" disabled={isSubmitting || !isValid} primary size="small" onClick={handleSubmit}> {isSubmitting ? 'Saving...' : 'Save'} </Button> </Form> ); }; export default observer(EINFilingEditor); // Code from file collective-frontend/src/modules/Hub/ClientInfo/Details/EINFilingEditor/EINFilingEditor.test.jsx import { cleanup, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { configure } from 'mobx'; import EINFilingEditor from '.'; import RootStoreContext from 'modules/common/stores/Root/Context'; import HubStoreContext from 'modules/Hub/common/Store/Context'; configure({ safeDescriptors: false }); class MockLegacyStore { fetchFilestackCredentials = () => ({ api_key: 'api_key_test', policy: 'policy_test', signature: 'signature_test' }); } class MockHubStore { // saveTaxPayment = () => jest.fn(); } class MockClient { id = 1; } const renderWithProviders = (einstatus) => { const legacyStore = new MockLegacyStore(); const hubStore = new MockHubStore(); const client = new MockClient(); return render( <RootStoreContext.Provider value={{ legacyStore }}> <HubStoreContext.Provider value={{ hubStore }}> <EINFilingEditor client={client} einstatus={einstatus} /> </HubStoreContext.Provider> </RootStoreContext.Provider> ); }; beforeEach(cleanup); describe('EINFilingEditor', () => { it('should render correctly', async () => { renderWithProviders('inprogress'); const elements = await screen.findAllByText(/What is the Employer Identification Number for this member?/i); expect(elements[0]).toBeInTheDocument(); }); it('should show completed ein filing message', async () => { renderWithProviders('completed'); const elements = await screen.findAllByText(/EIN filing has been successfully completed for this member/i); expect(elements[0]).toBeInTheDocument(); }); it('should show ein filing form', async () => { renderWithProviders('inprogress'); const user = userEvent.setup(); const entityNumberInput = screen.getByTestId('ein'); const federalBusinessName = screen.getByTestId('federal_business_name'); await user.type(entityNumberInput, '123456'); await user.type(federalBusinessName, 'Big Company LLC'); const uploadButton = screen.getByRole('button', { name: /Upload document/i, }); await user.click(uploadButton); }); }); // Code from file collective-frontend/src/modules/Hub/ClientInfo/Search/Page.js import React, { Component } from 'react'; import { observer } from 'mobx-react'; import { Input, Message, Button } from 'semantic-ui-react'; import TimeoutModal from '../../common/TimeoutModal'; import collectiveApi from 'modules/common/collectiveApi'; import RootStoreContext from 'modules/common/stores/Root/Context'; class ClientSearchPage extends Component { static contextType = RootStoreContext; constructor(props, context) { super(props, context); this.state = { clients: [], sentBackClients: [], errorMessage: null, searchString: '', typing: false, typingTimer: null, loadingClients: false, loadingSentBackClients: false, }; this.store = context.legacyStore; } componentDidMount() { this.store.redirectedfrompage = 'hub/client-info/search'; if (!this.store.isLoginSuccess || !this.store.isEmployee) { this.props.history.push('/login'); } else { this.fetchSentBackClients(); if (!this.store.startedTimeTracking) { this.store.startTimeTracker(); } } } fetchClients = async () => { try { const { searchString: q } = this.state; if (!q.length) { return; } this.setState({ loadingClients: true }); const { data: { data: clients }, } = await collectiveApi.get('searchclients/', { params: { q } }); this.setState({ clients, loadingClients: false, }); } catch (error) { console.log('Fetch searched clients error.', error); this.setState({ errorMessage: 'Something went wrong. Please contact Engineering', }); } }; fetchSentBackClients = async () => { try { this.setState({ loadingSentBackClients: true }); const { data: { data: sentBackClients }, } = await collectiveApi.get('sentbackclients/'); this.setState({ sentBackClients, loadingSentBackClients: false, }); } catch (error) { console.log('Fetch sent back clients error.', error); this.setState({ errorMessage: 'Something went wrong. Please contact Engineering', }); } }; goToClientInfo = (client) => { if (client.sfaccountid) { this.store.clientInQuestion = client; this.props.history.push(`/hub/client-info/${client.sfaccountid}/info`); } }; handleChange = (event) => { clearTimeout(this.state.typingTimer); const newSearchString = event.target.value; const newTyping = newSearchString.length > 0; const newTypingTimer = setTimeout(() => { this.setState({ typing: false }); this.fetchClients(); }, 1000); this.setState({ searchString: newSearchString, typing: newTyping, typingTimer: newTypingTimer, }); }; renderClients = () => { const { clients, loadingClients } = this.state; if (clients.length === 0 || loadingClients) { return null; } return ( <div className="client-names-box"> <div className="space-between box-fixed"> <div className="inner-box">Name:</div> <div className="inner-box">Email:</div> </div> {clients.map((client, index) => ( <div onClick={() => this.goToClientInfo(client)} className={`space-between client-row ${index === clients.length - 1 ? 'last-client' : ''} ${ client.sfaccountid !== '---' ? '' : 'disable' }`} key={client.email}> <div className="client-row-inner">{client.fullname}</div> <div className="client-row-inner">{client.email}</div>{' '} </div> ))} </div> ); }; renderSentBackClients = () => { const { loadingSentBackClients, sentBackClients } = this.state; let content; if (loadingSentBackClients) { content = <Message color="green">Loading Sent Back Plans...</Message>; } else if (sentBackClients.length === 0) { content = <Message>No sent back plans found</Message>; } else { content = ( <> <h1>Sent Back Plans</h1> {sentBackClients.map((client) => ( <div onClick={() => this.goToClientInfo(client)} className="sentback-client" key={client.email}> <div>{client.email}</div> </div> ))} </> ); } return <div className="sentback">{content}</div>; }; render() { const { errorMessage, loadingClients, searchString, typing } = this.state; return ( <div className="ui text container multistep-container pt85"> {this.store.isEmployee ? ( <> {errorMessage && ( <div> <Message color="red">{errorMessage}</Message> </div> )} <h1>Client information</h1> <p> Search for clients that are signed up for Collective. You can see and edit the information they already provided or fill out information for them they haven’t provided yet. </p> <Input style={{ width: '100%', marginBottom: 64 }} value={searchString} onChange={this.handleChange} loading={loadingClients || typing} icon="search" iconPosition="left" placeholder="Client name" /> {this.renderClients()} {this.renderSentBackClients()} {this.store.showTimeoutModal ? <TimeoutModal /> : null} <div className="sentback"> <Button color="pink" as="a" href={`${process.env.REACT_APP_RETOOL_URL}?token=${encodeURIComponent( window.localStorage.getItem('token') )}&api_url=${encodeURIComponent(process.env.REACT_APP_DJANGO_URL)}`}> Bookkeeping Migrations </Button> </div> </> ) : ( <h1>You are not authorized to view this page.</h1> )} </div> ); } } export default observer(ClientSearchPage); // Code from file collective-frontend/src/modules/Hub/ClientInfo/common/ImpersonateButton.js import React from 'react'; import { Button, Dialog, DialogTitle, DialogActions, DialogContentText, DialogContent } from '@mui/material'; import VisibilityIcon from '@mui/icons-material/Visibility'; import useRootStore from 'modules/common/stores/Root/useRootStore'; const ImpersonateButton = ({ userId, displayName }) => { const [isConfirming, setIsConfirming] = React.useState(false); const { legacyStore } = useRootStore(); const openModal = () => { setIsConfirming(true); }; const closeModal = () => { setIsConfirming(false); }; const impersonateMember = () => { // TODO: Implement/trigger impersonation logic here... legacyStore.impersonateStart(userId); setIsConfirming(false); }; return ( <> <Button type="button" variant="contained" onClick={openModal} className="tw-w-full" startIcon={<VisibilityIcon />}> Impersonate Member </Button> <Dialog open={isConfirming} onClose={closeModal}> <DialogTitle>Impersonate member?</DialogTitle> <DialogContent> <DialogContentText> You will leave this page and be logged in as member {displayName}. </DialogContentText> </DialogContent> <DialogActions> <Button onClick={closeModal}>Cancel</Button> <Button data-testid="confirmButton" onClick={impersonateMember}> Impersonate member </Button> </DialogActions> </Dialog> </> ); }; export default ImpersonateButton; // Code from file collective-frontend/src/modules/Hub/ClientInfo/common/ImpersonateButton.test.js import { render, screen, fireEvent } from '@testing-library/react'; import ImpersonateButton from './ImpersonateButton'; import RootStoreContext from 'modules/common/stores/Root/Context'; const legacyStore = { impersonateStart: jest.fn(), }; describe('ImpersonationButton', () => { it('should trigger an impersonation', async () => { render( <RootStoreContext.Provider value={{ legacyStore }}> <ImpersonateButton userId={1985} displayName="Marty Mcfly" /> </RootStoreContext.Provider> ); await fireEvent.click(await screen.findByRole('button', { name: /Impersonate Member/i })); await fireEvent.click(await screen.findByTestId('confirmButton')); expect(legacyStore.impersonateStart).toHaveBeenCalled(); }); it('should bail out of the model on cancel', async () => { render( <RootStoreContext.Provider value={{ legacyStore }}> <ImpersonateButton userId={1985} displayName="Marty Mcfly" /> </RootStoreContext.Provider> ); await fireEvent.click(await screen.findByRole('button', { name: /Impersonate Member/i })); await fireEvent.click(await screen.findByRole('button', { name: /Cancel/i })); expect(legacyStore.impersonateStart).not.toHaveBeenCalled(); }); }); // Code from file collective-frontend/src/modules/Hub/QuarterlyTaxEstimates/index.jsx import React from 'react'; import { Route } from 'react-router-dom'; import lazyWithSuspenseAndRetry from 'modules/common/lazyWithSuspenseAndRetry'; export const QTEHomePage = lazyWithSuspenseAndRetry(() => import('./Home/Page')); export const QTEDetailsPage = lazyWithSuspenseAndRetry(() => import('./Details/Page')); export default function getHubQTERoutes() { return [ <Route exact key="qte-home" path="/hub/qte/" component={QTEHomePage} />, <Route exact key="qte-details" path="/hub/qte/:workflowId" component={QTEDetailsPage} />, ]; } // Code from file collective-frontend/src/modules/Hub/QuarterlyTaxEstimates/Tags/Tags.test.jsx import { render } from '@testing-library/react'; import Tags from './Tags'; describe('TrackerTool/Tags', () => { it('should render correctly', () => { const { asFragment } = render( <> <Tags text={10} status="completed" /> <Tags text={10} status="incomplete" /> <Tags text={10} /> <Tags text="Hello World" /> </> ); expect(asFragment().firstChild).toMatchSnapshot(); }); it('should render completed tag', () => { const { getByText } = render(<Tags text="10 days" status="completed" />); expect(getByText(/completed/i)).toBeInTheDocument(); }); it('should render incomplete tag', () => { const { getByText } = render(<Tags text="10 days" status="incomplete" />); expect(getByText(/incomplete/i)).toBeInTheDocument(); }); it('should render text tag', () => { const { getByText } = render(<Tags text="Hello world" />); expect(getByText(/hello world/i)).toBeInTheDocument(); }); }); // Code from file collective-frontend/src/modules/Hub/QuarterlyTaxEstimates/Tags/index.js export { default } from './Tags'; // Code from file collective-frontend/src/modules/Hub/QuarterlyTaxEstimates/Tags/Tags.module.less @color-green: #008866; @color-red: #fa5a60; @color-olive: #b5cc18; .tags { display: flex; } .tag { margin-left: 10px; font-size: 12px; padding: 3px 8px; border-radius: 20px; background: rgba(0, 0, 0, 0.1); text-transform: capitalize; display: flex; justify-content: center; align-items: center; &[class~='text'] { text-transform: none; } &[class~='single'] { font-size: 1em; margin: 0; border-radius: 20px; padding: 4px 10px 5px 10px; } &[class~='completed'], &[class~='review'] { background: @color-green; color: #ffffff !important; } &[class~='incomplete'] { background: @color-red; color: #ffffff !important; } &[class~='flag'] { background: @color-olive; color: #ffffff !important; } &[class~='days'] { color: #000000; } } // Code from file collective-frontend/src/modules/Hub/QuarterlyTaxEstimates/Tags/Tags.jsx import StatusIcon from '../StatusIcon'; import classes from './Tags.module.less'; const Tags = ({ status, text, single }) => { return ( <div className={classes.tags}> {text && <div className={`${classes.tag} days text`}>{text}</div>} {status && ( <div className={`${classes.tag} ${status} ${single && 'single'}`}> {single && <StatusIcon status={status} />} {status || 'Upcoming'} </div> )} </div> ); }; export default Tags; // Code from file collective-frontend/src/modules/Hub/QuarterlyTaxEstimates/Details/EmailPreviewModal.jsx import Parser from 'html-react-parser'; import { cloneElement, useState } from 'react'; import { Modal, Button } from 'semantic-ui-react'; import { observer } from 'mobx-react'; import RootStoreContext from 'modules/common/stores/Root/Context'; const EmailPreviewModal = ({ subject, body, button }) => { const [open, setOpen] = useState(false); return ( <> {cloneElement(button, { onClick: () => setOpen(true) })} {subject && body && ( <Modal size="large" className="hyke-modal collective-modal" open={open} onClose={() => setOpen(false)} closeIcon> <Modal.Content> <div className="modal-body"> <h2>{subject}</h2> {Parser(body)} </div> </Modal.Content> </Modal> )} </> ); }; export default EmailPreviewModal; // Code from file collective-frontend/src/modules/Hub/QuarterlyTaxEstimates/Details/queries.js import collectiveApi from 'modules/common/collectiveApi'; export const getWorkflowDetail = (workflowId) => collectiveApi.get(`/workflows/qte/${workflowId}/`); // Code from file collective-frontend/src/modules/Hub/QuarterlyTaxEstimates/Details/Page.jsx import { useParams } from 'react-router-dom'; import { Loader } from 'semantic-ui-react'; import { useQuery } from '@tanstack/react-query'; import { getWorkflowDetail } from './queries'; import { BasicTable, CommentTable, QTETable, OauthTable, WorkflowTable } from './Tables'; const QTEDetailsPage = () => { const { workflowId } = useParams(); const query = useQuery(['workflowDetails', workflowId], () => getWorkflowDetail(workflowId)); const { isLoading, isError, data: resp, error } = query; if (isLoading) { return ( <Loader inline="centered" active> Loading </Loader> ); } if (isError) { return error.message; } const { states, oauth, qte, workflow, hub_link, ...basics } = resp.data; return ( <div> <h2 className="tw-float-right"> <a href={hub_link}>Member Profile</a> </h2> <BasicTable title="Basic Info" items={basics} /> <BasicTable title="State Info" items={states} /> <QTETable qte={qte} /> <OauthTable oauth={oauth} /> <WorkflowTable workflow={workflow} /> <CommentTable qte={qte} /> </div> ); }; export default QTEDetailsPage; // Code from file collective-frontend/src/modules/Hub/QuarterlyTaxEstimates/Details/CommentThread/queries.js import collectiveApi from 'modules/common/collectiveApi'; export const getComments = (contentType, objectId) => collectiveApi.get(`/comments/?content_type=${contentType}&object_id=${objectId}`); // Code from file collective-frontend/src/modules/Hub/QuarterlyTaxEstimates/Details/CommentThread/CommentThread.jsx import { useState, useRef, useEffect } from 'react'; import { useQuery } from '@tanstack/react-query'; import { Card, CardHeader, CardContent, CardActions, Typography, CircularProgress, Button, TextField, Snackbar, } from '@mui/material'; import { getComments } from './queries'; import Comment from './Comment'; import collectiveApi from 'modules/common/collectiveApi'; import useRootStore from 'modules/common/stores/Root/useRootStore'; const CommentThread = ({ contentType, objectId }) => { const [formShowing, setFormShowing] = useState(false); const [openSnack, setOpenSnack] = useState(false); const [snackMessage, setSnackMessage] = useState(''); const [newCommentContent, setNewCommentContent] = useState(''); const query = useQuery(['getComments'], () => getComments(contentType, objectId)); const { legacyStore } = useRootStore(); const bottomRef = useRef(null); useEffect(() => { if (!bottomRef.current) { return; } bottomRef.current.scrollIntoView({ behavior: 'smooth' }); }, [formShowing]); const { refetch, isLoading, isError, data: resp, error } = query; if (isLoading) { return ( <div className="tw-ml-3 tw-opacity-100"> <CircularProgress /> </div> ); } if (isError) { return error.message; } const comments = resp.data; const handleNewCommentContentChange = (event) => { setNewCommentContent(event.target.value); }; const handleSubmit = async () => { const result = await collectiveApi.post(`/comments/`, { content_type: contentType, object_id: objectId, content: newCommentContent, }); setSnackMessage('Comment sent'); setOpenSnack(true); setFormShowing(false); setNewCommentContent(''); if (result.status === 201) { comments.push(result.data); } }; const handleSnackClose = () => { setOpenSnack(false); }; const handleDeleteComment = (uuid) => { setSnackMessage('Comment deleted'); setOpenSnack(true); refetch(); }; return ( <div className="tw-flex tw-flex-col tw-grid-cols-1 tw-gap-2"> {comments.map((comment) => ( <Comment key={comment.uuid} data={comment} isOwner={legacyStore.email === comment.user.email} deleteCallback={handleDeleteComment} /> ))} {formShowing && ( <Card raised> <CardHeader title="Add new comment" /> <CardContent> <TextField id="outlined-multiline-flexible" label="---" multiline fullWidth minRows={4} maxRows={8} value={newCommentContent} onChange={handleNewCommentContentChange} /> </CardContent> <CardActions disableSpacing> <div className="tw-flex tw-w-full tw-justify-end tw-gap-2"> <Button variant="outlined" color="secondary" onClick={() => setFormShowing(false)}> Cancel </Button> <Button color="primary" onClick={handleSubmit}> Submit </Button> </div> </CardActions> </Card> )} {!formShowing && ( <div className="tw-flex tw-justify-end"> <Button onClick={() => setFormShowing(true)} variant="contained" size="large"> Add Comment </Button> </div> )} <div ref={bottomRef} /> <Snackbar open={openSnack} autoHideDuration={1500} message={snackMessage} onClose={handleSnackClose} /> </div> ); }; export default CommentThread; // Code from file collective-frontend/src/modules/Hub/QuarterlyTaxEstimates/Details/CommentThread/Comment.test.jsx import { fireEvent, render, waitFor } from '@testing-library/react'; import MockAdapter from 'axios-mock-adapter'; import moment from 'moment'; import { QueryClientProvider, QueryClient } from '@tanstack/react-query'; import Comment from './Comment'; import collectiveApi from 'modules/common/collectiveApi'; const mock = new MockAdapter(collectiveApi); const comment = { uuid: '15c3b737-9bd8-4d41-8b01-441240c0dcba', user: { email: 'jwon@slava.com', first_name: 'Jwon', last_name: 'Slaav' }, created_at: moment().subtract(7, 'days').format(), updated_at: moment().subtract(7, 'days').format(), content: 'A third comment.', }; const WrappedComponent = ({ isOwner, callback }) => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, }, }, }); return ( <QueryClientProvider client={queryClient}> <Comment data={comment} isOwner={isOwner} deleteCallback={callback} /> </QueryClientProvider> ); }; describe('Comment', () => { it('should render correctly', () => { const { asFragment } = render(<WrappedComponent isOwner={false} callback={() => {}} />); expect(asFragment()).toMatchSnapshot(); }); it('should render delete button and submit correctly', async () => { const mockback = jest.fn(); mock.onDelete('/comments/15c3b737-9bd8-4d41-8b01-441240c0dcba/').replyOnce(204, {}); const { getByTestId } = render(<WrappedComponent isOwner callback={mockback} />); expect(getByTestId('comment-delete-button')).toBeTruthy(); fireEvent.click(getByTestId('comment-delete-button')); await waitFor(() => expect(mockback.mock.calls.length).toBe(1)); expect(mock.history.delete.length).toBe(1); }); }); // Code from file collective-frontend/src/modules/Hub/QuarterlyTaxEstimates/Details/CommentThread/index.js export { default } from './CommentThread'; // Code from file collective-frontend/src/modules/Hub/QuarterlyTaxEstimates/Details/CommentThread/Comment.jsx import { Card, CardHeader, CardContent, Typography } from '@mui/material'; import { LoadingButton } from '@mui/lab'; import { useMutation } from '@tanstack/react-query'; import DeleteForeverIcon from '@mui/icons-material/DeleteForever'; import moment from 'moment'; import collectiveApi from 'modules/common/collectiveApi'; const Comment = ({ data, isOwner, deleteCallback }) => { const deleteComment = useMutation( (id) => { return collectiveApi.delete(`/comments/${id}/`); }, { onSuccess: () => deleteCallback(data.uuid), } ); const getCommentAction = () => { return isOwner ? ( <LoadingButton loading={deleteComment.isLoading} variant="contained" data-testid="comment-delete-button" onClick={() => deleteComment.mutate(data.uuid)}> <DeleteForeverIcon /> </LoadingButton> ) : null; }; return ( <Card raised aria-label={`comment-${data.uuid}`}> <CardHeader title={`${data.user.first_name} ${data.user.last_name} <${data.user.email}>`} subheader={`commented ${moment(data.created_at).fromNow()}`} action={getCommentAction()} /> <CardContent> <Typography variant="body1" color="text.secondary"> {data.content} </Typography> </CardContent> </Card> ); }; export default Comment; // Code from file collective-frontend/src/modules/Hub/QuarterlyTaxEstimates/Details/CommentThread/CommentThread.test.jsx import { fireEvent, render, waitFor } from '@testing-library/react'; import MockAdapter from 'axios-mock-adapter'; import { QueryClientProvider, QueryClient } from '@tanstack/react-query'; import CommentThread from './CommentThread'; import constants from 'modules/common/constants/constants'; import RootStoreContext from 'modules/common/stores/Root/Context'; import collectiveApi from 'modules/common/collectiveApi'; const commentsData = [ { uuid: '15c3b737-9bd8-4d41-8b01-440e40c0dcba', user: { email: 'flluch@collective.com', first_name: 'Francisco', last_name: 'Tester' }, created_at: '2022-08-24T06:54:59.857152-07:00', updated_at: '2022-08-24T06:54:59.857168-07:00', content: 'A simple comment.', }, { uuid: '15c3b737-9bd8-4d41-8b01-441240c0dcba', user: { email: 'jwon@slava.com', first_name: 'Jwon', last_name: 'Slaav' }, created_at: '2022-08-24T06:54:59.857152-07:00', updated_at: '2022-08-24T06:54:59.857168-07:00', content: 'Another comment.', }, ]; const WrappedComponent = () => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, }, }, }); return ( <RootStoreContext.Provider value={{ legacyStore: { email: 'jwon@slava.com' } }}> <QueryClientProvider client={queryClient}> <CommentThread contentType={constants.contentTypes.QUARTERLY_TAX_ESTIMATE} objectId="be20f314-042f-42c7-8fae-9c42618b9b07" /> </QueryClientProvider> </RootStoreContext.Provider> ); }; describe('CommentThread', () => { let mock; beforeEach(() => { mock = new MockAdapter(collectiveApi); mock.onGet('/comments/?content_type=quarterlytaxestimate&object_id=be20f314-042f-42c7-8fae-9c42618b9b07').reply( 200, commentsData ); }); it('should render correctly', async () => { const { getAllByTestId, getByText, findByText } = render(<WrappedComponent />); await findByText('Add Comment'); expect(getByText('A simple comment.')).toBeTruthy(); expect(getByText('Another comment.')).toBeTruthy(); expect(getAllByTestId('comment-delete-button').length).toBe(1); }); it('should render comment form and submit successfully', async () => { window.HTMLElement.prototype.scrollIntoView = jest.fn(); mock.onPost('/comments/').replyOnce(201, []); const { getByText, findByText } = render(<WrappedComponent />); // button is being shown await findByText('Add Comment'); fireEvent.click(getByText('Add Comment')); // form is being shown expect(getByText('Add new comment')).toBeTruthy(); fireEvent.click(getByText('Submit')); // form is sent await waitFor(() => expect(mock.history.post.length).toBe(1)); // button is shown again expect(getByText('Add Comment')).toBeTruthy(); }); }); // Code from file collective-frontend/src/modules/Hub/QuarterlyTaxEstimates/Details/Page.module.less .tableSection { margin: 14px auto; } .workflowTaskInset { padding-left: 32px; border-left: 2px solid lightgray; } // Code from file collective-frontend/src/modules/Hub/QuarterlyTaxEstimates/Details/Tables.jsx import { Label, Table, Button, Icon } from 'semantic-ui-react'; import moment from 'moment'; import styles from './Page.module.less'; import EmailPreviewModal from './EmailPreviewModal'; import CommentThread from './CommentThread'; import constants from 'modules/common/constants/constants'; import { moneyFormat } from 'modules/common/display'; const depythonKey = (value) => value.split('_').join(' '); export const BasicTable = ({ items, title }) => ( <div className={styles.tableSection}> <h2>{title}</h2> <Table celled> <Table.Header> <Table.Row> {Object.keys(items).map((columnName) => ( <Table.HeaderCell key={columnName}>{depythonKey(columnName)}</Table.HeaderCell> ))} </Table.Row> </Table.Header> <Table.Body> <Table.Row> {Object.values(items).map((value, index) => { return <Table.Cell key={index}>{value}</Table.Cell>; })} </Table.Row> </Table.Body> </Table> </div> ); export const QTETable = ({ qte }) => { const moneyColumns = [ 'net_operating_income', 'payroll_federal_tax', 'payroll_state_tax', 'payroll_wages', 'estimated_federal_tax', 'estimated_state_tax', ]; const { subject, body, uuid: _, ...qteInfo } = qte; const emailPreviewable = Boolean(subject) && Boolean(body); return ( <div className={styles.tableSection}> <h2>QTE</h2> <Table celled> <Table.Header> <Table.Row> {Object.keys(qteInfo).map((columnName) => ( <Table.HeaderCell key={columnName}>{depythonKey(columnName)}</Table.HeaderCell> ))} </Table.Row> </Table.Header> <Table.Body> <Table.Row> {Object.entries(qteInfo).map(([key, value], index) => { return ( <Table.Cell key={index}> {moneyColumns.includes(key) ? moneyFormat(value) : value} </Table.Cell> ); })} </Table.Row> </Table.Body> <Table.Footer fullWidth> <Table.Row> <Table.HeaderCell colSpan="15"> <EmailPreviewModal subject={subject} body={body} button={ <Button disabled={!emailPreviewable} icon labelPosition="left" primary size="small"> <Icon name="mail" /> Preview Email </Button> } /> {!emailPreviewable && ( <Label pointing="left" color="red"> QTE not generated, no email to preview </Label> )} </Table.HeaderCell> </Table.Row> </Table.Footer> </Table> </div> ); }; export const OauthTable = ({ oauth }) => { const dateFields = ['access_expiration_date', 'created', 'updated']; if (!oauth.length) { return null; } return ( <div className={styles.tableSection}> <h2>Connected Services</h2> <Table celled> <Table.Header> <Table.Row> {Object.keys(oauth[0]).map((columnName) => ( <Table.HeaderCell key={columnName}>{depythonKey(columnName)}</Table.HeaderCell> ))} </Table.Row> </Table.Header> <Table.Body> {oauth .sort((a, b) => a.id - b.id) // never change, js .map((row, index) => ( <Table.Row key={index}> {Object.entries(row).map(([columnName, value]) => ( <Table.Cell key={columnName}> {value && dateFields.includes(columnName) ? moment(value).format('MMMM Do YYYY, h:mm:ss a') : value} </Table.Cell> ))} </Table.Row> ))} </Table.Body> </Table> </div> ); }; export const WorkflowTable = ({ workflow }) => { const { tasks, input: workflowInput, ...workflowStats } = workflow; return ( <div className={styles.tableSection}> <h2>Workflow</h2> <Table celled> <Table.Header> <Table.Row> {Object.keys(workflowStats).map((key) => ( <Table.HeaderCell key={key}>{depythonKey(key)}</Table.HeaderCell> ))} </Table.Row> </Table.Header> <Table.Body> <Table.Row> {Object.values(workflowStats).map((value, index) => { return <Table.Cell key={index}>{value}</Table.Cell>; })} </Table.Row> </Table.Body> </Table> {tasks.length > 0 && ( <div className={styles.workflowTaskInset}> <h3>Workflow Tasks</h3> <Table celled striped> <Table.Header> <Table.Row> {Object.keys(tasks[0]).map((columnName) => ( <Table.HeaderCell key={columnName}>{depythonKey(columnName)}</Table.HeaderCell> ))} </Table.Row> </Table.Header> <Table.Body> {tasks.map((row, index) => ( <Table.Row key={index}> {Object.values(row).map((value, i) => ( <Table.Cell key={i}>{value}</Table.Cell> ))} </Table.Row> ))} </Table.Body> </Table> </div> )} </div> ); }; export const CommentTable = ({ qte }) => { return ( <div className={styles.tableSection}> <h2>Comments</h2> <CommentThread contentType={constants.contentTypes.QUARTERLY_TAX_ESTIMATE} objectId={qte.uuid} /> </div> ); }; // Code from file collective-frontend/src/modules/Hub/QuarterlyTaxEstimates/Details/Page.test.js import { render } from '@testing-library/react'; import { Route, MemoryRouter } from 'react-router-dom'; import { QueryClientProvider, QueryClient } from '@tanstack/react-query'; import MockAdapter from 'axios-mock-adapter'; import QTEDetailsPage from './Page'; import RootStoreContext from 'modules/common/stores/Root/Context'; import collectiveApi from 'modules/common/collectiveApi'; const commentsData = [ { uuid: '15c3b737-9bd8-4d41-8b01-440e40c0dcba', user: { email: 'flluch@collective.com', first_name: 'Francisco', last_name: 'Tester' }, created_at: '2022-08-24T06:54:59.857152-07:00', updated_at: '2022-08-24T06:54:59.857168-07:00', content: 'A simple comment.', }, { uuid: '15c3b737-9bd8-4d41-8b01-441240c0dcba', user: { email: 'jwon@slava.com', first_name: 'Jwon', last_name: 'Slaav' }, created_at: '2022-08-24T06:54:59.857152-07:00', updated_at: '2022-08-24T06:54:59.857168-07:00', content: 'Another comment.', }, ]; const workflowData = { id: 1, status: 'OK', fullname: 'Fourty Two', businessname: 'Forty Two', email: 'localtest1@collective.com', taxpro: 'Landon Brantley', bookkeeper: 'Prentiss Johnson', pops: 'Jacob Frediani', member_relationship_manager: 'Hector Rodriguez', usertype: 'LLC-SC', workflow: { status: 'COMPLETED', workflow_name: 'qte_process', workflow_id: '6d4eab49-0902-11ed-8fc0-aa06f1e740a0', input: { member_id: 3, year: 2022, due_date: 1234567890, email: 'joe5@collective.com', tags: 'asd1 asd2 asd5 asd4', quarter: 1, }, tasks: [ { reference_task_name: 'qte_initial_data_validation_loop_ref', status: 'COMPLETED', task_def_name: 'Data Validation', task_id: '6d4f207a-0902-11ed-8fc0-aa06f1e740a0', iteration: 1, }, { reference_task_name: 'qte_fetch_data__1', status: 'COMPLETED', task_def_name: 'QTE Fetch Data', task_id: '6d4f6e9b-0902-11ed-8fc0-aa06f1e740a0', iteration: 1, }, { reference_task_name: 'qte_manual_data_review_ref__1', status: 'COMPLETED', task_def_name: 'qte_manual_data_review', task_id: '61de8ca5-0903-11ed-983f-ca93eb8d3a16', iteration: 1, }, { reference_task_name: 'qte_is_record_valid__1', status: 'COMPLETED', task_def_name: 'SWITCH', task_id: '77e6f5f7-0903-11ed-983f-ca93eb8d3a16', iteration: 1, }, { reference_task_name: 'set_approved__1', status: 'COMPLETED', task_def_name: 'Approve Workflow', task_id: '77e6f5f8-0903-11ed-983f-ca93eb8d3a16', iteration: 1, }, { reference_task_name: 'qte_send_email', status: 'COMPLETED', task_def_name: 'qte_send_email', task_id: '77ed5e99-0903-11ed-983f-ca93eb8d3a16', iteration: 0, }, ], }, qte: { uuid: 'be20f314-042f-42c7-8fae-9c42618b9b07', subject: '[ACTION REQUIRED] Your 2022 Q1 Estimated Tax Payments -- Forty Two LLC', body: "\n<p>Hi Fourty,</p>\n\n\n<p>\n We've updated our forecast for your Q1 estimated\n <i>quarterly</i>\n tax payments.\n</p>\n<p>\n Our number is based on the year-to-last month bookkeeping activity in your accounting software\n and Gusto accounts. Based on that information, we're able to estimate what we think your total\n tax liability will be for the year – and how much you should pay each quarter.\n</p>\n<p>\n For more information on how we calculated the amount, check out our <a href=\"https://help.collective.com/en/articles/5482079-quarterly-tax-estimates\">Help Article</a>.\n</p>\n<p><b>IRS Estimated tax payment: $1,000</b></p>\n\n<p><b>CA estimated tax payment: $2,000</b></p>\n\n<p>\n This quarter's payment is due by April 18, 2022.\n</p>\n\n<h2> What if my 2021 Tax Returns haven't been completed yet? </h2>\n<p>\n No worries, this is very common! You should make the estimated payment as an\n Extension payment for 2021 and upload the confirmation\n page with the amount paid in your TaxCaddy account. Our tax team will notate\n that on the return and apply any of the excess to 2022.\n</p>\n\n\n<h2>Which bank account should I use?</h2>\n<p>\n Your estimated tax payment should only come from your personal bank account,\n not your business bank account. If you need funds from your business account\n to cover this payment, transfer the amount needed from your business bank account\n to your personal account and categorize this transaction as a\n 'shareholder distribution' in your accounting software.\n</p>\n\n<h2>What's the easiest way to pay the IRS?</h2>\n<p>\n To make your Federal estimated quarterly tax payment please <a\n href=\"https://help.collective.com/en/articles/4564769-irs-estimated-payment-instructions\">visit our Help Center</a>\n to view instructions.\n</p>\n\n\n\n<p>\n Questions? We have answers! Feel free to email us at <a href=\"mailto:hello@collective.com\">hello@collective.com</a>! We're\n always here to help.\n <br /><br />\n</p>\n\n<p>Best,</p>\n<p>The Collective Team</p>", reason: '', net_operating_income: '123.00', payroll_federal_tax: '567.00', payroll_state_tax: '890.00', payroll_wages: '1011.00', quarter: 1, year: 2022, estimated_federal_tax: '1000.00', estimated_state_tax: '2000.00', state: 'CA', sent: false, scheduled_date: null, state_exception: true, user: 1, }, states: { Operation: '---', Incorporation: 'Alaska', Residency: '---', 'Business Address': '---', 'Mail Address': '---', Shareholder: '---', }, hub_link: '/hub/client-info/None/info', oauth: [ { id: 2, service: 'qbo', service_id: '9130350073293736', service_name: '', access_expiration_date: null, created: '2022-06-16T19:33:18.097528-07:00', updated: '2022-06-27T12:44:39.985768-07:00', status: 'valid', user: 1, }, { id: 1, service: 'gusto', service_id: '9130350679423356', service_name: '', access_expiration_date: '2022-07-13T10:45:37.050603-07:00', created: '2022-06-15T14:03:18.987813-07:00', updated: '2022-07-13T08:45:38.051019-07:00', status: 'valid', user: 1, }, ], }; const Wrapper = ({ children, routeDefinition = '', initialRoute = '' }) => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, }, }, }); return ( <RootStoreContext.Provider value={{ legacyStore: {} }}> <MemoryRouter initialEntries={[initialRoute]}> <QueryClientProvider client={queryClient}> <Route exact path={routeDefinition || initialRoute}> {children} </Route> </QueryClientProvider> </MemoryRouter> </RootStoreContext.Provider> ); }; describe('QTEDetail', () => { let mock; beforeEach(() => { mock = new MockAdapter(collectiveApi); mock.onGet('workflows/qte/abc-123/').reply(200, workflowData); }); it('should render loading state', () => { const { asFragment } = render( <Wrapper routeDefinition="/hub/QTE/:workflowId" initialRoute="/hub/QTE/abc-123"> <QTEDetailsPage /> </Wrapper> ); expect(asFragment()).toMatchSnapshot(); }); it('should render loaded state', async () => { mock.onGet( '/comments/?content_type=quarterlytaxestimate&object_id=be20f314-042f-42c7-8fae-9c42618b9b07' ).replyOnce(200, commentsData); const { asFragment, getAllByRole, getByText, findByText } = render( <Wrapper routeDefinition="/hub/QTE/:workflowId" initialRoute="/hub/QTE/abc-123"> <QTEDetailsPage /> </Wrapper> ); await findByText('Basic Info'); expect(getByText('Basic Info')).toBeTruthy(); expect(getByText('Connected Services')).toBeTruthy(); expect(getByText('Workflow')).toBeTruthy(); expect(getByText('Workflow Tasks')).toBeTruthy(); expect(getByText('QTE')).toBeTruthy(); expect(getByText('Comments')).toBeTruthy(); expect(getAllByRole('table').length).toBe(6); }); xit('should render error state', async () => { /** * TODO: Disabling this test until @Joeseph Rodrigues can fix and re-enable it. * Reason: `react-query` is not handling axios exceptions properly. And that is causing our CI to fail with a * `UnhandledPromiseRejectionWarning` error. */ mock.onGet( '/comments/?content_type=quarterlytaxestimate&object_id=be20f314-042f-42c7-8fae-9c42618b9b07' ).replyOnce(403); const { findByText, asFragment } = render( <Wrapper routeDefinition="/hub/QTE/:workflowId" initialRoute="/hub/QTE/abc-123"> <QTEDetailsPage /> </Wrapper> ); await findByText('Request failed with status code 403'); expect(asFragment()).toMatchSnapshot(); }); }); // Code from file collective-frontend/src/modules/Hub/QuarterlyTaxEstimates/StatusIcon/StatusIcon.test.jsx import { render } from '@testing-library/react'; import StatusIcon from './StatusIcon'; describe('TrackerTool/StatusIcon', () => { it('should render correctly', () => { const { asFragment } = render( <> <StatusIcon status="completed" /> <StatusIcon status="incomplete" /> <StatusIcon status="not applicable" /> <StatusIcon /> </> ); expect(asFragment().firstChild).toMatchSnapshot(); }); }); // Code from file collective-frontend/src/modules/Hub/QuarterlyTaxEstimates/StatusIcon/index.js export { default } from './StatusIcon'; // Code from file collective-frontend/src/modules/Hub/QuarterlyTaxEstimates/StatusIcon/StatusIcon.jsx import { Icon } from 'semantic-ui-react'; const StatusIcon = ({ status }) => { switch (status) { case 'completed': return <Icon name="check circle outline" />; case 'incomplete': return <Icon name="hourglass one" />; case 'not applicable': return <Icon name="window close outline" />; case 'not started': return <Icon name="tasks" />; case 'review': return <Icon name="hourglass one" />; case 'flag': return <Icon name="flag" />; case 'fetch': return <Icon name="sync" />; default: return <Icon name="circle outline" />; } }; export default StatusIcon; // Code from file collective-frontend/src/modules/Hub/QuarterlyTaxEstimates/Home/Page.test.jsx import { makeObservable, observable, action } from 'mobx'; import { act, render, waitFor, screen, fireEvent } from '@testing-library/react'; import { Router } from 'react-router-dom'; import { createMemoryHistory } from 'history'; import { QueryClientProvider, QueryClient } from '@tanstack/react-query'; import HubStoreContext from '../../common/Store/Context'; import QTEHomePage from './Page'; import RootStoreContext from 'modules/common/stores/Root/Context'; class MockHubStore { filter = {}; milestones = []; members = []; constructor() { makeObservable(this, { filter: observable, milestones: observable, members: observable, setFilter: action, setMembers: action, setMilestones: action, fetchMembersQTE: action, }); } fetchMembersQTE = () => jest.fn(); fetchMilestones = () => jest.fn(); setFilter = () => jest.fn(); setMembers = () => jest.fn(); setMilestones = (milestones) => { this.milestones = milestones; }; } class MockLegacyStore { allStates = []; constructor() { makeObservable(this, { allStates: observable, getAllStates: action, }); } getAllStates = () => { this.allStates = [ { id: 'CA', name: 'California' }, { id: 'AZ', name: 'Arizona' }, { id: 'TX', name: 'Texas' }, ]; }; } const renderWithProviders = (hubStore, legacyStore) => { const history = createMemoryHistory(); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, }, }, }); return render( <RootStoreContext.Provider value={{ legacyStore }}> <HubStoreContext.Provider value={hubStore}> <Router history={history}> <QueryClientProvider client={queryClient}> <QTEHomePage /> </QueryClientProvider> </Router> </HubStoreContext.Provider> </RootStoreContext.Provider> ); }; describe('QTE Tracker', () => { it('should render correctly', () => { const hubStore = new MockHubStore(); const legacyStore = new MockLegacyStore(); const { asFragment } = renderWithProviders(hubStore, legacyStore); act(() => { legacyStore.getAllStates(); }); expect(asFragment().firstChild).toMatchSnapshot(); }); it('State options', async () => { const hubStore = new MockHubStore(); const legacyStore = new MockLegacyStore(); renderWithProviders(hubStore, legacyStore); await waitFor(() => { legacyStore.getAllStates(); }); expect(screen.getByText(/california/i)).toBeInTheDocument(); expect(screen.getByText(/arizona/i)).toBeInTheDocument(); expect(screen.getByText(/texas/i)).toBeInTheDocument(); }); it('QTE Member Row Actions', async () => { const hubStore = new MockHubStore(); const legacyStore = new MockLegacyStore(); hubStore.members = [{ id: 1, fullname: 'Test Tester', current_step: 'review' }]; hubStore.membersPagination = { total_record_count: 1, pages: 1 }; renderWithProviders(hubStore, legacyStore); await waitFor(() => { legacyStore.getAllStates(); }); // the icons for approve, flag, detail view expect(screen.getByLabelText('Approve QTE')).toBeInTheDocument(); expect(screen.getByLabelText('Flag QTE')).toBeInTheDocument(); expect(screen.getByLabelText('View Details')).toBeInTheDocument(); hubStore.members[0].current_step = 'flag'; expect(screen.queryByLabelText('Approve QTE')).not.toBeInTheDocument(); // the icons for restart and cancel expect(screen.getByLabelText('Restart QTE')).toBeInTheDocument(); expect(screen.getByLabelText('Cancel QTE')).toBeInTheDocument(); // clicking on cancel button reveals Reason Select field fireEvent.click(screen.getByLabelText('Cancel QTE')); expect(screen.getByLabelText('Reason')).toBeInTheDocument(); }); it('Confirm Action buttons', async () => { const hubStore = new MockHubStore(); const legacyStore = new MockLegacyStore(); hubStore.members = [{ id: 1, fullname: 'Test Tester', current_step: 'review' }]; hubStore.membersPagination = { total_record_count: 1, pages: 1 }; renderWithProviders(hubStore, legacyStore); await waitFor(() => { legacyStore.getAllStates(); }); const approveButton = screen.getByTestId('ThumbUpIcon'); fireEvent.click(approveButton); expect(screen.getByTestId('CheckIcon')).toBeInTheDocument(); }); }); // Code from file collective-frontend/src/modules/Hub/QuarterlyTaxEstimates/Home/Page.jsx import { useState } from 'react'; import { Divider, Header, Icon } from 'semantic-ui-react'; import Button from '@mui/material/Button'; import TrackingTool from '../TrackingTool'; import ExtraFilterFields from './FilterFormFields'; import MemberListActions from './MemberListActions'; import Milestones from './Milestones'; import AddMemberModal from './AddMemberModal'; import useHubStore from 'modules/Hub/common/Store/useHubStore'; function QTEHomePage() { const hubStore = useHubStore(); const [showAddMemberModal, setShowAddMemberModal] = useState(false); const today = new Date(); const currentQuarterQTE = { qte_quarter: Math.floor((today.getMonth() + 3) / 3), qte_year: today.getFullYear(), qte_step: 'review', activity_status: 'incomplete', }; const memberTableCells = [ { data: 'fullname', label: 'Full Name' }, { data: 'email', label: 'Email Address' }, { data: 'noi', label: 'NOI' }, { data: 'gross_wages', label: 'Gross Wages' }, { data: 'state_tax', label: 'State Tax' }, { data: 'federal_tax', label: 'Federal Tax' }, { data: 'state', label: 'State' }, { data: 'current_step', label: 'Current Step' }, ]; return ( <> <div className="tw-flex tw-items-start tw-justify-between"> <Header as="h2"> <Icon name="calculator" /> QTE (Quarterly Tax Estimates) </Header> <Button onClick={() => setShowAddMemberModal(true)}>Add Member</Button> </div> <Divider /> <TrackingTool memberTableCells={memberTableCells} ExtraFilterFields={ExtraFilterFields} accordianOpen={false} MemberListActions={MemberListActions} Milestones={Milestones} fetchMemberFunc={hubStore.fetchMembersQTE} initialValues={currentQuarterQTE} /> <AddMemberModal open={showAddMemberModal} onClose={() => setShowAddMemberModal(false)} /> </> ); } export default QTEHomePage; // Code from file collective-frontend/src/modules/Hub/QuarterlyTaxEstimates/Home/Milestones/Milestones.jsx import { useEffect } from 'react'; import { observer } from 'mobx-react'; import { Menu } from 'semantic-ui-react'; import { CircularProgress } from '@mui/material'; import Tags from '../../Tags'; import classes from './Milestones.module.less'; import useHubStore from 'modules/Hub/common/Store/useHubStore'; const Milestones = () => { const hubStore = useHubStore(); useEffect(() => { const fetchData = async () => { await hubStore.fetchMilestones('/workflows/qte/statistics/'); }; fetchData(); }, []); if (!hubStore.milestones.length) { return ( <div className="tw-ml-3 tw-opacity-100"> <CircularProgress /> </div> ); } return ( <Menu compact className={classes.milestones}> <Menu.Item header>QTE Numbers: </Menu.Item> {hubStore?.milestones?.map((item) => ( <Menu.Item key={item.name}> {item.name.toUpperCase()} <Tags text={`${item.member_count} QTEs`} /> </Menu.Item> ))} </Menu> ); }; export default observer(Milestones); // Code from file collective-frontend/src/modules/Hub/QuarterlyTaxEstimates/Home/Milestones/Milestones.test.jsx import { makeObservable, configure, observable, action } from 'mobx'; import { render } from '@testing-library/react'; import { Router } from 'react-router-dom'; import { createMemoryHistory } from 'history'; import HubStoreContext from '../../../common/Store/Context'; import Milestones from './Milestones'; /** * MobX makes some fields non-configurable or non-writable to disabling spying/mocking/stubbing in tests * @link https://mobx.js.org/configuration.html#safedescriptors-boolean */ configure({ safeDescriptors: false }); class MockHubStore { milestones = [ { name: 'fetch', member_count: 1 }, { name: 'review', member_count: 2 }, { name: 'cancel', member_count: 144 }, { name: 'flag', member_count: 44 }, ]; constructor() { makeObservable(this, { milestones: observable, setFilter: action, fetchMembers: action, setMilestones: action, }); } setFilter = () => jest.fn(); setMilestones = () => jest.fn(); fetchMembers = () => jest.fn(); fetchMilestones = () => jest.fn(); } const renderWithProviders = (hubStore) => { const history = createMemoryHistory(); return render( <HubStoreContext.Provider value={hubStore}> <Router history={history}> <Milestones /> </Router> </HubStoreContext.Provider> ); }; describe('Tracker QTE/Milestones', () => { it('should render correctly', () => { const hubStore = new MockHubStore(); const { asFragment } = renderWithProviders(hubStore); expect(asFragment().firstChild).toMatchSnapshot(); }); }); // Code from file collective-frontend/src/modules/Hub/QuarterlyTaxEstimates/Home/Milestones/index.js export { default } from './Milestones'; // Code from file collective-frontend/src/modules/Hub/QuarterlyTaxEstimates/Home/Milestones/Milestones.module.less @color-green: #008866; @color-red: #FA5A60; [class~=ui][class~=menu].milestones { border-radius: 40px; margin-bottom: 20px; box-shadow: 0px 1px 4px rgba(0, 0, 0, 0.1); border: none; [class~="item"] { padding: 0 40px; cursor: pointer; border-right: 1px solid rgba(0, 0, 0, 0.1); &:hover { color: #000; } &:last-child { border-right: none; } &[class~="active"] { border-radius: 40px; background-color: #faf9f6; } &[class~="completed"] { [class~="icon"] { color: @color-green; } } &[class~="incompleted"] { [class~="icon"] { color: @color-red; } } &::before { background: none; } } }// Code from file collective-frontend/src/modules/Hub/QuarterlyTaxEstimates/Home/FilterFormFields.jsx import { range } from 'lodash'; import { toJS } from 'mobx'; import { observer } from 'mobx-react'; import { useEffect } from 'react'; import { Form } from 'semantic-ui-react'; import useRootStore from 'modules/common/stores/Root/useRootStore'; function ExtraFilterFields({ values, handleSelectChange }) { const { legacyStore } = useRootStore(); const currentYear = new Date().getFullYear(); const options = { // Year choices for dropdown filter from 2019 to current year years: range(2019, currentYear + 1).map((year) => ({ key: year, text: year, value: year })), // These values will never change quarters: [ { key: 1, text: 1, value: 1 }, { key: 2, text: 2, value: 2 }, { key: 3, text: 3, value: 3 }, { key: 4, text: 4, value: 4 }, ], // Sourced from backend pre-filled region model states: toJS(legacyStore.allStates).map((state) => ({ key: state.id, text: state.name, value: state.id })), steps: [ { key: 'review', text: 'In Review', value: 'review' }, { key: 'flag', text: 'Flagged', value: 'flag' }, { key: 'cancel', text: 'Cancelled', value: 'cancel' }, { key: 'fetch', text: 'Fetching Data', value: 'fetch' }, { key: 'send', text: 'Sending Email', value: 'send' }, ], }; useEffect(() => { if (legacyStore.allStates.length === 0) { legacyStore.getAllStates(); } }, []); return ( <> <Form.Select fluid label="Year" name="qte_year" id="qte_year" value={values.qte_year || ''} options={options.years} onChange={handleSelectChange} placeholder="Year" search /> <Form.Select fluid label="Quarter" name="qte_quarter" id="qte_quarter" value={values.qte_quarter || ''} onChange={handleSelectChange} options={options.quarters} placeholder="Quarter" search /> <Form.Select fluid label="State" name="qte_state" id="qte_state" value={values.qte_state || ''} onChange={handleSelectChange} options={options.states} placeholder="State" search /> <Form.Select fluid label="QTE Step" name="qte_step" id="qte_step" value={values.qte_step || ''} onChange={handleSelectChange} options={options.steps} placeholder="QTE step" search /> </> ); } export default observer(ExtraFilterFields); // Code from file collective-frontend/src/modules/Hub/QuarterlyTaxEstimates/Home/MemberListActions.jsx import { Link } from 'react-router-dom'; import { useState, useRef } from 'react'; import InputLabel from '@mui/material/InputLabel'; import MenuItem from '@mui/material/MenuItem'; import FormHelperText from '@mui/material/FormHelperText'; import FormControl from '@mui/material/FormControl'; import Select from '@mui/material/Select'; import IconButton from '@mui/material/IconButton'; import Tooltip from '@mui/material/Tooltip'; import Flag from '@mui/icons-material/Flag'; import ThumbUp from '@mui/icons-material/ThumbUp'; import Cancel from '@mui/icons-material/Cancel'; import Preview from '@mui/icons-material/Preview'; import Sync from '@mui/icons-material/Sync'; import Check from '@mui/icons-material/Check'; import Undo from '@mui/icons-material/Undo'; import useHubStore from 'modules/Hub/common/Store/useHubStore'; const ToolTipIconButton = ({ title, iconComponent, toLink = null, onClick = null }) => ( <Tooltip title={title} arrow> {toLink ? ( <Link target="_blank" to={toLink}> <IconButton>{iconComponent}</IconButton> </Link> ) : ( <IconButton onClick={onClick}>{iconComponent}</IconButton> )} </Tooltip> ); const ReasonSelector = ({ choiceType, onChange, value }) => { const reasonChoices = { Cancel: [ { value: 'offboarded', label: 'Member Off-Boarded' }, { value: 'manual_qte', label: 'Requires Manual' }, { value: 'not_required', label: 'Not Required' }, { value: 'other', label: 'Other' }, ], }; return ( <FormControl variant="outlined" size="small"> <InputLabel id="reason-helper-label">Reason</InputLabel> <Select labelId="reason-helper-label" id="reason-helper" value={value} label="Reason" onChange={onChange}> {reasonChoices[choiceType].map((item) => ( <MenuItem value={item.value} key={item.value}> {item.label} </MenuItem> ))} </Select> <FormHelperText>Select a reason</FormHelperText> </FormControl> ); }; function MemberListActions({ member }) { const hubStore = useHubStore(); const [confirmation, setConfirmation] = useState(''); const reasonRef = useRef(''); const handleConfirm = async () => { await hubStore.patchWorkflowStatusQTE( member.workflow_id, member.task_id, member.current_step, confirmation.toLowerCase(), reasonRef.current.toLowerCase() ); setConfirmation(''); reasonRef.current = ''; hubStore.popMemberByID(member.id); }; const actionButtonDetails = { approve: { title: 'Approve QTE', iconComponent: <ThumbUp fontSize="large" color="success" />, onClick: () => setConfirmation('Approve'), }, flag: { title: 'Flag QTE', iconComponent: <Flag fontSize="large" color="warning" />, onClick: () => setConfirmation('Flag'), }, restart: { title: 'Restart QTE', iconComponent: <Sync fontSize="large" color="info" />, onClick: () => setConfirmation('Restart'), }, cancel: { title: 'Cancel QTE', iconComponent: <Cancel fontSize="large" color="error" />, onClick: () => setConfirmation('Cancel'), }, detail: { title: 'View Details', iconComponent: <Preview fontSize="large" />, toLink: `/hub/qte/${member.workflow_id}`, }, goBack: { title: 'Go Back', iconComponent: <Undo fontSize="Large" />, onClick: () => setConfirmation(''), }, confirm: { title: `Confirm ${confirmation} QTE`, iconComponent: <Check fontSize="Large" color="success" />, onClick: handleConfirm, }, }; const handleReasonSelection = (e) => { reasonRef.current = e.target.value; handleConfirm(); }; const actionButtonGroup = { review: ( <> <ToolTipIconButton {...actionButtonDetails.approve} /> <ToolTipIconButton {...actionButtonDetails.flag} /> </> ), flag: ( <> <ToolTipIconButton {...actionButtonDetails.restart} /> <ToolTipIconButton {...actionButtonDetails.cancel} /> </> ), }; if (!confirmation) { return ( <> {actionButtonGroup[member.current_step]} <ToolTipIconButton {...actionButtonDetails.detail} /> </> ); } return ( <> {confirmation === 'Cancel' ? ( <ReasonSelector choiceType={confirmation} onChange={handleReasonSelection} value={reasonRef.current} /> ) : ( <> {`${confirmation} QTE? `} <ToolTipIconButton {...actionButtonDetails.confirm} /> </> )} <ToolTipIconButton {...actionButtonDetails.goBack} /> </> ); } export default MemberListActions; // Code from file collective-frontend/src/modules/Hub/QuarterlyTaxEstimates/Home/AddMemberModal/index.js export { default } from './AddMemberModal'; // Code from file collective-frontend/src/modules/Hub/QuarterlyTaxEstimates/Home/AddMemberModal/AddMemberModal.jsx import { useEffect, useRef, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import Paper from '@mui/material/Paper'; import Modal from '@mui/material/Modal'; import TextField from '@mui/material/TextField'; import Autocomplete from '@mui/material/Autocomplete'; import CircularProgress from '@mui/material/CircularProgress'; import LoadingButton from '@mui/lab/LoadingButton'; import useDebounceValue from 'modules/common/useDebounceValue'; import collectiveApi from 'modules/common/collectiveApi'; const searchUsers = async (term) => { const { // our api has a great interface data: { data }, } = await collectiveApi.get(`searchclients/?q=${term}`); return data; }; const AddMemberModal = ({ open, onClose }) => { const [searchTerm, setSearchTerm] = useState(''); const [selection, setSelection] = useState(''); const debouncedSearchTerm = useDebounceValue(searchTerm); const buttonRef = useRef(); const queryClient = useQueryClient(); const { refetch, isFetching, data } = useQuery(['memberSearch'], () => searchUsers(debouncedSearchTerm), { enabled: false, // should only fetch on search input change, not on mount }); const submitUser = useMutation(async ({ email, id }) => { const results = await collectiveApi.post('/workflows/qte/', { id }); return results?.data; }); useEffect(() => { // debounce the search request if (debouncedSearchTerm.length) { refetch(); } }, [debouncedSearchTerm]); useEffect(() => { // enables focusing submit button until after selection is set, wherebyunto, the button will be undisabled // the ref kinda freaks out if you focus a disabled button, wont trigger the button onClick if (buttonRef.current) { buttonRef.current.focusVisible(); } }, [selection]); const handleClose = () => { onClose(); // cleanup for repeatedly opening modal and adding users submitUser.reset(); setSelection(''); queryClient.resetQueries('memberSearch'); }; useEffect(handleClose, [submitUser.isSuccess]); return ( <Modal open={open} onClose={handleClose}> <Paper className="tw-absolute tw-p-4 -tw-translate-x-1/2 -tw-translate-y-1/2 tw-w-80 tw-left-1/2 tw-top-1/2"> <div className="tw-flex tw-flex-row tw-items-center tw-justify-between tw-mb-3"> <Autocomplete fullWidth loading={isFetching} options={ data?.map(({ fullname, email, id }) => ({ label: fullname, email, id, })) || [] } getOptionLabel={(option) => `${option.label} - ${option.email}`} filterOptions={(o) => o} blurOnSelect autoHighlight renderInput={(params) => <TextField {...params} color="info" autoFocus label="Member Search" />} onInputChange={(_, value, reason) => { if (reason === 'input') { setSearchTerm(value); } }} onChange={(_, value) => setSelection(value)} isOptionEqualToValue={(a, b) => a.id === b.id} /> <div className={`tw-ml-3 ${isFetching ? 'tw-opacity-100' : 'tw-opacity-0'}`}> <CircularProgress /> </div> </div> <LoadingButton fullWidth disabled={!selection} loading={submitUser.isLoading} variant="contained" action={buttonRef} size="large" onClick={() => submitUser.mutate(selection)}> Add Member </LoadingButton> </Paper> </Modal> ); }; export default AddMemberModal; // Code from file collective-frontend/src/modules/Hub/QuarterlyTaxEstimates/Home/AddMemberModal/AddMemberModal.test.js import { render } from '@testing-library/react'; import { Route, MemoryRouter } from 'react-router-dom'; import { QueryClientProvider, QueryClient } from '@tanstack/react-query'; import AddMemberModal from './AddMemberModal'; const Wrapper = ({ children, routeDefinition = '', initialRoute = '' }) => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, }, }, }); return ( <MemoryRouter initialEntries={[initialRoute]}> <QueryClientProvider client={queryClient}> <Route exact path={routeDefinition || initialRoute}> {children} </Route> </QueryClientProvider> </MemoryRouter> ); }; describe('AddMemberModal', () => { it('should render successfully', () => { const { baseElement } = render( <Wrapper routeDefinition="/hub/QTE/:workflowId" initialRoute="/hub/QTE/abc-123"> <AddMemberModal open onClose={jest.fn()} /> </Wrapper> ); expect(baseElement).toMatchSnapshot(); }); }); // Code from file collective-frontend/src/modules/Hub/QuarterlyTaxEstimates/TrackingTool/TrackingTool.jsx import { useEffect, useState } from 'react'; import { observer } from 'mobx-react'; import { Loader, Divider, Accordion, Icon } from 'semantic-ui-react'; import { isEmpty } from 'lodash'; import classes from './TrackingTool.module.less'; import FilterForm from './FilterForm'; import MemberList from './List'; import useHubStore from 'modules/Hub/common/Store/useHubStore'; function TrackingTool({ accordianOpen = true, memberTableCells, ExtraFilterFields, MemberListActions, Milestones, fetchMemberFunc, initialValues = {}, }) { const hubStore = useHubStore(); const [loading, setLoading] = useState(false); const [showAccordian, setShowAccordian] = useState(accordianOpen); const setAccordian = () => { setShowAccordian(!showAccordian); }; useEffect(() => { hubStore.setMembers([]); const fetchOnPageLoad = async () => { await handleSubmit(initialValues); setLoading(false); }; if (!isEmpty(initialValues)) { setLoading(true); fetchOnPageLoad(); } }, [initialValues]); const handleReset = async () => { hubStore.setFilter({}); hubStore.setMembers([]); await hubStore.fetchMembersOnboarding(); }; const handleSubmit = async (values) => { hubStore.setFilter(values); await fetchMemberFunc(values); }; if (loading) { return ( <Loader inline="centered" active> Loading </Loader> ); } return ( <div className={classes.trackerTool}> {Milestones && <Milestones />} <Divider /> <Accordion fluid> <Accordion.Title active={showAccordian === true} onClick={setAccordian} as="h3" className="accordianTitle"> <Icon name="dropdown" /> Search Members </Accordion.Title> <Accordion.Content active={showAccordian === true}> <FilterForm onSubmit={handleSubmit} onReset={handleReset} ExtraFilterFields={ExtraFilterFields} /> </Accordion.Content> </Accordion> <Divider /> <MemberList tableCells={memberTableCells} MemberListActions={MemberListActions} fetchMemberFunc={fetchMemberFunc} /> </div> ); } export default observer(TrackingTool); // Code from file collective-frontend/src/modules/Hub/QuarterlyTaxEstimates/TrackingTool/List/List.jsx import { observer } from 'mobx-react'; import { useEffect, useState } from 'react'; import { Icon, Loader, Menu, Segment, Table } from 'semantic-ui-react'; import Tags from '../../Tags'; import classes from './List.module.less'; import useHubStore from 'modules/Hub/common/Store/useHubStore'; const MemberList = ({ tableCells, MemberListActions, fetchMemberFunc }) => { const hubStore = useHubStore(); const [hasData, setHasData] = useState(false); const [page, setPage] = useState(1); const [sortColumn, setSortColumn] = useState('businessname'); const [sortDirection, setSortDirection] = useState('ascending'); useEffect(() => { setHasData(hubStore.members.length > 0); }, [hubStore, hubStore.members]); useEffect(() => { const { filter } = hubStore; fetchMemberFunc(filter, page, sortColumn, sortDirection); }, [page, sortColumn, sortDirection]); const changeSort = (column, direction) => { let updatedSortDirection = direction === 'ascending' ? 'descending' : 'ascending'; if (column !== sortColumn) { updatedSortDirection = 'ascending'; } setSortColumn(column); setSortDirection(updatedSortDirection); }; const isSortableColumn = (currentColumn) => { return ['businessname', 'fullname', 'email', 'status', 'usertype'].includes(currentColumn); }; if (hubStore.isMembersLoading) { return ( <Segment padded="very" textAlign="center" className={classes.empty}> <div className="content"> <Loader active>Loading</Loader> </div> </Segment> ); } if (!hasData) { return ( <Segment padded="very" textAlign="center" className={classes.empty}> <div className="content">No data available.</div> </Segment> ); } return ( <Table sortable striped celled basic="very" className={classes.list}> <Table.Header> <Table.Row> {tableCells.map((cell) => ( <Table.HeaderCell key={cell.data} sorted={isSortableColumn(cell.data) && cell.data === sortColumn ? sortDirection : null} onClick={() => isSortableColumn(cell.data) && changeSort(cell.data, sortDirection)}> {cell.label} </Table.HeaderCell> ))} <Table.HeaderCell textAlign="center" width={2}> Actions </Table.HeaderCell> </Table.Row> </Table.Header> <Table.Body> {hubStore.members.map((member) => ( <Table.Row key={member.id}> {tableCells.map((cell) => { return ( <Table.Cell key={`${member.id || member.email}__${cell.data}`}> {['Completed', 'Incomplete', 'Not Started', 'review', 'flag', 'fetch'].includes( member[cell.data] ) ? ( <Tags status={member[cell.data].toLowerCase()} single> {member[cell.data]} </Tags> ) : ( member[cell.data] )} </Table.Cell> ); })} <Table.Cell collapsing textAlign="center" key={`${member.id}__actions`}> {MemberListActions && <MemberListActions member={member} />} </Table.Cell> </Table.Row> ))} </Table.Body> <Table.Footer> <Table.Row> <Table.HeaderCell colSpan={tableCells.length + 1}> <Menu pagination> <Menu.Item as="span" icon> Total number of members: {hubStore.membersPagination.total_record_count} </Menu.Item> </Menu> <Menu floated="right" pagination> <Menu.Item as="span" icon> {hubStore.membersPagination.number_of_pages} </Menu.Item> <Menu.Item as="a" icon disabled={!hubStore.membersPagination.previous_page} onClick={() => setPage(hubStore.membersPagination.previous_page)}> <Icon name="chevron left" /> </Menu.Item> <Menu.Item as="a" icon disabled={!hubStore.membersPagination.next_page} onClick={() => setPage(hubStore.membersPagination.next_page)}> <Icon name="chevron right" /> </Menu.Item> </Menu> </Table.HeaderCell> </Table.Row> </Table.Footer> </Table> ); }; export default observer(MemberList); // Code from file collective-frontend/src/modules/Hub/QuarterlyTaxEstimates/TrackingTool/List/index.js export { default } from './List'; // Code from file collective-frontend/src/modules/Hub/QuarterlyTaxEstimates/TrackingTool/List/List.module.less [class~='ui'][class~='table'].list { background: #faf9f6; padding: 20px; thead { font-size: 10px; text-transform: uppercase; tr { th { border: 0; border-radius: 0; box-shadow: none; text-transform: uppercase; color: rgba(0, 0, 0, 0.87); opacity: 0.5; border-bottom: 1px solid rgba(34, 36, 38, 0.1); border-left: transparent !important; } } } tbody { tr { background-color: #ffffff; td { a { color: rgba(0, 0, 0, 0.57); text-decoration: none; transition: all 0.2s linear; &:hover, &:active, &:focus { color: rgba(0, 0, 0, 0.87); } } [class~='ui'][class~='button'] { width: 54px; // color: rgba(34, 36, 38, 0.8); // border: 1px solid rgba(34, 36, 38, 0.2); // background-color: #ffffff; transition: all 0.2s linear; &:hover, &:active, &:focus { color: rgba(34, 36, 38, 0.9); background-color: #faf9f6; } } } } } tfoot { tr { th { padding: 10px 0; [class~='ui'][class~='pagination'][class~='menu'] { &:first-child { border: 0; } a:hover, a:active, a:focus { color: #000000; } } } } } } [class~='ui'][class~='segment'].empty { background: #faf9f6; [class~='content'] { padding: 40px; color: rgba(34, 36, 38, 0.8); opacity: 0.5; } } // Code from file collective-frontend/src/modules/Hub/QuarterlyTaxEstimates/TrackingTool/List/List.test.jsx import { act, render } from '@testing-library/react'; import { createMemoryHistory } from 'history'; import { action, makeObservable, observable } from 'mobx'; import { Router } from 'react-router-dom'; import HubStoreContext from '../../../common/Store/Context'; import List from './List'; class MockHubStore { members = []; membersPagination = {}; constructor() { makeObservable(this, { members: observable, membersPagination: observable, setMembers: action, setMembersPagination: action, }); } setMembers = (members) => { this.members = members; }; setMembersPagination = (membersPagination) => { this.membersPagination = membersPagination; }; } const renderWithProviders = (hubStore) => { const history = createMemoryHistory(); const tableCells = [ { data: 'businessname', label: 'Business Name' }, { data: 'fullname', label: 'Full Name' }, { data: 'email', label: 'Email Address' }, { data: 'onboarding_duration', label: 'Onboarding Duration' }, { data: 'legalSetup', label: 'Legal Setup' }, { data: 'financialSetup', label: 'Financial Setup' }, { data: 'technologySetup', label: 'Technology Setup' }, ]; return render( <HubStoreContext.Provider value={hubStore}> <Router history={history}> <List tableCells={tableCells} fetchMemberFunc={() => {}} /> </Router> </HubStoreContext.Provider> ); }; describe('TrackerTool/List', () => { it('should render correctly', () => { const hubStore = new MockHubStore(); const { asFragment } = renderWithProviders(hubStore); act(() => { hubStore.setMembers([ { email: 'test@test.com', days: 13, legalSetup: 'incomplete', financialSetup: 'incomplete', technologySetup: 'incomplete', }, ]); hubStore.setMembersPagination({ total_record_count: 0, number_of_pages: '', next_page: null, previous_page: null, }); }); expect(asFragment().firstChild).toMatchSnapshot(); }); it('Should display empty data table', () => { const hubStore = new MockHubStore(); const { getByText } = renderWithProviders(hubStore); expect(getByText(/No data available/i)).toBeInTheDocument(); }); it('Should display populated data table', () => { const hubStore = new MockHubStore(); const { getByText } = renderWithProviders(hubStore); expect(getByText(/No data available/i)).toBeInTheDocument(); act(() => { hubStore.setMembers([ { businessname: 'Test LLC', fullname: 'Test test', email: 'test@test.com', onboarding_duration: 13, legalSetup: 'incomplete', financialSetup: 'incomplete', technologySetup: 'incomplete', }, ]); }); act(() => { hubStore.setMembersPagination({ total_record_count: 0, number_of_pages: '', next_page: null, previous_page: null, }); }); expect(getByText(/test@test.com/i)).toBeInTheDocument(); }); }); // Code from file collective-frontend/src/modules/Hub/QuarterlyTaxEstimates/TrackingTool/index.js export { default } from './TrackingTool'; // Code from file collective-frontend/src/modules/Hub/QuarterlyTaxEstimates/TrackingTool/TrackingTool.module.less .trackerTool { h3 { font-family: 'recoletasemibold' !important; font-size: initial !important; } } // Code from file collective-frontend/src/modules/Hub/QuarterlyTaxEstimates/TrackingTool/FilterForm/index.js export { default } from './FilterForm'; // Code from file collective-frontend/src/modules/Hub/QuarterlyTaxEstimates/TrackingTool/FilterForm/FilterForm.module.less .filter { [class~='ui'][class~='form'] { [class~='ui'][class~='button'] { font-size: 16px; border: 1px solid !important; padding: 18px !important; border-radius: 5px !important; width: 140px; z-index: 0; &[class~='secondary'] { color: #fa5a60; border-color: lighten(#fa5a60, 0.3); &:hover { background-color: #ffffff; } &[class~='disabled'] { opacity: 0.3 !important; color: rgba(0, 0, 0, 0.5) !important; border-color: rgba(0, 0, 0, 0.2) !important; } } &[class~='disabled'] { opacity: 0.3 !important; } } [class~='fields'] { margin: 0em -0.5em 1em; [class~='dropdown'] { padding: 8px 6px; [class~='text'] { font-size: 1.286em; line-height: 1.30769231em; } [class~='item'] { font-size: 1em; line-height: 1em; } } [class~='field'] { [class~='text'] { color: #000; font-weight: normal; } [class~='default'] { opacity: 0.3; } label { font-family: 'mier_bregular'; font-weight: normal; opacity: 0.7; } input { font-size: 1.286em; line-height: 1.30769231em; padding: 8px 6px; } input::placeholder { opacity: 0.2; } input, input::placeholder { font-family: 'mier_bregular'; box-shadow: none; color: #000; font-weight: normal; } } } } } // Code from file collective-frontend/src/modules/Hub/QuarterlyTaxEstimates/TrackingTool/FilterForm/FilterForm.test.jsx import { cleanup, render, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { createMemoryHistory } from 'history'; import { makeObservable, observable } from 'mobx'; import { Router } from 'react-router-dom'; import HubStoreContext from '../../../common/Store/Context'; import Filter from './FilterForm'; class MockHubStore { filter = {}; memberRelationshipManagers = [{ key: 1, text: 'test', value: 'test' }]; onboardingAccountants = [{ key: 1, text: 'test', value: 'test' }]; taxpro = [{ key: 1, text: 'test', value: 'test' }]; pops = [{ key: 1, text: 'test', value: 'test' }]; constructor() { makeObservable(this, { filter: observable, memberRelationshipManagers: observable, onboardingAccountants: observable, taxpro: observable, pops: observable, }); } } const handleSubmit = jest.fn(); const handleReset = jest.fn(); const renderWithProviders = (hubStore) => { const history = createMemoryHistory(); return render( <HubStoreContext.Provider value={hubStore}> <Router history={history}> <Filter filter={{}} onSubmit={handleSubmit} onReset={handleReset} /> </Router> </HubStoreContext.Provider> ); }; beforeEach(cleanup); describe('TrackerTool/Filter', () => { it('should render correctly', () => { const hubStore = new MockHubStore(); const { asFragment } = renderWithProviders(hubStore); expect(asFragment().firstChild).toMatchSnapshot(); }); it('Should call the function handleSubmit once', async () => { const hubStore = new MockHubStore(); const user = userEvent.setup(); const { getByLabelText, getByRole } = renderWithProviders(hubStore); await user.type(getByLabelText(/Email Address/i), 'test@test.com'); await user.click(getByRole('button', { name: /Search/i })); await waitFor(() => expect(handleSubmit).toBeCalledTimes(1)); }); it('Should call the function handleReset once', async () => { const hubStore = new MockHubStore(); const user = userEvent.setup(); const { getByLabelText, getByRole } = renderWithProviders(hubStore); await user.type(getByLabelText(/Email Address/i), 'test@test.com'); await user.click(getByRole('button', { name: /Clear/i })); await waitFor(() => expect(handleSubmit).toBeCalledTimes(0)); await waitFor(() => expect(handleReset).toBeCalledTimes(1)); await waitFor(() => expect(getByLabelText(/Email Address/i)).not.toHaveValue('test@test.com')); }); }); // Code from file collective-frontend/src/modules/Hub/QuarterlyTaxEstimates/TrackingTool/FilterForm/FilterForm.jsx import { useFormik } from 'formik'; import { Form, Icon } from 'semantic-ui-react'; import { observer } from 'mobx-react'; import { isEmpty } from 'lodash'; import { useEffect } from 'react'; import { LoadingButton as Button } from '@mui/lab'; import classes from './FilterForm.module.less'; import useHubStore from 'modules/Hub/common/Store/useHubStore'; const FilterForm = ({ onReset, onSubmit, ExtraFilterFields }) => { const hubStore = useHubStore(); const options = [ { key: 'completed', text: 'Completed', value: 'completed' }, { key: 'incomplete', text: 'Incomplete', value: 'incomplete' }, ]; const { values, dirty, isSubmitting, handleSubmit, handleChange, resetForm, setValues, setFieldValue } = useFormik({ initialValues: { ...hubStore.filter }, onSubmit, onReset, }); useEffect(() => { setValues(hubStore.filter); }, [hubStore, hubStore.filter, setValues]); const handleSelectChange = (event, { value, name }) => { setFieldValue(name, value); }; const handleReset = () => { resetForm({ values: {} }); }; return ( <div className={classes.filter}> <Form> {ExtraFilterFields && ( <Form.Group widths="equal"> <ExtraFilterFields values={values} handleSelectChange={handleSelectChange} /> </Form.Group> )} <Form.Group widths="equal"> <Form.Input fluid name="member_businessname" id="member_businessname" value={values.member_businessname || ''} onChange={handleChange} label="Business Name" placeholder="Business Name" /> <Form.Input fluid name="member_fullname" id="member_fullname" value={values.member_fullname || ''} onChange={handleChange} label="Full Name" placeholder="Full Name" /> <Form.Input fluid name="member_email" id="member_email" value={values.member_email || ''} onChange={handleChange} label="Email Address" placeholder="Email Address" /> <Form.Select fluid label="Activity Status" name="activity_status" id="activity_status" value={values.activity_status || ''} options={options} onChange={handleSelectChange} placeholder="Activity Status" search /> </Form.Group> <div className="tw-flex tw-items-center"> <Button variant="contained" type="submit" className="tw-mr-2" onClick={handleSubmit} disabled={isSubmitting || (!dirty && isEmpty(hubStore.filter))} loading={isSubmitting}> <Icon name="search" /> Search </Button> <Button variant="outlined" type="reset" onClick={handleReset} disabled={isSubmitting || (!dirty && isEmpty(hubStore.filter))}> <Icon name="eraser" /> Clear </Button> </div> </Form> </div> ); }; export default observer(FilterForm); // Code from file collective-frontend/src/modules/Hub/QuarterlyTaxEstimates/TrackingTool/TrackingTool.test.jsx import { makeObservable, observable, action } from 'mobx'; import { act, render, waitFor } from '@testing-library/react'; import { Router } from 'react-router-dom'; import { createMemoryHistory } from 'history'; import HubStoreContext from '../../common/Store/Context'; import TrackingTool from './TrackingTool'; class MockHubStore { filter = {}; milestones = []; memberRelationshipManagers = []; onboardingAccountants = []; members = []; constructor() { makeObservable(this, { filter: observable, milestones: observable, memberRelationshipManagers: observable, onboardingAccountants: observable, members: observable, fetchMilestones: action, fetchMrmsAndOnbs: action, setFilter: action, setMembers: action, setMilestones: action, setMemberRelationshipManagers: action, setOnboardingAccountants: action, }); } fetchMilestones = () => jest.fn(); fetchMrmsAndOnbs = () => jest.fn(); setFilter = () => jest.fn(); setMembers = () => jest.fn(); setMilestones = (milestones) => { this.milestones = milestones; }; setMemberRelationshipManagers = (memberRelationshipManagers) => { this.memberRelationshipManagers = memberRelationshipManagers; }; setOnboardingAccountants = (onboardingAccountants) => { this.onboardingAccountants = onboardingAccountants; }; } const milestones = [ { name: 'Legal Setup', status: 'incompleted', members: 144, days: 44.5, }, ]; const mrmsAndOnbs = [{ key: 1, text: 'test', value: 'test' }]; const renderWithProviders = (hubStore) => { const history = createMemoryHistory(); return render( <HubStoreContext.Provider value={hubStore}> <Router history={history}> <TrackingTool fetchMemberFunc={() => {}} /> </Router> </HubStoreContext.Provider> ); }; describe('TrackerTool', () => { it('should render correctly', async () => { const hubStore = new MockHubStore(); const { asFragment } = renderWithProviders(hubStore); await waitFor(() => hubStore.fetchMilestones().mockResolvedValue(null)); await waitFor(() => hubStore.fetchMrmsAndOnbs().mockResolvedValue(null)); act(() => { hubStore.setMilestones(milestones); hubStore.setMemberRelationshipManagers(mrmsAndOnbs); hubStore.setOnboardingAccountants(mrmsAndOnbs); }); expect(asFragment().firstChild).toMatchSnapshot(); }); it('should render the component', async () => { const hubStore = new MockHubStore(); const { getByText } = renderWithProviders(hubStore); await waitFor(() => hubStore.fetchMilestones().mockResolvedValue(null)); await waitFor(() => hubStore.fetchMrmsAndOnbs().mockResolvedValue(null)); act(() => { hubStore.setMilestones(milestones); hubStore.setMemberRelationshipManagers(mrmsAndOnbs); hubStore.setOnboardingAccountants(mrmsAndOnbs); }); expect(getByText(/Search Members/i)).toBeInTheDocument(); }); }); // Code from file collective-frontend/src/modules/Hub/TaxReturnSignatureRequests/requests.js import collectiveApi from 'modules/common/collectiveApi'; export const getSignatureRequestByMemberId = async ({ signal, queryKey: [_key, { memberId: member_id }] }) => { const resp = await collectiveApi.get('tax_organizer/signature-requests/', { signal, params: { member_id }, }); return resp.data; }; export const getSignatureRequest = async ({ signal, queryKey: [_key, { requestId }] }) => { const resp = await collectiveApi.get(`tax_organizer/signature-requests/${requestId}/`, { signal }); return resp.data; }; export const getMember = async ({ signal, queryKey: [_key, { memberId }] }) => { const resp = await collectiveApi.get(`members/${memberId}/`, { signal }); return resp.data; }; export const downloadFile = ({ signal, queryKey: [_key, { fragment }] }) => { return collectiveApi.get(new URL(fragment, process.env.REACT_APP_DJANGO_URL), { signal, responseType: 'blob', }); }; // Code from file collective-frontend/src/modules/Hub/TaxReturnSignatureRequests/TaxReturnSignatureRequestDetail.jsx import { IconButton, Typography, Skeleton, Paper, Chip, Avatar } from '@mui/material'; import { useQuery } from '@tanstack/react-query'; import { useParams } from 'react-router-dom'; import { useState } from 'react'; import { LoadingButton } from '@mui/lab'; import ContentCopyIcon from '@mui/icons-material/ContentCopy'; import { getSignatureRequest, getMember, downloadFile } from './requests'; import states from 'modules/common/constants/states'; const taxTypeMapping = { ITR: 'Individual Tax Return', BTR: 'Business Tax Return', }; const TaxReturnSignatureRequestDetail = () => { const { requestId } = useParams(); const [downloadUrl, setDownloadUrl] = useState(null); const { data: signatureRequest } = useQuery(['signatureRequests', { requestId }], getSignatureRequest); const { isLoading: memberLoading, data: member } = useQuery( ['signatureRequests', { memberId: signatureRequest?.member_id }], getMember, { enabled: Boolean(signatureRequest?.member_id) } ); const { isFetching: documentFetching } = useQuery( [ 'downloadFile', { fragment: downloadUrl, }, ], downloadFile, { enabled: Boolean(downloadUrl), onSettled: () => setDownloadUrl(), onSuccess: (data) => { // use browser api to host data blob as if it was a hosted resource // open tab with resource url // kill resource url, we dont want to leave resources in memory after the fact const href = URL.createObjectURL(data.data); const openReference = window.open(); openReference.location.href = href; setTimeout(() => URL.revokeObjectURL(href), 1000); }, } ); return ( <div className="tw-max-w-full tw-min-w-min tw-m-auto"> <div className="tw-mb-4"> <Typography variant="h1">{memberLoading ? <Skeleton width="200px" /> : member?.fullname}</Typography> <Typography>{memberLoading ? <Skeleton width="200px" /> : member?.businessname}</Typography> <div className="tw-flex tw-gap-0.5 tw-items-center"> <Typography>{memberLoading ? <Skeleton width="200px" /> : member?.email}</Typography>{' '} <IconButton onClick={() => navigator.clipboard.writeText(member?.email)} size="small" aria-label="copy email" color="black"> <ContentCopyIcon color="black" fontSize="inherit" /> </IconButton> </div> </div> <Paper elevation={3} className="tw-p-4 child:tw-mb-2"> <Typography> {signatureRequest?.tax_year} {taxTypeMapping[signatureRequest?.tax_return_type]} </Typography> <div className="tw-flex tw-gap-x-1"> {signatureRequest?.states.map((s) => s === 'US' ? ( <Chip key={s} avatar={<Avatar>{s}</Avatar>} label="Federal" /> ) : ( <Chip key={s} avatar={<Avatar>{s}</Avatar>} label={states[s]} /> ) )} </div> <div className="tw-flex tw-gap-2 child:tw-basis-1/2"> <LoadingButton size="large" loading={documentFetching && downloadUrl === signatureRequest?.original_document_url} type="button" onClick={() => setDownloadUrl(signatureRequest.original_document_url)}> View original document </LoadingButton> <LoadingButton size="large" color="success" disabled={!signatureRequest?.signed_document_url} loading={documentFetching && downloadUrl === signatureRequest?.signed_document_url} type="button" onClick={() => setDownloadUrl(signatureRequest.signed_document_url)}> View signed document </LoadingButton> </div> </Paper> </div> ); }; export default TaxReturnSignatureRequestDetail; // Code from file collective-frontend/src/modules/Hub/TaxReturnSignatureRequests/TaxReturnSignatureRequestDetail.test.js import { render, screen } from '@testing-library/react'; import { QueryClientProvider, QueryClient } from '@tanstack/react-query'; import MockAdapter from 'axios-mock-adapter'; import { Route, MemoryRouter } from 'react-router-dom'; import { ThemeProvider } from '@mui/material'; import TaxReturnSignatureRequestDetail from './TaxReturnSignatureRequestDetail'; import theme from 'theme'; import collectiveApi from 'modules/common/collectiveApi'; const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, }, }, }); it('should render successfully', async () => { const sigReqId = 'abc-123'; const httpMock = new MockAdapter(collectiveApi); httpMock.onGet(`tax_organizer/signature-requests/${sigReqId}/`).reply(200, { member_id: 1, tax_year: 2022, tax_return_type: 'BTR', states: ['US', 'CA'], original_document_url: '/incompleteDocLink', signed_document_url: '/completeDocLink', }); httpMock .onGet(`members/1/`) .reply(200, { fullname: 'Bob Johansson', email: 'bob@bobsfishmart.com', businessname: 'Bobs Fish Mart' }); const { asFragment } = render( <MemoryRouter initialEntries={[`/hub/tax-return-signature-requests/${sigReqId}`]}> <QueryClientProvider client={queryClient}> <ThemeProvider theme={theme}> <Route exact path="/hub/tax-return-signature-requests/:requestId" component={TaxReturnSignatureRequestDetail} /> </ThemeProvider> </QueryClientProvider> </MemoryRouter> ); expect(await screen.findByText('Bob Johansson')).toBeInTheDocument(); expect(httpMock.history.get.length).toBe(2); expect(asFragment()).toMatchSnapshot(); }); // Code from file collective-frontend/src/modules/Hub/TaxReturnSignatureRequests/TaxReturnSignatureRequestSearch.test.js import { render, screen } from '@testing-library/react'; import { QueryClientProvider, QueryClient } from '@tanstack/react-query'; import { Route, MemoryRouter } from 'react-router-dom'; import { ThemeProvider } from '@mui/material'; import TaxReturnSignatureRequestSearch from './TaxReturnSignatureRequestSearch'; import theme from 'theme'; const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, }, }, }); describe('TaxReturnSignatureRequestSearch', () => { it('should render successfully', async () => { const sigReqId = 'abc-123'; const { asFragment } = render( <MemoryRouter initialEntries={[`/hub/tax-return-signature-requests/`]}> <QueryClientProvider client={queryClient}> <ThemeProvider theme={theme}> <Route exact path="/hub/tax-return-signature-requests/" component={TaxReturnSignatureRequestSearch} /> </ThemeProvider> </QueryClientProvider> </MemoryRouter> ); expect(asFragment()).toMatchSnapshot(); }); it('should render successfully with a search query', async () => { const { asFragment } = render( <MemoryRouter initialEntries={[`/hub/tax-return-signature-requests`]}> <QueryClientProvider client={queryClient}> <ThemeProvider theme={theme}> <Route exact path="/hub/tax-return-signature-requests"> <TaxReturnSignatureRequestSearch memberId={1} haveSignatureRequest={false} /> </Route> </ThemeProvider> </QueryClientProvider> </MemoryRouter> ); expect(asFragment()).toMatchSnapshot(); expect(await screen.findByText('No signature request found for this member')).toBeInTheDocument(); }); }); // Code from file collective-frontend/src/modules/Hub/TaxReturnSignatureRequests/TaxReturnSignatureRequestSearch.jsx import PropTypes from 'prop-types'; import { Button, Typography, Alert } from '@mui/material'; import { Formik, Form } from 'formik'; import { useQuery } from '@tanstack/react-query'; import { useHistory } from 'react-router-dom'; import { useState } from 'react'; import MemberSearch from '../TaxReturnUploader/MemberSearch'; import { getSignatureRequestByMemberId } from './requests'; const TaxReturnSignatureRequestSearch = (props) => { const history = useHistory(); const [memberId, setMemberId] = useState(props.memberId || null); const [haveSignatureRequest, setHaveSignatureRequest] = useState(props.haveSignatureRequest); useQuery(['signatureRequests', { memberId }], getSignatureRequestByMemberId, { enabled: Boolean(memberId), onSuccess: (data) => { if (data?.results?.at(0)?.uuid) { history.push(`/hub/tax-return-signature-requests/${data?.results[0].uuid}`); return; } setHaveSignatureRequest(false); }, }); return ( <div className="tw-max-w-full tw-min-w-min tw-m-auto"> <div className="tw-flex tw-justify-between tw-items-start"> <Typography className="tw-mb-4" variant="h1"> Tax Return Signature Requests </Typography> <Button onClick={() => history.push('/hub/tax-return-signature-requests/create')}>Create</Button> </div> {/* I know I'm not using the form, MemberSearch is wired for formik, just go with it */} <Formik initialValues={{ member: null }}> {() => ( <Form className="tw-flex tw-flex-col child:tw-mb-2"> <MemberSearch data-testid="member-search-input" name="member" onChange={(value) => setMemberId(value?.id)} /> {haveSignatureRequest === false && memberId && ( <Alert severity="info">No signature request found for this member</Alert> )} </Form> )} </Formik> </div> ); }; TaxReturnSignatureRequestSearch.propTypes = { memberId: PropTypes.number, haveSignatureRequest: PropTypes.bool, }; export default TaxReturnSignatureRequestSearch; // Code from file collective-frontend/src/modules/Hub/PayrollSetup/Page.js import { useCallback, useEffect, useState } from 'react'; import { useParams } from 'react-router-dom'; import TransitionNavBar from '../common/TransitionNavBar'; import useRootStore from 'modules/common/stores/Root/useRootStore'; import PayrollSetup from 'modules/Dashboard/Payroll/Setup/Page'; const PayrollSetupPage = () => { const { legacyStore } = useRootStore(); const { id } = useParams(); const [memberId, setMemberId] = useState(null); const fetchClientInfo = useCallback(async (sfaccountid) => { try { await legacyStore.getTransitionClientInfo(sfaccountid); setMemberId(legacyStore.transitionClientInfo.member_id); } catch (error) { console.error(error); } }, []); useEffect(() => { fetchClientInfo(id); }, []); if (!memberId) { return null; } return ( <> <TransitionNavBar clientName={legacyStore.transitionClientInfo.fullname} sfid={legacyStore.transitionClientInfo.sf_id} tpid={legacyStore.transitionClientInfo.transition_plan_id} clientCompany={legacyStore.transitionClientInfo.business_name} payrollSetup /> <PayrollSetup memberId={memberId} /> </> ); }; export default PayrollSetupPage; // Code from file collective-frontend/src/modules/Hub/Payments/Page.test.jsx import { render } from '@testing-library/react'; import { QueryClientProvider, QueryClient } from '@tanstack/react-query'; import { Router } from 'react-router-dom'; import MockAxiosAdapter from 'axios-mock-adapter'; import PaymentsPage from './Page'; import browserHistory from 'modules/common/browserHistory'; import RootStoreContext from 'modules/common/stores/Root/Context'; import collectiveApi from 'modules/common/collectiveApi'; const history = browserHistory; const mockCollectiveApi = new MockAxiosAdapter(collectiveApi); describe('Payments Home', () => { it('should render correctly', () => { const legacyStore = { transitionClientInfo: { fullname: '', sf_id: '', transition_plan_id: '', business_name: '', }, transactionInfo: { entity_name: '', }, getSplitTreatmentAttributes: jest.fn(), }; const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, }, }, }); const match = { params: { stripeCustomerId: 1, }, }; const { asFragment } = render( <Router history={history}> <RootStoreContext.Provider value={{ legacyStore }}> <QueryClientProvider client={queryClient}> <PaymentsPage match={match} /> </QueryClientProvider> </RootStoreContext.Provider> </Router> ); expect(asFragment().firstChild).toMatchSnapshot(); }); }); // Code from file collective-frontend/src/modules/Hub/Payments/InvoicesTableRow.jsx import { useEffect, useMemo, useState } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import moment from 'moment'; import { CircularProgress, Typography } from '@mui/material'; import Table from '@mui/material/Table'; import TableBody from '@mui/material/TableBody'; import TableHead from '@mui/material/TableHead'; import TableCell from '@mui/material/TableCell'; import IconButton from '@mui/material/IconButton'; import TableRow from '@mui/material/TableRow'; import Box from '@mui/material/Box'; import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'; import Collapse from '@mui/material/Collapse'; import LinearProgress from '@mui/material/LinearProgress'; import DownloadForOfflineIcon from '@mui/icons-material/DownloadForOffline'; import Link from '@mui/material/Link'; import InvoicesTableRowDetail from './InvoicesTableRowDetail'; import { useCreditNotes } from './query-hooks'; import { moneyWithCommas } from 'modules/common/display'; const InvoicesTableRow = ({ rowData, isStaleCreditNotes, setIsStaleCreditNotes }) => { const [open, setOpen] = useState(false); const { isLoading: creditNotesIsLoading, data: creditNotesData, refetch: refetchCreditNotes, isFetching: creditNotesIsFetching, } = useCreditNotes(rowData.invoice); useEffect(() => { if (rowData.invoice && isStaleCreditNotes) { refetchCreditNotes(); setIsStaleCreditNotes(false); } }, [rowData.invoice, isStaleCreditNotes]); const toggleDetails = () => { if (!creditNotesIsLoading) { setOpen(!open); } }; const lineItems = useMemo(() => { if (Boolean(rowData) && Boolean(creditNotesData)) { return rowData.lines.map((line) => { const creditNote = creditNotesData.find((note) => note.invoice_line_item_unique_id === line.unique_id); return { ...line, ...creditNote, }; }); } return []; }, [rowData, creditNotesData]); const amountCredited = useMemo(() => { if (creditNotesData) { return creditNotesData.reduce((sum, creditNote) => { const updatedSum = sum + creditNote.amount; return updatedSum; }, 0); } return 0; }, [creditNotesData]); return ( <> <TableRow sx={{ '& > *': { borderBottom: 'unset' } }} onClick={toggleDetails}> <TableCell> <IconButton aria-label="expand row" size="large" disabled={creditNotesIsLoading}> {open ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />} </IconButton> </TableCell> <TableCell component="th" scope="row"> <Typography>{moneyWithCommas(rowData.amount / 100)}</Typography> </TableCell> <TableCell align="right"> <Typography> − {rowData.charges ? moneyWithCommas(rowData.charges[0].amount_refunded / 100) : 0} </Typography> </TableCell> <TableCell align="right"> <Typography>− {moneyWithCommas(amountCredited / 100)}</Typography> </TableCell> <TableCell align="right"> <Typography>{rowData.description}</Typography> </TableCell> <TableCell align="right"> <Typography>{moment.unix(rowData.created).format('YYYY-MM-DD')}</Typography> </TableCell> <TableCell align="right"> <Typography>{rowData.status === 'succeeded' ? 'Paid' : 'Not Paid'}</Typography> </TableCell> </TableRow> <TableRow> <TableCell style={{ paddingBottom: 0, paddingTop: 0 }} colSpan={8}> {creditNotesIsLoading ? ( <LinearProgress /> ) : ( <Collapse in={open} timeout="auto" unmountOnExit> <Box className="tw-mt-5"> <Typography className="tw-mb-3" variant="h6" gutterBottom> <div className="tw-flex tw-items-baseline tw-justify-start"> <Typography fontWeight="bold" fontSize={20} className="tw-ml-2"> Invoice #{rowData.number} </Typography> <div className="tw-self-end"> <Link href={rowData.hosted_invoice_url} rel="noopener noreferrer" target="_blank" className="tw-ml-2"> <DownloadForOfflineIcon fontSize="large" /> </Link> </div> </div> </Typography> <Table size="medium" aria-label="purchases"> <TableHead> <TableRow> <TableCell> <Typography fontWeight="bold">Amount</Typography> </TableCell> <TableCell> <Typography fontWeight="bold">Description</Typography> </TableCell> <TableCell align="right"> <Typography fontWeight="bold">Quantity</Typography> </TableCell> <TableCell align="right"> <Typography fontWeight="bold">Status</Typography> </TableCell> </TableRow> </TableHead> <TableBody> {lineItems.map((lineDetail) => ( <InvoicesTableRowDetail key={lineDetail.id} detailData={lineDetail} /> ))} </TableBody> </Table> </Box> </Collapse> )} </TableCell> </TableRow> </> ); }; export default InvoicesTableRow; // Code from file collective-frontend/src/modules/Hub/Payments/query-hooks/useCreateRefundRequest.js import { useMutation, useQueryClient } from '@tanstack/react-query'; import collectiveApi from 'modules/common/collectiveApi'; const postRefundRequest = (postObject) => collectiveApi.post(`/stripe/refund_requests/`, postObject).then((response) => response.data); export default function useCreateRefundRequest(postObject) { const queryClient = useQueryClient(); return useMutation((postObject) => postRefundRequest(postObject), { onSuccess: (data) => queryClient.setQueryData(['refundRequest', data], data), }); } // Code from file collective-frontend/src/modules/Hub/Payments/query-hooks/usePayments.js import { useQuery } from '@tanstack/react-query'; import collectiveApi from 'modules/common/collectiveApi'; const fetchPayments = (stripeCustomerId) => collectiveApi .get(`/stripe/payment_intents/?customer=${stripeCustomerId}&limit=100`) .then((response) => response.data); export default function usePayments(stripeCustomerId) { return useQuery(['payments', stripeCustomerId], () => fetchPayments(stripeCustomerId), { enabled: false, staleTime: Infinity, }); } // Code from file collective-frontend/src/modules/Hub/Payments/query-hooks/useCustomer.js import { useQuery } from '@tanstack/react-query'; import collectiveApi from 'modules/common/collectiveApi'; const fetchCustomer = (stripeCustomerId) => collectiveApi.get(`/stripe/customers/${stripeCustomerId}/`).then((response) => response.data); export default function useCustomer(stripeCustomerId) { return useQuery(['customer', stripeCustomerId], () => fetchCustomer(stripeCustomerId), { enabled: false, staleTime: Infinity, }); } // Code from file collective-frontend/src/modules/Hub/Payments/query-hooks/useCreateCreditRequest.js import { useMutation, useQueryClient } from '@tanstack/react-query'; import collectiveApi from 'modules/common/collectiveApi'; const postCreditRequest = (postObject) => collectiveApi.post(`/stripe/credit_requests/`, postObject).then((response) => response.data); export default function useCreateCreditRequest() { const queryClient = useQueryClient(); return useMutation((postObject) => postCreditRequest(postObject), { onSuccess: (data) => queryClient.setQueryData(['creditRequest', data], data), }); } // Code from file collective-frontend/src/modules/Hub/Payments/query-hooks/useSubscriptions.js import { useQuery } from '@tanstack/react-query'; import collectiveApi from 'modules/common/collectiveApi'; const fetchSubscriptions = (stripeCustomerId) => collectiveApi.get(`/stripe/subscriptions/?customer=${stripeCustomerId}`).then((response) => response.data); export default function useSubscriptions(stripeCustomerId) { return useQuery(['subscriptions', stripeCustomerId], () => fetchSubscriptions(stripeCustomerId), { enabled: false, staleTime: Infinity, }); } // Code from file collective-frontend/src/modules/Hub/Payments/query-hooks/useCreateCreditNoteRequest.js import { useMutation, useQueryClient } from '@tanstack/react-query'; import collectiveApi from 'modules/common/collectiveApi'; const postCreditNoteRequest = (postObject) => collectiveApi.post(`/stripe/credit_note_requests/`, postObject).then((response) => response.data); export default function useCreateCreditRequest(postObject) { const queryClient = useQueryClient(); return useMutation((postObject) => postCreditNoteRequest(postObject), { onSuccess: (data) => queryClient.setQueryData(['creditNoteRequest', data], data), }); } // Code from file collective-frontend/src/modules/Hub/Payments/query-hooks/useCreditNotes.js import { useQuery } from '@tanstack/react-query'; import collectiveApi from 'modules/common/collectiveApi'; const fetchCreditNotes = (invoiceId) => collectiveApi.get(`/stripe/credit_notes/?invoice=${invoiceId}`).then((response) => response.data.data); export default function useCreditNotes(invoiceId) { return useQuery(['creditNotes', invoiceId], () => fetchCreditNotes(invoiceId), { enabled: Boolean(invoiceId), staleTime: Infinity, }); } // Code from file collective-frontend/src/modules/Hub/Payments/query-hooks/index.js export { default as useCreateCreditNoteRequest } from './useCreateCreditNoteRequest'; export { default as useCreateCreditRequest } from './useCreateCreditRequest'; export { default as useCreateRefundRequest } from './useCreateRefundRequest'; export { default as useCreditNotes } from './useCreditNotes'; export { default as useCustomer } from './useCustomer'; export { default as useInvoice } from './useInvoice'; export { default as useInvoices } from './useInvoices'; export { default as usePayments } from './usePayments'; export { default as useSubscriptions } from './useSubscriptions'; export { default as useToggleSubscription } from './useToggleSubscription'; // Code from file collective-frontend/src/modules/Hub/Payments/query-hooks/useInvoice.js import { useQuery } from '@tanstack/react-query'; import collectiveApi from 'modules/common/collectiveApi'; const fetchInvoice = (invoiceId) => collectiveApi.get(`/stripe/invoices/${invoiceId}/`).then((response) => response.data); export default function useInvoice(invoiceId) { return useQuery(['invoice', invoiceId], () => fetchInvoice(invoiceId), { enabled: false, staleTime: Infinity, }); } // Code from file collective-frontend/src/modules/Hub/Payments/query-hooks/useInvoices.js import { useQuery } from '@tanstack/react-query'; import collectiveApi from 'modules/common/collectiveApi'; const fetchInvoices = (stripeCustomerId) => collectiveApi.get(`/stripe/invoices/?customer=${stripeCustomerId}&limit=100`).then((response) => response.data); export default function useInvoices(stripeCustomerId) { return useQuery(['invoices', stripeCustomerId], () => fetchInvoices(stripeCustomerId), { enabled: false, staleTime: Infinity, }); } // Code from file collective-frontend/src/modules/Hub/Payments/query-hooks/useToggleSubscription.js import { useMutation, useQueryClient } from '@tanstack/react-query'; import collectiveApi from 'modules/common/collectiveApi'; const toggleSubscription = (putObject) => collectiveApi .put(`/stripe/subscriptions/${putObject.subscriptionId}/${putObject.verb}/`) .then((response) => response.data); export default function useToggleSubscription(putObject) { const queryClient = useQueryClient(); return useMutation((putObject) => toggleSubscription(putObject), { onSuccess: (data) => queryClient.setQueryData(['toggleSubscription', data], data), }); } // Code from file collective-frontend/src/modules/Hub/Payments/Page.jsx import { useEffect, useState } from 'react'; import { Typography, LinearProgress, Alert } from '@mui/material'; import { useQuery } from '@tanstack/react-query'; import TransitionNavBar from '../common/TransitionNavBar'; import CustomerPanel from './CustomerPanel'; import InvoicesTable from './InvoicesTable'; import ActionsPanel from './ActionsPanel'; import { useCustomer, useInvoices, usePayments } from './query-hooks'; import useRootStore from 'modules/common/stores/Root/useRootStore'; const PaymentsPage = (props) => { const { legacyStore } = useRootStore(); const { stripeCustomerId } = props.match.params; const [update, setUpdate] = useState(false); const [isStaleCreditNotes, setIsStaleCreditNotes] = useState(false); const [isStaleCreditNoteAction, setIsStaleCreditNoteAction] = useState(false); const { isError: customerIsError, isLoading: customerIsLoading, data: customerData, refetch: refetchCustomer, isFetching: customerIsFetching, } = useCustomer(stripeCustomerId); const { isError: paymentsIsError, isLoading: paymentsIsLoading, data: paymentData, refetch: refetchPayments, isFetching: paymentsIsFetching, } = usePayments(stripeCustomerId); const { isError: invoicesIsError, isLoading: invoicesIsLoading, data: invoicesData, refetch: refetchInvoices, isFetching: invoicesIsFetching, } = useInvoices(stripeCustomerId); const toggleUpdate = () => { setUpdate(!update); refetchCustomer(); refetchPayments(); refetchInvoices(); setIsStaleCreditNotes(true); setIsStaleCreditNoteAction(true); }; useEffect(() => { if (stripeCustomerId !== '---') { toggleUpdate(); } }, []); if (stripeCustomerId === '---' || customerIsError || paymentsIsError || invoicesIsError) { return ( <div className="tw-flex tw-flex-col"> <TransitionNavBar clientName={legacyStore.transitionClientInfo.fullname} payments sfid={legacyStore.transitionClientInfo.sf_id} tpid={legacyStore.transitionClientInfo.transition_plan_id} clientCompany={legacyStore.transactionInfo.entity_name} /> <div className="tw-w-full tw-mt-4"> <Alert severity="error">Failed to load member's data from Stripe</Alert> </div> </div> ); } const customer = customerIsLoading ? { subscriptions: [{ plan: { nickname: '' }, start_date: '1900-01-01' }], balance: 0, email: '' } : customerData; const payments = paymentsIsLoading ? [] : paymentData.data; const invoices = invoicesIsLoading ? [] : invoicesData.data; return ( <div className="tw-flex tw-flex-col"> {(customerIsLoading || customerIsFetching || paymentsIsFetching || paymentsIsLoading) && <LinearProgress />} <TransitionNavBar clientName={legacyStore.transitionClientInfo.fullname} payments sfid={legacyStore.transitionClientInfo.sf_id} tpid={legacyStore.transitionClientInfo.transition_plan_id} clientCompany={legacyStore.transactionInfo.entity_name} /> <div className="tw-w-full tw-mt-4"> <div className="tw-grid tw-grid-flow-col tw-auto-cols-2"> <div className=""> <Typography variant="h2">Subscription</Typography> <CustomerPanel customer={customer} /> </div> <div className="tw-space-y-2"> <Typography variant="h2">Actions</Typography> <ActionsPanel update={update} triggerUpdate={toggleUpdate} customer={customer} payments={payments} invoices={invoices} isStaleCreditNoteAction={isStaleCreditNoteAction} setIsStaleCreditNoteAction={setIsStaleCreditNoteAction} /> </div> </div> </div> <div className="tw-flex tw-space-y-2 tw-mt-2 tw-flex-col"> <Typography variant="h2">Invoices</Typography> {payments && invoices && ( <InvoicesTable invoices={invoices} payments={payments} stripeCustomerId={stripeCustomerId} isStaleCreditNotes={isStaleCreditNotes} setIsStaleCreditNotes={setIsStaleCreditNotes} /> )} </div> </div> ); }; export default PaymentsPage; // Code from file collective-frontend/src/modules/Hub/Payments/CustomerPanel.jsx import { Typography, Paper } from '@mui/material'; import moment from 'moment'; import { moneyWithCommas } from 'modules/common/display'; const CustomerPanel = ({ customer }) => { const subscription = customer.subscriptions[0]; const display_balance = customer.balance < 0 ? `(${moneyWithCommas((customer.balance * -1) / 100)})` : moneyWithCommas(customer.balance / 100); const status = subscription.pause_collection ? `paused (${subscription.pause_collection.behavior})` : 'active'; return ( <div className="tw-mt-2 tw-mb-2 tw-pr-4"> <Paper elevation={2}> <div className="tw-p-2 tw-flex-row"> <Typography variant="body1">Balance: {display_balance}</Typography> <Typography variant="body1">Type: {subscription.plan.nickname}</Typography> <Typography variant="body1">Status: {status}</Typography> <Typography variant="body1">Email: {customer.email}</Typography> <Typography variant="body1"> Started: {moment.unix(subscription.start_date).format('YYYY-MM-DD')} </Typography> </div> </Paper> </div> ); }; export default CustomerPanel; // Code from file collective-frontend/src/modules/Hub/Payments/Action.jsx import { useState } from 'react'; import { Button, Typography } from '@mui/material'; import Dialog from '@mui/material/Dialog'; import DialogActions from '@mui/material/DialogActions'; import DialogContent from '@mui/material/DialogContent'; import DialogTitle from '@mui/material/DialogTitle'; const Action = ({ verb, title, children, formik, disabled = false }) => { const [dialogOpen, setDialogOpen] = useState(false); const handleSubmit = (e) => { e.preventDefault(); formik.handleSubmit(); if (formik.isValid) { setDialogOpen(false); } }; return ( <> <Button size="large" onClick={() => setDialogOpen(true)}> <Typography>{verb}</Typography> </Button> <Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} fullWidth> <form onSubmit={handleSubmit}> <DialogTitle>{title}</DialogTitle> <DialogContent>{children}</DialogContent> <DialogActions className="tw-p-0 tw-pb-3 tw-pr-3"> <Button type="submit" disabled={disabled || formik.isSubmitting}> <Typography>Submit</Typography> </Button> <Button onClick={() => setDialogOpen(false)} variant="outlined"> <Typography>Close</Typography> </Button> </DialogActions> </form> </Dialog> </> ); }; export default Action; // Code from file collective-frontend/src/modules/Hub/Payments/InvoicesTableRowDetail.jsx import { Typography } from '@mui/material'; import TableCell from '@mui/material/TableCell'; import TableRow from '@mui/material/TableRow'; import { moneyWithCommas } from 'modules/common/display'; const InvoicesTableRowDetail = ({ detailData }) => { const status = detailData.status === 'issued' ? 'Credited' : 'Paid'; return ( <TableRow key={detailData.id}> <TableCell component="th" scope="row"> <Typography>{detailData ? moneyWithCommas(detailData.amount / 100) : '$0'}</Typography> </TableCell> <TableCell> <Typography>{detailData.description}</Typography> </TableCell> <TableCell align="right"> <Typography>{detailData.quantity}</Typography> </TableCell> <TableCell align="right"> <Typography>{status}</Typography> </TableCell> </TableRow> ); }; export default InvoicesTableRowDetail; // Code from file collective-frontend/src/modules/Hub/Payments/SubscriptionAction.jsx import DialogContentText from '@mui/material/DialogContentText'; import { useFormik } from 'formik'; import Action from './Action'; import { useToggleSubscription } from './query-hooks'; const SubscriptionAction = ({ triggerUpdate, customer }) => { const subscription = customer.subscriptions[0]; const verb = subscription.pause_collection ? 'Resume' : 'Pause'; const { mutateAsync: toggleSubscription } = useToggleSubscription(); const onSubmit = async (values) => { const putObject = { subscriptionId: subscription.id, verb: verb.toLowerCase(), }; await toggleSubscription(putObject); triggerUpdate(); }; const formik = useFormik({ initialValues: {}, onSubmit, }); return ( <Action title={`${verb} a Subscription`} verb={verb} formik={formik}> <DialogContentText>Are you sure you want to change the subscription?</DialogContentText> </Action> ); }; export default SubscriptionAction; // Code from file collective-frontend/src/modules/Hub/Payments/CreditAction.jsx import { DialogContentText, TextField, Box } from '@mui/material'; import { useFormik } from 'formik'; import { number, object, string } from 'yup'; import Action from './Action'; import { useCreateCreditRequest } from './query-hooks'; const validationSchema = object({ creditAmount: number().min(1).required('must credit at least 1 dollar'), reason: string().required('Please enter a reason'), }); const CreditAction = ({ triggerUpdate, stripeCustomerId }) => { const { mutateAsync: createCreditRequest } = useCreateCreditRequest(); const onSubmit = async (values) => { const postObject = { customer: stripeCustomerId, currency: 'usd', description: values.reason, amount: -1 * (values.creditAmount * 100), }; await createCreditRequest(postObject); resetForm(); triggerUpdate(); }; const formik = useFormik({ initialValues: { creditAmount: 0, reason: '', }, validationSchema, onSubmit, validateOnMount: true, }); const { values, errors, touched, handleChange, resetForm } = formik; return ( <Action title="Credit a Member" verb="Credit" formik={formik}> <form> <DialogContentText>Set the credit amount in dollars.</DialogContentText> <Box noValidate className="tw-mb-2"> <TextField error={touched.creditAmount && Boolean(errors.creditAmount)} type="number" id="creditAmount" name="creditAmount" value={values.creditAmount} onChange={handleChange} fullWidth helperText={touched.creditAmount && errors.creditAmount} /> </Box> <DialogContentText>Reason for credit</DialogContentText> <Box noValidate className="tw-mb-2"> <TextField error={touched.reason && Boolean(errors.reason)} multiline rows={5} id="reason" name="reason" type="text" value={values.reason} onChange={handleChange} fullWidth helperText={touched.reason && errors.reason} /> </Box> </form> </Action> ); }; export default CreditAction; // Code from file collective-frontend/src/modules/Hub/Payments/InvoicesTable.jsx import { useMemo } from 'react'; import LinearProgress from '@mui/material/LinearProgress'; import Table from '@mui/material/Table'; import TableBody from '@mui/material/TableBody'; import TableCell from '@mui/material/TableCell'; import TableContainer from '@mui/material/TableContainer'; import TableHead from '@mui/material/TableHead'; import TableRow from '@mui/material/TableRow'; import Typography from '@mui/material/Typography'; import Paper from '@mui/material/Paper'; import InvoicesTableRow from './InvoicesTableRow'; const InvoicesTable = ({ invoices, payments, isStaleCreditNotes, setIsStaleCreditNotes }) => { const rowDataArray = useMemo(() => { if (invoices.length !== 0 && payments.length !== 0) { return invoices .map((invoice) => { const payment = payments.find((payment) => payment.invoice === invoice.id); if (payment) { return { ...invoice, ...payment, }; } return undefined; }) .filter((element) => element !== undefined); } }, [invoices, payments]); console.log('rowDataArray', rowDataArray); if (invoices.length === 0 && payments.length === 0) { return <LinearProgress />; } return ( <TableContainer component={Paper} className="tw-ml-0 tw-pl-3 tw-pr-3"> <Table aria-label="collapsible table"> <TableHead> <TableRow> <TableCell /> <TableCell> <Typography fontWeight="bold">Amount</Typography> </TableCell> <TableCell align="right"> <Typography fontWeight="bold">Amount Refunded</Typography> </TableCell> <TableCell align="right"> <Typography fontWeight="bold">Amount Credited</Typography> </TableCell> <TableCell align="right"> <Typography fontWeight="bold">Description</Typography> </TableCell> <TableCell align="right"> <Typography fontWeight="bold">Date</Typography> </TableCell> <TableCell align="right"> <Typography fontWeight="bold">Status</Typography> </TableCell> </TableRow> </TableHead> <TableBody> {rowDataArray && rowDataArray.map((rowData) => ( <InvoicesTableRow key={rowData.created} rowData={rowData} isStaleCreditNotes={isStaleCreditNotes} setIsStaleCreditNotes={setIsStaleCreditNotes} /> ))} </TableBody> </Table> </TableContainer> ); }; export default InvoicesTable; // Code from file collective-frontend/src/modules/Hub/Payments/CreditNoteAction.jsx import { DialogContentText, TextField, Box, Select, MenuItem, Alert, Typography, FormHelperText } from '@mui/material'; import { useFormik } from 'formik'; import { useEffect, useMemo, useState } from 'react'; import { object, string } from 'yup'; import moment from 'moment'; import Action from './Action'; import { useCreateCreditNoteRequest, useCreditNotes } from './query-hooks'; import { moneyWithCommas } from 'modules/common/display'; const validationSchema = object({ selectedInvoice: string().required('Please select an invoice'), selectedInvoiceItem: string().required('Please select an item to refund'), reasonStripe: string().required('Please select a reason'), memo: string().required('Please add a memo for the customer'), }); const CreditNoteAction = ({ triggerUpdate, stripeCustomerId, invoices, isStaleCreditNoteAction, setIsStaleCreditNoteAction, }) => { const [invoiceId, setInvoiceId] = useState(null); const { mutateAsync: createCreditNoteRequest } = useCreateCreditNoteRequest(); const invoice = useMemo(() => invoices.find((invoice) => invoice.id === invoiceId), [invoiceId]); const { isLoading: creditNotesIsLoading, data: creditNotesData, refetch: refetchCreditNotes, isFetching: creditNotesIsFetching, } = useCreditNotes(invoiceId); useEffect(() => { if (invoiceId && isStaleCreditNoteAction) { refetchCreditNotes(); setIsStaleCreditNoteAction(false); } }, [invoiceId, isStaleCreditNoteAction]); const lineItems = useMemo(() => { if (invoice && creditNotesData) { return invoice.lines.map((line) => { const creditNote = creditNotesData.find((note) => note.invoice_line_item_unique_id === line.unique_id); return { ...line, ...creditNote, }; }); } }, [invoice, creditNotesData]); const onSubmit = async (values) => { const invoiceItem = invoice.lines.find((item) => item.unique_id === values.selectedInvoiceItem); const postObject = { amount: -1 * (invoiceItem.amount / 100), currency: invoiceItem.currency, customer: stripeCustomerId, description: invoiceItem.description, invoice: values.selectedInvoice, invoice_line_item: values.selectedInvoiceItem, reason_stripe: values.reasonStripe, memo: values.memo, }; await createCreditNoteRequest(postObject); resetForm(); triggerUpdate(); }; const formik = useFormik({ initialValues: { selectedInvoice: '', selectedInvoiceItem: '', reasonStripe: '', memo: '', }, validationSchema, onSubmit, }); const { values, errors, touched, handleChange, setFieldValue, resetForm } = formik; const handleSelectChange = (event) => { setFieldValue(event.target.name, event.target.value); }; const filteredLineItems = lineItems ? lineItems.filter((item) => item.status !== 'issued') : []; const noMoreCreditNotesAvailable = filteredLineItems ? filteredLineItems.length === 0 : false; if (!invoices) { return null; } return ( <Action title="Create a credit note" verb="Credit note" formik={formik}> <DialogContentText>Select the invoice to refund</DialogContentText> <Box noValidate className="tw-mb-2"> <Select autoFocus fullWidth value={values.selectedInvoice} error={touched.selectedInvoice && Boolean(errors.selectedInvoice)} helperText={<p className="error">{errors.selectedInvoice}</p>} onChange={(event) => { handleSelectChange(event); setInvoiceId(event.target.value); }} inputProps={{ name: 'selectedInvoice', id: 'selectedInvoice', }}> {invoices.map((invoice) => ( <MenuItem value={invoice.id} key={invoice.id}> Invoice #{invoice.number} - {moneyWithCommas(invoice.amount_paid / 100)} -{' '} {moment.unix(invoice.created).format('MM-DD-YYYY')} </MenuItem> ))} </Select> </Box> {creditNotesData && ( <> {noMoreCreditNotesAvailable && ( <Alert className="tw-mb-2" severity="warning"> <Typography>There are no more credit notes left to create from this invoice</Typography> </Alert> )} <DialogContentText> Select the invoice item you'd like to credit back to the member </DialogContentText> <Box noValidate className="tw-mb-2"> <Select autoFocus fullWidth disabled={noMoreCreditNotesAvailable} value={values.selectedInvoiceItem} error={touched.selectedInvoiceItem && Boolean(errors.selectedInvoiceItem)} helperText={<p className="error">{errors.selectedInvoiceItem}</p>} onChange={handleSelectChange} inputProps={{ name: 'selectedInvoiceItem', id: 'selectedInvoiceItem', }}> {filteredLineItems.map((item) => ( <MenuItem value={item.unique_id} key={item.unique_id}> {item.description} - {moneyWithCommas(item.amount / 100)} </MenuItem> ))} </Select> </Box> <DialogContentText>Reason for issuing this credit note</DialogContentText> <Box noValidate className="tw-mb-2"> <Select autoFocus fullWidth disabled={noMoreCreditNotesAvailable} value={values.reasonStripe} error={touched.reasonStripe && Boolean(errors.reasonStripe)} helperText={<p className="error">{errors.reasonStripe}</p>} onChange={handleSelectChange} inputProps={{ name: 'reasonStripe', id: 'reasonStripe', }}> <MenuItem value="duplicate" key="duplicate"> Duplicate </MenuItem> <MenuItem value="fraudulent" key="fraudulent"> Fraudulent </MenuItem> <MenuItem value="order_change" key="order_change"> Order Change </MenuItem> <MenuItem value="product_unsatisfactory" key="product_unsatisfactory"> Product Unsatisfactory </MenuItem> </Select> </Box> <DialogContentText>The credit note's memo appears on the credit note PDF</DialogContentText> <Box noValidate className="tw-mb-2"> <TextField disabled={noMoreCreditNotesAvailable} error={touched.memo && Boolean(errors.memo)} helperText={<p className="error">{errors.memo}</p>} value={values.memo} multiline id="memo" name="memo" rows={5} type="text" onChange={handleChange} fullWidth /> </Box> </> )} </Action> ); }; export default CreditNoteAction; // Code from file collective-frontend/src/modules/Hub/Payments/RefundAction.jsx import { DialogContentText, TextField, Box, Select, MenuItem } from '@mui/material'; import { useFormik } from 'formik'; import { number, object, string } from 'yup'; import moment from 'moment'; import { useCreateRefundRequest } from './query-hooks'; import Action from './Action'; import { moneyWithCommas } from 'modules/common/display'; const validationSchema = object({ refundAmount: number().min(1).required('must refund at least 1 dollar'), reasonInternal: string().required('Please select a reason'), reasonStripe: string().required('Please select a reason'), selectedPayment: string().required('Please select a payment'), }); const RefundAction = ({ triggerUpdate, stripeCustomerId, payments }) => { const { mutateAsync: createRefundRequest } = useCreateRefundRequest(); const onSubmit = async (values) => { const postObject = { customer: stripeCustomerId, reason_stripe: values.reasonStripe, reason_internal: values.reasonInternal, amount: values.refundAmount * 100, payment_intent_id: values.selectedPayment, }; await createRefundRequest(postObject); resetForm(); triggerUpdate(); }; const formik = useFormik({ initialValues: { refundAmount: 0, reasonStripe: '', reasonInternal: '', selectedPayment: '', }, validationSchema, onSubmit, }); const { values, errors, touched, handleChange, setFieldValue, resetForm } = formik; const handleSelectChange = (event) => { setFieldValue(event.target.name, event.target.value); }; if (!payments) { return null; } return ( <Action title="Refund a Payment" verb="Refund" formik={formik}> <DialogContentText>Select the payment to refund</DialogContentText> <Box noValidate className="tw-mb-2"> <Select autoFocus fullWidth value={values.selectedPayment} error={touched.selectedPayment && Boolean(errors.selectedPayment)} helperText={errors.selectedPayment} onChange={handleSelectChange} inputProps={{ name: 'selectedPayment', id: 'selectedPayment', }}> {payments.map((payment) => ( <MenuItem value={payment.id} key={payment.id}> {moment.unix(payment.created).format('MM-DD-YYYY')} {moneyWithCommas(payment.amount / 100)} </MenuItem> ))} </Select> </Box> <DialogContentText>Set the refund amount in dollars.</DialogContentText> <Box noValidate className="tw-mb-2"> <TextField type="number" id="refundAmount" name="refundAmount" error={touched.refundAmount && Boolean(errors.refundAmount)} helperText={errors.refundAmount} value={values.refundAmount} onChange={handleChange} fullWidth /> </Box> <DialogContentText>Reason for refund</DialogContentText> <Box noValidate className="tw-mb-2"> <Select autoFocus fullWidth value={values.reasonStripe} error={touched.reasonStripe && Boolean(errors.reasonStripe)} helperText={errors.reasonStripe} onChange={handleSelectChange} inputProps={{ name: 'reasonStripe', id: 'reasonStripe', }}> <MenuItem value="duplicate" key="duplicate"> Duplicate </MenuItem> <MenuItem value="fraudulent" key="fraudulent"> Fraudulent </MenuItem> <MenuItem value="requested_by_customer" key="requested_by_customer"> Requested by Customer </MenuItem> </Select> </Box> <DialogContentText>Additional details for refund</DialogContentText> <Box noValidate className="tw-mb-2"> <TextField error={touched.reasonInternal && Boolean(errors.reasonInternal)} helperText={errors.reasonInternal} value={values.reasonInternal} multiline id="reasonInternal" name="reasonInternal" rows={5} type="text" onChange={handleChange} fullWidth /> </Box> </Action> ); }; export default RefundAction; // Code from file collective-frontend/src/modules/Hub/Payments/ActionsPanel.jsx import CreditAction from './CreditAction'; import CreditNoteAction from './CreditNoteAction'; import RefundAction from './RefundAction'; import SubscriptionAction from './SubscriptionAction'; const ActionsPanel = ({ triggerUpdate, customer, payments, invoices, isStaleCreditNoteAction, setIsStaleCreditNoteAction, }) => { return ( <div className="tw-flex tw-cols-2 tw-space-x-1"> <CreditAction triggerUpdate={triggerUpdate} stripeCustomerId={customer.id} /> <RefundAction triggerUpdate={triggerUpdate} stripeCustomerId={customer.id} payments={payments} /> <CreditNoteAction triggerUpdate={triggerUpdate} stripeCustomerId={customer.id} invoices={invoices} isStaleCreditNoteAction={isStaleCreditNoteAction} setIsStaleCreditNoteAction={setIsStaleCreditNoteAction} /> <SubscriptionAction triggerUpdate={triggerUpdate} customer={customer} /> </div> ); }; export default ActionsPanel; // Code from file collective-frontend/src/modules/Hub/ClientAppSync/index.jsx import React from 'react'; import { Route } from 'react-router-dom'; import lazyWithSuspenseAndRetry from 'modules/common/lazyWithSuspenseAndRetry'; export const ClientAppSyncHomePage = lazyWithSuspenseAndRetry(() => import('./Home/Page')); export default function getHubClientAppSyncRoutes() { return [<Route exact key="client-app-sync-home" path="/hub/client-app-sync" component={ClientAppSyncHomePage} />]; } // Code from file collective-frontend/src/modules/Hub/ClientAppSync/services.test.js import { taxReturnCsvHistoryAdapter } from './adapters'; import collectiveApi from 'modules/common/collectiveApi'; import { commitChanges, fetchTaxActivityCsvCompare, fetchTaxReturnCSVHistory, hubClientAppSyncUploadFile, } from 'modules/Hub/ClientAppSync/services'; jest.mock('modules/common/collectiveApi'); jest.mock('./adapters'); describe('Client App Sync services', () => { const client = { post: jest.fn(), patch: jest.fn(), }; collectiveApi.mockImplementation(() => client); describe('hubClientAppSyncUploadFile', () => { it('should upload file', async () => { // Given const data = { message: 'success' }; const expectedResponse = { data, status: 201 }; collectiveApi.post.mockResolvedValueOnce(expectedResponse); const formData = new FormData(); formData.append('file', 'file content'); // When const response = await hubClientAppSyncUploadFile(formData); // Then expect(collectiveApi.post).toHaveBeenCalledWith('/taxtracker/csv/', formData, { headers: { 'Content-Type': 'multipart/form-data' }, }); expect(response).toEqual(expectedResponse); }); }); describe('commitChanges', () => { it('should commit changes', async () => { // Given const data = { message: 'success' }; const expectedResponse = { data, status: 200 }; collectiveApi.patch.mockResolvedValueOnce(expectedResponse); // When const response = await commitChanges(1); // Then expect(collectiveApi.patch).toHaveBeenCalledWith('taxtracker/csv-compare/1/'); expect(response).toEqual(data); }); }); describe('fetchTaxActivityCsvCompare', () => { it('should fetch tax activity csv compare', async () => { // Given const data = { message: 'success' }; const expectedResponse = { data, status: 200 }; collectiveApi.get.mockResolvedValueOnce(expectedResponse); // When const response = await fetchTaxActivityCsvCompare(1); // Then expect(collectiveApi.get).toHaveBeenCalledWith('taxtracker/csv-compare/1/'); expect(response).toEqual(data); }); }); describe('fetchTaxReturnCSVHistory', () => { beforeEach(() => { taxReturnCsvHistoryAdapter.mockResolvedValueOnce({ message: 'success', }); }); it('should fetch tax activity csv history', async () => { // Given const data = { message: 'success' }; const expectedResponse = { data, status: 200 }; collectiveApi.get.mockResolvedValueOnce(expectedResponse); // When const response = await fetchTaxReturnCSVHistory(1); // Then expect(collectiveApi.get).toHaveBeenCalledWith('taxtracker/tax-return-csv-history/'); expect(response).toEqual(data); }); it('should fetch tax activity csv history if giving a page number', async () => { // Given const data = { message: 'success' }; const expectedResponse = { data, status: 200 }; collectiveApi.get.mockResolvedValueOnce(expectedResponse); // When const response = await fetchTaxReturnCSVHistory(2); // Then expect(collectiveApi.get).toHaveBeenCalledWith('taxtracker/tax-return-csv-history/?page=2'); expect(response).toEqual(data); }); }); }); // Code from file collective-frontend/src/modules/Hub/ClientAppSync/services.js import { taxReturnCsvHistoryAdapter } from './adapters'; import collectiveApi from 'modules/common/collectiveApi'; // TaxTracker: Client App Sync export const hubClientAppSyncUploadFile = (formData) => collectiveApi.post('/taxtracker/csv/', formData, { headers: { 'Content-Type': 'multipart/form-data', }, }); // Fetch tax activity csv compare tool export const fetchTaxActivityCsvCompare = async (file_id) => { const { data } = await collectiveApi.get(`taxtracker/csv-compare/${file_id}/`); return data; }; // Apply the changes to the tax activity export const commitChanges = async (file_id) => { const { data } = await collectiveApi.patch(`taxtracker/csv-compare/${file_id}/`); return data; }; // Fetch the list of Tax Return CSV History paginated export const fetchTaxReturnCSVHistory = async (page) => { const next_page = page > 1 ? `?page=${page}` : ''; const { data } = await collectiveApi.get(`taxtracker/tax-return-csv-history/${next_page}`); return taxReturnCsvHistoryAdapter(data); }; export const fetchDownloadCsvFile = async (uuid) => { const { data } = await collectiveApi.get(`taxtracker/csv-download/${uuid}/`); const url = window.URL.createObjectURL(new Blob([data])); const link = document.createElement('a'); link.href = url; link.setAttribute('download', `${uuid}.csv`); document.body.appendChild(link); link.click(); }; // Code from file collective-frontend/src/modules/Hub/ClientAppSync/adapters.js import moment from 'moment'; export const dateFormat = 'MMM DD, YYYY H:mm:ss'; const taxReturnCsvHistoryAdapter = (data) => { return { next_page: data.next_page, number_of_pages: data.number_of_pages, previous_page: data.previous_page, total_records_count: data.total_records_count, results: data.results.map((res) => { return { is_active: res.is_active, uuid: res.uuid, updated_at: moment.utc(res.updated_at).format(dateFormat), created_at: moment.utc(res.created_at).format(dateFormat), uploaded_by: res.uploaded_by, }; }), }; }; export { taxReturnCsvHistoryAdapter }; // Code from file collective-frontend/src/modules/Hub/ClientAppSync/Home/Page.test.jsx import { QueryClientProvider, QueryClient } from '@tanstack/react-query'; import { ThemeProvider } from '@mui/material'; import { render, act } from '@testing-library/react'; import ClientAppSyncHomePage, { UploadActions, DataDiffInfo } from './Page'; import theme from 'theme'; const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, }, }, }); const setup = (customProps) => { const props = { ...customProps, }; return render( <ThemeProvider theme={theme}> <QueryClientProvider client={queryClient}> <ClientAppSyncHomePage {...props} /> </QueryClientProvider> </ThemeProvider> ); }; describe('ClientAppSyncHomePage', () => { it('should render correctly', () => { const { asFragment } = setup(); expect(asFragment().firstChild).toMatchSnapshot(); }); it('should show the upload form when click on first tab', () => { const { getByRole } = setup(); const tab = getByRole('tab', { name: 'Upload Data' }); expect(tab).toBeInTheDocument(); }); it('should show the commit history when click on second tab', () => { const { getByRole } = setup(); const tab = getByRole('tab', { name: 'History' }); expect(tab).toBeInTheDocument(); }); it('should show the confirmation dialog modal when click on commit changes', async () => { const { debug, getByTestId, asFragment, getByText } = setup({ uploadedFileMetadata: { file: 'test.csv', data: null, }, changesCount: 1, }); await act(() => { const button = getByTestId('commit-changes-button'); button.click(); }); expect(getByText('Do you wan to commit the changes?')).toBeInTheDocument(); expect(asFragment()).toMatchSnapshot(); }); describe('UploadActions', () => { it('should render correctly when changesCount is 0', () => { const { asFragment } = render(<UploadActions changesCount={0} setDialogOpen={jest.fn()} />); expect(asFragment().firstChild).toMatchSnapshot(); }); it('should render correctly when changesCount is greater than 0', () => { const { asFragment } = render(<UploadActions changesCount={1} setDialogOpen={jest.fn()} />); expect(asFragment().firstChild).toMatchSnapshot(); }); }); describe('DataDiffInfo', () => { it('should render correctly when changesCount is 0', () => { const { asFragment } = render(<DataDiffInfo changesCount={0} />); expect(asFragment().firstChild).toMatchSnapshot(); }); it('should render correctly when changesCount is greater than 0', () => { const { asFragment } = render(<DataDiffInfo changesCount={1} />); expect(asFragment().firstChild).toMatchSnapshot(); }); it('should render correctly when changesCount is greater than 1', () => { const { asFragment } = render(<DataDiffInfo changesCount={2} />); expect(asFragment().firstChild).toMatchSnapshot(); }); }); }); // Code from file collective-frontend/src/modules/Hub/ClientAppSync/Home/Page.jsx import { useState } from 'react'; import { Alert, Box, Button, Tab, Tabs, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, } from '@mui/material'; import Header from './Header'; import UploadData from './UploadData'; import FileInfo from './FileInfo'; import DataDiffTable from './DataDiffTable'; import UploadHistory from './UploadHistory'; import MissingMembersTable from './MissingMembersTable'; import { fetchTaxActivityCsvCompare, commitChanges } from 'modules/Hub/ClientAppSync/services'; import CommitChangesSuccessModal from 'modules/Hub/ClientAppSync/Home/CommitChangesSuccessModal'; export function UploadActions({ changesCount, setDialogOpen }) { if (changesCount === 0) { return null; } return ( <div className="tw-flex tw-flex-row tw-justify-end"> <Button variant="contained" data-testid="commit-changes-button" color="primary" size="large" style={{ marginTop: '20px' }} onClick={() => setDialogOpen(true)}> Commit Changes </Button> </div> ); } export function DataDiffInfo({ changesCount }) { if (changesCount === 0) { return null; } return ( <div className="info"> <strong>{`${changesCount} change${changesCount > 1 ? 's' : ''} found. `}</strong> Review anomalies and commit change </div> ); } function TabPanel(props) { const { children, value, index, ...other } = props; return ( <div role="tabpanel" hidden={value !== index} id={`simple-tabpanel-${index}`} aria-labelledby={`simple-tab-${index}`} {...other}> {value === index && ( <Box sx={{ p: 3 }}> <div>{children}</div> </Box> )} </div> ); } function a11yProps(index) { return { id: `simple-tab-${index}`, 'aria-controls': `simple-tabpanel-${index}`, }; } export default function ClientAppSyncHomePage(props) { const [value, setValue] = useState(0); const [tabDataValue, setTabDataValue] = useState(0); const [uploadedFileMetadata, setUploadedFileMetadata] = useState(props.uploadedFileMetadata || null); const [dialogOpen, setDialogOpen] = useState(false); const [loadingDiff, setLoadingDiff] = useState(false); const [applyingChanges, setApplyingChanges] = useState(false); const [changesApplied, setChangesApplied] = useState(false); const [diffData, setDiffData] = useState(null); const [changesCount, setChangesCount] = useState(props.changesCount || 0); const onUploadedFile = async (file, data) => { setUploadedFileMetadata({ file, data }); setLoadingDiff(true); const diffResponse = await fetchTaxActivityCsvCompare(data.uuid); setDiffData(diffResponse); setLoadingDiff(false); setChangesCount(diffResponse.added.length + diffResponse.modified.length + diffResponse.deleted.length); }; const handleChange = (event, newValue) => { setValue(newValue); }; const handleTabDataChange = (event, newValue) => { setTabDataValue(newValue); }; const handleOnClear = () => { setUploadedFileMetadata(null); setDiffData(null); setChangesCount(0); setDialogOpen(false); }; const handleCommitChanges = async () => { setApplyingChanges(true); await commitChanges(uploadedFileMetadata.data.uuid); setApplyingChanges(false); setDialogOpen(false); setChangesApplied(true); handleOnClear(); }; return ( <div> <Header title="Client App Sync" /> <Box sx={{ borderBottom: 1, borderColor: 'divider', marginTop: '20px' }}> <Tabs value={value} onChange={handleChange} aria-label="basic tabs example"> <Tab label="Upload Data" {...a11yProps(0)} /> <Tab label="History" {...a11yProps(1)} /> </Tabs> </Box> <TabPanel value={value} index={0}> {uploadedFileMetadata === null ? ( <UploadData onUploadedFile={onUploadedFile} /> ) : ( <FileInfo onClear={handleOnClear} metadata={uploadedFileMetadata} /> )} {changesApplied && ( <CommitChangesSuccessModal isOpen={changesApplied} onClose={() => setChangesApplied(false)} /> )} {uploadedFileMetadata && ( <div className="tw-mt-4"> <Dialog open={dialogOpen} onClose={() => handleOnClear()} aria-labelledby="alert-dialog-title" aria-describedby="alert-dialog-description"> <DialogTitle id="alert-dialog-title">Confirmation Dialog</DialogTitle> <DialogContent> <DialogContentText id="alert-dialog-description"> {applyingChanges ? ( <Alert severity="info">Applying changes...</Alert> ) : ( 'Do you wan to commit the changes?' )} </DialogContentText> </DialogContent> <DialogActions> <Button onClick={() => setDialogOpen(false)} disabled={applyingChanges}> Cancel </Button> <Button onClick={handleCommitChanges} disabled={applyingChanges} autoFocus> {applyingChanges ? 'Applying changes...' : 'Agree'} </Button> </DialogActions> </Dialog> <Header title="Prepare to commit" /> <Box sx={{ borderBottom: 1, borderColor: 'divider', marginTop: '20px' }}> <Tabs value={tabDataValue} onChange={handleTabDataChange}> <Tab label="Data Diff" id="tab-data-tab-0" aria-controls="tab-data-tabpanel-0" /> <Tab label="Missing Members" id="tab-data-tab-1" aria-controls="tab-data-tabpanel-1" /> </Tabs> </Box> <TabPanel value={tabDataValue} index={0}> <DataDiffInfo changesCount={changesCount} /> <DataDiffTable diffData={diffData} loading={loadingDiff} data={uploadedFileMetadata.data} /> <UploadActions changesCount={changesCount} setDialogOpen={setDialogOpen} /> </TabPanel> <TabPanel value={tabDataValue} index={1}> {diffData && <MissingMembersTable data={diffData?.missing} />} </TabPanel> </div> )} </TabPanel> <TabPanel value={value} index={1}> <UploadHistory /> </TabPanel> </div> ); } // Code from file collective-frontend/src/modules/Hub/ClientAppSync/Home/index.js export { default } from './Page'; // Code from file collective-frontend/src/modules/Hub/ClientAppSync/Home/DownloadFile/index.js export { default } from './DownloadFile'; // Code from file collective-frontend/src/modules/Hub/ClientAppSync/Home/DownloadFile/DownloadFile.jsx import PropTypes from 'prop-types'; import { useState } from 'react'; import { LoadingButton } from '@mui/lab'; import FileDownloadIcon from '@mui/icons-material/FileDownload'; import { fetchDownloadCsvFile } from 'modules/Hub/ClientAppSync/services'; const DownloadFile = ({ uuid, setError }) => { const [isDownloading, setIsDownloading] = useState(false); const download = async (uuid) => { try { setIsDownloading(true); await fetchDownloadCsvFile(uuid); } catch (error) { setError(error.message); } finally { setIsDownloading(false); } }; return ( <LoadingButton color="success" loading={isDownloading} variant="contained" data-testid="csv-download-button" onClick={() => download(uuid)}> <FileDownloadIcon color="white" /> </LoadingButton> ); }; DownloadFile.propTypes = { uuid: PropTypes.string.isRequired, setError: PropTypes.func.isRequired, }; export default DownloadFile; // Code from file collective-frontend/src/modules/Hub/ClientAppSync/Home/DownloadFile/DownloadFile.test.jsx import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import MockAdapter from 'axios-mock-adapter'; import { act, fireEvent, render } from '@testing-library/react'; import DownloadFile from './DownloadFile'; import collectiveApi from 'modules/common/collectiveApi'; const mockCollectiveApi = new MockAdapter(collectiveApi); const WrappedComponent = ({ uuid, setError }) => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, }, }, }); return ( <QueryClientProvider client={queryClient}> <DownloadFile uuid={uuid} setError={setError} /> </QueryClientProvider> ); }; describe('DownloadFile', () => { const fakeUUID = '15c3b737-9bd8-4d41-8b01-441240c0dcba'; it('should render correctly', () => { const { asFragment } = render(<WrappedComponent uuid={fakeUUID} setError={() => {}} />); expect(asFragment().firstChild).toMatchSnapshot(); }); it('should render download when the button is clicked', async () => { mockCollectiveApi.onGet(`taxtracker/csv-download/${fakeUUID}/`).replyOnce(204, {}); const { getByTestId } = render(<WrappedComponent uuid={fakeUUID} setError={() => {}} />); expect(getByTestId('csv-download-button')).toBeTruthy(); await act(async () => { await fireEvent.click(getByTestId('csv-download-button')); }); expect(mockCollectiveApi.history.get.length).toBe(1); }); }); // Code from file collective-frontend/src/modules/Hub/ClientAppSync/Home/DataDiffTable/DataDiffTable.module.less .headCell { font-weight: bold; } .cellChanges { background-color: #fff5ef; } // Code from file collective-frontend/src/modules/Hub/ClientAppSync/Home/DataDiffTable/index.js export { default } from './DataDiffTable'; // Code from file collective-frontend/src/modules/Hub/ClientAppSync/Home/DataDiffTable/DataDiffTable.jsx import { Table, TableBody, TableCell, TableHead, TableRow } from '@mui/material'; import classes from './DataDiffTable.module.less'; /* * Shows the diff rows between the csv and database data */ const DataDiffTable = ({ loading, diffData }) => { const fields = [ 'client_id', 'member_name', 'project_type', 'task_activity', 'assigned', 'task_status', 'task_end_by', ]; const custom_style = { '&:last-child td, &:last-child th': { border: 0 } }; const diffAttribute = (row, field) => { return row.current_state[field] !== row.new_state[field]; }; if (diffData === null) { return null; } return ( <Table aria-label="task activity table"> <TableHead> <TableRow> <TableCell className={classes.headCell}>Operation</TableCell> <TableCell className={classes.headCell}>Client ID</TableCell> <TableCell className={classes.headCell}>Project</TableCell> <TableCell className={classes.headCell}>Type</TableCell> <TableCell className={classes.headCell}>Task</TableCell> <TableCell className={classes.headCell}>Assigned</TableCell> <TableCell className={classes.headCell}>Status</TableCell> <TableCell className={classes.headCell}>End By</TableCell> </TableRow> </TableHead> <TableBody> {/* Modified */} {!loading && diffData.modified.map((row, row_idx) => ( <TableRow key={`${row_idx}`} sx={custom_style}> <TableCell>Modified</TableCell> {fields.map((field, field_idx) => ( <TableCell key={`${field_idx}`} className={diffAttribute(row, field) ? classes.cellChanges : ''}> {row.current_state[field]} {diffAttribute(row, field) && ( <> <br /> <b>{row.new_state[field]}</b> </> )} </TableCell> ))} </TableRow> ))} {/* Added */} {!loading && diffData.added.map((row, row_idx) => ( <TableRow key={`${row_idx}`} sx={custom_style}> <TableCell>Added</TableCell> {fields.map((field, field_idx) => ( <TableCell key={`${field_idx}`}>{row[field]}</TableCell> ))} </TableRow> ))} {/* Deleted */} {!loading && diffData.deleted.map((row, row_idx) => ( <TableRow key={`${row_idx}`} sx={custom_style}> <TableCell>Deleted</TableCell> {fields.map((field, field_idx) => ( <TableCell key={`${field_idx}`} className={classes.cellChanges}> {row[field]} </TableCell> ))} </TableRow> ))} </TableBody> </Table> ); }; export default DataDiffTable; // Code from file collective-frontend/src/modules/Hub/ClientAppSync/Home/DataDiffTable/DataDiffTable.test.jsx import { render } from '@testing-library/react'; import DataDiffTable from './DataDiffTable'; const data = { modified: [ { current_state: { id: 1, client_id: '27ENFDAA00', project_name: 'George Washington', project_id: '2020 UltraTax CS', extended: false, project_tracking: 'Started', project_type: 'BTR', task_activity: 'Review', assigned: 'AMEND', task_status: 'PRESCREEN', task_end_by: '10/12/22 18.34', }, new_state: { id: 1, client_id: '27ENFDAA00', project_name: 'George Washington', project_id: '2020 UltraTax CS', extended: true, project_tracking: 'In Progress', project_type: 'BTR', task_activity: 'Review', assigned: 'AMEND NEW ASSIGN', task_status: 'PRESCREEN', task_end_by: '10/12/22 18.34', }, }, { current_state: { id: 2, client_id: '27ENFDAA01', extended: false, project_tracking: 'Started', project_type: 'ITR', project_name: 'Tom', project_id: '2020 UltraTax CS', task_activity: 'Review', assigned: 'AMEND', task_status: 'PRESCREEN', task_end_by: '10/12/22 18.34', }, new_state: { id: 2, client_id: '27ENFDAA01', extended: false, project_tracking: 'Completed', project_type: 'ITR', project_name: 'Thomas Jefferson', project_id: '2020 UltraTax CS', task_activity: 'Review', assigned: 'AMEND', task_status: 'PRESCREEN', task_end_by: '10/12/22 11.34', }, }, ], added: [ { id: 3, client_id: '27ENFDAA02', extended: false, project_tracking: 'Started', project_type: 'BTR', project_name: 'John Adams', project_id: '2020 UltraTax CS', task_activity: 'Review', assigned: 'AMEND', task_status: 'COMPLETED', task_end_by: '10/12/22 13.34', }, ], deleted: [ { id: 4, client_id: '27ENFDAA03', extended: false, project_tracking: 'Started', project_type: 'ITR', project_name: 'Benjamin Franklin', project_id: '2020 UltraTax CS', task_activity: 'PRESCREEN', assigned: 'AMEND', task_status: 'COMPLETED', task_end_by: '10/12/22 12.34', }, ], }; describe('DataDiffTable', () => { it('should render correctly', async () => { // Given const diffData = { ...data }; const loading = false; // When const { asFragment } = render(<DataDiffTable diffData={diffData} loading={loading} />); // Then expect(asFragment().firstChild).toMatchSnapshot(); }); it('should render correctly when the data is loading', async () => { // Given const loading = true; const diffData = null; // When const { asFragment } = render(<DataDiffTable diffData={diffData} loading={loading} />); // Then expect(asFragment().firstChild).toMatchSnapshot(); }); }); // Code from file collective-frontend/src/modules/Hub/ClientAppSync/Home/UploadHistoryList/UploadHistoryList.module.less .headCell { font-weight: bold; } .actions { display: flex; gap: 4px; justify-items: flex-start; } // Code from file collective-frontend/src/modules/Hub/ClientAppSync/Home/UploadHistoryList/UploadHistoryList.jsx import PropTypes from 'prop-types'; import { Table, TableBody, TableCell, TableHead, TableRow, TableFooter, Checkbox } from '@mui/material'; import { Menu, Icon } from 'semantic-ui-react'; import DeleteFile from '../DeleteFile'; import DownloadFile from '../DownloadFile'; import classes from './UploadHistoryList.module.less'; /* * Shows history of the CSVs uploaded */ export default function UploadHistoryList({ csvData, csvDataPagination, handlePageChange, setError, fetchData }) { const fields = ['created_at', 'updated_at', 'uploaded_by']; const custom_style = { '&:last-child td, &:last-child th': { border: 0 } }; return ( <div> <Table aria-label="upload history table"> <TableHead> <TableRow className="tw-grid tw-grid-cols-5"> <TableCell className={classes.headCell}>Created</TableCell> <TableCell className={classes.headCell}>Updated</TableCell> <TableCell className={classes.headCell}>Uploaded By</TableCell> <TableCell className={classes.headCell}>Is Active</TableCell> <TableCell className={classes.headCell}>Actions</TableCell> </TableRow> </TableHead> <TableBody> {csvData.map((row, row_idx) => ( <TableRow className="tw-grid tw-grid-cols-5" key={`${row_idx}`} sx={custom_style}> {fields.map((field, field_idx) => ( <TableCell key={`${field_idx}`}>{row[field]}</TableCell> ))} <TableCell> <Checkbox data-testid="is-active-checkbox" disableRipple disabled color="secondary" checked={row.is_active} /> </TableCell> <TableCell key={row.uuid}> <span className={classes.actions}> <DownloadFile key={`${row.uuid}-download`} uuid={row.uuid} setError={setError} /> {!row.is_active && ( <DeleteFile key={`${row.uuid}-delete`} uuid={row.uuid} setError={setError} fetchData={fetchData} /> )} </span> </TableCell> </TableRow> ))} </TableBody> <TableFooter> <TableRow> <TableHead colSpan={fields.length + 1}> <Menu pagination> <Menu.Item as="span" icon> Total of CSV files: {csvDataPagination?.total_record_count} </Menu.Item> </Menu> <Menu floated="right" pagination> <Menu.Item as="span" icon> {csvDataPagination?.number_of_pages} </Menu.Item> <Menu.Item as="a" icon disabled={!csvDataPagination?.previous_page} onClick={() => handlePageChange(csvDataPagination?.previous_page)}> <Icon name="chevron left" /> </Menu.Item> <Menu.Item as="a" icon disabled={!csvDataPagination?.next_page} onClick={() => handlePageChange(csvDataPagination?.next_page)}> <Icon name="chevron right" /> </Menu.Item> </Menu> </TableHead> </TableRow> </TableFooter> </Table> </div> ); } UploadHistoryList.propTypes = { csvData: PropTypes.arrayOf( PropTypes.shape({ is_active: PropTypes.bool, uuid: PropTypes.string, updated_at: PropTypes.string, created_at: PropTypes.string, }).isRequired ), csvDataPagination: PropTypes.shape({ total_record_count: PropTypes.number, number_of_pages: PropTypes.string, next_page: PropTypes.number, previous_page: PropTypes.number, }).isRequired, handlePageChange: PropTypes.func.isRequired, setError: PropTypes.func.isRequired, fetchData: PropTypes.func.isRequired, }; // Code from file collective-frontend/src/modules/Hub/ClientAppSync/Home/UploadHistoryList/index.js export { default } from './UploadHistoryList'; // Code from file collective-frontend/src/modules/Hub/ClientAppSync/Home/UploadHistoryList/UploadHistoryList.test.jsx import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { cleanup, render } from '@testing-library/react'; import UploadHistoryList from './UploadHistoryList'; const data = [ { commit_owner: 3, commit_owner_user: 'user@email.com', is_active: false, uuid: 'b111f67d-59fe-4017-a55a-5d860b4c66a2', updated_at: 'Nov 18, 2022 18:11:78', created_at: 'Nov 18, 2022 18:11:78', }, { commit_owner: 1, commit_owner_user: 'user@email.com', is_active: true, uuid: '5d5975b7-6180-4567-992c-f5282a0f7c87', updated_at: 'Nov 18, 2022 18:11:34', created_at: 'Nov 18, 2022 13:11:90', }, ]; const pagination = { total_record_count: 2, number_of_pages: 'Page 1 of 1', next_page: null, previous_page: null, }; const WrappedComponent = ({ csvData, csvPagination, handlePageChange, setError, fetchData }) => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, }, }, }); return ( <QueryClientProvider client={queryClient}> <UploadHistoryList csvData={csvData} csvDataPagination={csvPagination} handlePageChange={handlePageChange} setError={setError} fetchData={fetchData} /> </QueryClientProvider> ); }; afterAll(cleanup); describe('UploadHistoryList', () => { it('should render correctly', () => { // Given const csvData = data; const csvPagination = pagination; // When const { asFragment } = render( <WrappedComponent csvData={csvData} csvDataPagination={csvPagination} handlePageChange={jest.fn()} setError={jest.fn()} fetchData={jest.fn()} /> ); // Then expect(asFragment().firstChild).toMatchSnapshot(); }); it('should render correctly when data is empty', () => { // Given const csvData = []; const csvPagination = {}; // When const { asFragment } = render( <UploadHistoryList csvData={csvData} csvDataPagination={csvPagination} handlePageChange={jest.fn()} setError={jest.fn()} fetchData={jest.fn()} /> ); // Then expect(asFragment().firstChild).toMatchSnapshot(); }); }); // Code from file collective-frontend/src/modules/Hub/ClientAppSync/Home/FileInfo/FileInfo.jsx import classes from './FileInfo.module.less'; import csvFileUploadedIcon from 'assets/img/icons/csv-file-uploaded-icon.svg'; import arrowUpperRightIcon from 'assets/img/icons/arrow-upper-right-icon.svg'; import closeButtonIcon from 'assets/img/icons/close-circle-icon.svg'; const FileInfo = ({ metadata, onClear }) => { return ( <div className={classes.fileInfo}> <div> <img src={csvFileUploadedIcon} alt="csv file uploaded icon" /> </div> <div className={classes.fileData}> <div className={classes.fileTitle}> {metadata.file.name} <a href="#"> <img src={arrowUpperRightIcon} alt="arrow upper right" /> </a> </div> <div className={classes.fileDate}>Today at 12.00pm</div> </div> <div className={classes.closeButton}> <a href="#" onClick={onClear}> <img src={closeButtonIcon} alt="close button" /> </a> </div> </div> ); }; export default FileInfo; // Code from file collective-frontend/src/modules/Hub/ClientAppSync/Home/FileInfo/FileInfo.module.less .fileInfo { border: 1px solid #a3998f; padding: 20px; border-radius: 8px; color: #333; display: flex; justify-content: space-between; } .fileData { padding-left: 5px; width: 100%; } .fileTitle { font-size: 17px; font-weight: 600; line-height: 20px; img { margin-left: 5px; } } .fileDate { font-size: 12px; line-height: 14px; font-weight: 400; color: #333; } .closeButton { display: flex; } // Code from file collective-frontend/src/modules/Hub/ClientAppSync/Home/FileInfo/index.js export { default } from './FileInfo'; // Code from file collective-frontend/src/modules/Hub/ClientAppSync/Home/FileInfo/FileInfo.test.jsx import { render } from '@testing-library/react'; import FileInfo from './FileInfo'; describe('FileInfo', () => { it('should render correctly', () => { const { asFragment } = render(<FileInfo onClear={jest.fn()} metadata={{ file: { name: 'filename.pdf' } }} />); expect(asFragment().firstChild).toMatchSnapshot(); }); }); // Code from file collective-frontend/src/modules/Hub/ClientAppSync/Home/Header/Header.test.jsx import { render } from '@testing-library/react'; import Header from './Header'; describe('Header', () => { it('should render correctly', () => { const { asFragment } = render(<Header />); expect(asFragment().firstChild).toMatchSnapshot(); }); }); // Code from file collective-frontend/src/modules/Hub/ClientAppSync/Home/Header/index.js export { default } from './Header'; // Code from file collective-frontend/src/modules/Hub/ClientAppSync/Home/Header/Header.jsx import classes from './Header.module.less'; export default function Header({ title, subtitle }) { return ( <div className={classes.header}> <h1 className={classes.title}>{title}</h1> {subtitle && <div className={classes.subtitle}>{subtitle}</div>} </div> ); } // Code from file collective-frontend/src/modules/Hub/ClientAppSync/Home/Header/Header.module.less .header { font-family: recoletasemibold; line-height: 32px; .subtitle, .subtitle { letter-spacing: 0.15px; } .title { font-size: 20px; line-height: 32px; font-weight: 500; color: #1c1c1a; } .subtitle { font-family: mier_bregular; font-style: normal; font-weight: 400; font-size: 12px; line-height: 12px; color: #989794; } } // Code from file collective-frontend/src/modules/Hub/ClientAppSync/Home/DeleteFile/DeleteFile.test.jsx import { fireEvent, render, waitFor } from '@testing-library/react'; import MockAdapter from 'axios-mock-adapter'; import { QueryClientProvider, QueryClient } from '@tanstack/react-query'; import DeleteFile from './DeleteFile'; import collectiveApi from 'modules/common/collectiveApi'; const mock = new MockAdapter(collectiveApi); const WrappedComponent = ({ uuid, setError, fetchData }) => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, }, }, }); return ( <QueryClientProvider client={queryClient}> <DeleteFile uuid={uuid} setError={setError} fetchData={fetchData} /> </QueryClientProvider> ); }; describe('DeleteFile', () => { const fakeUUID = '15c3b737-9bd8-4d41-8b01-441240c0dcba'; it('should render correctly', () => { const { asFragment } = render(<WrappedComponent uuid={fakeUUID} fetchData={() => {}} setError={() => {}} />); expect(asFragment()).toMatchSnapshot(); }); it('should render delete button and submit correctly when action is confirmed', async () => { const mockFetchData = jest.fn(); mock.onDelete(`/taxtracker/tax-return-csv-history/${fakeUUID}/`).replyOnce(204, {}); const { getByTestId } = render( <WrappedComponent uuid={fakeUUID} fetchData={mockFetchData} setError={() => {}} /> ); expect(getByTestId('csv-delete-button')).toBeTruthy(); fireEvent.click(getByTestId('csv-delete-button')); expect(getByTestId('yes-button')).toBeInTheDocument(); fireEvent.click(getByTestId('yes-button')); await waitFor(() => expect(mockFetchData).toHaveBeenCalledTimes(1)); expect(mock.history.delete.length).toBe(1); }); it('should render delete button and close Confirmation Modal when action is canceled', async () => { const { getByTestId, queryByTestId } = render( <WrappedComponent uuid={fakeUUID} fetchData={jest.fn()} setError={() => {}} /> ); expect(getByTestId('csv-delete-button')).toBeTruthy(); fireEvent.click(getByTestId('csv-delete-button')); expect(getByTestId('no-button')).toBeInTheDocument(); fireEvent.click(getByTestId('no-button')); expect(queryByTestId('no-button')).not.toBeInTheDocument(); }); it('should set an error when API throws', async () => { const mockFetchData = jest.fn(); const mockSetError = jest.fn(); mock.onDelete(`/taxtracker/tax-return-csv-history/${fakeUUID}/`).replyOnce(500, { detail: 'An error occured.', }); const { getByTestId } = render( <WrappedComponent uuid={fakeUUID} fetchData={mockFetchData} setError={mockSetError} /> ); expect(getByTestId('csv-delete-button')).toBeTruthy(); fireEvent.click(getByTestId('csv-delete-button')); fireEvent.click(getByTestId('yes-button')); await waitFor(() => expect(mockSetError).toHaveBeenCalledTimes(1)); expect(mockSetError).toHaveBeenCalledWith('An error occured.'); }); }); // Code from file collective-frontend/src/modules/Hub/ClientAppSync/Home/DeleteFile/DeleteFile.jsx import PropTypes from 'prop-types'; import { useMutation } from '@tanstack/react-query'; import { LoadingButton } from '@mui/lab'; import DeleteForeverIcon from '@mui/icons-material/DeleteForever'; import ConfirmationModal from 'modules/Dashboard/common/ConfirmationModal'; import collectiveApi from 'modules/common/collectiveApi'; const DeleteFile = ({ uuid, setError, fetchData }) => { const deleteCsvHistory = useMutation( (id) => { return collectiveApi.delete(`/taxtracker/tax-return-csv-history/${id}/`); }, { onSuccess: () => fetchData(1), onError: (error) => { setError(error.response.data.detail || error.message); }, } ); return ( <ConfirmationModal trigger={ <LoadingButton loading={deleteCsvHistory.isLoading} variant="contained" data-testid="csv-delete-button"> <DeleteForeverIcon /> </LoadingButton> } title="Delete file" message="Do you want to delete this file permanently?" yesText="Delete" noText="Cancel" callback={() => deleteCsvHistory.mutate(uuid)} /> ); }; DeleteFile.propTypes = { uuid: PropTypes.string.isRequired, setError: PropTypes.func.isRequired, fetchData: PropTypes.func.isRequired, }; export default DeleteFile; // Code from file collective-frontend/src/modules/Hub/ClientAppSync/Home/DeleteFile/index.js export { default } from './DeleteFile'; // Code from file collective-frontend/src/modules/Hub/ClientAppSync/Home/CommitChangesSuccessModal/CommitChangesSuccessModal.test.jsx import { render } from '@testing-library/react'; import CommitChangesSuccessModal from './CommitChangesSuccessModal'; describe('CommitChangesSuccessModal', () => { it('should render correctly', () => { const { asFragment } = render(<CommitChangesSuccessModal isOpen onClose={() => {}} />); expect(asFragment().firstChild).toMatchSnapshot(); }); it('should render correctly when isOpen is false', () => { const { asFragment } = render(<CommitChangesSuccessModal isOpen={false} onClose={() => {}} />); expect(asFragment().firstChild).toMatchSnapshot(); }); }); // Code from file collective-frontend/src/modules/Hub/ClientAppSync/Home/CommitChangesSuccessModal/index.js export { default } from './CommitChangesSuccessModal'; // Code from file collective-frontend/src/modules/Hub/ClientAppSync/Home/CommitChangesSuccessModal/CommitChangesSuccessModal.module.less .description { width: 250px; margin: 0 auto; font-size: 17px; line-height: 25px; font-weight: 400; padding: 20px 0; text-align: center; } // Code from file collective-frontend/src/modules/Hub/ClientAppSync/Home/CommitChangesSuccessModal/CommitChangesSuccessModal.jsx import { Button, Paper, Typography, Modal } from '@mui/material'; import classes from './CommitChangesSuccessModal.module.less'; import csvUploadSuccess from 'assets/img/pcs-csv-upload-success.svg'; export default function CommitChangesSuccessModal({ isOpen, onClose }) { return ( <Modal open={isOpen} onClose={onClose} aria-labelledby="modal-modal-title" aria-describedby="modal-modal-description"> <Paper className="tw-absolute tw-p-4 -tw-translate-x-1/2 -tw-translate-y-1/2 tw-w-80 tw-left-1/2 tw-top-1/2"> <div className="tw-pt-12 tw-pb-12"> <Typography id="modal-modal-title" className="tw-text-center" variant="h5" component="h2"> Member Data updated </Typography> <p id="modal-modal-description" className={classes.description}> Updates will now be reflected on the client dashboard </p> <div className="tw-flex tw-justify-center"> <img src={csvUploadSuccess} alt="csv upload success" /> </div> <div className="tw-mt-5 tw-justify-center tw-flex"> <Button variant="contained" onClick={onClose}> Close </Button> </div> </div> </Paper> </Modal> ); } // Code from file collective-frontend/src/modules/Hub/ClientAppSync/Home/UploadData/index.js export { default } from './UploadData'; // Code from file collective-frontend/src/modules/Hub/ClientAppSync/Home/UploadData/UploadData.test.jsx import { QueryClientProvider, QueryClient } from '@tanstack/react-query'; import { render } from '@testing-library/react'; import UploadData, { ShowParsingErrors, UploadingFile, ShowUploadError } from './UploadData'; describe('UploadData', () => { const queryClient = new QueryClient(); it('should render correctly', () => { const { asFragment } = render( <QueryClientProvider client={queryClient}> <UploadData /> </QueryClientProvider> ); expect(asFragment().firstChild).toMatchSnapshot(); }); describe('ShowUploadError', () => { it('should render correctly', () => { const errors = []; const postFileUploadError = null; const isPostFileUploadError = false; const { asFragment } = render( <ShowUploadError postFileUploadError={postFileUploadError} errors={errors} isPostFileUploadError={isPostFileUploadError} /> ); expect(asFragment().firstChild).toMatchSnapshot(); }); it('should render correctly with parsing errors defined', () => { const errors = [ { type: 'error', message: 'Error message', }, ]; const postFileUploadError = null; const isPostFileUploadError = true; const { asFragment } = render( <ShowUploadError postFileUploadError={postFileUploadError} errors={errors} isPostFileUploadError={isPostFileUploadError} /> ); expect(asFragment().firstChild).toMatchSnapshot(); }); it('should render correctly showing error message', () => { const errors = []; const postFileUploadError = { message: 'Error message', }; const isPostFileUploadError = true; const { asFragment } = render( <ShowUploadError postFileUploadError={postFileUploadError} errors={errors} isPostFileUploadError={isPostFileUploadError} /> ); expect(asFragment().firstChild).toMatchSnapshot(); }); }); describe('UploadingFile', () => { it('should render correctly', () => { const isPostFileUploadLoading = false; const { asFragment } = render(<UploadingFile isPostFileUploadLoading={isPostFileUploadLoading} />); expect(asFragment().firstChild).toMatchSnapshot(); }); it('should render correctly with loading', () => { const isPostFileUploadLoading = true; const { asFragment } = render(<UploadingFile isPostFileUploadLoading={isPostFileUploadLoading} />); expect(asFragment().firstChild).toMatchSnapshot(); }); }); describe('ShowParsingErrors', () => { it('should render correctly', () => { const errors = []; const { asFragment } = render(<ShowParsingErrors errors={errors} />); expect(asFragment().firstChild).toMatchSnapshot(); }); it('should render correctly with errors', () => { const errors = [ { type: 'error', message: 'Error message', }, ]; const { asFragment } = render(<ShowParsingErrors errors={errors} />); expect(asFragment().firstChild).toMatchSnapshot(); }); }); }); // Code from file collective-frontend/src/modules/Hub/ClientAppSync/Home/UploadData/UploadData.jsx import { useState, useEffect } from 'react'; import { DropzoneArea } from 'material-ui-dropzone'; import { useMutation, QueryClientProvider, QueryClient } from '@tanstack/react-query'; import { Alert, Stack } from '@mui/material'; import classes from './UploadData.module.less'; import { hubClientAppSyncUploadFile } from 'modules/Hub/ClientAppSync/services'; const queryClient = new QueryClient(); export const ShowParsingErrors = ({ errors }) => { if (errors.length === 0) { return null; } return ( <Stack className={classes.alertStack} sx={{ width: '100%' }} spacing={2}> {errors.map((error) => ( <Alert key={error.message} severity={error.type}> {error.message} </Alert> ))} </Stack> ); }; export const UploadingFile = ({ isPostFileUploadLoading }) => { if (!isPostFileUploadLoading) { return null; } return ( <Stack className={classes.alertStack} sx={{ width: '100%' }} spacing={2}> <Alert severity="info">Uploading file...</Alert> </Stack> ); }; export const ShowUploadError = ({ errors, isPostFileUploadError, postFileUploadError }) => { if (errors.length > 0 || !isPostFileUploadError) { return null; } return ( <Stack className={classes.alertStack} sx={{ width: '100%' }} spacing={2}> <Alert severity="error">{postFileUploadError.message}</Alert> </Stack> ); }; const UploadData = ({ onUploadedFile }) => { const [file, setFile] = useState(null); const [errors, setErrors] = useState([]); const { error: postFileUploadError, isError: isPostFileUploadError, isLoading: isPostFileUploadLoading, mutate, } = useMutation(hubClientAppSyncUploadFile, { onSuccess: ({ data }) => { onUploadedFile(file, data); }, onError: ({ response: { data } }) => { setErrors(data.messages); }, }); const saveFile = () => { const formData = new FormData(); formData.append('file', file); mutate(formData); }; useEffect(() => { if (file !== null) { saveFile(); } }, [file]); return ( <QueryClientProvider client={queryClient}> <ShowUploadError errors={errors} isPostFileUploadError={isPostFileUploadError} postFileUploadError={postFileUploadError} /> <UploadingFile isPostFileUploadLoading={isPostFileUploadLoading} /> <ShowParsingErrors errors={errors} /> <DropzoneArea clearOnUnmount={false} acceptedFiles={['text/csv']} dropzoneText="Upload a file or drag and drop .CSV" dropzoneClass={classes.dropzone} previewText="Selected file" filesLimit={1} showFileNames showFileNamesInPreview useChipsForPreview showPreviewsInDropzone={false} showPreviews showAlerts={['error']} previewGridClasses={{ container: classes.previewGridContainer, item: classes.previewGridItem }} onDrop={(files) => setFile(files[0])} /> </QueryClientProvider> ); }; export default UploadData; // Code from file collective-frontend/src/modules/Hub/ClientAppSync/Home/UploadData/UploadData.module.less .dropzone { border: 2px dashed #d1d5dc; border-radius: 4px; background: #ffffff; padding: 20px; text-align: center; margin-bottom: 20px; min-height: 150px !important; div { display: flex; flex-direction: column-reverse; justify-content: center; align-items: center; p { color: #1c1c1a; } } } .previewGridContainer { display: flex; flex-direction: row; flex-wrap: wrap; } .previewGridItem { display: flex; flex-direction: column; justify-content: center; align-items: center; align-content: center; margin: 10px; width: 200px; border: 1px solid #000; border-radius: 5px; background: #fff; cursor: pointer; min-height: 132px; } .alertStack { margin-bottom: 20px; } // Code from file collective-frontend/src/modules/Hub/ClientAppSync/Home/MissingMembersTable/MissingMembersTable.jsx import { useState, useEffect } from 'react'; import { Table, TableBody, Divider, Typography, TableCell, TableHead, TableRow, TextField, FormControl, } from '@mui/material'; import Pagination from '@mui/material/Pagination'; let delayTimer = null; const MissingMembersTable = ({ data, maxRowsPerPage = 25 }) => { const [originalMissingMembers, setOriginalMissingMembers] = useState(null); const [missingMembers, setMissingMembers] = useState(null); const [selectedMembers, setSelectedMembers] = useState([]); const [searchTerm, setSearchTerm] = useState(''); const [page, setPage] = useState(1); const buildRecordData = () => { const members = data.map((row) => { row.searchableText = `${row.fullname} ${row.email} ${row.sfaccountid}`; return row; }); setMissingMembers(members); setOriginalMissingMembers(members); setSelectedMembers([...members.slice(0, maxRowsPerPage)]); }; useEffect(() => { if (missingMembers === null) { buildRecordData(); } }, [data]); const handleSearch = (e) => { const search = e.target.value; clearTimeout(delayTimer); delayTimer = setTimeout(() => { setSearchTerm(search); }, 500); }; useEffect(() => { if (searchTerm !== '') { const filteredMembers = originalMissingMembers.filter((member) => { const terms = searchTerm.split(' '); // search using AND logic const returnMatches = terms.map((term) => { return member.searchableText.toLowerCase().includes(term.toLowerCase()); }); return returnMatches.every((match) => match === true); }); setMissingMembers(filteredMembers); setSelectedMembers([...filteredMembers.slice(0, maxRowsPerPage)]); } if (searchTerm === '') { buildRecordData(); } }, [searchTerm]); const handleSetPage = (e, value) => { const start = (value - 1) * maxRowsPerPage; const end = start + maxRowsPerPage; const newMembers = [...missingMembers.slice(start, end)]; setSelectedMembers(newMembers); }; if (missingMembers === null) { return null; } return ( <> <FormControl> <TextField id="search" label="Member search" type="search" variant="outlined" size="small" inputProps={{ 'data-testid': 'search-input' }} onChange={handleSearch} /> </FormControl> <Divider className="tw-mt-2 tw-mb-2" /> <Typography variant="h6" gutterBottom component="div"> Showing <strong>{missingMembers.length}</strong> of <strong>{data.length}</strong> missing members </Typography> <Pagination className="tw-mb-2" size="small" onChange={handleSetPage} count={Math.ceil(missingMembers.length / maxRowsPerPage)} variant="outlined" shape="rounded" color="primary" data-testid="pagination-top" showFirstButton showLastButton defaultPage={1} /> <Table> <TableHead> <TableRow> <TableCell align="center" className="tw-font-black"> ID </TableCell> <TableCell align="center" className="tw-font-black"> SF Account ID </TableCell> <TableCell className="tw-font-black">Member Name</TableCell> <TableCell className="tw-font-black">Member Email</TableCell> </TableRow> </TableHead> <TableBody> {selectedMembers.map((row) => ( <TableRow key={row.sfaccountid}> <TableCell align="center" component="th" scope="row"> {row.sfaccountid_short} </TableCell> <TableCell align="center">{row.sfaccountid}</TableCell> <TableCell>{row.fullname}</TableCell> <TableCell>{row.email}</TableCell> </TableRow> ))} </TableBody> </Table> <Divider className="tw-mt-2 tw-mb-2" /> <Pagination size="small" onChange={handleSetPage} count={Math.ceil(missingMembers.length / maxRowsPerPage)} variant="outlined" shape="rounded" color="primary" showFirstButton showLastButton data-testid="pagination-bottom" defaultPage={1} /> </> ); }; export default MissingMembersTable; // Code from file collective-frontend/src/modules/Hub/ClientAppSync/Home/MissingMembersTable/index.js export { default } from './MissingMembersTable'; // Code from file collective-frontend/src/modules/Hub/ClientAppSync/Home/MissingMembersTable/MissingMembersTable.test.js import { act, fireEvent, render, waitFor } from '@testing-library/react'; import { ThemeProvider } from '@mui/material'; import MissingMembersTable from './MissingMembersTable'; import theme from 'theme'; const missingMembersData = [ { sfaccountid: 'JOHN456789ABCDEFGH', sfaccountid_short: '9ABCDEFGH', fullname: 'John Doe', email: 'johndoe@domain.com', }, { sfaccountid: 'JANE456789ABCDEFGH', sfaccountid_short: '9ABCDEFGH', fullname: 'Jane Doe', email: 'janedoe@domain.com', }, ]; const setup = (customProps) => { const props = { data: [], ...customProps, }; return render( <ThemeProvider theme={theme}> <MissingMembersTable {...props} /> </ThemeProvider> ); }; describe('MissingMembersTable', () => { it('should render successfully with empty missing members list', async () => { const { asFragment } = setup(); expect(asFragment()).toMatchSnapshot(); }); it('should render successfully with missing members list', async () => { const { asFragment } = setup({ data: missingMembersData }); expect(asFragment()).toMatchSnapshot(); }); it('should render after click to go to next page', async () => { const { asFragment, container, getByText } = setup({ data: missingMembersData, maxRowsPerPage: 1 }); container.querySelector('button[aria-label="Go to page 2"]').click(); await waitFor(() => { expect(getByText('Jane Doe')).toBeInTheDocument(); }); expect(asFragment()).toMatchSnapshot(); }); it('should filter members after type on search input', async () => { const { asFragment, container, getByText, getByTestId } = setup({ data: missingMembersData, maxRowsPerPage: 1, }); const searchInput = getByTestId('search-input'); fireEvent.change(searchInput, { target: { value: 'JANE456789ABCDEFGH' } }); await waitFor(() => { expect(getByText('Jane Doe')).toBeInTheDocument(); }); }); }); // Code from file collective-frontend/src/modules/Hub/ClientAppSync/Home/UploadHistory/UploadHistory.jsx import { useState, useEffect } from 'react'; import { Alert } from '@mui/lab'; import { Typography } from '@mui/material'; import queryString from 'query-string'; import UploadHistoryList from '../UploadHistoryList'; import { fetchTaxReturnCSVHistory } from 'modules/Hub/ClientAppSync/services'; const UploadHistory = () => { const [isLoadingUploadHistory, setIsLoadingUploadHistory] = useState(false); const [uploadHistory, setUploadHistory] = useState(null); const [uploadHistoryPagination, setUploadHistoryPagination] = useState(null); const [error, setError] = useState(null); const fetchData = async (page) => { setIsLoadingUploadHistory(true); const uploadHistoryResponse = await fetchTaxReturnCSVHistory(page); setUploadHistory(uploadHistoryResponse); setIsLoadingUploadHistory(false); }; const handlePageChange = (page) => { fetchData(page); }; useEffect(() => { fetchData(1); }, []); useEffect(() => { const next_page = uploadHistory?.next_page ? queryString.parseUrl(uploadHistory?.next_page).query.page : null; const previous_page = uploadHistory?.previous_page ? queryString.parseUrl(uploadHistory?.previous_page).query.page || 1 : null; setUploadHistoryPagination({ total_record_count: uploadHistory?.total_record_count || uploadHistory?.results.length || 0, number_of_pages: uploadHistory?.number_of_pages, next_page: parseInt(next_page, 10), previous_page: parseInt(previous_page, 10), }); }, [uploadHistory]); return ( <> {error && ( <Alert onClose={() => { setError(null); }} className="tw-mb-2" severity="error"> <Typography>{error}</Typography> </Alert> )} {!isLoadingUploadHistory && ( <UploadHistoryList csvData={uploadHistory?.results || []} csvDataPagination={uploadHistoryPagination} handlePageChange={handlePageChange} setError={setError} fetchData={fetchData} /> )} </> ); }; export default UploadHistory; // Code from file collective-frontend/src/modules/Hub/ClientAppSync/Home/UploadHistory/index.js export { default } from './UploadHistory'; // Code from file collective-frontend/src/modules/Hub/ClientAppSync/Home/UploadHistory/UploadHistory.test.jsx import { render, waitFor } from '@testing-library/react'; import MockAdapter from 'axios-mock-adapter'; import UploadHistory from './UploadHistory'; import collectiveApi from 'modules/common/collectiveApi'; const mockCollectiveApi = new MockAdapter(collectiveApi); describe('UploadHistory', () => { it('should render correctly', async () => { mockCollectiveApi.onGet('taxtracker/tax-return-csv-history/').replyOnce(204, { total_record_count: 2, number_of_pages: 'Page 1 of 1', next_page: null, previous_page: null, results: [], }); const { asFragment, getByText } = render(<UploadHistory />); await waitFor(() => { expect(getByText('Created')).toBeInTheDocument(); }); expect(asFragment().firstChild).toMatchSnapshot(); }); }); // Code from file collective-frontend/src/modules/Hub/common/TransitionInput.js import React, { Component } from 'react'; import { observer } from 'mobx-react'; import ClickOutHandler from 'react-onclickout'; import RootStoreContext from 'modules/common/stores/Root/Context'; class TransitionInput extends Component { static contextType = RootStoreContext; constructor(props, context) { super(props, context); this.store = context.legacyStore; this.state = { edit: false, hover: false, oldValue: '' }; this.clientInfoEditableFields = [ 'home_aptunit', 'home_street', 'home_city', 'home_state', 'home_zipcode', 'home_number', 'business_street', 'business_number', 'business_city', 'business_state', 'business_zipcode', 'business_aptunit', 'mail_street', 'mail_number', 'mail_city', 'mail_state', 'mail_zipcode', 'mail_aptunit', 'business_name', 'entity_number', 'ein', 'federal_business_name', 'registered_agent', 'state_of_incorporation', 'state_of_operation', ]; this.editableClientInfoField = this.props.clientInfo && this.clientInfoEditableFields.includes(this.props.name); this.addDollar = false; } enterEditMode = () => { if (!(this.props.clientInfo || this.store.transactionInfo.approved) || this.editableClientInfoField) { this.setState({ oldValue: this.props.index ? this.store.transactionInfo[this.props.name][this.props.index - 1] : this.store.transactionInfo[this.props.name], edit: true, }); } }; cancelEdit = () => { if (!this.store.keepEmployeeInputState) { if (this.props.index) { this.store.transactionInfo[this.props.name][this.props.index - 1] = this.state.oldValue; } else { this.store.transactionInfo[this.props.name] = this.state.oldValue; } this.setState({ edit: false }); } }; saveEdit = async (removeBtn) => { if (this.editableClientInfoField) { this.store.updateClientInfo(this.props.name); } else if (!this.store.transactionInfo.approved) { if (this.props.name.includes('advised_salary')) { this.store.transactionInfo[this.props.name] = Math.round(this.store.transactionInfo[this.props.name] / 1000) * 1000; await this.store.getTransitionPlanPotentialSavings(); } const data = { [this.props.name]: this.store.transactionInfo[this.props.name] }; await this.store.patchTransitionPlanInfo(data); this.store.keepEmployeeInputState = false; } if (removeBtn) { this.setState({ edit: false, hover: false, }); } else { this.setState({ edit: false, }); } }; convertToBoolean = (e) => { let value = false; if (e.target.value === 'Yes') { value = true; } if (e.target.value === 'Select an option') { value = null; } this.store.transactionInfo[this.props.name] = value; }; setHoverTrue = () => { if (!(this.props.clientInfo || this.store.transactionInfo.approved) || this.editableClientInfoField) { this.setState({ hover: true }); } }; handleInputChange = (e) => { if (!this.store.transactionInfo.approved) { if (this.props.index) { this.store[this.props.clientInfo ? 'transitionClientInfo' : 'transactionInfo'][this.props.name][ this.props.index - 1 ] = e.target.value; } else { this.store[this.props.clientInfo ? 'transitionClientInfo' : 'transactionInfo'][this.props.name] = e.target.value; } } }; render() { this.dollarValue = this.props.dollar ? `${this.store.transactionInfo[this.props.name]}` : ''; if (this.dollarValue) { this.addDollar = !this.dollarValue.includes('$'); } return this.props.notes ? ( <div onMouseEnter={this.setHoverTrue} onMouseLeave={() => this.setState({ hover: false })} className="transition-input transition-note"> <textarea placeholder="Add a note about this section" value={this.store.transactionInfo[this.props.name]} onChange={(e) => this.handleInputChange(e)} /> {this.state.hover && ( <div onClick={() => this.saveEdit(true)} className="transition-edit"> Save </div> )} </div> ) : this.props.choices ? ( <div onMouseEnter={this.setHoverTrue} onMouseLeave={() => this.setState({ hover: false })} className="transition-input"> <select onChange={(e) => this.handleInputChange(e)} value={this.store.transactionInfo[this.props.name]}> <option value="">Select an option</option> {this.props.choices.map((choice) => ( <option value={choice.value}>{choice.name}</option> ))} </select> {this.state.hover && ( <div onClick={() => this.saveEdit(true)} className="transition-edit"> Save </div> )} </div> ) : this.props.link ? ( <div onMouseEnter={this.setHoverTrue} onMouseLeave={() => this.setState({ hover: false })} className="transition-input transition-note"> <a href={this.store.transactionInfo[this.props.name]} target="_blank" rel="noreferrer"> {this.store.transactionInfo[this.props.name]} </a> </div> ) : ( <div className="transition-input" onMouseEnter={this.setHoverTrue} onMouseLeave={() => this.setState({ hover: false })}> {this.state.edit && ( <ClickOutHandler onClickOut={this.cancelEdit}> {this.props.boolean ? ( <select onChange={(e) => this.convertToBoolean(e)} value={ this.props.value || (this.store[this.props.clientInfo ? 'transitionClientInfo' : 'transactionInfo'][ this.props.name ] && this.store[this.props.clientInfo ? 'transitionClientInfo' : 'transactionInfo'][ this.props.name ] === true ? 'Yes' : this.store[ this.props.clientInfo ? 'transitionClientInfo' : 'transactionInfo' ][this.props.name] === false ? 'No' : 'Select an option') }> <option>Select an option</option> <option>Yes</option> <option>No</option> </select> ) : ( <input type={this.props.dollar ? 'number' : 'text'} placeholder={this.props.placeholder} value={ this.props.index ? this.store[ this.props.clientInfo ? 'transitionClientInfo' : 'transactionInfo' ][this.props.name][this.props.index - 1] : this.store[ this.props.clientInfo ? 'transitionClientInfo' : 'transactionInfo' ][this.props.name] } onChange={(e) => this.handleInputChange(e)} /> )} {this.state.edit && ( <div onClick={this.saveEdit} className="transition-edit"> Save </div> )} </ClickOutHandler> )} {!this.state.edit && ( <> <span onClick={this.enterEditMode}> {this.props.boolean ? this.props.value || (this.store[this.props.clientInfo ? 'transitionClientInfo' : 'transactionInfo'][ this.props.name ] && this.store[this.props.clientInfo ? 'transitionClientInfo' : 'transactionInfo'][ this.props.name ] === true ? 'Yes' : this.store[this.props.clientInfo ? 'transitionClientInfo' : 'transactionInfo'][ this.props.name ] === false ? 'No' : 'Select an option') : this.props.index ? this.store[this.props.clientInfo ? 'transitionClientInfo' : 'transactionInfo'][ this.props.name ][this.props.index - 1] : `${this.props.dollar ? (this.addDollar ? '$' : '') : ''}${ this.store[this.props.clientInfo ? 'transitionClientInfo' : 'transactionInfo'][ this.props.name ] }`} </span> {this.state.hover && ( <div onClick={this.enterEditMode} className="transition-edit"> Edit </div> )} </> )} </div> ); } } export default observer(TransitionInput); // Code from file collective-frontend/src/modules/Hub/common/TransitionNavBar.test.jsx import React from 'react'; import { MemoryRouter } from 'react-router-dom'; import { action, observable, makeObservable } from 'mobx'; import { screen, render, cleanup } from '@testing-library/react'; import TransitionNavBar from './TransitionNavBar'; import RootStoreContext from 'modules/common/stores/Root/Context'; class MockLegacyStore { email = 'elon@tesla.com'; fullname = 'Elon Musk'; sfaccountid = '0000000000000000'; bookkeeperTrainingStatus = 'call'; bookkeepingCallReady = false; bookkeeperTrainingTaker = ''; bookkeeperTrainingImage = ''; bookkeeperTrainingTime = ''; bookkeeperTrainingLink = ''; usertype = 'SC-SC'; transitionClientInfo = { member_id: 123, fullname: '', sf_id: '', transition_plan_id: '', business_name: '', }; fetchTrainingCallPrep = () => jest.fn(); fetchCallScheduler = () => jest.fn(); onboardingReadyToSchedule = jest.fn().mockImplementation((checked) => { this.bookkeepingCallReady = checked; }); constructor() { makeObservable(this, { bookkeepingCallReady: observable, bookkeeperTrainingStatus: observable, bookkeeperTrainingTaker: observable, bookkeeperTrainingImage: observable, bookkeeperTrainingTime: observable, bookkeeperTrainingLink: observable, fetchTrainingCallPrep: action, fetchCallScheduler: action, onboardingReadyToSchedule: action, }); } } class MockGustoStore { fetchCompanyOnboardingStatus = jest.fn().mockImplementation(() => ({ onboarding_qualified: true, onboarding_completed: false, onboarding_steps: [ { title: "Add Your Company's Addresses", id: 'add_addresses', completed: false }, { title: 'Enter Your Federal Tax Information', id: 'federal_tax_setup', completed: false }, { title: 'Select Industry', id: 'select_industry', completed: false }, { title: 'Add Your Bank Account', id: 'add_bank_info', completed: false }, { title: 'Add Your Employees', id: 'add_employees', completed: false }, { title: 'Enter Your State Tax Information', id: 'state_setup', completed: false }, { title: 'Select a Pay Schedule', id: 'payroll_schedule', completed: false }, { title: 'Sign Documents', id: 'sign_all_forms', completed: false }, { title: 'Verify Your Bank Account', id: 'verify_bank_info', completed: false }, ], })); constructor() { makeObservable(this, { fetchCompanyOnboardingStatus: action, }); } } const renderWithStores = (legacyStore, gustoStore) => render( <RootStoreContext.Provider value={{ legacyStore, gustoStore }}> <MemoryRouter> <TransitionNavBar /> </MemoryRouter> </RootStoreContext.Provider> ); beforeEach(cleanup); describe('TransitionNavBar', () => { it('should match gusto onboarding on', async () => { const legacyStore = new MockLegacyStore(); const gustoStore = new MockGustoStore(); renderWithStores(legacyStore, gustoStore); const element = await screen.findByText(/Payroll Setup/i); expect(element).toBeInTheDocument(); }); }); // Code from file collective-frontend/src/modules/Hub/common/UploadDocument.js import React, { Component } from 'react'; import { observer } from 'mobx-react'; import { Modal, Button } from 'semantic-ui-react'; import RootStoreContext from 'modules/common/stores/Root/Context'; import UploadToFilestack from 'modules/common/UploadToFilestack'; class UploadTransitionDocument extends Component { static contextType = RootStoreContext; constructor(props, context) { super(props, context); this.store = context.legacyStore; this.state = { showModal: false, documentName: '' }; } componentDidMount() { if (this.props.requiredDoc) { this.setState({ documentName: this.props.documentName, }); } } uploadDocModal = () => { if (this.props.requiredDoc) { return; } this.setState({ showModal: true, }); }; onSuccess = (result) => { this.store.setUploadedDocuments(this.state.documentName, result.filesUploaded, 1); this.store.uploadUserDocuments(this.state.documentName, this.store.transactionInfo.email); this.setState({ showModal: false }, () => { this.store.getTransactionInfo(this.store.transactionClientId); if (this.props.requiredDoc) { window.setTimeout(() => { this.props.refresh(); }, 1500); } else { window.setTimeout(() => { this.store.fetchTransitionUserDocuments(this.store.transactionInfo.email); this.props.update(); // window.location.reload(false); }, 1500); } }); }; render() { return ( <> <div className="transition-document dashed"> <div className="doc-container"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <path d="M18.414 6.414L15.586 3.586C15.211 3.211 14.702 3 14.172 3H7C5.895 3 5 3.895 5 5V19C5 20.105 5.895 21 7 21H17C18.105 21 19 20.105 19 19V7.828C19 7.298 18.789 6.789 18.414 6.414V6.414Z" stroke="#A3998F" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /> <path d="M19 8H15C14.448 8 14 7.552 14 7V3" stroke="#A3998F" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /> </svg> <div> {this.props.requiredDoc ? ( <span> {this.props.documentName}{' '} <span style={{ color: '#CECABD', marginLeft: '12px' }}>Not uploaded by client</span> </span> ) : ( 'Missing a file?' )} </div> </div> <div className="doc-container"> <div> {this.props.requiredDoc ? ( <UploadToFilestack primary size="mini" buttonText="Upload" onSuccess={this.onSuccess} /> ) : ( <Button primary size="mini" onClick={this.uploadDocModal}> Upload </Button> )} </div> </div> </div> <Modal size="large" className={`hyke-modal ${ process.env.REACT_APP_COMPANY_NAME === 'Collective' ? 'collective-modal' : '' }`} open={this.state.showModal} onClose={() => this.setState({ showModal: false })} closeIcon> <Modal.Content> <div className="modal-body"> <h1>Upload document</h1> <div> <div> <div>Document Name:</div> <select value={this.state.documentName} onChange={(e) => this.setState({ documentName: e.target.value })}> <option>Articles of Organization</option> <option>IRS EIN</option> <option>Operating Agreement</option> <option>Business License</option> <option>Tax Return 2019</option> <option>Tax Return 2020</option> <option>Statement of Information</option> <option>Annual Report</option> <option>Form 2553</option> <option>Business Tax Return</option> </select> </div> <UploadToFilestack primary buttonText="Upload document(s)" onSuccess={this.onSuccess} /> </div> </div> </Modal.Content> </Modal> </> ); } } export default observer(UploadTransitionDocument); // Code from file collective-frontend/src/modules/Hub/common/TimeoutModal.js import React, { Component } from 'react'; import { observer } from 'mobx-react'; import { Button } from 'semantic-ui-react'; import modalIllustration from 'assets/img/icons/dashboard/small-modal.svg'; import RootStoreContext from 'modules/common/stores/Root/Context'; class TimeoutModal extends Component { static contextType = RootStoreContext; constructor(props, context) { super(props, context); this.store = context.legacyStore; } componentDidMount() { this.store.keepEmployeeInputState = true; } submit = () => { this.store.resetTimer(); }; render() { return ( <div className="modal-backdrop"> <div style={{ height: '380px' }} className="annual-profit-modal"> <div className="annual-profit-top"> <img alt="modal-illustration" src={modalIllustration} /> </div> <div className="annual-profit-content"> <h1>Still using this page?</h1> <Button primary onClick={this.submit}> Click here to continue with your session </Button> </div> <div onClick={this.submit} className="exit-annual-profit"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <path d="M6 6L18 18" stroke="#A3998F" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" /> <path d="M18 6L6 18" stroke="#A3998F" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" /> </svg> </div> </div> </div> ); } } export default observer(TimeoutModal); // Code from file collective-frontend/src/modules/Hub/common/TransitionNavBar.jsx import { useState, useEffect } from 'react'; import { observer } from 'mobx-react'; import SVG from 'react-inlinesvg'; import { withRouter } from 'react-router-dom'; import MonetizationOnIcon from '@mui/icons-material/MonetizationOn'; import Payments from '@mui/icons-material/Payments'; import useRootStore from 'modules/common/stores/Root/useRootStore'; import useTreatment from 'modules/common/useTreatment'; import config from 'modules/common/config'; const TransitionNavBar = (props) => { const { gustoStore, legacyStore } = useRootStore(); const [showPayrollSetup, setShowPayrollSetup] = useState(false); useEffect(() => { const getPayrollOnboardingStatus = async () => { try { const data = await gustoStore.fetchCompanyOnboardingStatus(legacyStore.transitionClientInfo.member_id); const onboardingQualified = data.onboarding_qualified; setShowPayrollSetup(onboardingQualified); } catch (error) { // do nothing, note the calling function handles error logging setShowPayrollSetup(false); } }; if (legacyStore.transitionClientInfo.member_id) { getPayrollOnboardingStatus(); } }, [legacyStore.transitionClientInfo.member_id]); const getInitials = () => { let name = legacyStore.transitionClientInfo.fullname || ''; if (!name) { return ''; } let initials = ''; name = name.split(' '); if (name) { name.forEach((word) => { if (word) { initials += word[0].toUpperCase(); } }); } return initials; }; const goToTransitionPlan = () => { if (legacyStore.transitionClientInfo.transition_plan_id) { props.history.push(`/hub/transition-plan/${legacyStore.transitionClientInfo.transition_plan_id}/edit`); } else { legacyStore.globalError = 'This client does not have a transition plan.'; } }; const goToPayments = () => { props.history.push(`/hub/client-info/${props.sfid}/payments/${legacyStore.clientInQuestion.stripecustomerid}`); }; return ( <> <div className="transition-top-nav"> <a href="/hub/client-info/search"> <img src="https://collective.com/images/collective__logo.svg" alt="Collective." className="header__logo" /> </a> </div> <div className="transition-plan-nav-bar"> <div className="trans-info-row"> <div className="client-initials">{getInitials()}</div> <div className="client-name"> <h3>{legacyStore.transitionClientInfo.fullname}</h3> <p>{legacyStore.transitionClientInfo.business_name}</p> </div> </div> <div className="transition-nav-links"> <div onClick={goToTransitionPlan} className={`trans-plan-btn ${props.edit ? 'active' : ''} ${ !legacyStore.transitionClientInfo.transition_plan_id ? 'disabled' : '' }`}> <div className="btn-svg"> <svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg"> <circle cx="20" cy="20" r="20" fill="#5700CC" /> <path d="M30 21.8759V21.8748C30 20.2862 28.9975 18.8705 27.4989 18.343V17.7092C27.4989 13.5671 24.1411 10.2092 19.999 10.2092C15.8568 10.2092 12.499 13.5671 12.499 17.7092V18.3434C10.5458 19.0323 9.5209 21.174 10.2098 23.1272C10.5873 24.1976 11.4294 25.0395 12.5 25.4167C13.1509 25.6468 13.865 25.3058 14.0952 24.6549C14.143 24.5195 14.1672 24.377 14.1667 24.2334V17.7092V17.7092C14.1667 14.4876 16.7783 11.8759 20 11.8759C23.2216 11.8759 25.8333 14.4876 25.8333 17.7092V24.2334V24.2334C25.8369 24.584 25.9882 24.9168 26.25 25.1501V25.6234C26.25 26.8517 25.1741 27.2901 24.1666 27.2901H22.685C22.2247 26.4929 21.2054 26.2198 20.4083 26.68C19.6111 27.1402 19.338 28.1596 19.7982 28.9567C20.2585 29.7539 21.2778 30.027 22.0749 29.5668C22.3283 29.4205 22.5387 29.2101 22.685 28.9567H24.1666C26.3741 28.9567 27.9166 27.5859 27.9166 25.6234V25.2251C29.1895 24.5935 29.9961 23.2968 30 21.8759H30Z" fill="white" /> </svg> </div> <h3>Transition Plan</h3> </div> <div onClick={() => props.history.push(`/hub/client-info/${legacyStore.transitionClientInfo.sf_id}/info`) } className={`trans-plan-btn ${ !props.edit && !props.payments && !props.payrollSetup ? 'active' : '' }`}> <div className="btn-svg"> <svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg"> <circle cx="20" cy="20" r="20" fill="#5700CC" /> <path d="M20 21.9165C21.3807 21.9165 22.5 20.7972 22.5 19.4165C22.5 18.0358 21.3807 16.9165 20 16.9165C18.6193 16.9165 17.5 18.0358 17.5 19.4165C17.5 20.7972 18.6193 21.9165 20 21.9165Z" fill="white" /> <path d="M19.9998 22.75C18.3106 22.7489 16.746 23.6386 15.8831 25.0908C15.8124 25.2091 15.8124 25.3567 15.8831 25.475C15.9581 25.5959 16.0909 25.6687 16.2331 25.6667H23.7648C23.9054 25.6674 24.0363 25.5952 24.1106 25.4758C24.1823 25.3578 24.1823 25.2097 24.1106 25.0917C23.2507 23.6393 21.6877 22.749 19.9998 22.75Z" fill="white" /> <path d="M26.2497 11.5H24.1663C23.9362 11.5 23.7497 11.6865 23.7497 11.9167V12.75C23.7497 12.9801 23.9362 13.1667 24.1663 13.1667H25.833C26.0631 13.1667 26.2497 13.3532 26.2497 13.5833V26.9167C26.2497 27.1468 26.0631 27.3333 25.833 27.3333H14.1663C13.9362 27.3333 13.7497 27.1468 13.7497 26.9167V13.5833C13.7497 13.3532 13.9362 13.1667 14.1663 13.1667H15.833C16.0631 13.1667 16.2497 12.9801 16.2497 12.75V11.9167V11.9167C16.2497 11.6865 16.0631 11.5 15.833 11.5H13.7497C12.8292 11.5 12.083 12.2462 12.083 13.1667V27.3333C12.083 28.2538 12.8292 29 13.7497 29H26.2497C27.1701 29 27.9163 28.2538 27.9163 27.3333V13.1667C27.9163 12.2462 27.1701 11.5 26.2497 11.5V11.5Z" fill="white" /> <path d="M17.5 14C17.5 14.6904 18.0596 15.25 18.75 15.25H21.25C21.9404 15.25 22.5 14.6904 22.5 14V12.5C22.5 11.1193 21.3807 10 20 10C18.6193 10 17.5 11.1193 17.5 12.5V14ZM19.1667 12.0833C19.1667 11.6231 19.5398 11.25 20 11.25C20.4602 11.25 20.8333 11.6231 20.8333 12.0833C20.8333 12.5436 20.4602 12.9167 20 12.9167C19.5398 12.9167 19.1667 12.5436 19.1667 12.0833Z" fill="white" /> </svg> </div> <h3>Client Information</h3> </div> <div onClick={goToPayments} className={`trans-plan-btn ${props.payments ? 'active' : ''}`}> <div className="btn-svg"> <MonetizationOnIcon fontSize="large" /> </div> <h3>Payments</h3> </div> {showPayrollSetup && ( <div onClick={() => props.history.push( `/hub/client-info/${legacyStore.transitionClientInfo.member_id}/payroll-setup` ) } className={`trans-plan-btn ${props.payrollSetup ? 'active' : ''}`}> <div className="btn-svg"> <Payments fontSize="large" /> </div> <h3>Payroll Setup</h3> </div> )} </div> <div className="client-type"> <div className="client-type-content"> <p>Client Type</p> <p> <strong> {legacyStore.transitionClientInfo.usertype ? ( <> {legacyStore.transitionClientInfo.usertype !== 'SP-SC' ? 'Takeover' : 'Formation'}{' '} ({legacyStore.transitionClientInfo.usertype}) </> ) : ( '' )} </strong> </p> </div> <div className="client-type-content"> {/* <p>In Progress</p> <p> <strong>EIN, Banking</strong> </p> */} </div> </div> </div> </> ); }; export default withRouter(observer(TransitionNavBar)); // Code from file collective-frontend/src/modules/Hub/common/UploadParticularDocument/index.js export { default } from './UploadParticularDocument'; // Code from file collective-frontend/src/modules/Hub/common/UploadParticularDocument/TransitionDocument.js import React, { Component } from 'react'; import { Modal, Button } from 'semantic-ui-react'; import { observer } from 'mobx-react'; import RootStoreContext from 'modules/common/stores/Root/Context'; class TransitionDocument extends Component { static contextType = RootStoreContext; constructor(props, context) { super(props, context); this.store = context.legacyStore; this.state = { showDeleteModal: false }; } toggleDeleteModal = () => { this.setState((previousState) => ({ showDeleteModal: !previousState.showDeleteModal, })); }; showDocument = (e, url) => { e.preventDefault(); e.stopPropagation(); this.store.fetchDocumentTemporaryLink(url, true); }; removeUpload = (e) => { this.store.removeUploadedDocument( this.props.documentName, this.props.document, this.store.transactionInfo.email ); this.store.getTransactionInfo(this.store.transactionClientId); window.setTimeout(() => { this.props.refresh(); }, 1000); this.toggleDeleteModal(); }; render() { return ( <> <div key={this.props.documentUrl} className="transition-document"> <div className="doc-container"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <path d="M18.414 6.414L15.586 3.586C15.211 3.211 14.702 3 14.172 3H7C5.895 3 5 3.895 5 5V19C5 20.105 5.895 21 7 21H17C18.105 21 19 20.105 19 19V7.828C19 7.298 18.789 6.789 18.414 6.414V6.414Z" stroke="#A3998F" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /> <path d="M19 8H15C14.448 8 14 7.552 14 7V3" stroke="#A3998F" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /> </svg> <div>{this.props.documentName}</div> </div> <div className="doc-container"> <a href={this.props.documentUrl} onClick={(e) => this.showDocument(e, this.props.documentUrl)} target="_blank" className="transition-document-icon" rel="noreferrer"> <svg className="no-margin" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <path d="M3.11824 12.467C2.96124 12.176 2.96124 11.823 3.11824 11.532C5.01024 8.033 8.50524 5 12.0002 5C15.4952 5 18.9902 8.033 20.8822 11.533C21.0392 11.824 21.0392 12.177 20.8822 12.468C18.9902 15.967 15.4952 19 12.0002 19C8.50524 19 5.01024 15.967 3.11824 12.467V12.467Z" stroke="#A3998F" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /> <path d="M14.1213 9.87868C15.2929 11.0502 15.2929 12.9497 14.1213 14.1213C12.9497 15.2929 11.0502 15.2929 9.87868 14.1213C8.70711 12.9497 8.70711 11.0502 9.87868 9.87868C11.0502 8.70711 12.9497 8.70711 14.1213 9.87868Z" stroke="#A3998F" strokeWidth="1.4286" strokeLinecap="round" strokeLinejoin="round" /> </svg> </a> <div className="transition-document-icon"> <svg onClick={this.toggleDeleteModal} style={{ cursor: 'pointer' }} width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg"> <path d="M2 2L10 10" stroke="#A3998F" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /> <path d="M10 2L2 10" stroke="#A3998F" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /> </svg> </div> </div> </div> <Modal size="small" className={`hyke-modal ${ process.env.REACT_APP_COMPANY_NAME === 'Collective' ? 'collective-modal' : '' }`} open={this.state.showDeleteModal} onClose={() => this.setState({ showDeleteModal: false })} closeIcon> <Modal.Content> <div className="modal-body center"> <h1> You're about to delete: <br /> {this.props.documentName} </h1> </div> </Modal.Content> <Modal.Actions> <div className="aligned"> <Button secondary className="back-button" size="huge" content="Keep this file" onClick={this.toggleDeleteModal} /> <Button primary size="huge" content="Yes, delete" onClick={(e) => this.removeUpload(e)} /> </div> </Modal.Actions> </Modal> </> ); } } export default observer(TransitionDocument); // Code from file collective-frontend/src/modules/Hub/common/UploadParticularDocument/UploadParticularDocument.js import React, { Component } from 'react'; import { observer } from 'mobx-react'; import UploadDocument from '../UploadDocument'; import TransitionDocument from './TransitionDocument'; import RootStoreContext from 'modules/common/stores/Root/Context'; class UploadParticularDocument extends Component { static contextType = RootStoreContext; constructor(props, context) { super(props, context); this.store = context.legacyStore; this.state = { docs: [] }; } componentDidMount() { this.refresh(); } refresh = () => { const docs = []; this.store.transitionDocs.forEach((doc) => { if (doc['document-title'].includes(this.props.documentTitle)) { docs.push(doc); } }); this.setState({ docs, }); }; render() { return this.state.docs.length ? ( this.state.docs.map((doc, index) => ( <TransitionDocument key={doc['document-url']} documentName={doc['document-title']} document={doc} documentUrl={doc['document-url']} refresh={this.refresh} /> )) ) : ( <UploadDocument requiredDoc documentName={this.props.documentTitle} refresh={this.refresh} /> ); } } export default observer(UploadParticularDocument); // Code from file collective-frontend/src/modules/Hub/common/Store/Store.js import { makeAutoObservable } from 'mobx'; import { camelCase } from 'lodash'; import * as Sentry from '@sentry/react'; import queryString from 'query-string'; import collectiveApi from 'modules/common/collectiveApi'; export default class HubStore { milestones = []; memberRelationshipManagers = []; onboardingAccountants = []; taxpro = []; pops = []; filter = {}; members = []; membersPagination = { total_record_count: 0, number_of_pages: '', next_page: null, previous_page: null, }; isMembersLoading = false; memberInfo = { name: null, businessName: null, type: null, }; upcomingOutputs = []; memberMilestones = []; memberOutputs = []; constructor() { makeAutoObservable(this); } setMembers = (members) => { this.members = members; }; setMembersPagination = (membersPagination) => { this.membersPagination = membersPagination; }; setIsMembersLoading = (isMembersLoading) => { this.isMembersLoading = isMembersLoading; }; setMemberInfo = (memberInfo) => { this.memberInfo = memberInfo; }; setMemberMilestones = (memberMilestones) => { this.memberMilestones = memberMilestones; }; setMemberOutputs = (memberOutputs) => { this.memberOutputs = memberOutputs; }; setMilestones = (milestones) => { this.milestones = milestones; }; setFilter = (filter) => { this.filter = filter; }; setMemberRelationshipManagers = (memberRelationshipManagers) => { this.memberRelationshipManagers = memberRelationshipManagers; }; setOnboardingAccountants = (onboardingAccountants) => { this.onboardingAccountants = onboardingAccountants; }; setTaxpro = (taxpro) => { this.taxpro = taxpro; }; setPops = (pops) => { this.pops = pops; }; popMemberByID = (memberID) => { this.members = this.members.filter((member) => member?.id !== memberID); }; makeParams = (filter, page, sortColumn, sortDirection) => { let params = {}; if (filter) { params = { ...params, ...filter }; } if (page) { params = { ...params, page }; } if (sortColumn && sortDirection) { params = { ...params, sort_column: sortColumn, sort_direction: sortDirection, }; } return Object.entries({ ...params }) .map((e) => e.join('=')) .join('&'); }; fetchMilestones = async (milestoneURL) => { if (!milestoneURL) { return; } try { const { data: milestones } = await collectiveApi.get(milestoneURL); this.setMilestones(milestones); } catch (error) { console.error('Error while fetching milestones data: ', error); Sentry.captureException(error); throw error; } }; fetchMrmsAndOnbs = async () => { try { const { data: teams } = await collectiveApi.get('/tracker/teams/'); const mrms = teams.member_relationship_manager .filter((name) => name !== '---') .map((name) => ({ key: name, text: name, value: name })); const onbs = teams.bookkeeper .filter((name) => name !== '---') .map((name) => ({ key: name, text: name, value: name })); const pops = teams.pops .filter((name) => name !== '---') .map((name) => ({ key: name, text: name, value: name })); const taxpro = teams.taxpro .filter((name) => name !== '---') .map((name) => ({ key: name, text: name, value: name })); this.setMemberRelationshipManagers(mrms); this.setOnboardingAccountants(onbs); this.setPops(pops); this.setTaxpro(taxpro); } catch (error) { console.error('Error while fetching mrms and onbs data: ', error); Sentry.captureException(error); throw error; } }; setFetchedMembers = (members, newMembers) => { /** * Django's `rest_framework.pagination` returns this response as default pagination. * A URL with the next and the previous page. * In a single specific case, when there is no parameter `?page=` in the previous_page it means the framework is return 1. */ const next_page = members.next_page ? queryString.parseUrl(members.next_page).query.page : null; const previous_page = members.previous_page ? queryString.parseUrl(members.previous_page).query.page || 1 : null; this.setMembers(newMembers); this.setMembersPagination({ total_record_count: members.total_record_count || newMembers.length, number_of_pages: members.number_of_pages, next_page, previous_page, }); }; fetchMembersQTE = async (filter, page = 1) => { try { this.setIsMembersLoading(true); const params = this.makeParams(filter, page); const { data: members } = await collectiveApi.get(`/workflows/qte/?${params}`); const newMembers = members.results; this.setFetchedMembers(members, newMembers); } catch (error) { console.error('Error while fetching members data: ', error); Sentry.captureException(error); throw error; } finally { this.setIsMembersLoading(false); } }; fetchMembersOnboarding = async (filter, page = 1, sortColumn = 'businessname', sortDirection = 'ascending') => { try { this.setIsMembersLoading(true); const params = this.makeParams(filter, page, sortColumn, sortDirection); const { data: members } = await collectiveApi.get(`/tracker/members/?${params}`); const newMembers = members.results.map((member) => { return member.tracking.milestones?.reduce( (acc, cur) => { acc[camelCase(cur.name)] = cur.status; return acc; }, { ...member } ); }); this.setFetchedMembers(members, newMembers); } catch (error) { console.error('Error while fetching members data: ', error); Sentry.captureException(error); throw error; } finally { this.setIsMembersLoading(false); } }; fetchMemberInfo = async (id) => { try { const { data: memberInfo } = await collectiveApi.get(`/tracker/members/${id}/`); this.setMemberInfo(memberInfo[0]); this.setMemberMilestones(memberInfo[0].tracking.milestones); this.fetchMemberOutputs(memberInfo[0].tracking.milestones[0].name); } catch (error) { console.error('Error while fetching members data: ', error); Sentry.captureException(error); throw error; } }; fetchMemberOutputs = (milestoneName) => { const milestone = this.memberMilestones?.find((m) => m.name === milestoneName) || []; this.setMemberOutputs(milestone.outputs); }; patchWorkflowStatusQTE = async (workflowID, taskID, step, action, reason) => { const payload = { step, // review | flag action, // flag | approve | restart | cancel task_id: taskID, reason, }; await collectiveApi.patch(`/workflows/qte/${workflowID}/`, payload); }; } // Code from file collective-frontend/src/modules/Hub/common/Store/Provider.js import React from 'react'; import HubStore from './Store'; import HubStoreContext from './Context'; export default function HubStoreProvider(props) { return <HubStoreContext.Provider value={new HubStore()} {...props} />; } // Code from file collective-frontend/src/modules/Hub/common/Store/Store.test.js import HubStore from './Store'; import collectiveApi from 'modules/common/collectiveApi'; jest.mock('modules/common/collectiveApi'); describe('hubStore', () => { let hubStore; beforeEach(() => { hubStore = new HubStore(); }); it('should fetchMilestones and update milestones', async () => { const milestones = [{ name: 'test' }]; collectiveApi.get.mockReturnValue({ data: milestones }); await hubStore.fetchMilestones('fake-url'); expect(hubStore.milestones).toEqual(milestones); }); it('should fetchMrmsAndOnbs to update memberRelationshipManagers and onboardingAccountants', async () => { const mrmsAndOnbs = { member_relationship_manager: ['test'], bookkeeper: ['test'], pops: ['test'], taxpro: ['test'], }; collectiveApi.get.mockReturnValue({ data: mrmsAndOnbs }); await hubStore.fetchMrmsAndOnbs(); expect(hubStore.memberRelationshipManagers).toHaveLength(1); expect(hubStore.onboardingAccountants).toHaveLength(1); }); it('should fetchMembers to update members', async () => { const members = { total_record_count: 0, number_of_pages: '', next_page: null, previous_page: null, results: [ { id: 1, status: '---', fullname: 'Test Test', businessname: 'Test in a Million LLC', email: 'Test.pinheiro@collective.com', taxpro: 'Prentiss Johnson', bookkeeper: 'Prentiss Johnson', pops: 'Jacob Frediani', member_relationship_manager: 'Hector Rodriguez', usertype: 'SP-SC', onboarding_duration: '2 hours, 14 minutes', tracking: { milestones: [ { name: 'Financial Setup', status: 'Incomplete', }, { name: 'Legal Setup', status: 'Completed', }, { name: 'Technology Setup', status: 'Incomplete', }, ], }, }, ], }; collectiveApi.get.mockReturnValue({ data: members }); hubStore.fetchMembers = hubStore.fetchMembersOnboarding; await hubStore.fetchMembers({}); expect(hubStore.members[0].financialSetup).toBe('Incomplete'); expect(hubStore.members[0].legalSetup).toBe('Completed'); expect(hubStore.members[0].technologySetup).toBe('Incomplete'); }); it('should fetchMemberInfo to update memberInfo', async () => { const memberInfo = [ { fullname: 'Test', businessname: 'Test LLC', usertype: 'Takeover (LLC > SC)', tracking: { milestones: [{ name: 'Test Setup' }], }, }, ]; collectiveApi.get.mockReturnValue({ data: memberInfo }); await hubStore.fetchMemberInfo('test@test.com'); expect(hubStore.memberInfo).toEqual(memberInfo[0]); expect(hubStore.memberMilestones).toEqual([{ name: 'Test Setup' }]); }); }); // Code from file collective-frontend/src/modules/Hub/common/Store/Context.js import { createContext } from 'react'; const HubStoreContext = createContext(); export default HubStoreContext; // Code from file collective-frontend/src/modules/Hub/OnboardingTracker/index.jsx import React from 'react'; import { Route } from 'react-router-dom'; import lazyWithSuspenseAndRetry from 'modules/common/lazyWithSuspenseAndRetry'; export const OnboardingTrackerHomePage = lazyWithSuspenseAndRetry(() => import('./Home/Page')); export const MemberInfoPage = lazyWithSuspenseAndRetry(() => import('./MemberInfo/Page')); export default function getHubOnboardingTrackerRoutes() { return [ <Route exact key="onboarding-tracker-home" path="/hub/onboarding-tracker/" component={OnboardingTrackerHomePage} />, <Route exact key="member-info" path="/hub/tracker-tool/member-info/:email" component={MemberInfoPage} />, ]; } // Code from file collective-frontend/src/modules/Hub/OnboardingTracker/Tags/Tags.test.jsx import { render } from '@testing-library/react'; import Tags from './Tags'; describe('TrackerTool/Tags', () => { it('should render correctly', () => { const { asFragment } = render( <> <Tags text={10} status="completed" /> <Tags text={10} status="incomplete" /> <Tags text={10} /> <Tags text="Hello World" /> </> ); expect(asFragment().firstChild).toMatchSnapshot(); }); it('should render completed tag', () => { const { getByText } = render(<Tags text="10 days" status="completed" />); expect(getByText(/completed/i)).toBeInTheDocument(); }); it('should render incomplete tag', () => { const { getByText } = render(<Tags text="10 days" status="incomplete" />); expect(getByText(/incomplete/i)).toBeInTheDocument(); }); it('should render text tag', () => { const { getByText } = render(<Tags text="Hello world" />); expect(getByText(/hello world/i)).toBeInTheDocument(); }); }); // Code from file collective-frontend/src/modules/Hub/OnboardingTracker/Tags/index.js export { default } from './Tags'; // Code from file collective-frontend/src/modules/Hub/OnboardingTracker/Tags/Tags.module.less @color-green: #008866; @color-red: #fa5a60; @color-olive: #b5cc18; .tags { display: flex; } .tag { margin-left: 10px; font-size: 12px; padding: 3px 8px; border-radius: 20px; background: rgba(0, 0, 0, 0.1); text-transform: capitalize; display: flex; justify-content: center; align-items: center; &[class~='text'] { text-transform: none; } &[class~='single'] { font-size: 1em; margin: 0; border-radius: 20px; padding: 4px 10px 5px 10px; } &[class~='completed'], &[class~='review'] { background: @color-green; color: #ffffff !important; } &[class~='incomplete'] { background: @color-red; color: #ffffff !important; } &[class~='flag'] { background: @color-olive; color: #ffffff !important; } &[class~='days'] { color: #000000; } } // Code from file collective-frontend/src/modules/Hub/OnboardingTracker/Tags/Tags.jsx import StatusIcon from '../StatusIcon'; import classes from './Tags.module.less'; const Tags = ({ status, text, single }) => { return ( <div className={classes.tags}> {text && <div className={`${classes.tag} days text`}>{text}</div>} {status && ( <div className={`${classes.tag} ${status} ${single && 'single'}`}> {single && <StatusIcon status={status} />} {status || 'Upcoming'} </div> )} </div> ); }; export default Tags; // Code from file collective-frontend/src/modules/Hub/OnboardingTracker/StatusIcon/StatusIcon.test.jsx import { render } from '@testing-library/react'; import StatusIcon from './StatusIcon'; describe('TrackerTool/StatusIcon', () => { it('should render correctly', () => { const { asFragment } = render( <> <StatusIcon status="completed" /> <StatusIcon status="incomplete" /> <StatusIcon status="not applicable" /> <StatusIcon /> </> ); expect(asFragment().firstChild).toMatchSnapshot(); }); }); // Code from file collective-frontend/src/modules/Hub/OnboardingTracker/StatusIcon/index.js export { default } from './StatusIcon'; // Code from file collective-frontend/src/modules/Hub/OnboardingTracker/StatusIcon/StatusIcon.jsx import { Icon } from 'semantic-ui-react'; const StatusIcon = ({ status }) => { switch (status) { case 'completed': return <Icon name="check circle outline" />; case 'incomplete': return <Icon name="hourglass one" />; case 'not applicable': return <Icon name="window close outline" />; case 'not started': return <Icon name="tasks" />; case 'review': return <Icon name="hourglass one" />; case 'flag': return <Icon name="flag" />; case 'fetch': return <Icon name="sync" />; default: return <Icon name="circle outline" />; } }; export default StatusIcon; // Code from file collective-frontend/src/modules/Hub/OnboardingTracker/MemberInfo/Page.test.jsx import { render, waitFor } from '@testing-library/react'; import { createMemoryHistory } from 'history'; import { action, makeObservable, observable } from 'mobx'; import { Router } from 'react-router-dom'; import HubStoreContext from '../../common/Store/Context'; import MemberInfoPage from './Page'; class MockHubStore { memberInfo = { email: 'test@test.com', fullname: 'Test', businessname: 'Test LLC', usertype: 'LLC > SC', }; memberMilestones = []; memberOutputs = []; upcomingOutputs = []; setMemberOutputs = () => jest.fn(); fetchMemberInfo = () => jest.fn(); fetchMemberOutputs = () => jest.fn(); fetchMemberUpcomingOutputs = () => jest.fn(); constructor() { makeObservable(this, { memberInfo: observable, memberMilestones: observable, memberOutputs: observable, upcomingOutputs: observable, setMemberOutputs: action, fetchMemberInfo: action, fetchMemberOutputs: action, fetchMemberUpcomingOutputs: action, }); } } const renderWithProviders = (hubStore) => { const history = createMemoryHistory(); return render( <HubStoreContext.Provider value={hubStore}> <Router history={history}> <MemberInfoPage /> </Router> </HubStoreContext.Provider> ); }; describe('TrackerTool/MemberInfo', () => { it('should render correctly', async () => { const hubStore = new MockHubStore(); const { asFragment } = renderWithProviders(hubStore); await waitFor(() => hubStore.fetchMemberInfo().mockResolvedValue({})); await waitFor(() => hubStore.fetchMemberUpcomingOutputs().mockResolvedValue([])); expect(asFragment().firstChild).toMatchSnapshot(); }); it('should render member informaton', async () => { const hubStore = new MockHubStore(); const { getAllByText } = renderWithProviders(hubStore); await waitFor(() => hubStore.fetchMemberInfo().mockResolvedValue({})); await waitFor(() => hubStore.fetchMemberUpcomingOutputs().mockResolvedValue([])); const element = getAllByText(/Test LLC/i); expect(element[0]).toBeInTheDocument(); }); }); // Code from file collective-frontend/src/modules/Hub/OnboardingTracker/MemberInfo/Page.jsx import { useEffect, useState } from 'react'; import { observer } from 'mobx-react'; import { useParams, Link } from 'react-router-dom'; import { Breadcrumb, Loader, Button, Header, Icon, Divider } from 'semantic-ui-react'; import Milestones from './Milestones'; import Outputs from './Outputs'; import MemberStatus from './Status'; import classes from './Page.module.less'; import useHubStore from 'modules/Hub/common/Store/useHubStore'; const MemberInfoPage = () => { const { email } = useParams(); const hubStore = useHubStore(); const [loading, setLoading] = useState(false); useEffect(() => { setLoading(true); const fetchData = async () => { await hubStore.fetchMemberInfo(email); // await hubStore.fetchMemberUpcomingOutputs(email); setLoading(false); }; fetchData(); }, [hubStore, email]); const handleClick = (name) => { hubStore.setMemberOutputs([]); hubStore.fetchMemberOutputs(name); }; if (loading) { return ( <Loader inline="centered" active> Loading </Loader> ); } return ( <> <Header as="h2"> <Icon name="compass" /> Onboarding Tracker </Header> <Divider /> <div className={classes.memberInfo}> <Breadcrumb> <Breadcrumb.Section> <Link to="/hub/onboarding-tracker">Onboarding Tracker</Link> </Breadcrumb.Section> <Breadcrumb.Divider /> <Breadcrumb.Section active>{hubStore.memberInfo.businessname}</Breadcrumb.Section> </Breadcrumb> <MemberStatus memberInfo={hubStore.memberInfo} upcomingOutputs={hubStore.upcomingOutputs} /> <Divider /> <Milestones milestones={hubStore.memberMilestones} onClick={handleClick} /> <Outputs outputs={hubStore.memberOutputs} /> <Divider /> <Link to="/hub/onboarding-tracker"> <Button primary type="button"> <Icon name="chevron left" /> Back </Button> </Link> </div> </> ); }; export default observer(MemberInfoPage); // Code from file collective-frontend/src/modules/Hub/OnboardingTracker/MemberInfo/Status/Status.test.jsx import { render } from '@testing-library/react'; import MemberStatus from './Status'; const memberInfo = { fullname: 'Tester', status: 'Member Active', businessname: 'Test LLC', email: 'test@test.com', usertype: 'SP-SC', }; describe('TrackerTool/MemberStatus', () => { it('should render correctly', () => { const { asFragment } = render(<MemberStatus memberInfo={memberInfo} />); expect(asFragment().firstChild).toMatchSnapshot(); }); it('Should render member information', () => { const { getByText } = render(<MemberStatus memberInfo={memberInfo} />); expect(getByText(/Tester/i)).toBeInTheDocument(); expect(getByText(/Member Active/i)).toBeInTheDocument(); expect(getByText(/Test LLC/i)).toBeInTheDocument(); expect(getByText(/test@test.com/i)).toBeInTheDocument(); expect(getByText(/SP-SC/i)).toBeInTheDocument(); }); }); // Code from file collective-frontend/src/modules/Hub/OnboardingTracker/MemberInfo/Status/index.js export { default } from './Status'; // Code from file collective-frontend/src/modules/Hub/OnboardingTracker/MemberInfo/Status/Status.module.less [class~='ui'][class~='cards'].status { display: flex; flex-direction: row; flex-wrap: nowrap; [class~='card'] { box-shadow: none; width: auto; [class~='content'] { display: flex; flex-direction: column; align-items: flex-start; justify-content: center; [class~='header'] { font-family: 'recoletasemibold'; font-weight: normal; } [class~='meta'] { margin-bottom: 5px; } } } } // Code from file collective-frontend/src/modules/Hub/OnboardingTracker/MemberInfo/Status/Status.jsx import { Card, Header } from 'semantic-ui-react'; import classes from './Status.module.less'; const MemberStatus = ({ memberInfo }) => { return ( <> <Header as="h2">{memberInfo.businessname}</Header> <Card.Group className={classes.status}> <Card> <Card.Content> <Card.Description> <strong>Full Name:</strong> {memberInfo.fullname} </Card.Description> <Card.Description> <strong>Email Address:</strong> {memberInfo.email} </Card.Description> <Card.Description> <strong>Status:</strong> {memberInfo.status} </Card.Description> <Card.Description> <strong>User Type:</strong> {memberInfo.usertype} </Card.Description> </Card.Content> </Card> <Card> <Card.Content> <Card.Description> <strong>Member Relationship Manager:</strong> {memberInfo.member_relationship_manager} </Card.Description> <Card.Description> <strong>Onboarding Accountant:</strong> {memberInfo.bookkeeper} </Card.Description> <Card.Description> <strong>Taxpro:</strong> {memberInfo.taxpro} </Card.Description> <Card.Description> <strong>Pops:</strong> {memberInfo.pops} </Card.Description> </Card.Content> </Card> </Card.Group> </> ); }; export default MemberStatus; // Code from file collective-frontend/src/modules/Hub/OnboardingTracker/MemberInfo/Milestones/Milestones.jsx import { useState } from 'react'; import { Header, Menu, Divider } from 'semantic-ui-react'; import Tags from '../../Tags'; import StatusIcon from '../../StatusIcon'; import classes from './Milestones.module.less'; const Milestones = ({ onClick, milestones }) => { const [activeIndex, setActiveIndex] = useState(0); const handleClick = (index) => { setActiveIndex(index); onClick(milestones[index].name); }; return ( <> <Header as="h3">Milestone Summary</Header> <Menu fluid widths={3} className={classes.milestones}> {milestones.map((milestone, index) => ( <Menu.Item key={milestone.name} active={index === activeIndex} className={milestone.status?.toLowerCase()} onClick={() => handleClick(index)}> <StatusIcon status={milestone.status?.toLowerCase()} /> {milestone.name} <Tags status={milestone.status?.toLowerCase()} text={milestone.status !== milestone.duration && `Duration: ${milestone.duration}`} /> </Menu.Item> ))} </Menu> <Divider /> </> ); }; export default Milestones; // Code from file collective-frontend/src/modules/Hub/OnboardingTracker/MemberInfo/Milestones/Milestones.test.jsx import { render } from '@testing-library/react'; import MemberMilestones from './Milestones'; const milestones = [ { name: 'Legal Setup', status: 'incomplete', members: 144, days: 44.5, }, { name: 'Financial Setup', status: 'completed', members: 55, days: 18, }, { name: 'Technology Setup', status: 'completed', members: 11, days: 4.5, }, ]; describe('TrackerTool/MemberInfo/MemberMilestones', () => { it('should render correctly', () => { const { asFragment } = render(<MemberMilestones milestones={milestones} />); expect(asFragment().firstChild).toMatchSnapshot(); }); it('Should render member milestones data', () => { const { getByText } = render(<MemberMilestones milestones={milestones} />); expect(getByText(/Legal Setup/i)).toBeInTheDocument(); expect(getByText(/Financial Setup/i)).toBeInTheDocument(); expect(getByText(/Technology/i)).toBeInTheDocument(); }); }); // Code from file collective-frontend/src/modules/Hub/OnboardingTracker/MemberInfo/Milestones/index.js export { default } from './Milestones'; // Code from file collective-frontend/src/modules/Hub/OnboardingTracker/MemberInfo/Milestones/Milestones.module.less @color-green: #008866; @color-red: #FA5A60; [class~=ui][class~=menu].milestones { margin-bottom: 20px; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1); border: none; [class~="item"] { padding: 0 40px; cursor: pointer; border-right: 1px solid rgba(0, 0, 0, 0.1); color: rgba(0, 0, 0, 0.6); &[class~="active"] { color: rgba(0, 0, 0, 0.6); background-color: #faf9f6; } &:hover, &:active, &:focus { color: rgba(0, 0, 0, 0.6) !important; background: darken(#faf9f6, 1.5%); } &[class~="completed"] { [class~="icon"] { color: @color-green; } } &[class~="incomplete"] { [class~="icon"] { color: @color-red; } } &::before { background: none; } } }// Code from file collective-frontend/src/modules/Hub/OnboardingTracker/MemberInfo/Page.module.less .memberInfo { i[class~="green"][class~="icon"] { color: #008866 !important; } [class~="ui"][class~="breadcrumb"] { margin-bottom: 30px; } [class~="member"] { margin-bottom: 40px; [class~="member-name"] { margin-bottom: 0; font-family: 'recoletasemibold'; } [class~="member-description"] { opacity: 0.75; } } }// Code from file collective-frontend/src/modules/Hub/OnboardingTracker/MemberInfo/Outputs/index.js export { default } from './Outputs'; // Code from file collective-frontend/src/modules/Hub/OnboardingTracker/MemberInfo/Outputs/Outputs.test.jsx import { render } from '@testing-library/react'; import MemberOutputs from './Outputs'; const outputs = [ { name: 'Output', status: 'complete', days: 10, tasks: [ { name: 'Task?', activity: { status: 'Completed', completed_by: 'System Event', completed_on: '2022-05-06T08:19:01.034761-07:00', }, }, ], }, ]; describe('TrackerTool/MemberInfo/MemberOutputs', () => { it('should render correctly', () => { const { asFragment } = render(<MemberOutputs outputs={outputs} />); expect(asFragment().firstChild).toMatchSnapshot(); }); it('Should render member outputs data', () => { const { getByText } = render(<MemberOutputs outputs={outputs} />); expect(getByText(/Output/i)).toBeInTheDocument(); }); }); // Code from file collective-frontend/src/modules/Hub/OnboardingTracker/MemberInfo/Outputs/Outputs.jsx import { useState } from 'react'; import { Accordion, Icon, Segment } from 'semantic-ui-react'; import Tags from '../../Tags'; import StatusIcon from '../../StatusIcon'; import Tasks from './Tasks'; import classes from './Outputs.module.less'; const Output = (output) => { const [active, setActive] = useState(false); const showDuration = () => { const completedTasks = output.tasks.filter((task) => task.activity?.status === 'Completed'); return completedTasks.length > 0; }; const getTaskProgress = () => { const statusCompleted = output.tasks?.filter((task) => task.activity?.status === 'Completed') || []; const statusNotApplicable = output.tasks?.filter((task) => task.activity?.status === 'Not Applicable') || []; const total = output.tasks || []; return `${statusCompleted.length + statusNotApplicable.length} /${total.length}`; }; return ( <Accordion className={classes.output}> <Accordion.Title className={output.status?.toLowerCase()} active={active} index={0} onClick={() => setActive(!active)}> <div className="left-content"> <Icon name="dropdown" /> <StatusIcon status={output.status?.toLowerCase()} /> {output.name} </div> <div className="right-content"> {showDuration() && <Tags text={`Duration: ${output.duration}`} />} <Tags text={getTaskProgress()} status={output.status?.toLowerCase()} /> </div> </Accordion.Title> <Accordion.Content active={active}> <Tasks tasks={output.tasks} /> </Accordion.Content> </Accordion> ); }; const Outputs = ({ outputs }) => { if (outputs.length === 0) { return ( <Segment padded="very" textAlign="center" className={classes.empty}> <div className="content">No data available.</div> </Segment> ); } return ( <> {outputs.map((output) => ( <Output {...output} key={output.name} /> ))} </> ); }; export default Outputs; // Code from file collective-frontend/src/modules/Hub/OnboardingTracker/MemberInfo/Outputs/Outputs.module.less @color-green: #008866; @color-red: #FA5A60; [class~="ui"][class~="accordion"].output { background: white; border-radius: 5px; padding: 5px 20px; background: #faf9f6; margin-bottom: 20px; box-shadow: 0px 1px 4px rgba(0, 0, 0, 0.1); [class~=right-content] { display: flex; } [class~="title"] { color: #000000; display: flex; justify-content: space-between; [class~="dropdown"] { color: #000000 !important; } &[class~="completed"] { color: @color-green; [class~="icon"] { color: @color-green; } } &[class~="incomplete"] { color: @color-red; [class~="icon"] { color: @color-red; } } } } [class~=ui][class~=segment].empty { background: #faf9f6; [class~=content] { padding: 40px; color: rgba(34, 36, 38, 0.8); opacity: 0.5; } }// Code from file collective-frontend/src/modules/Hub/OnboardingTracker/MemberInfo/Outputs/Tasks/Tasks.test.jsx import { render } from '@testing-library/react'; import Tasks from './Tasks'; const tasks = [ { name: 'Did something great', activity: { status: 'Completed', completed_by: 'System Event', completed_on: '2022-05-06T08:19:01.034761-07:00', }, }, ]; describe('Tasks', () => { it('should render correctly', () => { const { asFragment } = render(<Tasks tasks={tasks} />); expect(asFragment().firstChild).toMatchSnapshot(); }); it('Should render tasks data', () => { const { getByText } = render(<Tasks tasks={tasks} />); expect(getByText(/Did something great/i)).toBeInTheDocument(); }); }); // Code from file collective-frontend/src/modules/Hub/OnboardingTracker/MemberInfo/Outputs/Tasks/Tasks.jsx import { Table } from 'semantic-ui-react'; import Tags from '../../../Tags'; import classes from './Tasks.module.less'; const Tasks = ({ tasks }) => { return ( <Table striped celled basic="very" className={classes.tasks}> <Table.Header> <Table.Row> <Table.HeaderCell width={6}>Task Description</Table.HeaderCell> <Table.HeaderCell width={2}>Task Status</Table.HeaderCell> <Table.HeaderCell width={2}>Completed By</Table.HeaderCell> <Table.HeaderCell width={2}>Completed On</Table.HeaderCell> </Table.Row> </Table.Header> <Table.Body> {tasks?.map((task, index) => ( <Table.Row key={`${task.name}${index}`}> <Table.Cell>{task.name}</Table.Cell> <Table.Cell collapsing> <Tags status={task.activity?.status?.toLowerCase()} single /> </Table.Cell> <Table.Cell collapsing> <strong>{task.activity?.status === 'Completed' && task.activity?.completed_by}</strong> </Table.Cell> <Table.Cell collapsing> {task.activity?.status === 'Completed' && task.activity?.completed_on} </Table.Cell> </Table.Row> ))} </Table.Body> </Table> ); }; export default Tasks; // Code from file collective-frontend/src/modules/Hub/OnboardingTracker/MemberInfo/Outputs/Tasks/index.js export { default } from './Tasks'; // Code from file collective-frontend/src/modules/Hub/OnboardingTracker/MemberInfo/Outputs/Tasks/Tasks.module.less [class~="ui"][class~="table"].tasks { margin-bottom: 10px; thead { font-size: 10px; tr { th { opacity: 0.5; text-transform: uppercase; padding: 10px 20px !important; } } } tbody { background-color: #ffffff; tr { td { padding: 10px 20px !important; border-bottom: none; } } } }// Code from file collective-frontend/src/modules/Hub/OnboardingTracker/Home/Page.test.jsx import { makeObservable, observable, action } from 'mobx'; import { act, render, waitFor } from '@testing-library/react'; import { Router } from 'react-router-dom'; import { createMemoryHistory } from 'history'; import HubStoreContext from '../../common/Store/Context'; import OnboardingTrackerHomePage from './Page'; class MockHubStore { filter = {}; milestones = []; memberRelationshipManagers = []; onboardingAccountants = []; members = []; constructor() { makeObservable(this, { filter: observable, milestones: observable, memberRelationshipManagers: observable, onboardingAccountants: observable, members: observable, fetchMrmsAndOnbs: action, fetchMembersOnboarding: action, setFilter: action, setMembers: action, setMilestones: action, setMemberRelationshipManagers: action, setOnboardingAccountants: action, }); } fetchMembersOnboarding = () => jest.fn(); fetchMilestones = () => jest.fn(); fetchMrmsAndOnbs = () => jest.fn(); setFilter = () => jest.fn(); setMembers = () => jest.fn(); setMilestones = (milestones) => { this.milestones = milestones; }; setMemberRelationshipManagers = (memberRelationshipManagers) => { this.memberRelationshipManagers = memberRelationshipManagers; }; setOnboardingAccountants = (onboardingAccountants) => { this.onboardingAccountants = onboardingAccountants; }; } const milestones = [ { name: 'Legal Setup', status: 'incompleted', members: 144, days: 44.5, }, ]; const mrmsAndOnbs = [{ key: 1, text: 'test', value: 'test' }]; const renderWithProviders = (hubStore) => { const history = createMemoryHistory(); return render( <HubStoreContext.Provider value={hubStore}> <Router history={history}> <OnboardingTrackerHomePage /> </Router> </HubStoreContext.Provider> ); }; describe('Onboarding Tracker', () => { it('should render correctly', async () => { const hubStore = new MockHubStore(); const { asFragment } = renderWithProviders(hubStore); await waitFor(() => hubStore.fetchMrmsAndOnbs().mockResolvedValue(null)); act(() => { hubStore.setMilestones(milestones); hubStore.setMemberRelationshipManagers(mrmsAndOnbs); hubStore.setOnboardingAccountants(mrmsAndOnbs); }); expect(asFragment().firstChild).toMatchSnapshot(); }); }); // Code from file collective-frontend/src/modules/Hub/OnboardingTracker/Home/Page.jsx import { Divider, Header, Icon } from 'semantic-ui-react'; import TrackingTool from '../TrackingTool'; import ExtraFilterFields from './FilterFormFields'; import MemberListActions from './MemberListActions'; import Milestones from './Milestones'; import useHubStore from 'modules/Hub/common/Store/useHubStore'; export default function OnboardingTrackerHomePage() { const hubStore = useHubStore(); const memberTableCells = [ { data: 'businessname', label: 'Business Name' }, { data: 'fullname', label: 'Full Name' }, { data: 'email', label: 'Email Address' }, { data: 'status', label: 'Status' }, { data: 'usertype', label: 'User Type' }, { data: 'onboarding_duration', label: 'Onboarding Duration' }, { data: 'legalSetup', label: 'Legal Setup' }, { data: 'financialSetup', label: 'Financial Setup' }, { data: 'technologySetup', label: 'Technology Setup' }, ]; return ( <> <Header as="h2"> <Icon name="compass" /> Onboarding Tracker </Header> <Divider /> <TrackingTool memberTableCells={memberTableCells} ExtraFilterFields={ExtraFilterFields} MemberListActions={MemberListActions} Milestones={Milestones} fetchMemberFunc={hubStore.fetchMembersOnboarding} /> </> ); } // Code from file collective-frontend/src/modules/Hub/OnboardingTracker/Home/Milestones/Milestones.jsx import React, { useEffect } from 'react'; import { observer } from 'mobx-react'; import { Header, Statistic, Icon } from 'semantic-ui-react'; import useHubStore from 'modules/Hub/common/Store/useHubStore'; const Milestones = () => { const hubStore = useHubStore(); useEffect(() => { const fetchData = async () => { await hubStore.fetchMilestones('/tracker/milestones/'); }; if (hubStore.milestones.length === 0 || hubStore.milestones.length > 4) { fetchData(); } }, []); return ( <> <Header as="h3">Milestone Summary</Header> <Statistic.Group widths={3}> {hubStore?.milestones?.map((item) => ( <Statistic key={item.id}> <Statistic.Value text> <Icon name="clock outline" /> ~ {item.average_duration} </Statistic.Value> <Statistic.Label>{item.name}</Statistic.Label> </Statistic> ))} </Statistic.Group> </> ); }; export default observer(Milestones); // Code from file collective-frontend/src/modules/Hub/OnboardingTracker/Home/Milestones/Milestones.test.jsx import { render } from '@testing-library/react'; import { createMemoryHistory } from 'history'; import { action, configure, makeObservable, observable } from 'mobx'; import { Router } from 'react-router-dom'; import HubStoreContext from '../../../common/Store/Context'; import Milestones from './Milestones'; /** * MobX makes some fields non-configurable or non-writable to disabling spying/mocking/stubbing in tests * @link https://mobx.js.org/configuration.html#safedescriptors-boolean */ configure({ safeDescriptors: false }); class MockHubStore { milestones = [ { name: 'Legal Setup', status: 'incompleted', member_count: 144, average_duration: 44.5, }, ]; constructor() { makeObservable(this, { milestones: observable, setFilter: action, fetchMembersOnboarding: action, setMilestones: action, }); } setFilter = () => jest.fn(); setMilestones = () => jest.fn(); fetchMembersOnboarding = () => jest.fn(); } const renderWithProviders = (hubStore) => { const history = createMemoryHistory(); return render( <HubStoreContext.Provider value={hubStore}> <Router history={history}> <Milestones /> </Router> </HubStoreContext.Provider> ); }; describe('TrackerTool/Milestones', () => { it('should render correctly', () => { const hubStore = new MockHubStore(); const { asFragment } = renderWithProviders(hubStore); expect(asFragment().firstChild).toMatchSnapshot(); }); it('should load milestone title', () => { const hubStore = new MockHubStore(); const { getByText } = renderWithProviders(hubStore); expect(getByText(/Milestone Summary/i)).toBeInTheDocument(); }); }); // Code from file collective-frontend/src/modules/Hub/OnboardingTracker/Home/Milestones/index.js export { default } from './Milestones'; // Code from file collective-frontend/src/modules/Hub/OnboardingTracker/Home/Milestones/Milestones.module.less @color-green: #008866; @color-red: #FA5A60; [class~=ui][class~=menu].milestones { margin-bottom: 20px; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1); border: none; [class~="item"] { padding: 0 40px; cursor: pointer; border-right: 1px solid rgba(0, 0, 0, 0.1); &:hover { color: #000; } &:last-child { border-right: none; } &[class~="active"] { background-color: #faf9f6; } &[class~="completed"] { [class~="icon"] { color: @color-green; } } &[class~="incompleted"] { [class~="icon"] { color: @color-red; } } &::before { background: none; } } }// Code from file collective-frontend/src/modules/Hub/OnboardingTracker/Home/FilterFormFields.jsx import { useEffect } from 'react'; import { Form } from 'semantic-ui-react'; import { observer } from 'mobx-react'; import useHubStore from 'modules/Hub/common/Store/useHubStore'; function ExtraFilterFields({ values, handleSelectChange }) { const hubStore = useHubStore(); useEffect(() => { if (hubStore.onboardingAccountants.length === 0 || hubStore.memberRelationshipManagers.length === 0) { hubStore.fetchMrmsAndOnbs(); } }, [hubStore.onboardingAccountants, hubStore.memberRelationshipManagers]); return ( <> <Form.Select fluid label="Member Relationship Manager" name="member_relationship_manager" id="member_relationship_manager" value={values.member_relationship_manager || ''} options={hubStore.memberRelationshipManagers} onChange={handleSelectChange} placeholder="Member Relationship Manager" search /> <Form.Select fluid label="Onboarding Accountant" name="member_bookkeeper" id="member_bookkeeper" value={values.member_bookkeeper || ''} onChange={handleSelectChange} options={hubStore.onboardingAccountants} placeholder="Onboarding Accountant" search /> <Form.Select fluid label="Taxpro" name="member_taxpro" id="member_taxpro" value={values.member_taxpro || ''} onChange={handleSelectChange} options={hubStore.taxpro} placeholder="Taxpro" search /> <Form.Select fluid label="Pops" name="member_pops" id="member_pops" value={values.member_pops || ''} options={hubStore.pops} onChange={handleSelectChange} placeholder="Pops" search /> </> ); } export default observer(ExtraFilterFields); // Code from file collective-frontend/src/modules/Hub/OnboardingTracker/Home/MemberListActions.jsx import React from 'react'; import { Link } from 'react-router-dom'; import Tooltip from '@mui/material/Tooltip'; import IconButton from '@mui/material/IconButton'; import Preview from '@mui/icons-material/Preview'; const MemberListActions = ({ member }) => ( <Tooltip title="View Details" arrow> <Link target="_blank" to={`/hub/tracker-tool/member-info/${member.id}`}> <IconButton> <Preview fontSize="large" /> </IconButton> </Link> </Tooltip> ); export default MemberListActions; // Code from file collective-frontend/src/modules/Hub/OnboardingTracker/TrackingTool/TrackingTool.jsx import { useEffect, useState } from 'react'; import { observer } from 'mobx-react'; import { Loader, Divider, Accordion, Icon } from 'semantic-ui-react'; import { isEmpty } from 'lodash'; import FilterForm from './FilterForm'; import MemberList from './List'; import classes from './TrackingTool.module.less'; import useHubStore from 'modules/Hub/common/Store/useHubStore'; function TrackingTool({ accordianOpen = true, memberTableCells, ExtraFilterFields, MemberListActions, Milestones, fetchMemberFunc, initialValues = {}, }) { const hubStore = useHubStore(); const [loading, setLoading] = useState(false); const [showAccordian, setShowAccordian] = useState(accordianOpen); const setAccordian = () => { setShowAccordian(!showAccordian); }; useEffect(() => { hubStore.setMembers([]); const fetchOnPageLoad = async () => { await handleSubmit(initialValues); setLoading(false); }; if (!isEmpty(initialValues)) { setLoading(true); fetchOnPageLoad(); } }, [initialValues]); const handleReset = async () => { hubStore.setFilter({}); hubStore.setMembers([]); await hubStore.fetchMembersOnboarding(); }; const handleSubmit = async (values) => { hubStore.setFilter(values); await fetchMemberFunc(values); }; if (loading) { return ( <Loader inline="centered" active> Loading </Loader> ); } return ( <div className={classes.trackerTool}> {Milestones && <Milestones />} <Divider /> <Accordion fluid> <Accordion.Title active={showAccordian === true} onClick={setAccordian} as="h3" className="accordianTitle"> <Icon name="dropdown" /> Search Members </Accordion.Title> <Accordion.Content active={showAccordian === true}> <FilterForm onSubmit={handleSubmit} onReset={handleReset} ExtraFilterFields={ExtraFilterFields} /> </Accordion.Content> </Accordion> <Divider /> <MemberList tableCells={memberTableCells} MemberListActions={MemberListActions} fetchMemberFunc={fetchMemberFunc} /> </div> ); } export default observer(TrackingTool); // Code from file collective-frontend/src/modules/Hub/OnboardingTracker/TrackingTool/List/List.jsx import { observer } from 'mobx-react'; import { useEffect, useState } from 'react'; import { Icon, Loader, Menu, Segment, Table } from 'semantic-ui-react'; import Tags from '../../Tags'; import classes from './List.module.less'; import useHubStore from 'modules/Hub/common/Store/useHubStore'; const MemberList = ({ tableCells, MemberListActions, fetchMemberFunc }) => { const hubStore = useHubStore(); const [hasData, setHasData] = useState(false); const [page, setPage] = useState(1); const [sortColumn, setSortColumn] = useState('businessname'); const [sortDirection, setSortDirection] = useState('ascending'); useEffect(() => { setHasData(hubStore.members.length > 0); }, [hubStore, hubStore.members]); useEffect(() => { const { filter } = hubStore; fetchMemberFunc(filter, page, sortColumn, sortDirection); }, [page, sortColumn, sortDirection]); const changeSort = (column, direction) => { let updatedSortDirection = direction === 'ascending' ? 'descending' : 'ascending'; if (column !== sortColumn) { updatedSortDirection = 'ascending'; } setSortColumn(column); setSortDirection(updatedSortDirection); }; const isSortableColumn = (currentColumn) => { return ['businessname', 'fullname', 'email', 'status', 'usertype'].includes(currentColumn); }; if (hubStore.isMembersLoading) { return ( <Segment padded="very" textAlign="center" className={classes.empty}> <div className="content"> <Loader active>Loading</Loader> </div> </Segment> ); } if (!hasData) { return ( <Segment padded="very" textAlign="center" className={classes.empty}> <div className="content">No data available.</div> </Segment> ); } return ( <Table sortable striped celled basic="very" className={classes.list}> <Table.Header> <Table.Row> {tableCells.map((cell) => ( <Table.HeaderCell key={cell.data} sorted={isSortableColumn(cell.data) && cell.data === sortColumn ? sortDirection : null} onClick={() => isSortableColumn(cell.data) && changeSort(cell.data, sortDirection)}> {cell.label} </Table.HeaderCell> ))} <Table.HeaderCell textAlign="center" width={2}> Actions </Table.HeaderCell> </Table.Row> </Table.Header> <Table.Body> {hubStore.members.map((member) => ( <Table.Row key={member.id}> {tableCells.map((cell) => { return ( <Table.Cell key={`${member.id || member.email}__${cell.data}`}> {['Completed', 'Incomplete', 'Not Started', 'review', 'flag', 'fetch'].includes( member[cell.data] ) ? ( <Tags status={member[cell.data].toLowerCase()} single> {member[cell.data]} </Tags> ) : ( member[cell.data] )} </Table.Cell> ); })} <Table.Cell collapsing textAlign="center" key={`${member.id}__actions`}> {MemberListActions && <MemberListActions member={member} />} </Table.Cell> </Table.Row> ))} </Table.Body> <Table.Footer> <Table.Row> <Table.HeaderCell colSpan={tableCells.length + 1}> <Menu pagination> <Menu.Item as="span" icon> Total number of members: {hubStore.membersPagination.total_record_count} </Menu.Item> </Menu> <Menu floated="right" pagination> <Menu.Item as="span" icon> {hubStore.membersPagination.number_of_pages} </Menu.Item> <Menu.Item as="a" icon disabled={!hubStore.membersPagination.previous_page} onClick={() => setPage(hubStore.membersPagination.previous_page)}> <Icon name="chevron left" /> </Menu.Item> <Menu.Item as="a" icon disabled={!hubStore.membersPagination.next_page} onClick={() => setPage(hubStore.membersPagination.next_page)}> <Icon name="chevron right" /> </Menu.Item> </Menu> </Table.HeaderCell> </Table.Row> </Table.Footer> </Table> ); }; export default observer(MemberList); // Code from file collective-frontend/src/modules/Hub/OnboardingTracker/TrackingTool/List/index.js export { default } from './List'; // Code from file collective-frontend/src/modules/Hub/OnboardingTracker/TrackingTool/List/List.module.less [class~='ui'][class~='table'].list { background: #faf9f6; padding: 20px; thead { font-size: 10px; text-transform: uppercase; tr { th { border: 0; border-radius: 0; box-shadow: none; text-transform: uppercase; color: rgba(0, 0, 0, 0.87); opacity: 0.5; border-bottom: 1px solid rgba(34, 36, 38, 0.1); border-left: transparent !important; } } } tbody { tr { background-color: #ffffff; td { a { color: rgba(0, 0, 0, 0.57); text-decoration: none; transition: all 0.2s linear; &:hover, &:active, &:focus { color: rgba(0, 0, 0, 0.87); } } [class~='ui'][class~='button'] { width: 54px; // color: rgba(34, 36, 38, 0.8); // border: 1px solid rgba(34, 36, 38, 0.2); // background-color: #ffffff; transition: all 0.2s linear; &:hover, &:active, &:focus { color: rgba(34, 36, 38, 0.9); background-color: #faf9f6; } } } } } tfoot { tr { th { padding: 10px 0; [class~='ui'][class~='pagination'][class~='menu'] { &:first-child { border: 0; } a:hover, a:active, a:focus { color: #000000; } } } } } } [class~='ui'][class~='segment'].empty { background: #faf9f6; [class~='content'] { padding: 40px; color: rgba(34, 36, 38, 0.8); opacity: 0.5; } } // Code from file collective-frontend/src/modules/Hub/OnboardingTracker/TrackingTool/List/List.test.jsx import { act, render } from '@testing-library/react'; import { createMemoryHistory } from 'history'; import { action, makeObservable, observable } from 'mobx'; import { Router } from 'react-router-dom'; import HubStoreContext from '../../../common/Store/Context'; import List from './List'; class MockHubStore { members = []; membersPagination = {}; constructor() { makeObservable(this, { members: observable, membersPagination: observable, setMembers: action, setMembersPagination: action, }); } setMembers = (members) => { this.members = members; }; setMembersPagination = (membersPagination) => { this.membersPagination = membersPagination; }; } const renderWithProviders = (hubStore) => { const history = createMemoryHistory(); const tableCells = [ { data: 'businessname', label: 'Business Name' }, { data: 'fullname', label: 'Full Name' }, { data: 'email', label: 'Email Address' }, { data: 'onboarding_duration', label: 'Onboarding Duration' }, { data: 'legalSetup', label: 'Legal Setup' }, { data: 'financialSetup', label: 'Financial Setup' }, { data: 'technologySetup', label: 'Technology Setup' }, ]; return render( <HubStoreContext.Provider value={hubStore}> <Router history={history}> <List tableCells={tableCells} fetchMemberFunc={() => {}} /> </Router> </HubStoreContext.Provider> ); }; describe('TrackerTool/List', () => { it('should render correctly', () => { const hubStore = new MockHubStore(); const { asFragment } = renderWithProviders(hubStore); act(() => { hubStore.setMembers([ { email: 'test@test.com', days: 13, legalSetup: 'incomplete', financialSetup: 'incomplete', technologySetup: 'incomplete', }, ]); hubStore.setMembersPagination({ total_record_count: 0, number_of_pages: '', next_page: null, previous_page: null, }); }); expect(asFragment().firstChild).toMatchSnapshot(); }); it('Should display empty data table', () => { const hubStore = new MockHubStore(); const { getByText } = renderWithProviders(hubStore); expect(getByText(/No data available/i)).toBeInTheDocument(); }); it('Should display populated data table', () => { const hubStore = new MockHubStore(); const { getByText } = renderWithProviders(hubStore); expect(getByText(/No data available/i)).toBeInTheDocument(); act(() => { hubStore.setMembers([ { businessname: 'Test LLC', fullname: 'Test test', email: 'test@test.com', onboarding_duration: 13, legalSetup: 'incomplete', financialSetup: 'incomplete', technologySetup: 'incomplete', }, ]); }); act(() => { hubStore.setMembersPagination({ total_record_count: 0, number_of_pages: '', next_page: null, previous_page: null, }); }); expect(getByText(/test@test.com/i)).toBeInTheDocument(); }); }); // Code from file collective-frontend/src/modules/Hub/OnboardingTracker/TrackingTool/index.js export { default } from './TrackingTool'; // Code from file collective-frontend/src/modules/Hub/OnboardingTracker/TrackingTool/TrackingTool.module.less .trackerTool { h3 { font-family: 'recoletasemibold' !important; font-size: initial !important; } } // Code from file collective-frontend/src/modules/Hub/OnboardingTracker/TrackingTool/FilterForm/index.js export { default } from './FilterForm'; // Code from file collective-frontend/src/modules/Hub/OnboardingTracker/TrackingTool/FilterForm/FilterForm.module.less .filter { [class~='ui'][class~='form'] { [class~='ui'][class~='button'] { font-size: 16px; border: 1px solid !important; padding: 18px !important; border-radius: 5px !important; width: 140px; z-index: 0; &[class~='secondary'] { color: #fa5a60; border-color: lighten(#fa5a60, 0.3); &:hover { background-color: #ffffff; } &[class~='disabled'] { opacity: 0.3 !important; color: rgba(0, 0, 0, 0.5) !important; border-color: rgba(0, 0, 0, 0.2) !important; } } &[class~='disabled'] { opacity: 0.3 !important; } } [class~='fields'] { margin: 0em -0.5em 1em; [class~='dropdown'] { padding: 8px 6px; [class~='text'] { font-size: 1.286em; line-height: 1.30769231em; } [class~='item'] { font-size: 1em; line-height: 1em; } } [class~='field'] { [class~='text'] { color: #000; font-weight: normal; } [class~='default'] { opacity: 0.3; } label { font-family: 'mier_bregular'; font-weight: normal; opacity: 0.7; } input { font-size: 1.286em; line-height: 1.30769231em; padding: 8px 6px; } input::placeholder { opacity: 0.2; } input, input::placeholder { font-family: 'mier_bregular'; box-shadow: none; color: #000; font-weight: normal; } } } } } // Code from file collective-frontend/src/modules/Hub/OnboardingTracker/TrackingTool/FilterForm/FilterForm.test.jsx import { cleanup, render, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { createMemoryHistory } from 'history'; import { makeObservable, observable } from 'mobx'; import { Router } from 'react-router-dom'; import HubStoreContext from '../../../common/Store/Context'; import Filter from './FilterForm'; class MockHubStore { filter = {}; memberRelationshipManagers = [{ key: 1, text: 'test', value: 'test' }]; onboardingAccountants = [{ key: 1, text: 'test', value: 'test' }]; taxpro = [{ key: 1, text: 'test', value: 'test' }]; pops = [{ key: 1, text: 'test', value: 'test' }]; constructor() { makeObservable(this, { filter: observable, memberRelationshipManagers: observable, onboardingAccountants: observable, taxpro: observable, pops: observable, }); } } const handleSubmit = jest.fn(); const handleReset = jest.fn(); const renderWithProviders = (hubStore) => { const history = createMemoryHistory(); return render( <HubStoreContext.Provider value={hubStore}> <Router history={history}> <Filter filter={{}} onSubmit={handleSubmit} onReset={handleReset} /> </Router> </HubStoreContext.Provider> ); }; beforeEach(cleanup); describe('TrackerTool/Filter', () => { it('should render correctly', () => { const hubStore = new MockHubStore(); const { asFragment } = renderWithProviders(hubStore); expect(asFragment().firstChild).toMatchSnapshot(); }); it('Should call the function handleSubmit once', async () => { const hubStore = new MockHubStore(); const user = userEvent.setup(); const { getByLabelText, getByRole } = renderWithProviders(hubStore); await user.type(getByLabelText(/Email Address/i), 'test@test.com'); await user.click(getByRole('button', { name: /Search/i })); await waitFor(() => expect(handleSubmit).toBeCalledTimes(1)); }); it('Should call the function handleReset once', async () => { const hubStore = new MockHubStore(); const user = userEvent.setup(); const { getByLabelText, getByRole } = renderWithProviders(hubStore); await user.type(getByLabelText(/Email Address/i), 'test@test.com'); await user.click(getByRole('button', { name: /Clear/i })); await waitFor(() => expect(handleSubmit).toBeCalledTimes(0)); await waitFor(() => expect(handleReset).toBeCalledTimes(1)); await waitFor(() => expect(getByLabelText(/Email Address/i)).not.toHaveValue('test@test.com')); }); }); // Code from file collective-frontend/src/modules/Hub/OnboardingTracker/TrackingTool/FilterForm/FilterForm.jsx import { useFormik } from 'formik'; import { Form, Icon } from 'semantic-ui-react'; import { observer } from 'mobx-react'; import { isEmpty } from 'lodash'; import { useEffect } from 'react'; import { LoadingButton as Button } from '@mui/lab'; import classes from './FilterForm.module.less'; import useHubStore from 'modules/Hub/common/Store/useHubStore'; const FilterForm = ({ onReset, onSubmit, ExtraFilterFields }) => { const hubStore = useHubStore(); const options = [ { key: 'completed', text: 'Completed', value: 'completed' }, { key: 'incomplete', text: 'Incomplete', value: 'incomplete' }, ]; const { values, dirty, isSubmitting, handleSubmit, handleChange, resetForm, setValues, setFieldValue } = useFormik({ initialValues: { ...hubStore.filter }, onSubmit, onReset, }); useEffect(() => { setValues(hubStore.filter); }, [hubStore, hubStore.filter, setValues]); const handleSelectChange = (event, { value, name }) => { setFieldValue(name, value); }; const handleReset = () => { resetForm({ values: {} }); }; return ( <div className={classes.filter}> <Form> {ExtraFilterFields && ( <Form.Group widths="equal"> <ExtraFilterFields values={values} handleSelectChange={handleSelectChange} /> </Form.Group> )} <Form.Group widths="equal"> <Form.Input fluid name="member_businessname" id="member_businessname" value={values.member_businessname || ''} onChange={handleChange} label="Business Name" placeholder="Business Name" /> <Form.Input fluid name="member_fullname" id="member_fullname" value={values.member_fullname || ''} onChange={handleChange} label="Full Name" placeholder="Full Name" /> <Form.Input fluid name="member_email" id="member_email" value={values.member_email || ''} onChange={handleChange} label="Email Address" placeholder="Email Address" /> <Form.Select fluid label="Activity Status" name="activity_status" id="activity_status" value={values.activity_status || ''} options={options} onChange={handleSelectChange} placeholder="Activity Status" search /> </Form.Group> <div className="tw-flex tw-items-center"> <Button variant="contained" type="submit" className="tw-mr-2" onClick={handleSubmit} disabled={isSubmitting || (!dirty && isEmpty(hubStore.filter))} loading={isSubmitting}> <Icon name="search" /> Search </Button> <Button variant="outlined" type="reset" onClick={handleReset} disabled={isSubmitting || (!dirty && isEmpty(hubStore.filter))}> <Icon name="eraser" /> Clear </Button> </div> </Form> </div> ); }; export default observer(FilterForm); // Code from file collective-frontend/src/modules/Hub/OnboardingTracker/TrackingTool/TrackingTool.test.jsx import { makeObservable, observable, action } from 'mobx'; import { act, render, waitFor } from '@testing-library/react'; import { Router } from 'react-router-dom'; import { createMemoryHistory } from 'history'; import HubStoreContext from '../../common/Store/Context'; import TrackingTool from './TrackingTool'; class MockHubStore { filter = {}; milestones = []; memberRelationshipManagers = []; onboardingAccountants = []; members = []; constructor() { makeObservable(this, { filter: observable, milestones: observable, memberRelationshipManagers: observable, onboardingAccountants: observable, members: observable, fetchMilestones: action, fetchMrmsAndOnbs: action, setFilter: action, setMembers: action, setMilestones: action, setMemberRelationshipManagers: action, setOnboardingAccountants: action, }); } fetchMilestones = () => jest.fn(); fetchMrmsAndOnbs = () => jest.fn(); setFilter = () => jest.fn(); setMembers = () => jest.fn(); setMilestones = (milestones) => { this.milestones = milestones; }; setMemberRelationshipManagers = (memberRelationshipManagers) => { this.memberRelationshipManagers = memberRelationshipManagers; }; setOnboardingAccountants = (onboardingAccountants) => { this.onboardingAccountants = onboardingAccountants; }; } const milestones = [ { name: 'Legal Setup', status: 'incompleted', members: 144, days: 44.5, }, ]; const mrmsAndOnbs = [{ key: 1, text: 'test', value: 'test' }]; const renderWithProviders = (hubStore) => { const history = createMemoryHistory(); return render( <HubStoreContext.Provider value={hubStore}> <Router history={history}> <TrackingTool fetchMemberFunc={() => {}} /> </Router> </HubStoreContext.Provider> ); }; describe('TrackerTool', () => { it('should render correctly', async () => { const hubStore = new MockHubStore(); const { asFragment } = renderWithProviders(hubStore); await waitFor(() => hubStore.fetchMilestones().mockResolvedValue(null)); await waitFor(() => hubStore.fetchMrmsAndOnbs().mockResolvedValue(null)); act(() => { hubStore.setMilestones(milestones); hubStore.setMemberRelationshipManagers(mrmsAndOnbs); hubStore.setOnboardingAccountants(mrmsAndOnbs); }); expect(asFragment().firstChild).toMatchSnapshot(); }); it('should render the component', async () => { const hubStore = new MockHubStore(); const { getByText } = renderWithProviders(hubStore); await waitFor(() => hubStore.fetchMilestones().mockResolvedValue(null)); await waitFor(() => hubStore.fetchMrmsAndOnbs().mockResolvedValue(null)); act(() => { hubStore.setMilestones(milestones); hubStore.setMemberRelationshipManagers(mrmsAndOnbs); hubStore.setOnboardingAccountants(mrmsAndOnbs); }); expect(getByText(/Search Members/i)).toBeInTheDocument(); }); });
Editor is loading...