• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Problematy / goodmap / 28131466663

24 Jun 2026 09:41PM UTC coverage: 74.158%. First build
28131466663

Pull #365

github

web-flow
Merge 6f4f8c15d into 27cbf5bb0
Pull Request #365: refactor!: aligned with breaking changes on platzky

174 of 277 branches covered (62.82%)

Branch coverage included in aggregate %.

575 of 733 new or added lines in 26 files covered. (78.44%)

575 of 733 relevant lines covered (78.44%)

8.77 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

85.95
/frontend/src/components/Map/components/SuggestNewPointButton.jsx
1
import React, { useState, useEffect } from 'react';
2
import {
3
    Button,
4
    Box,
5
    Dialog,
6
    DialogTitle,
7
    DialogContent,
8
    DialogActions,
9
    TextField,
10
    Select,
11
    MenuItem,
12
    InputLabel,
13
    FormControl,
14
    Snackbar,
15
    IconButton,
16
    Checkbox,
17
    ListItemText,
18
    OutlinedInput,
19
    Tooltip,
20
} from '@mui/material';
21

22
import AddAPhotoIcon from '@mui/icons-material/AddAPhoto';
23
import AddIcon from '@mui/icons-material/Add';
24
import RefreshIcon from '@mui/icons-material/Refresh';
25
import Control from 'react-leaflet-custom-control';
26
import axios from 'axios';
27
import { useTranslation } from 'react-i18next';
28
import { buttonStyle, getLocationAwareStyles } from '../../../styles/buttonStyle';
29
import { getCsrfToken } from '../../../utils/csrf';
30
import { useLocation } from '../context/LocationContext';
31
import { httpService } from '../../../services/http/httpService';
32

33
/**
34
 * Button component that allows users to suggest new map points/locations.
35
 * Opens a dialog form with dynamically generated fields based on window.LOCATION_SCHEMA.
36
 * The form includes the user's position, optional photo upload, and fields for all
37
 * obligatory location attributes defined in the backend schema.
38
 * Requires user's geolocation permission to function properly.
39
 *
40
 * @returns {React.ReactElement} Button with dialog form for suggesting new points
41
 */
