diff --git a/frontend/graph/generated.go b/frontend/graph/generated.go index bd2077fecd..78302506be 100644 --- a/frontend/graph/generated.go +++ b/frontend/graph/generated.go @@ -41,7 +41,6 @@ type Config struct { type ResolverRoot interface { ComputePlatform() ComputePlatformResolver Destination() DestinationResolver - DestinationTypesCategoryItem() DestinationTypesCategoryItemResolver K8sActualNamespace() K8sActualNamespaceResolver Mutation() MutationResolver Query() QueryResolver @@ -180,9 +179,6 @@ type DestinationResolver interface { Conditions(ctx context.Context, obj *model.Destination) ([]*model.Condition, error) } -type DestinationTypesCategoryItemResolver interface { - Type(ctx context.Context, obj *model.DestinationTypesCategoryItem) (string, error) -} type K8sActualNamespaceResolver interface { K8sActualSources(ctx context.Context, obj *model.K8sActualNamespace, instrumentationLabeled *bool) ([]*model.K8sActualSource, error) } @@ -1848,7 +1844,7 @@ func (ec *executionContext) _DestinationTypesCategoryItem_type(ctx context.Conte }() resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { ctx = rctx // use context from middleware stack in children - return ec.resolvers.DestinationTypesCategoryItem().Type(rctx, obj) + return obj.Type, nil }) if err != nil { ec.Error(ctx, err) @@ -1869,8 +1865,8 @@ func (ec *executionContext) fieldContext_DestinationTypesCategoryItem_type(_ con fc = &graphql.FieldContext{ Object: "DestinationTypesCategoryItem", Field: field, - IsMethod: true, - IsResolver: true, + IsMethod: false, + IsResolver: false, Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { return nil, errors.New("field of type String does not have child fields") }, @@ -6268,60 +6264,29 @@ func (ec *executionContext) _DestinationTypesCategoryItem(ctx context.Context, s case "__typename": out.Values[i] = graphql.MarshalString("DestinationTypesCategoryItem") case "type": - field := field - - innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { - defer func() { - if r := recover(); r != nil { - ec.Error(ctx, ec.Recover(ctx, r)) - } - }() - res = ec._DestinationTypesCategoryItem_type(ctx, field, obj) - if res == graphql.Null { - atomic.AddUint32(&fs.Invalids, 1) - } - return res - } - - if field.Deferrable != nil { - dfs, ok := deferred[field.Deferrable.Label] - di := 0 - if ok { - dfs.AddField(field) - di = len(dfs.Values) - 1 - } else { - dfs = graphql.NewFieldSet([]graphql.CollectedField{field}) - deferred[field.Deferrable.Label] = dfs - } - dfs.Concurrently(di, func(ctx context.Context) graphql.Marshaler { - return innerFunc(ctx, dfs) - }) - - // don't run the out.Concurrently() call below - out.Values[i] = graphql.Null - continue + out.Values[i] = ec._DestinationTypesCategoryItem_type(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ } - - out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) case "displayName": out.Values[i] = ec._DestinationTypesCategoryItem_displayName(ctx, field, obj) if out.Values[i] == graphql.Null { - atomic.AddUint32(&out.Invalids, 1) + out.Invalids++ } case "imageUrl": out.Values[i] = ec._DestinationTypesCategoryItem_imageUrl(ctx, field, obj) if out.Values[i] == graphql.Null { - atomic.AddUint32(&out.Invalids, 1) + out.Invalids++ } case "supportedSignals": out.Values[i] = ec._DestinationTypesCategoryItem_supportedSignals(ctx, field, obj) if out.Values[i] == graphql.Null { - atomic.AddUint32(&out.Invalids, 1) + out.Invalids++ } case "testConnectionSupported": out.Values[i] = ec._DestinationTypesCategoryItem_testConnectionSupported(ctx, field, obj) if out.Values[i] == graphql.Null { - atomic.AddUint32(&out.Invalids, 1) + out.Invalids++ } default: panic("unknown field " + strconv.Quote(field.Name)) diff --git a/frontend/graph/model/destination.go b/frontend/graph/model/destination.go index 8f9af77a98..58a8bea118 100644 --- a/frontend/graph/model/destination.go +++ b/frontend/graph/model/destination.go @@ -10,11 +10,11 @@ type GetDestinationTypesResponse struct { } type DestinationTypesCategoryItem struct { - Type common.DestinationType `json:"type"` - DisplayName string `json:"display_name"` - ImageUrl string `json:"image_url"` - SupportedSignals SupportedSignals `json:"supported_signals"` - TestConnectionSupported bool `json:"test_connection_supported"` + Type string `json:"type"` + DisplayName string `json:"display_name"` + ImageUrl string `json:"image_url"` + SupportedSignals SupportedSignals `json:"supported_signals"` + TestConnectionSupported bool `json:"test_connection_supported"` } type SupportedSignals struct { diff --git a/frontend/graph/schema.resolvers.go b/frontend/graph/schema.resolvers.go index e8e75addb6..de65f4e5f5 100644 --- a/frontend/graph/schema.resolvers.go +++ b/frontend/graph/schema.resolvers.go @@ -74,11 +74,6 @@ func (r *destinationResolver) Conditions(ctx context.Context, obj *model.Destina panic(fmt.Errorf("not implemented: Conditions - conditions")) } -// Type is the resolver for the type field. -func (r *destinationTypesCategoryItemResolver) Type(ctx context.Context, obj *model.DestinationTypesCategoryItem) (string, error) { - panic(fmt.Errorf("not implemented: Type - type")) -} - // K8sActualSources is the resolver for the k8sActualSources field. func (r *k8sActualNamespaceResolver) K8sActualSources(ctx context.Context, obj *model.K8sActualNamespace, instrumentationLabeled *bool) ([]*model.K8sActualSource, error) { return obj.K8sActualSources, nil @@ -158,11 +153,6 @@ func (r *Resolver) ComputePlatform() ComputePlatformResolver { return &computePl // Destination returns DestinationResolver implementation. func (r *Resolver) Destination() DestinationResolver { return &destinationResolver{r} } -// DestinationTypesCategoryItem returns DestinationTypesCategoryItemResolver implementation. -func (r *Resolver) DestinationTypesCategoryItem() DestinationTypesCategoryItemResolver { - return &destinationTypesCategoryItemResolver{r} -} - // K8sActualNamespace returns K8sActualNamespaceResolver implementation. func (r *Resolver) K8sActualNamespace() K8sActualNamespaceResolver { return &k8sActualNamespaceResolver{r} @@ -176,7 +166,6 @@ func (r *Resolver) Query() QueryResolver { return &queryResolver{r} } type computePlatformResolver struct{ *Resolver } type destinationResolver struct{ *Resolver } -type destinationTypesCategoryItemResolver struct{ *Resolver } type k8sActualNamespaceResolver struct{ *Resolver } type mutationResolver struct{ *Resolver } type queryResolver struct{ *Resolver } diff --git a/frontend/services/destinations.go b/frontend/services/destinations.go index 39e7835e8c..4b0564e423 100644 --- a/frontend/services/destinations.go +++ b/frontend/services/destinations.go @@ -31,7 +31,7 @@ func GetDestinationTypes() model.GetDestinationTypesResponse { func DestinationTypeConfigToCategoryItem(destConfig destinations.Destination) model.DestinationTypesCategoryItem { return model.DestinationTypesCategoryItem{ - Type: common.DestinationType(destConfig.Metadata.Type), + Type: string(destConfig.Metadata.Type), DisplayName: destConfig.Metadata.DisplayName, ImageUrl: GetImageURL(destConfig.Spec.Image), TestConnectionSupported: destConfig.Spec.TestConnectionSupported, diff --git a/frontend/webapp/app/setup/choose-destination/page.tsx b/frontend/webapp/app/setup/choose-destination/page.tsx index 9d2189d08d..92ffac25a2 100644 --- a/frontend/webapp/app/setup/choose-destination/page.tsx +++ b/frontend/webapp/app/setup/choose-destination/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { ChooseDestinationContainer } from '@/containers/main'; import React from 'react'; +import { ChooseDestinationContainer } from '@/containers/main'; export default function ChooseDestinationPage() { return ( 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 index eab8f59d3a..7378c2020e 100644 --- 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 @@ -11,6 +11,10 @@ interface AddDestinationModalProps { handleCloseModal: () => void; } +interface DestinationCategory { + name: string; + items: DestinationTypeItem[]; +} function ModalActionComponent({ onNext, onBack, @@ -60,12 +64,14 @@ export function AddDestinationModal({ function buildDestinationTypeList() { const destinationTypes = data?.destinationTypes?.categories || []; const destinationTypeList: DestinationTypeItem[] = destinationTypes.reduce( - (acc: DestinationTypeItem[], category: any) => { - const items = category.items.map((item: any) => ({ + (acc: DestinationTypeItem[], category: DestinationCategory) => { + const items = category.items.map((item: DestinationTypeItem) => ({ category: category.name, displayName: item.displayName, imageUrl: item.imageUrl, supportedSignals: item.supportedSignals, + testConnectionSupported: item.testConnectionSupported, + type: item.type, })); return [...acc, ...items]; }, @@ -73,6 +79,7 @@ export function AddDestinationModal({ ); setDestinationTypeList(destinationTypeList); } + function handleNextStep(item: DestinationTypeItem) { setSelectedItem(item); } 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 index d86051d7db..dae60964a9 100644 --- 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 @@ -1,8 +1,24 @@ -import React from 'react'; -import { DestinationTypeItem, StepProps } from '@/types'; +import React, { useEffect, useMemo, useState } from 'react'; +import { + DestinationDetailsResponse, + DestinationTypeItem, + DynamicField, + StepProps, +} from '@/types'; import { SideMenu } from '@/components'; -import { Divider, SectionTitle } from '@/reuseable-components'; +import { + Button, + CheckboxList, + Divider, + Input, + SectionTitle, +} from '@/reuseable-components'; import { Body, Container, SideMenuWrapper } from '../styled'; +import { GET_DESTINATION_TYPE_DETAILS } from '@/graphql'; +import { useQuery } from '@apollo/client'; +import styled from 'styled-components'; +import { DynamicConnectDestinationFormFields } from '../dynamic-form-fields'; +import { useConnectDestinationForm } from '@/hooks'; const SIDE_MENU_DATA: StepProps[] = [ { title: 'DESTINATIONS', @@ -16,6 +32,13 @@ const SIDE_MENU_DATA: StepProps[] = [ }, ]; +const FormContainer = styled.div` + display: flex; + width: 100%; + flex-direction: column; + gap: 24px; +`; + interface ConnectDestinationModalBodyProps { destination: DestinationTypeItem | undefined; } @@ -23,7 +46,63 @@ interface ConnectDestinationModalBodyProps { export function ConnectDestinationModalBody({ destination, }: ConnectDestinationModalBodyProps) { - console.log({ destination }); + const { data } = useQuery( + GET_DESTINATION_TYPE_DETAILS, + { + variables: { type: destination?.type }, + skip: !destination, + } + ); + const [checkedState, setCheckedState] = useState([]); + const [destinationName, setDestinationName] = useState(''); + const [dynamicFields, setDynamicFields] = useState([]); + const [formData, setFormData] = useState>({}); + const { buildFormDynamicFields } = useConnectDestinationForm(); + + 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]); + + useEffect(() => { + data && console.log({ destination, data }); + + if (data) { + const df = buildFormDynamicFields(data.destinationTypeDetails.fields); + console.log( + 'is missing fileds', + df.length !== data.destinationTypeDetails.fields.length + ); + console.log({ df }); + setDynamicFields(df); + } + }, [data]); + + function handleDynamicFieldChange(name: string, value: any) { + setFormData((prev) => ({ ...prev, [name]: value })); + } + + function handleSubmit() { + console.log({ formData }); + } + + if (!destination) return null; + return ( @@ -37,7 +116,28 @@ export function ConnectDestinationModalBody({ buttonText="Check connection" onButtonClick={() => {}} /> - + + + + setDestinationName(e.target.value)} + /> + + + ); diff --git a/frontend/webapp/containers/main/destinations/add-destination/dynamic-form-fields/index.tsx b/frontend/webapp/containers/main/destinations/add-destination/dynamic-form-fields/index.tsx new file mode 100644 index 0000000000..6b955f2deb --- /dev/null +++ b/frontend/webapp/containers/main/destinations/add-destination/dynamic-form-fields/index.tsx @@ -0,0 +1,43 @@ +import React from 'react'; + +import { INPUT_TYPES } from '@/utils/constants/string'; +import { Dropdown, Input } from '@/reuseable-components'; + +export function DynamicConnectDestinationFormFields({ + fields, + onChange, +}: { + fields: any[]; + onChange: (name: string, value: any) => void; +}) { + return fields?.map((field: any) => { + switch (field.componentType) { + case INPUT_TYPES.INPUT: + return ( + onChange(field.name, value)} + /> + ); + + case INPUT_TYPES.DROPDOWN: + return ( + onChange(field.name, option.value)} + /> + ); + case INPUT_TYPES.MULTI_INPUT: + return
; + + case INPUT_TYPES.KEY_VALUE_PAIR: + return
; + case INPUT_TYPES.TEXTAREA: + return
; + default: + return null; + } + }); +} diff --git a/frontend/webapp/containers/main/destinations/add-destination/styled.ts b/frontend/webapp/containers/main/destinations/add-destination/styled.ts index 9ad79d7d58..6588c89360 100644 --- a/frontend/webapp/containers/main/destinations/add-destination/styled.ts +++ b/frontend/webapp/containers/main/destinations/add-destination/styled.ts @@ -3,6 +3,8 @@ import styled from 'styled-components'; export const Body = styled.div` padding: 32px 32px 0; border-left: 1px solid rgba(249, 249, 249, 0.08); + min-height: 600px; + width: 692px; `; export const SideMenuWrapper = styled.div` diff --git a/frontend/webapp/graphql/queries/destination.ts b/frontend/webapp/graphql/queries/destination.ts index 34124683f2..993f302c4e 100644 --- a/frontend/webapp/graphql/queries/destination.ts +++ b/frontend/webapp/graphql/queries/destination.ts @@ -6,6 +6,8 @@ export const GET_DESTINATION_TYPE = gql` categories { name items { + type + testConnectionSupported displayName imageUrl supportedSignals { @@ -24,3 +26,17 @@ export const GET_DESTINATION_TYPE = gql` } } `; + +export const GET_DESTINATION_TYPE_DETAILS = gql` + query GetDestinationTypeDetails($type: String!) { + destinationTypeDetails(type: $type) { + fields { + name + displayName + componentType + componentProperties + initialValue + } + } + } +`; diff --git a/frontend/webapp/hooks/destinations/index.ts b/frontend/webapp/hooks/destinations/index.ts index fda9693350..fdf8dc796e 100644 --- a/frontend/webapp/hooks/destinations/index.ts +++ b/frontend/webapp/hooks/destinations/index.ts @@ -1,2 +1,3 @@ export * from './useDestinations'; export * from './useCheckConnection'; +export * from './useConnectDestinationForm'; diff --git a/frontend/webapp/hooks/destinations/useConnectDestinationForm.ts b/frontend/webapp/hooks/destinations/useConnectDestinationForm.ts new file mode 100644 index 0000000000..aa1c71fca3 --- /dev/null +++ b/frontend/webapp/hooks/destinations/useConnectDestinationForm.ts @@ -0,0 +1,65 @@ +import { safeJsonParse } from '@/utils'; +import { DestinationDetailsField, DynamicField } from '@/types'; + +export function useConnectDestinationForm() { + function buildFormDynamicFields( + fields: DestinationDetailsField[] + ): DynamicField[] { + return fields + .map((field) => { + const { name, componentType, displayName, componentProperties } = field; + + let componentPropertiesJson; + switch (componentType) { + case 'dropdown': + componentPropertiesJson = safeJsonParse<{ [key: string]: string }>( + componentProperties, + {} + ); + + const options = Object.entries(componentPropertiesJson.values).map( + ([key, value]) => ({ + id: key, + value, + }) + ); + + return { + name, + componentType, + title: displayName, + onSelect: () => {}, + options, + selectedOption: options[0], + ...componentPropertiesJson, + }; + + case 'input': + componentPropertiesJson = safeJsonParse( + componentProperties, + [] + ); + return { + name, + componentType, + title: displayName, + ...componentPropertiesJson, + }; + + // case 'multi_input': + // case 'textarea': + // return { + // name, + // componentType, + // title: displayName, + // ...componentPropertiesJson, + // }; + default: + return undefined; + } + }) + .filter((field): field is DynamicField => field !== undefined); + } + + return { buildFormDynamicFields }; +} diff --git a/frontend/webapp/reuseable-components/checkbox-list/index.tsx b/frontend/webapp/reuseable-components/checkbox-list/index.tsx new file mode 100644 index 0000000000..3888efa9bf --- /dev/null +++ b/frontend/webapp/reuseable-components/checkbox-list/index.tsx @@ -0,0 +1,78 @@ +import React, { useState, useEffect } from 'react'; +import styled from 'styled-components'; +import { Checkbox } from '../checkbox'; +import { Text } from '../text'; + +interface Monitor { + id: string; + title: string; + tooltip?: string; +} + +interface CheckboxListProps { + monitors: Monitor[]; + title?: string; + checkedState?: boolean[]; + setCheckedState: (checkedState: boolean[]) => void; +} + +const ListContainer = styled.div` + display: flex; + gap: 32px; +`; + +const TextWrapper = styled.div` + margin-bottom: 14px; +`; + +const CheckboxList: React.FC = ({ + monitors, + title, + checkedState = [], + setCheckedState, +}) => { + useEffect(() => { + // Initialize the checked state with all true if no initial values provided + setCheckedState(Array(monitors.length).fill(true)); + }, [monitors.length]); + + const handleCheckboxChange = (index: number, value: boolean) => { + const newCheckedState = [...checkedState]; + newCheckedState[index] = value; + + // Ensure at least one checkbox remains checked + if (newCheckedState.filter((checked) => checked).length === 0) { + return; + } + + setCheckedState(newCheckedState); + }; + + return ( +
+ {title && ( + + + {title} + + + )} + + {monitors.map((monitor, index) => ( + handleCheckboxChange(index, value)} + disabled={ + checkedState.filter((checked) => checked).length === 1 && + checkedState[index] + } + /> + ))} + +
+ ); +}; + +export { CheckboxList }; diff --git a/frontend/webapp/reuseable-components/checkbox/index.tsx b/frontend/webapp/reuseable-components/checkbox/index.tsx index 956fbb6ec9..92a3c1470a 100644 --- a/frontend/webapp/reuseable-components/checkbox/index.tsx +++ b/frontend/webapp/reuseable-components/checkbox/index.tsx @@ -24,20 +24,18 @@ const CheckboxWrapper = styled.div<{ isChecked: boolean; disabled?: boolean }>` width: 18px; height: 18px; border-radius: 6px; - border: 1px dashed rgba(249, 249, 249, 0.4); + border: ${({ isChecked }) => + isChecked + ? '1px dashed transparent' + : '1px dashed rgba(249, 249, 249, 0.4)'}; display: flex; align-items: center; justify-content: center; background-color: ${({ isChecked, theme }) => - isChecked ? theme.colors.primary : 'transparent'}; + isChecked ? theme.colors.majestic_blue : 'transparent'}; pointer-events: ${({ disabled }) => (disabled ? 'none' : 'auto')}; `; -const Title = styled.span` - font-size: 16px; - color: #fff; -`; - const Checkbox: React.FC = ({ title, tooltip, diff --git a/frontend/webapp/reuseable-components/index.ts b/frontend/webapp/reuseable-components/index.ts index 0391f7f113..1839e88d64 100644 --- a/frontend/webapp/reuseable-components/index.ts +++ b/frontend/webapp/reuseable-components/index.ts @@ -11,3 +11,4 @@ export * from './checkbox'; export * from './modal'; export * from './navigation-buttons'; export * from './tag'; +export * from './checkbox-list'; diff --git a/frontend/webapp/reuseable-components/input/index.tsx b/frontend/webapp/reuseable-components/input/index.tsx index 2f5e3607cf..7a908ea966 100644 --- a/frontend/webapp/reuseable-components/input/index.tsx +++ b/frontend/webapp/reuseable-components/input/index.tsx @@ -62,14 +62,16 @@ const InputWrapper = styled.div<{ } `; -const StyledInput = styled.input` +const StyledInput = styled.input<{ hasIcon?: string }>` flex: 1; border: none; outline: none; background: none; color: ${({ theme }) => theme.colors.text}; font-size: 14px; - + padding-left: ${({ hasIcon }) => (hasIcon ? '0' : '16px')}; + font-family: ${({ theme }) => theme.font_family.primary}; + font-weight: 300; &::placeholder { color: ${({ theme }) => theme.colors.text}; font-family: ${({ theme }) => theme.font_family.primary}; @@ -124,8 +126,9 @@ const ErrorMessage = styled(Text)` `; const Title = styled(Text)` - font-size: 16px; - font-weight: bold; + font-size: 14px; + opacity: 0.8; + line-height: 22px; margin-bottom: 4px; `; @@ -173,7 +176,7 @@ const Input: React.FC = ({ )} - + {buttonLabel && onButtonClick && (