diff --git a/package-lock.json b/package-lock.json index acb1190b6..c1a017803 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2934,6 +2934,15 @@ "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.178.tgz", "integrity": "sha512-0d5Wd09ItQWH1qFbEyQ7oTQ3GZrMfth5JkbN3EvTKLXcHLRDSXeLnlvlOn0wvxVIwK5o2M8JzP/OWz7T3NRsbw==" }, + "@types/lodash.debounce": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@types/lodash.debounce/-/lodash.debounce-4.0.6.tgz", + "integrity": "sha512-4WTmnnhCfDvvuLMaF3KV4Qfki93KebocUF45msxhYyjMttZDQYzHkO639ohhk8+oco2cluAFL3t5+Jn4mleylQ==", + "dev": true, + "requires": { + "@types/lodash": "*" + } + }, "@types/lodash.isequal": { "version": "4.5.5", "resolved": "https://registry.npmjs.org/@types/lodash.isequal/-/lodash.isequal-4.5.5.tgz", @@ -4037,7 +4046,8 @@ "dev": true }, "fsevents": { - "version": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==" }, "has-flag": { diff --git a/package.json b/package.json index d2b64a20e..fe66da6d1 100644 --- a/package.json +++ b/package.json @@ -93,6 +93,7 @@ "@types/enzyme-adapter-react-16": "1.0.5", "@types/isomorphic-fetch": "0.0.35", "@types/jest": "24.0.6", + "@types/lodash.debounce": "4.0.6", "@types/react": "17.0.35", "@types/react-dom": "17.0.11", "@types/react-intl": "3.0.0", @@ -123,4 +124,4 @@ "reportFile": "test-report.xml", "indent": 4 } -} +} \ No newline at end of file diff --git a/src/app/utils/string-operations.ts b/src/app/utils/string-operations.ts index 51cc30252..4ef0d1d2e 100644 --- a/src/app/utils/string-operations.ts +++ b/src/app/utils/string-operations.ts @@ -1,11 +1,24 @@ declare global { interface String { - toSentenceCase(): String; + toSentenceCase(): string; + contains(searchText: string): boolean; } } -String.prototype.toSentenceCase = function () { +/** + * Converts the first character to uppercase if character is alphanumeric and the rest to lowercase + */ +String.prototype.toSentenceCase = function (): string { return `${this.charAt(0).toUpperCase()}${this.toLowerCase().slice(1)}`; }; +/** + * Performs a case-insenstive search of a substring within a string and + * returns true if searchString appears as a substring of a string + * @param searchString search string + */ +String.prototype.contains = function (searchString: string): boolean { + return this.toLowerCase().includes(searchString.toLowerCase()); +}; + export {}; diff --git a/src/app/views/sidebar/Sidebar.tsx b/src/app/views/sidebar/Sidebar.tsx index a7497e7b8..ae6ff2529 100644 --- a/src/app/views/sidebar/Sidebar.tsx +++ b/src/app/views/sidebar/Sidebar.tsx @@ -4,6 +4,7 @@ import React from 'react'; import { telemetry } from '../../../telemetry'; import { translateMessage } from '../../utils/translate-messages'; import History from './history/History'; +import { ResourceExplorer } from './resource-explorer'; import SampleQueries from './sample-queries/SampleQueries'; export const Sidebar = () => { return ( @@ -19,6 +20,16 @@ export const Sidebar = () => { >
+ +
+
{ { key: 'beta', text: 'beta', iconProps: { iconName: 'PartlyCloudyNight' } } ]; const [version, setVersion] = useState(versions[0].key); - const filteredPayload = getResourcesSupportedByVersion(data, version); - const navigationGroup = createResourcesList(filteredPayload.children, version); + const [searchText, setSearchText] = useState(''); + const filteredPayload = getResourcesSupportedByVersion(data.children, version, searchText); + const navigationGroup = createResourcesList(filteredPayload, version, searchText); - const [resourceItems, setResourceItems] = useState(filteredPayload.children); + const [resourceItems, setResourceItems] = useState(filteredPayload); const [items, setItems] = useState(navigationGroup); useEffect(() => { setItems(navigationGroup); - setResourceItems(filteredPayload.children) - }, [filteredPayload.children.length]); + setResourceItems(filteredPayload) + }, [filteredPayload.length]); const [isolated, setIsolated] = useState(null); - const [searchText, setSearchText] = useState(''); - - const performSearch = (needle: string, haystack: IResource[]) => { - const keyword = needle.toLowerCase(); - return haystack.filter((sample: IResource) => { - const name = sample.segment.toLowerCase(); - return name.toLowerCase().includes(keyword); - }); - } + const [linkLevel, setLinkLevel] = useState(-1); const generateBreadCrumbs = () => { if (!!isolated && isolated.paths.length > 0) { @@ -81,31 +76,20 @@ const unstyledResourceExplorer = (props: any) => { dispatch(addResourcePaths(getResourcePaths(item, version))); } - const changeVersion = (ev: React.FormEvent | undefined, - option: IChoiceGroupOption | undefined): void => { - const selectedVersion = option!.key; + const changeVersion = (_event: React.MouseEvent, checked?: boolean | undefined): void => { + const selectedVersion = checked ? versions[1].key : versions[0].key; setVersion(selectedVersion); - const list = getResourcesSupportedByVersion(data, selectedVersion); - const dataSet = (searchText) ? performSearch(searchText, list.children) : list.children; - setResourceItems(dataSet); - setItems(createResourcesList(dataSet, selectedVersion)); } const changeSearchValue = (event: any, value?: string) => { - let filtered: any[] = [...data.children]; - setSearchText(value || ''); - if (value) { - filtered = performSearch(value, filtered); - } - const dataSet = getResourcesSupportedByVersion({ - children: filtered, - labels: data.labels, - segment: data.segment - }, version).children; - setResourceItems(dataSet); - setItems(createResourcesList(dataSet, version)); + const trimmedSearchText = value ? value.trim() : ''; + setSearchText(trimmedSearchText); } + const debouncedSearch = useMemo(() => { + return debouce(changeSearchValue, 300); + }, []); + const navigateToBreadCrumb = (ev?: any, item?: IBreadcrumbItem): void => { const iterator = item!.key; if (iterator === '/') { @@ -130,6 +114,7 @@ const unstyledResourceExplorer = (props: any) => { ]; setItems(tree); setIsolated(navLink); + setLinkLevel(navLink.level); telemetry.trackEvent(eventTypes.LISTITEM_CLICK_EVENT, { ComponentName: componentNames.RESOURCES_ISOLATE_QUERY_LIST_ITEM, @@ -140,8 +125,9 @@ const unstyledResourceExplorer = (props: any) => { const disableIsolation = (): void => { setIsolated(null); setSearchText(''); - const filtered = getResourcesSupportedByVersion(data, version); - setItems(createResourcesList(filtered.children, version)); + const filtered = getResourcesSupportedByVersion(data.children, version); + setLinkLevel(-1); + setItems(createResourcesList(filtered, version)); } const clickLink = (ev?: React.MouseEvent, item?: INavLink) => { @@ -196,17 +182,17 @@ const unstyledResourceExplorer = (props: any) => { {!isolated && <>
- } @@ -246,6 +232,7 @@ const unstyledResourceExplorer = (props: any) => { link={link} isolateTree={isolateTree} resourceOptionSelected={(activity: string, context: unknown) => resourceOptionSelected(activity, context)} + linkLevel={linkLevel} classes={classes} /> }} diff --git a/src/app/views/sidebar/resource-explorer/ResourceLink.tsx b/src/app/views/sidebar/resource-explorer/ResourceLink.tsx index af719761e..e6b2bd0d6 100644 --- a/src/app/views/sidebar/resource-explorer/ResourceLink.tsx +++ b/src/app/views/sidebar/resource-explorer/ResourceLink.tsx @@ -8,11 +8,11 @@ import { FormattedMessage } from 'react-intl'; import { ResourceLinkType, ResourceOptions } from '../../../../types/resources'; import { getStyleFor } from '../../../utils/http-methods.utils'; import { translateMessage } from '../../../utils/translate-messages'; - interface IResourceLinkProps { link: any; isolateTree: Function; resourceOptionSelected: Function; + linkLevel: number; classes: any; } @@ -22,9 +22,10 @@ const ResourceLink = (props: IResourceLinkProps) => { const tooltipId = getId('tooltip'); const buttonId = getId('targetButton'); + const iconButtonStyles = { - root: { paddingBottom: 10 }, - menuIcon: { fontSize: 20, padding: 10 } + root: { paddingBottom:10, marginTop: -5, marginRight: 2 }, + menuIcon: { fontSize: 20, padding: 5 } }; const methodButtonStyles: CSSProperties = { @@ -44,8 +45,12 @@ const ResourceLink = (props: IResourceLinkProps) => { > {resourceLink.method} } - {resourceLink.name} + + + {resourceLink.name} + + {items.length > 0 && { role='button' id={buttonId} aria-describedby={tooltipId} - className={linkStyle.button} styles={iconButtonStyles} menuIconProps={{ iconName: 'MoreVertical' }} title={translateMessage('More actions')} @@ -77,7 +81,7 @@ const ResourceLink = (props: IResourceLinkProps) => { /> } - ; + function getMenuItems() { const menuItems: IContextualMenuItem[] = []; @@ -108,8 +112,9 @@ const ResourceLink = (props: IResourceLinkProps) => { const linkStyle = mergeStyleSets( { - link: { display: 'flex', lineHeight: 'normal' }, - button: { float: 'right', position: 'absolute', right: 0 } + link: { display: 'flex', lineHeight: 'normal', width: '100%', overflow: 'hidden' }, + resourceLinkNameContainer: { textAlign: 'left', flex: '1', overflow:'hidden', display: 'flex' }, + resourceLinkText: { textOverflow: 'ellipsis', overflow: 'hidden', whiteSpace: 'nowrap' } } ); diff --git a/src/app/views/sidebar/resource-explorer/command-options/CommandOptions.tsx b/src/app/views/sidebar/resource-explorer/command-options/CommandOptions.tsx index 69b36fb49..6949902cd 100644 --- a/src/app/views/sidebar/resource-explorer/command-options/CommandOptions.tsx +++ b/src/app/views/sidebar/resource-explorer/command-options/CommandOptions.tsx @@ -1,44 +1,83 @@ -import { CommandBar, CommandBarButton, getTheme, IButtonProps, ICommandBarItemProps } from '@fluentui/react'; +import { + CommandBar, CommandBarButton, DefaultButton, Dialog, DialogFooter, DialogType, + getId, getTheme, IButtonProps, ICommandBarItemProps, PrimaryButton +} from '@fluentui/react'; import React, { useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { IRootState } from '../../../../../types/root'; +import { removeResourcePaths } from '../../../../services/actions/resource-explorer-action-creators'; import { translateMessage } from '../../../../utils/translate-messages'; import PathsReview from '../panels/PathsReview'; import { resourceExplorerStyles } from '../resources.styles'; + interface ICommandOptions { version: string; } const CommandOptions = (props: ICommandOptions) => { + const dispatch = useDispatch(); const [isOpen, setIsOpen] = useState(false); + const [isDialogHidden, setIsDialogHidden] = useState(true); const { version } = props; const theme = getTheme(); + const { resources: { paths } } = useSelector((state: IRootState) => state); const itemStyles = resourceExplorerStyles(theme).itemStyles; const commandStyles = resourceExplorerStyles(theme).commandBarStyles; const options: ICommandBarItemProps[] = [ { key: 'preview', text: translateMessage('Preview collection'), + ariaLabel: translateMessage('Preview collection'), iconProps: { iconName: 'View' }, onClick: () => toggleSelectedResourcesPreview() } ]; + const farItems: ICommandBarItemProps[] = [ + { + key: 'delete-all', + iconProps: { iconName: 'Delete' }, + style: { + marginLeft: 30 + }, + ariaLabel: translateMessage('Delete'), + onClick: () => toggleIsDialogHidden() + } + ] + const toggleSelectedResourcesPreview = () => { let open = isOpen; open = !open; setIsOpen(open); } + const removeAllResources = () => { + dispatch(removeResourcePaths(paths)); + } + const CustomButton: React.FunctionComponent = (props_: any) => { - return ; + return ; + }; + + const deleteResourcesDialogProps = { + type: DialogType.normal, + title: translateMessage('Delete collection'), + closeButtonAriaLabel: 'Close', + subText: translateMessage('Do you want to remove all the items you have added to the collection?') }; + const toggleIsDialogHidden = () => { + setIsDialogHidden(!isDialogHidden); + } + return (
{ version={version} toggleSelectedResourcesPreview={toggleSelectedResourcesPreview} /> +
) } diff --git a/src/app/views/sidebar/resource-explorer/panels/postman.util.spec.ts b/src/app/views/sidebar/resource-explorer/panels/postman.util.spec.ts index c214a37a7..06fe4535e 100644 --- a/src/app/views/sidebar/resource-explorer/panels/postman.util.spec.ts +++ b/src/app/views/sidebar/resource-explorer/panels/postman.util.spec.ts @@ -12,7 +12,6 @@ describe('Postman collection should', () => { const item: any = filtered.links[0]; const paths = getResourcePaths(item, version); const collection = generatePostmanCollection(paths); - const folderItems: any = collection.item[0]; - expect(folderItems.item.length).toBe(33); + expect(collection.item.length).toBe(33); }); }); diff --git a/src/app/views/sidebar/resource-explorer/panels/postman.util.ts b/src/app/views/sidebar/resource-explorer/panels/postman.util.ts index cc93b2b74..801324e32 100644 --- a/src/app/views/sidebar/resource-explorer/panels/postman.util.ts +++ b/src/app/views/sidebar/resource-explorer/panels/postman.util.ts @@ -23,37 +23,36 @@ export function generatePostmanCollection( } function generateItemsFromPaths(resources: IResourceLink[]): Item[] { - const folderNames = resources - .map((resource) => { - if (resource.paths.length > 1) { - return resource.paths[1]; - } - }) - .filter((value, i, arr) => arr.indexOf(value) === i) // selects distinct folder names - .sort(); + const list: Item[] = []; + resources.forEach(resource => { + const { + method, + name, + url, + version, + paths: path + } = resource; + + path.shift(); + path.unshift(version!); - const folderItems: any = folderNames.map((folder) => { - const items = resources - .filter((resource) => resource.url.match(`^/${folder}/?`)) - .map((resource) => { - const { method, url, version, paths: path } = resource; - path.shift(); - path.unshift(version!); - const item: Item = { - name: url, - request: { - method: method!, - url: { - raw: `${GRAPH_URL}/${version}${url}`, - protocol: 'https', - host: ['graph', 'microsoft', 'com'], - path - } - } - }; - return item; - }); - return { name: folder, item: items }; + const item: Item = { + name: `${name}-${version}`, + request: { + method: method!, + url: { + raw: `${GRAPH_URL}/${version}${url}`, + protocol: 'https', + host: [ + 'graph', + 'microsoft', + 'com' + ], + path + } + } + } + list.push(item); }); - return folderItems; + return list; } diff --git a/src/app/views/sidebar/resource-explorer/resource-explorer.utils.spec.ts b/src/app/views/sidebar/resource-explorer/resource-explorer.utils.spec.ts index 2ec512241..2cf81f3b7 100644 --- a/src/app/views/sidebar/resource-explorer/resource-explorer.utils.spec.ts +++ b/src/app/views/sidebar/resource-explorer/resource-explorer.utils.spec.ts @@ -15,8 +15,8 @@ describe('Resource payload should', () => { }); it('return children with version v1.0', async () => { - const resources = getResourcesSupportedByVersion(resource, 'v1.0'); - expect(resources.children.length).toBe(64); + const resources = getResourcesSupportedByVersion(resource.children, 'v1.0'); + expect(resources.length).toBe(64); }); it('return links with version v1.0', async () => { diff --git a/src/app/views/sidebar/resource-explorer/resource-explorer.utils.ts b/src/app/views/sidebar/resource-explorer/resource-explorer.utils.ts index 958143292..94b59a96c 100644 --- a/src/app/views/sidebar/resource-explorer/resource-explorer.utils.ts +++ b/src/app/views/sidebar/resource-explorer/resource-explorer.utils.ts @@ -6,7 +6,6 @@ import { IResourceLink, ResourceLinkType } from '../../../../types/resources'; - interface ITreeFilter { paths: string[]; level: number; @@ -16,7 +15,8 @@ interface ITreeFilter { export function createResourcesList( source: IResource[], - version: string + version: string, + searchText?: string ): INavLinkGroup[] { function getLinkType({ segment, links }: any): ResourceLinkType { const isGraphFunction = segment.startsWith('microsoft.graph'); @@ -34,21 +34,27 @@ export function createResourcesList( ): IResourceLink[] { const { segment, children } = parent; const links: IResourceLink[] = []; + const childPaths = [...paths, segment]; if (methods.length > 1) { - methods.forEach((method) => { - links.push( - createNavLink( - { + if ( + !searchText || + (searchText && childPaths.some((path) => path.contains(searchText))) + ) { + methods.forEach((method) => { + links.push( + createNavLink( + { + segment, + labels: [], + children: [] + }, segment, - labels: [], - children: [] - }, - segment, - [...paths, segment], - method.toUpperCase() - ) - ); - }); + childPaths, + method.toUpperCase() + ) + ); + }); + } } // versioned children @@ -56,19 +62,17 @@ export function createResourcesList( children .filter((child) => versionExists(child, version)) .forEach((versionedChild) => { - links.push( - createNavLink(versionedChild, segment, [...paths, segment]) - ); + links.push(createNavLink(versionedChild, segment, childPaths)); }); return links; } function sortResourceLinks(a: IResourceLink, b: IResourceLink): number { - if (a.links.length === 0 && a.links.length < b.links.length) { + if (a.links.length === 0 && b.links.length > 0) { return -1; } - if (b.links.length === 0 && a.links.length > b.links.length) { + if (b.links.length === 0 && a.links.length > 0) { return 1; } return 0; @@ -103,12 +107,19 @@ export function createResourcesList( ? ` (${versionedChildren.length})` : ''; + // if segment name does not contain search text, then found text is in child, so expand this link + const isExpanded = + searchText && + ![...paths, segment].some((path) => path.contains(searchText)) + ? true + : false; + return { key, url: key, name: `${segment}${enclosedCounter}`, labels, - isExpanded: false, + isExpanded, parent, level, paths, @@ -160,23 +171,46 @@ export function removeCounter(title: string): string { } export function getResourcesSupportedByVersion( - content: IResource, - version: string -): IResource { - const resources: IResource = { ...content }; - const children: IResource[] = []; + resources: IResource[], + version: string, + searchText?: string +): IResource[] { + const versionedResources: IResource[] = []; + const resourcesList = JSON.parse(JSON.stringify(resources)); // deep copy + resourcesList.forEach((resource: IResource) => { + if (versionExists(resource, version)) { + resource.children = getResourcesSupportedByVersion( + resource.children || [], + version + ); + versionedResources.push(resource); + } + }); + return searchText + ? searchResources(versionedResources, searchText) + : versionedResources; +} - resources.children.forEach((child: IResource) => { - if (versionExists(child, version)) { - children.push(child); +function searchResources(haystack: IResource[], needle: string): IResource[] { + const foundResources: IResource[] = []; + haystack.forEach((resource: IResource) => { + if (resource.segment.contains(needle)) { + foundResources.push(resource); + return; + } + if (resource.children) { + const foundChildResources = searchResources(resource.children, needle); + if (foundChildResources.length > 0) { + resource.children = foundChildResources; + foundResources.push(resource); + } } }); - resources.children = children; - return resources; + return foundResources; } -export function versionExists(child: IResource, version: string): boolean { - return !!child.labels.find((k) => k.name === version); +export function versionExists(resource: IResource, version: string): boolean { + return resource.labels.some((k) => k.name === version); } export function getAvailableMethods( @@ -214,6 +248,7 @@ export function getResourcePaths( content.forEach((element: IResourceLink) => { element.version = version; element.url = `${getUrlFromLink(element)}`; + element.key = element.key?.includes(version) ? element.key : `${element.key}-${element.version}` }); } return content; @@ -229,3 +264,5 @@ function flatten(content: IResourceLink[]): IResourceLink[] { }); return result; } + + diff --git a/src/app/views/sidebar/resource-explorer/resources.styles.ts b/src/app/views/sidebar/resource-explorer/resources.styles.ts index 50fd22912..5b0cd4823 100644 --- a/src/app/views/sidebar/resource-explorer/resources.styles.ts +++ b/src/app/views/sidebar/resource-explorer/resources.styles.ts @@ -20,10 +20,14 @@ export const resourceExplorerStyles = (theme: ITheme) => { export const navStyles: any = (properties: any) => ({ chevronIcon: [ properties.isExpanded && { - transform: 'rotate(0deg)' + transform: 'rotate(0deg)', + position: 'relative', + top: '-3px' }, !properties.isExpanded && { - transform: 'rotate(-90deg)' + transform: 'rotate(-90deg)', + position: 'relative', + top: '-3px' } ], chevronButton: [ diff --git a/src/messages/GE.json b/src/messages/GE.json index a4ab6dddc..6f15ece2f 100644 --- a/src/messages/GE.json +++ b/src/messages/GE.json @@ -431,5 +431,7 @@ "Invalid version in URL": "Invalid version in URL", "More info": "More info", "Could not connect to the sandbox": "Could not connect to the sandbox", - "Failed to get profile information": "Failed to get profile information" + "Failed to get profile information": "Failed to get profile information", + "Do you want to remove all the items you have added to the collection?": "Do you want to remove all the items you have added to the collection?", + "Delete collection": "Delete collection" } \ No newline at end of file