Skip to content

Commit 3d88c45

Browse files
Merge branch 'main' into fix/global-background-regression
2 parents 2c79448 + 142bb2f commit 3d88c45

6 files changed

Lines changed: 260 additions & 19 deletions

File tree

application/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import os
22

33
basedir = os.path.abspath(os.path.dirname(__file__))
4+
ENABLE_MYOPENCRE = os.getenv("ENABLE_MYOPENCRE", "false").lower() == "true"
45

56

67
class Config:

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
.myopencre-container {
2+
margin-top: 3rem;
3+
}
4+
15
.myopencre-section {
26
margin-top: 2rem;
37
}
@@ -9,3 +13,17 @@
913
.myopencre-disabled {
1014
opacity: 0.7;
1115
}
16+
17+
.myopencre-preview {
18+
margin-top: 1rem;
19+
}
20+
21+
.myopencre-intro {
22+
font-size: 1.05rem;
23+
font-weight: 400;
24+
margin-bottom: 0.5rem;
25+
}
26+
27+
.cursor-pointer summary {
28+
cursor: pointer;
29+
}

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

Lines changed: 209 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,31 @@
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';
77

8+
type RowValidationError = {
9+
row: number;
10+
code: string;
11+
message: string;
12+
column?: string;
13+
};
14+
15+
type ImportErrorResponse = {
16+
success: false;
17+
type: string;
18+
message?: string;
19+
errors?: RowValidationError[];
20+
};
21+
22+
type CsvPreview = {
23+
rows: number;
24+
creMappings: number;
25+
uniqueSections: number;
26+
creColumns: string[];
27+
};
28+
829
export const MyOpenCRE = () => {
930
const { apiUrl } = useEnvironment();
1031

@@ -13,8 +34,13 @@ export const MyOpenCRE = () => {
1334

1435
const [selectedFile, setSelectedFile] = useState<File | null>(null);
1536
const [loading, setLoading] = useState(false);
16-
const [error, setError] = useState<string | null>(null);
37+
const [error, setError] = useState<ImportErrorResponse | null>(null);
1738
const [success, setSuccess] = useState<any | null>(null);
39+
const [info, setInfo] = useState<string | null>(null);
40+
const [preview, setPreview] = useState<CsvPreview | null>(null);
41+
const [confirmedImport, setConfirmedImport] = useState(false);
42+
43+
const fileInputRef = useRef<HTMLInputElement>(null);
1844

1945
/* ------------------ CSV DOWNLOAD ------------------ */
2046

@@ -49,34 +75,89 @@ export const MyOpenCRE = () => {
4975
}
5076
};
5177

78+
/* ------------------ CSV PREVIEW ------------------ */
79+
80+
const generateCsvPreview = async (file: File) => {
81+
const text = await file.text();
82+
const lines = text.split('\n').filter(Boolean);
83+
84+
if (lines.length < 2) {
85+
setPreview(null);
86+
return;
87+
}
88+
89+
const headers = lines[0].split(',').map((h) => h.trim());
90+
const rows = lines.slice(1);
91+
92+
const creColumns = headers.filter((h) => h.startsWith('CRE'));
93+
let creMappings = 0;
94+
const sectionSet = new Set<string>();
95+
96+
rows.forEach((line) => {
97+
const values = line.split(',');
98+
const rowObj: Record<string, string> = {};
99+
100+
headers.forEach((h, i) => {
101+
rowObj[h] = (values[i] || '').trim();
102+
});
103+
104+
const name = (rowObj['standard|name'] || '').trim();
105+
const id = (rowObj['standard|id'] || '').trim();
106+
107+
if (name || id) {
108+
sectionSet.add(`${name}|${id}`);
109+
}
110+
111+
creColumns.forEach((col) => {
112+
if (rowObj[col]) creMappings += 1;
113+
});
114+
});
115+
116+
setPreview({
117+
rows: rows.length,
118+
creMappings,
119+
uniqueSections: sectionSet.size,
120+
creColumns,
121+
});
122+
};
123+
52124
/* ------------------ FILE SELECTION ------------------ */
53125

54126
const onFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
55127
setError(null);
56128
setSuccess(null);
129+
setInfo(null);
130+
setPreview(null);
131+
setConfirmedImport(false);
57132

58133
if (!e.target.files || e.target.files.length === 0) return;
59134

60135
const file = e.target.files[0];
61136

62137
if (!file.name.toLowerCase().endsWith('.csv')) {
63-
setError('Please upload a valid CSV file.');
138+
setError({
139+
success: false,
140+
type: 'FILE_ERROR',
141+
message: 'Please upload a valid CSV file.',
142+
});
64143
e.target.value = '';
65144
setSelectedFile(null);
66145
return;
67146
}
68147

69148
setSelectedFile(file);
149+
generateCsvPreview(file);
70150
};
71151

72152
/* ------------------ CSV UPLOAD ------------------ */
73153

74154
const uploadCsv = async () => {
75-
if (!selectedFile) return;
155+
if (!selectedFile || !confirmedImport) return;
76156

77157
setLoading(true);
78158
setError(null);
79159
setSuccess(null);
160+
setInfo(null);
80161

81162
const formData = new FormData();
82163
formData.append('cre_csv', selectedFile);
@@ -93,25 +174,68 @@ export const MyOpenCRE = () => {
93174
);
94175
}
95176

177+
const payload = await response.json();
178+
96179
if (!response.ok) {
97-
const text = await response.text();
98-
throw new Error(text || 'CSV import failed');
180+
setError(payload);
181+
return;
99182
}
100183

101-
const result = await response.json();
102-
setSuccess(result);
103-
setSelectedFile(null);
184+
if (payload.import_type === 'noop') {
185+
setInfo(
186+
'Import completed successfully, but no new CREs or standards were added because all mappings already exist.'
187+
);
188+
} else if (payload.import_type === 'empty') {
189+
setInfo('The uploaded CSV did not contain any importable rows. No changes were made.');
190+
} else {
191+
setSuccess(payload);
192+
}
193+
194+
setConfirmedImport(false);
195+
setPreview(null);
196+
if (fileInputRef.current) {
197+
fileInputRef.current.value = '';
198+
}
104199
} catch (err: any) {
105-
setError(err.message || 'Unexpected error during import');
200+
setError({
201+
success: false,
202+
type: 'CLIENT_ERROR',
203+
message: err.message || 'Unexpected error during import',
204+
});
205+
setPreview(null);
206+
setConfirmedImport(false);
106207
} finally {
107208
setLoading(false);
108209
}
109210
};
110211

212+
/* ------------------ ERROR RENDERING ------------------ */
213+
214+
const renderErrorMessage = () => {
215+
if (!error) return null;
216+
217+
if (error.errors && error.errors.length > 0) {
218+
return (
219+
<Message negative>
220+
<strong>Import failed due to validation errors</strong>
221+
<ul>
222+
{error.errors.map((e, idx) => (
223+
<li key={idx}>
224+
<strong>Row {e.row}:</strong> {e.message}
225+
</li>
226+
))}
227+
</ul>
228+
</Message>
229+
);
230+
}
231+
232+
return <Message negative>{error.message || 'Import failed'}</Message>;
233+
};
234+
111235
/* ------------------ UI ------------------ */
112236

113237
return (
114-
<Container style={{ marginTop: '3rem' }}>
238+
<Container className="myopencre-container">
115239
<Header as="h1">MyOpenCRE</Header>
116240

117241
<p>
@@ -120,7 +244,7 @@ export const MyOpenCRE = () => {
120244
</p>
121245

122246
<p>
123-
Start by downloading the CRE catalogue below, then map your standards controls or sections to CRE IDs
247+
Start by downloading the CRE catalogue below, then map your standard's controls or sections to CRE IDs
124248
in the spreadsheet.
125249
</p>
126250

@@ -132,8 +256,29 @@ export const MyOpenCRE = () => {
132256

133257
<div className="myopencre-section myopencre-upload">
134258
<Header as="h3">Upload Mapping CSV</Header>
259+
<Message info className="cursor-pointer">
260+
<details>
261+
<summary>
262+
<strong>How to prepare your CSV</strong>
263+
</summary>
135264

136-
<p>Upload your completed mapping spreadsheet to import your standard into OpenCRE.</p>
265+
<ul>
266+
<li>Start from the downloaded CRE Catalogue CSV.</li>
267+
<li>
268+
Fill <code>standard|name</code> and <code>standard|id</code> for your standard.
269+
</li>
270+
<li>
271+
Map your controls using CRE columns (<code>CRE 0</code>, <code>CRE 1</code>, …).
272+
</li>
273+
274+
<li>
275+
CRE values must be in the format <code>&lt;CRE-ID&gt;|&lt;Name&gt;</code>
276+
<br />
277+
<em>Example:</em> <code>616-305|Development processes for security</code>
278+
</li>
279+
</ul>
280+
</details>
281+
</Message>
137282

138283
{!isUploadEnabled && (
139284
<Message info className="myopencre-disabled">
@@ -143,7 +288,8 @@ export const MyOpenCRE = () => {
143288
</Message>
144289
)}
145290

146-
{error && <Message negative>{error}</Message>}
291+
{renderErrorMessage()}
292+
{info && <Message info>{info}</Message>}
147293

148294
{success && (
149295
<Message positive>
@@ -155,15 +301,62 @@ export const MyOpenCRE = () => {
155301
</Message>
156302
)}
157303

304+
{confirmedImport && !loading && !success && !error && (
305+
<Message positive>
306+
CSV validated successfully. Click <strong>Upload CSV</strong> to start importing.
307+
</Message>
308+
)}
309+
310+
{preview && (
311+
<Message info className="myopencre-preview">
312+
<strong>Import Preview</strong>
313+
<ul>
314+
<li>Rows detected: {preview.rows}</li>
315+
<li>CRE mappings found: {preview.creMappings}</li>
316+
<li>Unique standard sections: {preview.uniqueSections}</li>
317+
<li>CRE columns detected: {preview.creColumns.join(', ')}</li>
318+
</ul>
319+
320+
<Button
321+
primary
322+
size="small"
323+
onClick={() => {
324+
setPreview(null);
325+
setConfirmedImport(true);
326+
}}
327+
>
328+
Confirm Import
329+
</Button>
330+
331+
<Button
332+
size="small"
333+
onClick={() => {
334+
setPreview(null);
335+
setConfirmedImport(false);
336+
setSelectedFile(null);
337+
if (fileInputRef.current) fileInputRef.current.value = '';
338+
}}
339+
>
340+
Cancel
341+
</Button>
342+
</Message>
343+
)}
344+
158345
<Form>
159346
<Form.Field>
160-
<input type="file" accept=".csv" disabled={!isUploadEnabled || loading} onChange={onFileChange} />
347+
<input
348+
ref={fileInputRef}
349+
type="file"
350+
accept=".csv"
351+
disabled={!isUploadEnabled || loading || !!preview}
352+
onChange={onFileChange}
353+
/>
161354
</Form.Field>
162355

163356
<Button
164357
primary
165358
loading={loading}
166-
disabled={!isUploadEnabled || !selectedFile || loading}
359+
disabled={!isUploadEnabled || !selectedFile || !confirmedImport || loading}
167360
onClick={uploadCsv}
168361
>
169362
Upload CSV

application/frontend/src/pages/Search/components/SearchBar.scss

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
.navbar__search {
22
display: none;
3-
align-items: center;
43

54
@media (min-width: 1024px) {
65
display: flex;
6+
align-items: center;
77
}
88
.navbar__mobile-menu &,
99
.mobile-search-container & {
@@ -59,3 +59,16 @@
5959
color: #f87171; /* red-400 */
6060
}
6161
}
62+
63+
.visually-hidden {
64+
position: absolute;
65+
width: 1px;
66+
height: 1px;
67+
padding: 0;
68+
margin: -1px;
69+
overflow: hidden;
70+
clip: rect(0, 0, 0, 0);
71+
clip-path: inset(50%);
72+
white-space: nowrap;
73+
border: 0;
74+
}

0 commit comments

Comments
 (0)