integrate LinkedItems into /sight pages
				
					
				
			This commit is contained in:
		| @@ -1,5 +1,5 @@ | |||||||
| import {useState, useEffect} from 'react' | import {useState, useEffect} from 'react' | ||||||
| import {Stack, Typography, Button, MenuItem, Select, FormControl, InputLabel, Grid, Box, Accordion, AccordionSummary, AccordionDetails, useTheme} from '@mui/material' | import {Stack, Typography, Button, MenuItem, Select, FormControl, InputLabel, Grid, Box, Accordion, AccordionSummary, AccordionDetails, useTheme, TextField} from '@mui/material' | ||||||
| import ExpandMoreIcon from '@mui/icons-material/ExpandMore' | import ExpandMoreIcon from '@mui/icons-material/ExpandMore' | ||||||
| import axios from 'axios' | import axios from 'axios' | ||||||
| import {BACKEND_URL} from '../lib/constants' | import {BACKEND_URL} from '../lib/constants' | ||||||
| @@ -22,6 +22,7 @@ export const LinkedItems = <T extends {id: number; [key: string]: any}>({parentI | |||||||
|   const [items, setItems] = useState<T[]>([]) |   const [items, setItems] = useState<T[]>([]) | ||||||
|   const [linkedItems, setLinkedItems] = useState<T[]>([]) |   const [linkedItems, setLinkedItems] = useState<T[]>([]) | ||||||
|   const [selectedItemId, setSelectedItemId] = useState<number | ''>('') |   const [selectedItemId, setSelectedItemId] = useState<number | ''>('') | ||||||
|  |   const [pageNum, setPageNum] = useState<number>(1) | ||||||
|   const [isLoading, setIsLoading] = useState<boolean>(true) |   const [isLoading, setIsLoading] = useState<boolean>(true) | ||||||
|   const theme = useTheme() |   const theme = useTheme() | ||||||
|  |  | ||||||
| @@ -55,18 +56,35 @@ export const LinkedItems = <T extends {id: number; [key: string]: any}>({parentI | |||||||
|     } |     } | ||||||
|   }, [childResource, type]) |   }, [childResource, type]) | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     if (childResource === 'article' && parentResource === 'sight') { | ||||||
|  |       setPageNum(linkedItems.length + 1) | ||||||
|  |     } | ||||||
|  |   }, [linkedItems, childResource, parentResource]) | ||||||
|  |  | ||||||
|   const availableItems = items.filter((item) => !linkedItems.some((linked) => linked.id === item.id)) |   const availableItems = items.filter((item) => !linkedItems.some((linked) => linked.id === item.id)) | ||||||
|  |  | ||||||
|   const linkItem = () => { |   const linkItem = () => { | ||||||
|     if (selectedItemId) { |     if (selectedItemId) { | ||||||
|       axios |       const requestData = | ||||||
|         .post(`${BACKEND_URL}/${parentResource}/${parentId}/${childResource}`, { |         childResource === 'article' | ||||||
|  |           ? { | ||||||
|               [`${childResource}_id`]: selectedItemId, |               [`${childResource}_id`]: selectedItemId, | ||||||
|         }) |               page_num: pageNum, | ||||||
|  |             } | ||||||
|  |           : { | ||||||
|  |               [`${childResource}_id`]: selectedItemId, | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |       axios | ||||||
|  |         .post(`${BACKEND_URL}/${parentResource}/${parentId}/${childResource}`, requestData) | ||||||
|         .then(() => { |         .then(() => { | ||||||
|           axios.get(`${BACKEND_URL}/${parentResource}/${parentId}/${childResource}`).then((response) => { |           axios.get(`${BACKEND_URL}/${parentResource}/${parentId}/${childResource}`).then((response) => { | ||||||
|             setLinkedItems(response?.data || []) |             setLinkedItems(response?.data || []) | ||||||
|             setSelectedItemId('') |             setSelectedItemId('') | ||||||
|  |             if (childResource === 'article') { | ||||||
|  |               setPageNum(pageNum + 1) | ||||||
|  |             } | ||||||
|           }) |           }) | ||||||
|         }) |         }) | ||||||
|         .catch((error) => { |         .catch((error) => { | ||||||
| @@ -98,7 +116,7 @@ export const LinkedItems = <T extends {id: number; [key: string]: any}>({parentI | |||||||
|         }} |         }} | ||||||
|       > |       > | ||||||
|         <Typography variant="subtitle1" fontWeight="bold"> |         <Typography variant="subtitle1" fontWeight="bold"> | ||||||
|           {type === 'show' ? `Привязанные ${title}` : title} |           Привязанные {title} | ||||||
|         </Typography> |         </Typography> | ||||||
|       </AccordionSummary> |       </AccordionSummary> | ||||||
|  |  | ||||||
| @@ -138,11 +156,11 @@ export const LinkedItems = <T extends {id: number; [key: string]: any}>({parentI | |||||||
|           </Grid> |           </Grid> | ||||||
|  |  | ||||||
|           {type === 'edit' && ( |           {type === 'edit' && ( | ||||||
|             <> |             <Stack gap={2}> | ||||||
|  |               <Typography variant="subtitle1">Добавить {title}</Typography> | ||||||
|               <FormControl fullWidth> |               <FormControl fullWidth> | ||||||
|                 <InputLabel>{title}</InputLabel> |                 <InputLabel>Выберите {title}</InputLabel> | ||||||
|  |                 <Select value={selectedItemId} onChange={(e) => setSelectedItemId(Number(e.target.value))} label={`Выберите ${title}`} fullWidth> | ||||||
|                 <Select value={selectedItemId} onChange={(e) => setSelectedItemId(Number(e.target.value))} label={title} fullWidth> |  | ||||||
|                   {availableItems.map((item) => ( |                   {availableItems.map((item) => ( | ||||||
|                     <MenuItem key={item.id} value={item.id}> |                     <MenuItem key={item.id} value={item.id}> | ||||||
|                       {item[fields[0].data]} |                       {item[fields[0].data]} | ||||||
| @@ -151,10 +169,28 @@ export const LinkedItems = <T extends {id: number; [key: string]: any}>({parentI | |||||||
|                 </Select> |                 </Select> | ||||||
|               </FormControl> |               </FormControl> | ||||||
|  |  | ||||||
|  |               {childResource === 'article' && ( | ||||||
|  |                 <FormControl fullWidth> | ||||||
|  |                   <TextField | ||||||
|  |                     type="number" | ||||||
|  |                     label="Номер страницы" | ||||||
|  |                     name="page_num" | ||||||
|  |                     value={pageNum} | ||||||
|  |                     onChange={(e) => { | ||||||
|  |                       const newValue = Number(e.target.value) | ||||||
|  |                       const minValue = linkedItems.length + 1 // page number on articles lenght | ||||||
|  |                       setPageNum(newValue < minValue ? minValue : newValue) | ||||||
|  |                     }} | ||||||
|  |                     fullWidth | ||||||
|  |                     InputLabelProps={{shrink: true}} | ||||||
|  |                   /> | ||||||
|  |                 </FormControl> | ||||||
|  |               )} | ||||||
|  |  | ||||||
|               <Button variant="contained" onClick={linkItem} disabled={!selectedItemId}> |               <Button variant="contained" onClick={linkItem} disabled={!selectedItemId}> | ||||||
|                 Добавить |                 Добавить | ||||||
|               </Button> |               </Button> | ||||||
|             </> |             </Stack> | ||||||
|           )} |           )} | ||||||
|         </Stack> |         </Stack> | ||||||
|       </AccordionDetails> |       </AccordionDetails> | ||||||
|   | |||||||
| @@ -2,6 +2,9 @@ import {Autocomplete, Box, TextField} from '@mui/material' | |||||||
| import {Edit, useAutocomplete} from '@refinedev/mui' | import {Edit, useAutocomplete} from '@refinedev/mui' | ||||||
| import {useForm} from '@refinedev/react-hook-form' | import {useForm} from '@refinedev/react-hook-form' | ||||||
| import {Controller} from 'react-hook-form' | import {Controller} from 'react-hook-form' | ||||||
|  | import {useParams} from 'react-router' | ||||||
|  | import {LinkedItems} from '../../components/LinkedItems' | ||||||
|  | import {ArticleItem, articleFields} from './types' | ||||||
|  |  | ||||||
| export const SightEdit = () => { | export const SightEdit = () => { | ||||||
|   const { |   const { | ||||||
| @@ -11,6 +14,7 @@ export const SightEdit = () => { | |||||||
|     formState: {errors}, |     formState: {errors}, | ||||||
|   } = useForm({}) |   } = useForm({}) | ||||||
|  |  | ||||||
|  |   const {id: sightId} = useParams<{id: string}>() | ||||||
|   const {autocompleteProps: cityAutocompleteProps} = useAutocomplete({ |   const {autocompleteProps: cityAutocompleteProps} = useAutocomplete({ | ||||||
|     resource: 'city', |     resource: 'city', | ||||||
|   }) |   }) | ||||||
| @@ -83,6 +87,7 @@ export const SightEdit = () => { | |||||||
|           )} |           )} | ||||||
|         /> |         /> | ||||||
|       </Box> |       </Box> | ||||||
|  |       {sightId && <LinkedItems<ArticleItem> type="edit" parentId={sightId} parentResource="sight" childResource="article" fields={articleFields} title="статьи" />} | ||||||
|     </Edit> |     </Edit> | ||||||
|   ) |   ) | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,101 +1,14 @@ | |||||||
| import {Stack, Typography, Box, Grid2 as Grid, Button, MenuItem, Select, FormControl, InputLabel, TextField} from '@mui/material' | import {Stack, Typography} from '@mui/material' | ||||||
| import {useShow} from '@refinedev/core' | import {useShow} from '@refinedev/core' | ||||||
| import {Show, TextFieldComponent} from '@refinedev/mui' | import {Show, TextFieldComponent} from '@refinedev/mui' | ||||||
|  | import {LinkedItems} from '../../components/LinkedItems' | ||||||
| import {useEffect, useState} from 'react' | import {ArticleItem, articleFields} from './types' | ||||||
| import axios from 'axios' |  | ||||||
|  |  | ||||||
| import {BACKEND_URL} from '../../lib/constants' |  | ||||||
|  |  | ||||||
| type ArticleItem = { |  | ||||||
|   id: number |  | ||||||
|   heading: string |  | ||||||
|   body: string |  | ||||||
| } |  | ||||||
|  |  | ||||||
| export const SightShow = () => { | export const SightShow = () => { | ||||||
|   const {query} = useShow({}) |   const {query} = useShow({}) | ||||||
|   const {data, isLoading} = query |   const {data, isLoading} = query | ||||||
|   const record = data?.data |   const record = data?.data | ||||||
|  |  | ||||||
|   const [articles, setArticles] = useState<ArticleItem[]>([]) |  | ||||||
|   const [linkedArticles, setLinkedArticles] = useState<ArticleItem[]>([]) |  | ||||||
|   const [selectedArticleId, setSelectedArticleId] = useState<number | ''>('') |  | ||||||
|   const [pageNum, setPageNum] = useState<number>(1) |  | ||||||
|   const [articlesLoading, setArticlesLoading] = useState<boolean>(true) |  | ||||||
|  |  | ||||||
|   useEffect(() => { |  | ||||||
|     if (record?.id) { |  | ||||||
|       axios |  | ||||||
|         .get(`${BACKEND_URL}/sight/${record.id}/article`) |  | ||||||
|         .then((response) => { |  | ||||||
|           setLinkedArticles(response?.data || []) |  | ||||||
|         }) |  | ||||||
|         .catch(() => { |  | ||||||
|           setLinkedArticles([]) |  | ||||||
|         }) |  | ||||||
|     } |  | ||||||
|   }, [record?.id]) |  | ||||||
|  |  | ||||||
|   useEffect(() => { |  | ||||||
|     axios |  | ||||||
|       .get(`${BACKEND_URL}/article/`) // without "/" throws CORS error |  | ||||||
|       .then((response) => { |  | ||||||
|         setArticles(response?.data || []) |  | ||||||
|         setArticlesLoading(false) |  | ||||||
|       }) |  | ||||||
|       .catch(() => { |  | ||||||
|         setArticles([]) |  | ||||||
|         setArticlesLoading(false) |  | ||||||
|       }) |  | ||||||
|   }, []) |  | ||||||
|  |  | ||||||
|   const availableArticles = articles.filter((article) => !linkedArticles.some((linked) => linked.id === article.id)) |  | ||||||
|  |  | ||||||
|   const linkArticle = () => { |  | ||||||
|     if (selectedArticleId) { |  | ||||||
|       const requestData = { |  | ||||||
|         article_id: selectedArticleId, |  | ||||||
|         page_num: pageNum, |  | ||||||
|       } |  | ||||||
|  |  | ||||||
|       axios |  | ||||||
|         .post(`${BACKEND_URL}/sight/${record?.id}/article`, requestData, { |  | ||||||
|           headers: { |  | ||||||
|             accept: 'application/json', |  | ||||||
|             'Content-Type': 'application/json', |  | ||||||
|           }, |  | ||||||
|         }) |  | ||||||
|         .then(() => { |  | ||||||
|           axios |  | ||||||
|             .get(`${BACKEND_URL}/sight/${record?.id}/article`) |  | ||||||
|             .then((response) => { |  | ||||||
|               setLinkedArticles(response?.data || []) |  | ||||||
|               setPageNum(pageNum + 1) |  | ||||||
|             }) |  | ||||||
|             .catch(() => { |  | ||||||
|               setLinkedArticles([]) |  | ||||||
|             }) |  | ||||||
|         }) |  | ||||||
|         .catch((error) => { |  | ||||||
|           console.error('Error linking article:', error) |  | ||||||
|         }) |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   const deleteArticle = (articleId: number) => { |  | ||||||
|     axios |  | ||||||
|       .delete(`${BACKEND_URL}/sight/${record?.id}/article`, { |  | ||||||
|         data: {article_id: articleId}, |  | ||||||
|       }) |  | ||||||
|       .then(() => { |  | ||||||
|         setLinkedArticles((prev) => prev.filter((item) => item.id !== articleId)) |  | ||||||
|       }) |  | ||||||
|       .catch((error) => { |  | ||||||
|         console.error('Error unlinking article:', error) |  | ||||||
|       }) |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   const fields = [ |   const fields = [ | ||||||
|     // {label: 'ID', data: 'id'}, |     // {label: 'ID', data: 'id'}, | ||||||
|     {label: 'Название', data: 'name'}, |     {label: 'Название', data: 'name'}, | ||||||
| @@ -117,79 +30,7 @@ export const SightShow = () => { | |||||||
|           </Stack> |           </Stack> | ||||||
|         ))} |         ))} | ||||||
|  |  | ||||||
|         <Stack gap={2}> |         {record?.id && <LinkedItems<ArticleItem> type="show" parentId={record.id} parentResource="sight" childResource="article" fields={articleFields} title="статьи" />} | ||||||
|           <Typography variant="body1" fontWeight="bold"> |  | ||||||
|             Привязанные статьи |  | ||||||
|           </Typography> |  | ||||||
|  |  | ||||||
|           <Grid container gap={2}> |  | ||||||
|             {articlesLoading ? ( |  | ||||||
|               <Typography>Загрузка статей...</Typography> |  | ||||||
|             ) : linkedArticles.length > 0 ? ( |  | ||||||
|               linkedArticles.map((article) => ( |  | ||||||
|                 <Box |  | ||||||
|                   key={article.id} |  | ||||||
|                   sx={{ |  | ||||||
|                     marginBottom: '2px', |  | ||||||
|                     width: '100%', |  | ||||||
|                     padding: '14px', |  | ||||||
|                     borderRadius: 2, |  | ||||||
|                     border: (theme) => `2px solid ${theme.palette.divider}`, |  | ||||||
|                   }} |  | ||||||
|                 > |  | ||||||
|                   <Stack gap={1}> |  | ||||||
|                     <Typography variant="h5"> |  | ||||||
|                       <strong>{article.heading}</strong> |  | ||||||
|                     </Typography> |  | ||||||
|  |  | ||||||
|                     <Typography |  | ||||||
|                       className="limited-text" |  | ||||||
|                       sx={{ |  | ||||||
|                         whiteSpace: 'pre-wrap', |  | ||||||
|                         lineClamp: 3, |  | ||||||
|                       }} |  | ||||||
|                     > |  | ||||||
|                       {article.body} |  | ||||||
|                     </Typography> |  | ||||||
|  |  | ||||||
|                     <Button variant="outlined" color="error" onClick={() => deleteArticle(article.id)} sx={{mt: 1.5}}> |  | ||||||
|                       Отвязать статью |  | ||||||
|                     </Button> |  | ||||||
|                   </Stack> |  | ||||||
|                 </Box> |  | ||||||
|               )) |  | ||||||
|             ) : ( |  | ||||||
|               <Typography>Статьи не найдены</Typography> |  | ||||||
|             )} |  | ||||||
|           </Grid> |  | ||||||
|  |  | ||||||
|           <Stack gap={2}> |  | ||||||
|             <Typography variant="body1" fontWeight="bold"> |  | ||||||
|               Привязать статью |  | ||||||
|             </Typography> |  | ||||||
|  |  | ||||||
|             <Stack gap={2.5}> |  | ||||||
|               <FormControl fullWidth> |  | ||||||
|                 <InputLabel>Статья</InputLabel> |  | ||||||
|                 <Select value={selectedArticleId} onChange={(e) => setSelectedArticleId(Number(e.target.value))} label="Статья" fullWidth> |  | ||||||
|                   {availableArticles.map((article) => ( |  | ||||||
|                     <MenuItem key={article.id} value={article.id}> |  | ||||||
|                       {article.heading} |  | ||||||
|                     </MenuItem> |  | ||||||
|                   ))} |  | ||||||
|                 </Select> |  | ||||||
|               </FormControl> |  | ||||||
|  |  | ||||||
|               <FormControl fullWidth> |  | ||||||
|                 <TextField type="number" label="Номер страницы" name="page_num" value={pageNum} onChange={(e) => setPageNum(Number(e.target.value))} fullWidth InputLabelProps={{shrink: true}} /> |  | ||||||
|               </FormControl> |  | ||||||
|  |  | ||||||
|               <Button variant="contained" onClick={linkArticle} disabled={!selectedArticleId}> |  | ||||||
|                 Привязать |  | ||||||
|               </Button> |  | ||||||
|             </Stack> |  | ||||||
|           </Stack> |  | ||||||
|         </Stack> |  | ||||||
|       </Stack> |       </Stack> | ||||||
|     </Show> |     </Show> | ||||||
|   ) |   ) | ||||||
|   | |||||||
							
								
								
									
										17
									
								
								src/pages/sight/types.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								src/pages/sight/types.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,17 @@ | |||||||
|  | export type ArticleItem = { | ||||||
|  |   id: number | ||||||
|  |   heading: string | ||||||
|  |   body: string | ||||||
|  |   [key: string]: string | number | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export type FieldType<T> = { | ||||||
|  |   label: string | ||||||
|  |   data: keyof T | ||||||
|  |   render?: (value: any) => React.ReactNode | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export const articleFields: Array<FieldType<ArticleItem>> = [ | ||||||
|  |   {label: 'Заголовок', data: 'heading'}, | ||||||
|  |   {label: 'Текст', data: 'body'}, | ||||||
|  | ] | ||||||
		Reference in New Issue
	
	Block a user