init LinkedItems component for show, edit pages

This commit is contained in:
maxim 2025-03-19 16:13:42 +03:00
parent 0a1eeccf08
commit 9cb939deac
4 changed files with 236 additions and 167 deletions

View 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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)

View 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'},
]