Skip to content

Commit

Permalink
Make invoice file separate field (opencollective#11021)
Browse files Browse the repository at this point in the history
* Separate invoice file from attachments

* Update graphql

* update lang

* Fix rebase
  • Loading branch information
hdiniz authored Feb 25, 2025
1 parent 176bd84 commit 9fa19da
Show file tree
Hide file tree
Showing 42 changed files with 652 additions and 170 deletions.
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ env:
PDF_SERVICE_URL: http://localhost:3002
API_KEY: dvl-1510egmf4a23d80342403fb599qd
CI: true
DISABLE_MOCK_UPLOADS: 1

jobs:
lint:
Expand Down
27 changes: 25 additions & 2 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,16 @@ env:
API_KEY: dvl-1510egmf4a23d80342403fb599qd
CI: true

AWS_KEY: user
AWS_SECRET: password
AWS_S3_BUCKET: opencollective-e2e
AWS_S3_REGION: us-east-1
AWS_S3_API_VERSION: latest
AWS_S3_ENDPOINT: http://localhost:9000
AWS_S3_SSL_ENABLED: false
AWS_S3_FORCE_PATH_STYLE: true
GRAPHQL_ERROR_DETAILED: true
DISABLE_MOCK_UPLOADS: 1
E2E_TEST: 1
PGHOST: localhost
PGUSER: postgres
Expand All @@ -31,8 +41,6 @@ env:
TERM: xterm
STRIPE_WEBHOOK_KEY: ${{ secrets.STRIPE_WEBHOOK_KEY }}
STRIPE_WEBHOOK_SIGNING_SECRET: ${{ secrets.STRIPE_WEBHOOK_SIGNING_SECRET }}
AWS_KEY: ${{ secrets.AWS_KEY }}
AWS_SECRET: ${{ secrets.AWS_SECRET }}

jobs:
e2e:
Expand Down Expand Up @@ -63,6 +71,14 @@ jobs:
- 5432:5432
# needed because the postgres container does not provide a healthcheck
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
minio:
image: minio/minio:edge-cicd
ports:
- 9000:9000
options: --name=minio --health-cmd "curl http://localhost:9000/minio/health/live"
env:
MINIO_ROOT_USER: user
MINIO_ROOT_PASSWORD: password

steps:
# man-db trigger on apt install is taking some time
Expand All @@ -87,6 +103,13 @@ jobs:
wget https://github.com/stripe/stripe-cli/releases/download/v1.13.9/stripe_1.13.9_linux_x86_64.tar.gz -O /tmp/stripe_1.13.9_linux_x86_64.tar.gz
sudo tar xzvf /tmp/stripe_1.13.9_linux_x86_64.tar.gz -C /bin/
- name: Setup minio bucket
run: |
wget https://dl.min.io/client/mc/release/linux-amd64/mc
chmod +x ./mc
./mc alias set minio http://127.0.0.1:9000 user password
./mc mb --ignore-existing minio/opencollective-e2e
- name: Checkout (frontend)
uses: actions/checkout@v4

Expand Down
2 changes: 1 addition & 1 deletion components/Dropzone.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,7 @@ type DropzoneProps = React.HTMLAttributes<HTMLDivElement> & {
} & (
| {
/** Collect File only, do not upload files */
collectFilesOnly: true;
collectFilesOnly?: boolean;
/** Whether the dropzone should accept multiple files */
isMulti?: boolean;
/** Called back with the uploaded files on success */
Expand Down
55 changes: 50 additions & 5 deletions components/budget/ExpenseBudgetItem.js
Original file line number Diff line number Diff line change
Expand Up @@ -117,14 +117,19 @@ const ExpenseBudgetItem = ({
}) => {
const intl = useIntl();
const { LoggedInUser } = useLoggedInUser();
const [showFilesViewerModal, setShowFilesViewerModal] = React.useState(false);
const [showFilesViewerModal, setShowFilesViewerModal] = React.useState(null);
const featuredProfile = isInverted ? expense?.account : expense?.payee;
const isAdminView = view === 'admin';
const isSubmitterView = view === 'submitter';
const isCharge = expense?.type === expenseTypes.CHARGE;
const pendingReceipt = isCharge && expense?.items?.every(i => i.url === null);
const files = React.useMemo(() => getFilesFromExpense(expense, intl), [expense]);
const nbAttachedFiles = !isAdminView ? 0 : files.length;
const invoiceFile = expense?.invoiceFile;
const attachedFiles = React.useMemo(
() => getFilesFromExpense(expense, intl).filter(f => !invoiceFile || f.url !== invoiceFile.url),
[expense, intl, invoiceFile],
);
const files = React.useMemo(() => getFilesFromExpense(expense, intl), [expense, intl]);
const nbAttachedFiles = !isAdminView ? 0 : attachedFiles.length;
const isExpensePaidOrRejected = [ExpenseStatus.REJECTED, ExpenseStatus.PAID].includes(expense?.status);
const shouldDisplayStatusTagActions =
(isExpensePaidOrRejected || expense?.status === ExpenseStatus.APPROVED) &&
Expand Down Expand Up @@ -423,6 +428,40 @@ const ExpenseBudgetItem = ({
)}
</div>
)}
{expense.invoiceFile && (
<div>
<DetailColumnHeader>
<FormattedMessage defaultMessage="Invoice" id="Expense.Type.Invoice" />
</DetailColumnHeader>
{isLoading ? (
<LoadingPlaceholder height={15} width={90} />
) : (
<StyledButton
color="black.700"
fontSize="11px"
cursor="pointer"
buttonSize="tiny"
onClick={
useDrawer
? e => expandExpense(e, expense.invoiceFile.url)
: () => setShowFilesViewerModal([expense.invoiceFile])
}
px={2}
ml={-2}
isBorderless
textAlign="left"
>
<MaximizeIcon size={10} />
&nbsp;&nbsp;
<FormattedMessage
id="ExpenseInvoice.count"
defaultMessage="{count, plural, one {# invoice} other {# invoices}}"
values={{ count: 1 }}
/>
</StyledButton>
)}
</div>
)}
{nbAttachedFiles > 0 && (
<div>
<DetailColumnHeader>
Expand All @@ -436,7 +475,11 @@ const ExpenseBudgetItem = ({
fontSize="11px"
cursor="pointer"
buttonSize="tiny"
onClick={useDrawer ? expandExpense : () => setShowFilesViewerModal(true)}
onClick={
useDrawer
? e => expandExpense(e, attachedFiles[0].url)
: () => setShowFilesViewerModal(attachedFiles)
}
px={2}
ml={-2}
isBorderless
Expand All @@ -453,6 +496,7 @@ const ExpenseBudgetItem = ({
)}
</div>
)}

{lastComment && (
<div>
<DetailColumnHeader>
Expand Down Expand Up @@ -519,7 +563,7 @@ const ExpenseBudgetItem = ({
},
{ expenseId: expense.legacyId },
)}
onClose={() => setShowFilesViewerModal(false)}
onClose={() => setShowFilesViewerModal(null)}
/>
)}
</ExpenseContainer>
Expand Down Expand Up @@ -557,6 +601,7 @@ ExpenseBudgetItem.propTypes = {
items: PropTypes.arrayOf(PropTypes.object),
requiredLegalDocuments: PropTypes.arrayOf(PropTypes.string),
attachedFiles: PropTypes.arrayOf(PropTypes.object),
invoiceFile: PropTypes.object,
payee: PropTypes.shape({
id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
type: PropTypes.string.isRequired,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -307,11 +307,11 @@ const HostExpenses = ({ accountSlug: hostSlug }: DashboardSectionProps) => {
}}
useDrawer
openExpenseLegacyId={Number(router.query.openExpenseId)}
setOpenExpenseLegacyId={legacyId => {
setOpenExpenseLegacyId={(legacyId, attachmentUrl) => {
router.push(
{
pathname: pageRoute,
query: getQueryParams({ ...query, openExpenseId: legacyId }),
query: getQueryParams({ ...query, openExpenseId: legacyId, attachmentUrl }),
},
undefined,
{ shallow: true },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ export const TaxInformationForm = ({
}

return (
<FormikZod
<FormikZod<typeof schema._output>
schema={schema}
initialValues={initialValues}
config={FORM_CONFIG}
Expand Down
73 changes: 69 additions & 4 deletions components/expenses/EditExpenseDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { FormField } from '../FormField';
import { FormikZod } from '../FormikZod';
import { AdditionalAttachments, ExpenseItemsForm } from '../submit-expense/form/ExpenseItemsSection';
import { PayoutMethodFormContent } from '../submit-expense/form/PayoutMethodSection';
import { InvoiceFormOption } from '../submit-expense/form/TypeOfExpenseSection';
import { WhoIsGettingPaidForm } from '../submit-expense/form/WhoIsGettingPaidSection';
import { InviteeAccountType, useExpenseForm, YesNoOption } from '../submit-expense/useExpenseForm';
import { Button } from '../ui/Button';
Expand Down Expand Up @@ -48,6 +49,8 @@ const RenderFormFields = ({ field, onSubmit, expense }) => {
return <EditPayee onSubmit={onSubmit} expense={expense} />;
case 'attachments':
return <EditAttachments onSubmit={onSubmit} expense={expense} />;
case 'invoiceFile':
return <EditInvoiceFile onSubmit={onSubmit} expense={expense} />;
}
};
const EditPayee = ({ expense, onSubmit }) => {
Expand Down Expand Up @@ -235,10 +238,12 @@ const EditAttachments = ({ expense, onSubmit }) => {
pickSchemaFields: { expenseAttachedFiles: true },
});
const transformedOnSubmit = values => {
const attachedFiles = values.additionalAttachments.map(a => ({
url: typeof a === 'string' ? a : a?.url,
}));

const editValues = {
attachedFiles: values.additionalAttachments.map(a => ({
url: typeof a === 'string' ? a : a?.url,
})),
attachedFiles,
};
return onSubmit(editValues);
};
Expand Down Expand Up @@ -279,6 +284,66 @@ const EditAttachments = ({ expense, onSubmit }) => {
</FormikProvider>
);
};

const EditInvoiceFile = ({ expense, onSubmit }) => {
const formRef = React.useRef<HTMLFormElement>();
const startOptions = React.useRef({
expenseId: expense.legacyId,
isInlineEdit: true,
pickSchemaFields: { invoiceFile: true, invoiceNumber: true },
});
const transformedOnSubmit = values => {
let invoiceFile;
if (values.hasInvoiceOption === YesNoOption.YES && values.invoiceFile) {
invoiceFile = { url: typeof values.invoiceFile === 'string' ? values.invoiceFile : values.invoiceFile.url };
} else if (values.hasInvoiceOption === YesNoOption.NO) {
invoiceFile = null;
}

const editValues = {
invoiceFile,
reference: values.invoiceNumber,
};
return onSubmit(editValues);
};

const expenseForm = useExpenseForm({
formRef,
initialValues: {
inviteeAccountType: InviteeAccountType.INDIVIDUAL,
expenseItems: [
{
amount: {
valueInCents: 0,
currency: 'USD',
},
description: '',
},
],
additionalAttachments: [],
hasInvoiceOption: YesNoOption.YES,
inviteeNewIndividual: {},
inviteeNewOrganization: {
organization: {},
},
newPayoutMethod: {
data: {},
},
},
startOptions: startOptions.current,
onSubmit: transformedOnSubmit,
});

return (
<FormikProvider value={expenseForm}>
<form className="space-y-4" ref={formRef} onSubmit={e => e.preventDefault()}>
<InvoiceFormOption {...InvoiceFormOption.getFormProps(expenseForm)} />
<EditExpenseActionButtons loading={expenseForm.initialLoading} handleSubmit={expenseForm.handleSubmit} />
</form>
</FormikProvider>
);
};

const EditExpenseItems = ({ expense, onSubmit }) => {
const formRef = React.useRef<HTMLFormElement>();
const startOptions = React.useRef({
Expand Down Expand Up @@ -395,7 +460,7 @@ export default function EditExpenseDialog({
goToLegacyEdit,
}: {
expense: Expense;
field: 'title' | 'expenseItems' | 'payoutMethod' | 'payee' | 'type' | 'attachments';
field: 'title' | 'expenseItems' | 'payoutMethod' | 'payee' | 'type' | 'attachments' | 'invoiceFile';
title: string;
description?: string;
dialogContentClassName?: string;
Expand Down
4 changes: 2 additions & 2 deletions components/expenses/Expense.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ function Expense(props) {
createdUser: null,
showFilesViewerModal: false,
});
const [openUrl, setOpenUrl] = useState(null);
const [openUrl, setOpenUrl] = useState(router.query.attachmentUrl as string);
const [replyingToComment, setReplyingToComment] = useState(null);
const [hasConfirmedOCR, setConfirmedOCR] = useState(false);
const hasItemsWithOCR = Boolean(state.editedExpense?.items?.some(itemHasOCR));
Expand Down Expand Up @@ -430,7 +430,7 @@ function Expense(props) {
setState({ ...state, showFilesViewerModal: true });
};

const files = React.useMemo(() => getFilesFromExpense(expense, intl), [expense]);
const files = React.useMemo(() => getFilesFromExpense(expense, intl), [expense, intl]);

useEffect(() => {
const showFilesViewerModal = isDrawer && isDesktop && files?.length > 0;
Expand Down
24 changes: 17 additions & 7 deletions components/expenses/ExpenseAttachedFilesForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React from 'react';
import { uniqBy } from 'lodash';
import { FormattedMessage } from 'react-intl';

import type { Account } from '../../lib/graphql/types/v2/schema';
import type { Account, UploadedFileKind } from '../../lib/graphql/types/v2/schema';
import { attachmentDropzoneParams } from './lib/attachments';

import Dropzone from '../Dropzone';
Expand All @@ -20,6 +20,9 @@ const ExpenseAttachedFilesForm = ({
title,
description,
onChange,
fieldName = 'attachedFiles',
isSingle = false,
kind,
}: ExpenseAttachedFilesFormProps) => {
const [files, setFiles] = React.useState(uniqBy(defaultValue, 'url'));

Expand All @@ -42,7 +45,7 @@ const ExpenseAttachedFilesForm = ({
<PrivateInfoIcon className="text-muted-foreground" />
</Span>
<StyledHr flex="1" borderColor="black.300" mx={2} />
{files?.length > 0 && (
{files?.length > 0 && !isSingle && (
<AddNewAttachedFilesButton
disabled={disabled}
onSuccess={newFiles => {
Expand All @@ -69,14 +72,18 @@ const ExpenseAttachedFilesForm = ({
) : (
<Dropzone
{...attachmentDropzoneParams}
isMulti
name="attachedFiles"
kind="EXPENSE_ATTACHED_FILE"
isMulti={!isSingle}
name={fieldName}
kind={kind || 'EXPENSE_ATTACHED_FILE'}
disabled={disabled}
minHeight={72}
onSuccess={uploadedFiles => {
setFiles(uploadedFiles);
onChange(uploadedFiles);
let value = uploadedFiles;
if (!Array.isArray(uploadedFiles)) {
value = [uploadedFiles];
}
setFiles(value);
onChange(value);
}}
/>
)}
Expand All @@ -91,6 +98,9 @@ type ExpenseAttachedFilesFormProps = {
disabled?: boolean;
hasOCRFeature?: boolean;
collective?: Account;
fieldName?: string;
isSingle?: boolean;
kind?: UploadedFileKind;
onChange: (attachedFiles: Array<{ url: string }>) => void;
};

Expand Down
Loading

0 comments on commit 9fa19da

Please sign in to comment.