diff --git a/frontend/webapp/app/(setup)/choose-destination/page.tsx b/frontend/webapp/app/(setup)/choose-destination/page.tsx index 07187db2a..eae2863c9 100644 --- a/frontend/webapp/app/(setup)/choose-destination/page.tsx +++ b/frontend/webapp/app/(setup)/choose-destination/page.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { SideMenu } from '@/components'; import { SideMenuWrapper } from '../styled'; -import { ChooseDestinationContainer } from '@/containers/main'; +import { AddDestinationContainer } from '@/containers/main'; export default function ChooseDestinationPage() { return ( @@ -10,7 +10,7 @@ export default function ChooseDestinationPage() { - + ); } diff --git a/frontend/webapp/components/destinations/add-destination-button/index.tsx b/frontend/webapp/components/destinations/add-destination-button/index.tsx deleted file mode 100644 index 281d403c7..000000000 --- a/frontend/webapp/components/destinations/add-destination-button/index.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import React from 'react'; -import Image from 'next/image'; -import theme from '@/styles/theme'; -import styled from 'styled-components'; -import { Button, Text } from '@/reuseable-components'; - -const StyledAddDestinationButton = styled(Button)` - display: flex; - align-items: center; - justify-content: center; - gap: 8px; - width: 100%; -`; - -interface ModalActionComponentProps { - onClick: () => void; -} - -export function AddDestinationButton({ onClick }: ModalActionComponentProps) { - return ( - - back - - ADD DESTINATION - - - ); -} diff --git a/frontend/webapp/components/destinations/edit-destination-form/index.tsx b/frontend/webapp/components/destinations/edit-destination-form/index.tsx deleted file mode 100644 index 674de1f67..000000000 --- a/frontend/webapp/components/destinations/edit-destination-form/index.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import React from 'react'; -import styled from 'styled-components'; -import { CheckboxList } from '@/reuseable-components'; -import type { DynamicField, ExportedSignals, SupportedDestinationSignals } from '@/types'; -import { DynamicConnectDestinationFormFields } from '@/containers/main/destinations/add-destination/dynamic-form-fields'; - -interface DestinationFormProps { - exportedSignals: ExportedSignals; - supportedSignals: SupportedDestinationSignals; - dynamicFields: DynamicField[]; - handleDynamicFieldChange: (name: string, value: any) => void; - handleSignalChange: (signal: keyof ExportedSignals, value: boolean) => void; -} - -const Container = styled.div` - display: flex; - flex-direction: column; - gap: 24px; - padding: 4px; -`; - -export const EditDestinationForm: React.FC = ({ exportedSignals, supportedSignals, dynamicFields, handleSignalChange, handleDynamicFieldChange }) => { - const monitors = [ - supportedSignals.logs.supported && { id: 'logs', title: 'Logs' }, - supportedSignals.metrics.supported && { id: 'metrics', title: 'Metrics' }, - supportedSignals.traces.supported && { id: 'traces', title: 'Traces' }, - ].filter(Boolean); - - return ( - - - - - ); -}; diff --git a/frontend/webapp/components/destinations/index.ts b/frontend/webapp/components/destinations/index.ts deleted file mode 100644 index e852a65cc..000000000 --- a/frontend/webapp/components/destinations/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './add-destination-button'; -export * from './monitors-tap-list'; -export * from './edit-destination-form'; diff --git a/frontend/webapp/components/destinations/monitors-tap-list/index.tsx b/frontend/webapp/components/destinations/monitors-tap-list/index.tsx deleted file mode 100644 index fbc4685b4..000000000 --- a/frontend/webapp/components/destinations/monitors-tap-list/index.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import React from 'react'; -import styled from 'styled-components'; -import { Text, Tag } from '@/reuseable-components'; -import { MONITORS_OPTIONS } from '@/utils'; -import Image from 'next/image'; - -interface MonitorButtonsProps { - selectedMonitors: string[]; - onMonitorSelect: (monitor: string) => void; -} - -const MonitorButtonsContainer = styled.div` - display: flex; - gap: 8px; - margin-left: 12px; -`; - -const MonitorsTitle = styled(Text)` - opacity: 0.8; - font-size: 14px; - margin-left: 32px; -`; - -const MonitorsTapList: React.FC = ({ - selectedMonitors, - onMonitorSelect, -}) => { - return ( - <> - Monitor by: - - {MONITORS_OPTIONS.map((monitor) => ( - onMonitorSelect(monitor.id)} - > - monitor - {monitor.value} - - ))} - - - ); -}; - -export { MonitorsTapList }; diff --git a/frontend/webapp/components/index.ts b/frontend/webapp/components/index.ts index aa76f1ec5..73363b759 100644 --- a/frontend/webapp/components/index.ts +++ b/frontend/webapp/components/index.ts @@ -1,7 +1,6 @@ export * from './setup'; export * from './overview'; export * from './common'; -export * from './destinations'; export * from './main'; export * from './modals'; export * from './notification'; diff --git a/frontend/webapp/containers/main/actions/action-drawer-container/build-card-from-action-spec.ts b/frontend/webapp/containers/main/actions/action-drawer/build-card-from-action-spec.ts similarity index 100% rename from frontend/webapp/containers/main/actions/action-drawer-container/build-card-from-action-spec.ts rename to frontend/webapp/containers/main/actions/action-drawer/build-card-from-action-spec.ts diff --git a/frontend/webapp/containers/main/actions/action-drawer-container/index.tsx b/frontend/webapp/containers/main/actions/action-drawer/index.tsx similarity index 87% rename from frontend/webapp/containers/main/actions/action-drawer-container/index.tsx rename to frontend/webapp/containers/main/actions/action-drawer/index.tsx index d4fb5d123..e64aefc18 100644 --- a/frontend/webapp/containers/main/actions/action-drawer-container/index.tsx +++ b/frontend/webapp/containers/main/actions/action-drawer/index.tsx @@ -1,14 +1,14 @@ import React, { useMemo, useState } from 'react'; +import { ActionFormBody } from '../'; import styled from 'styled-components'; import { getActionIcon } from '@/utils'; import { useDrawerStore } from '@/store'; import { CardDetails } from '@/components'; import type { ActionDataParsed } from '@/types'; -import { ChooseActionBody } from '../choose-action-body'; import { useActionCRUD, useActionFormData } from '@/hooks'; import OverviewDrawer from '../../overview/overview-drawer'; +import { ACTION_OPTIONS } from '../action-modal/action-options'; import buildCardFromActionSpec from './build-card-from-action-spec'; -import { ACTION_OPTIONS } from '../choose-action-modal/action-options'; interface Props {} @@ -36,7 +36,10 @@ const ActionDrawer: React.FC = () => { } const { item } = selectedItem as { item: ActionDataParsed }; - const found = ACTION_OPTIONS.find(({ type }) => type === item.type) || ACTION_OPTIONS.find(({ id }) => id === 'sampler')?.items?.find(({ type }) => type === item.type); + const found = + ACTION_OPTIONS.find(({ type }) => type === item.type) || + ACTION_OPTIONS.find(({ id }) => id === 'attributes')?.items?.find(({ type }) => type === item.type) || + ACTION_OPTIONS.find(({ id }) => id === 'sampler')?.items?.find(({ type }) => type === item.type); if (!found) return undefined; @@ -86,7 +89,7 @@ const ActionDrawer: React.FC = () => { > {isEditing && thisAction ? ( - = ({ isUpdate, action, formData, handleFormChange }) => { +export const ActionFormBody: React.FC = ({ isUpdate, action, formData, handleFormChange }) => { return ( {isUpdate && ( @@ -45,5 +45,3 @@ const ChooseActionBody: React.FC = ({ isUpdate, action ); }; - -export { ChooseActionBody }; diff --git a/frontend/webapp/containers/main/actions/choose-action-modal/action-options.ts b/frontend/webapp/containers/main/actions/action-modal/action-options.ts similarity index 100% rename from frontend/webapp/containers/main/actions/choose-action-modal/action-options.ts rename to frontend/webapp/containers/main/actions/action-modal/action-options.ts diff --git a/frontend/webapp/containers/main/actions/choose-action-modal/index.tsx b/frontend/webapp/containers/main/actions/action-modal/index.tsx similarity index 88% rename from frontend/webapp/containers/main/actions/choose-action-modal/index.tsx rename to frontend/webapp/containers/main/actions/action-modal/index.tsx index 3f7e62fdb..3aa6a5164 100644 --- a/frontend/webapp/containers/main/actions/choose-action-modal/index.tsx +++ b/frontend/webapp/containers/main/actions/action-modal/index.tsx @@ -1,16 +1,16 @@ -import { ChooseActionBody } from '../'; +import { ActionFormBody } from '../'; import React, { useMemo, useState } from 'react'; import { CenterThis, ModalBody } from '@/styles'; import { useActionCRUD, useActionFormData } from '@/hooks/actions'; import { ACTION_OPTIONS, type ActionOption } from './action-options'; import { AutocompleteInput, Modal, NavigationButtons, Divider, FadeLoader, SectionTitle } from '@/reuseable-components'; -interface AddActionModalProps { +interface Props { isOpen: boolean; onClose: () => void; } -export const AddActionModal: React.FC = ({ isOpen, onClose }) => { +export const ActionModal: React.FC = ({ isOpen, onClose }) => { const { formData, handleFormChange, resetFormData, validateForm } = useActionFormData(); const { createAction, loading } = useActionCRUD({ onSuccess: handleClose }); const [selectedItem, setSelectedItem] = useState(undefined); @@ -64,7 +64,7 @@ export const AddActionModal: React.FC = ({ isOpen, onClose ) : ( - + )} ) : null} diff --git a/frontend/webapp/containers/main/actions/index.ts b/frontend/webapp/containers/main/actions/index.ts index f3e35db2c..b588abd98 100644 --- a/frontend/webapp/containers/main/actions/index.ts +++ b/frontend/webapp/containers/main/actions/index.ts @@ -1,3 +1,3 @@ -export * from './choose-action-modal'; -export * from './choose-action-body'; -export * from './action-drawer-container'; +export * from './action-modal'; +export * from './action-form-body'; +export * from './action-drawer'; diff --git a/frontend/webapp/containers/main/destinations/add-destination/add-destination-modal/index.tsx b/frontend/webapp/containers/main/destinations/add-destination/add-destination-modal/index.tsx deleted file mode 100644 index b8d158a97..000000000 --- a/frontend/webapp/containers/main/destinations/add-destination/add-destination-modal/index.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import React, { useState, useRef, useCallback } from 'react'; -import type { DestinationTypeItem } from '@/types'; -import { ChooseDestinationModalBody } from '../choose-destination-modal-body'; -import { ConnectDestinationModalBody } from '../connect-destination-modal-body'; -import { Modal, type NavigationButtonProps, NavigationButtons } from '@/reuseable-components'; - -interface AddDestinationModalProps { - isOpen: boolean; - onClose: () => void; -} - -export const AddDestinationModal: React.FC = ({ isOpen, onClose }) => { - const submitRef = useRef<(() => void) | null>(null); - const [selectedItem, setSelectedItem] = useState(); - const [isFormValid, setIsFormValid] = useState(false); - - const handleNextStep = useCallback((item: DestinationTypeItem) => { - setSelectedItem(item); - }, []); - - const handleNext = useCallback(() => { - if (submitRef.current) { - submitRef.current(); - setSelectedItem(undefined); - onClose(); - } - }, [onClose]); - - const handleBack = useCallback(() => { - setSelectedItem(undefined); - }, []); - - const handleClose = useCallback(() => { - setSelectedItem(undefined); - onClose(); - }, [onClose]); - - const renderHeaderButtons = () => { - const buttons: NavigationButtonProps[] = [ - { - label: 'DONE', - variant: 'primary' as const, - disabled: !isFormValid, - onClick: handleNext, - }, - ]; - - if (!!selectedItem) { - buttons.unshift({ - label: 'BACK', - iconSrc: '/icons/common/arrow-white.svg', - variant: 'secondary' as const, - onClick: handleBack, - }); - } - - return buttons; - }; - - const renderModalBody = () => { - return selectedItem ? ( - - ) : ( - - ); - }; - - return ( - }> - {renderModalBody()} - - ); -}; diff --git a/frontend/webapp/containers/main/destinations/add-destination/choose-destination-menu/index.tsx b/frontend/webapp/containers/main/destinations/add-destination/choose-destination-menu/index.tsx deleted file mode 100644 index 87d91e948..000000000 --- a/frontend/webapp/containers/main/destinations/add-destination/choose-destination-menu/index.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import React, { useState } from 'react'; -import styled from 'styled-components'; -import { DropdownOption } from '@/types'; -import { MONITORS_OPTIONS } from '@/utils'; -import { Checkbox, Dropdown, Input } from '@/reuseable-components'; - -interface FilterComponentProps { - selectedTag: DropdownOption | undefined; - onTagSelect: (option: DropdownOption) => void; - onSearch: (value: string) => void; - selectedMonitors: string[]; - onMonitorSelect: (monitor: string) => void; -} - -const InputAndDropdownContainer = styled.div` - display: flex; - gap: 12px; - width: 370px; -`; - -const FilterContainer = styled.div` - display: flex; - align-items: center; - padding: 24px 0; -`; - -const MonitorButtonsContainer = styled.div` - display: flex; - gap: 32px; - margin-left: 32px; -`; - -const DROPDOWN_OPTIONS = [ - { value: 'All types', id: 'all' }, - { value: 'Managed', id: 'managed' }, - { value: 'Self-hosted', id: 'self hosted' }, -]; - -const DestinationFilterComponent: React.FC = ({ selectedTag, selectedMonitors, onTagSelect, onSearch, onMonitorSelect }) => { - const [searchTerm, setSearchTerm] = useState(''); - - const handleSearchChange = (e: React.ChangeEvent) => { - const value = e.target.value; - setSearchTerm(value); - onSearch(value); - }; - - return ( - - -
- -
- -
- - - {MONITORS_OPTIONS.map((monitor) => ( - onMonitorSelect(monitor.id)} - disabled={selectedMonitors.length === 1 && selectedMonitors.includes(monitor.id)} - /> - ))} - -
- ); -}; - -export { DestinationFilterComponent }; diff --git a/frontend/webapp/containers/main/destinations/add-destination/choose-destination-modal-body/index.tsx b/frontend/webapp/containers/main/destinations/add-destination/choose-destination-modal-body/index.tsx deleted file mode 100644 index a0e09537f..000000000 --- a/frontend/webapp/containers/main/destinations/add-destination/choose-destination-modal-body/index.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import React, { useMemo, useState } from 'react'; -import { SideMenu } from '@/components'; -import { useDestinationTypes } from '@/hooks'; -import { DestinationsList } from '../destinations-list'; -import { Body, Container, SideMenuWrapper } from '../styled'; -import { Divider, SectionTitle } from '@/reuseable-components'; -import { DestinationFilterComponent } from '../choose-destination-menu'; -import { StepProps, DropdownOption, DestinationTypeItem } from '@/types'; - -interface ChooseDestinationModalBodyProps { - onSelect: (item: DestinationTypeItem) => void; -} - -const SIDE_MENU_DATA: StepProps[] = [ - { - title: 'DESTINATIONS', - state: 'active', - stepNumber: 1, - }, - { - title: 'CONNECTION', - state: 'disabled', - stepNumber: 2, - }, -]; - -const DEFAULT_MONITORS = ['logs', 'metrics', 'traces']; -const DEFAULT_DROPDOWN_VALUE = { id: 'all', value: 'All types' }; - -export function ChooseDestinationModalBody({ - onSelect, -}: ChooseDestinationModalBodyProps) { - const [searchValue, setSearchValue] = useState(''); - const [selectedMonitors, setSelectedMonitors] = - useState(DEFAULT_MONITORS); - const [dropdownValue, setDropdownValue] = useState( - DEFAULT_DROPDOWN_VALUE - ); - - const { destinations } = useDestinationTypes(); - - function handleTagSelect(option: DropdownOption) { - setDropdownValue(option); - } - - const filteredDestinations = useMemo(() => { - return destinations - .map((category) => { - const filteredItems = category.items.filter((item) => { - const matchesSearch = searchValue - ? item.displayName.toLowerCase().includes(searchValue.toLowerCase()) - : true; - - const matchesDropdown = - dropdownValue.id !== 'all' - ? category.name === dropdownValue.id - : true; - - const matchesMonitor = selectedMonitors.length - ? selectedMonitors.some( - (monitor) => item.supportedSignals[monitor]?.supported - ) - : true; - - return matchesSearch && matchesDropdown && matchesMonitor; - }); - - return { ...category, items: filteredItems }; - }) - .filter((category) => category.items.length > 0); // Filter out empty categories - }, [destinations, searchValue, dropdownValue, selectedMonitors]); - - function onMonitorSelect(monitor: string) { - if (selectedMonitors.includes(monitor)) { - setSelectedMonitors(selectedMonitors.filter((item) => item !== monitor)); - } else { - setSelectedMonitors([...selectedMonitors, monitor]); - } - } - - return ( - - - - - - - - - - - - ); -} diff --git a/frontend/webapp/containers/main/destinations/add-destination/configured-destinations-list/index.tsx b/frontend/webapp/containers/main/destinations/add-destination/configured-destinations-list/index.tsx index 68caac63e..175700e4e 100644 --- a/frontend/webapp/containers/main/destinations/add-destination/configured-destinations-list/index.tsx +++ b/frontend/webapp/containers/main/destinations/add-destination/configured-destinations-list/index.tsx @@ -1,9 +1,10 @@ -import React from 'react'; +import React, { useState } from 'react'; import Image from 'next/image'; import styled from 'styled-components'; -import { ConfiguredFields } from '@/components'; -import { ConfiguredDestination } from '@/types'; -import { Divider, Text } from '@/reuseable-components'; +import { ConfiguredFields, DeleteWarning } from '@/components'; +import { IAppState, useAppStore } from '@/store'; +import type { ConfiguredDestination } from '@/types'; +import { Button, Divider, Text } from '@/reuseable-components'; const Container = styled.div` display: flex; @@ -72,41 +73,22 @@ const TextWrapper = styled.div` justify-content: space-between; `; -const ExpandIconContainer = styled.div` +const IconsContainer = styled.div` display: flex; justify-content: center; align-items: center; margin-right: 16px; `; -const IconBorder = styled.div` - height: 16px; - width: 1px; - margin-right: 12px; - background: ${({ theme }) => theme.colors.border}; -`; - -const ExpandIconWrapper = styled.div<{ $expand?: boolean }>` - display: flex; - width: 36px; - height: 36px; - cursor: pointer; - justify-content: center; - align-items: center; - border-radius: 100%; +const IconButton = styled(Button)<{ $expand?: boolean }>` transition: background 0.3s ease 0s, transform 0.3s ease 0s; - transform: ${({ $expand }) => ($expand ? 'rotate(180deg)' : 'rotate(0deg)')}; - &:hover { - background: ${({ theme }) => theme.colors.translucent_bg}; - } + transform: ${({ $expand }) => ($expand ? 'rotate(-180deg)' : 'rotate(0deg)')}; `; -interface DestinationsListProps { - data: ConfiguredDestination[]; -} - -function ConfiguredDestinationsListItem({ item }: { item: ConfiguredDestination }) { - const [expand, setExpand] = React.useState(false); +const ConfiguredDestinationsListItem: React.FC<{ item: ConfiguredDestination; isLastItem: boolean }> = ({ item, isLastItem }) => { + const [expand, setExpand] = useState(false); + const [deleteWarning, setDeleteWarning] = useState(false); + const { removeConfiguredDestination } = useAppStore((state) => state); function renderSupportedSignals(item: ConfiguredDestination) { const supportedSignals = item.exportedSignals; @@ -127,44 +109,63 @@ function ConfiguredDestinationsListItem({ item }: { item: ConfiguredDestination } return ( - - - - - destination - - - {item.displayName} - {renderSupportedSignals(item)} - - - - - - setExpand(!expand)}> - destination - - - - - {expand && ( - - - - - )} - + <> + + + + + destination + + + {item.displayName} + {renderSupportedSignals(item)} + + + + + setDeleteWarning(true)}> + delete + + + setExpand(!expand)}> + show more + + + + + {expand && ( + + + + + )} + + + removeConfiguredDestination(item)} + onDeny={() => setDeleteWarning(false)} + /> + ); -} +}; -const ConfiguredDestinationsList: React.FC = ({ data }) => { +export const ConfiguredDestinationsList: React.FC<{ data: IAppState['configuredDestinations'] }> = ({ data }) => { return ( - {data.map((item) => ( - + {data.map(({ stored }) => ( + ))} ); }; - -export { ConfiguredDestinationsList }; diff --git a/frontend/webapp/containers/main/destinations/add-destination/connect-destination-modal-body/connection-notification.tsx b/frontend/webapp/containers/main/destinations/add-destination/connect-destination-modal-body/connection-notification.tsx deleted file mode 100644 index c94d7ef21..000000000 --- a/frontend/webapp/containers/main/destinations/add-destination/connect-destination-modal-body/connection-notification.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { NotificationNote } from '@/reuseable-components'; -import styled from 'styled-components'; - -export const ConnectionNotification = ({ showConnectionError, destination }) => ( - <> - {showConnectionError && ( - - - - )} - {destination?.fields && !showConnectionError && ( - - - - )} - -); - -const NotificationNoteWrapper = styled.div` - margin-top: 24px; -`; diff --git a/frontend/webapp/containers/main/destinations/add-destination/connect-destination-modal-body/form-container.tsx b/frontend/webapp/containers/main/destinations/add-destination/connect-destination-modal-body/form-container.tsx deleted file mode 100644 index 4948674a1..000000000 --- a/frontend/webapp/containers/main/destinations/add-destination/connect-destination-modal-body/form-container.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import styled from 'styled-components'; -import { CheckboxList, Input } from '@/reuseable-components'; -import { DynamicConnectDestinationFormFields } from '../dynamic-form-fields'; - -export const FormContainer = ({ - monitors, - dynamicFields, - exportedSignals, - destinationName, - handleDynamicFieldChange, - handleSignalChange, - setDestinationName, -}) => ( - - - setDestinationName(e.target.value)} - /> - - -); - -const StyledFormContainer = styled.div` - display: flex; - width: 100%; - flex-direction: column; - gap: 24px; - height: 443px; - overflow-y: auto; - padding-right: 16px; - box-sizing: border-box; - overflow: overlay; - max-height: calc(100vh - 410px); - - @media (height < 768px) { - max-height: calc(100vh - 350px); - } -`; diff --git a/frontend/webapp/containers/main/destinations/add-destination/connect-destination-modal-body/index.tsx b/frontend/webapp/containers/main/destinations/add-destination/connect-destination-modal-body/index.tsx deleted file mode 100644 index 94aa48242..000000000 --- a/frontend/webapp/containers/main/destinations/add-destination/connect-destination-modal-body/index.tsx +++ /dev/null @@ -1,220 +0,0 @@ -import React, { useEffect, useLayoutEffect, useMemo, useState } from 'react'; -import { useAppStore } from '@/store'; -import { INPUT_TYPES } from '@/utils'; -import { SideMenu } from '@/components'; -import { useQuery } from '@apollo/client'; -import { FormContainer } from './form-container'; -import { TestConnection } from '../test-connection'; -import { GET_DESTINATION_TYPE_DETAILS } from '@/graphql'; -import { Body, Container, SideMenuWrapper } from '../styled'; -import { Divider, SectionTitle } from '@/reuseable-components'; -import { ConnectionNotification } from './connection-notification'; -import type { StepProps, DestinationInput, DestinationTypeItem, DestinationDetailsResponse, ConfiguredDestination } from '@/types'; -import { useComputePlatform, useConnectDestinationForm, useConnectEnv, useDestinationFormData, useEditDestinationFormHandlers } from '@/hooks'; - -const SIDE_MENU_DATA: StepProps[] = [ - { - title: 'DESTINATIONS', - state: 'finish', - stepNumber: 1, - }, - { - title: 'CONNECTION', - state: 'active', - stepNumber: 2, - }, -]; - -interface ConnectDestinationModalBodyProps { - destination: DestinationTypeItem | undefined; - onSubmitRef: React.MutableRefObject<(() => void) | null>; - onFormValidChange: (isValid: boolean) => void; -} - -export function ConnectDestinationModalBody({ destination, onSubmitRef, onFormValidChange }: ConnectDestinationModalBodyProps) { - const [destinationName, setDestinationName] = useState(''); - const [showConnectionError, setShowConnectionError] = useState(false); - const [isFormDirty, setIsFormDirty] = useState(false); - - const { dynamicFields, exportedSignals, setDynamicFields, setExportedSignals } = useDestinationFormData(); - - const { connectEnv } = useConnectEnv(); - const { refetch } = useComputePlatform(); - const { buildFormDynamicFields } = useConnectDestinationForm(); - - const { handleDynamicFieldChange, handleSignalChange } = useEditDestinationFormHandlers( - (...params) => { - setIsFormDirty(true); - setExportedSignals(...params); - }, - (...params) => { - setIsFormDirty(true); - setDynamicFields(...params); - }, - ); - - const addConfiguredDestination = useAppStore(({ addConfiguredDestination }) => addConfiguredDestination); - - const { data } = useQuery(GET_DESTINATION_TYPE_DETAILS, { - variables: { type: destination?.type }, - skip: !destination, - }); - - useLayoutEffect(() => { - if (!destination) return; - const { logs, metrics, traces } = destination.supportedSignals; - setExportedSignals({ - logs: logs.supported, - metrics: metrics.supported, - traces: traces.supported, - }); - }, [destination, setExportedSignals]); - - useEffect(() => { - if (data && destination) { - const df = buildFormDynamicFields(data.destinationTypeDetails.fields); - - const newDynamicFields = df.map((field) => { - if (destination.fields && field?.name in destination.fields) { - return { - ...field, - value: destination.fields[field.name], - }; - } - return field; - }); - - setDynamicFields(newDynamicFields); - } - }, [data, destination]); - - useEffect(() => { - // Assign handleSubmit to the onSubmitRef so it can be triggered externally - onSubmitRef.current = handleSubmit; - }, [dynamicFields, destinationName, exportedSignals]); - - useEffect(() => { - const isFormValid = dynamicFields.every((field) => (field.required ? field.value : true)); - onFormValidChange(isFormValid); - }, [dynamicFields]); - - const monitors = useMemo(() => { - if (!destination) return []; - const { logs, metrics, traces } = destination.supportedSignals; - - return [logs.supported && { id: 'logs', title: 'Logs' }, metrics.supported && { id: 'metrics', title: 'Metrics' }, traces.supported && { id: 'traces', title: 'Traces' }].filter(Boolean); - }, [destination]); - - function onDynamicFieldChange(name: string, value: any) { - setIsFormDirty(true); - setShowConnectionError(false); - handleDynamicFieldChange(name, value); - } - function processFieldValue(field) { - return field.componentType === INPUT_TYPES.DROPDOWN ? field.value.value : field.value; - } - - function processFormFields() { - // Prepare fields for the request body - return dynamicFields.map((field) => ({ - key: field.name, - value: processFieldValue(field), - })); - } - - async function handleSubmit() { - // Prepare fields for the request body - const fields = processFormFields(); - - // Function to store configured destination to display in the UI - function storeConfiguredDestination() { - const destinationTypeDetails = dynamicFields.map((field) => ({ - title: field.title, - value: processFieldValue(field), - })); - - // Add 'Destination name' as the first item - destinationTypeDetails.unshift({ - title: 'Destination name', - value: destinationName, - }); - - // Construct the configured destination object - const storedDestination: ConfiguredDestination = { - exportedSignals, - destinationTypeDetails, - type: destination?.type || '', - imageUrl: destination?.imageUrl || '', - category: '', // Could be handled in a more dynamic way if needed - displayName: destination?.displayName || '', - }; - - // Dispatch action to store the destination - addConfiguredDestination(storedDestination); - refetch(); - } - - // Prepare the request body - const body: DestinationInput = { - name: destinationName, - type: destination?.type || '', - exportedSignals, - fields, - }; - - try { - // Await connection and store the configured destination if successful - await connectEnv(body, storeConfiguredDestination); - // await connectEnv(body, refetch); - } catch (error) { - console.error('Failed to submit destination configuration:', error); - // Handle error (e.g., show notification or alert) - } - } - - if (!destination) return null; - - return ( - - - - - - - setIsFormDirty(false)} - onError={() => { - setShowConnectionError(true); - onFormValidChange(false); - }} - /> - ) : undefined - } - /> - - - - - - ); -} diff --git a/frontend/webapp/containers/main/destinations/add-destination/index.tsx b/frontend/webapp/containers/main/destinations/add-destination/index.tsx index 7a34ce103..b2d90d994 100644 --- a/frontend/webapp/containers/main/destinations/add-destination/index.tsx +++ b/frontend/webapp/containers/main/destinations/add-destination/index.tsx @@ -1,17 +1,15 @@ import React, { useState } from 'react'; +import Image from 'next/image'; import { ROUTES } from '@/utils'; +import theme from '@/styles/theme'; import { useAppStore } from '@/store'; import styled from 'styled-components'; +import { SetupHeader } from '@/components'; import { useRouter } from 'next/navigation'; -import { AddDestinationModal } from './add-destination-modal'; -import { AddDestinationButton, SetupHeader } from '@/components'; -import { NotificationNote, SectionTitle } from '@/reuseable-components'; +import { useDestinationCRUD, useSourceCRUD } from '@/hooks'; +import { DestinationModal } from '../destination-modal'; import { ConfiguredDestinationsList } from './configured-destinations-list'; - -const AddDestinationButtonWrapper = styled.div` - width: 100%; - margin-top: 24px; -`; +import { Button, NotificationNote, SectionTitle, Text } from '@/reuseable-components'; const ContentWrapper = styled.div` width: 640px; @@ -26,31 +24,44 @@ const NotificationNoteWrapper = styled.div` margin-top: 24px; `; -export function ChooseDestinationContainer() { - const [isModalOpen, setModalOpen] = useState(false); +const AddDestinationButtonWrapper = styled.div` + width: 100%; + margin-top: 24px; +`; + +const StyledAddDestinationButton = styled(Button)` + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + width: 100%; +`; +export function AddDestinationContainer() { const router = useRouter(); - const { configuredSources, configuredDestinations, resetState } = useAppStore((state) => state); - - const isSourcesListEmpty = () => { - const sourceLen = Object.keys(configuredSources).length === 0; - if (sourceLen) { - return true; - } - - let empty = true; - for (const source in configuredSources) { - if (configuredSources[source].length > 0) { - empty = false; - break; - } - } - return empty; - }; + const { createSources, loading: sourcesLoading } = useSourceCRUD(); + const { createDestination, loading: destinationsLoading } = useDestinationCRUD(); + const { configuredSources, configuredFutureApps, configuredDestinations, resetState } = useAppStore((state) => state); + const [isModalOpen, setModalOpen] = useState(false); const handleOpenModal = () => setModalOpen(true); const handleCloseModal = () => setModalOpen(false); + const clickBack = () => { + router.push(ROUTES.CHOOSE_SOURCES); + }; + + const clickDone = async () => { + await createSources(configuredSources, configuredFutureApps); + await Promise.all(configuredDestinations.map(async ({ form }) => await createDestination(form))); + + resetState(); + router.push(ROUTES.OVERVIEW); + }; + + const isSourcesListEmpty = () => !Object.values(configuredSources).some((sources) => !!sources.length); + const isCreating = sourcesLoading || destinationsLoading; + return ( <> @@ -59,27 +70,27 @@ export function ChooseDestinationContainer() { { label: 'BACK', iconSrc: '/icons/common/arrow-white.svg', - onClick: () => router.push(ROUTES.CHOOSE_SOURCES), variant: 'secondary', + onClick: clickBack, + disabled: isCreating, }, { label: 'DONE', - onClick: () => { - resetState(); - router.push(ROUTES.OVERVIEW); - }, variant: 'primary', + onClick: clickDone, + disabled: isCreating, }, ]} /> - {isSourcesListEmpty() && configuredDestinations.length === 0 && ( + + {isSourcesListEmpty() && ( router.push(ROUTES.CHOOSE_SOURCES), @@ -87,11 +98,19 @@ export function ChooseDestinationContainer() { /> )} + - handleOpenModal()} /> + handleOpenModal()}> + back + + ADD DESTINATION + + + + + - ); diff --git a/frontend/webapp/containers/main/destinations/add-destination/styled.ts b/frontend/webapp/containers/main/destinations/add-destination/styled.ts deleted file mode 100644 index 579e93e6d..000000000 --- a/frontend/webapp/containers/main/destinations/add-destination/styled.ts +++ /dev/null @@ -1,21 +0,0 @@ -import styled from 'styled-components'; - -export const Body = styled.div` - padding: 32px 24px 0; - border-left: 1px solid rgba(249, 249, 249, 0.08); - min-height: 600px; - width: 100%; - min-width: 770px; -`; - -export const SideMenuWrapper = styled.div` - padding: 32px; - width: 196px; - @media (max-width: 1050px) { - display: none; - } -`; - -export const Container = styled.div` - display: flex; -`; diff --git a/frontend/webapp/containers/main/destinations/destination-drawer-container/index.tsx b/frontend/webapp/containers/main/destinations/destination-drawer-container/index.tsx deleted file mode 100644 index 144094d13..000000000 --- a/frontend/webapp/containers/main/destinations/destination-drawer-container/index.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import React, { useState } from 'react'; -import styled from 'styled-components'; -import { useDrawerStore } from '@/store'; -import { ActualDestination } from '@/types'; -import OverviewDrawer from '../../overview/overview-drawer'; -import { CardDetails, EditDestinationForm } from '@/components'; -import { useDestinationCRUD, useDestinationFormData, useEditDestinationFormHandlers } from '@/hooks'; - -interface Props {} - -const DestinationDrawer: React.FC = () => { - const selectedItem = useDrawerStore(({ selectedItem }) => selectedItem); - const [isEditing, setIsEditing] = useState(false); - const [isFormDirty, setIsFormDirty] = useState(false); - - const { cardData, dynamicFields, exportedSignals, supportedSignals, destinationType, resetFormData, setDynamicFields, setExportedSignals } = useDestinationFormData(); - const { handleSignalChange, handleDynamicFieldChange } = useEditDestinationFormHandlers(setExportedSignals, setDynamicFields); - const { updateDestination, deleteDestination } = useDestinationCRUD(); - - if (!selectedItem?.item) return null; - const { id, item } = selectedItem; - - const handleEdit = (bool?: boolean) => { - if (typeof bool === 'boolean') { - setIsEditing(bool); - } else { - setIsEditing(true); - } - }; - - const handleCancel = () => { - resetFormData(); - setIsEditing(false); - }; - - const handleDelete = async () => { - await deleteDestination(id as string); - }; - - const handleSave = async (newTitle: string) => { - const title = newTitle !== (item as ActualDestination).destinationType.displayName ? newTitle : ''; - const payload = { - type: destinationType, - name: title, - exportedSignals, - fields: dynamicFields.map(({ name, value }) => ({ key: name, value })), - }; - - await updateDestination(id as string, payload); - }; - - return ( - - {isEditing ? ( - - { - setIsFormDirty(true); - handleSignalChange(...params); - }} - handleDynamicFieldChange={(...params) => { - setIsFormDirty(true); - handleDynamicFieldChange(...params); - }} - /> - - ) : ( - - )} - - ); -}; - -export { DestinationDrawer }; - -const FormContainer = styled.div` - width: 100%; - height: 100%; - max-height: calc(100vh - 220px); - overflow: overlay; - overflow-y: auto; -`; diff --git a/frontend/webapp/containers/main/destinations/destination-drawer/index.tsx b/frontend/webapp/containers/main/destinations/destination-drawer/index.tsx new file mode 100644 index 000000000..f2595f6e4 --- /dev/null +++ b/frontend/webapp/containers/main/destinations/destination-drawer/index.tsx @@ -0,0 +1,141 @@ +import React, { useMemo, useState } from 'react'; +import styled from 'styled-components'; +import { safeJsonParse } from '@/utils'; +import { useDrawerStore } from '@/store'; +import { CardDetails } from '@/components'; +import type { ActualDestination } from '@/types'; +import OverviewDrawer from '../../overview/overview-drawer'; +import { DestinationFormBody } from '../destination-form-body'; +import { useDestinationCRUD, useDestinationFormData, useDestinationTypes } from '@/hooks'; + +interface Props {} + +const FormContainer = styled.div` + width: 100%; + height: 100%; + max-height: calc(100vh - 220px); + overflow: overlay; + overflow-y: auto; +`; + +export const DestinationDrawer: React.FC = () => { + const selectedItem = useDrawerStore(({ selectedItem }) => selectedItem); + const [isEditing, setIsEditing] = useState(false); + const [isFormDirty, setIsFormDirty] = useState(false); + + const { updateDestination, deleteDestination } = useDestinationCRUD(); + const { formData, handleFormChange, resetFormData, validateForm, loadFormWithDrawerItem, destinationTypeDetails, dynamicFields, setDynamicFields } = useDestinationFormData({ + destinationType: (selectedItem?.item as ActualDestination)?.destinationType?.type, + preLoadedFields: (selectedItem?.item as ActualDestination)?.fields, + // TODO: supportedSignals: thisDestination?.supportedSignals, + // currently, the real "supportedSignals" is being used by "destination" passed as prop to "DestinationFormBody" + }); + + const cardData = useMemo(() => { + if (!selectedItem) return []; + + const buildMonitorsList = (exportedSignals: ActualDestination['exportedSignals']): string => + Object.keys(exportedSignals) + .filter((key) => exportedSignals[key]) + .join(', ') || 'N/A'; + + const buildDestinationFieldData = (parsedFields: Record) => + Object.entries(parsedFields).map(([key, value]) => { + const found = destinationTypeDetails?.fields?.find((field) => field.name === key); + + const { type } = safeJsonParse(found?.componentProperties, { type: '' }); + const secret = type === 'password' ? new Array(value.length).fill('•').join('') : ''; + + return { + title: found?.displayName || key, + value: secret || value || 'N/A', + }; + }); + + const { exportedSignals, destinationType, fields } = selectedItem.item as ActualDestination; + const parsedFields = safeJsonParse>(fields, {}); + const fieldsData = buildDestinationFieldData(parsedFields); + + return [{ title: 'Destination', value: destinationType.displayName || 'N/A' }, { title: 'Monitors', value: buildMonitorsList(exportedSignals) }, ...fieldsData]; + }, [selectedItem, destinationTypeDetails]); + + const { destinations } = useDestinationTypes(); + const thisDestination = useMemo(() => { + if (!destinations.length || !selectedItem || !isEditing) { + resetFormData(); + return undefined; + } + + const { item } = selectedItem as { item: ActualDestination }; + const found = destinations.map(({ items }) => items.filter(({ type }) => type === item.destinationType.type)).filter((arr) => !!arr.length)[0][0]; + + if (!found) return undefined; + + loadFormWithDrawerItem(selectedItem); + + return found; + }, [destinations, selectedItem, isEditing]); + + if (!selectedItem?.item) return null; + const { id, item } = selectedItem; + + const handleEdit = (bool?: boolean) => { + if (typeof bool === 'boolean') { + setIsEditing(bool); + } else { + setIsEditing(true); + } + }; + + const handleCancel = () => { + resetFormData(); + setIsEditing(false); + }; + + const handleDelete = async () => { + await deleteDestination(id as string); + }; + + const handleSave = async (newTitle: string) => { + if (validateForm({ withAlert: true })) { + const title = newTitle !== (item as ActualDestination).destinationType.displayName ? newTitle : ''; + + await updateDestination(id as string, { ...formData, name: title }); + } + }; + + return ( + + {isEditing ? ( + + { + setIsFormDirty(true); + handleFormChange(...params); + }} + dynamicFields={dynamicFields} + setDynamicFields={(...params) => { + setIsFormDirty(true); + setDynamicFields(...params); + }} + /> + + ) : ( + + )} + + ); +}; diff --git a/frontend/webapp/containers/main/destinations/add-destination/dynamic-form-fields/index.tsx b/frontend/webapp/containers/main/destinations/destination-form-body/dynamic-fields/index.tsx similarity index 86% rename from frontend/webapp/containers/main/destinations/add-destination/dynamic-form-fields/index.tsx rename to frontend/webapp/containers/main/destinations/destination-form-body/dynamic-fields/index.tsx index 033a13ac8..98a733483 100644 --- a/frontend/webapp/containers/main/destinations/add-destination/dynamic-form-fields/index.tsx +++ b/frontend/webapp/containers/main/destinations/destination-form-body/dynamic-fields/index.tsx @@ -2,7 +2,12 @@ import React from 'react'; import { INPUT_TYPES } from '@/utils'; import { Dropdown, Input, TextArea, InputList, KeyValueInputsList } from '@/reuseable-components'; -export function DynamicConnectDestinationFormFields({ fields, onChange }: { fields: any[]; onChange: (name: string, value: any) => void }) { +interface Props { + fields: any[]; + onChange: (name: string, value: any) => void; +} + +export const DestinationDynamicFields: React.FC = ({ fields, onChange }) => { return fields?.map((field: any) => { const { componentType, ...rest } = field; @@ -21,4 +26,4 @@ export function DynamicConnectDestinationFormFields({ fields, onChange }: { fiel return null; } }); -} +}; diff --git a/frontend/webapp/containers/main/destinations/destination-form-body/index.tsx b/frontend/webapp/containers/main/destinations/destination-form-body/index.tsx new file mode 100644 index 000000000..021a7558b --- /dev/null +++ b/frontend/webapp/containers/main/destinations/destination-form-body/index.tsx @@ -0,0 +1,121 @@ +import React, { Dispatch, SetStateAction, useEffect, useMemo, useState } from 'react'; +import styled from 'styled-components'; +import { TestConnection } from './test-connection'; +import { DestinationDynamicFields } from './dynamic-fields'; +import type { DestinationInput, DestinationTypeItem, DynamicField } from '@/types'; +import { CheckboxList, Divider, Input, NotificationNote, SectionTitle } from '@/reuseable-components'; + +interface Props { + isUpdate?: boolean; + destination?: DestinationTypeItem; + isFormOk: boolean; + formData: DestinationInput; + handleFormChange: (key: keyof DestinationInput | string, val: any) => void; + dynamicFields: DynamicField[]; + setDynamicFields: Dispatch>; +} + +const Container = styled.div` + display: flex; + flex-direction: column; + gap: 24px; + padding: 0 4px; +`; + +export function DestinationFormBody({ isUpdate, destination, isFormOk, formData, handleFormChange, dynamicFields, setDynamicFields }: Props) { + const { supportedSignals, testConnectionSupported, displayName } = destination || {}; + + const [isFormDirty, setIsFormDirty] = useState(false); + const [showConnectionError, setShowConnectionError] = useState(false); + + // this is to allow test connection when there are default values loaded + useEffect(() => { + if (isFormOk) setIsFormDirty(true); + }, [isFormOk]); + + const supportedMonitors = useMemo(() => { + const { logs, metrics, traces } = supportedSignals || {}; + const arr: { id: string; title: string }[] = []; + + if (logs?.supported) arr.push({ id: 'logs', title: 'Logs' }); + if (metrics?.supported) arr.push({ id: 'metrics', title: 'Metrics' }); + if (traces?.supported) arr.push({ id: 'traces', title: 'Traces' }); + + return arr; + }, [supportedSignals]); + + return ( + + {!isUpdate && ( + <> + { + setIsFormDirty(false); + setShowConnectionError(false); + }} + onError={() => { + setIsFormDirty(false); + setShowConnectionError(true); + }} + /> + ) + } + /> + + {testConnectionSupported && showConnectionError ? ( + + ) : testConnectionSupported && !showConnectionError && !!displayName ? ( + + ) : null} + + + )} + + { + if (!isFormDirty) setIsFormDirty(true); + handleFormChange(`exportedSignals.${signal}`, value); + }} + /> + + {!isUpdate && ( + { + if (!isFormDirty) setIsFormDirty(true); + handleFormChange('name', e.target.value); + }} + /> + )} + + { + if (!isFormDirty) setIsFormDirty(true); + setDynamicFields((prev) => { + const payload = [...prev]; + const foundIndex = payload.findIndex((field) => field.name === name); + + if (foundIndex !== -1) { + payload[foundIndex] = { ...payload[foundIndex], value }; + } + + return payload; + }); + }} + /> + + ); +} diff --git a/frontend/webapp/containers/main/destinations/add-destination/test-connection/index.tsx b/frontend/webapp/containers/main/destinations/destination-form-body/test-connection/index.tsx similarity index 77% rename from frontend/webapp/containers/main/destinations/add-destination/test-connection/index.tsx rename to frontend/webapp/containers/main/destinations/destination-form-body/test-connection/index.tsx index 74fa51f46..fe3c756ab 100644 --- a/frontend/webapp/containers/main/destinations/add-destination/test-connection/index.tsx +++ b/frontend/webapp/containers/main/destinations/destination-form-body/test-connection/index.tsx @@ -8,8 +8,8 @@ import { Button, FadeLoader, Text } from '@/reuseable-components'; interface TestConnectionProps { destination: DestinationInput; - isFormDirty: boolean; - clearFormDirty: () => void; + disabled: boolean; + clearStatus: () => void; onError: () => void; } @@ -30,21 +30,19 @@ const ActionButtonText = styled(Text)<{ $success?: boolean }>` color: ${({ theme, $success }) => ($success ? theme.text.success : theme.colors.white)}; `; -const TestConnection: React.FC = ({ destination, isFormDirty, clearFormDirty, onError }) => { +export const TestConnection: React.FC = ({ destination, disabled, clearStatus, onError }) => { const { testConnection, loading, data } = useTestConnection(); - - const disabled = useMemo(() => !destination.fields.find((field) => !!field.value), [destination.fields]); const success = useMemo(() => data?.testConnectionForDestination.succeeded || false, [data]); useEffect(() => { if (data) { - clearFormDirty(); + clearStatus(); if (!success) onError && onError(); } }, [data, success]); return ( - testConnection(destination)} $success={success}> + testConnection(destination)} $success={success}> {loading ? : success ? checkmark : null} @@ -53,5 +51,3 @@ const TestConnection: React.FC = ({ destination, isFormDirt ); }; - -export { TestConnection }; diff --git a/frontend/webapp/containers/main/destinations/destination-modal/choose-destination-body/choose-destination-filters/index.tsx b/frontend/webapp/containers/main/destinations/destination-modal/choose-destination-body/choose-destination-filters/index.tsx new file mode 100644 index 000000000..cb652ffd2 --- /dev/null +++ b/frontend/webapp/containers/main/destinations/destination-modal/choose-destination-body/choose-destination-filters/index.tsx @@ -0,0 +1,52 @@ +import React, { Dispatch, SetStateAction, useState } from 'react'; +import styled from 'styled-components'; +import { SignalUppercase } from '@/utils'; +import type { DropdownOption } from '@/types'; +import { Dropdown, Input, MonitoringCheckboxes } from '@/reuseable-components'; + +interface Props { + selectedTag: DropdownOption | undefined; + onTagSelect: (option: DropdownOption) => void; + onSearch: (value: string) => void; + selectedMonitors: SignalUppercase[]; + setSelectedMonitors: Dispatch>; +} + +const Container = styled.div` + display: flex; + align-items: center; + gap: 12px; +`; + +const WidthConstraint = styled.div` + width: 160px; + margin-right: 8px; +`; + +const DROPDOWN_OPTIONS = [ + { value: 'All types', id: 'all' }, + { value: 'Managed', id: 'managed' }, + { value: 'Self-hosted', id: 'self hosted' }, +]; + +export const ChooseDestinationFilters: React.FC = ({ selectedTag, onTagSelect, onSearch, selectedMonitors, setSelectedMonitors }) => { + const [searchTerm, setSearchTerm] = useState(''); + + const handleSearchChange = (e: React.ChangeEvent) => { + const value = e.target.value; + setSearchTerm(value); + onSearch(value); + }; + + return ( + + + + + + {}} /> + + + + ); +}; diff --git a/frontend/webapp/containers/main/destinations/add-destination/destinations-list/destination-list-item/index.tsx b/frontend/webapp/containers/main/destinations/destination-modal/choose-destination-body/destinations-list/destination-list-item/index.tsx similarity index 85% rename from frontend/webapp/containers/main/destinations/add-destination/destinations-list/destination-list-item/index.tsx rename to frontend/webapp/containers/main/destinations/destination-modal/choose-destination-body/destinations-list/destination-list-item/index.tsx index aa16aea36..8ce00ad1e 100644 --- a/frontend/webapp/containers/main/destinations/add-destination/destinations-list/destination-list-item/index.tsx +++ b/frontend/webapp/containers/main/destinations/destination-modal/choose-destination-body/destinations-list/destination-list-item/index.tsx @@ -47,11 +47,7 @@ const DestinationIconWrapper = styled.div` align-items: center; gap: 8px; border-radius: 8px; - background: linear-gradient( - 180deg, - rgba(249, 249, 249, 0.06) 0%, - rgba(249, 249, 249, 0.02) 100% - ); + background: linear-gradient(180deg, rgba(249, 249, 249, 0.06) 0%, rgba(249, 249, 249, 0.02) 100%); `; const SignalsWrapper = styled.div` @@ -83,14 +79,9 @@ interface DestinationListItemProps { onSelect: (item: DestinationTypeItem) => void; } -const DestinationListItem: React.FC = ({ - item, - onSelect, -}) => { +export const DestinationListItem: React.FC = ({ item, onSelect }) => { const renderSupportedSignals = () => { - const signals = Object.keys(item.supportedSignals).filter( - (signal) => item.supportedSignals[signal].supported - ); + const signals = Object.keys(item.supportedSignals).filter((signal) => item.supportedSignals[signal].supported); return signals.map((signal, index) => ( @@ -104,7 +95,7 @@ const DestinationListItem: React.FC = ({ onSelect(item)}> - destination + destination {item.displayName} @@ -117,5 +108,3 @@ const DestinationListItem: React.FC = ({ ); }; - -export { DestinationListItem }; diff --git a/frontend/webapp/containers/main/destinations/add-destination/destinations-list/index.tsx b/frontend/webapp/containers/main/destinations/destination-modal/choose-destination-body/destinations-list/index.tsx similarity index 100% rename from frontend/webapp/containers/main/destinations/add-destination/destinations-list/index.tsx rename to frontend/webapp/containers/main/destinations/destination-modal/choose-destination-body/destinations-list/index.tsx diff --git a/frontend/webapp/containers/main/destinations/add-destination/destinations-list/potential-destinations-list/index.tsx b/frontend/webapp/containers/main/destinations/destination-modal/choose-destination-body/destinations-list/potential-destinations-list/index.tsx similarity index 53% rename from frontend/webapp/containers/main/destinations/add-destination/destinations-list/potential-destinations-list/index.tsx rename to frontend/webapp/containers/main/destinations/destination-modal/choose-destination-body/destinations-list/potential-destinations-list/index.tsx index 8470f2e81..d52c75038 100644 --- a/frontend/webapp/containers/main/destinations/add-destination/destinations-list/potential-destinations-list/index.tsx +++ b/frontend/webapp/containers/main/destinations/destination-modal/choose-destination-body/destinations-list/potential-destinations-list/index.tsx @@ -15,36 +15,20 @@ interface PotentialDestinationsListProps { setSelectedItems: (item: DestinationTypeItem) => void; } -const PotentialDestinationsList: React.FC = ({ - setSelectedItems, -}) => { +export const PotentialDestinationsList: React.FC = ({ setSelectedItems }) => { const { loading, data } = usePotentialDestinations(); - if (!data.length) { - return null; - } + if (!data.length) return null; return ( - {loading ? ( - - ) : ( - data.map((item) => ( - - )) - )} + {loading ? : data.map((item) => )} ); }; - -export { PotentialDestinationsList }; diff --git a/frontend/webapp/containers/main/destinations/destination-modal/choose-destination-body/index.tsx b/frontend/webapp/containers/main/destinations/destination-modal/choose-destination-body/index.tsx new file mode 100644 index 000000000..d40486e9e --- /dev/null +++ b/frontend/webapp/containers/main/destinations/destination-modal/choose-destination-body/index.tsx @@ -0,0 +1,60 @@ +import React, { useMemo, useState } from 'react'; +import styled from 'styled-components'; +import { SignalUppercase } from '@/utils'; +import { useDestinationTypes } from '@/hooks'; +import { DestinationsList } from './destinations-list'; +import { Divider, SectionTitle } from '@/reuseable-components'; +import type { DropdownOption, DestinationTypeItem } from '@/types'; +import { ChooseDestinationFilters } from './choose-destination-filters'; + +interface Props { + onSelect: (item: DestinationTypeItem) => void; +} + +const DEFAULT_MONITORS: SignalUppercase[] = ['LOGS', 'METRICS', 'TRACES']; +const DEFAULT_DROPDOWN_VALUE = { id: 'all', value: 'All types' }; + +const Container = styled.div` + display: flex; + flex-direction: column; + gap: 24px; +`; + +export const ChooseDestinationBody: React.FC = ({ onSelect }) => { + const [searchValue, setSearchValue] = useState(''); + const [selectedMonitors, setSelectedMonitors] = useState(DEFAULT_MONITORS); + const [dropdownValue, setDropdownValue] = useState(DEFAULT_DROPDOWN_VALUE); + + const { destinations } = useDestinationTypes(); + + const filteredDestinations = useMemo(() => { + return destinations + .map((category) => { + const filteredItems = category.items.filter((item) => { + const matchesSearch = searchValue ? item.displayName.toLowerCase().includes(searchValue.toLowerCase()) : true; + const matchesDropdown = dropdownValue.id !== 'all' ? category.name === dropdownValue.id : true; + const matchesMonitor = selectedMonitors.length ? selectedMonitors.some((monitor) => item.supportedSignals[monitor.toLowerCase()]?.supported) : true; + + return matchesSearch && matchesDropdown && matchesMonitor; + }); + + return { ...category, items: filteredItems }; + }) + .filter((category) => category.items.length > 0); // Filter out empty categories + }, [destinations, searchValue, dropdownValue, selectedMonitors]); + + return ( + + + setDropdownValue(opt)} + onSearch={setSearchValue} + selectedMonitors={selectedMonitors} + setSelectedMonitors={setSelectedMonitors} + /> + + + + ); +}; diff --git a/frontend/webapp/containers/main/destinations/destination-modal/index.tsx b/frontend/webapp/containers/main/destinations/destination-modal/index.tsx new file mode 100644 index 000000000..433cd0945 --- /dev/null +++ b/frontend/webapp/containers/main/destinations/destination-modal/index.tsx @@ -0,0 +1,142 @@ +import React, { useState } from 'react'; +import { ModalBody } from '@/styles'; +import { useAppStore } from '@/store'; +import { INPUT_TYPES } from '@/utils'; +import styled from 'styled-components'; +import { SideMenu } from '@/components'; +import { DestinationFormBody } from '../destination-form-body'; +import { ChooseDestinationBody } from './choose-destination-body'; +import { useDestinationCRUD, useDestinationFormData } from '@/hooks'; +import type { ConfiguredDestination, DestinationTypeItem } from '@/types'; +import { Modal, type NavigationButtonProps, NavigationButtons } from '@/reuseable-components'; + +interface AddDestinationModalProps { + isOnboarding?: boolean; + isOpen: boolean; + onClose: () => void; +} + +const Container = styled.div` + display: flex; +`; + +const SideMenuWrapper = styled.div` + border-right: 1px solid ${({ theme }) => theme.colors.border}; + padding: 32px; + width: 200px; + @media (max-width: 1050px) { + display: none; + } +`; + +export const DestinationModal: React.FC = ({ isOnboarding, isOpen, onClose }) => { + const [selectedItem, setSelectedItem] = useState(); + + const { createDestination } = useDestinationCRUD(); + const addConfiguredDestination = useAppStore(({ addConfiguredDestination }) => addConfiguredDestination); + const { formData, handleFormChange, resetFormData, validateForm, dynamicFields, setDynamicFields } = useDestinationFormData({ + supportedSignals: selectedItem?.supportedSignals, + preLoadedFields: selectedItem?.fields, + }); + + const isFormOk = !!selectedItem && validateForm(); + + const handleClose = () => { + resetFormData(); + setSelectedItem(undefined); + onClose(); + }; + + const handleBack = () => { + resetFormData(); + setSelectedItem(undefined); + }; + + const handleSelect = (item: DestinationTypeItem) => { + resetFormData(); + handleFormChange('type', item.type); + setSelectedItem(item); + }; + + const handleSubmit = async () => { + if (isOnboarding) { + const destinationTypeDetails = dynamicFields.map((field) => ({ + title: field.title, + value: field.componentType === INPUT_TYPES.DROPDOWN ? field.value.value : field.value, + })); + + destinationTypeDetails.unshift({ + title: 'Destination name', + value: formData.name, + }); + + const storedDestination: ConfiguredDestination = { + type: selectedItem?.type || '', + displayName: selectedItem?.displayName || '', + imageUrl: selectedItem?.imageUrl || '', + exportedSignals: formData.exportedSignals, + destinationTypeDetails, + category: '', // Could be handled in a more dynamic way if needed + }; + + addConfiguredDestination({ stored: storedDestination, form: formData }); + } else { + createDestination(formData); + } + + handleClose(); + }; + + const renderHeaderButtons = () => { + const buttons: NavigationButtonProps[] = [ + { + label: 'DONE', + variant: 'primary' as const, + onClick: handleSubmit, + disabled: !isFormOk, + }, + ]; + + if (!!selectedItem) { + buttons.unshift({ + label: 'BACK', + iconSrc: '/icons/common/arrow-white.svg', + variant: 'secondary' as const, + onClick: handleBack, + }); + } + + return buttons; + }; + + return ( + }> + + + + + + + {!!selectedItem ? ( + + ) : ( + + )} + + + + ); +}; diff --git a/frontend/webapp/containers/main/destinations/index.tsx b/frontend/webapp/containers/main/destinations/index.tsx index 6872095ac..d1c0e791f 100644 --- a/frontend/webapp/containers/main/destinations/index.tsx +++ b/frontend/webapp/containers/main/destinations/index.tsx @@ -1,2 +1,4 @@ export * from './add-destination'; -export * from './destination-drawer-container'; +export * from './destination-drawer'; +export * from './destination-form-body'; +export * from './destination-modal'; diff --git a/frontend/webapp/containers/main/instrumentation-rules/index.ts b/frontend/webapp/containers/main/instrumentation-rules/index.ts index c5c586edb..e49028254 100644 --- a/frontend/webapp/containers/main/instrumentation-rules/index.ts +++ b/frontend/webapp/containers/main/instrumentation-rules/index.ts @@ -1 +1,3 @@ -export * from './add-rule-modal'; +export * from './rule-drawer'; +export * from './rule-form-body'; +export * from './rule-modal'; diff --git a/frontend/webapp/containers/main/instrumentation-rules/rule-drawer-container/build-card-from-rule-spec.ts b/frontend/webapp/containers/main/instrumentation-rules/rule-drawer/build-card-from-rule-spec.ts similarity index 100% rename from frontend/webapp/containers/main/instrumentation-rules/rule-drawer-container/build-card-from-rule-spec.ts rename to frontend/webapp/containers/main/instrumentation-rules/rule-drawer/build-card-from-rule-spec.ts diff --git a/frontend/webapp/containers/main/instrumentation-rules/rule-drawer-container/index.tsx b/frontend/webapp/containers/main/instrumentation-rules/rule-drawer/index.tsx similarity index 93% rename from frontend/webapp/containers/main/instrumentation-rules/rule-drawer-container/index.tsx rename to frontend/webapp/containers/main/instrumentation-rules/rule-drawer/index.tsx index 417974e2a..4a64b1d67 100644 --- a/frontend/webapp/containers/main/instrumentation-rules/rule-drawer-container/index.tsx +++ b/frontend/webapp/containers/main/instrumentation-rules/rule-drawer/index.tsx @@ -1,18 +1,26 @@ import React, { useMemo, useState } from 'react'; +import { RuleFormBody } from '../'; import styled from 'styled-components'; import { getRuleIcon } from '@/utils'; import { useDrawerStore } from '@/store'; import { CardDetails } from '@/components'; -import { ChooseRuleBody } from '../choose-rule-body'; import type { InstrumentationRuleSpec } from '@/types'; +import { RULE_OPTIONS } from '../rule-modal/rule-options'; import OverviewDrawer from '../../overview/overview-drawer'; -import { RULE_OPTIONS } from '../add-rule-modal/rule-options'; import buildCardFromRuleSpec from './build-card-from-rule-spec'; import { useInstrumentationRuleCRUD, useInstrumentationRuleFormData } from '@/hooks'; interface Props {} -const RuleDrawer: React.FC = () => { +const FormContainer = styled.div` + width: 100%; + height: 100%; + max-height: calc(100vh - 220px); + overflow: overlay; + overflow-y: auto; +`; + +export const RuleDrawer: React.FC = () => { const selectedItem = useDrawerStore(({ selectedItem }) => selectedItem); const [isEditing, setIsEditing] = useState(false); const [isFormDirty, setIsFormDirty] = useState(false); @@ -86,7 +94,7 @@ const RuleDrawer: React.FC = () => { > {isEditing && thisRule ? ( - = () => { ); }; - -export { RuleDrawer }; - -const FormContainer = styled.div` - width: 100%; - height: 100%; - max-height: calc(100vh - 220px); - overflow: overlay; - overflow-y: auto; -`; diff --git a/frontend/webapp/containers/main/instrumentation-rules/choose-rule-body/custom-fields/index.tsx b/frontend/webapp/containers/main/instrumentation-rules/rule-form-body/custom-fields/index.tsx similarity index 100% rename from frontend/webapp/containers/main/instrumentation-rules/choose-rule-body/custom-fields/index.tsx rename to frontend/webapp/containers/main/instrumentation-rules/rule-form-body/custom-fields/index.tsx diff --git a/frontend/webapp/containers/main/instrumentation-rules/choose-rule-body/custom-fields/payload-collection.tsx b/frontend/webapp/containers/main/instrumentation-rules/rule-form-body/custom-fields/payload-collection.tsx similarity index 100% rename from frontend/webapp/containers/main/instrumentation-rules/choose-rule-body/custom-fields/payload-collection.tsx rename to frontend/webapp/containers/main/instrumentation-rules/rule-form-body/custom-fields/payload-collection.tsx diff --git a/frontend/webapp/containers/main/instrumentation-rules/choose-rule-body/index.tsx b/frontend/webapp/containers/main/instrumentation-rules/rule-form-body/index.tsx similarity index 88% rename from frontend/webapp/containers/main/instrumentation-rules/choose-rule-body/index.tsx rename to frontend/webapp/containers/main/instrumentation-rules/rule-form-body/index.tsx index a038de5af..fea52ec03 100644 --- a/frontend/webapp/containers/main/instrumentation-rules/choose-rule-body/index.tsx +++ b/frontend/webapp/containers/main/instrumentation-rules/rule-form-body/index.tsx @@ -2,7 +2,7 @@ import React from 'react'; import styled from 'styled-components'; import RuleCustomFields from './custom-fields'; import type { InstrumentationRuleInput } from '@/types'; -import type { RuleOption } from '../add-rule-modal/rule-options'; +import type { RuleOption } from '../rule-modal/rule-options'; import { DocsButton, Input, Text, TextArea, SectionTitle, ToggleButtons } from '@/reuseable-components'; interface Props { @@ -23,7 +23,7 @@ const FieldTitle = styled(Text)` margin-bottom: 12px; `; -const ChooseRuleBody: React.FC = ({ isUpdate, rule, formData, handleFormChange }) => { +export const RuleFormBody: React.FC = ({ isUpdate, rule, formData, handleFormChange }) => { return ( {isUpdate && ( @@ -43,5 +43,3 @@ const ChooseRuleBody: React.FC = ({ isUpdate, rule, formData, handleFormC ); }; - -export { ChooseRuleBody }; diff --git a/frontend/webapp/containers/main/instrumentation-rules/add-rule-modal/index.tsx b/frontend/webapp/containers/main/instrumentation-rules/rule-modal/index.tsx similarity index 91% rename from frontend/webapp/containers/main/instrumentation-rules/add-rule-modal/index.tsx rename to frontend/webapp/containers/main/instrumentation-rules/rule-modal/index.tsx index 80cd7e9ed..89a9530b3 100644 --- a/frontend/webapp/containers/main/instrumentation-rules/add-rule-modal/index.tsx +++ b/frontend/webapp/containers/main/instrumentation-rules/rule-modal/index.tsx @@ -1,7 +1,7 @@ +import React, { useMemo, useState } from 'react'; import { CenterThis, ModalBody } from '@/styles'; -import { ChooseRuleBody } from '../choose-rule-body'; +import { RuleFormBody } from '../'; import { RULE_OPTIONS, RuleOption } from './rule-options'; -import React, { useMemo, useState } from 'react'; import { useInstrumentationRuleCRUD, useInstrumentationRuleFormData } from '@/hooks'; import { AutocompleteInput, Divider, FadeLoader, Modal, NavigationButtons, NotificationNote, SectionTitle } from '@/reuseable-components'; @@ -10,7 +10,7 @@ interface Props { onClose: () => void; } -export const AddRuleModal: React.FC = ({ isOpen, onClose }) => { +export const RuleModal: React.FC = ({ isOpen, onClose }) => { const { formData, handleFormChange, resetFormData, validateForm } = useInstrumentationRuleFormData(); const { createInstrumentationRule, loading } = useInstrumentationRuleCRUD({ onSuccess: handleClose }); const [selectedItem, setSelectedItem] = useState(RULE_OPTIONS[0]); @@ -64,7 +64,7 @@ export const AddRuleModal: React.FC = ({ isOpen, onClose }) => { ) : ( - + )} ) : null} diff --git a/frontend/webapp/containers/main/instrumentation-rules/add-rule-modal/rule-options.ts b/frontend/webapp/containers/main/instrumentation-rules/rule-modal/rule-options.ts similarity index 100% rename from frontend/webapp/containers/main/instrumentation-rules/add-rule-modal/rule-options.ts rename to frontend/webapp/containers/main/instrumentation-rules/rule-modal/rule-options.ts diff --git a/frontend/webapp/containers/main/overview/all-drawers/index.tsx b/frontend/webapp/containers/main/overview/all-drawers/index.tsx index 0600ceb69..65b2bba52 100644 --- a/frontend/webapp/containers/main/overview/all-drawers/index.tsx +++ b/frontend/webapp/containers/main/overview/all-drawers/index.tsx @@ -1,10 +1,10 @@ import React from 'react'; import { useDrawerStore } from '@/store'; -import { OVERVIEW_ENTITY_TYPES } from '@/types'; import { SourceDrawer } from '../../sources'; import { ActionDrawer } from '../../actions'; +import { OVERVIEW_ENTITY_TYPES } from '@/types'; import { DestinationDrawer } from '../../destinations'; -import { RuleDrawer } from '../../instrumentation-rules/rule-drawer-container'; +import { RuleDrawer } from '../../instrumentation-rules'; const AllDrawers = () => { const selected = useDrawerStore(({ selectedItem }) => selectedItem); diff --git a/frontend/webapp/containers/main/overview/all-modals/index.tsx b/frontend/webapp/containers/main/overview/all-modals/index.tsx index db2ac9bb2..96039338a 100644 --- a/frontend/webapp/containers/main/overview/all-modals/index.tsx +++ b/frontend/webapp/containers/main/overview/all-modals/index.tsx @@ -1,9 +1,9 @@ import React from 'react'; import { useModalStore } from '@/store'; +import { ActionModal } from '../../actions'; import { OVERVIEW_ENTITY_TYPES } from '@/types'; -import { AddRuleModal } from '../../instrumentation-rules'; -import { AddActionModal } from '../../actions'; -import { AddDestinationModal } from '../../destinations/add-destination/add-destination-modal'; +import { DestinationModal } from '../../destinations'; +import { RuleModal } from '../../instrumentation-rules'; import { AddSourceModal } from '../../sources/choose-sources/choose-source-modal'; const AllModals = () => { @@ -16,16 +16,16 @@ const AllModals = () => { switch (selected) { case OVERVIEW_ENTITY_TYPES.RULE: - return ; + return ; case OVERVIEW_ENTITY_TYPES.SOURCE: return ; case OVERVIEW_ENTITY_TYPES.ACTION: - return ; + return ; case OVERVIEW_ENTITY_TYPES.DESTINATION: - return ; + return ; default: return <>; diff --git a/frontend/webapp/containers/main/sources/choose-sources/index.tsx b/frontend/webapp/containers/main/sources/choose-sources/index.tsx index 7cb934222..355841b93 100644 --- a/frontend/webapp/containers/main/sources/choose-sources/index.tsx +++ b/frontend/webapp/containers/main/sources/choose-sources/index.tsx @@ -17,14 +17,12 @@ export function ChooseSourcesContainer() { const menuState = useSourceFormData(); const onNext = () => { - const { selectedNamespace, availableSources, selectedSources, selectedFutureApps } = menuState; + const { availableSources, selectedSources, selectedFutureApps } = menuState; const { setAvailableSources, setConfiguredSources, setConfiguredFutureApps } = appState; - if (selectedNamespace) { - setAvailableSources(availableSources); - setConfiguredSources(selectedSources); - setConfiguredFutureApps(selectedFutureApps); - } + setAvailableSources(availableSources); + setConfiguredSources(selectedSources); + setConfiguredFutureApps(selectedFutureApps); router.push(ROUTES.CHOOSE_DESTINATION); }; diff --git a/frontend/webapp/hooks/destinations/index.ts b/frontend/webapp/hooks/destinations/index.ts index 071f0c685..3b987c456 100644 --- a/frontend/webapp/hooks/destinations/index.ts +++ b/frontend/webapp/hooks/destinations/index.ts @@ -3,5 +3,4 @@ export * from './useConnectDestinationForm'; export * from './usePotentialDestinations'; export * from './useDestinationCRUD'; export * from './useDestinationFormData'; -export * from './useEditDestinationFormHandlers'; export * from './useDestinationTypes'; diff --git a/frontend/webapp/hooks/destinations/useConnectDestinationForm.ts b/frontend/webapp/hooks/destinations/useConnectDestinationForm.ts index 7e8c989b8..67295a5f8 100644 --- a/frontend/webapp/hooks/destinations/useConnectDestinationForm.ts +++ b/frontend/webapp/hooks/destinations/useConnectDestinationForm.ts @@ -2,34 +2,26 @@ import { safeJsonParse, INPUT_TYPES } from '@/utils'; import { DestinationDetailsField, DynamicField } from '@/types'; export function useConnectDestinationForm() { - function buildFormDynamicFields( - fields: DestinationDetailsField[] - ): DynamicField[] { + function buildFormDynamicFields(fields: DestinationDetailsField[]): DynamicField[] { return fields .map((field) => { - const { - name, - componentType, - displayName, - componentProperties, - initialValue, - } = field; + const { name, componentType, displayName, componentProperties, initialValue } = field; let componentPropertiesJson; let initialValuesJson; switch (componentType) { case INPUT_TYPES.DROPDOWN: - componentPropertiesJson = safeJsonParse<{ [key: string]: string }>( - componentProperties, - {} - ); + componentPropertiesJson = safeJsonParse<{ [key: string]: string }>(componentProperties, {}); - const options = Object.entries(componentPropertiesJson.values).map( - ([key, value]) => ({ - id: key, - value, - }) - ); + const options = Array.isArray(componentPropertiesJson.values) + ? componentPropertiesJson.values.map((value) => ({ + id: value, + value, + })) + : Object.entries(componentPropertiesJson.values).map(([key, value]) => ({ + id: key, + value, + })); return { name, @@ -43,10 +35,8 @@ export function useConnectDestinationForm() { case INPUT_TYPES.INPUT: case INPUT_TYPES.TEXTAREA: - componentPropertiesJson = safeJsonParse( - componentProperties, - [] - ); + componentPropertiesJson = safeJsonParse(componentProperties, []); + return { name, componentType, @@ -55,10 +45,7 @@ export function useConnectDestinationForm() { }; case INPUT_TYPES.MULTI_INPUT: - componentPropertiesJson = safeJsonParse( - componentProperties, - [] - ); + componentPropertiesJson = safeJsonParse(componentProperties, []); initialValuesJson = safeJsonParse(initialValue, []); return { @@ -69,6 +56,7 @@ export function useConnectDestinationForm() { value: initialValuesJson, ...componentPropertiesJson, }; + case INPUT_TYPES.KEY_VALUE_PAIR: return { name, @@ -76,6 +64,7 @@ export function useConnectDestinationForm() { title: displayName, ...componentPropertiesJson, }; + default: return undefined; } diff --git a/frontend/webapp/hooks/destinations/useDestinationFormData.ts b/frontend/webapp/hooks/destinations/useDestinationFormData.ts index 069ee25de..ce4c41904 100644 --- a/frontend/webapp/hooks/destinations/useDestinationFormData.ts +++ b/frontend/webapp/hooks/destinations/useDestinationFormData.ts @@ -1,137 +1,158 @@ -import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; -import { safeJsonParse } from '@/utils'; -import { useDrawerStore } from '@/store'; +import { useState, useEffect } from 'react'; +import { DrawerBaseItem } from '@/store'; import { useQuery } from '@apollo/client'; -import { useConnectDestinationForm } from '@/hooks'; import { GET_DESTINATION_TYPE_DETAILS } from '@/graphql'; -import { DynamicField, ActualDestination, isActualDestination, DestinationDetailsResponse, SupportedDestinationSignals, DestinationDetailsField } from '@/types'; - -const DEFAULT_SUPPORTED_SIGNALS: SupportedDestinationSignals = { - logs: { supported: false }, - metrics: { supported: false }, - traces: { supported: false }, -}; - -export function useDestinationFormData() { - const [dynamicFields, setDynamicFields] = useState([]); - const [supportedSignals, setSupportedSignals] = useState(DEFAULT_SUPPORTED_SIGNALS); - const [exportedSignals, setExportedSignals] = useState({ +import { useConnectDestinationForm, useNotify } from '@/hooks'; +import { ACTION, FORM_ALERTS, NOTIFICATION, safeJsonParse } from '@/utils'; +import { + type DynamicField, + type DestinationDetailsResponse, + type DestinationInput, + type DestinationTypeItem, + type ActualDestination, + type SupportedDestinationSignals, + OVERVIEW_ENTITY_TYPES, +} from '@/types'; + +const INITIAL: DestinationInput = { + type: '', + name: '', + exportedSignals: { logs: false, metrics: false, traces: false, - }); + }, + fields: [], +}; - const destination = useDrawerStore(({ selectedItem }) => selectedItem); - const shouldSkip = !isActualDestination(destination?.item); - const destinationType = isActualDestination(destination?.item) ? destination.item.destinationType.type : null; +export function useDestinationFormData(params?: { destinationType?: string; supportedSignals?: SupportedDestinationSignals; preLoadedFields?: string | DestinationTypeItem['fields'] }) { + const { destinationType, supportedSignals, preLoadedFields } = params || {}; + const notify = useNotify(); const { buildFormDynamicFields } = useConnectDestinationForm(); - const { data: destinationFields } = useQuery(GET_DESTINATION_TYPE_DETAILS, { - variables: { type: destinationType }, - skip: shouldSkip, - }); - - // Memoize the buildFormDynamicFields to ensure it's stable across renders - const memoizedBuildFormDynamicFields = useCallback(buildFormDynamicFields, []); + const [formData, setFormData] = useState({ ...INITIAL }); + const [dynamicFields, setDynamicFields] = useState([]); - const initialDynamicFieldsRef = useRef([]); - const initialExportedSignalsRef = useRef({ - logs: false, - metrics: false, - traces: false, + const t = destinationType || formData.type; + const { data: { destinationTypeDetails } = {} } = useQuery(GET_DESTINATION_TYPE_DETAILS, { + variables: { type: t }, + skip: !t, + onError: (error) => notify({ type: NOTIFICATION.ERROR, title: ACTION.FETCH, message: error.message, crdType: OVERVIEW_ENTITY_TYPES.DESTINATION }), }); - const initialSupportedSignalsRef = useRef(DEFAULT_SUPPORTED_SIGNALS); useEffect(() => { - if (destinationFields && isActualDestination(destination?.item)) { - const { fields, exportedSignals, destinationType } = destination.item; - const destinationTypeDetails = destinationFields.destinationTypeDetails; + if (destinationTypeDetails) { + setDynamicFields( + buildFormDynamicFields(destinationTypeDetails.fields).map((field) => { + // if we have preloaded fields, we need to set the value of the field + // (this can be from an odigos-detected-destination during create, or from an existing destination during edit/update) + if (!!preLoadedFields) { + const parsedFields = typeof preLoadedFields === 'string' ? safeJsonParse>(preLoadedFields, {}) : preLoadedFields; + + if (field.name in parsedFields) { + return { + ...field, + value: parsedFields[field.name], + }; + } + } - const parsedFields = safeJsonParse>(fields, {}); - const formFields = memoizedBuildFormDynamicFields(destinationTypeDetails?.fields || []); + return field; + }), + ); + } else { + setDynamicFields([]); + } + }, [destinationTypeDetails, preLoadedFields]); - const df = formFields.map((field) => { - let fieldValue: any = parsedFields[field.name] || ''; + useEffect(() => { + handleFormChange( + 'fields', + dynamicFields.map((field) => ({ + key: field.name, + value: field.value, + })), + ); + }, [dynamicFields]); - // Check if fieldValue is a JSON string that needs stringifying - try { - const parsedValue = JSON.parse(fieldValue); + useEffect(() => { + const { logs, metrics, traces } = supportedSignals || {}; + + handleFormChange('exportedSignals', { + logs: logs?.supported || false, + metrics: metrics?.supported || false, + traces: traces?.supported || false, + }); + }, [supportedSignals]); + + function handleFormChange(key: keyof typeof INITIAL | string, val: any) { + const [parentKey, childKey] = key.split('.'); + + if (!!childKey) { + setFormData((prev) => ({ + ...prev, + [parentKey]: { + ...prev[parentKey], + [childKey]: val, + }, + })); + } else { + setFormData((prev) => ({ + ...prev, + [parentKey]: val, + })); + } + } - if (Array.isArray(parsedValue)) { - // If it's an array, stringify it for setting the value - fieldValue = parsedValue; - } - } catch (e) { - // If parsing fails, it's not JSON, so we keep it as is - } - - return { - ...field, - value: fieldValue, - }; - }); + const resetFormData = () => { + setFormData({ ...INITIAL }); + }; - setDynamicFields(df); - setExportedSignals(exportedSignals); - setSupportedSignals(destinationType.supportedSignals); + const validateForm = (params?: { withAlert?: boolean }) => { + let ok = true; - initialDynamicFieldsRef.current = df; - initialExportedSignalsRef.current = exportedSignals; - initialSupportedSignalsRef.current = destinationType.supportedSignals; - } - }, [destinationFields, destination, memoizedBuildFormDynamicFields]); + ok = dynamicFields.every((field) => (field.required ? !!field.value : true)); - const cardData = useMemo(() => { - if (shouldSkip || !isActualDestination(destination?.item) || !destinationFields) { - return [{ title: 'Error', value: 'No destination selected or data missing' }]; + if (!ok && params?.withAlert) { + notify({ + type: NOTIFICATION.WARNING, + title: ACTION.UPDATE, + message: FORM_ALERTS.REQUIRED_FIELDS, + }); } - const { exportedSignals, destinationType, fields } = destination.item; - const parsedFields = safeJsonParse>(fields, {}); - const destinationDetails = destinationFields.destinationTypeDetails?.fields; - const fieldsData = buildDestinationFieldData(parsedFields, destinationDetails); + return ok; + }; - return [{ title: 'Destination', value: destinationType.displayName || 'N/A' }, { title: 'Monitors', value: buildMonitorsList(exportedSignals) }, ...fieldsData]; - }, [shouldSkip, destination, destinationFields]); + const loadFormWithDrawerItem = (drawerItem: DrawerBaseItem) => { + const { + destinationType: { type }, + name, + exportedSignals, + fields, + } = drawerItem.item as ActualDestination; + + const updatedData: DestinationInput = { + ...INITIAL, + type, + name, + exportedSignals, + fields: Object.entries(safeJsonParse(fields, {})).map(([key, value]: [string, string]) => ({ key, value })), + }; - // Reset function using initial values from refs - const resetFormData = useCallback(() => { - setDynamicFields(initialDynamicFieldsRef.current); - setExportedSignals(initialExportedSignalsRef.current); - setSupportedSignals(initialSupportedSignalsRef.current); - }, []); + setFormData(updatedData); + }; return { - cardData, + formData, + handleFormChange, + resetFormData, + validateForm, + loadFormWithDrawerItem, + + destinationTypeDetails, dynamicFields, - destinationType: destinationType || '', - exportedSignals, - supportedSignals, - setExportedSignals, setDynamicFields, - resetFormData, }; } - -function buildDestinationFieldData(parsedFields: Record, fieldDetails?: DestinationDetailsField[]) { - return Object.entries(parsedFields).map(([key, value]) => { - const found = fieldDetails?.find((field) => field.name === key); - - const { type } = safeJsonParse(found?.componentProperties, { type: '' }); - const secret = type === 'password' ? new Array(value.length).fill('•').join('') : ''; - - return { - title: found?.displayName || key, - value: secret || value || 'N/A', - }; - }); -} - -function buildMonitorsList(exportedSignals: ActualDestination['exportedSignals']): string { - return ( - Object.keys(exportedSignals) - .filter((key) => exportedSignals[key] && key !== '__typename') - .join(', ') || 'None' - ); -} diff --git a/frontend/webapp/hooks/destinations/useDestinationTypes.ts b/frontend/webapp/hooks/destinations/useDestinationTypes.ts index ed6ae8265..0b4ac1867 100644 --- a/frontend/webapp/hooks/destinations/useDestinationTypes.ts +++ b/frontend/webapp/hooks/destinations/useDestinationTypes.ts @@ -5,8 +5,7 @@ import { DestinationsCategory, GetDestinationTypesResponse } from '@/types'; const CATEGORIES_DESCRIPTION = { managed: 'Effortless Monitoring with Scalable Performance Management', - 'self hosted': - 'Full Control and Customization for Advanced Application Monitoring', + 'self hosted': 'Full Control and Customization for Advanced Application Monitoring', }; export interface IDestinationListItem extends DestinationsCategory { @@ -19,16 +18,13 @@ export function useDestinationTypes() { useEffect(() => { if (data) { - const destinationsCategories = data.destinationTypes.categories.map( - (category) => { - return { - name: category.name, - description: CATEGORIES_DESCRIPTION[category.name], - items: category.items, - }; - } + setDestinations( + data.destinationTypes.categories.map((category) => ({ + name: category.name, + description: CATEGORIES_DESCRIPTION[category.name], + items: category.items, + })), ); - setDestinations(destinationsCategories); } }, [data]); diff --git a/frontend/webapp/hooks/destinations/useEditDestinationFormHandlers.ts b/frontend/webapp/hooks/destinations/useEditDestinationFormHandlers.ts deleted file mode 100644 index 330b1390f..000000000 --- a/frontend/webapp/hooks/destinations/useEditDestinationFormHandlers.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Dispatch, SetStateAction } from 'react'; -import { DynamicField, ExportedSignals } from '@/types'; - -export function useEditDestinationFormHandlers( - setExportedSignals: Dispatch>, - setDynamicFields: Dispatch> -) { - const handleSignalChange = ( - signal: keyof ExportedSignals, - value: boolean - ) => { - setExportedSignals((prev) => ({ ...prev, [signal]: value })); - }; - - const handleDynamicFieldChange = (name: string, value: any) => { - setDynamicFields((prev) => - prev.map((field) => (field.name === name ? { ...field, value } : field)) - ); - }; - - return { handleSignalChange, handleDynamicFieldChange }; -} diff --git a/frontend/webapp/hooks/index.tsx b/frontend/webapp/hooks/index.tsx index c06f1e88c..6198e2ae9 100644 --- a/frontend/webapp/hooks/index.tsx +++ b/frontend/webapp/hooks/index.tsx @@ -1,4 +1,3 @@ -export * from './setup'; export * from './common'; export * from './config'; export * from './sources'; diff --git a/frontend/webapp/hooks/setup/index.ts b/frontend/webapp/hooks/setup/index.ts deleted file mode 100644 index 34949e277..000000000 --- a/frontend/webapp/hooks/setup/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './useConnectEnv'; diff --git a/frontend/webapp/hooks/setup/useConnectEnv.ts b/frontend/webapp/hooks/setup/useConnectEnv.ts deleted file mode 100644 index 6a00d4ae9..000000000 --- a/frontend/webapp/hooks/setup/useConnectEnv.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { useAppStore } from '@/store'; -import { DestinationInput } from '@/types'; -import { useSourceCRUD } from '../sources'; -import { useState, useCallback } from 'react'; -import { useDestinationCRUD } from '../destinations'; - -type ConnectEnvResult = { - success: boolean; - destinationId?: string; -}; - -export const useConnectEnv = () => { - const { createSources } = useSourceCRUD(); - const { createDestination } = useDestinationCRUD(); - const { configuredSources, configuredFutureApps, resetSources } = useAppStore((state) => state); - - const [result, setResult] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const connectEnv = useCallback( - async (destination: DestinationInput, callback?: () => void) => { - setLoading(true); - setError(null); - setResult(null); - - try { - await createSources(configuredSources, configuredFutureApps); - resetSources(); - - const { data } = await createDestination(destination); - const destinationId = data?.createNewDestination.id; - - callback && callback(); - setResult({ success: true, destinationId }); - } catch (err) { - setError((err as Error).message); - setResult({ success: false }); - } finally { - setLoading(false); - } - }, - [configuredSources, configuredFutureApps, createSources, resetSources, createDestination], - ); - - return { - connectEnv, - result, - loading, - error, - }; -}; diff --git a/frontend/webapp/hooks/sources/useSourceFormData.ts b/frontend/webapp/hooks/sources/useSourceFormData.ts index 9987d25e4..c2d4e5265 100644 --- a/frontend/webapp/hooks/sources/useSourceFormData.ts +++ b/frontend/webapp/hooks/sources/useSourceFormData.ts @@ -1,4 +1,4 @@ -import { Dispatch, SetStateAction, useCallback, useEffect, useState } from 'react'; +import { Dispatch, SetStateAction, useCallback, useEffect, useRef, useState } from 'react'; import { useAppStore } from '@/store'; import type { K8sActualSource } from '@/types'; import { useNamespace } from '../compute-platform'; @@ -31,7 +31,7 @@ export interface UseSourceFormDataResponse { selectAllForNamespace: string; showSelectedOnly: boolean; setSearchText: Dispatch>; - onSelectAll: (bool: boolean, namespace?: string) => void; + onSelectAll: (bool: boolean, namespace?: string, isFromInterval?: boolean) => void; setShowSelectedOnly: Dispatch>; filterSources: (namespace?: string, options?: { cancelSearch?: boolean; cancelSelected?: boolean }) => K8sActualSource[]; @@ -108,9 +108,11 @@ export const useSourceFormData = (params?: UseSourceFormDataParams): UseSourceFo }); }; + const namespaceWasSelected = useRef(false); const onSelectAll: UseSourceFormDataResponse['onSelectAll'] = useCallback( - (bool, namespace) => { + (bool, namespace, isFromInterval) => { if (!!namespace) { + if (!isFromInterval) namespaceWasSelected.current = selectedNamespace === namespace; const nsAvailableSources = availableSources[namespace]; const nsSelectedSources = selectedSources[namespace]; @@ -120,7 +122,8 @@ export const useSourceFormData = (params?: UseSourceFormDataParams): UseSourceFo } else { setSelectedSources((prev) => ({ ...prev, [namespace]: bool ? nsAvailableSources : [] })); setSelectAllForNamespace(''); - if (!!nsAvailableSources.length) setSelectedNamespace(''); + if (!!nsAvailableSources.length && !namespaceWasSelected.current) setSelectedNamespace(''); + namespaceWasSelected.current = false; } } else { setSelectAll(bool); @@ -139,7 +142,7 @@ export const useSourceFormData = (params?: UseSourceFormDataParams): UseSourceFo // if selectedSources returns an emtpy array, it will stop to prevent inifnite loop where no availableSources ever exist for that namespace useEffect(() => { if (!!selectAllForNamespace) { - const interval = setInterval(() => onSelectAll(true, selectAllForNamespace), 100); + const interval = setInterval(() => onSelectAll(true, selectAllForNamespace, true), 100); return () => clearInterval(interval); } }, [selectAllForNamespace, onSelectAll]); diff --git a/frontend/webapp/reuseable-components/divider/index.tsx b/frontend/webapp/reuseable-components/divider/index.tsx index b941378b6..c2b81abf0 100644 --- a/frontend/webapp/reuseable-components/divider/index.tsx +++ b/frontend/webapp/reuseable-components/divider/index.tsx @@ -4,7 +4,7 @@ import styled from 'styled-components'; interface Props { orientation?: 'horizontal' | 'vertical'; thickness?: number; - length?: number | string; + length?: string; color?: string; margin?: string; } diff --git a/frontend/webapp/reuseable-components/monitoring-checkboxes/index.tsx b/frontend/webapp/reuseable-components/monitoring-checkboxes/index.tsx index abcaa9916..a8172c8d7 100644 --- a/frontend/webapp/reuseable-components/monitoring-checkboxes/index.tsx +++ b/frontend/webapp/reuseable-components/monitoring-checkboxes/index.tsx @@ -6,6 +6,7 @@ import { MONITORING_OPTIONS, SignalLowercase, SignalUppercase } from '@/utils'; interface Props { isVertical?: boolean; + title?: string; allowedSignals?: SignalUppercase[]; selectedSignals: SignalUppercase[]; setSelectedSignals: (value: SignalUppercase[]) => void; @@ -14,7 +15,7 @@ interface Props { const ListContainer = styled.div<{ $isVertical?: Props['isVertical'] }>` display: flex; flex-direction: ${({ $isVertical }) => ($isVertical ? 'column' : 'row')}; - gap: ${({ $isVertical }) => ($isVertical ? '16px' : '32px')}; + gap: ${({ $isVertical }) => ($isVertical ? '12px' : '24px')}; `; const monitors = MONITORING_OPTIONS; @@ -27,7 +28,7 @@ const isSelected = (type: SignalLowercase, selectedSignals: Props['selectedSigna return !!selectedSignals?.find((str) => str === type.toUpperCase()); }; -const MonitoringCheckboxes: React.FC = ({ isVertical, allowedSignals, selectedSignals, setSelectedSignals }) => { +const MonitoringCheckboxes: React.FC = ({ isVertical, title = 'Monitoring', allowedSignals, selectedSignals, setSelectedSignals }) => { const [isLastSelection, setIsLastSelection] = useState(selectedSignals.length === 1); const recordedRows = useRef(JSON.stringify(selectedSignals)); @@ -64,7 +65,7 @@ const MonitoringCheckboxes: React.FC = ({ isVertical, allowedSignals, sel return (
- + {title && } {monitors.map((monitor) => { diff --git a/frontend/webapp/reuseable-components/textarea/index.tsx b/frontend/webapp/reuseable-components/textarea/index.tsx index 435cb13dd..a69131c97 100644 --- a/frontend/webapp/reuseable-components/textarea/index.tsx +++ b/frontend/webapp/reuseable-components/textarea/index.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useRef } from 'react'; import { Text } from '../text'; import { FieldLabel } from '../field-label'; import styled, { css } from 'styled-components'; @@ -93,13 +93,27 @@ const ErrorMessage = styled(Text)` margin-top: 4px; `; -const TextArea: React.FC = ({ errorMessage, title, tooltip, required, ...props }) => { +const TextArea: React.FC = ({ errorMessage, title, tooltip, required, onChange, ...props }) => { + const ref = useRef(null); + return ( - + { + if (ref.current) { + // The following auto-resizes the textarea to the number of rows typed + ref.current.style.height = 'auto'; + ref.current.style.height = `${ref.current.scrollHeight}px`; + } + + onChange?.(e); + }} + {...props} + /> {errorMessage && ( diff --git a/frontend/webapp/store/useAppStore.ts b/frontend/webapp/store/useAppStore.ts index 680a4eb2c..e3013f0bf 100644 --- a/frontend/webapp/store/useAppStore.ts +++ b/frontend/webapp/store/useAppStore.ts @@ -1,20 +1,22 @@ import { create } from 'zustand'; -import type { ConfiguredDestination, K8sActualSource } from '@/types'; +import type { ConfiguredDestination, DestinationInput, K8sActualSource } from '@/types'; export interface IAppState { availableSources: { [key: string]: K8sActualSource[] }; configuredSources: { [key: string]: K8sActualSource[] }; configuredFutureApps: { [key: string]: boolean }; - configuredDestinations: ConfiguredDestination[]; + configuredDestinations: { stored: ConfiguredDestination; form: DestinationInput }[]; } interface IAppStateSetters { setAvailableSources: (payload: IAppState['availableSources']) => void; setConfiguredSources: (payload: IAppState['configuredSources']) => void; setConfiguredFutureApps: (payload: IAppState['configuredFutureApps']) => void; + setConfiguredDestinations: (payload: IAppState['configuredDestinations']) => void; - addConfiguredDestination: (payload: ConfiguredDestination) => void; - resetSources: () => void; + addConfiguredDestination: (payload: { stored: ConfiguredDestination; form: DestinationInput }) => void; + removeConfiguredDestination: (payload: { type: string }) => void; + resetState: () => void; } @@ -27,10 +29,11 @@ const useAppStore = create((set) => ({ setAvailableSources: (payload) => set({ availableSources: payload }), setConfiguredSources: (payload) => set({ configuredSources: payload }), setConfiguredFutureApps: (payload) => set({ configuredFutureApps: payload }), + setConfiguredDestinations: (payload) => set({ configuredDestinations: payload }), addConfiguredDestination: (payload) => set((state) => ({ configuredDestinations: [...state.configuredDestinations, payload] })), + removeConfiguredDestination: (payload) => set((state) => ({ configuredDestinations: state.configuredDestinations.filter(({ stored }) => stored.type !== payload.type) })), - resetSources: () => set(() => ({ availableSources: {}, configuredSources: {}, configuredFutureApps: {} })), resetState: () => set(() => ({ availableSources: {}, configuredSources: {}, configuredFutureApps: {}, configuredDestinations: [] })), })); diff --git a/frontend/webapp/styles/styled.tsx b/frontend/webapp/styles/styled.tsx index bfc3ecf3e..891638cda 100644 --- a/frontend/webapp/styles/styled.tsx +++ b/frontend/webapp/styles/styled.tsx @@ -24,7 +24,6 @@ export const Overlay = styled.div` export const ModalBody = styled.div` width: 640px; height: calc(100vh - 300px); - margin: 0 7vw; - padding-top: 64px; + margin: 64px 7vw 0 7vw; overflow-y: scroll; `; diff --git a/frontend/webapp/types/destinations.ts b/frontend/webapp/types/destinations.ts index 55850608b..4a83057fe 100644 --- a/frontend/webapp/types/destinations.ts +++ b/frontend/webapp/types/destinations.ts @@ -131,7 +131,6 @@ export interface DestinationConfig { export interface ActualDestination { id: string; name: string; - type: string; exportedSignals: { traces: boolean; metrics: boolean; diff --git a/frontend/webapp/utils/constants/string.tsx b/frontend/webapp/utils/constants/string.tsx index c65eb8124..457bbfa68 100644 --- a/frontend/webapp/utils/constants/string.tsx +++ b/frontend/webapp/utils/constants/string.tsx @@ -29,6 +29,7 @@ export const ACTION = { CREATE: 'Create', UPDATE: 'Update', DELETE: 'Delete', + FETCH: 'Fetch', }; export const FORM_ALERTS = {