fix(documents): allow CSV and Excel file uploads for evidence forms#2471
fix(documents): allow CSV and Excel file uploads for evidence forms#2471
Conversation
Customers could only upload PDFs and images when submitting evidence. Added CSV, XLSX, and XLS support to the file type mapper and evidence form definitions (whistleblower-report, tabletop-exercise). Also added an optional spreadsheet upload field to the RBAC matrix form so users can upload their matrix as a file instead of entering rows manually. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
PR SummaryMedium Risk Overview Adds an optional Reviewed by Cursor Bugbot for commit 77d71bd. Bugbot is set up for automated code reviews on this repo. Configure here. |
apps/app/src/app/(app)/[orgId]/documents/components/CompanySubmissionWizard.tsx
Show resolved
Hide resolved
…apper - Add matrixFile to rbacMatrixDataSchema with .refine() so the uploaded file isn't silently stripped by Zod - Make matrixRows optional when a companion file is uploaded (both in the Zod schema and the validateRequiredMatrixCells guard) - Add CSV/XLSX/XLS MIME mappings to the step 3 file uploader (four-step forms like tabletop-exercise) which was missed in the prior commit Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
Autofix Details
Bugbot Autofix prepared fixes for both issues found in the latest run.
- ✅ Fixed: Zod refinement blocked by default empty matrix row
- I preprocess
matrixRowsto treat all-empty rows as undefined so row-level validation no longer blocks file-only submissions and the object-level refine can enforce rows-or-file correctly.
- I preprocess
- ✅ Fixed: File upload renders above matrix, contradicting "rows above"
- I reordered step-2 rendering so matrix fields are displayed before extended/file fields, making the 'rows above' description accurate and the 'Or upload' label logical.
Or push these changes by commenting:
@cursor push 59b3ddd323
Preview (59b3ddd323)
diff --git a/apps/app/src/app/(app)/[orgId]/documents/components/CompanySubmissionWizard.tsx b/apps/app/src/app/(app)/[orgId]/documents/components/CompanySubmissionWizard.tsx
--- a/apps/app/src/app/(app)/[orgId]/documents/components/CompanySubmissionWizard.tsx
+++ b/apps/app/src/app/(app)/[orgId]/documents/components/CompanySubmissionWizard.tsx
@@ -618,6 +618,78 @@
)}
/>
))}
+ {matrixFields.map((field) => {
+ const rows = normalizeMatrixRows(watch(field.key as never));
+ const rowValues = rows.length > 0 ? rows : [createEmptyMatrixRow(field.columns)];
+ const matrixError = errors[field.key as keyof typeof errors];
+ return (
+ <Field key={field.key}>
+ <FieldLabel>{field.label}</FieldLabel>
+ {field.description && (
+ <Text size="sm" variant="muted">
+ {field.description}
+ </Text>
+ )}
+ <div className="space-y-3">
+ {rowValues.map((row, rowIndex) => (
+ <div
+ key={`${field.key}-${rowIndex}`}
+ className="rounded-md border border-border p-3"
+ >
+ <div className="mb-3 flex items-center justify-between">
+ <Text size="sm" weight="medium">
+ Row {rowIndex + 1}
+ </Text>
+ <Button
+ type="button"
+ variant="ghost"
+ onClick={() => removeMatrixRow(field, rowIndex)}
+ disabled={rowValues.length <= 1}
+ >
+ Remove row
+ </Button>
+ </div>
+ <div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
+ {field.columns.map((column) => (
+ <Field key={`${field.key}-${rowIndex}-${column.key}`}>
+ <FieldLabel htmlFor={`${field.key}-${rowIndex}-${column.key}`}>
+ {column.label}
+ </FieldLabel>
+ {column.description && (
+ <Text size="sm" variant="muted">
+ {column.description}
+ </Text>
+ )}
+ <Input
+ id={`${field.key}-${rowIndex}-${column.key}`}
+ value={row[column.key] ?? ''}
+ onChange={(event) =>
+ updateMatrixCell(
+ field,
+ rowIndex,
+ column.key,
+ event.target.value,
+ )
+ }
+ placeholder={column.placeholder}
+ />
+ </Field>
+ ))}
+ </div>
+ </div>
+ ))}
+ <Button
+ type="button"
+ variant="secondary"
+ onClick={() => addMatrixRow(field)}
+ >
+ {field.addRowLabel ?? 'Add row'}
+ </Button>
+ </div>
+ <FieldError errors={[matrixError as never]} />
+ </Field>
+ );
+ })}
{extendedFields.map((field) => (
<Controller
key={field.key}
@@ -726,78 +798,6 @@
)}
/>
))}
- {matrixFields.map((field) => {
- const rows = normalizeMatrixRows(watch(field.key as never));
- const rowValues = rows.length > 0 ? rows : [createEmptyMatrixRow(field.columns)];
- const matrixError = errors[field.key as keyof typeof errors];
- return (
- <Field key={field.key}>
- <FieldLabel>{field.label}</FieldLabel>
- {field.description && (
- <Text size="sm" variant="muted">
- {field.description}
- </Text>
- )}
- <div className="space-y-3">
- {rowValues.map((row, rowIndex) => (
- <div
- key={`${field.key}-${rowIndex}`}
- className="rounded-md border border-border p-3"
- >
- <div className="mb-3 flex items-center justify-between">
- <Text size="sm" weight="medium">
- Row {rowIndex + 1}
- </Text>
- <Button
- type="button"
- variant="ghost"
- onClick={() => removeMatrixRow(field, rowIndex)}
- disabled={rowValues.length <= 1}
- >
- Remove row
- </Button>
- </div>
- <div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
- {field.columns.map((column) => (
- <Field key={`${field.key}-${rowIndex}-${column.key}`}>
- <FieldLabel htmlFor={`${field.key}-${rowIndex}-${column.key}`}>
- {column.label}
- </FieldLabel>
- {column.description && (
- <Text size="sm" variant="muted">
- {column.description}
- </Text>
- )}
- <Input
- id={`${field.key}-${rowIndex}-${column.key}`}
- value={row[column.key] ?? ''}
- onChange={(event) =>
- updateMatrixCell(
- field,
- rowIndex,
- column.key,
- event.target.value,
- )
- }
- placeholder={column.placeholder}
- />
- </Field>
- ))}
- </div>
- </div>
- ))}
- <Button
- type="button"
- variant="secondary"
- onClick={() => addMatrixRow(field)}
- >
- {field.addRowLabel ?? 'Add row'}
- </Button>
- </div>
- <FieldError errors={[matrixError as never]} />
- </Field>
- );
- })}
</>
)}
</FieldGroup>
diff --git a/packages/company/src/evidence-forms/submission-schemas.ts b/packages/company/src/evidence-forms/submission-schemas.ts
--- a/packages/company/src/evidence-forms/submission-schemas.ts
+++ b/packages/company/src/evidence-forms/submission-schemas.ts
@@ -58,10 +58,25 @@
lastReviewed: requiredTrimmed('Last reviewed'),
});
+const emptyMatrixRowsToUndefined = (value: unknown): unknown => {
+ if (!Array.isArray(value)) return value;
+
+ const nonEmptyRows = value.filter((row) => {
+ if (!row || typeof row !== 'object') return true;
+
+ return Object.values(row).some((cell) => {
+ if (typeof cell === 'string') return cell.trim().length > 0;
+ return Boolean(cell);
+ });
+ });
+
+ return nonEmptyRows.length > 0 ? nonEmptyRows : undefined;
+};
+
const rbacMatrixDataSchema = z
.object({
submissionDate: required('Submission date'),
- matrixRows: z.array(rbacMatrixRowSchema).optional(),
+ matrixRows: z.preprocess(emptyMatrixRowsToUndefined, z.array(rbacMatrixRowSchema).optional()),
matrixFile: evidenceFormFileSchema.optional(),
})
.refine((data) => (data.matrixRows && data.matrixRows.length > 0) || data.matrixFile, {This Bugbot Autofix run was free. To enable autofix for future PRs, go to the Cursor dashboard.
|
Bugbot Autofix prepared fixes for all 3 issues found in the latest run.
Or push these changes by commenting: Preview (c6b30e932c)diff --git a/apps/app/src/app/(app)/[orgId]/documents/components/CompanySubmissionWizard.tsx b/apps/app/src/app/(app)/[orgId]/documents/components/CompanySubmissionWizard.tsx
--- a/apps/app/src/app/(app)/[orgId]/documents/components/CompanySubmissionWizard.tsx
+++ b/apps/app/src/app/(app)/[orgId]/documents/components/CompanySubmissionWizard.tsx
@@ -63,6 +63,37 @@
return Object.fromEntries(columns.map((column) => [column.key, '']));
}
+function buildFileAcceptMap(accept?: string): Record<string, string[]> {
+ if (!accept) {
+ return { 'application/pdf': [], 'image/*': [], 'text/*': [] };
+ }
+
+ return Object.fromEntries(
+ accept
+ .split(',')
+ .map((ext): [string, string[]] | null => {
+ const trimmed = ext.trim().toLowerCase();
+ if (trimmed === '.pdf') return ['application/pdf', []];
+ if (trimmed === '.doc') return ['application/msword', []];
+ if (trimmed === '.docx') {
+ return ['application/vnd.openxmlformats-officedocument.wordprocessingml.document', []];
+ }
+ if (trimmed === '.png') return ['image/png', []];
+ if (trimmed === '.jpg' || trimmed === '.jpeg') return ['image/jpeg', []];
+ if (trimmed === '.txt') return ['text/plain', []];
+ if (trimmed === '.svg') return ['image/svg+xml', []];
+ if (trimmed === '.vsdx') return ['application/vnd.visio', []];
+ if (trimmed === '.csv') return ['text/csv', []];
+ if (trimmed === '.xlsx') {
+ return ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', []];
+ }
+ if (trimmed === '.xls') return ['application/vnd.ms-excel', []];
+ return null;
+ })
+ .filter((entry): entry is [string, string[]] => entry !== null),
+ );
+}
+
export function CompanySubmissionWizard({
organizationId,
formType,
@@ -251,6 +282,15 @@
const validateRequiredMatrixCells = () => {
for (const field of matrixFields) {
+ if (field.key === 'matrixRows') {
+ const matrixFileValue = getValues('matrixFile' as never);
+ const hasMatrixFile =
+ typeof matrixFileValue === 'object' &&
+ matrixFileValue !== null &&
+ 'fileName' in matrixFileValue;
+ if (hasMatrixFile) continue;
+ }
+
const rows = normalizeMatrixRows(getValues(field.key as never));
const rowValues = rows.length > 0 ? rows : [createEmptyMatrixRow(field.columns)];
@@ -649,46 +689,7 @@
<FileUploader
maxFileCount={1}
maxSize={100 * 1024 * 1024}
- accept={
- field.accept
- ? Object.fromEntries(
- field.accept
- .split(',')
- .map((ext): [string, string[]] | null => {
- const trimmed = ext.trim().toLowerCase();
- if (trimmed === '.pdf') return ['application/pdf', []];
- if (trimmed === '.doc') return ['application/msword', []];
- if (trimmed === '.docx') {
- return [
- 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
- [],
- ];
- }
- if (trimmed === '.png') return ['image/png', []];
- if (trimmed === '.jpg' || trimmed === '.jpeg') {
- return ['image/jpeg', []];
- }
- if (trimmed === '.txt') return ['text/plain', []];
- if (trimmed === '.svg') return ['image/svg+xml', []];
- if (trimmed === '.vsdx')
- return ['application/vnd.visio', []];
- if (trimmed === '.csv') return ['text/csv', []];
- if (trimmed === '.xlsx') {
- return [
- 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
- [],
- ];
- }
- if (trimmed === '.xls')
- return ['application/vnd.ms-excel', []];
- return null;
- })
- .filter(
- (entry): entry is [string, string[]] => entry !== null,
- ),
- )
- : { 'application/pdf': [], 'image/*': [], 'text/*': [] }
- }
+ accept={buildFileAcceptMap(field.accept)}
disabled={uploadingField === field.key}
onUpload={async (files) => {
const file = files[0];
@@ -817,34 +818,7 @@
<FileUploader
maxFileCount={1}
maxSize={100 * 1024 * 1024}
- accept={
- field.accept
- ? Object.fromEntries(
- field.accept
- .split(',')
- .map((ext): [string, string[]] | null => {
- const trimmed = ext.trim().toLowerCase();
- if (trimmed === '.pdf') return ['application/pdf', []];
- if (trimmed === '.doc') return ['application/msword', []];
- if (trimmed === '.docx') {
- return [
- 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
- [],
- ];
- }
- if (trimmed === '.png') return ['image/png', []];
- if (trimmed === '.jpg' || trimmed === '.jpeg') {
- return ['image/jpeg', []];
- }
- if (trimmed === '.txt') return ['text/plain', []];
- if (trimmed === '.svg') return ['image/svg+xml', []];
- if (trimmed === '.vsdx') return ['application/vnd.visio', []];
- return null;
- })
- .filter((entry): entry is [string, string[]] => entry !== null),
- )
- : { 'application/pdf': [], 'image/*': [], 'text/*': [] }
- }
+ accept={buildFileAcceptMap(field.accept)}
disabled={uploadingField === field.key}
onUpload={async (files) => {
const file = files[0];
diff --git a/packages/company/src/evidence-forms/submission-schemas.ts b/packages/company/src/evidence-forms/submission-schemas.ts
--- a/packages/company/src/evidence-forms/submission-schemas.ts
+++ b/packages/company/src/evidence-forms/submission-schemas.ts
@@ -58,11 +58,55 @@
lastReviewed: requiredTrimmed('Last reviewed'),
});
-const rbacMatrixDataSchema = z.object({
- submissionDate: required('Submission date'),
- matrixRows: z.array(rbacMatrixRowSchema).min(1, 'At least one RBAC entry is required'),
+const optionalRbacMatrixRowSchema = z.object({
+ system: z.string().trim().optional(),
+ roleName: z.string().trim().optional(),
+ permissionsScope: z.string().trim().optional(),
+ approvedBy: z.string().trim().optional(),
+ lastReviewed: z.string().trim().optional(),
});
+const rbacMatrixDataSchema = z
+ .object({
+ submissionDate: required('Submission date'),
+ matrixRows: z.array(optionalRbacMatrixRowSchema).optional(),
+ matrixFile: evidenceFormFileSchema.optional(),
+ })
+ .superRefine((data, ctx) => {
+ if (data.matrixFile) return;
+
+ const rows = data.matrixRows ?? [];
+ const nonEmptyRows = rows.filter((row) =>
+ Object.values(row).some((value) => typeof value === 'string' && value.trim().length > 0),
+ );
+
+ if (nonEmptyRows.length === 0) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: 'At least one RBAC entry is required',
+ path: ['matrixRows'],
+ });
+ return;
+ }
+
+ rows.forEach((row, rowIndex) => {
+ const hasAnyValue = Object.values(row).some(
+ (value) => typeof value === 'string' && value.trim().length > 0,
+ );
+ if (!hasAnyValue) return;
+
+ const parsedRow = rbacMatrixRowSchema.safeParse(row);
+ if (parsedRow.success) return;
+
+ parsedRow.error.issues.forEach((issue) => {
+ ctx.addIssue({
+ ...issue,
+ path: ['matrixRows', rowIndex, ...issue.path],
+ });
+ });
+ });
+ });
+
const infrastructureInventoryRowSchema = z.object({
assetId: requiredTrimmed('Asset ID'),
systemType: requiredTrimmed('System type'),This Bugbot Autofix run was free. To enable autofix for future PRs, go to the Cursor dashboard. |
…lidation The default empty matrix row blocked Zod parsing before the refine could check for a file upload. Switch to superRefine with a lenient base schema that accepts empty strings, then validate non-empty rows strictly only when no file is present. Also fix description text that incorrectly referenced "rows above" when the file field renders first. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit edababd. Configure here.
Iterate over the original matrixRows array instead of a filtered copy so error paths reference the correct row index in the form UI. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
🎉 This PR is included in version 3.17.0 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |


Summary
.csv,.xlsx, and.xlsin theCompanySubmissionWizardfile-type mapper so the dropzone recognizes these extensionsCustomers reported that evidence uploads only accept PDFs and images. The backend (
attachments.service.ts) already allows these file types — only the frontend was blocking them.Test plan
.csv,.xlsx,.xlsfiles.csv,.xlsx,.xls,.pdf.csv,.xlsx,.xlsfiles.pdf(unchanged).xlsxand.csvfile end-to-end and confirm they land in S3 correctly🤖 Generated with Claude Code