diff --git a/src/components/ModalsContainer/AddNodeEdgeModal/Body/index.tsx b/src/components/ModalsContainer/AddNodeEdgeModal/Body/index.tsx index 72c75d15b..88a58d7bc 100644 --- a/src/components/ModalsContainer/AddNodeEdgeModal/Body/index.tsx +++ b/src/components/ModalsContainer/AddNodeEdgeModal/Body/index.tsx @@ -99,7 +99,7 @@ export const Body = () => { ) : ( void selectedValue: TEdge | null + topicId: string } -export const ToNode: FC = ({ onSelect, selectedValue }) => { +export const ToNode: FC = ({ onSelect, selectedValue, topicId }) => { const [options, setOptions] = useState([]) const [optionsIsLoading, setOptionsIsLoading] = useState(false) - const handleSearch = async (val: string) => { - const filters = { - is_muted: 'False', - sort_by: ALPHABETICALLY, - search: val, - skip: '0', - limit: '1000', - } + const debouncedSearch = useMemo(() => { + const handleSearch = async (val: string) => { + const filters = { + is_muted: 'False', + sort_by: ALPHABETICALLY, + search: val, + skip: '0', + limit: '1000', + } - setOptionsIsLoading(true) + setOptionsIsLoading(true) - try { - const responseData: FetchEdgesResponse = await getEdges(filters.search) + try { + const responseData: FetchEdgesResponse = await getEdges(filters.search) - setOptions(responseData.data) - } catch (error) { - setOptions([]) - } finally { - setOptionsIsLoading(false) + const filteredData = responseData.data.filter((item) => item?.ref_id !== topicId) + + setOptions(filteredData) + } catch (error) { + setOptions([]) + } finally { + setOptionsIsLoading(false) + } } - } - const debouncedSearch = useMemo(() => debounce(handleSearch, 300), []) + return debounce(handleSearch, 300) + }, [topicId]) const handleChange = (e: string) => { if (!e) { diff --git a/src/components/ModalsContainer/AddNodeEdgeModal/Title/index.tsx b/src/components/ModalsContainer/AddNodeEdgeModal/Title/index.tsx index 81507996a..60bcec2b6 100644 --- a/src/components/ModalsContainer/AddNodeEdgeModal/Title/index.tsx +++ b/src/components/ModalsContainer/AddNodeEdgeModal/Title/index.tsx @@ -6,12 +6,12 @@ import FlipIcon from '~/components/Icons/FlipIcon' import NodeCircleIcon from '~/components/Icons/NodeCircleIcon' import { Flex } from '~/components/common/Flex' import { Text } from '~/components/common/Text' -import { TEdge } from '~/types' +import { NodeExtended, TEdge } from '~/types' import { ConnectionType } from './ConnectionType' import { ToNode } from './ToNode' type Props = { - from: string + from: TEdge | NodeExtended | null onSelect: (edge: TEdge | null) => void selectedType: string setSelectedType: (type: string) => void @@ -37,6 +37,8 @@ export const TitleEditor: FC = ({ setIsSwapped() } + const nodeName: string | null = from && ('search_value' in from ? from.search_value : from.name) + return ( @@ -47,18 +49,18 @@ export const TitleEditor: FC = ({
- + - Type + Type {!isSwapped ? 'To' : 'From'} - + @@ -128,7 +130,7 @@ const ToSection = styled.div` align-items: center; ` -const StyledLabel = styled.label` +const StyledLabels = styled.label` color: #bac1c6; font-size: 13px; font-weight: 400; diff --git a/src/components/ModalsContainer/MergeTopicModal/Title/ToNode/index.tsx b/src/components/ModalsContainer/MergeTopicModal/Title/ToNode/index.tsx new file mode 100644 index 000000000..0f87cc3ac --- /dev/null +++ b/src/components/ModalsContainer/MergeTopicModal/Title/ToNode/index.tsx @@ -0,0 +1,88 @@ +import { IconButton } from '@mui/material' +import { debounce } from 'lodash' +import { FC, useMemo, useState } from 'react' +import { OPTIONS } from '~/components/AddItemModal/SourceTypeStep/constants' +import ClearIcon from '~/components/Icons/ClearIcon' +import { ALPHABETICALLY } from '~/components/SourcesTableModal/SourcesView/constants' +import { AutoComplete, TAutocompleteOption } from '~/components/common/AutoComplete' +import { Flex } from '~/components/common/Flex' +import { getEdges } from '~/network/fetchSourcesData' +import { FetchEdgesResponse, TEdge } from '~/types' + +type Props = { + topicId: string + onSelect: (topic: TEdge | null) => void + selectedValue: TEdge | null +} + +export const ToNode: FC = ({ topicId, onSelect, selectedValue }) => { + const [options, setOptions] = useState([]) + const [optionsIsLoading, setOptionsIsLoading] = useState(false) + + const debouncedSearch = useMemo(() => { + const handleSearch = async (val: string) => { + const filters = { + is_muted: 'False', + sort_by: ALPHABETICALLY, + search: val, + skip: '0', + limit: '1000', + } + + setOptionsIsLoading(true) + + try { + const responseData: FetchEdgesResponse = await getEdges(filters.search) + + const filteredData = responseData.data.filter((item) => item?.ref_id !== topicId) + + setOptions(filteredData) + } catch (error) { + setOptions([]) + } finally { + setOptionsIsLoading(false) + } + } + + return debounce(handleSearch, 300) + }, [topicId]) + + const handleChange = (e: string) => { + if (!e) { + setOptions([]) + + return + } + + if (e.length > 2) { + debouncedSearch(e) + } + } + + const handleSelect = (val: TAutocompleteOption | null) => { + const option = val ? options.find((i) => i.ref_id === val.value) : null + + onSelect(option || null) + } + + const resolveOption = (i: TEdge) => ({ label: i.search_value, value: i.ref_id, type: i.node_type }) + + const resolveOptions = (values: TEdge[]) => values.map(resolveOption) + + return selectedValue ? ( + + {selectedValue.search_value} + onSelect(null)} size="small"> + + + + ) : ( + + ) +} diff --git a/src/components/ModalsContainer/MergeTopicModal/Title/index.tsx b/src/components/ModalsContainer/MergeTopicModal/Title/index.tsx new file mode 100644 index 000000000..fa2ab656c --- /dev/null +++ b/src/components/ModalsContainer/MergeTopicModal/Title/index.tsx @@ -0,0 +1,172 @@ +import { TextField } from '@mui/material' +import { FC } from 'react' +import styled from 'styled-components' +import ArrowRight from '~/components/Icons/ArrowRight' +import FlipIcon from '~/components/Icons/FlipIcon' +import NodeCircleIcon from '~/components/Icons/NodeCircleIcon' +import { Flex } from '~/components/common/Flex' +import { Text } from '~/components/common/Text' +import { TEdge, Topic } from '~/types' +import { ToNode } from './ToNode' + +type Props = { + from: Topic + onSelect: (edge: TEdge | null) => void + isSwapped: boolean + setIsSwapped: () => void + selectedToNode: TEdge | null +} + +interface SectionProps { + swap: boolean +} + +export const TitleEditor: FC = ({ from, onSelect, selectedToNode, isSwapped, setIsSwapped }) => ( + + + + Merge topic + + +
+ + + + + + Type + IS ALIAS + + + + + {!isSwapped ? 'To' : 'From'} + + + + + + + + + + + + + + + +
+
+) + +const StyledText = styled(Text)` + font-size: 22px; + font-weight: 600; + font-family: 'Barlow'; +` + +const SectionWrapper = styled(Flex)` + flex: 1 1 100%; +` + +const NodeConnectorDiv = styled.div` + position: absolute; + top: 26px; + bottom: 26px; + left: 4px; + width: 35px; + border-left: 1.5px solid #6b7a8d4d; + border-top: 1.5px solid #6b7a8d4d; + border-bottom: 1.5px solid #6b7a8d4d; + border-radius: 12px 0 0 12px; +` + +const Div = styled.div` + position: relative; + color: white; + font-family: 'Barlow'; + display: flex; + flex-direction: ${(props) => (props.swap ? 'column-reverse' : 'column')}; + margin-bottom: 10px; + padding-left: 38px; +` + +const FromSection = styled(TextField)` + position: relative; + width: 100%; + padding: 16px; + gap: 10px; + border-radius: 6px; + border: 1px solid #6b7a8d4d; + opacity: 0px; + display: flex; +` + +const ToSection = styled.div` + position: relative; + width: 100%; + padding: 15px; + gap: 10px; + border-radius: 6px; + border: 1.4px solid #6b7a8d4d; + opacity: 0px; + display: flex; + align-items: center; +` + +const StyledLabel = styled.label` + color: #bac1c6; + font-size: 13px; + font-weight: 400; + line-height: 18px; + letter-spacing: 0.01em; + text-align: left; + margin-bottom: 6px; +` + +const ToLabel = styled.label` + color: #bac1c6; + background-color: #23252f; + font-size: 13px; + font-weight: 400; + line-height: 18px; + letter-spacing: 0.01em; + text-align: left; + position: absolute; + left: 15px; + top: -10px; +` + +const IconTopContainer = styled.div` + position: absolute; + top: 0; + right: 0; + transform: translateY(-50%) translateX(50%); + color: #23252f; +` + +const IconMidContainer = styled.div` + position: absolute; + color: transparent; + top: 50%; + left: 0; + transform: translateY(-50%) translateX(-50%); + cursor: pointer; + width: 32px; + height: 32px; + background-color: #303342; + display: flex; + justify-content: center; + align-items: center; + border-radius: 8px; +` + +const IconBottomContainer = styled.div` + position: absolute; + bottom: 0; + right: 0; + transform: translateY(10px) translateX(3px); + color: #6b7a8d; + line-height: 1; +` diff --git a/src/components/ModalsContainer/MergeTopicModal/index.tsx b/src/components/ModalsContainer/MergeTopicModal/index.tsx new file mode 100644 index 000000000..abd48d000 --- /dev/null +++ b/src/components/ModalsContainer/MergeTopicModal/index.tsx @@ -0,0 +1,127 @@ +import { Button } from '@mui/material' +import { useEffect, useState } from 'react' +import { FormProvider, useForm } from 'react-hook-form' +import { ClipLoader } from 'react-spinners' +import { BaseModal } from '~/components/Modal' +import { getTopicsData, postMergeTopics } from '~/network/fetchSourcesData' +import { useModal } from '~/stores/useModalStore' +import { useTopicsStore } from '~/stores/useTopicsStore' +import { TEdge, Topic } from '~/types' +import { colors } from '~/utils/colors' +import { IS_ALIAS } from '../../SourcesTableModal/SourcesView/constants' +import { TitleEditor } from './Title' +import { useSelectedNode } from '~/stores/useDataStore' +import { Flex } from '~/components/common/Flex' +import styled from 'styled-components' + +export type FormData = { + name: string +} + +export const MergeNodeModal = () => { + const { close } = useModal('mergeTopic') + const [data, ids, total] = useTopicsStore((s) => [s.data, s.ids, s.total]) + const form = useForm({ mode: 'onChange' }) + const [loading, setLoading] = useState(false) + const [isSwapped, setIsSwapped] = useState(false) + const [selectedToNode, setSelectedToNode] = useState(null) + const [topicIsLoading, setTopicIsLoading] = useState(false) + + const [topic, setTopic] = useState() + + const selectedNode = useSelectedNode() + + useEffect(() => { + const init = async () => { + if (!selectedNode) { + return + } + + setTopicIsLoading(true) + + try { + if (selectedNode.type === 'topic') { + const response = await getTopicsData({ search: selectedNode?.name }) + + const node = response?.data.find((i: Topic) => i.name === selectedNode.name) + + setTopic(node) + } + } catch (error) { + console.log(error) + } finally { + setTopicIsLoading(false) + } + } + + init() + }, [selectedNode]) + + const closeHandler = () => { + close() + } + + const handleSave = async () => { + if (!selectedToNode || !data) { + return + } + + setLoading(true) + + try { + await postMergeTopics({ from: topic?.ref_id, to: selectedToNode?.ref_id }) + + if (topic?.ref_id) { + data[topic?.ref_id] = { + ...data[topic?.ref_id], + edgeList: [IS_ALIAS], + edgeCount: data[topic?.ref_id].edgeCount - 1, + } + + useTopicsStore.setState({ ids: ids.filter((i) => i !== selectedToNode.ref_id), total: total - 1 }) + } + + closeHandler() + } catch (error) { + console.warn(error) + } finally { + setLoading(false) + } + } + + return ( + + + {topicIsLoading ? ( + + + + ) : ( + setIsSwapped(!isSwapped)} + /> + )} + + Merge topics + {loading && } + + + + ) +} + +const CustomButton = styled(Button)` + width: 293px !important; + margin: 0 0 10px auto !important; +` diff --git a/src/components/ModalsContainer/index.tsx b/src/components/ModalsContainer/index.tsx index 40a6cb100..965cc3772 100644 --- a/src/components/ModalsContainer/index.tsx +++ b/src/components/ModalsContainer/index.tsx @@ -26,6 +26,10 @@ const LazyAddNodeEdgeModal = lazy(() => import('./AddNodeEdgeModal').then(({ AddNodeEdgeModal }) => ({ default: AddNodeEdgeModal })), ) +const LazyMergeNodeModal = lazy(() => + import('./MergeTopicModal').then(({ MergeNodeModal }) => ({ default: MergeNodeModal })), +) + const LazyChangeNodeTypeModal = lazy(() => import('./ChangeNodeTypeModal').then(({ ChangeNodeTypeModal }) => ({ default: ChangeNodeTypeModal })), ) @@ -45,5 +49,6 @@ export const ModalsContainer = () => ( + ) diff --git a/src/components/Universe/Graph/UI/NodeControls/index.tsx b/src/components/Universe/Graph/UI/NodeControls/index.tsx index f68d4e797..0a04991c1 100644 --- a/src/components/Universe/Graph/UI/NodeControls/index.tsx +++ b/src/components/Universe/Graph/UI/NodeControls/index.tsx @@ -1,6 +1,6 @@ import { Html } from '@react-three/drei' import { useFrame } from '@react-three/fiber' -import { memo, useCallback, useMemo, useRef } from 'react' +import React, { memo, useCallback, useMemo, useRef } from 'react' import { MdClose, MdViewInAr } from 'react-icons/md' import styled from 'styled-components' import { Group, Vector3 } from 'three' @@ -11,15 +11,22 @@ import { useDataStore, useSelectedNode } from '~/stores/useDataStore' import { useModal } from '~/stores/useModalStore' import { useUserStore } from '~/stores/useUserStore' import { buttonColors } from './constants' +import { Flex } from '~/components/common/Flex' +import { colors } from '~/utils/colors' +import MergeIcon from '~/components/Icons/MergeIcon' +import AddCircleIcon from '~/components/Icons/AddCircleIcon' +import Popover from '@mui/material/Popover' const reuseableVector3 = new Vector3() export const NodeControls = memo(() => { const ref = useRef(null) const setSidebarOpen = useAppStore((s) => s.setSidebarOpen) + const [anchorEl, setAnchorEl] = React.useState(null) const { open: openEditNodeNameModal } = useModal('editNodeName') const { open: addEdgeToNodeModal } = useModal('addEdgeToNode') + const { open: mergeTopicModal } = useModal('mergeTopic') const [isAdmin] = useUserStore((s) => [s.isAdmin]) @@ -57,8 +64,8 @@ export const NodeControls = memo(() => { icon: , left: -80, className: 'add', - onClick: () => { - addEdgeToNodeModal() + onClick: (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget as unknown as HTMLButtonElement) }, }, { @@ -105,20 +112,20 @@ export const NodeControls = memo(() => { ] return [...conditionalActions, ...baseActions].map((i, index) => ({ ...i, left: -80 + index * 40 })) - }, [ - showSelectionGraph, - addEdgeToNodeModal, - openEditNodeNameModal, - setShowSelectionGraph, - setSidebarOpen, - setSelectedNode, - isAdmin, - ]) + }, [showSelectionGraph, openEditNodeNameModal, setShowSelectionGraph, setSidebarOpen, setSelectedNode, isAdmin]) if (!selectedNode) { return null } + const handleClose = () => { + setAnchorEl(null) + } + + const open = Boolean(anchorEl) + + const id = open ? 'simple-popover' : undefined + return ( { left={b.left} onClick={(e) => { e.stopPropagation() - b.onClick() + b.onClick(e) }} > {b.icon} ))} + + + { + mergeTopicModal() + handleClose() + }} + > + Merge + + { + addEdgeToNodeModal() + handleClose() + }} + > + Add edge + + ) @@ -182,3 +217,38 @@ const IconButton = styled.div` transition: opacity 0.4s; box-shadow: 0px 2px 12px rgba(0, 0, 0, 0.5); ` + +const PopoverOption = styled(Flex).attrs({ + direction: 'row', + px: 12, + py: 8, +})` + display: flex; + align-items: center; + justify-content: start; + gap: 12px; + cursor: pointer; + background: ${colors.BUTTON1}; + color: ${colors.white}; + + &:hover { + background: ${colors.BUTTON1_HOVER}; + color: ${colors.GRAY3}; + } +` + +const PopoverWrapper = styled(Popover)` + && { + z-index: 9999; + } + .MuiPaper-root { + min-width: 149px; + color: ${colors.GRAY3}; + box-shadow: 0px 1px 6px 0px rgba(0, 0, 0, 0.2); + border-radius: 6px; + z-index: 1; + font-family: Barlow; + font-size: 14px; + font-weight: 500; + } +`