From 845625e27db0dcd8c3eab3965ef9e2c2caed577a Mon Sep 17 00:00:00 2001 From: Charles Wahome Date: Thu, 21 Sep 2023 17:20:09 +0300 Subject: [PATCH 01/17] strongly type autocomplete components --- .../auto-complete/AutoComplete.styles.ts | 129 +++++++++--------- .../auto-complete/AutoComplete.tsx | 27 ++-- .../auto-complete/suffix/SuffixRenderer.tsx | 6 +- .../suggestion-list/SuggestionsList.tsx | 25 ++-- src/types/auto-complete.ts | 2 +- 5 files changed, 96 insertions(+), 93 deletions(-) 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 513224fdfa..eda6c88459 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 32bc6d3a6e..9d3a0a05e0 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,4 +1,4 @@ -import { getTheme, KeyCodes, TextField, Text, ITextFieldProps } from '@fluentui/react'; +import { getTheme, ITextFieldProps, KeyCodes, mergeStyles, Text, TextField } from '@fluentui/react'; import { useEffect, useRef, useState } from 'react'; import { useDispatch } from 'react-redux'; @@ -23,6 +23,7 @@ import { usePrevious } from './use-previous'; const AutoComplete = (props: IAutoCompleteProps) => { const dispatch: AppDispatch = useDispatch(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any const focusRef = useRef(null); let element: HTMLDivElement | null | undefined = null; @@ -55,7 +56,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 +71,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,11 +93,11 @@ 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(); @@ -244,7 +244,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 +252,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,7 +265,6 @@ const AutoComplete = (props: IAutoCompleteProps) => { return null; }; - return (
{ element = el }}> @@ -294,7 +291,7 @@ const AutoComplete = (props: IAutoCompleteProps) => { selectSuggestion(e)} />} + onSuggestionSelected={selectSuggestion} />}
); } 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 18146c1e8f..8ab1216f78 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 c34a056f9b..46235a90b6 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/types/auto-complete.ts b/src/types/auto-complete.ts index d2f710999b..40a9298a6f 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 { From e9b4eab80aa0e6f9e6970d7a1cf660b68562e826 Mon Sep 17 00:00:00 2001 From: Charles Wahome Date: Thu, 21 Sep 2023 17:20:22 +0300 Subject: [PATCH 02/17] create validator utility --- .../auto-complete/auto-complete.util.ts | 17 +++-------------- src/modules/validation/url-validator.ts | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 14 deletions(-) create mode 100644 src/modules/validation/url-validator.ts 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 53dda6658f..17874819ec 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,4 +1,4 @@ -import { ValidatedUrl } from '../../../../../modules/validation/abnf'; +import { getValidationError } from '../../../../../modules/validation/url-validator'; import { hasPlaceHolders } from '../../../../utils/sample-url-generation'; import { translateMessage } from '../../../../utils/translate-messages'; @@ -57,17 +57,6 @@ function getErrorMessage(queryUrl: string) { 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 +65,9 @@ function getSearchText(input: string, index: number) { } export { + cleanUpSelectedSuggestion, getErrorMessage, getFilteredSuggestions, - cleanUpSelectedSuggestion, getLastCharacterOf, getSearchText -} \ No newline at end of file +}; diff --git a/src/modules/validation/url-validator.ts b/src/modules/validation/url-validator.ts new file mode 100644 index 0000000000..f68bbf9b78 --- /dev/null +++ b/src/modules/validation/url-validator.ts @@ -0,0 +1,14 @@ +import { ValidatedUrl } from './abnf'; + +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; + } +} + +export { getValidationError }; From 1a6474e3fbb3f20d0298b9362f8fac44d037e4ad Mon Sep 17 00:00:00 2001 From: Charles Wahome Date: Wed, 27 Sep 2023 15:42:53 +0300 Subject: [PATCH 03/17] create validation service --- .../auto-complete/auto-complete.util.ts | 6 ++-- src/modules/validation/url-validator.ts | 14 --------- src/modules/validation/validation-service.ts | 31 +++++++++++++++++++ 3 files changed, 35 insertions(+), 16 deletions(-) delete mode 100644 src/modules/validation/url-validator.ts create mode 100644 src/modules/validation/validation-service.ts 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 17874819ec..7094dcf196 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,4 +1,4 @@ -import { getValidationError } from '../../../../../modules/validation/url-validator'; +import { ValidationService } from '../../../../../modules/validation/validation-service'; import { hasPlaceHolders } from '../../../../utils/sample-url-generation'; import { translateMessage } from '../../../../utils/translate-messages'; @@ -47,7 +47,8 @@ function getErrorMessage(queryUrl: string) { return translateMessage('Parts between {} need to be replaced with real values'); } - const error = getValidationError(queryUrl); + const validationService = new ValidationService(queryUrl); + const error = validationService.getValidationError(); if (error) { return `${translateMessage('Possible error found in URL near')}: ${error}`; } @@ -71,3 +72,4 @@ export { getLastCharacterOf, getSearchText }; + diff --git a/src/modules/validation/url-validator.ts b/src/modules/validation/url-validator.ts deleted file mode 100644 index f68bbf9b78..0000000000 --- a/src/modules/validation/url-validator.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { ValidatedUrl } from './abnf'; - -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; - } -} - -export { getValidationError }; diff --git a/src/modules/validation/validation-service.ts b/src/modules/validation/validation-service.ts new file mode 100644 index 0000000000..f527b26ef0 --- /dev/null +++ b/src/modules/validation/validation-service.ts @@ -0,0 +1,31 @@ +import { ValidatedUrl } from './abnf'; + +interface IValidationService { + getValidationError(): string | null; +} + +class ValidationService implements IValidationService { + queryUrl: string; + + constructor(queryUrl: string) { + this.queryUrl = queryUrl; + } + + private getAbnfValidationError(): string | null { + try { + const validator = new ValidatedUrl(); + const validation = validator.validate(this.queryUrl); + return (!validation.success) ? + this.queryUrl.substring(validation.matched, validation.maxMatched) : null; + } catch (error) { + return null; + } + } + + public getValidationError(): string | null { + const abnfError = this.getAbnfValidationError(); + return abnfError; + } +} + +export { ValidationService } \ No newline at end of file From 1c6aaa37282448077e95972576d2d49bc531e80b Mon Sep 17 00:00:00 2001 From: Charles Wahome Date: Mon, 2 Oct 2023 12:52:42 +0300 Subject: [PATCH 04/17] create validation service --- src/app/utils/error-utils/ValidationError.ts | 8 ++++ .../auto-complete/auto-complete.util.ts | 24 +++------- src/modules/validation/validation-service.ts | 44 ++++++++++++------- 3 files changed, 42 insertions(+), 34 deletions(-) create mode 100644 src/app/utils/error-utils/ValidationError.ts diff --git a/src/app/utils/error-utils/ValidationError.ts b/src/app/utils/error-utils/ValidationError.ts new file mode 100644 index 0000000000..2790aa3a69 --- /dev/null +++ b/src/app/utils/error-utils/ValidationError.ts @@ -0,0 +1,8 @@ +class ValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'ValidationError'; + } +} + +export { ValidationError }; \ No newline at end of file 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 7094dcf196..a19d93a3f9 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,5 +1,5 @@ import { ValidationService } from '../../../../../modules/validation/validation-service'; -import { hasPlaceHolders } from '../../../../utils/sample-url-generation'; +import { ValidationError } from '../../../../utils/error-utils/ValidationError'; import { translateMessage } from '../../../../utils/translate-messages'; function cleanUpSelectedSuggestion(compare: string, userInput: string, selected: string) { @@ -39,23 +39,13 @@ function getFilteredSuggestions(compareString: string, suggestions: string[]) { } function getErrorMessage(queryUrl: string) { - if (!queryUrl) { - return translateMessage('Missing url'); + try { + ValidationService.validate(queryUrl); + return ''; + } catch (error: unknown) { + const theError = error as ValidationError; + return theError.message || translateMessage('Invalid URL'); } - - if (hasPlaceHolders(queryUrl)) { - return translateMessage('Parts between {} need to be replaced with real values'); - } - - const validationService = new ValidationService(queryUrl); - const error = validationService.getValidationError(); - 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 getSearchText(input: string, index: number) { diff --git a/src/modules/validation/validation-service.ts b/src/modules/validation/validation-service.ts index f527b26ef0..302f6ff2fb 100644 --- a/src/modules/validation/validation-service.ts +++ b/src/modules/validation/validation-service.ts @@ -1,31 +1,41 @@ +import { ValidationError } from '../../app/utils/error-utils/ValidationError'; +import { hasPlaceHolders } from '../../app/utils/sample-url-generation'; +import { translateMessage } from '../../app/utils/translate-messages'; import { ValidatedUrl } from './abnf'; -interface IValidationService { - getValidationError(): string | null; -} - -class ValidationService implements IValidationService { - queryUrl: string; - - constructor(queryUrl: string) { - this.queryUrl = queryUrl; - } +class ValidationService { - private getAbnfValidationError(): string | null { + private static getAbnfValidationError(queryUrl: string): string | null { try { const validator = new ValidatedUrl(); - const validation = validator.validate(this.queryUrl); + const validation = validator.validate(queryUrl); return (!validation.success) ? - this.queryUrl.substring(validation.matched, validation.maxMatched) : null; + queryUrl.substring(validation.matched, validation.maxMatched) : null; } catch (error) { return null; } } - public getValidationError(): string | null { - const abnfError = this.getAbnfValidationError(); - return abnfError; + static validate(queryUrl: string): boolean { + + if (!queryUrl) { + throw new ValidationError(`${translateMessage('Missing url')}`); + } + + if (queryUrl.indexOf('graph.microsoft.com') === -1) { + throw new ValidationError(`${translateMessage('The URL must contain graph.microsoft.com')}`); + } + + if (hasPlaceHolders(queryUrl)) { + throw new ValidationError(`${translateMessage('Parts between {} need to be replaced with real values')}`); + } + + const abnfError = ValidationService.getAbnfValidationError(queryUrl); + if (abnfError) { + throw new ValidationError(`${translateMessage('Possible error found in URL near')}: ${abnfError}`); + } + return true; } } -export { ValidationService } \ No newline at end of file +export { ValidationService }; From 4e25baa87e9e8957195f70c6a03de91bd31503ef Mon Sep 17 00:00:00 2001 From: Charles Wahome Date: Mon, 2 Oct 2023 14:32:26 +0300 Subject: [PATCH 05/17] use validation validation context --- .../validation-context/ValidationContext.tsx | 12 ++++++ .../validation-context/ValidationProvider.tsx | 39 +++++++++++++++++++ src/app/views/App.tsx | 24 ++++++------ .../auto-complete/AutoComplete.tsx | 13 +++++-- .../auto-complete/auto-complete.util.ts | 11 ------ 5 files changed, 74 insertions(+), 25 deletions(-) create mode 100644 src/app/services/context/validation-context/ValidationContext.tsx create mode 100644 src/app/services/context/validation-context/ValidationProvider.tsx 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 0000000000..ef0e30fdde --- /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) => boolean; + 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 0000000000..12206af567 --- /dev/null +++ b/src/app/services/context/validation-context/ValidationProvider.tsx @@ -0,0 +1,39 @@ +import { useState, ReactNode } from 'react'; +import { ValidationContext } from './ValidationContext'; +import { ValidationService } from '../../../../modules/validation/validation-service'; +import { ValidationError } from '../../../utils/error-utils/ValidationError'; + +interface ValidationProviderProps { + children: ReactNode; +} + +export const ValidationProvider = ({ children }: ValidationProviderProps) => { + const [isValid, setIsValid] = useState(false); + const [query, setQuery] = useState(''); + const [validationError, setValidationError] = useState(''); + + const validate = (queryToValidate: string): boolean => { + setQuery(queryToValidate); + try { + ValidationService.validate(queryToValidate); + setIsValid(true); + setValidationError(''); + return true; + } catch (error: unknown) { + const theError = error as ValidationError; + setValidationError(theError.message); + setIsValid(false); + } + return false; + }; + + console.log('ValidationProvider', { isValid, query, validationError }); + + return ( + + {children} + + ); +}; \ No newline at end of file diff --git a/src/app/views/App.tsx b/src/app/views/App.tsx index 441357f2b0..812f9eef45 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-runner/query-input/auto-complete/AutoComplete.tsx b/src/app/views/query-runner/query-input/auto-complete/AutoComplete.tsx index 9d3a0a05e0..76e3c1e645 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, ITextFieldProps, KeyCodes, mergeStyles, Text, TextField } from '@fluentui/react'; -import { useEffect, useRef, useState } from '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,7 @@ 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); @@ -265,6 +267,11 @@ const AutoComplete = (props: IAutoCompleteProps) => { return null; }; + function getErrorMessage() { + validation.validate(queryUrl); + return validation.error; + } + return (
      { element = el }}> @@ -284,7 +291,7 @@ const AutoComplete = (props: IAutoCompleteProps) => { ariaLabel={translateMessage('Query Sample Input')} role='textbox' onRenderDescription={handleRenderDescription} - description={getErrorMessage(queryUrl)} + description={getErrorMessage()} />
      {shouldShowSuggestions && queryUrl && suggestions.length > 0 && 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 a19d93a3f9..95936c5a09 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 @@ -38,16 +38,6 @@ function getFilteredSuggestions(compareString: string, suggestions: string[]) { return Array.from(new Set(filteredSuggestions)); } -function getErrorMessage(queryUrl: string) { - try { - ValidationService.validate(queryUrl); - return ''; - } catch (error: unknown) { - const theError = error as ValidationError; - return theError.message || translateMessage('Invalid URL'); - } -} - function getSearchText(input: string, index: number) { const stringPosition = index + 1; const previous = input.substring(0, stringPosition); @@ -57,7 +47,6 @@ function getSearchText(input: string, index: number) { export { cleanUpSelectedSuggestion, - getErrorMessage, getFilteredSuggestions, getLastCharacterOf, getSearchText From da80bdb1b1740e9618c08172c25990c4abb553d7 Mon Sep 17 00:00:00 2001 From: Charles Wahome Date: Mon, 2 Oct 2023 15:47:19 +0300 Subject: [PATCH 06/17] add types to validation error definition --- .../validation-context/ValidationContext.tsx | 2 +- .../validation-context/ValidationProvider.tsx | 8 ++------ src/app/utils/error-utils/ValidationError.ts | 12 +++++++++--- src/messages/GE.json | 3 ++- src/modules/validation/validation-service.ts | 16 ++++++++++++---- 5 files changed, 26 insertions(+), 15 deletions(-) diff --git a/src/app/services/context/validation-context/ValidationContext.tsx b/src/app/services/context/validation-context/ValidationContext.tsx index ef0e30fdde..c2325d0f91 100644 --- a/src/app/services/context/validation-context/ValidationContext.tsx +++ b/src/app/services/context/validation-context/ValidationContext.tsx @@ -2,7 +2,7 @@ import { createContext } from 'react'; interface ValidationContext { isValid: boolean; - validate: (queryUrl: string) => boolean; + validate: (queryUrl: string) => void; query: string; error: string; } diff --git a/src/app/services/context/validation-context/ValidationProvider.tsx b/src/app/services/context/validation-context/ValidationProvider.tsx index 12206af567..c6d2b13418 100644 --- a/src/app/services/context/validation-context/ValidationProvider.tsx +++ b/src/app/services/context/validation-context/ValidationProvider.tsx @@ -12,23 +12,19 @@ export const ValidationProvider = ({ children }: ValidationProviderProps) => { const [query, setQuery] = useState(''); const [validationError, setValidationError] = useState(''); - const validate = (queryToValidate: string): boolean => { + const validate = (queryToValidate: string) => { setQuery(queryToValidate); try { ValidationService.validate(queryToValidate); setIsValid(true); setValidationError(''); - return true; } catch (error: unknown) { const theError = error as ValidationError; setValidationError(theError.message); - setIsValid(false); + setIsValid(theError.type === 'warning'); } - return false; }; - console.log('ValidationProvider', { isValid, query, validationError }); - return ( Date: Mon, 2 Oct 2023 15:49:14 +0300 Subject: [PATCH 07/17] use context to prevent running queries when errors exist --- .../query-response/snippets/Snippets.tsx | 21 ++++++++++---- .../query-runner/query-input/QueryInput.tsx | 7 +++-- .../request/permissions/Permissions.Query.tsx | 28 +++++++++++++------ 3 files changed, 40 insertions(+), 16 deletions(-) diff --git a/src/app/views/query-response/snippets/Snippets.tsx b/src/app/views/query-response/snippets/Snippets.tsx index 9a8188709e..1274684ad5 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': { @@ -38,7 +44,7 @@ function GetSnippets() { sdkDownloadLink: 'https://aka.ms/msgraphpythonsdk', sdkDocLink: 'https://aka.ms/sdk-doc' }, - 'Cli' : { + 'Cli': { sdkDownloadLink: 'https://aka.ms/msgraphclisdk', sdkDocLink: 'https://aka.ms/sdk-doc' } @@ -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.tsx b/src/app/views/query-runner/query-input/QueryInput.tsx index ad6818ed31..b34efdab01 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/request/permissions/Permissions.Query.tsx b/src/app/views/query-runner/request/permissions/Permissions.Query.tsx index aa9c84d10a..02696a8371 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 } 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.contains('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 (); From f680ae9df315b43a250ba6ce1410a3419d679368 Mon Sep 17 00:00:00 2001 From: Charles Wahome Date: Fri, 6 Oct 2023 12:16:30 +0300 Subject: [PATCH 08/17] Fix: use resources to validate paths (#2812) --- .../validation-context/ValidationProvider.tsx | 40 +++++++-- src/modules/validation/ODataUrlABNF.txt | 2 +- src/modules/validation/abnf.ts | 2 +- .../validation/validation-service.spec.ts | 87 +++++++++++++++++++ src/modules/validation/validation-service.ts | 29 ++++++- 5 files changed, 150 insertions(+), 10 deletions(-) create mode 100644 src/modules/validation/validation-service.spec.ts diff --git a/src/app/services/context/validation-context/ValidationProvider.tsx b/src/app/services/context/validation-context/ValidationProvider.tsx index c6d2b13418..c45348a110 100644 --- a/src/app/services/context/validation-context/ValidationProvider.tsx +++ b/src/app/services/context/validation-context/ValidationProvider.tsx @@ -1,21 +1,49 @@ -import { useState, ReactNode } from 'react'; -import { ValidationContext } from './ValidationContext'; +import { ReactNode, useEffect, 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 [isValid, setIsValid] = useState(false); - const [query, setQuery] = useState(''); - const [validationError, setValidationError] = useState(''); + 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); + ValidationService.validate(queryToValidate, versionedResources); setIsValid(true); setValidationError(''); } catch (error: unknown) { diff --git a/src/modules/validation/ODataUrlABNF.txt b/src/modules/validation/ODataUrlABNF.txt index bc6b0a29aa..0039558cfa 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 98b486f2c8..1c74b92cf8 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 0000000000..18f15034cf --- /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 index 72f6b52683..cd35a98f33 100644 --- a/src/modules/validation/validation-service.ts +++ b/src/modules/validation/validation-service.ts @@ -1,10 +1,26 @@ import { ValidationError } from '../../app/utils/error-utils/ValidationError'; -import { hasPlaceHolders } from '../../app/utils/sample-url-generation'; +import { sanitizeQueryUrl } from '../../app/utils/query-url-sanitization'; +import { getMatchingResourceForUrl } from '../../app/utils/resources/resources-filter'; +import { hasPlaceHolders, 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(); @@ -16,7 +32,7 @@ class ValidationService { } } - static validate(queryUrl: string): boolean { + static validate(queryUrl: string, resources: IResource[]): boolean { if (!queryUrl) { throw new ValidationError( @@ -36,14 +52,23 @@ class ValidationService { , 'warning'); } + const resourcesError = ValidationService.getResourceValidationError(queryUrl, resources); + if (resourcesError) { + throw new ValidationError( + `${translateMessage(resourcesError)}`, + 'error'); + } + const abnfError = ValidationService.getAbnfValidationError(queryUrl); if (abnfError) { throw new ValidationError( `${translateMessage('Possible error found in URL near')}: ${abnfError}`, 'warning'); } + return true; } } export { ValidationService }; + From bfb0c5d63e0d982d02609b1502e7a499f76d1214 Mon Sep 17 00:00:00 2001 From: Charles Wahome Date: Fri, 6 Oct 2023 12:31:55 +0300 Subject: [PATCH 09/17] reduce code smells --- .../auto-complete/AutoComplete.tsx | 75 ++++++++++++------- .../auto-complete/auto-complete.util.ts | 4 - .../auto-complete/suffix/SuffixRenderer.tsx | 2 +- 3 files changed, 48 insertions(+), 33 deletions(-) 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 76e3c1e645..7dec3b35d4 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 @@ -103,51 +103,28 @@ const AutoComplete = (props: IAutoCompleteProps) => { 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: @@ -160,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); 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 95936c5a09..3539bb3a88 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 { ValidationService } from '../../../../../modules/validation/validation-service'; -import { ValidationError } from '../../../../utils/error-utils/ValidationError'; -import { translateMessage } from '../../../../utils/translate-messages'; - function cleanUpSelectedSuggestion(compare: string, userInput: string, selected: string) { let finalSelectedSuggestion = `${userInput + selected}`; if (compare) { 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 8ab1216f78..1fcb4fd5b3 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 @@ -57,7 +57,7 @@ const SuffixRenderer = () => { const properties: { [key: string]: string } = { ComponentName: componentNames.AUTOCOMPLETE_DOCUMENTATION_LINK, QueryUrl: parsed.requestUrl, - Link: documentationLink || '' + Link: documentationLink ?? '' }; telemetry.trackEvent(eventTypes.LINK_CLICK_EVENT, properties); From 086457e08a16c22ee60b56026ec14e17ecaea675 Mon Sep 17 00:00:00 2001 From: Charles Wahome Date: Fri, 6 Oct 2023 12:44:01 +0300 Subject: [PATCH 10/17] useMemo to remove code smell --- .../context/validation-context/ValidationProvider.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/app/services/context/validation-context/ValidationProvider.tsx b/src/app/services/context/validation-context/ValidationProvider.tsx index c45348a110..935dc8fcb3 100644 --- a/src/app/services/context/validation-context/ValidationProvider.tsx +++ b/src/app/services/context/validation-context/ValidationProvider.tsx @@ -1,4 +1,4 @@ -import { ReactNode, useEffect, useState } from 'react'; +import { ReactNode, useEffect, useMemo, useState } from 'react'; import { ValidationService } from '../../../../modules/validation/validation-service'; import { useAppSelector } from '../../../../store'; @@ -53,11 +53,14 @@ export const ValidationProvider = ({ children }: ValidationProviderProps) => { } }; + const contextValue = useMemo(() => { + return { isValid, validate, query, error: validationError }; + }, [isValid, validate, query, validationError]); + return ( - + {children} ); + }; \ No newline at end of file From 99f1cd39374abb9199b490bef8fb2f142f0aef74 Mon Sep 17 00:00:00 2001 From: Charles Wahome Date: Mon, 9 Oct 2023 15:39:45 +0300 Subject: [PATCH 11/17] use url constant --- src/modules/validation/validation-service.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/modules/validation/validation-service.ts b/src/modules/validation/validation-service.ts index cd35a98f33..f731af8f37 100644 --- a/src/modules/validation/validation-service.ts +++ b/src/modules/validation/validation-service.ts @@ -1,3 +1,4 @@ +import { GRAPH_URL } from '../../app/services/graph-constants'; import { ValidationError } from '../../app/utils/error-utils/ValidationError'; import { sanitizeQueryUrl } from '../../app/utils/query-url-sanitization'; import { getMatchingResourceForUrl } from '../../app/utils/resources/resources-filter'; @@ -40,7 +41,7 @@ class ValidationService { 'error'); } - if (queryUrl.indexOf('graph.microsoft.com') === -1) { + if (queryUrl.indexOf(GRAPH_URL) === -1) { throw new ValidationError( `${translateMessage('The URL must contain graph.microsoft.com')}`, 'error'); From 7dfa02ba958a68a107d97d42cab4456f97769c46 Mon Sep 17 00:00:00 2001 From: Charles Wahome Date: Mon, 9 Oct 2023 17:54:26 +0300 Subject: [PATCH 12/17] Change query-input control width --- src/app/views/query-runner/query-input/QueryInput.styles.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 71c7ea45a9..e505274e49 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: { From d26c4a5d1cc87ff0f16cf44e34405f64e4e5afe1 Mon Sep 17 00:00:00 2001 From: Charles Wahome Date: Thu, 19 Oct 2023 09:46:53 +0300 Subject: [PATCH 13/17] properly validate against the protocol and hostname --- src/modules/validation/validation-service.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/modules/validation/validation-service.ts b/src/modules/validation/validation-service.ts index f731af8f37..a0dd44042b 100644 --- a/src/modules/validation/validation-service.ts +++ b/src/modules/validation/validation-service.ts @@ -41,7 +41,8 @@ class ValidationService { 'error'); } - if (queryUrl.indexOf(GRAPH_URL) === -1) { + const { hostname, protocol } = new URL(queryUrl); + if (`${protocol}//${hostname}` !== GRAPH_URL) { throw new ValidationError( `${translateMessage('The URL must contain graph.microsoft.com')}`, 'error'); From acb303b1e1787623598a897630dd317b892893c1 Mon Sep 17 00:00:00 2001 From: Charles Wahome Date: Thu, 19 Oct 2023 10:12:26 +0300 Subject: [PATCH 14/17] check the hostname against allowed parameters --- src/modules/validation/validation-service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/modules/validation/validation-service.ts b/src/modules/validation/validation-service.ts index a0dd44042b..74563729ec 100644 --- a/src/modules/validation/validation-service.ts +++ b/src/modules/validation/validation-service.ts @@ -42,7 +42,7 @@ class ValidationService { } const { hostname, protocol } = new URL(queryUrl); - if (`${protocol}//${hostname}` !== GRAPH_URL) { + if (`${protocol}//${hostname}` !== GRAPH_URL || !hostname.includes('graph.microsoft.com')) { throw new ValidationError( `${translateMessage('The URL must contain graph.microsoft.com')}`, 'error'); @@ -58,7 +58,7 @@ class ValidationService { if (resourcesError) { throw new ValidationError( `${translateMessage(resourcesError)}`, - 'error'); + 'warning'); } const abnfError = ValidationService.getAbnfValidationError(queryUrl); From b8e895bd983ad93b98ea1ce6112d875364d02b5f Mon Sep 17 00:00:00 2001 From: Charles Wahome Date: Thu, 19 Oct 2023 10:31:19 +0300 Subject: [PATCH 15/17] add validation for missing versions --- src/messages/GE.json | 4 +++- src/modules/validation/validation-service.ts | 10 +++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/messages/GE.json b/src/messages/GE.json index fb2e4334f9..6a704af49a 100644 --- a/src/messages/GE.json +++ b/src/messages/GE.json @@ -487,5 +487,7 @@ "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" + "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" } \ No newline at end of file diff --git a/src/modules/validation/validation-service.ts b/src/modules/validation/validation-service.ts index 74563729ec..ad99e5cc21 100644 --- a/src/modules/validation/validation-service.ts +++ b/src/modules/validation/validation-service.ts @@ -42,12 +42,20 @@ class ValidationService { } const { hostname, protocol } = new URL(queryUrl); - if (`${protocol}//${hostname}` !== GRAPH_URL || !hostname.includes('graph.microsoft.com')) { + const { hostname: graphHostname } = new URL(GRAPH_URL); + if (`${protocol}//${hostname}` !== GRAPH_URL || !hostname.includes(graphHostname)) { 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')}` From d5b3843492dc75b7aa128c6642935417e3f7dffd Mon Sep 17 00:00:00 2001 From: Charles Wahome Date: Tue, 31 Oct 2023 17:11:56 +0300 Subject: [PATCH 16/17] validate hostname to be graph / subdomain --- src/app/utils/sample-url-generation.ts | 5 +++++ .../views/sidebar/resource-explorer/ResourceExplorer.tsx | 1 - src/modules/validation/validation-service.ts | 8 +++----- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/app/utils/sample-url-generation.ts b/src/app/utils/sample-url-generation.ts index ec53ae843d..d30e610f38 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/sidebar/resource-explorer/ResourceExplorer.tsx b/src/app/views/sidebar/resource-explorer/ResourceExplorer.tsx index 50593f1c20..e2e3149777 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/modules/validation/validation-service.ts b/src/modules/validation/validation-service.ts index ad99e5cc21..a09668f5fb 100644 --- a/src/modules/validation/validation-service.ts +++ b/src/modules/validation/validation-service.ts @@ -1,8 +1,7 @@ -import { GRAPH_URL } from '../../app/services/graph-constants'; 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, parseSampleUrl } from '../../app/utils/sample-url-generation'; +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'; @@ -41,9 +40,8 @@ class ValidationService { 'error'); } - const { hostname, protocol } = new URL(queryUrl); - const { hostname: graphHostname } = new URL(GRAPH_URL); - if (`${protocol}//${hostname}` !== GRAPH_URL || !hostname.includes(graphHostname)) { + const { hostname } = new URL(queryUrl); + if (!isValidHostname(hostname)) { throw new ValidationError( `${translateMessage('The URL must contain graph.microsoft.com')}`, 'error'); From ae0976263c929d1a9ad62f627cc12e2c0a1c0df6 Mon Sep 17 00:00:00 2001 From: Charles Wahome Date: Thu, 2 Nov 2023 15:00:02 +0300 Subject: [PATCH 17/17] remove duplicate imports --- .../query-runner/request/permissions/Permissions.Query.tsx | 2 -- 1 file changed, 2 deletions(-) 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 71c5cb25be..856fe3f004 100644 --- a/src/app/views/query-runner/request/permissions/Permissions.Query.tsx +++ b/src/app/views/query-runner/request/permissions/Permissions.Query.tsx @@ -17,9 +17,7 @@ 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();