diff --git a/web/src/components/CurrentSpanSelector/CurrentSpanSelector.styled.ts b/web/src/components/CurrentSpanSelector/CurrentSpanSelector.styled.ts new file mode 100644 index 0000000000..5d2b72ac86 --- /dev/null +++ b/web/src/components/CurrentSpanSelector/CurrentSpanSelector.styled.ts @@ -0,0 +1,16 @@ +import styled from 'styled-components'; + +export const Container = styled.div` + display: flex; + justify-content: center; + margin-top: 2px; +`; + +export const FloatingText = styled.div` + background-color: ${({theme}) => theme.color.interactive}; + color: ${({theme}) => theme.color.white}; + border-radius: 12px; + padding: 2px 6px; + font-size: ${({theme}) => theme.size.xs}; + cursor: pointer; +`; diff --git a/web/src/components/CurrentSpanSelector/CurrentSpanSelector.tsx b/web/src/components/CurrentSpanSelector/CurrentSpanSelector.tsx new file mode 100644 index 0000000000..468a0d417f --- /dev/null +++ b/web/src/components/CurrentSpanSelector/CurrentSpanSelector.tsx @@ -0,0 +1,59 @@ +import {useCallback} from 'react'; +import {LoadingOutlined} from '@ant-design/icons'; +import SpanService from 'services/Span.service'; +import {useTest} from 'providers/Test/Test.provider'; +import {useTestRun} from 'providers/TestRun/TestRun.provider'; +import {useAppSelector} from 'redux/hooks'; +import SpanSelectors from 'selectors/Span.selectors'; +import {useSpan} from 'providers/Span/Span.provider'; +import TestOutput from 'models/TestOutput.model'; +import {useTestOutput} from 'providers/TestOutput/TestOutput.provider'; +import {useTestSpecForm} from '../TestSpecForm/TestSpecForm.provider'; +import * as S from './CurrentSpanSelector.styled'; + +interface IProps { + spanId: string; +} + +const CurrentSpanSelector = ({spanId}: IProps) => { + const {open, isOpen: isTestSpecFormOpen} = useTestSpecForm(); + const {onOpen} = useTestOutput(); + const { + run: {id: runId}, + } = useTestRun(); + const { + test: {id: testId}, + } = useTest(); + const {isTriggerSelectorLoading} = useSpan(); + const span = useAppSelector(state => SpanSelectors.selectSpanById(state, spanId, testId, runId)); + + const handleOnClick = useCallback(() => { + const selector = SpanService.getSelectorInformation(span!); + + if (isTestSpecFormOpen) + open({ + isEditing: false, + selector, + defaultValues: { + selector, + }, + }); + else onOpen(TestOutput({selector: {query: selector}})); + }, [isTestSpecFormOpen, onOpen, open, span]); + + return ( + + !isTriggerSelectorLoading && handleOnClick()}> + {isTriggerSelectorLoading ? ( + <> + Updating selected span + + ) : ( + <>Select as current span + )} + + + ); +}; + +export default CurrentSpanSelector; diff --git a/web/src/components/CurrentSpanSelector/index.ts b/web/src/components/CurrentSpanSelector/index.ts new file mode 100644 index 0000000000..eb19aff230 --- /dev/null +++ b/web/src/components/CurrentSpanSelector/index.ts @@ -0,0 +1,2 @@ +// eslint-disable-next-line no-restricted-exports +export {default} from './CurrentSpanSelector'; diff --git a/web/src/components/RunDetailTest/RunDetailTest.tsx b/web/src/components/RunDetailTest/RunDetailTest.tsx index b2c467822a..9e85d8b240 100644 --- a/web/src/components/RunDetailTest/RunDetailTest.tsx +++ b/web/src/components/RunDetailTest/RunDetailTest.tsx @@ -40,7 +40,7 @@ const RunDetailTest = ({run, testId}: IProps) => { const {selectedSpan, onSetFocusedSpan, onSelectSpan} = useSpan(); const {remove, revert, selectedTestSpec, setSelectedSpec, setSelectorSuggestions, setPrevSelector, specs} = useTestSpecs(); - const {isOpen: isTestSpecFormOpen, formProps, onSubmit, open, close} = useTestSpecForm(); + const {isOpen: isTestSpecFormOpen, formProps, onSubmit, open, close, isValid, onIsValid} = useTestSpecForm(); const { isEditing, isLoading, @@ -49,6 +49,8 @@ const RunDetailTest = ({run, testId}: IProps) => { onSubmit: onSubmitTestOutput, output, outputs, + isValid: isOutputFormValid, + onValidate, } = useTestOutput(); const [visualizationType, setVisualizationType] = useState(VisualizationType.Dag); const {isGuidedTourRunning, setGuidedTourStep} = useGuidedTour(); @@ -134,6 +136,8 @@ const RunDetailTest = ({run, testId}: IProps) => { setPrevSelector(''); onSubmit(values); }} + isValid={isValid} + onIsValid={onIsValid} runId={run.id} testId={testId} {...formProps} @@ -160,6 +164,8 @@ const RunDetailTest = ({run, testId}: IProps) => { output={output} runId={run.id} testId={testId} + isValid={isOutputFormValid} + onValidate={onValidate} /> )} diff --git a/web/src/components/TestOutputForm/TestOutputForm.tsx b/web/src/components/TestOutputForm/TestOutputForm.tsx index b3602e0747..fb35fb2ff3 100644 --- a/web/src/components/TestOutputForm/TestOutputForm.tsx +++ b/web/src/components/TestOutputForm/TestOutputForm.tsx @@ -8,7 +8,6 @@ import {useAppSelector} from 'redux/hooks'; import SpanSelectors from 'selectors/Span.selectors'; import {singularOrPlural} from 'utils/Common'; import TestOutput from 'models/TestOutput.model'; -import useValidateOutput from './hooks/useValidateOutput'; import SelectorInput from './SelectorInput'; import * as S from './TestOutputForm.styled'; @@ -17,16 +16,27 @@ interface IProps { isLoading?: boolean; onCancel(): void; onSubmit(values: TestOutput, spanId?: string): void; + onValidate(_: any, output: TestOutput): void; + isValid: boolean; output?: TestOutput; runId: string; testId: string; } -const TestOutputForm = ({isEditing = false, isLoading = false, onCancel, onSubmit, output, runId, testId}: IProps) => { +const TestOutputForm = ({ + isEditing = false, + isLoading = false, + onCancel, + onSubmit, + output, + runId, + testId, + onValidate, + isValid, +}: IProps) => { const [form] = Form.useForm(); - const spanIdList = useAppSelector(SpanSelectors.selectMatchedSpans); - const {isValid, onValidate} = useValidateOutput({spanIdList}); const selector = Form.useWatch('selector', form) || ''; + const spanIdList = useAppSelector(SpanSelectors.selectMatchedSpans); useEffect(() => { if (form.getFieldValue('selector') && form.getFieldValue('value') && form.getFieldValue('name')) { @@ -35,6 +45,18 @@ const TestOutputForm = ({isEditing = false, isLoading = false, onCancel, onSubmi } }, [form, onValidate]); + useEffect(() => { + if (!isEditing) { + form.setFieldsValue({selector: output?.selector, value: output?.value, name: output?.name}); + } + }, [form, isEditing, output?.name, output?.selector, output?.value]); + + useEffect(() => { + return () => { + onCancel(); + }; + }, []); + return ( {isEditing ? 'Edit Test Output' : 'Add Test Output'} diff --git a/web/src/components/TestSpecForm/TestSpecForm.provider.tsx b/web/src/components/TestSpecForm/TestSpecForm.provider.tsx index bfa576c6b9..c788dda3ae 100644 --- a/web/src/components/TestSpecForm/TestSpecForm.provider.tsx +++ b/web/src/components/TestSpecForm/TestSpecForm.provider.tsx @@ -12,8 +12,9 @@ import RouterActions from 'redux/actions/Router.actions'; import TestSpecsSelectors from 'selectors/TestSpecs.selectors'; import CreateAssertionModalAnalyticsService from 'services/Analytics/CreateAssertionModalAnalytics.service'; import AssertionService from 'services/Assertion.service'; -import { TTestSpecEntry } from 'models/TestSpecs.model'; +import {TTestSpecEntry} from 'models/TestSpecs.model'; import {IValues} from './TestSpecForm'; +import {useConfirmationModal} from '../../providers/ConfirmationModal/ConfirmationModal.provider'; interface IFormProps { defaultValues?: IValues; @@ -23,10 +24,12 @@ interface IFormProps { interface IContext { isOpen: boolean; + isValid: boolean; + formProps: IFormProps; open(props?: IFormProps): void; close(): void; onSubmit(values: IValues, spanId?: string): void; - formProps: IFormProps; + onIsValid(isValid: boolean): void; } const initialFormProps = { @@ -35,10 +38,12 @@ const initialFormProps = { export const Context = createContext({ isOpen: false, + isValid: false, + formProps: initialFormProps, open: noop, close: noop, - formProps: initialFormProps, onSubmit: noop, + onIsValid: noop, }); export const useTestSpecForm = () => useContext(Context); @@ -48,13 +53,15 @@ const TestSpecFormProvider: React.FC<{testId: string}> = ({children}) => { const [isOpen, setIsOpen] = useState(false); const [isConfirmationModalOpen, setIsConfirmationModalOpen] = useState(false); const [formProps, setFormProps] = useState(initialFormProps); + const [isValid, onIsValid] = useState(false); const {test} = useTest(); const {update, add, isDraftMode} = useTestSpecs(); const {run} = useTestRun(); const {onClearMatchedSpans} = useSpan(); + const {onOpen} = useConfirmationModal(); const specs = useAppSelector(state => TestSpecsSelectors.selectSpecs(state)); - const open = useCallback( + const handleOpen = useCallback( (props: IFormProps = {}) => { const {isEditing, defaultValues: {assertions = [], selector: defaultSelector, name = ''} = {}} = props; const spec = specs.find(({selector}) => defaultSelector === selector); @@ -90,11 +97,28 @@ const TestSpecFormProvider: React.FC<{testId: string}> = ({children}) => { [dispatch, specs, isDraftMode, run.testVersion, test?.version] ); + const open = useCallback( + (props: IFormProps) => { + if (isValid) { + onOpen({ + title: 'Unsaved changes', + heading: 'Discard unsaved changes?', + okText: 'Discard', + onConfirm: () => { + handleOpen(props); + }, + }); + } else handleOpen(props); + }, + [handleOpen, isValid, onOpen] + ); + const close = useCallback(() => { setFormProps(initialFormProps); onClearMatchedSpans(); setIsOpen(false); + onIsValid(false); }, [onClearMatchedSpans]); const onConfirm = useCallback(() => { @@ -130,8 +154,8 @@ const TestSpecFormProvider: React.FC<{testId: string}> = ({children}) => { ); const contextValue = useMemo( - () => ({isOpen, open, close, formProps, onSubmit}), - [isOpen, open, close, formProps, onSubmit] + () => ({isOpen, open, close, formProps, onSubmit, isValid, onIsValid}), + [isOpen, open, close, formProps, onSubmit, isValid, onIsValid] ); return ( diff --git a/web/src/components/TestSpecForm/TestSpecForm.tsx b/web/src/components/TestSpecForm/TestSpecForm.tsx index 0834bd9d1e..95c23577b9 100644 --- a/web/src/components/TestSpecForm/TestSpecForm.tsx +++ b/web/src/components/TestSpecForm/TestSpecForm.tsx @@ -1,15 +1,16 @@ import {Button, Form, Input, Tag} from 'antd'; -import {useEffect, useState} from 'react'; +import {LoadingOutlined} from '@ant-design/icons'; +import {useEffect} from 'react'; import {SELECTOR_LANGUAGE_CHEAT_SHEET_URL} from 'constants/Common.constants'; import {CompareOperator} from 'constants/Operator.constants'; import {useAppSelector} from 'redux/hooks'; import AssertionSelectors from 'selectors/Assertion.selectors'; -import SpanSelectors from 'selectors/Span.selectors'; import TestSpecsSelectors from 'selectors/TestSpecs.selectors'; import OperatorService from 'services/Operator.service'; import {TStructuredAssertion} from 'types/Assertion.types'; import {singularOrPlural} from 'utils/Common'; +import {useSpan} from 'providers/Span/Span.provider'; import AssertionCheckList from './AssertionCheckList'; import useOnFieldsChange from './hooks/useOnFieldsChange'; import useOnValuesChange from './hooks/useOnValuesChange'; @@ -30,22 +31,22 @@ interface IProps { onClearSelectorSuggestions(): void; onClickPrevSelector(prevSelector: string): void; onSubmit(values: IValues): void; + isValid: boolean; + onIsValid(isValid: boolean): void; runId: string; testId: string; } +const initialAssertions = [ + { + left: '', + comparator: OperatorService.getOperatorSymbol(CompareOperator.EQUALS), + right: '', + }, +]; + const TestSpecForm = ({ - defaultValues: { - assertions = [ - { - left: '', - comparator: OperatorService.getOperatorSymbol(CompareOperator.EQUALS), - right: '', - }, - ], - selector = '', - name = '', - } = {}, + defaultValues: {assertions = initialAssertions, selector = '', name = ''} = {}, isEditing = false, onCancel, onClearSelectorSuggestions, @@ -53,22 +54,37 @@ const TestSpecForm = ({ onSubmit, runId, testId, + isValid, + onIsValid, }: IProps) => { const [form] = Form.useForm(); - const [isValid, setIsValid] = useState(false); - const spanIdList = useAppSelector(SpanSelectors.selectMatchedSpans); + const {matchedSpans: spanIdList, isTriggerSelectorLoading} = useSpan(); const attributeList = useAppSelector(state => AssertionSelectors.selectAttributeList(state, testId, runId, spanIdList) ); - const onValuesChange = useOnValuesChange({setIsValid}); + const onValuesChange = useOnValuesChange({setIsValid: onIsValid}); const onFieldsChange = useOnFieldsChange(); useEffect(() => { onValuesChange(null, {assertions, selector, name}); + + return () => { + onCancel(); + }; }, []); + useEffect(() => { + if (!isEditing) { + form.setFieldsValue({ + selector, + name, + assertions, + }); + } + }, [form, isEditing, name, selector]); + const selectorSuggestions = useAppSelector(TestSpecsSelectors.selectSelectorSuggestions); const prevSelector = useAppSelector(TestSpecsSelectors.selectPrevSelector); @@ -98,7 +114,13 @@ const TestSpecForm = ({ 1. Select Spans - {`${spanIdList.length} ${singularOrPlural('span', spanIdList.length)} selected`} + + {isTriggerSelectorLoading ? ( + + ) : ( + `${spanIdList.length} ${singularOrPlural('span', spanIdList.length)} selected` + )} + SL cheat sheet @@ -107,7 +129,7 @@ const TestSpecForm = ({ Select the spans to which a set of assertions will be applied - + { diff --git a/web/src/components/TestSpecForm/hooks/useQuerySelector.ts b/web/src/components/TestSpecForm/hooks/useQuerySelector.ts index 21e95cefd3..eda03c7dd1 100644 --- a/web/src/components/TestSpecForm/hooks/useQuerySelector.ts +++ b/web/src/components/TestSpecForm/hooks/useQuerySelector.ts @@ -5,7 +5,6 @@ import {useEffect, useMemo, useState} from 'react'; import {SupportedEditors} from 'constants/Editor.constants'; import {useSpan} from 'providers/Span/Span.provider'; import {useTestSpecs} from 'providers/TestSpecs/TestSpecs.provider'; -import {useLazyGetSelectedSpansQuery} from 'redux/apis/TraceTest.api'; import {useAppSelector} from 'redux/hooks'; import SpanSelectors from 'selectors/Span.selectors'; import EditorService from 'services/Editor.service'; @@ -28,12 +27,12 @@ interface IProps { } const useQuerySelector = ({form, runId, testId, onValidSelector}: IProps) => { - const {onSetMatchedSpans, onClearMatchedSpans, selectedSpan} = useSpan(); + const {onClearMatchedSpans, selectedSpan, onTriggerSelector, triggerSelectorResult, isTriggerSelectorError} = + useSpan(); const {setSelectorSuggestions} = useTestSpecs(); const [isLoading, setIsLoading] = useState(true); const {currentSelector} = useAssertionFormValues(form); - const [onTriggerSelectedSpans, {data: selectedSpansData, isError}] = useLazyGetSelectedSpansQuery(); - const [isValid, setIsValid] = useState(!isError); + const [isValid, setIsValid] = useState(!isTriggerSelectorError); const selectedParentSpan = useAppSelector(state => SpanSelectors.selectSpanById(state, selectedSpan?.parentId ?? '', testId, runId) ); @@ -45,18 +44,11 @@ const useQuerySelector = ({form, runId, testId, onValidSelector}: IProps) => { setIsValid(isValidSelector); - if (isValidSelector) { - const data = await onTriggerSelectedSpans({ - query: q, - testId: tId, - runId: rId, - }).unwrap(); - onSetMatchedSpans(data.spanIds); - } + if (isValidSelector) await onTriggerSelector(q, tId, rId); setIsLoading(false); }, 500), - [onSetMatchedSpans, onTriggerSelectedSpans] + [onTriggerSelector] ); useEffect(() => { @@ -65,21 +57,21 @@ const useQuerySelector = ({form, runId, testId, onValidSelector}: IProps) => { }, [handleSelector, currentSelector, runId, testId]); useEffect(() => { - if (!selectedSpansData) return; + if (!triggerSelectorResult) return; const selectedSpanId = selectedSpan?.id ?? ''; const selectedSpanSelector = SpanService.getSelectorInformation(selectedSpan!); const selectedParentSpanSelector = selectedParentSpan ? SpanService.getSelectorInformation(selectedParentSpan) : ''; const selectorSuggestions = SelectorSuggestionsService.getSuggestions( - selectedSpansData.selector, - selectedSpansData.spanIds, + triggerSelectorResult.selector, + triggerSelectorResult.spanIds, selectedSpanId, selectedSpanSelector, selectedParentSpanSelector ); setSelectorSuggestions(selectorSuggestions); - }, [selectedParentSpan, selectedSpan, selectedSpansData, setSelectorSuggestions]); + }, [selectedParentSpan, selectedSpan, setSelectorSuggestions, triggerSelectorResult]); useEffect(() => { return () => { @@ -88,8 +80,8 @@ const useQuerySelector = ({form, runId, testId, onValidSelector}: IProps) => { }, [onClearMatchedSpans]); useEffect(() => { - setIsValid(!isError); - }, [isError]); + setIsValid(!isTriggerSelectorError); + }, [isTriggerSelectorError]); useEffect(() => { form.setFields([ @@ -102,7 +94,7 @@ const useQuerySelector = ({form, runId, testId, onValidSelector}: IProps) => { }, [form, isValid, onValidSelector]); return { - spanIdList: selectedSpansData?.spanIds ?? [], + spanIdList: triggerSelectorResult?.spanIds ?? [], isValid, isLoading, }; diff --git a/web/src/components/Visualization/components/DAG/DAG.styled.tsx b/web/src/components/Visualization/components/DAG/DAG.styled.tsx index f55d00b227..54ddf00e68 100644 --- a/web/src/components/Visualization/components/DAG/DAG.styled.tsx +++ b/web/src/components/Visualization/components/DAG/DAG.styled.tsx @@ -46,7 +46,7 @@ export const Container = styled.div<{$showMatched: boolean}>` ${({$showMatched}) => $showMatched && css` - .react-flow__node-span > div:not(.matched) { + .react-flow__node-span > div:not(.matched):not(.selectedAsCurrent) { opacity: 0.5; } `} diff --git a/web/src/components/Visualization/components/DAG/SpanNode.tsx b/web/src/components/Visualization/components/DAG/SpanNode.tsx index 62fe4f8d7e..91d63caabf 100644 --- a/web/src/components/Visualization/components/DAG/SpanNode.tsx +++ b/web/src/components/Visualization/components/DAG/SpanNode.tsx @@ -11,6 +11,10 @@ import {INodeDataSpan} from 'types/DAG.types'; import AssertionResultChecks from 'components/AssertionResultChecks/AssertionResultChecks'; import {selectOutputsBySpanId} from 'redux/testOutputs/selectors'; import TestOutputMark from 'components/TestOutputMark'; +import {useTestSpecForm} from 'components/TestSpecForm/TestSpecForm.provider'; +import CurrentSpanSelector from 'components/CurrentSpanSelector'; +import {useSpan} from 'providers/Span/Span.provider'; +import {useTestOutput} from 'providers/TestOutput/TestOutput.provider'; import * as S from './SpanNode.styled'; interface IProps extends NodeProps {} @@ -19,53 +23,60 @@ const SpanNode = ({data, id, selected}: IProps) => { const assertions = useAppSelector(state => TestSpecsSelectors.selectAssertionResultsBySpan(state, data?.id || '')); const outputs = useAppSelector(state => selectOutputsBySpanId(state, data?.id || '')); const {failed, passed} = useMemo(() => SpanService.getAssertionResultSummary(assertions), [assertions]); - - const className = data.isMatched ? 'matched' : ''; + const {isOpen: isTestSpecFormOpen} = useTestSpecForm(); + const {isOpen: isTestOutputFormOpen} = useTestOutput(); + const {matchedSpans} = useSpan(); + const showSelectAsCurrent = + !data.isMatched && !!matchedSpans.length && (isTestSpecFormOpen || isTestOutputFormOpen) && selected; + const className = `${data.isMatched ? 'matched' : ''} ${showSelectAsCurrent ? 'selectedAsCurrent' : ''}`; return ( - - + <> + + - + - - - - - {data.name} - + + + + + {data.name} + - - - - - {data.service} {SpanKindToText[data.kind]} - - - {Boolean(data.system) && ( + + + + + {data.service} {SpanKindToText[data.kind]} + + + {Boolean(data.system) && ( + + + {data.system} + + )} - - {data.system} + + {data.duration} - )} - - - {data.duration} - - + - - {!!outputs.length && } - - + + {!!outputs.length && } + + - - + + + {showSelectAsCurrent && } + ); }; diff --git a/web/src/providers/Span/Span.provider.tsx b/web/src/providers/Span/Span.provider.tsx index e8e57de7ad..45a16cca2f 100644 --- a/web/src/providers/Span/Span.provider.tsx +++ b/web/src/providers/Span/Span.provider.tsx @@ -7,14 +7,20 @@ import SpanSelectors from 'selectors/Span.selectors'; import {RouterSearchFields} from 'constants/Common.constants'; import Span from 'models/Span.model'; import RouterActions from 'redux/actions/Router.actions'; +import {useLazyGetSelectedSpansQuery} from 'redux/apis/TraceTest.api'; +import SelectedSpans from 'models/SelectedSpans.model'; interface IContext { selectedSpan?: Span; + triggerSelectorResult?: SelectedSpans; matchedSpans: string[]; focusedSpan: string; + isTriggerSelectorError: boolean; + isTriggerSelectorLoading: boolean; onSelectSpan(spanId: string): void; onSetMatchedSpans(spanIdList: string[]): void; onSetFocusedSpan(spanId: string): void; + onTriggerSelector(query: string, testId: string, runId: string): void; onClearMatchedSpans(): void; onClearSelectedSpan(): void; } @@ -27,6 +33,9 @@ export const Context = createContext({ onClearMatchedSpans: noop, onSetMatchedSpans: noop, onClearSelectedSpan: noop, + onTriggerSelector: noop, + isTriggerSelectorError: false, + isTriggerSelectorLoading: false, }); interface IProps { @@ -40,6 +49,10 @@ const SpanProvider = ({children}: IProps) => { const selectedSpan = useSelector(SpanSelectors.selectSelectedSpan); const matchedSpans = useSelector(SpanSelectors.selectMatchedSpans); const focusedSpan = useSelector(SpanSelectors.selectFocusedSpan); + const [ + triggerSelector, + {data: triggerSelectorResult, isError: isTriggerSelectorError, isFetching: isTriggerSelectorLoading}, + ] = useLazyGetSelectedSpansQuery(); useEffect(() => { return () => { @@ -66,6 +79,18 @@ const SpanProvider = ({children}: IProps) => { [dispatch] ); + const onTriggerSelector = useCallback( + async (query: string, testId: string, runId: string) => { + const {spanIds = []} = await triggerSelector({ + query, + testId, + runId, + }).unwrap(); + onSetMatchedSpans(spanIds); + }, + [onSetMatchedSpans, triggerSelector] + ); + const onSetFocusedSpan = useCallback( (spanId: string) => { dispatch(setFocusedSpan({spanId})); @@ -87,16 +112,24 @@ const SpanProvider = ({children}: IProps) => { onSetFocusedSpan, onClearMatchedSpans, onClearSelectedSpan, + onTriggerSelector, + triggerSelectorResult, + isTriggerSelectorError, + isTriggerSelectorLoading, }), [ + selectedSpan, matchedSpans, focusedSpan, - onClearMatchedSpans, onSelectSpan, onSetMatchedSpans, onSetFocusedSpan, - selectedSpan, + onClearMatchedSpans, onClearSelectedSpan, + onTriggerSelector, + triggerSelectorResult, + isTriggerSelectorError, + isTriggerSelectorLoading, ] ); diff --git a/web/src/providers/TestOutput/TestOutput.provider.tsx b/web/src/providers/TestOutput/TestOutput.provider.tsx index a3edf4cf4b..9d7420a1c1 100644 --- a/web/src/providers/TestOutput/TestOutput.provider.tsx +++ b/web/src/providers/TestOutput/TestOutput.provider.tsx @@ -15,17 +15,20 @@ import { outputsTestRunOutputsMerged, outputUpdated, } from 'redux/testOutputs/slice'; -import TestOutput from '../../models/TestOutput.model'; +import TestOutput from 'models/TestOutput.model'; +import SpanSelectors from 'selectors/Span.selectors'; import {useConfirmationModal} from '../ConfirmationModal/ConfirmationModal.provider'; import {useEnvironment} from '../Environment/Environment.provider'; import {useTest} from '../Test/Test.provider'; import {useTestRun} from '../TestRun/TestRun.provider'; +import useValidateOutput from './hooks/useValidateOutput'; interface IContext { isDraftMode: boolean; isEditing: boolean; isLoading: boolean; isOpen: boolean; + isValid: boolean; onCancel(): void; onClose(): void; onDelete(id: number): void; @@ -33,6 +36,7 @@ interface IContext { onOpen(draft?: TestOutput): void; onSubmit(values: TestOutput): void; onSelectedOutputs(outputs: TestOutput[]): void; + onValidate(_: any, output: TestOutput): void; output?: TestOutput; outputs: TestOutput[]; selectedOutputs: TestOutput[]; @@ -43,6 +47,7 @@ export const Context = createContext({ isEditing: false, isLoading: false, isOpen: false, + isValid: false, onCancel: noop, onClose: noop, onDelete: noop, @@ -50,6 +55,7 @@ export const Context = createContext({ onOpen: noop, onSubmit: noop, onSelectedOutputs: noop, + onValidate: noop, output: undefined, outputs: [], selectedOutputs: [], @@ -81,6 +87,8 @@ const TestOutputProvider = ({children, runId, testId}: IProps) => { const outputs = useAppSelector(state => selectTestOutputs(state)); const selectedOutputs = useAppSelector(selectSelectedOutputs); const isDraftMode = useAppSelector(selectIsPending); + const spanIdList = useAppSelector(SpanSelectors.selectMatchedSpans); + const {isValid, onValidate} = useValidateOutput({spanIdList}); useEffect(() => { dispatch(outputsInitiated(testOutputs)); @@ -94,16 +102,33 @@ const TestOutputProvider = ({children, runId, testId}: IProps) => { dispatch(outputsTestRunOutputsMerged(runOutputs)); }, [dispatch, runOutputs]); - const onOpen = useCallback((values?: TestOutput) => { + const handleOpen = useCallback((values?: TestOutput) => { setDraft(values); setIsOpen(true); const id = values?.id ?? -1; setIsEditing(id !== -1); }, []); + const onOpen = useCallback( + (values?: TestOutput) => { + if (isValid) { + onOpenConfirmationModal({ + title: 'Unsaved changes', + heading: 'Discard unsaved changes?', + okText: 'Discard', + onConfirm: () => { + handleOpen(values); + }, + }); + } else handleOpen(values); + }, + [handleOpen, isValid, onOpenConfirmationModal] + ); + const onClose = useCallback(() => { setDraft(undefined); setIsOpen(false); + onValidate(undefined, TestOutput({})); }, []); const onCancel = useCallback(() => { @@ -171,6 +196,8 @@ const TestOutputProvider = ({children, runId, testId}: IProps) => { isEditing, isLoading, isOpen, + isValid, + onValidate, onCancel, onClose, onDelete, @@ -188,6 +215,7 @@ const TestOutputProvider = ({children, runId, testId}: IProps) => { isEditing, isLoading, isOpen, + isValid, onCancel, onClose, onDelete, @@ -195,6 +223,7 @@ const TestOutputProvider = ({children, runId, testId}: IProps) => { onOpen, onSelectedOutputs, onSubmit, + onValidate, outputs, selectedOutputs, ] diff --git a/web/src/components/TestOutputForm/hooks/useValidateOutput.ts b/web/src/providers/TestOutput/hooks/useValidateOutput.ts similarity index 100% rename from web/src/components/TestOutputForm/hooks/useValidateOutput.ts rename to web/src/providers/TestOutput/hooks/useValidateOutput.ts