From 656e10aa8031141b031578006cea33011ac5c6c5 Mon Sep 17 00:00:00 2001 From: Charles Wahome Date: Thu, 2 Nov 2023 15:11:31 +0300 Subject: [PATCH] Fix: Autocomplete validation (#2811) --- .../validation-context/ValidationContext.tsx | 12 ++ .../validation-context/ValidationProvider.tsx | 66 +++++++++ src/app/utils/error-utils/ValidationError.ts | 14 ++ src/app/utils/sample-url-generation.ts | 5 + src/app/views/App.tsx | 24 ++-- .../query-response/snippets/Snippets.tsx | 19 ++- .../query-input/QueryInput.styles.ts | 2 +- .../query-runner/query-input/QueryInput.tsx | 7 +- .../auto-complete/AutoComplete.styles.ts | 129 +++++++++--------- .../auto-complete/AutoComplete.tsx | 113 +++++++++------ .../auto-complete/auto-complete.util.ts | 40 +----- .../auto-complete/suffix/SuffixRenderer.tsx | 6 +- .../suggestion-list/SuggestionsList.tsx | 25 ++-- .../request/permissions/Permissions.Query.tsx | 28 ++-- .../resource-explorer/ResourceExplorer.tsx | 1 - src/messages/GE.json | 3 + src/modules/validation/ODataUrlABNF.txt | 2 +- src/modules/validation/abnf.ts | 2 +- .../validation/validation-service.spec.ts | 87 ++++++++++++ src/modules/validation/validation-service.ts | 82 +++++++++++ src/types/auto-complete.ts | 2 +- 21 files changed, 479 insertions(+), 190 deletions(-) create mode 100644 src/app/services/context/validation-context/ValidationContext.tsx create mode 100644 src/app/services/context/validation-context/ValidationProvider.tsx create mode 100644 src/app/utils/error-utils/ValidationError.ts create mode 100644 src/modules/validation/validation-service.spec.ts create mode 100644 src/modules/validation/validation-service.ts diff --git a/src/app/services/context/validation-context/ValidationContext.tsx b/src/app/services/context/validation-context/ValidationContext.tsx new file mode 100644 index 000000000..c2325d0f9 --- /dev/null +++ b/src/app/services/context/validation-context/ValidationContext.tsx @@ -0,0 +1,12 @@ +import { createContext } from 'react'; + +interface ValidationContext { + isValid: boolean; + validate: (queryUrl: string) => void; + query: string; + error: string; +} + +export const ValidationContext = createContext( + {} as ValidationContext +); \ No newline at end of file diff --git a/src/app/services/context/validation-context/ValidationProvider.tsx b/src/app/services/context/validation-context/ValidationProvider.tsx new file mode 100644 index 000000000..935dc8fcb --- /dev/null +++ b/src/app/services/context/validation-context/ValidationProvider.tsx @@ -0,0 +1,66 @@ +import { ReactNode, useEffect, useMemo, useState } from 'react'; + +import { ValidationService } from '../../../../modules/validation/validation-service'; +import { useAppSelector } from '../../../../store'; +import { IResource } from '../../../../types/resources'; +import { ValidationError } from '../../../utils/error-utils/ValidationError'; +import { getResourcesSupportedByVersion } from '../../../utils/resources/resources-filter'; +import { parseSampleUrl } from '../../../utils/sample-url-generation'; +import { GRAPH_API_VERSIONS } from '../../graph-constants'; +import { ValidationContext } from './ValidationContext'; + +interface ValidationProviderProps { + children: ReactNode; +} + +export const ValidationProvider = ({ children }: ValidationProviderProps) => { + const { resources } = useAppSelector((state) => state); + const base = getResourcesSupportedByVersion(resources.data.children, GRAPH_API_VERSIONS[0]); + + const [isValid, setIsValid] = useState(false); + const [query, setQuery] = useState(''); + const [validationError, setValidationError] = useState(''); + + const [versionedResources, setVersionedResources] = + useState(resources.data.children.length > 0 ? base : []); + const [version, setVersion] = useState(GRAPH_API_VERSIONS[0]); + + const { queryVersion } = parseSampleUrl(query); + + useEffect(() => { + if (resources.data.children.length > 0) { + setVersionedResources(getResourcesSupportedByVersion(resources.data.children, GRAPH_API_VERSIONS[0])); + } + }, [resources]) + + useEffect(() => { + if (version !== queryVersion && GRAPH_API_VERSIONS.includes(queryVersion) && resources.data.children.length > 0) { + setVersionedResources(getResourcesSupportedByVersion(resources.data.children, queryVersion)); + setVersion(queryVersion); + } + }, [query]); + + const validate = (queryToValidate: string) => { + setQuery(queryToValidate); + try { + ValidationService.validate(queryToValidate, versionedResources); + setIsValid(true); + setValidationError(''); + } catch (error: unknown) { + const theError = error as ValidationError; + setValidationError(theError.message); + setIsValid(theError.type === 'warning'); + } + }; + + const contextValue = useMemo(() => { + return { isValid, validate, query, error: validationError }; + }, [isValid, validate, query, validationError]); + + return ( + + {children} + + ); + +}; \ No newline at end of file diff --git a/src/app/utils/error-utils/ValidationError.ts b/src/app/utils/error-utils/ValidationError.ts new file mode 100644 index 000000000..b330fe827 --- /dev/null +++ b/src/app/utils/error-utils/ValidationError.ts @@ -0,0 +1,14 @@ +type ErrorType = 'warning' | 'error'; + +class ValidationError extends Error { + type: ErrorType; + + constructor(message: string, type: ErrorType, name: string = 'ValidationError') { + super(message); + this.name = name; + this.type = type; + this.message = message; + } +} + +export { ValidationError }; diff --git a/src/app/utils/sample-url-generation.ts b/src/app/utils/sample-url-generation.ts index ec53ae843..d30e610f3 100644 --- a/src/app/utils/sample-url-generation.ts +++ b/src/app/utils/sample-url-generation.ts @@ -89,3 +89,8 @@ export function hasPlaceHolders(url: string): boolean { return placeHolderChars.length > 1 && placeHolderChars.every((char) => url.includes(char)); } +export function isValidHostname(hostname: string): boolean { + const regex = /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)?graph\.microsoft\.com$/; + return regex.test(hostname); +} + diff --git a/src/app/views/App.tsx b/src/app/views/App.tsx index 441357f2b..812f9eef4 100644 --- a/src/app/views/App.tsx +++ b/src/app/views/App.tsx @@ -39,6 +39,7 @@ import { QueryResponse } from './query-response'; import { QueryRunner } from './query-runner'; import { parse } from './query-runner/util/iframe-message-parser'; import { Sidebar } from './sidebar/Sidebar'; +import { ValidationProvider } from '../services/context/validation-context/ValidationProvider'; export interface IAppProps { theme?: ITheme; styles?: object; @@ -465,18 +466,19 @@ class App extends Component { display: 'flex', flexDirection: 'column', alignItems: 'stretch', flex: 1 }} > -
- -
- -
-
- + +
+ +
+
+
+ +
+
- -
+ )}
diff --git a/src/app/views/query-response/snippets/Snippets.tsx b/src/app/views/query-response/snippets/Snippets.tsx index dcc6f6884..b92660594 100644 --- a/src/app/views/query-response/snippets/Snippets.tsx +++ b/src/app/views/query-response/snippets/Snippets.tsx @@ -1,13 +1,19 @@ -import { FontSizes, Pivot, PivotItem } from '@fluentui/react'; +import { FontSizes, Label, Pivot, PivotItem } from '@fluentui/react'; import { useDispatch } from 'react-redux'; +import { useContext } from 'react'; +import { FormattedMessage } from 'react-intl'; import { AppDispatch, useAppSelector } from '../../../../store'; import { componentNames, telemetry } from '../../../../telemetry'; import { setSnippetTabSuccess } from '../../../services/actions/snippet-action-creator'; -import { renderSnippets } from './snippets-helper'; +import { ValidationContext } from '../../../services/context/validation-context/ValidationContext'; import { translateMessage } from '../../../utils/translate-messages'; +import { renderSnippets } from './snippets-helper'; + function GetSnippets() { const dispatch: AppDispatch = useDispatch(); + const validation = useContext(ValidationContext); + const { snippets, sampleQuery } = useAppSelector((state) => state); const supportedLanguages = { 'CSharp': { @@ -52,7 +58,7 @@ function GetSnippets() { dispatch(setSnippetTabSuccess(pivotItem.props.itemKey!)); } - return {renderSnippets(supportedLanguages)} - ; -} + : +}; + const Snippets = telemetry.trackReactComponent( GetSnippets, componentNames.CODE_SNIPPETS_TAB diff --git a/src/app/views/query-runner/query-input/QueryInput.styles.ts b/src/app/views/query-runner/query-input/QueryInput.styles.ts index 71c7ea45a..e505274e4 100644 --- a/src/app/views/query-runner/query-input/QueryInput.styles.ts +++ b/src/app/views/query-runner/query-input/QueryInput.styles.ts @@ -1,7 +1,7 @@ import { ITheme } from '@fluentui/react'; export const queryInputStyles = (theme: ITheme) => { - const controlWidth = '96.5%'; + const controlWidth = '94%'; return { autoComplete: { input: { diff --git a/src/app/views/query-runner/query-input/QueryInput.tsx b/src/app/views/query-runner/query-input/QueryInput.tsx index ad6818ed3..b34efdab0 100644 --- a/src/app/views/query-runner/query-input/QueryInput.tsx +++ b/src/app/views/query-runner/query-input/QueryInput.tsx @@ -1,10 +1,12 @@ import { Dropdown, IDropdownOption, IStackTokens, Stack } from '@fluentui/react'; +import { useContext } from 'react'; import { injectIntl } from 'react-intl'; import { useDispatch } from 'react-redux'; import { AppDispatch, useAppSelector } from '../../../../store'; import { IQuery, IQueryInputProps, httpMethods } from '../../../../types/query-runner'; import { setSampleQuery } from '../../../services/actions/query-input-action-creators'; +import { ValidationContext } from '../../../services/context/validation-context/ValidationContext'; import { GRAPH_API_VERSIONS } from '../../../services/graph-constants'; import { getStyleFor } from '../../../utils/http-methods.utils'; import { parseSampleUrl } from '../../../utils/sample-url-generation'; @@ -23,6 +25,7 @@ const QueryInput = (props: IQueryInputProps) => { } = props; const dispatch: AppDispatch = useDispatch(); + const validation = useContext(ValidationContext); const urlVersions: IDropdownOption[] = []; GRAPH_API_VERSIONS.forEach(version => { @@ -69,7 +72,7 @@ const QueryInput = (props: IQueryInputProps) => { if (queryUrl) { query = getChangedQueryContent(queryUrl); } - if (!query.sampleUrl || query.sampleUrl.indexOf('graph.microsoft.com') === -1) { + if (!validation.isValid) { return; } handleOnRunQuery(query); @@ -111,7 +114,7 @@ const QueryInput = (props: IQueryInputProps) => { runQuery()} submitting={submitting} diff --git a/src/app/views/query-runner/query-input/auto-complete/AutoComplete.styles.ts b/src/app/views/query-runner/query-input/auto-complete/AutoComplete.styles.ts index 513224fdf..eda6c8845 100644 --- a/src/app/views/query-runner/query-input/auto-complete/AutoComplete.styles.ts +++ b/src/app/views/query-runner/query-input/auto-complete/AutoComplete.styles.ts @@ -1,8 +1,70 @@ -import { ITheme } from '@fluentui/react'; +import { IStyle, ITheme } from '@fluentui/react'; export const autoCompleteStyles = (theme: ITheme) => { const controlWidth = '95.5%'; + const suggestions: IStyle = { + maxHeight: '250px', + overflow: 'auto', + paddingLeft: 0, + position: 'absolute', + backgroundColor: theme.palette.neutralLighter, + minWidth: '40%', + maxWidth: '50%', + zIndex: 1, + cursor: 'pointer', + color: theme.palette.black + }; + const suggestionOption: IStyle = { + display: 'block', + selectors: { + ':hover': { + background: theme.palette.neutralLight + } + }, + cursor: 'pointer', + backgroundColor: theme.palette.white, + boxShadow: 'none', + margin: '0px 0px 0px 0px', + padding: '10px 32px 12px 10px', + boxSizing: 'border-box', + height: '32px', + lineHeight: '30px', + position: 'relative', + whiteSpace: 'nowrap', + textOverflow: 'ellipsis', + borderWidth: '1px', + borderStyle: 'solid', + borderColor: theme.palette.neutralLight, + overflow: 'hidden' + }; + const suggestionActive: IStyle = { + display: 'block', + cursor: 'pointer', + boxShadow: 'none', + margin: '0px 0px 0px 0px', + padding: '10px 32px 12px 10px', + boxSizing: 'border-box', + height: '32px', + lineHeight: '30px', + position: 'relative', + whiteSpace: 'nowrap', + textOverflow: 'ellipsis', + borderWidth: '1px', + borderStyle: 'solid', + overflow: 'hidden', + wordWrap: 'normal', + backgroundColor: theme.palette.neutralLight + }; + const suggestionTitle: IStyle = { + display: 'flex', + height: '100%', + flexWrap: 'nowrap', + justifyContent: 'flex-start', + alignItems: 'center', + fontWeight: 400 + }; + return { input: { minWidth: controlWidth, @@ -12,66 +74,9 @@ export const autoCompleteStyles = (theme: ITheme) => { color: theme.palette.black, padding: 10 }, - suggestions: { - maxHeight: '250px', - overflow: 'auto', - paddingLeft: 0, - position: 'absolute', - backgroundColor: theme.palette.neutralLighter, - minWidth: '40%', - maxWidth: '50%', - zIndex: 1, - cursor: 'pointer', - color: theme.palette.black - }, - suggestionOption: { - display: 'block', - selectors: { - ':hover': { - background: theme.palette.neutralLight - } - }, - cursor: 'pointer', - backgroundColor: theme.palette.white, - boxShadow: 'none', - margin: '0px 0px 0px 0px', - padding: '10px 32px 12px 10px', - boxSizing: 'border-box', - height: '32px', - lineHeight: '30px', - position: 'relative', - whiteSpace: 'nowrap', - textOverflow: 'ellipsis', - borderWidth: '1px', - borderStyle: 'solid', - borderColor: theme.palette.neutralLight, - overflow: 'hidden' - }, - suggestionActive: { - display: 'block', - cursor: 'pointer', - boxShadow: 'none', - margin: '0px 0px 0px 0px', - padding: '10px 32px 12px 10px', - boxSizing: 'border-box', - height: '32px', - lineHeight: '30px', - position: 'relative', - whiteSpace: 'nowrap', - textOverflow: 'ellipsis', - borderWidth: '1px', - borderStyle: 'solid', - overflow: 'hidden', - wordWrap: 'normal', - backgroundColor: theme.palette.neutralLight - }, - suggestionTitle: { - display: 'flex', - height: '100%', - flexWrap: 'nowrap', - justifyContent: 'flex-start', - alignItems: 'center', - fontWeight: 400 - } + suggestions, + suggestionOption, + suggestionActive, + suggestionTitle }; }; \ No newline at end of file diff --git a/src/app/views/query-runner/query-input/auto-complete/AutoComplete.tsx b/src/app/views/query-runner/query-input/auto-complete/AutoComplete.tsx index 32bc6d3a6..7dec3b35d 100644 --- a/src/app/views/query-runner/query-input/auto-complete/AutoComplete.tsx +++ b/src/app/views/query-runner/query-input/auto-complete/AutoComplete.tsx @@ -1,5 +1,5 @@ -import { getTheme, KeyCodes, TextField, Text, ITextFieldProps } from '@fluentui/react'; -import { useEffect, useRef, useState } from 'react'; +import { getTheme, ITextFieldProps, KeyCodes, mergeStyles, Text, TextField } from '@fluentui/react'; +import { useContext, useEffect, useRef, useState } from 'react'; import { useDispatch } from 'react-redux'; import { delimiters, getLastDelimiterInUrl, getSuggestions, SignContext } from '../../../../../modules/suggestions'; @@ -7,13 +7,14 @@ import { AppDispatch, useAppSelector } from '../../../../../store'; import { componentNames, eventTypes, telemetry } from '../../../../../telemetry'; import { IAutoCompleteProps } from '../../../../../types/auto-complete'; import { fetchAutoCompleteOptions } from '../../../../services/actions/autocomplete-action-creators'; +import { ValidationContext } from '../../../../services/context/validation-context/ValidationContext'; import { GRAPH_API_VERSIONS, GRAPH_URL } from '../../../../services/graph-constants'; import { sanitizeQueryUrl } from '../../../../utils/query-url-sanitization'; import { parseSampleUrl } from '../../../../utils/sample-url-generation'; import { translateMessage } from '../../../../utils/translate-messages'; import { queryInputStyles } from '../QueryInput.styles'; import { - cleanUpSelectedSuggestion, getErrorMessage, getFilteredSuggestions, + cleanUpSelectedSuggestion, getFilteredSuggestions, getSearchText } from './auto-complete.util'; import SuffixRenderer from './suffix/SuffixRenderer'; @@ -23,6 +24,8 @@ import { usePrevious } from './use-previous'; const AutoComplete = (props: IAutoCompleteProps) => { const dispatch: AppDispatch = useDispatch(); + const validation = useContext(ValidationContext); + // eslint-disable-next-line @typescript-eslint/no-explicit-any const focusRef = useRef(null); let element: HTMLDivElement | null | undefined = null; @@ -55,7 +58,7 @@ const AutoComplete = (props: IAutoCompleteProps) => { focusRef?.current?.focus(); } - const updateUrlContent = (e: any) => { + const updateUrlContent = (e: React.FocusEvent) => { const targetValue = e.target.value; setQueryUrl(targetValue); props.contentChanged(targetValue); @@ -70,10 +73,9 @@ const AutoComplete = (props: IAutoCompleteProps) => { } } - const onChange = (e: any) => { - const currentValue = e.target.value; - setQueryUrl(currentValue); - initialiseAutoComplete(currentValue) + const onChange = (event_: React.FormEvent, newValue?: string) => { + setQueryUrl(newValue!); + initialiseAutoComplete(newValue!) }; const isOverflowing = (input: string) => { @@ -93,59 +95,36 @@ const AutoComplete = (props: IAutoCompleteProps) => { return !!element && getTextWidth(input) > element.scrollWidth; } - const selectSuggestion = (e: any) => { - appendSuggestionToUrl(e.currentTarget.innerText); + const selectSuggestion = (suggestion: string) => { + appendSuggestionToUrl(suggestion); }; - const onKeyDown = (event: any) => { + const onKeyDown = (event: React.KeyboardEvent) => { switch (event.keyCode) { case KeyCodes.enter: event.preventDefault(); - if (shouldShowSuggestions) { - const selected = suggestions[activeSuggestion]; - appendSuggestionToUrl(selected); - } else { - props.contentChanged(queryUrl); - props.runQuery(queryUrl); - } + handleEnterKeyPressed(); break; case KeyCodes.tab: if (shouldShowSuggestions) { event.preventDefault(); - const selected = suggestions[activeSuggestion]; - appendSuggestionToUrl(selected); - setShouldShowSuggestions(false); + handleTabKeyPressed(); } break; case KeyCodes.up: event.preventDefault(); - if (shouldShowSuggestions) { - let active = activeSuggestion - 1; - if (activeSuggestion === 0) { - active = suggestions.length - 1; - } - setActiveSuggestion(active); - } + handleUpKeyPressed(); break; case KeyCodes.down: event.preventDefault(); - if (shouldShowSuggestions) { - let active = activeSuggestion + 1; - if (activeSuggestion === suggestions.length - 1) { - active = 0; - } - setActiveSuggestion(active); - } + handleDownKeyPressed(); break; case KeyCodes.escape: - if (shouldShowSuggestions) { - props.contentChanged(queryUrl) - setShouldShowSuggestions(false); - } + handleEscapeKeyPressed(); break; case KeyCodes.backspace: @@ -158,6 +137,48 @@ const AutoComplete = (props: IAutoCompleteProps) => { } }; + function handleEscapeKeyPressed() { + if (shouldShowSuggestions) { + props.contentChanged(queryUrl); + setShouldShowSuggestions(false); + } + } + + function handleDownKeyPressed() { + if (shouldShowSuggestions) { + let active = activeSuggestion + 1; + if (activeSuggestion === suggestions.length - 1) { + active = 0; + } + setActiveSuggestion(active); + } + } + + function handleUpKeyPressed() { + if (shouldShowSuggestions) { + let active = activeSuggestion - 1; + if (activeSuggestion === 0) { + active = suggestions.length - 1; + } + setActiveSuggestion(active); + } + } + + function handleTabKeyPressed() { + const selected = suggestions[activeSuggestion]; + appendSuggestionToUrl(selected); + setShouldShowSuggestions(false); + } + + function handleEnterKeyPressed() { + if (shouldShowSuggestions) { + const selected = suggestions[activeSuggestion]; + appendSuggestionToUrl(selected); + } else { + props.contentChanged(queryUrl); + props.runQuery(queryUrl); + } + } const requestForAutocompleteOptions = (url: string, context: SignContext) => { const signature = sanitizeQueryUrl(url); @@ -244,7 +265,7 @@ const AutoComplete = (props: IAutoCompleteProps) => { return ; } - const closeSuggestionDialog = (event: any) => { + const closeSuggestionDialog = (event: React.FocusEvent) => { const { currentTarget, relatedTarget } = event; if (!currentTarget.contains(relatedTarget as Node) && shouldShowSuggestions) { setShouldShowSuggestions(false); @@ -252,9 +273,7 @@ const AutoComplete = (props: IAutoCompleteProps) => { } const currentTheme = getTheme(); - const { - input: autoInput - }: any = queryInputStyles(currentTheme).autoComplete; + const autoInput = mergeStyles(queryInputStyles(currentTheme).autoComplete); const handleRenderDescription = (properties?: ITextFieldProps): JSX.Element | null => { if (!shouldShowSuggestions && !autoCompletePending && properties?.description) { @@ -267,6 +286,10 @@ const AutoComplete = (props: IAutoCompleteProps) => { return null; }; + function getErrorMessage() { + validation.validate(queryUrl); + return validation.error; + } return (
@@ -287,14 +310,14 @@ const AutoComplete = (props: IAutoCompleteProps) => { ariaLabel={translateMessage('Query Sample Input')} role='textbox' onRenderDescription={handleRenderDescription} - description={getErrorMessage(queryUrl)} + description={getErrorMessage()} />
{shouldShowSuggestions && queryUrl && suggestions.length > 0 && selectSuggestion(e)} />} + onSuggestionSelected={selectSuggestion} />} ); } diff --git a/src/app/views/query-runner/query-input/auto-complete/auto-complete.util.ts b/src/app/views/query-runner/query-input/auto-complete/auto-complete.util.ts index 53dda6658..3539bb3a8 100644 --- a/src/app/views/query-runner/query-input/auto-complete/auto-complete.util.ts +++ b/src/app/views/query-runner/query-input/auto-complete/auto-complete.util.ts @@ -1,7 +1,3 @@ -import { ValidatedUrl } from '../../../../../modules/validation/abnf'; -import { hasPlaceHolders } from '../../../../utils/sample-url-generation'; -import { translateMessage } from '../../../../utils/translate-messages'; - function cleanUpSelectedSuggestion(compare: string, userInput: string, selected: string) { let finalSelectedSuggestion = `${userInput + selected}`; if (compare) { @@ -38,36 +34,6 @@ function getFilteredSuggestions(compareString: string, suggestions: string[]) { return Array.from(new Set(filteredSuggestions)); } -function getErrorMessage(queryUrl: string) { - if (!queryUrl) { - return translateMessage('Missing url'); - } - - if (hasPlaceHolders(queryUrl)) { - return translateMessage('Parts between {} need to be replaced with real values'); - } - - const error = getValidationError(queryUrl); - if (error) { - return `${translateMessage('Possible error found in URL near')}: ${error}`; - } - if (queryUrl.indexOf('graph.microsoft.com') === -1) { - return translateMessage('The URL must contain graph.microsoft.com'); - } - return ''; -} - -function getValidationError(queryUrl: string): string | null { - try { - const validator = new ValidatedUrl(); - const validation = validator.validate(queryUrl); - return (!validation.success) ? - queryUrl.substring(validation.matched, validation.maxMatched) : null; - } catch (error) { - return null; - } -} - function getSearchText(input: string, index: number) { const stringPosition = index + 1; const previous = input.substring(0, stringPosition); @@ -76,9 +42,9 @@ function getSearchText(input: string, index: number) { } export { - getErrorMessage, - getFilteredSuggestions, cleanUpSelectedSuggestion, + getFilteredSuggestions, getLastCharacterOf, getSearchText -} \ No newline at end of file +}; + diff --git a/src/app/views/query-runner/query-input/auto-complete/suffix/SuffixRenderer.tsx b/src/app/views/query-runner/query-input/auto-complete/suffix/SuffixRenderer.tsx index 18146c1e8..1fcb4fd5b 100644 --- a/src/app/views/query-runner/query-input/auto-complete/suffix/SuffixRenderer.tsx +++ b/src/app/views/query-runner/query-input/auto-complete/suffix/SuffixRenderer.tsx @@ -54,10 +54,10 @@ const SuffixRenderer = () => { const { requestUrl } = parseSampleUrl(sanitizeQueryUrl(sampleQuery.sampleUrl)); const parsed = parseSampleUrl(sanitizeQueryUrl(`${GRAPH_URL}/v1.0/${requestUrl}`)); - const properties: { [key: string]: any } = { + const properties: { [key: string]: string } = { ComponentName: componentNames.AUTOCOMPLETE_DOCUMENTATION_LINK, QueryUrl: parsed.requestUrl, - Link: documentationLink + Link: documentationLink ?? '' }; telemetry.trackEvent(eventTypes.LINK_CLICK_EVENT, properties); @@ -90,4 +90,4 @@ const SuffixRenderer = () => { ); } -export default SuffixRenderer; +export default SuffixRenderer; \ No newline at end of file diff --git a/src/app/views/query-runner/query-input/auto-complete/suggestion-list/SuggestionsList.tsx b/src/app/views/query-runner/query-input/auto-complete/suggestion-list/SuggestionsList.tsx index c34a056f9..46235a90b 100644 --- a/src/app/views/query-runner/query-input/auto-complete/suggestion-list/SuggestionsList.tsx +++ b/src/app/views/query-runner/query-input/auto-complete/suggestion-list/SuggestionsList.tsx @@ -1,15 +1,18 @@ -import { Label, styled } from '@fluentui/react'; +import { Label, getTheme, mergeStyles } from '@fluentui/react'; import { createRef, useEffect } from 'react'; import { ISuggestionsList } from '../../../../../../types/auto-complete'; -import { classNames } from '../../../../classnames'; import { autoCompleteStyles } from '../AutoComplete.styles'; -const StyledSuggesions = (props: any) => { - const { filteredSuggestions, activeSuggestion, onClick }: ISuggestionsList = props; - const classes = classNames(props); +const SuggestionsList = ({ filteredSuggestions, activeSuggestion, onSuggestionSelected }: ISuggestionsList) => { + const theme = getTheme(); + const suggestionsClass = mergeStyles(autoCompleteStyles(theme).suggestions); + const suggestionActiveClass = mergeStyles(autoCompleteStyles(theme).suggestionActive); + const suggestionOptionClass = mergeStyles(autoCompleteStyles(theme).suggestionOption); + const suggestionTitleClass = mergeStyles(autoCompleteStyles(theme).suggestionTitle); - const refs = filteredSuggestions.reduce((ref: any, value: any) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const refs = filteredSuggestions.reduce((ref: any, value: string) => { const itemIndex = filteredSuggestions.findIndex(k => k === value); ref[itemIndex] = createRef(); return ref; @@ -26,16 +29,16 @@ const StyledSuggesions = (props: any) => { }, [activeSuggestion]); return ( -
    +
      {filteredSuggestions.map((suggestion: string, index: number) => { return (
    • onClick(e)} + onClick={() => onSuggestionSelected(suggestion)} > -
    • @@ -45,6 +48,4 @@ const StyledSuggesions = (props: any) => { ); }; -// @ts-ignore -const SuggestionsList = styled(StyledSuggesions, autoCompleteStyles); export default SuggestionsList; \ No newline at end of file diff --git a/src/app/views/query-runner/request/permissions/Permissions.Query.tsx b/src/app/views/query-runner/request/permissions/Permissions.Query.tsx index b2b638fe1..856fe3f00 100644 --- a/src/app/views/query-runner/request/permissions/Permissions.Query.tsx +++ b/src/app/views/query-runner/request/permissions/Permissions.Query.tsx @@ -2,24 +2,26 @@ import { DetailsList, DetailsListLayoutMode, getTheme, IColumn, Label, Link, SelectionMode, TooltipHost } from '@fluentui/react'; -import { useEffect, useState } from 'react'; +import { useContext, useEffect, useState } from 'react'; import { FormattedMessage } from 'react-intl'; import { useDispatch } from 'react-redux'; import { AppDispatch, useAppSelector } from '../../../../../store'; import { IPermission, IPermissionProps } from '../../../../../types/permissions'; import { fetchAllPrincipalGrants, fetchScopes } from '../../../../services/actions/permissions-action-creator'; +import { ValidationContext } from '../../../../services/context/validation-context/ValidationContext'; import { usePopups } from '../../../../services/hooks'; import { translateMessage } from '../../../../utils/translate-messages'; import { classNames } from '../../../classnames'; +import { convertVhToPx } from '../../../common/dimensions/dimensions-adjustment'; +import { getColumns } from './columns'; import { permissionStyles } from './Permission.styles'; import PermissionItem from './PermissionItem'; -import { getColumns } from './columns'; import { setConsentedStatus, sortPermissionsWithPrivilege } from './util'; -import { convertVhToPx } from '../../../common/dimensions/dimensions-adjustment'; export const Permissions = (permissionProps?: IPermissionProps): JSX.Element => { const dispatch: AppDispatch = useDispatch(); + const validation = useContext(ValidationContext); const { sampleQuery, scopes, authToken, consentedScopes, dimensions } = useAppSelector((state) => state); const { show: showPermissions } = usePopups('full-permissions', 'panel'); @@ -31,7 +33,7 @@ export const Permissions = (permissionProps?: IPermissionProps): JSX.Element => const [permissionsError, setPermissionsError] = useState(error); useEffect(() => { - if(error?.error && error?.error?.url?.includes('permissions')){ + if (error?.error && error?.error?.url.contains('permissions')) { setPermissionsError(error?.error); } }, [error]) @@ -44,7 +46,7 @@ export const Permissions = (permissionProps?: IPermissionProps): JSX.Element => const classes = classNames(classProps); const theme = getTheme(); const { tooltipStyles, detailsHeaderStyles } = permissionStyles(theme); - const tabHeight = convertVhToPx(dimensions.request.height, 110); + const tabHeight = convertVhToPx(dimensions.request.height, 110); setConsentedStatus(tokenPresent, permissions, consentedScopes); @@ -75,7 +77,9 @@ export const Permissions = (permissionProps?: IPermissionProps): JSX.Element => } useEffect(() => { - getPermissions(); + if (validation.isValid) { + getPermissions(); + } }, [sampleQuery]); useEffect(() => { @@ -83,7 +87,7 @@ export const Permissions = (permissionProps?: IPermissionProps): JSX.Element => }, [consentedScopes]); useEffect(() => { - if (tokenPresent) { + if (tokenPresent && validation.isValid) { dispatch(fetchAllPrincipalGrants()); } }, []); @@ -108,6 +112,14 @@ export const Permissions = (permissionProps?: IPermissionProps): JSX.Element => ); } + if (!validation.isValid) { + return ( + + ); + } + const displayNoPermissionsFoundMessage = (): JSX.Element => { return ( ) } - const displayErrorFetchingPermissionsMessage = () : JSX.Element => { + const displayErrorFetchingPermissionsMessage = (): JSX.Element => { return (); diff --git a/src/app/views/sidebar/resource-explorer/ResourceExplorer.tsx b/src/app/views/sidebar/resource-explorer/ResourceExplorer.tsx index 50593f1c2..e2e314977 100644 --- a/src/app/views/sidebar/resource-explorer/ResourceExplorer.tsx +++ b/src/app/views/sidebar/resource-explorer/ResourceExplorer.tsx @@ -45,7 +45,6 @@ const UnstyledResourceExplorer = (props: any) => { ]; const resourcesToUse = data.children ? JSON.parse(JSON.stringify(data.children)) : [] as IResource[]; - const [version, setVersion] = useState(versions[0].key); const [searchText, setSearchText] = useState(''); const filteredPayload = getResourcesSupportedByVersion(resourcesToUse, version, searchText); diff --git a/src/messages/GE.json b/src/messages/GE.json index 3edec6d7d..4f1f2a91b 100644 --- a/src/messages/GE.json +++ b/src/messages/GE.json @@ -487,5 +487,8 @@ "Invalid file format": "Looks like you tried to upload an invalid file format. Please upload a valid Postman collection file", "Upload collection": "Upload collection", "Would you like to merge with the current collection?": "Would you like to merge with the current collection?", + "Invalid URL": "Invalid URL", + "Missing version": "The URL must include a valid version to run the query", + "No resource found matching this query": "No resource was found matching this query", "Least privileged permission": "This is the least privileged permission in the list" } \ No newline at end of file diff --git a/src/modules/validation/ODataUrlABNF.txt b/src/modules/validation/ODataUrlABNF.txt index bc6b0a29a..0039558cf 100644 --- a/src/modules/validation/ODataUrlABNF.txt +++ b/src/modules/validation/ODataUrlABNF.txt @@ -1075,4 +1075,4 @@ VCHAR = %x21-7E ;------------------------------------------------------------------------------ ; End of odata-abnf-construction-rules -;------------------------------------------------------------------------------ +;------------------------------------------------------------------------------ \ No newline at end of file diff --git a/src/modules/validation/abnf.ts b/src/modules/validation/abnf.ts index 98b486f2c..1c74b92cf 100644 --- a/src/modules/validation/abnf.ts +++ b/src/modules/validation/abnf.ts @@ -47,4 +47,4 @@ export class ValidatedUrl { ); return result; } -} +} \ No newline at end of file diff --git a/src/modules/validation/validation-service.spec.ts b/src/modules/validation/validation-service.spec.ts new file mode 100644 index 000000000..18f15034c --- /dev/null +++ b/src/modules/validation/validation-service.spec.ts @@ -0,0 +1,87 @@ +import { ValidationError } from '../../app/utils/error-utils/ValidationError'; +import { ValidationService } from './validation-service'; + +const validUrls = [ + 'https://graph.microsoft.com/v1.0/me/events', + 'https://graph.microsoft.com/me', + + // eslint-disable-next-line max-len + 'https://graph.microsoft.com/v1.0/me/messages/AAMkAGVmMDEzMTM4LTZmYWUtNDdkNC1hMDZiLTU1OGY5OTZhYmY4OABGAAAAAAAiQ8W967B7TKBjgx9rVEURBwAiIsqMbYjsT5e-T7KzowPTAAAAAAEMAAAiIsqMbYjsT5e-T7KzowPTAAMCzwJpAAA=/', + 'https://graph.microsoft.com/v1.0/security/alerts?$filter=Category eq \'ransomware\'&$top=5', + 'https://graph.microsoft.com/v1.0/planner/plans/CONGZUWfGUu4msTgNP66e2UAAySi', + 'https://graph.microsoft.com/v1.0/me/onenote/sections/1f7ff346-c174-45e5-af38-294e51d9969a/pages', + 'https://graph.microsoft.com/v1.0/users(\'48d31887-5fad-4d73-a9f5-3c356e68a038\')', + 'https://graph.microsoft.com/beta/me/drive/root/delta(token=\'1230919asd190410jlka\')', + // eslint-disable-next-line max-len + 'https://graph.microsoft.com/beta/items/getActivitiesByInterval(startDateTime=\'2017-01-01\',endDateTime=\'2017-01-03\',interval=\'day\')', + 'https://graph.microsoft.com/beta/me/drive/root:/FolderA/FileB.txt:/content', + 'https://graph.microsoft.com/v1.0/me/drive/root:/Test Folder', + 'https://graph.microsoft.com/v1.0/me/drive/root:/Encoded%20URL' +]; + +const invalidUrls = [ + 'https://graph.microsoft.com/me+you', + 'https://graph.microsoft.com/v1.0/me/messages?$$select=id', + 'https://graph.microsoft.com/v1.0/me/drive/root:/Encoded%', + 'https://graph.microsoft.com/v1.0/me/drive/root:/Encoded%2' +]; + +/* +* These are valid URLs that fail without the trailing slash added to them. +*/ + +const forcedTrailingSlashes = [ + 'https://graph.microsoft.com/beta/directory/deleteditems/microsoft.graph.group/', + 'https://graph.microsoft.com/v1.0/me/photo/$value/', + 'https://graph.microsoft.com/v1.0/admin/serviceAnnouncement/healthOverviews/$count/', + 'https://graph.microsoft.com/v1.0/me/drive/root:/book1.xlsx/', + 'https://graph.microsoft.com/v1.0/planner/tasks/oIx3zN98jEmVOM-4mUJzSGUANeje/', + 'https://graph.microsoft.com/v1.0/users/MiriamG@M365x214355.onmicrosoft.com/', + 'https://graph.microsoft.com/v1.0/me/extensions/com.contoso.roamingSettings/', + 'https://graph.microsoft.com/v1.0/applications_v2/02bd9fd6-8f93-4758-87c3-1fb73740a315/', + 'https://graph.microsoft.com/beta/groups/02bd9fd6-8f93-4758-87c3-1fb73740a315/owners/$ref/', + // eslint-disable-next-line max-len + 'https://graph.microsoft.com/v1.0/teams/02bd9fd6-8f93-4758-87c3-1fb73740a315/channels/19:09fc54a3141a45d0bc769cf506d2e079@thread.skype/' + +] +describe('Abnf parser should', () => { + validUrls.forEach((sample) => { + it(`validate url: ${sample} should pass`, () => { + let error = ''; + try { + ValidationService.validate(sample, []); + } catch (err) { + const theError = err as ValidationError; + error = theError.message; + } + expect(error).toBeFalsy(); + }); + }); + + invalidUrls.forEach((sample) => { + it(`validate url: ${sample} should fail`, () => { + let error = ''; + try { + ValidationService.validate(sample, []); + } catch (err) { + const theError = err as ValidationError; + error = theError.message; + } + expect(error).toBeTruthy(); + }); + }); + + forcedTrailingSlashes.forEach((sample) => { + it(`validate url: ${sample} should pass`, () => { + let error = ''; + try { + ValidationService.validate(sample, []); + } catch (err) { + const theError = err as ValidationError; + error = theError.message; + } + expect(error).toBeFalsy(); + }); + }); + +}); \ No newline at end of file diff --git a/src/modules/validation/validation-service.ts b/src/modules/validation/validation-service.ts new file mode 100644 index 000000000..a09668f5f --- /dev/null +++ b/src/modules/validation/validation-service.ts @@ -0,0 +1,82 @@ +import { ValidationError } from '../../app/utils/error-utils/ValidationError'; +import { sanitizeQueryUrl } from '../../app/utils/query-url-sanitization'; +import { getMatchingResourceForUrl } from '../../app/utils/resources/resources-filter'; +import { hasPlaceHolders, isValidHostname, parseSampleUrl } from '../../app/utils/sample-url-generation'; +import { translateMessage } from '../../app/utils/translate-messages'; +import { IResource } from '../../types/resources'; +import { ValidatedUrl } from './abnf'; + +class ValidationService { + + private static getResourceValidationError(queryUrl: string, resources: IResource[]): string | null { + if (resources.length === 0) { + return null; + } + const sanitizedUrl = sanitizeQueryUrl(queryUrl); + const { requestUrl } = parseSampleUrl(sanitizedUrl); + const matchingResource = getMatchingResourceForUrl(requestUrl, resources)!; + if (!matchingResource) { + return 'No resource found matching this query'; + } + return null; + } + + private static getAbnfValidationError(queryUrl: string): string | null { + try { + const validator = new ValidatedUrl(); + const validation = validator.validate(queryUrl); + return (!validation.success) ? + queryUrl.substring(validation.matched, validation.maxMatched) : null; + } catch (error) { + return null; + } + } + + static validate(queryUrl: string, resources: IResource[]): boolean { + + if (!queryUrl) { + throw new ValidationError( + `${translateMessage('Missing url')}`, + 'error'); + } + + const { hostname } = new URL(queryUrl); + if (!isValidHostname(hostname)) { + throw new ValidationError( + `${translateMessage('The URL must contain graph.microsoft.com')}`, + 'error'); + } + + const { queryVersion } = parseSampleUrl(queryUrl); + if (!queryVersion) { + throw new ValidationError( + `${translateMessage('Missing version')}`, + 'error'); + } + + if (hasPlaceHolders(queryUrl)) { + throw new ValidationError( + `${translateMessage('Parts between {} need to be replaced with real values')}` + , 'warning'); + } + + const resourcesError = ValidationService.getResourceValidationError(queryUrl, resources); + if (resourcesError) { + throw new ValidationError( + `${translateMessage(resourcesError)}`, + 'warning'); + } + + const abnfError = ValidationService.getAbnfValidationError(queryUrl); + if (abnfError) { + throw new ValidationError( + `${translateMessage('Possible error found in URL near')}: ${abnfError}`, + 'warning'); + } + + return true; + } +} + +export { ValidationService }; + diff --git a/src/types/auto-complete.ts b/src/types/auto-complete.ts index d2f710999..40a9298a6 100644 --- a/src/types/auto-complete.ts +++ b/src/types/auto-complete.ts @@ -25,7 +25,7 @@ export interface IAutoCompleteState { export interface ISuggestionsList { activeSuggestion: number; filteredSuggestions: string[]; - onClick: Function; + onSuggestionSelected: (suggestion: string) => void; } export interface IAutocompleteResponse extends IApiResponse {