42
export const SuggestNewPointButton = () => {
2✔
43
    const { t } = useTranslation();
45✔
44
    const { locationGranted, userPosition, requestLocationWithFeedback } = useLocation();
45✔
45
    const [showNewPointBox, setShowNewPointSuggestionBox] = useState(false);
45✔
46
    const [snackbarOpen, setSnackbarOpen] = useState(false);
45✔
47
    const [snackbarMessage, setSnackbarMessage] = useState('');
45✔
48
    const [photo, setPhoto] = useState(null);
45✔
49
    const [photoURL, setPhotoURL] = useState(null);
45✔
50
    const [categoryTranslations, setCategoryTranslations] = useState({
45✔
51
        fieldNames: {},
52
        options: {},
53
    });
54

55
    // Fetch translated category data
56
    useEffect(() => {
45✔
57
        const fetchCategories = async () => {
10✔
58
            try {
10✔
59
                const categoriesData = await httpService.getCategoriesData();
10✔
60
                const fieldNames = {};
10✔
61
                const options = {};
10✔
62

63
                categoriesData.forEach(categoryData => {
10✔
64
                    const [categoryKey, categoryName] = categoryData[0];
20✔
65
                    fieldNames[categoryKey] = categoryName;
20✔
66

67
                    // Build options translation map
68
                    // Options come as [[key, translation], ...] or [key, ...]
69
                    const categoryOptions = categoryData[1];
20✔
70
                    if (categoryOptions && categoryOptions.length > 0) {
20!
71
                        options[categoryKey] = {};
20✔
72
                        categoryOptions.forEach(opt => {
20✔
73
                            if (Array.isArray(opt)) {
50!
74
                                // [key, translation] format
75
                                options[categoryKey][opt[0]] = opt[1];
50✔
76
                            } else {
77
                                // Just key, use as-is
NEW
78
                                options[categoryKey][opt] = opt;
×
79
                            }
80
                        });
81
                    }
82
                });
83

84
                setCategoryTranslations({ fieldNames, options });
10✔
85
            } catch (error) {
NEW
86
                console.error('Failed to fetch category translations:', error);
×
87
            }
88
        };
89

90
        fetchCategories();
10✔
91
    }, []);
92

93
    // Read location schema from global object
94
    const locationSchema = globalThis.LOCATION_SCHEMA || { obligatory_fields: [], categories: {} };
45!
95

96
    // Initialize dynamic form fields based on schema
97
    const initializeFormFields = () => {
45✔
98
        const fields = {};
11✔
99
        locationSchema.obligatory_fields.forEach(([fieldName, fieldType]) => {
11✔
100
            // Skip uuid - it's generated on backend
101
            if (fieldName === 'uuid') {
27!
NEW
102
                return;
×
103
            }
104
            if (fieldType === 'list') {
27✔
105
                fields[fieldName] = [];
8✔
106
            } else {
107
                fields[fieldName] = '';
19✔
108
            }
109
        });
110
        return fields;
11✔
111
    };
112

113
    const [formFields, setFormFields] = useState(initializeFormFields);
45✔
114

115
    const handleNewPointButton = () => {
45✔
116
        requestLocationWithFeedback(() => setShowNewPointSuggestionBox(true));
10✔
117
    };
118

119
    const handleLocateMe = () => {
45✔
NEW
120
        requestLocationWithFeedback();
×
121
    };
122

123
    const handleCloseNewPointBox = () => {
45✔
NEW
124
        setShowNewPointSuggestionBox(false);
×
125
    };
126

127
    const handleSnackbarClose = (event, reason) => {
45✔
NEW
128
        if (reason === 'clickaway') {
×
NEW
129
            return;
×
130
        }
131

NEW
132
        setSnackbarOpen(false);
×
133
    };
134

135
    const handlePhotoUpload = event => {
45✔
136
        const file = event.target.files[0];
2✔
137
        if (!file) {
2✔
138
            return;
1✔
139
        }
140
        const fileSizeMB = file.size / 1024 / 1024;
1✔
141
        if (fileSizeMB > 5) {
1!
142
            setSnackbarMessage(t('fileTooLarge'));
1✔
143
            setSnackbarOpen(true);
1✔
144
            return;
1✔
145
        }
NEW
146
        setPhoto(file);
×
NEW
147
        setPhotoURL(URL.createObjectURL(file));
×
148
    };
149

150
    const handleFieldChange = fieldName => event => {
111✔
151
        setFormFields({ ...formFields, [fieldName]: event.target.value });
2✔
152
    };
153

154
    const handleConfirmNewPoint = async event => {
45✔
155
        event.preventDefault();
4✔
156

157
        // Validate user position is available
158
        if (!userPosition || userPosition.lat === null || userPosition.lng === null) {
4✔
159
            setSnackbarMessage(t('locationNotAvailable'));
1✔
160
            setSnackbarOpen(true);
1✔
161
            return;
1✔
162
        }
163

164
        // Validate required fields are filled
165
        const emptyFields = [];
3✔
166
        locationSchema.obligatory_fields.forEach(([fieldName, fieldType]) => {
3✔
167
            if (fieldName === 'uuid') return; // Skip uuid - generated on backend
5!
168

169
            const value = formFields[fieldName];
5✔
170
            const isEmpty =
171
                fieldType === 'list' ? !value || value.length === 0 : !value || value.trim() === '';
5✔
172

173
            if (isEmpty) {
5✔
174
                emptyFields.push(fieldName);
3✔
175
            }
176
        });
177

178
        if (emptyFields.length > 0) {
3✔
179
            setSnackbarMessage(t('fillRequiredFields', { fields: emptyFields.join(', ') }));
1✔
180
            setSnackbarOpen(true);
1✔
181
            return;
1✔
182
        }
183

184
        const formData = new FormData();
2✔
185
        // Convert position from {lat, lng} to [lat, lng] array format
186
        formData.append('position', JSON.stringify([userPosition.lat, userPosition.lng]));
2✔
187
        if (photo) {
2!
NEW
188
            formData.append('photo', photo);
×
189
        }
190

191
        // Add all dynamic form fields
192
        Object.entries(formFields).forEach(([fieldName, fieldValue]) => {
2✔
193
            if (Array.isArray(fieldValue)) {
2!
NEW
194
                formData.append(fieldName, JSON.stringify(fieldValue));
×
195
            } else {
196
                formData.append(fieldName, fieldValue);
2✔
197
            }
198
        });
199

200
        try {
2✔
201
            const csrfToken = await getCsrfToken();
2✔
202
            await axios.post('/api/suggest-new-point', formData, {
2✔
203
                headers: {
204
                    'Content-Type': 'multipart/form-data',
205
                    'X-CSRFToken': csrfToken,
206
                },
207
            });
208
            setSnackbarMessage(t('locationSuggestedSuccess'));
1✔
209
            setSnackbarOpen(true);
1✔
210

211
            // Reset form after successful submission
212
            setFormFields(initializeFormFields());
1✔
213
            setPhoto(null);
1✔
214
            setPhotoURL(null);
1✔
215

216
            // Close dialog only after successful submission
217
            setShowNewPointSuggestionBox(false);
1✔
218
        } catch (error) {
219
            console.error('Error suggesting new point:', error);
1✔
220
            setSnackbarMessage(t('locationSuggestedError'));
1✔
221
            setSnackbarOpen(true);
1✔
222
            // Dialog stays open on error so user can retry
223
        }
224
    };
225

226
    // Static field name translations (for non-category fields)
227
    const staticFieldTranslations = {
45✔
228
        name: t('fieldName'),
229
    };
230

231
    // Helper to get translated field label
232
    const getFieldLabel = fieldName => {
45✔
233
        // Check static translations first, then category translations, then fallback to raw name
234
        return (
111✔
235
            staticFieldTranslations[fieldName] ||
217✔
236
            categoryTranslations.fieldNames[fieldName] ||
237
            fieldName
238
        );
239
    };
240

241
    // Helper to get translated option label
242
    const getOptionLabel = (fieldName, optionKey) => {
45✔
243
        return categoryTranslations.options[fieldName]?.[optionKey] || optionKey;
165✔
244
    };
245

246
    // Helper to get translated selected values for display
247
    const getSelectedDisplay = (fieldName, selectedValues) => {
45✔
NEW
248
        return selectedValues.map(val => getOptionLabel(fieldName, val)).join(', ');
×
249
    };
250

251
    // Render form field based on field type and whether it's a category
252
    const renderFormField = (fieldName, fieldType) => {
45✔
253
        const isCategory = fieldName in locationSchema.categories;
111✔
254
        const categoryOptions = isCategory ? locationSchema.categories[fieldName] : [];
111✔
255
        const fieldLabel = getFieldLabel(fieldName);
111✔
256

257
        if (fieldType === 'list' && isCategory) {
111✔
258
            // Multi-select for list categories
259
            return (
33✔
260
                <FormControl fullWidth margin="dense" key={fieldName}>
261
                    <InputLabel id={`${fieldName}-label`}>{fieldLabel}</InputLabel>
262
                    <Select
263
                        labelId={`${fieldName}-label`}
264
                        multiple
265
                        value={formFields[fieldName] || []}
33!
266
                        onChange={handleFieldChange(fieldName)}
267
                        input={<OutlinedInput label={fieldLabel} />}
NEW
268
                        renderValue={selected => getSelectedDisplay(fieldName, selected)}
×
269
                        data-testid={`${fieldName}-select`}
270
                    >
271
                        {categoryOptions.map(option => (
272
                            <MenuItem key={option} value={option}>
99✔
273
                                <Checkbox checked={formFields[fieldName].includes(option)} />
274
                                <ListItemText primary={getOptionLabel(fieldName, option)} />
275
                            </MenuItem>
276
                        ))}
277
                    </Select>
278
                </FormControl>
279
            );
280
        } else if (isCategory) {
78✔
281
            // Single select for category fields
282
            return (
33✔
283
                <FormControl fullWidth margin="dense" key={fieldName}>
284
                    <InputLabel id={`${fieldName}-label`}>{fieldLabel}</InputLabel>
285
                    <Select
286
                        labelId={`${fieldName}-label`}
287
                        value={formFields[fieldName] || ''}
66✔
288
                        onChange={handleFieldChange(fieldName)}
289
                        data-testid={`${fieldName}-select`}
290
                    >
291
                        {categoryOptions.map(option => (
292
                            <MenuItem key={option} value={option}>
66✔
293
                                {getOptionLabel(fieldName, option)}
294
                            </MenuItem>
295
                        ))}
296
                    </Select>
297
                </FormControl>
298
            );
299
        } else {
300
            // Text field for non-category fields
301
            return (
45✔
302
                <TextField
303
                    key={fieldName}
304
                    label={fieldLabel}
305
                    value={formFields[fieldName] || ''}
87✔
306
                    onChange={handleFieldChange(fieldName)}
307
                    fullWidth
308
                    margin="dense"
309
                    data-testid={`${fieldName}-input`}
310
                />
311
            );
312
        }
313
    };
314

315
    return (
45✔
316
        <>
317
            <Tooltip
318
                title={locationGranted ? t('suggestNewPoint') : t('locationServicesDisabled')}
45✔
319
                placement="left"
320
                arrow
321
                enterTouchDelay={0}
322
                leaveTouchDelay={1500}
323
            >
324
                <Button
325
                    onClick={handleNewPointButton}
326
                    variant="contained"
327
                    data-testid="suggest-new-point"
328
                    sx={{
329
                        ...buttonStyle,
330
                        ...getLocationAwareStyles(locationGranted),
331
                    }}
332
                >
333
                    <AddIcon style={{ color: 'white', fontSize: 24 }} />
334
                </Button>
335
            </Tooltip>
336

337
            <Dialog open={showNewPointBox} onClose={handleCloseNewPointBox}>
338
                <DialogTitle>{t('suggestNewPointDialogTitle')}</DialogTitle>
339
                <form onSubmit={handleConfirmNewPoint}>
340
                    <DialogContent>
341
                        <Box display="flex" alignItems="center" gap={2}>
342
                            <TextField
343
                                label={t('yourPosition')}
344
                                value={
345
                                    userPosition ? `${userPosition.lat}, ${userPosition.lng}` : ''
45✔
346
                                }
347
                                disabled
348
                                fullWidth
349
                                margin="dense"
350
                            />
351
                            <IconButton onClick={handleLocateMe}>
352
                                <RefreshIcon />
353
                            </IconButton>
354
                        </Box>
355
                        <Button variant="contained" component="label">
356
                            <AddAPhotoIcon />
357
                            <input
358
                                type="file"
359
                                hidden
360
                                onChange={handlePhotoUpload}
361
                                data-testid="photo-of-point"
362
                            />
363
                        </Button>
364
                        {photoURL && (
45!
365
                            <img
366
                                src={photoURL}
367
                                alt="Selected"
368
                                style={{ width: '100%', height: 'auto' }}
369
                            />
370
                        )}
371

372
                        {/* Dynamically render form fields based on schema */}
373
                        {locationSchema.obligatory_fields
374
                            .filter(([fieldName]) => fieldName !== 'uuid')
111✔
375
                            .map(([fieldName, fieldType]) => renderFormField(fieldName, fieldType))}
111✔
376
                    </DialogContent>
377
                    <DialogActions>
378
                        <Button type="submit" variant="contained" color="primary">
379
                            {t('submit')}
380
                        </Button>
381
                        <Button
382
                            onClick={handleCloseNewPointBox}
383
                            variant="outlined"
384
                            color="secondary"
385
                        >
386
                            {t('cancel')}
387
                        </Button>
388
                    </DialogActions>
389
                </form>
390
            </Dialog>
391
            <Snackbar
392
                open={snackbarOpen}
393
                autoHideDuration={6000}
394
                onClose={handleSnackbarClose}
395
                message={snackbarMessage}
396
            />
397
        </>
398
    );
399
};
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc