Projekt

Obecné

Profil

« Předchozí | Další » 

Revize a7ae217f

Přidáno uživatelem Václav Honzík před asi 2 roky(ů)

import export part 1

re #9741

Zobrazit rozdíly:

frontend/src/features/Notification/notificationSlice.ts
1 1
import { AlertColor } from '@mui/material'
2
import { createSlice } from '@reduxjs/toolkit'
2
import { Action, createSlice } from '@reduxjs/toolkit'
3 3

  
4 4
export interface NotificationState {
5 5
    message?: string
......
7 7
    autohideSecs?: number
8 8
}
9 9

  
10
const initialState = {
10
const initialState: NotificationState = {
11 11
    message: undefined,
12 12
    severity: 'info',
13 13
    autohideSecs: undefined
......
17 17
    name: 'notification',
18 18
    initialState,
19 19
    reducers: {
20
        showNotification: (state, action) => ({
20
        showNotification: (state: NotificationState, action: { payload: NotificationState }) => ({
21 21
            ...state,
22 22
            message: action.payload.message,
23 23
            severity: action.payload.severity,
24 24
            autohideSecs: action.payload.autohideSecs,
25 25
        }),
26 26
        // consumes the message so it is not displayed after the page gets refreshed
27
        consumeNotification: (state) => ({
27
        consumeNotification: () => ({
28 28
            ...initialState,
29 29
        }),
30 30
    },
frontend/src/features/Reusables/SingleFileSelectionForm.tsx
1
import { Button, Link, Stack, Typography } from '@mui/material'
2
import { Fragment, FunctionComponent } from 'react'
3
import AttachmentIcon from '@mui/icons-material/Attachment'
4
import DeleteIcon from '@mui/icons-material/Delete'
5
import SendIcon from '@mui/icons-material/Send'
6

  
7
export interface SingleFileSelectionFormProps {
8
    filename?: string
9
    onFileSelected: (event: any) => void
10
    formik: any
11
    onClearSelectedFile: () => void
12
}
13

  
14
const SingleFileSelectionForm: FunctionComponent<
15
    SingleFileSelectionFormProps
16
> = ({ filename, onFileSelected, formik, onClearSelectedFile }) => {
17
    return (
18
        <form onSubmit={formik.handleSubmit}>
19
            {!filename ? (
20
                <Fragment>
21
                    <Stack
22
                        direction="row"
23
                        justifyContent="flex-end"
24
                        alignItems="center"
25
                    >
26
                        <Button
27
                            variant="contained"
28
                            color="primary"
29
                            component="label"
30
                            // size="small"
31
                            startIcon={<AttachmentIcon />}
32
                        >
33
                            Select File
34
                            <input
35
                                id="file"
36
                                name="file"
37
                                type="file"
38
                                hidden
39
                                onChange={onFileSelected}
40
                            />
41
                        </Button>
42
                    </Stack>
43
                </Fragment>
44
            ) : (
45
                <Fragment>
46
                    <Stack direction="row" spacing={1}>
47
                        <Typography variant="body1">Selected File: </Typography>
48
                        <Typography
49
                            sx={{
50
                                textOverflow: 'ellipsis',
51
                                overflow: 'hidden',
52
                            }}
53
                            component={Link}
54
                        >
55
                            {filename}
56
                        </Typography>
57
                    </Stack>
58
                    <Stack
59
                        direction="row"
60
                        justifyContent="flex-end"
61
                        alignItems="center"
62
                        spacing={2}
63
                        sx={{ mt: 2 }}
64
                    >
65
                        <Button
66
                            // sx={{ mb: 2, mt: 1 }}
67
                            variant="contained"
68
                            size="small"
69
                            endIcon={<DeleteIcon />}
70
                            onClick={onClearSelectedFile}
71
                        >
72
                            Remove Selection
73
                        </Button>
74
                        <Button
75
                            size="small"
76
                            type="submit"
77
                            variant="contained"
78
                            startIcon={<SendIcon />}
79
                        >
80
                            Submit
81
                        </Button>
82
                    </Stack>
83
                </Fragment>
84
            )}
85
        </form>
86
    )
87
}
88

  
89
export default SingleFileSelectionForm
frontend/src/features/TrackingTool/FileUpload.tsx
1
import {
2
    Button,
3
    DialogContent,
4
    DialogTitle,
5
    Link,
6
    Stack,
7
    Typography,
8
} from "@mui/material"
9
import { useFormik } from "formik"
10
import { Fragment, useState } from "react"
11
import * as yup from "yup"
12
import axiosInstance from "../../api/api"
13
import ButtonOpenableDialog from "../Reusables/ButtonOpenableDialog"
14
import AttachmentIcon from "@mui/icons-material/Attachment"
15
import DeleteIcon from "@mui/icons-material/Delete"
16
import SendIcon from "@mui/icons-material/Send"
17
import { useDispatch } from "react-redux"
18
import { sendTextForProcessing } from "./trackingToolThunks"
19

  
20
interface UploadValues {
21
    file?: File
22
}
23

  
24
const initialValues: UploadValues = {}
25

  
26
const FileUpload = () => {
27
    const dispatch = useDispatch()
28

  
29
    const [filename, setFilename] = useState<string | undefined>(undefined)
30
    const [fileProcessing, setFileProcessing] = useState(false)
31

  
32
    const validationSchema = yup.object().shape({
33
        file: yup.mixed().required("File is required"),
34
    })
35

  
36
    const formik = useFormik({
37
        initialValues,
38
        validationSchema,
39
        onSubmit: async (values) => {
40
            setFileProcessing(true)
41
            const reader = new FileReader()
42
            reader.readAsText(values.file as File)
43
            reader.onload = async () => {
44
                dispatch(sendTextForProcessing(reader.result as string))
45
                setFileProcessing(false)
46
            }
47
        },
48
    })
49

  
50
    // Callback when user selects the file
51
    const onFileSelected = (event: any) => {
52
        const file = event.currentTarget.files[0]
53
        if (file) {
54
            setFilename(file.name)
55
            formik.setFieldValue("file", file)
56
        }
57
    }
58

  
59
    const onClose = () => {
60
        if (fileProcessing) {
61
            return
62
        }
63
        setFilename(undefined)
64
        formik.resetForm()
65
    }
66

  
67
    const onClearSelectedFile = () => {
68
        setFilename(undefined)
69
        formik.setFieldValue("file", undefined)
70
    }
71

  
72
    return (
73
        <ButtonOpenableDialog
74
            buttonText="File"
75
            buttonColor="primary"
76
            buttonVariant="contained"
77
            onCloseCallback={onClose}
78
            maxWidth="xs"
79
        >
80
            <DialogTitle>Upload New File</DialogTitle>
81
            <DialogContent>
82
                <form onSubmit={formik.handleSubmit}>
83
                    {!filename ? (
84
                        <Fragment>
85
                            <Stack
86
                                direction="row"
87
                                justifyContent="flex-end"
88
                                alignItems="center"
89
                            >
90
                                <Button
91
                                    variant="contained"
92
                                    color="primary"
93
                                    component="label"
94
                                    // size="small"
95
                                    startIcon={<AttachmentIcon />}
96
                                >
97
                                    Select File
98
                                    <input
99
                                        id="file"
100
                                        name="file"
101
                                        type="file"
102
                                        hidden
103
                                        onChange={onFileSelected}
104
                                    />
105
                                </Button>
106
                            </Stack>
107
                        </Fragment>
108
                    ) : (
109
                        <Fragment>
110
                            <Stack direction="row" spacing={1}>
111
                                <Typography
112
                                    sx={
113
                                        {
114
                                            // textOverflow: 'ellipsis',
115
                                            // overflow: 'hidden',
116
                                        }
117
                                    }
118
                                    variant="body1"
119
                                >
120
                                    Selected File:{" "}
121
                                </Typography>
122
                                <Typography
123
                                    sx={{
124
                                        textOverflow: "ellipsis",
125
                                        overflow: "hidden",
126
                                    }}
127
                                    // color="text.secondary"
128
                                    component={Link}
129
                                    // download={(formik.values?.file as File).}
130
                                    // align="right"
131
                                >
132
                                    {filename}
133
                                </Typography>
134
                            </Stack>
135
                            <Stack
136
                                direction="row"
137
                                justifyContent="flex-end"
138
                                alignItems="center"
139
                                spacing={2}
140
                                sx={{ mt: 2 }}
141
                            >
142
                                <Button
143
                                    // sx={{ mb: 2, mt: 1 }}
144
                                    variant="contained"
145
                                    size="small"
146
                                    endIcon={<DeleteIcon />}
147
                                    onClick={onClearSelectedFile}
148
                                >
149
                                    Remove Selection
150
                                </Button>
151
                                <Button
152
                                    size="small"
153
                                    type="submit"
154
                                    variant="contained"
155
                                    startIcon={<SendIcon />}
156
                                >
157
                                    Submit
158
                                </Button>
159
                            </Stack>
160
                        </Fragment>
161
                    )}
162
                </form>
163
            </DialogContent>
164
        </ButtonOpenableDialog>
165
    )
166
}
167

  
168
export default FileUpload
frontend/src/features/TrackingTool/MapPath.tsx
1
import { Fragment, FunctionComponent, useEffect, useState } from "react"
2
import { useDispatch, useSelector } from "react-redux"
3
import { RootState } from "../redux/store"
4
import { PathVariant, MapPoint } from "./buildPathVariants"
5
import TextPath from "react-leaflet-textpath"
6
import { setPrimaryIdx, updateMapMarker } from "./trackingToolSlice"
7
import MapMarker from "./MapMarker"
8
import { LatLngTuple } from "leaflet"
9
import { Popup, Tooltip } from "react-leaflet"
10
import { Checkbox, FormControlLabel, Stack, Typography } from "@mui/material"
11
import { formatHtmlStringToReactDom } from "../../utils/formatting/HtmlUtils"
12
import { DialogCatalogItemDetail as CatalogItemDetailDialog } from "../Catalog/CatalogItemDetail"
1
import { Fragment, FunctionComponent, useEffect, useState } from 'react'
2
import { useDispatch, useSelector } from 'react-redux'
3
import { RootState } from '../redux/store'
4
import { PathVariant, MapPoint, isMapPointDisplayable } from './pathUtils'
5
import TextPath from 'react-leaflet-textpath'
6
import { setPrimaryIdx, updateMapMarker } from './trackingToolSlice'
7
import MapMarker from './MapMarker'
8
import { LatLngTuple } from 'leaflet'
9
import { Popup, Tooltip } from 'react-leaflet'
10
import { Checkbox, FormControlLabel, Stack, Typography } from '@mui/material'
11
import { formatHtmlStringToReactDom } from '../../utils/formatting/HtmlUtils'
12
import { DialogCatalogItemDetail as CatalogItemDetailDialog } from '../Catalog/CatalogItemDetail'
13 13

  
14 14
export interface MapPathProps {
15 15
    idx: number // index of the path in the list
......
18 18
type EdgeElement = any
19 19

  
20 20
// Blue
21
export const primaryPathColor = "#346eeb"
21
export const primaryPathColor = '#346eeb'
22 22

  
23 23
// Grey
24
export const secondaryPathColor = "#878e9c"
24
export const secondaryPathColor = '#878e9c'
25 25

  
26 26
const MapPath: FunctionComponent<MapPathProps> = ({ idx }) => {
27 27
    const dispatch = useDispatch()
......
48 48
    >([])
49 49
    useEffect(() => {
50 50
        // Set all displayable vertices
51
        setDisplayableMapPoints(path.filter((vertex) => vertex.displayable))
51
        setDisplayableMapPoints(
52
            path.filter((mapPoint) => isMapPointDisplayable(mapPoint))
53
        )
52 54
    }, [path])
53 55

  
54 56
    // List of all edges in the path
......
81 83
                    text="►"
82 84
                    // text=" > > > > "
83 85
                    attributes={{
84
                        "font-size": 19,
86
                        'font-size': 19,
85 87
                        // Set to primaryPathColor if primary index in the tracking tool is equal to this index
86 88
                        fill:
87 89
                            primaryPathIdx === idx
......
114 116
                        dispatch(
115 117
                            updateMapMarker({
116 118
                                idx,
117
                                item: new MapPoint(item.idx, item.active, {
118
                                    ...item.catalogItem,
119
                                    latitude: position[0],
120
                                    longitude: position[1],
121
                                }),
119
                                item: {
120
                                    idx: item.idx,
121
                                    active: item.active,
122
                                    catalogItem: {
123
                                        ...item.catalogItem,
124
                                        latitude: position[0],
125
                                        longitude: position[1],
126
                                    },
127
                                },
122 128
                            })
123 129
                        )
124 130
                    }}
......
126 132
                    <Fragment>
127 133
                        <Tooltip>
128 134
                            {/* <Typography> */}
129
                                {item.catalogItem.name ?? ""}
135
                            {item.catalogItem.name ?? ''}
130 136
                            {/* </Typography> */}
131 137
                        </Tooltip>
132 138
                        <Popup>
......
149 155
                                                    dispatch(
150 156
                                                        updateMapMarker({
151 157
                                                            idx,
152
                                                            item: new MapPoint(
153
                                                                item.idx,
154
                                                                !item.active,
155
                                                                item.catalogItem
156
                                                            ),
158
                                                            item: {
159
                                                                ...item,
160
                                                                active: !item.active,
161
                                                            },
157 162
                                                        })
158 163
                                                    )
159 164
                                                }}
......
163 168
                                        label="Active"
164 169
                                    />
165 170
                                    <CatalogItemDetailDialog
166
                                        itemId={item.catalogItem.id ?? ""}
171
                                        itemId={item.catalogItem.id ?? ''}
167 172
                                    />
168 173
                                </Stack>
169 174
                            </Fragment>
frontend/src/features/TrackingTool/PlaintextUpload.tsx
1
import {
2
    Button,
3
    Dialog,
4
    DialogContent,
5
    DialogTitle,
6
    Stack,
7
    TextField,
8
} from '@mui/material'
9
import { useFormik } from 'formik'
10
import { Fragment, FunctionComponent, useEffect, useState } from 'react'
11
import SendIcon from '@mui/icons-material/Send'
12
import ClearIcon from '@mui/icons-material/Clear'
13
import { useDispatch, useSelector } from 'react-redux'
14
import { RootState } from '../redux/store'
15
import { sendTextForProcessing } from './trackingToolThunks'
16
import * as yup from 'yup'
17
import { resetDialogApiCallSuccess } from './trackingToolSlice'
18

  
19
const PlaintextUpload = () => {
20
    const loading = useSelector(
21
        (state: RootState) => state.trackingTool.isLoading
22
    )
23
    const [open, setOpen] = useState(false) // controls whether the dialog is open
24

  
25
    // This controls whether to keep dialog open after sending the request to the API
26
    const dialogApiCallSuccess = useSelector(
27
        (state: RootState) => state.trackingTool.dialogApiCallSuccess
28
    )
29

  
30
    const dispatch = useDispatch()
31

  
32
    const validationSchema = yup.object().shape({
33
        text: yup.mixed().required('Text is required'),
34
    })
35

  
36
    const formik = useFormik({
37
        initialValues: {
38
            text: '',
39
        },
40
        validationSchema,
41
        onSubmit: async () => {
42
            // Dispatch the thunk
43
            dispatch(sendTextForProcessing(formik.values.text))
44
        },
45
    })
46

  
47
    const onCloseDialog = () => {
48
        formik.resetForm()
49
        setOpen(false)
50
    }
51

  
52
    const resetForm = () => {
53
        formik.resetForm()
54
    }
55

  
56
    useEffect(() => {
57
        if (!dialogApiCallSuccess) {
58
            return
59
        }
60
        dispatch(resetDialogApiCallSuccess())
61
        setOpen(false)
62
    }, [dialogApiCallSuccess, dispatch])
63

  
64
    return (
65
        <Fragment>
66
            <Button variant="contained" onClick={() => setOpen(true)}>
67
                Text
68
            </Button>
69
            <Dialog
70
                open={open}
71
                fullWidth={true}
72
                onClose={onCloseDialog}
73
                maxWidth="lg"
74
            >
75
                <DialogTitle>Plaintext Input</DialogTitle>
76
                <DialogContent>
77
                    <form onSubmit={formik.handleSubmit}>
78
                        <TextField
79
                            sx={{ my: 2 }}
80
                            fullWidth
81
                            multiline
82
                            label="Plaintext input"
83
                            rows={10}
84
                            name="text"
85
                            value={formik.values.text}
86
                            onChange={formik.handleChange}
87
                        />
88
                        <Stack
89
                            alignItems="flex-end"
90
                            justifyContent="flex-end"
91
                            spacing={2}
92
                            direction="row"
93
                        >
94
                            <Button
95
                                variant="contained"
96
                                color="secondary"
97
                                onClick={resetForm}
98
                                startIcon={<ClearIcon />}
99
                            >
100
                                Clear
101
                            </Button>
102
                            <Button
103
                                type="submit"
104
                                variant="contained"
105
                                startIcon={<SendIcon />}
106
                                disabled={loading}
107
                            >
108
                                Submit
109
                            </Button>
110
                        </Stack>
111
                    </form>
112
                </DialogContent>
113
            </Dialog>
114
        </Fragment>
115
    )
116
}
117

  
118
export default PlaintextUpload
frontend/src/features/TrackingTool/TrackingTool.tsx
5 5
    Grid,
6 6
    Stack,
7 7
    Typography,
8
} from "@mui/material"
9
import { Fragment, useEffect, useRef, useState } from "react"
10
import { MapContainer, TileLayer, useMap } from "react-leaflet"
11
import mapConfig from "../../config/mapConfig"
12
import TextPath from "react-leaflet-textpath"
13
import PlaintextUpload from "./PlaintextUpload"
14
import FileUpload from "./FileUpload"
15
import L, { Map } from "leaflet"
16
import DeleteIcon from "@mui/icons-material/Delete"
17
import { PathDto } from "../../swagger/data-contracts"
18
import { formatHtmlStringToReactDom } from "../../utils/formatting/HtmlUtils"
19
import MapPath from "./MapPath"
20
import EditIcon from "@mui/icons-material/Edit"
21
import { useDispatch, useSelector } from "react-redux"
22
import { RootState } from "../redux/store"
23
import { clear, consumeErr as consumeError } from "./trackingToolSlice"
24
import { showNotification } from "../Notification/notificationSlice"
25
import ClearIcon from "@mui/icons-material/Clear"
8
} from '@mui/material'
9
import { Fragment, useEffect, useRef } from 'react'
10
import { MapContainer, TileLayer } from 'react-leaflet'
11
import mapConfig from '../../config/mapConfig'
12
import PlaintextUpload from './Upload/PlaintextUpload'
13
import FileUpload from './Upload/FileUpload'
14
import { Map } from 'leaflet'
15
import { formatHtmlStringToReactDom } from '../../utils/formatting/HtmlUtils'
16
import MapPath from './MapPath'
17
import { useDispatch, useSelector } from 'react-redux'
18
import { RootState } from '../redux/store'
19
import { clear, consumeErr as consumeError } from './trackingToolSlice'
20
import { showNotification } from '../Notification/notificationSlice'
21
import ClearIcon from '@mui/icons-material/Clear'
22
import GeoJsonExportButton from './Upload/GeoJsonExportButton'
23
import GeoJsonImportDialog from './Upload/GeoJsonImportDialog'
26 24

  
27 25
// Page with tracking tool
28 26
const TrackingTool = () => {
......
50 48
        dispatch(
51 49
            showNotification({
52 50
                message: error,
53
                severity: "error",
51
                severity: 'error',
54 52
            })
55 53
        )
56 54
    }, [err, dispatch])
......
58 56
    const mapRef = useRef<Map | undefined>(undefined)
59 57
    useEffect(() => {
60 58
        if (!mapRef || !mapRef.current) {
61
            console.log("No map ref")
59
            console.log('No map ref')
62 60
            return
63 61
        }
64 62

  
......
87 85
                            Looks like no path / catalog items match this query.
88 86
                        </Typography>
89 87
                    )}
90
                    {!pathDto && (
91
                        <Stack
92
                            direction="row"
93
                            alignItems="flex-start"
94
                            spacing={2}
95
                            sx={{ mt: 1 }}
96
                        >
97
                            <Typography
98
                                variant="h5"
99
                                sx={{ mb: 2 }}
100
                                fontWeight="500"
101
                            >
102
                                Upload:
103
                            </Typography>
104
                            <PlaintextUpload />
105
                            <FileUpload />
106
                        </Stack>
107
                    )}
88
                    <Stack
89
                        direction="row"
90
                        alignItems="flex-start"
91
                        spacing={2}
92
                        sx={{ mt: 1 }}
93
                    >
94
                        {!pathDto && (
95
                            <Fragment>
96
                                <Typography
97
                                    variant="h5"
98
                                    sx={{ mb: 2 }}
99
                                    fontWeight="500"
100
                                >
101
                                    Upload:
102
                                </Typography>
103
                                <PlaintextUpload />
104
                                <FileUpload />
105
                            </Fragment>
106
                        )}
107
                        <GeoJsonImportDialog />
108
                        {pathVariants && pathVariants.length > 0 && (
109
                            <GeoJsonExportButton />
110
                        )}
111
                    </Stack>
108 112

  
109 113
                    {pathDto && (
110 114
                        <Stack alignItems="flex-end">
......
125 129
                    xs={12}
126 130
                    md={12}
127 131
                    style={{
128
                        minHeight: "60vh",
129
                        maxHeight: "100vh",
130
                        width: "100%",
132
                        minHeight: '60vh',
133
                        maxHeight: '100vh',
134
                        width: '100%',
131 135
                    }}
132 136
                >
133 137
                    <MapContainer
134 138
                        center={[mapCenter[0], mapCenter[1]]}
135 139
                        zoom={mapConfig.defaultZoom}
136
                        style={{ height: "100%", minHeight: "100%" }}
137
                        whenCreated={(map) => { mapRef.current = map }}
140
                        style={{ height: '100%', minHeight: '100%' }}
141
                        whenCreated={(map) => {
142
                            mapRef.current = map
143
                        }}
138 144
                    >
139 145
                        <TileLayer
140 146
                            attribution={mapConfig.attribution}
......
158 164
                                        </Typography>
159 165
                                        <Typography variant="body2">
160 166
                                            {formatHtmlStringToReactDom(
161
                                                pathDto.text ?? ""
167
                                                pathDto.text ?? ''
162 168
                                            )}
163 169
                                        </Typography>
164 170
                                    </Stack>
frontend/src/features/TrackingTool/TrackingToolState.ts
1
import { LatLngTuple } from 'leaflet'
2
import { PathDto } from '../../swagger/data-contracts'
3
import { PathVariant } from './pathUtils'
4

  
5
export default interface TrackingToolState {
6
    isLoading: boolean // whether the data is being loaded
7
    pathDto?: PathDto // the data
8
    pathVariants?: PathVariant[] // undefined signals that no path variants were yet fetched from the API
9
    lastError?: string // consumable for errors during thunks
10
    mapCenter: LatLngTuple // pair of latitude and longitude
11
    primaryPathIdx: number // index of the primary path. This index is always in relation to the pathVariants array and not the current page
12
    // trigger to close the dialog when API call is finished
13
    dialogApiCallSuccess: boolean
14
    pathsPerPage: number // max number of paths to show on the map at once
15
    currentPage: number // current page of paths - starts from 0
16
}
frontend/src/features/TrackingTool/Upload/FileUpload.tsx
1
import {
2
    Button,
3
    DialogContent,
4
    DialogTitle,
5
    Link,
6
    Stack,
7
    Typography,
8
} from '@mui/material'
9
import { useFormik } from 'formik'
10
import { Fragment, useState } from 'react'
11
import * as yup from 'yup'
12
import axiosInstance from '../../../api/api'
13
import ButtonOpenableDialog from '../../Reusables/ButtonOpenableDialog'
14
import { useDispatch } from 'react-redux'
15
import { sendTextForProcessing } from '../trackingToolThunks'
16
import SingleFileSelectionForm from '../../Reusables/SingleFileSelectionForm'
17

  
18
interface UploadValues {
19
    file?: File
20
}
21

  
22
const initialValues: UploadValues = {}
23

  
24
const FileUpload = () => {
25
    const dispatch = useDispatch()
26

  
27
    const [filename, setFilename] = useState<string | undefined>(undefined)
28
    const [fileProcessing, setFileProcessing] = useState(false)
29

  
30
    const validationSchema = yup.object().shape({
31
        file: yup.mixed().required('File is required'),
32
    })
33

  
34
    const formik = useFormik({
35
        initialValues,
36
        validationSchema,
37
        onSubmit: async (values) => {
38
            setFileProcessing(true)
39
            const reader = new FileReader()
40
            reader.readAsText(values.file as File)
41
            reader.onload = async () => {
42
                dispatch(sendTextForProcessing(reader.result as string))
43
                setFileProcessing(false)
44
            }
45
        },
46
    })
47

  
48
    // Callback when user selects the file
49
    const onFileSelected = (event: any) => {
50
        const file = event.currentTarget.files[0]
51
        if (file) {
52
            setFilename(file.name)
53
            formik.setFieldValue('file', file)
54
        }
55
    }
56

  
57
    const onClose = () => {
58
        if (fileProcessing) {
59
            return
60
        }
61
        setFilename(undefined)
62
        formik.resetForm()
63
    }
64

  
65
    const onClearSelectedFile = () => {
66
        setFilename(undefined)
67
        formik.setFieldValue('file', undefined)
68
    }
69

  
70
    return (
71
        <ButtonOpenableDialog
72
            buttonText="File"
73
            buttonColor="primary"
74
            buttonVariant="contained"
75
            onCloseCallback={onClose}
76
            maxWidth="xs"
77
        >
78
            <DialogTitle>Upload New File</DialogTitle>
79
            <DialogContent>
80
                <SingleFileSelectionForm
81
                    onFileSelected={onFileSelected}
82
                    onClearSelectedFile={onClearSelectedFile}
83
                    filename={filename}
84
                    formik={formik}
85
                />
86
            </DialogContent>
87
        </ButtonOpenableDialog>
88
    )
89
}
90

  
91
export default FileUpload
frontend/src/features/TrackingTool/Upload/GeoJsonExportButton.tsx
1
import { Button } from '@mui/material'
2
import { useEffect, useState } from 'react'
3
import { useSelector } from 'react-redux'
4
import { RootState } from '../../redux/store'
5
import { isMapPointDisplayable, PathVariant } from '../pathUtils'
6
import { exportAsGeoJsonString } from './GeoJsonIo'
7

  
8
const GeoJsonExportButton = () => {
9
    const [path, setPath] = useState<PathVariant | undefined>(undefined)
10
    const primaryPathIdx = useSelector(
11
        (state: RootState) => state.trackingTool.primaryPathIdx
12
    )
13
    const pathVariants = useSelector(
14
        (state: RootState) => state.trackingTool.pathVariants
15
    )
16
    useEffect(() => {
17
        if (
18
            !pathVariants ||
19
            pathVariants.length === 0 ||
20
            pathVariants.length <= primaryPathIdx
21
        ) {
22
            setPath(undefined)
23
            return
24
        }
25

  
26
        setPath(pathVariants[primaryPathIdx])
27
    }, [primaryPathIdx, pathVariants])
28

  
29
    const exportPath = () => {
30
        if (!path) {
31
            return
32
        }
33

  
34
        const exportPath = path.filter(
35
            (vertex) => isMapPointDisplayable(vertex) && vertex.active
36
        )
37
        const exportPathString = exportAsGeoJsonString(exportPath)
38
        const blob = new Blob([exportPathString], { type: 'application/json' })
39
        const url = window.URL.createObjectURL(blob)
40
        const link = document.createElement('a')
41
        link.href = url
42
        link.setAttribute('download', 'path.json')
43
        document.body.appendChild(link)
44
        link.click()
45
        document.body.removeChild(link)
46
    }
47

  
48
    return (
49
        <Button variant="contained" onClick={exportPath}>
50
            Export
51
        </Button>
52
    )
53
}
54

  
55
export default GeoJsonExportButton
frontend/src/features/TrackingTool/Upload/GeoJsonImportDialog.tsx
1
import { Button, Dialog, DialogContent, DialogTitle } from '@mui/material'
2
import { useFormik } from 'formik'
3
import { Fragment, useState } from 'react'
4
import { useDispatch, useSelector } from 'react-redux'
5
import { RootState } from '../../redux/store'
6
import ButtonOpenableDialog from '../../Reusables/ButtonOpenableDialog'
7
import { PathVariant } from '../pathUtils'
8
import * as yup from 'yup'
9
import { showNotification } from '../../Notification/notificationSlice'
10
import SingleFileSelectionForm from '../../Reusables/SingleFileSelectionForm'
11
import { mergeWithCurrentPath } from '../trackingToolSlice'
12
import { parseGeoJsonToPathVariant } from './GeoJsonIo'
13

  
14
const GeoJsonImportDialog = () => {
15
    const dispatch = useDispatch()
16

  
17
    const [filename, setFilename] = useState<string | undefined>(undefined)
18
    const [fileProcessing, setFileProcessing] = useState(false)
19

  
20
    const validationSchema = yup.object().shape({
21
        file: yup.mixed().required('File is required'),
22
    })
23

  
24
    const initialValues: { file?: File } = {
25
        file: undefined,
26
    }
27

  
28
    // Callback when user selects the file
29
    const onFileSelected = (event: any) => {
30
        const file = event.currentTarget.files[0]
31
        if (file) {
32
            setFilename(file.name)
33
            formik.setFieldValue('file', file)
34
        }
35
    }
36

  
37
    const onClose = () => {
38
        if (fileProcessing) {
39
            return
40
        }
41
        setFilename(undefined)
42
        formik.resetForm()
43
    }
44

  
45
    const onClearSelectedFile = () => {
46
        setFilename(undefined)
47
        formik.setFieldValue('file', undefined)
48
    }
49

  
50
    const formik = useFormik({
51
        initialValues,
52
        validationSchema,
53
        onSubmit: async (values) => {
54
            setFileProcessing(true)
55
            const reader = new FileReader()
56
            reader.readAsText(values.file as File)
57
            reader.onload = async () => {
58
                try {
59
                    const pathVariant = parseGeoJsonToPathVariant(reader.result as string)
60
                    console.log(pathVariant)
61
                    // Merge current path variant with the new one
62
                    dispatch(mergeWithCurrentPath(pathVariant))
63
                } catch (e: any) {
64
                    dispatch(
65
                        showNotification({
66
                            message: e.message,
67
                            // message: 'Error importing GeoJson, the file has invalid format',
68
                            severity: 'error',
69
                            autohideSecs: 5,
70
                        })
71
                    )
72
                }
73
                setFileProcessing(false)
74
            }
75
        },
76
    })
77

  
78
    return (
79
        <ButtonOpenableDialog
80
            buttonText="Import"
81
            buttonColor="primary"
82
            buttonVariant="contained"
83
            onCloseCallback={() => {}}
84
            maxWidth="xs"
85
        >
86
            <DialogTitle>Import Path</DialogTitle>
87
            <DialogContent>
88
                <SingleFileSelectionForm
89
                    onFileSelected={onFileSelected}
90
                    onClearSelectedFile={onClearSelectedFile}
91
                    filename={filename}
92
                    formik={formik}
93
                />
94
            </DialogContent>
95
        </ButtonOpenableDialog>
96
    )
97
}
98

  
99
export default GeoJsonImportDialog
frontend/src/features/TrackingTool/Upload/GeoJsonIo.ts
1
import { isMapPointDisplayable, PathVariant } from '../pathUtils'
2
import * as yup from 'yup'
3

  
4
export const exportAsGeoJsonString = (path: PathVariant) => JSON.stringify({
5
    type: 'FeatureCollection',
6
    features: path.filter(item => item.active && isMapPointDisplayable(item)).map((item) => {
7
        const catalogItem = item.catalogItem
8
        return {
9
            type: 'Feature',
10
            properties: {
11
                catalogItem: {
12
                    id: catalogItem.id,
13
                    name: catalogItem.name,
14
                    allNames: catalogItem.allNames,
15
                    description: catalogItem.description,
16
                    latitude: catalogItem.latitude,
17
                    longitude: catalogItem.longitude,
18
                },
19
                idx: item.idx,
20
                displayable: isMapPointDisplayable(item),
21
            },
22
            geometry: {
23
                type: 'Point',
24
                coordinates: [catalogItem.longitude, catalogItem.latitude],
25
            },
26
        }
27
    }),
28
})
29

  
30
const catalogItemValidationSchema = yup.object({
31
    id: yup.string().required(),
32
    name: yup.string().required(),
33
    allNames: yup.array().of(yup.string()).required(),
34
    description: yup.string(),
35
    latitude: yup.number().required(),
36
    longitude: yup.number().required(),
37
})
38

  
39
/**
40
 * Parses a GeoJson string and returns a list of MapPoints
41
 * @param geoJson loaded file
42
 * @returns 
43
 */
44
export const parseGeoJsonToPathVariant = (geoJson: string) => {
45
    const parsed = JSON.parse(geoJson)
46
    if (parsed.type !== 'FeatureCollection') {
47
        throw new Error('Invalid GeoJson')
48
    }
49
    const features = parsed.features
50
    if (!features) {
51
        throw new Error('Invalid GeoJson provided')
52
    }
53
    const path: PathVariant = features.map((feature: any) => {
54
        const catalogItemDto = feature.properties.catalogItem
55
        
56
        if (!catalogItemDto) {
57
            throw new Error('GeoJson file does not have a valid structure')
58
        }
59
        // validate catalog item
60
        const catalogItem = catalogItemValidationSchema.validateSync(catalogItemDto)
61

  
62
        return {
63
            idx: feature.properties.idx,
64
            active: true,
65
            catalogItem: {
66
                id: catalogItem.id,
67
                name: catalogItem.name,
68
                description: catalogItem.description,
69
                latitude: catalogItem.latitude,
70
                longitude: catalogItem.longitude,
71
            },
72
        }
73
    })
74
    return path
75
}
76

  
frontend/src/features/TrackingTool/Upload/PlaintextUpload.tsx
1
import {
2
    Button,
3
    Dialog,
4
    DialogContent,
5
    DialogTitle,
6
    Stack,
7
    TextField,
8
} from '@mui/material'
9
import { useFormik } from 'formik'
10
import { Fragment, useEffect, useState } from 'react'
11
import SendIcon from '@mui/icons-material/Send'
12
import ClearIcon from '@mui/icons-material/Clear'
13
import { useDispatch, useSelector } from 'react-redux'
14
import { RootState } from '../../redux/store'
15
import { sendTextForProcessing } from '../trackingToolThunks'
16
import * as yup from 'yup'
17
import { resetDialogApiCallSuccess } from '../trackingToolSlice'
18

  
19
const PlaintextUpload = () => {
20
    const loading = useSelector(
21
        (state: RootState) => state.trackingTool.isLoading
22
    )
23
    const [open, setOpen] = useState(false) // controls whether the dialog is open
24

  
25
    // This controls whether to keep dialog open after sending the request to the API
26
    const dialogApiCallSuccess = useSelector(
27
        (state: RootState) => state.trackingTool.dialogApiCallSuccess
28
    )
29

  
30
    const dispatch = useDispatch()
31

  
32
    const validationSchema = yup.object().shape({
33
        text: yup.mixed().required('Text is required'),
34
    })
35

  
36
    const formik = useFormik({
37
        initialValues: {
38
            text: '',
39
        },
40
        validationSchema,
41
        onSubmit: async () => {
42
            // Dispatch the thunk
43
            dispatch(sendTextForProcessing(formik.values.text))
44
        },
45
    })
46

  
47
    const onCloseDialog = () => {
48
        formik.resetForm()
49
        setOpen(false)
50
    }
51

  
52
    const resetForm = () => {
53
        formik.resetForm()
54
    }
55

  
56
    useEffect(() => {
57
        if (!dialogApiCallSuccess) {
58
            return
59
        }
60
        dispatch(resetDialogApiCallSuccess())
61
        setOpen(false)
62
    }, [dialogApiCallSuccess, dispatch])
63

  
64
    return (
65
        <Fragment>
66
            <Button variant="contained" onClick={() => setOpen(true)}>
67
                Text
68
            </Button>
69
            <Dialog
70
                open={open}
71
                fullWidth={true}
72
                onClose={onCloseDialog}
73
                maxWidth="lg"
74
            >
75
                <DialogTitle>Plaintext Input</DialogTitle>
76
                <DialogContent>
77
                    <form onSubmit={formik.handleSubmit}>
78
                        <TextField
79
                            sx={{ my: 2 }}
80
                            fullWidth
81
                            multiline
82
                            label="Plaintext input"
83
                            rows={10}
84
                            name="text"
85
                            value={formik.values.text}
86
                            onChange={formik.handleChange}
87
                        />
88
                        <Stack
89
                            alignItems="flex-end"
90
                            justifyContent="flex-end"
91
                            spacing={2}
92
                            direction="row"
93
                        >
94
                            <Button
95
                                variant="contained"
96
                                color="secondary"
97
                                onClick={resetForm}
98
                                startIcon={<ClearIcon />}
99
                            >
100
                                Clear
101
                            </Button>
102
                            <Button
103
                                type="submit"
104
                                variant="contained"
105
                                startIcon={<SendIcon />}
106
                                disabled={loading}
107
                            >
108
                                Submit
109
                            </Button>
110
                        </Stack>
111
                    </form>
112
                </DialogContent>
113
            </Dialog>
114
        </Fragment>
115
    )
116
}
117

  
118
export default PlaintextUpload
frontend/src/features/TrackingTool/buildPathVariants.ts
1
// Business logic for tracking tool
2

  
3
import { CatalogItemDto, PathDto } from '../../swagger/data-contracts'
4

  
5
// For more comprehensive code alias CatalogItemDto[] as path variant
6
export type PathVariant = MapPoint[]
7

  
8
export class MapPoint {
9
    constructor(
10
        public idx: number,
11
        public active: boolean,
12
        public catalogItem: CatalogItemDto
13
    ) { }
14

  
15
    /**
16
     * @returns true if the map point is displayable - i.e. it has a valid lat/lng
17
     */
18
    get displayable() {
19
        return !!this.catalogItem.latitude && !!this.catalogItem.longitude
20
    }
21
}
22

  
23
/**
24
 * Cartesian product of two arrays
25
 * @param sets
26
 * @returns
27
 */
28
const cartesianProduct = (sets: CatalogItemDto[][]): CatalogItemDto[][] =>
29
    sets.reduce<CatalogItemDto[][]>(
30
        (results, ids) =>
31
            results
32
                .map((result) => ids.map((id) => [...result, id]))
33
                .reduce((nested, result) => [...nested, ...result]),
34
        [[]]
35
    )
36

  
37
/**
38
 * Builds a list of all possible path variants from pathDto
39
 * @param pathDto
40
 * @returns
41
 */
42
export const buildPathVariants = (pathDto: PathDto): PathVariant[] => {
43
    if (!pathDto.foundCatalogItems) {
44
        return []
45
    }
46

  
47
    return (
48
        pathDto.foundCatalogItems.length === 1
49
            ? pathDto.foundCatalogItems
50
            : cartesianProduct(pathDto.foundCatalogItems)
51
    ).map((variant, _) =>
52
        variant.map(
53
            (catalogItem, idx) =>
54
                new MapPoint(
55
                    idx,
56
                    !!catalogItem.latitude && !!catalogItem.longitude,
57
                    catalogItem
58
                )
59
        )
60
    )
61
}
62

  
63
export default buildPathVariants
frontend/src/features/TrackingTool/pathUtils.ts
1
// Business logic for tracking tool
2

  
3
import { CatalogItemDto, PathDto } from '../../swagger/data-contracts'
4

  
5
// For more comprehensive code alias CatalogItemDto[] as path variant
6
export type PathVariant = MapPoint[]
7

  
8
export interface MapPoint {
9
    idx: number,
10
    active: boolean,
11
    catalogItem: CatalogItemDto,
12
}
13

  
14
export const isMapPointDisplayable = (mapPoint: MapPoint): boolean =>
15
    !!mapPoint.catalogItem.latitude && !!mapPoint.catalogItem.longitude
16

  
17
/**
18
 * Cartesian product of two arrays
19
 * @param sets
20
 * @returns
21
 */
22
const cartesianProduct = (sets: CatalogItemDto[][]): CatalogItemDto[][] =>
23
    sets.reduce<CatalogItemDto[][]>(
24
        (results, ids) =>
25
            results
26
                .map((result) => ids.map((id) => [...result, id]))
27
                .reduce((nested, result) => [...nested, ...result]),
28
        [[]]
29
    )
30

  
31
/**
32
 * Builds a list of all possible path variants from pathDto
33
 * @param pathDto
34
 * @returns
35
 */
36
export const buildPathVariants = (pathDto: PathDto): PathVariant[] => {
37
    if (!pathDto.foundCatalogItems) {
38
        return []
39
    }
40

  
41
    return (
42
        pathDto.foundCatalogItems.length === 1
43
            ? pathDto.foundCatalogItems
44
            : cartesianProduct(pathDto.foundCatalogItems)
45
    ).map((variant, _) =>
46
        variant.map(
47
            (catalogItem, idx) => (
48
                {
49
                    idx,
50
                    active: !!catalogItem.latitude && !!catalogItem.longitude,
51
                    catalogItem
52
                })
53
        )
54
    )
55
}
56

  
57
export default buildPathVariants
frontend/src/features/TrackingTool/trackingToolSlice.ts
1 1
import { createSlice } from "@reduxjs/toolkit"
2 2
import { LatLngTuple } from "leaflet"
3
import { persistReducer } from "redux-persist"
4 3
import mapConfig from "../../config/mapConfig"
5 4
import { PathDto } from "../../swagger/data-contracts"
6
import buildPathVariants, { MapPoint, PathVariant } from "./buildPathVariants"
5
import buildPathVariants, { isMapPointDisplayable, MapPoint, PathVariant } from "./pathUtils"
7 6
import { sendTextForProcessing } from "./trackingToolThunks"
8 7
import storage from "redux-persist/lib/storage"
8
import TrackingToolState from './TrackingToolState'
9 9

  
10
export interface TrackingToolState {
11
    isLoading: boolean // whether the data is being loaded
12
    pathDto?: PathDto // the data
13
    pathVariants?: PathVariant[] // undefined signals that no path variants were yet fetched from the API
14
    lastError?: string // consumable for errors during thunks
15
    mapCenter: LatLngTuple // pair of latitude and longitude
16
    primaryPathIdx: number // index of the primary path
17
    // trigger to close the dialog when API call is finished
18
    dialogApiCallSuccess: boolean
19
    pathsPerPage: number // max number of paths to show on the map at once
20
    currentPage: number // current page of paths - starts from 0
21
}
22 10

  
23 11
const defaultPathsPerPage = 5
24 12

  
......
32 20
}
33 21

  
34 22
const calculateMapCenter = (pathVariant: PathVariant): LatLngTuple | undefined => {
35
    const displayableItems = pathVariant.filter((item) => item.displayable)
23
    const displayableItems = pathVariant.filter((item) => isMapPointDisplayable(item))
36 24
    if (displayableItems.length === 0) {
37 25
        return undefined
38 26
    }
......
94 82
            }
95 83
        },
96 84
        clear: () => ({ ...initialState }),
85
        mergeWithCurrentPath: (state: TrackingToolState, action: { payload: PathVariant }) => {
86
            const { payload: jsonPath } = action
87
            if (!jsonPath) {
88
                return { ...state }
89
            }
90

  
91
            const pathVariants = [...state.pathVariants ?? []]
92
            let primaryPathIdx = state.primaryPathIdx
93
            let currentPage = state.currentPage
94

  
95
            // If there are no path append a new array to the pathVariants array and set primaryIdx to 0
96
            if (pathVariants.length === 0) {
97
                primaryPathIdx = 0
98
                currentPage = 0
99
                pathVariants.push([])
100
            }
101

  
102
            // Get the path and create a map to check whether some point with the same id already exists
103
            const path = pathVariants[primaryPathIdx]
104
            const pathMap = new Map(path.map((item) => [item.catalogItem.id as string, item]))
105

  
106
            // Create an array of items to be replaced and items to be added to the end
107
            const itemsToReplace: MapPoint[] = []
108
            const itemsToAdd: MapPoint[] = []
109
            jsonPath.forEach((item) => {
110
                if (!pathMap.has(item.catalogItem.id as string)) {
111
                    itemsToAdd.push(item)
112
                    return
113
                }
114
                
115
                const idx = pathMap.get(item.catalogItem.id as string)!.idx
116
                item.idx = idx
117
                itemsToReplace.push(item)
118
            })
119

  
120
            // Iterate over items to replace and replace them
121
            const newPath = [...path]
122
            itemsToReplace.forEach((item) => {
123
                newPath[item.idx] = item
124
            })
125

  
126
            // Add items to the end
127
            itemsToAdd.forEach((item) => {
128
                item.active = false
129
                item.idx = newPath.length
130
                newPath.push(item)
131
            })
132

  
133
            // Return the new path
134
            return {
135
                ...state,
136
                pathVariants: [
137
                    ...pathVariants.slice(0, primaryPathIdx),
138
                    newPath,
139
                    ...pathVariants.slice(primaryPathIdx + 1),
140
                ],
141
                primaryPathIdx, // in case the list is empty
142
                currentPage, // in case the list is empty
143
            }
144
        }
97 145
    },
98 146
    extraReducers: (builder) => {
99 147
        builder.addCase(sendTextForProcessing.fulfilled, (state, action) => {
......
128 176
    },
129 177
})
130 178

  
131
export const { consumeErr, setPrimaryIdx, resetDialogApiCallSuccess, clear, updateMapMarker } =
179
export const { consumeErr, setPrimaryIdx, resetDialogApiCallSuccess, clear, updateMapMarker, mergeWithCurrentPath } =
132 180
    trackingToolSlice.actions
133 181
const trackingToolReducer = trackingToolSlice.reducer
134 182
export default trackingToolReducer
frontend/src/features/TrackingTool/trackingToolThunks.ts
1 1
import { createAsyncThunk } from '@reduxjs/toolkit'
2 2
import axiosInstance from '../../api/api'
3
import { RootState } from '../redux/store'
4
import { MapPoint, PathVariant } from './pathUtils'
3 5

  
4 6
export const sendTextForProcessing = createAsyncThunk(
5 7
    'trackingTool/sendTextForProcessing',
......
17 19
        }
18 20
    }
19 21
)
22

  
23
// export const mergeWithImportedPath = createAsyncThunk('trackingTool/mergeWithImportedPath', async (jsonPath: PathVariant, { getState }) => {
24
//     if (!jsonPath) {
25
//         return undefined
26
//     }
27

  
28
//     // Get current state
29
//     const state = getState() as RootState
30
//     const { primaryPathIdx, pathVariants } = state.trackingTool
31

  
32
//     // Return undefined if there is no pathVariants or index is out of range
33
//     if (!pathVariants || pathVariants.length === 0 || primaryPathIdx >= pathVariants.length) {
34
//         return undefined
35
//     }
36

  
37
//     // Get the path and create a map to check whether some point with the same id already exists
38
//     const path = pathVariants[primaryPathIdx]
39
//     const pathMap = new Map(path.map((item) => [item.catalogItem.id as string, item]))
40

  
41
//     // Create an array of items to be replaced and items to be added to the end
42
//     const itemsToReplace: MapPoint[] = []
43
//     const itemsToAdd: MapPoint[] = []
44

  
45
//     jsonPath.forEach((item) => {
46
//         if (pathMap.has(item.catalogItem.id as string)) {
47
//             // @ts-ignore - we know that the id is a string and typescript refuses to acknowledge that
48
//             item.idx = pathMap[item.catalogItem.id as string].idx
49
//             itemsToReplace.push(item)
50
//         } else {
51
//             itemsToAdd.push(item)
52
//         }
53
//     })
54

  
55
//     // Iterate over items to replace and replace them
56
//     const newPath = [...path]
57
//     itemsToReplace.forEach((item) => {
58
//         newPath[item.idx] = item
59
//     })
60

  
61
//     // Add items to the end
62
//     itemsToAdd.forEach((item) => {
63
//         item.active = false
... Rozdílový soubor je zkrácen, protože jeho délka přesahuje max. limit.

Také k dispozici: Unified diff