Skip to content

Commit 4cf74df

Browse files
feat(myopencre): add CSV import preview and confirmation flow
1 parent 756e128 commit 4cf74df

File tree

2 files changed

+148
-59
lines changed

2 files changed

+148
-59
lines changed

application/frontend/src/pages/MyOpenCRE/MyOpenCRE.scss

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,7 @@
1212

1313
.myopencre-disabled {
1414
opacity: 0.7;
15-
}
15+
}
16+
.myopencre-preview {
17+
margin-bottom: 1rem;
18+
}

application/frontend/src/pages/MyOpenCRE/MyOpenCRE.tsx

Lines changed: 144 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import './MyOpenCRE.scss';
22

3-
import React, { useState } from 'react';
3+
import React, { useRef, useState } from 'react';
44
import { Button, Container, Form, Header, Message } from 'semantic-ui-react';
55

66
import { useEnvironment } from '../../hooks';
@@ -21,17 +21,52 @@ type ImportErrorResponse = {
2121

2222
export const MyOpenCRE = () => {
2323
const { apiUrl } = useEnvironment();
24-
2524
const isUploadEnabled = apiUrl !== '/rest/v1';
2625

2726
const [selectedFile, setSelectedFile] = useState<File | null>(null);
2827
const [loading, setLoading] = useState(false);
2928
const [error, setError] = useState<ImportErrorResponse | null>(null);
3029
const [success, setSuccess] = useState<any | null>(null);
3130

32-
// informational (no-op / empty) messages
31+
const [preview, setPreview] = useState<{
32+
rows: number;
33+
creMappings: number;
34+
uniqueSections: number;
35+
creColumns: string[];
36+
} | null>(null);
37+
3338
const [info, setInfo] = useState<string | null>(null);
39+
const [confirmedImport, setConfirmedImport] = useState(false);
40+
41+
const fileInputRef = useRef<HTMLInputElement | null>(null);
42+
43+
/* ------------------ FILE SELECTION ------------------ */
44+
45+
const onFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
46+
setError(null);
47+
setSuccess(null);
48+
setInfo(null);
49+
setConfirmedImport(false);
50+
setPreview(null);
51+
52+
if (!e.target.files || e.target.files.length === 0) return;
53+
54+
const file = e.target.files[0];
55+
56+
if (!file.name.toLowerCase().endsWith('.csv')) {
57+
setError({
58+
success: false,
59+
type: 'FILE_ERROR',
60+
message: 'Please upload a valid CSV file.',
61+
});
62+
e.target.value = '';
63+
setSelectedFile(null);
64+
return;
65+
}
3466

67+
setSelectedFile(file);
68+
generateCsvPreview(file);
69+
};
3570
/* ------------------ CSV DOWNLOAD ------------------ */
3671

3772
const downloadCreCsv = async () => {
@@ -61,41 +96,15 @@ export const MyOpenCRE = () => {
6196
alert('Failed to download CRE CSV');
6297
}
6398
};
64-
65-
/* ------------------ FILE SELECTION ------------------ */
66-
67-
const onFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
68-
setError(null);
69-
setSuccess(null);
70-
setInfo(null); // lear stale info messages
71-
72-
if (!e.target.files || e.target.files.length === 0) return;
73-
74-
const file = e.target.files[0];
75-
76-
if (!file.name.toLowerCase().endsWith('.csv')) {
77-
setError({
78-
success: false,
79-
type: 'FILE_ERROR',
80-
message: 'Please upload a valid CSV file.',
81-
});
82-
e.target.value = '';
83-
setSelectedFile(null);
84-
return;
85-
}
86-
87-
setSelectedFile(file);
88-
};
89-
9099
/* ------------------ CSV UPLOAD ------------------ */
91100

92101
const uploadCsv = async () => {
93-
if (!selectedFile) return;
102+
if (!selectedFile || !confirmedImport) return;
94103

95104
setLoading(true);
96105
setError(null);
97106
setSuccess(null);
98-
setInfo(null); // reset info on upload
107+
setInfo(null);
99108

100109
const formData = new FormData();
101110
formData.append('cre_csv', selectedFile);
@@ -116,10 +125,11 @@ export const MyOpenCRE = () => {
116125

117126
if (!response.ok) {
118127
setError(payload);
128+
setPreview(null);
129+
setConfirmedImport(false);
119130
return;
120131
}
121132

122-
// handle backend import_type semantics
123133
if (payload.import_type === 'noop') {
124134
setInfo(
125135
'Import completed successfully, but no new CREs or standards were added because all mappings already exist.'
@@ -130,13 +140,19 @@ export const MyOpenCRE = () => {
130140
setSuccess(payload);
131141
}
132142

133-
setSelectedFile(null);
143+
setConfirmedImport(false);
144+
setPreview(null);
145+
if (fileInputRef.current) {
146+
fileInputRef.current.value = '';
147+
}
134148
} catch (err: any) {
135149
setError({
136150
success: false,
137151
type: 'CLIENT_ERROR',
138152
message: err.message || 'Unexpected error during import',
139153
});
154+
setPreview(null);
155+
setConfirmedImport(false);
140156
} finally {
141157
setLoading(false);
142158
}
@@ -165,22 +181,58 @@ export const MyOpenCRE = () => {
165181
return <Message negative>{error.message || 'Import failed'}</Message>;
166182
};
167183

184+
/* ------------------ CSV PREVIEW ------------------ */
185+
186+
const generateCsvPreview = async (file: File) => {
187+
const text = await file.text();
188+
const lines = text.split('\n').filter(Boolean);
189+
190+
if (lines.length < 2) {
191+
setPreview(null);
192+
return;
193+
}
194+
195+
const headers = lines[0].split(',').map((h) => h.trim());
196+
const rows = lines.slice(1);
197+
198+
const creColumns = headers.filter((h) => h.startsWith('CRE'));
199+
let creMappings = 0;
200+
const sectionSet = new Set<string>();
201+
202+
rows.forEach((line) => {
203+
const values = line.split(',');
204+
const rowObj: Record<string, string> = {};
205+
206+
headers.forEach((h, i) => {
207+
rowObj[h] = (values[i] || '').trim();
208+
});
209+
210+
const name = (rowObj['standard|name'] || '').trim();
211+
const id = (rowObj['standard|id'] || '').trim();
212+
213+
if (name || id) {
214+
sectionSet.add(`${name}|${id}`);
215+
}
216+
217+
creColumns.forEach((col) => {
218+
if (rowObj[col]) creMappings += 1;
219+
});
220+
});
221+
222+
setPreview({
223+
rows: rows.length,
224+
creMappings,
225+
uniqueSections: sectionSet.size,
226+
creColumns,
227+
});
228+
};
229+
168230
/* ------------------ UI ------------------ */
169231

170232
return (
171233
<Container className="myopencre-container">
172234
<Header as="h1">MyOpenCRE</Header>
173235

174-
<p>
175-
MyOpenCRE allows you to map your own security standard (e.g. SOC2) to OpenCRE Common Requirements
176-
using a CSV spreadsheet.
177-
</p>
178-
179-
<p>
180-
Start by downloading the CRE catalogue below, then map your standard’s controls or sections to CRE IDs
181-
in the spreadsheet.
182-
</p>
183-
184236
<div className="myopencre-section">
185237
<Button primary onClick={downloadCreCsv}>
186238
Download CRE Catalogue (CSV)
@@ -190,21 +242,8 @@ export const MyOpenCRE = () => {
190242
<div className="myopencre-section myopencre-upload">
191243
<Header as="h3">Upload Mapping CSV</Header>
192244

193-
<p>Upload your completed mapping spreadsheet to import your standard into OpenCRE.</p>
194-
195-
{!isUploadEnabled && (
196-
<Message info className="myopencre-disabled">
197-
CSV upload is disabled on hosted environments due to resource constraints.
198-
<br />
199-
Please run OpenCRE locally to enable standard imports.
200-
</Message>
201-
)}
202-
203245
{renderErrorMessage()}
204-
205-
{/* informational messages for noop / empty */}
206246
{info && <Message info>{info}</Message>}
207-
208247
{success && (
209248
<Message positive>
210249
<strong>Import successful</strong>
@@ -215,15 +254,62 @@ export const MyOpenCRE = () => {
215254
</Message>
216255
)}
217256

257+
{confirmedImport && !loading && !success && !error && (
258+
<Message positive>
259+
CSV validated successfully. Click <strong>Upload CSV</strong> to start importing.
260+
</Message>
261+
)}
262+
263+
{preview && (
264+
<Message info className="myopencre-preview">
265+
<strong>Import Preview</strong>
266+
<ul>
267+
<li>Rows detected: {preview.rows}</li>
268+
<li>CRE mappings found: {preview.creMappings}</li>
269+
<li>Unique standard sections: {preview.uniqueSections}</li>
270+
<li>CRE columns detected: {preview.creColumns.join(', ')}</li>
271+
</ul>
272+
273+
<Button
274+
primary
275+
size="small"
276+
onClick={() => {
277+
setPreview(null);
278+
setConfirmedImport(true);
279+
}}
280+
>
281+
Confirm Import
282+
</Button>
283+
284+
<Button
285+
size="small"
286+
onClick={() => {
287+
setPreview(null);
288+
setConfirmedImport(false);
289+
setSelectedFile(null);
290+
if (fileInputRef.current) fileInputRef.current.value = '';
291+
}}
292+
>
293+
Cancel
294+
</Button>
295+
</Message>
296+
)}
297+
218298
<Form>
219299
<Form.Field>
220-
<input type="file" accept=".csv" disabled={!isUploadEnabled || loading} onChange={onFileChange} />
300+
<input
301+
ref={fileInputRef}
302+
type="file"
303+
accept=".csv"
304+
disabled={!isUploadEnabled || loading || !!preview}
305+
onChange={onFileChange}
306+
/>
221307
</Form.Field>
222308

223309
<Button
224310
primary
225311
loading={loading}
226-
disabled={!isUploadEnabled || !selectedFile || loading}
312+
disabled={!isUploadEnabled || !selectedFile || !confirmedImport || loading}
227313
onClick={uploadCsv}
228314
>
229315
Upload CSV

0 commit comments

Comments
 (0)