init LinkedItems component for show, edit pages
				
					
				
			This commit is contained in:
		
							
								
								
									
										163
									
								
								src/components/LinkedItems.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										163
									
								
								src/components/LinkedItems.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,163 @@ | ||||
| import {useState, useEffect} from 'react' | ||||
| import {Stack, Typography, Button, MenuItem, Select, FormControl, InputLabel, Grid, Box, Accordion, AccordionSummary, AccordionDetails, useTheme} from '@mui/material' | ||||
| import ExpandMoreIcon from '@mui/icons-material/ExpandMore' | ||||
| import axios from 'axios' | ||||
| import {BACKEND_URL} from '../lib/constants' | ||||
|  | ||||
| type Field<T> = { | ||||
|   label: string | ||||
|   data: keyof T | ||||
| } | ||||
|  | ||||
| type LinkedItemsProps<T> = { | ||||
|   parentId: string | number | ||||
|   parentResource: string | ||||
|   childResource: string | ||||
|   fields: Field<T>[] | ||||
|   title: string | ||||
|   type: 'show' | 'edit' | ||||
| } | ||||
|  | ||||
| export const LinkedItems = <T extends {id: number; [key: string]: any}>({parentId, parentResource, childResource, fields, title, type}: LinkedItemsProps<T>) => { | ||||
|   const [items, setItems] = useState<T[]>([]) | ||||
|   const [linkedItems, setLinkedItems] = useState<T[]>([]) | ||||
|   const [selectedItemId, setSelectedItemId] = useState<number | ''>('') | ||||
|   const [isLoading, setIsLoading] = useState<boolean>(true) | ||||
|   const theme = useTheme() | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (parentId) { | ||||
|       axios | ||||
|         .get(`${BACKEND_URL}/${parentResource}/${parentId}/${childResource}`) | ||||
|         .then((response) => { | ||||
|           setLinkedItems(response?.data || []) | ||||
|         }) | ||||
|         .catch(() => { | ||||
|           setLinkedItems([]) | ||||
|         }) | ||||
|     } | ||||
|   }, [parentId, parentResource, childResource]) | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (type === 'edit') { | ||||
|       axios | ||||
|         .get(`${BACKEND_URL}/${childResource}/`) | ||||
|         .then((response) => { | ||||
|           setItems(response?.data || []) | ||||
|           setIsLoading(false) | ||||
|         }) | ||||
|         .catch(() => { | ||||
|           setItems([]) | ||||
|           setIsLoading(false) | ||||
|         }) | ||||
|     } else { | ||||
|       setIsLoading(false) | ||||
|     } | ||||
|   }, [childResource, type]) | ||||
|  | ||||
|   const availableItems = items.filter((item) => !linkedItems.some((linked) => linked.id === item.id)) | ||||
|  | ||||
|   const linkItem = () => { | ||||
|     if (selectedItemId) { | ||||
|       axios | ||||
|         .post(`${BACKEND_URL}/${parentResource}/${parentId}/${childResource}`, { | ||||
|           [`${childResource}_id`]: selectedItemId, | ||||
|         }) | ||||
|         .then(() => { | ||||
|           axios.get(`${BACKEND_URL}/${parentResource}/${parentId}/${childResource}`).then((response) => { | ||||
|             setLinkedItems(response?.data || []) | ||||
|             setSelectedItemId('') | ||||
|           }) | ||||
|         }) | ||||
|         .catch((error) => { | ||||
|           console.error('Error linking item:', error) | ||||
|         }) | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   const deleteItem = (itemId: number) => { | ||||
|     axios | ||||
|       .delete(`${BACKEND_URL}/${parentResource}/${parentId}/${childResource}`, { | ||||
|         data: {[`${childResource}_id`]: itemId}, | ||||
|       }) | ||||
|       .then(() => { | ||||
|         setLinkedItems((prev) => prev.filter((item) => item.id !== itemId)) | ||||
|       }) | ||||
|       .catch((error) => { | ||||
|         console.error('Error unlinking item:', error) | ||||
|       }) | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <Accordion defaultExpanded> | ||||
|       <AccordionSummary | ||||
|         expandIcon={<ExpandMoreIcon />} | ||||
|         sx={{ | ||||
|           background: theme.palette.background.paper, | ||||
|           borderBottom: `1px solid ${theme.palette.divider}`, | ||||
|         }} | ||||
|       > | ||||
|         <Typography variant="subtitle1" fontWeight="bold"> | ||||
|           {type === 'show' ? `Привязанные ${title}` : title} | ||||
|         </Typography> | ||||
|       </AccordionSummary> | ||||
|  | ||||
|       <AccordionDetails sx={{background: theme.palette.background.paper}}> | ||||
|         <Stack gap={2}> | ||||
|           <Grid container gap={2}> | ||||
|             {isLoading ? ( | ||||
|               <Typography>Загрузка...</Typography> | ||||
|             ) : linkedItems.length > 0 ? ( | ||||
|               linkedItems.map((item, index) => ( | ||||
|                 <Box | ||||
|                   key={index} | ||||
|                   sx={{ | ||||
|                     marginBottom: '8px', | ||||
|                     padding: '14px', | ||||
|                     borderRadius: 2, | ||||
|                     border: `2px solid ${theme.palette.divider}`, | ||||
|                   }} | ||||
|                 > | ||||
|                   <Stack gap={0.5}> | ||||
|                     {fields.map(({label, data}) => ( | ||||
|                       <Typography key={String(data)}> | ||||
|                         <strong>{label}:</strong> {item[data]} | ||||
|                       </Typography> | ||||
|                     ))} | ||||
|                     {type === 'edit' && ( | ||||
|                       <Button variant="outlined" color="error" onClick={() => deleteItem(item.id)} sx={{mt: 1.5}}> | ||||
|                         Отвязать | ||||
|                       </Button> | ||||
|                     )} | ||||
|                   </Stack> | ||||
|                 </Box> | ||||
|               )) | ||||
|             ) : ( | ||||
|               <Typography>{title} не найдены</Typography> | ||||
|             )} | ||||
|           </Grid> | ||||
|  | ||||
|           {type === 'edit' && ( | ||||
|             <> | ||||
|               <FormControl fullWidth> | ||||
|                 <InputLabel>{title}</InputLabel> | ||||
|  | ||||
|                 <Select value={selectedItemId} onChange={(e) => setSelectedItemId(Number(e.target.value))} label={title} fullWidth> | ||||
|                   {availableItems.map((item) => ( | ||||
|                     <MenuItem key={item.id} value={item.id}> | ||||
|                       {item[fields[0].data]} | ||||
|                     </MenuItem> | ||||
|                   ))} | ||||
|                 </Select> | ||||
|               </FormControl> | ||||
|  | ||||
|               <Button variant="contained" onClick={linkItem} disabled={!selectedItemId}> | ||||
|                 Добавить | ||||
|               </Button> | ||||
|             </> | ||||
|           )} | ||||
|         </Stack> | ||||
|       </AccordionDetails> | ||||
|     </Accordion> | ||||
|   ) | ||||
| } | ||||
| @@ -1,6 +1,9 @@ | ||||
| import {Box, TextField} from '@mui/material' | ||||
| import {Edit} from '@refinedev/mui' | ||||
| import {useForm} from '@refinedev/react-hook-form' | ||||
| import {useParams} from 'react-router' | ||||
| import {LinkedItems} from '../../components/LinkedItems' | ||||
| import {type SightItem, sightFields} from './types' | ||||
|  | ||||
| export const StationEdit = () => { | ||||
|   const { | ||||
| @@ -9,6 +12,8 @@ export const StationEdit = () => { | ||||
|     formState: {errors}, | ||||
|   } = useForm({}) | ||||
|  | ||||
|   const {id: stationId} = useParams<{id: string}>() | ||||
|  | ||||
|   return ( | ||||
|     <Edit saveButtonProps={saveButtonProps}> | ||||
|       <Box component="form" sx={{display: 'flex', flexDirection: 'column'}} autoComplete="off"> | ||||
| @@ -67,6 +72,17 @@ export const StationEdit = () => { | ||||
|           name="longitude" | ||||
|         /> | ||||
|       </Box> | ||||
|  | ||||
|       {stationId && ( | ||||
|         <LinkedItems<SightItem> | ||||
|           type="edit" // display and manage | ||||
|           parentId={stationId} | ||||
|           parentResource="station" | ||||
|           childResource="sight" | ||||
|           fields={sightFields} | ||||
|           title="достопримечательности" | ||||
|         /> | ||||
|       )} | ||||
|     </Edit> | ||||
|   ) | ||||
| } | ||||
|   | ||||
| @@ -1,123 +1,18 @@ | ||||
| import {Stack, Typography, Box, Grid2 as Grid, Button, MenuItem, Select, FormControl, InputLabel} from '@mui/material' | ||||
| import {useShow} from '@refinedev/core' | ||||
| import {Show, TextFieldComponent as TextField} from '@refinedev/mui' | ||||
|  | ||||
| import {useEffect, useState} from 'react' | ||||
| import axios from 'axios' | ||||
|  | ||||
| import {BACKEND_URL} from '../../lib/constants' | ||||
|  | ||||
| type SightItem = { | ||||
|   id: number | ||||
|   name: string | ||||
|   latitude: number | ||||
|   longitude: number | ||||
|   city_id: number | ||||
|   city: string | ||||
|   [key: string]: string | number | ||||
| } | ||||
| import {Stack, Typography} from '@mui/material' | ||||
| import {LinkedItems} from '../../components/LinkedItems' | ||||
| import {type SightItem, sightFields, stationFields} from './types' | ||||
|  | ||||
| export const StationShow = () => { | ||||
|   const {query} = useShow({}) | ||||
|   const {data, isLoading} = query | ||||
|   const record = data?.data | ||||
|  | ||||
|   const [sights, setSights] = useState<SightItem[]>([]) | ||||
|   const [linkedSights, setLinkedSights] = useState<SightItem[]>([]) | ||||
|   const [selectedSightId, setSelectedSightId] = useState<number | ''>('') | ||||
|   const [sightsLoading, setSightsLoading] = useState<boolean>(true) | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (record?.id) { | ||||
|       axios | ||||
|         .get(`${BACKEND_URL}/station/${record.id}/sight`) | ||||
|         .then((response) => { | ||||
|           setLinkedSights(response?.data || []) | ||||
|         }) | ||||
|         .catch(() => { | ||||
|           setLinkedSights([]) | ||||
|         }) | ||||
|     } | ||||
|   }, [record?.id]) | ||||
|  | ||||
|   useEffect(() => { | ||||
|     axios | ||||
|       .get(`${BACKEND_URL}/sight/`) // without "/" throws CORS error | ||||
|       .then((response) => { | ||||
|         setSights(response?.data || []) | ||||
|         setSightsLoading(false) | ||||
|       }) | ||||
|       .catch(() => { | ||||
|         setSights([]) | ||||
|         setSightsLoading(false) | ||||
|       }) | ||||
|   }, []) | ||||
|  | ||||
|   const availableSights = sights.filter((sight) => !linkedSights.some((linked) => linked.id === sight.id)) | ||||
|  | ||||
|   const linkSight = () => { | ||||
|     if (selectedSightId) { | ||||
|       axios | ||||
|         .post( | ||||
|           `${BACKEND_URL}/station/${record?.id}/sight`, | ||||
|           {sight_id: selectedSightId}, | ||||
|           { | ||||
|             headers: { | ||||
|               accept: 'application/json', | ||||
|               'Content-Type': 'application/json', | ||||
|             }, | ||||
|           }, | ||||
|         ) | ||||
|         .then(() => { | ||||
|           axios | ||||
|             .get(`${BACKEND_URL}/station/${record?.id}/sight`) | ||||
|             .then((response) => { | ||||
|               setLinkedSights(response?.data || []) | ||||
|             }) | ||||
|             .catch(() => { | ||||
|               setLinkedSights([]) | ||||
|             }) | ||||
|         }) | ||||
|         .catch((error) => { | ||||
|           console.error('Error linking sight:', error) | ||||
|         }) | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   const deleteSight = (sightId: number) => { | ||||
|     axios | ||||
|       .delete(`${BACKEND_URL}/station/${record?.id}/sight`, { | ||||
|         data: {sight_id: sightId}, | ||||
|       }) | ||||
|       .then(() => { | ||||
|         setLinkedSights((prevSights) => prevSights.filter((item) => item.id !== sightId)) | ||||
|       }) | ||||
|       .catch((error) => { | ||||
|         console.error('Error deleting sight:', error) | ||||
|       }) | ||||
|   } | ||||
|  | ||||
|   const fields = [ | ||||
|     // {label: 'ID', data: 'id'}, | ||||
|     {label: 'Название', data: 'name'}, | ||||
|     // {label: 'Широта', data: 'latitude'}, #* | ||||
|     // {label: 'Долгота', data: 'longitude'}, #* | ||||
|     {label: 'Описание', data: 'description'}, | ||||
|   ] | ||||
|  | ||||
|   const sightFields: Array<{label: string; data: keyof SightItem}> = [ | ||||
|     // {label: 'ID', data: 'id'}, | ||||
|     {label: 'Название', data: 'name'}, | ||||
|     // {label: 'Широта', data: 'latitude'}, #* | ||||
|     // {label: 'Долгота', data: 'longitude'}, #* | ||||
|     // {label: 'ID города', data: 'city_id'}, | ||||
|     {label: 'Город', data: 'city'}, | ||||
|   ] | ||||
|  | ||||
|   return ( | ||||
|     <Show isLoading={isLoading}> | ||||
|       <Stack gap={4}> | ||||
|         {fields.map(({label, data}) => ( | ||||
|         {stationFields.map(({label, data}) => ( | ||||
|           <Stack key={data} gap={1}> | ||||
|             <Typography variant="body1" fontWeight="bold"> | ||||
|               {label} | ||||
| @@ -126,64 +21,16 @@ export const StationShow = () => { | ||||
|           </Stack> | ||||
|         ))} | ||||
|  | ||||
|         <Stack gap={2}> | ||||
|           <Typography variant="body1" fontWeight="bold"> | ||||
|             Привязанные достопримечательности | ||||
|           </Typography> | ||||
|  | ||||
|           <Grid container gap={2}> | ||||
|             {sightsLoading ? ( | ||||
|               <Typography>Загрузка достопримечательностей...</Typography> | ||||
|             ) : linkedSights.length > 0 ? ( | ||||
|               linkedSights.map((sight, index) => ( | ||||
|                 <Box | ||||
|                   key={index} | ||||
|                   sx={{ | ||||
|                     marginBottom: '8px', | ||||
|                     padding: '14px', | ||||
|                     borderRadius: 2, | ||||
|                     border: (theme) => `2px solid ${theme.palette.divider}`, | ||||
|                   }} | ||||
|                 > | ||||
|                   <Stack gap={0.5}> | ||||
|                     {sightFields.map(({label, data}) => ( | ||||
|                       <Typography key={data}> | ||||
|                         <strong>{label}:</strong> {sight[data]} | ||||
|                       </Typography> | ||||
|                     ))} | ||||
|  | ||||
|                     <Button variant="outlined" color="error" onClick={() => deleteSight(sight?.id)} sx={{mt: 1.5}}> | ||||
|                       Отвязать | ||||
|                     </Button> | ||||
|                   </Stack> | ||||
|                 </Box> | ||||
|               )) | ||||
|             ) : ( | ||||
|               <Typography>Достопримечательности не найдены</Typography> | ||||
|         {record?.id && ( | ||||
|           <LinkedItems<SightItem> | ||||
|             type="show" // only display | ||||
|             parentId={record.id} | ||||
|             parentResource="station" | ||||
|             childResource="sight" | ||||
|             fields={sightFields} | ||||
|             title="достопримечательности" | ||||
|           /> | ||||
|         )} | ||||
|           </Grid> | ||||
|  | ||||
|           <Stack gap={2}> | ||||
|             <Typography variant="body1" fontWeight="bold"> | ||||
|               Привязать достопримечательность | ||||
|             </Typography> | ||||
|  | ||||
|             <FormControl fullWidth> | ||||
|               <InputLabel>Достопримечательность</InputLabel> | ||||
|               <Select value={selectedSightId} onChange={(e) => setSelectedSightId(Number(e.target.value))} label="Достопримечательность" fullWidth> | ||||
|                 {availableSights.map((sight) => ( | ||||
|                   <MenuItem key={sight.id} value={sight.id}> | ||||
|                     {sight.name} | ||||
|                   </MenuItem> | ||||
|                 ))} | ||||
|               </Select> | ||||
|             </FormControl> | ||||
|  | ||||
|             <Button variant="contained" onClick={linkSight} disabled={!selectedSightId}> | ||||
|               Привязать | ||||
|             </Button> | ||||
|           </Stack> | ||||
|         </Stack> | ||||
|       </Stack> | ||||
|     </Show> | ||||
|   ) | ||||
|   | ||||
							
								
								
									
										43
									
								
								src/pages/station/types.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								src/pages/station/types.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,43 @@ | ||||
| import React from 'react' | ||||
|  | ||||
| export type StationItem = { | ||||
|   id: number | ||||
|   name: string | ||||
|   description: string | ||||
|   latitude: number | ||||
|   longitude: number | ||||
|   [key: string]: string | number | ||||
| } | ||||
|  | ||||
| export type SightItem = { | ||||
|   id: number | ||||
|   name: string | ||||
|   latitude: number | ||||
|   longitude: number | ||||
|   city_id: number | ||||
|   city: string | ||||
|   [key: string]: string | number | ||||
| } | ||||
|  | ||||
| export type FieldType<T> = { | ||||
|   label: string | ||||
|   data: keyof T | ||||
|   render?: (value: any) => React.ReactNode | ||||
| } | ||||
|  | ||||
| export const stationFields: Array<FieldType<StationItem>> = [ | ||||
|   // {label: 'ID', data: 'id'}, | ||||
|   {label: 'Название', data: 'name'}, | ||||
|   // {label: 'Широта', data: 'latitude'}, | ||||
|   // {label: 'Долгота', data: 'longitude'}, | ||||
|   {label: 'Описание', data: 'description'}, | ||||
| ] | ||||
|  | ||||
| export const sightFields: Array<FieldType<SightItem>> = [ | ||||
|   // {label: 'ID', data: 'id'}, | ||||
|   {label: 'Название', data: 'name'}, | ||||
|   // {label: 'Широта', data: 'latitude'}, | ||||
|   // {label: 'Долгота', data: 'longitude'}, | ||||
|   // {label: 'ID города', data: 'city_id'}, | ||||
|   {label: 'Город', data: 'city'}, | ||||
| ] | ||||
		Reference in New Issue
	
	Block a user