11import './MyOpenCRE.scss' ;
22
3- import React , { useState } from 'react' ;
3+ import React , { useRef , useState } from 'react' ;
44import { Button , Container , Form , Header , Message } from 'semantic-ui-react' ;
55
66import { 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+
829export 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 standard’ s 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 > <CRE-ID>|<Name></ 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
0 commit comments