diff --git a/src/app/utils/dynamic-sort.spec.ts b/src/app/utils/dynamic-sort.spec.ts index 630a49f78..7572e9065 100644 --- a/src/app/utils/dynamic-sort.spec.ts +++ b/src/app/utils/dynamic-sort.spec.ts @@ -1,6 +1,9 @@ -import { dynamicSort } from './dynamic-sort'; import { SortOrder } from '../../types/enums'; - +import { dynamicSort } from './dynamic-sort'; +interface INameAge { + name: string + age: number +} describe('Dynamic Sort', () => { it('should sort an array of objects in ascending order', () => { @@ -16,7 +19,7 @@ describe('Dynamic Sort', () => { { name: 'Lucy', age: 18 } ]; - const sortedArray = arrayToSort.sort(dynamicSort('name', SortOrder.ASC)); + const sortedArray = arrayToSort.sort(dynamicSort('name', SortOrder.ASC)); expect(expected).toEqual(sortedArray); }); @@ -33,30 +36,7 @@ describe('Dynamic Sort', () => { { name: 'Ann', age: 14 } ]; - const sortedArray = arrayToSort.sort(dynamicSort('name', SortOrder.DESC)); + const sortedArray = arrayToSort.sort(dynamicSort('name', SortOrder.DESC)); expect(expected).toEqual(sortedArray); }); - - it('should return unsorted object array when property does not exist', () => { - const arrayToSort = [ - { name: 'Ann', age: 14 }, - { name: 'Lucy', age: 18 }, - { name: 'Diana', age: 11 } - ]; - - const sortedArray = arrayToSort.sort(dynamicSort('gender', SortOrder.DESC)); - expect(arrayToSort).toEqual(sortedArray); - }); - - it('should return unsorted object array when property is empty', () => { - const arrayToSort = [ - { name: 'Ann', age: 14 }, - { name: 'Lucy', age: 18 }, - { name: 'Diana', age: 11 } - ]; - - const sortedArray = arrayToSort.sort(dynamicSort('', SortOrder.DESC)); - expect(arrayToSort).toEqual(sortedArray); - }); - }); diff --git a/src/app/utils/dynamic-sort.ts b/src/app/utils/dynamic-sort.ts index a66cacae5..299441d29 100644 --- a/src/app/utils/dynamic-sort.ts +++ b/src/app/utils/dynamic-sort.ts @@ -5,18 +5,18 @@ import { SortOrder } from '../../types/enums'; * @param {SortOrder} sortOrder the direction to follow Ascending / Descending * You pass this helper to the array sort function */ -export function dynamicSort(property: string | null, sortOrder: SortOrder) { +export function dynamicSort(property: keyof T, sortOrder: SortOrder) { let order = 1; if (sortOrder === SortOrder.DESC) { order = -1; } if (property) { - return (first: any, second: any) => { + return (first: T, second: T) => { const result = (first[property] < second[property]) ? -1 : (first[property] > second[property]) ? 1 : 0; return result * order; }; } - return (first: any, second: any) => { + return (first: T, second: T) => { const result = (first < second) ? -1 : (first > second) ? 1 : 0; return result * order; }; diff --git a/src/app/views/query-runner/request/permissions/Permissions.Full.tsx b/src/app/views/query-runner/request/permissions/Permissions.Full.tsx index 3a2d6204c..6c0c85ca3 100644 --- a/src/app/views/query-runner/request/permissions/Permissions.Full.tsx +++ b/src/app/views/query-runner/request/permissions/Permissions.Full.tsx @@ -59,7 +59,7 @@ const FullPermissions: React.FC> = (): JSX.Element => { const sortPermissions = (permissionsToSort: IPermission[]): IPermission[] => { try { - return [...permissionsToSort].sort(dynamicSort('value', SortOrder.ASC)); + return [...permissionsToSort].sort(dynamicSort('value', SortOrder.ASC)); } catch (error) { // ignore } diff --git a/src/app/views/sidebar/SidebarV9.tsx b/src/app/views/sidebar/SidebarV9.tsx index d031a7676..a67e41b19 100644 --- a/src/app/views/sidebar/SidebarV9.tsx +++ b/src/app/views/sidebar/SidebarV9.tsx @@ -11,6 +11,7 @@ import { IDimensions } from '../../../types/dimensions'; import { setDimensions } from '../../services/slices/dimensions.slice'; import { translateMessage } from '../../utils/translate-messages'; import { History } from './history'; +import { HistoryV9 } from './history/HistoryV9'; import ResourceExplorer from './resource-explorer'; import { SampleQueriesV9 } from './sample-queries/SampleQueriesV9'; @@ -62,7 +63,7 @@ const SidebarV9 = ()=>{ const tabItems: Record = { 'sample-queries': , 'resources': , - 'history': + 'history': } // TODO: Resizing is not showing/ hiding sidebar. Should be checked when // updated to v9 @@ -119,3 +120,4 @@ const getDimensions = (show: boolean, dimensions: IDimensions)=>{ } export { SidebarV9 }; + diff --git a/src/app/views/sidebar/history/History.tsx b/src/app/views/sidebar/history/History.tsx index a89b03e76..bf7fe765d 100644 --- a/src/app/views/sidebar/history/History.tsx +++ b/src/app/views/sidebar/history/History.tsx @@ -57,7 +57,7 @@ const yesterday = formatDate(yesterdaysDate); yesterdaysDate.setDate(yesterdaysDate.getDate() - 1); const sortItems = (content: IHistoryItem[]) => { - content.sort(dynamicSort('createdAt', SortOrder.DESC)); + content.sort(dynamicSort('createdAt', SortOrder.DESC)); content.forEach((value: any, index: number) => { value.index = index; }); diff --git a/src/app/views/sidebar/history/HistoryV9.tsx b/src/app/views/sidebar/history/HistoryV9.tsx new file mode 100644 index 000000000..c1b559d4c --- /dev/null +++ b/src/app/views/sidebar/history/HistoryV9.tsx @@ -0,0 +1,494 @@ +import { + AriaLiveAnnouncer, + Badge, + Button, + Dialog, + DialogActions, + DialogBody, + DialogContent, + DialogSurface, + DialogTitle, + DialogTrigger, + Divider, + FlatTree, + FlatTreeItem, + InputOnChangeData, + Label, + makeStyles, + Menu, + MenuGroup, + MenuGroupHeader, + MenuItem, + MenuList, + MenuPopover, + MenuTrigger, + MessageBar, + MessageBarBody, + SearchBox, + SearchBoxChangeEvent, + Text, + Tooltip, + TreeItemLayout, + TreeItemValue, + TreeOpenChangeData, + TreeOpenChangeEvent +} from '@fluentui/react-components'; +import { IGroup } from '@fluentui/react/lib/DetailsList'; + +import { MessageBarType } from '@fluentui/react'; +import { + ArrowDownloadRegular, + ArrowRepeatAllRegular, + DeleteRegular, + EyeRegular, + MoreHorizontalRegular +} from '@fluentui/react-icons'; +import React, { useEffect, useRef, useState } from 'react'; +import { historyCache } from '../../../../modules/cache/history-utils'; +import { useAppDispatch, useAppSelector } from '../../../../store'; +import { componentNames, eventTypes, telemetry } from '../../../../telemetry'; +import { SortOrder } from '../../../../types/enums'; +import { Entry } from '../../../../types/har'; +import { IHistoryItem } from '../../../../types/history'; +import { IQuery } from '../../../../types/query-runner'; +import { GRAPH_URL } from '../../../services/graph-constants'; +import { runQuery, setQueryResponse } from '../../../services/slices/graph-response.slice'; +import { removeAllHistoryItems, removeHistoryItem } from '../../../services/slices/history.slice'; +import { setQueryResponseStatus } from '../../../services/slices/query-status.slice'; +import { setSampleQuery } from '../../../services/slices/sample-query.slice'; +import { dynamicSort } from '../../../utils/dynamic-sort'; +import { generateGroupsFromList } from '../../../utils/generate-groups'; +import { sanitizeQueryUrl } from '../../../utils/query-url-sanitization'; +import { parseSampleUrl } from '../../../utils/sample-url-generation'; +import { translateMessage } from '../../../utils/translate-messages'; +import { createHarEntry, exportQuery, generateHar } from './har-utils'; + +type BadgeColors = 'brand' | 'danger' | 'important' | 'informative' | 'severe' | 'subtle' | 'success' | 'warning'; +const formatDate = (date: Date) => { + const year = date.getFullYear(); + const month = date.getMonth() + 1; + const monthStr = (month < 10 ? '0' : '') + month; + const day = date.getDate(); + const dayStr = (day < 10 ? '0' : '') + day; + return `${year}-${monthStr}-${dayStr}`; +}; + + +const today = formatDate(new Date()); +const yesterdaysDate = new Date(); +const yesterday = formatDate(yesterdaysDate); +yesterdaysDate.setDate(yesterdaysDate.getDate() - 1); + +const useStyles = makeStyles({ + container: { + display: 'flex', + flexDirection: 'column', + gap: '4px' + }, + searchBox: { + width: '100%', + maxWidth: '100%' + }, + titleAside: { + display: 'flex', + gap: '2px' + } +}) + +const handleDownloadHistoryGroup = ( + event: React.MouseEvent, value: string, + historyItems: IHistoryItem[])=>{ + event.preventDefault() + const itemsToExport = historyItems.filter((query: IHistoryItem) => getCategory(query) === value); + const entries: Entry[] = []; + + itemsToExport.forEach((query: IHistoryItem) => { + const harPayload = createHarEntry(query); + entries.push(harPayload); + }); + + const generatedHarData = generateHar(entries); + const { origin } = new URL(itemsToExport[0].url); + const exportTitle = `${origin}/${value.toLowerCase()}/${itemsToExport[0].createdAt.slice(0, 10)}/`; + + exportQuery(generatedHarData, exportTitle); +} + +interface AsideGroupIconsProps { + groupName: string + historyItems: IHistoryItem[] +} + +const AsideGroupIcons = (props: AsideGroupIconsProps)=>{ + const dispatch = useAppDispatch() + const {groupName, historyItems} = props + const [open, setOpen] = useState(false); + const styles = useStyles() + + const handleDeleteHistoryGroup = (event: React.MouseEvent)=>{ + event.preventDefault() + const itemsToDelete = historyItems.filter((query: IHistoryItem) => getCategory(query) === groupName); + const listOfKeys: string[] = []; + itemsToDelete.forEach(historyItem => { + listOfKeys.push(historyItem.createdAt); + }); + historyCache.bulkRemoveHistoryData(listOfKeys) + dispatch(removeAllHistoryItems(listOfKeys)); + setOpen(false) + } + + return
+ + + + setOpen(data.open)}> + + + + + + + + {translateMessage('Delete requests')} "{groupName}" + {translateMessage('Are you sure you want to delete these requests?')} + + + + + + + + + + + +
+} + +interface HistoryProps { + history: IHistoryItem[] + groups: IGroup[] + searchValue: string +} + +const History = (props: HistoryProps)=>{ + const dispatch = useAppDispatch() + const {groups, history} = props + + const openHistoryItems = new Set() + 'Today'.split('').forEach(ch=> openHistoryItems.add(ch)) + openHistoryItems.add('Today') + + const [openItems, setOpenItems] = useState>( + () => openHistoryItems + ); + const handleOpenChange = (_: TreeOpenChangeEvent, data: TreeOpenChangeData) => { + setOpenItems(data.openItems); + }; + + const handleViewQuery = (query: IHistoryItem)=>{ + const { sampleUrl, queryVersion } = parseSampleUrl(query.url); + const sampleQuery: IQuery = { + sampleUrl, + selectedVerb: query.method, + sampleBody: query.body, + sampleHeaders: query.headers, + selectedVersion: queryVersion + }; + const { duration, status, statusText } = query; + dispatch(setSampleQuery(sampleQuery)); + dispatch(setQueryResponse({ + body: query.result, + headers: query.responseHeaders + })) + // TODO: change the message bar type to v9 types + dispatch(setQueryResponseStatus({ + duration, + messageType: + status < 300 ? MessageBarType.success : MessageBarType.error, + ok: status < 300, + status, + statusText + })); + trackHistoryItemEvent( + eventTypes.LISTITEM_CLICK_EVENT, + componentNames.HISTORY_LIST_ITEM, + query + ); + } + + + return( + + {groups.map((group, pos) => { + const {name, ariaLabel, count, key, startIndex} = group + const historyLeafs = history.slice(startIndex, startIndex + count) + return ( + + + }> + {name}{' '} + + {count} + + + + {openItems.has(name) && + historyLeafs.map((h: IHistoryItem) => ( + q.createdAt === h.createdAt) + 1} + > + handleViewQuery(h)} + iconBefore={} + aside={}> + + {h.url.replace(GRAPH_URL, '')} + + + + ))} + + ) + })} + + ) +} + +const HistoryStatusCodes = ({status}:{status: number})=>{ + const getBadgeColor = (): BadgeColors =>{ + if(status >= 100 && status < 199) {return 'informative'} + if(status >= 200 && status < 299) {return 'success'} + if(status >= 300 && status < 399) {return 'important'} + if(status >= 400 && status < 599) {return 'danger'} + return 'success' + } + return {status} +} + +const trackHistoryItemEvent = (eventName: string, componentName: string, query: IHistoryItem) => { + const sanitizedUrl = sanitizeQueryUrl(query.url); + telemetry.trackEvent( + eventName, + { + ComponentName: componentName, + ItemIndex: query.index, + QuerySignature: `${query.method} ${sanitizedUrl}` + }); +} + +interface HistoryItemActionMenuProps { + item: IHistoryItem +} + + +const HistoryItemActionMenu = (props: HistoryItemActionMenuProps)=>{ + const dispatch = useAppDispatch() + const {item} = props + + const handleViewQuery = (query: IHistoryItem)=>{ + const { sampleUrl, queryVersion } = parseSampleUrl(query.url); + const sampleQuery: IQuery = { + sampleUrl, + selectedVerb: query.method, + sampleBody: query.body, + sampleHeaders: query.headers, + selectedVersion: queryVersion + }; + const { duration, status, statusText } = query; + dispatch(setSampleQuery(sampleQuery)); + dispatch(setQueryResponse({ + body: query.result, + headers: query.responseHeaders + })) + // TODO: change the message bar type to v9 types + dispatch(setQueryResponseStatus({ + duration, + messageType: + status < 300 ? MessageBarType.success : MessageBarType.error, + ok: status < 300, + status, + statusText + })); + trackHistoryItemEvent( + eventTypes.LISTITEM_CLICK_EVENT, + componentNames.HISTORY_LIST_ITEM, + query + ); + } + + const handleRunQuery = (query: IHistoryItem) =>{ + const { sampleUrl, queryVersion } = parseSampleUrl(query.url); + const sampleQuery: IQuery = { + sampleUrl, + selectedVerb: query.method, + sampleBody: query.body, + sampleHeaders: query.headers, + selectedVersion: queryVersion + }; + + if (sampleQuery.selectedVerb === 'GET') { + sampleQuery.sampleBody = JSON.parse('{}') as string; + } + dispatch(setSampleQuery(sampleQuery)); + dispatch(runQuery(sampleQuery)); + + trackHistoryItemEvent( + eventTypes.BUTTON_CLICK_EVENT, + componentNames.RUN_HISTORY_ITEM_BUTTON, + query + ); + } + + const handleExportQuery = (query: IHistoryItem)=>{ + const harPayload = createHarEntry(query); + const generatedHarData = generateHar([harPayload]); + exportQuery(generatedHarData, `${query.url}/`); + trackHistoryItemEvent( + eventTypes.BUTTON_CLICK_EVENT, + componentNames.EXPORT_HISTORY_ITEM_BUTTON, + query + ); + } + + const handleDeleteQuery = (query:IHistoryItem)=>{ + delete query.category; + historyCache.removeHistoryData(query); + dispatch(removeHistoryItem(query)); + trackHistoryItemEvent( + eventTypes.BUTTON_CLICK_EVENT, + componentNames.DELETE_HISTORY_ITEM_BUTTON, + query + ); + } + return + + + + + + + + {translateMessage('actions')} + } onClick={()=>handleViewQuery(item)}>{translateMessage('view')} + } + onClick={()=>handleRunQuery(item)}>{translateMessage('Run Query')} + } + onClick={()=>handleExportQuery(item)}>{translateMessage('Export')} + } + onClick={()=>handleDeleteQuery(item)}>{translateMessage('Delete')} + + + + +} + +const sortItems = (content: IHistoryItem[]) => { + content.sort(dynamicSort('createdAt', SortOrder.DESC)); + content.forEach((value: IHistoryItem, index: number) => { + value.index = index; + }); + return content; +} + +const getCategory = (historyItem: IHistoryItem) => { + const olderText = translateMessage('older'); + const todayText = translateMessage('today'); + const yesterdayText = translateMessage('yesterday'); + let category = olderText; + if (historyItem.createdAt.includes(today)) { + category = todayText; + } else if (historyItem.createdAt.includes(yesterday)) { + category = yesterdayText; + } + return category; +} + +const getItems = (content: IHistoryItem[]): IHistoryItem[] => { + const list: IHistoryItem[] = []; + content.forEach((historyItem) => { + list.push({ + ...historyItem, + category: getCategory(historyItem) + }); + }); + return sortItems(list); +} + + +export const HistoryV9 = ()=>{ + const styles = useStyles() + const history = useAppSelector(state=> state.history) + const [historyItems, setHistoryItems] = useState(history) + const [searchValue, setSearchValue] = useState(''); + const [searchStarted, setSearchStarted] = useState(false); + const shouldGenerateGroups = useRef(true) + const [groups, setGroups] = useState([]); + + const items = getItems(historyItems); + + useEffect(() => { + if (shouldGenerateGroups.current) { + setGroups(generateGroupsFromList(items, 'category')); + if (groups && groups.length > 0) { + shouldGenerateGroups.current = false; + } + } + }, [historyItems, searchStarted, shouldGenerateGroups]) + + useEffect(()=>{ + setHistoryItems(history) + }, [history]) + + const handleSearchValueChanged = (_: SearchBoxChangeEvent, data: InputOnChangeData)=>{ + shouldGenerateGroups.current = true + setSearchStarted(true) + let content = [...history] + const value = data.value.trim() ?? ''; + if(value) { + setSearchValue(value) + content = history.filter((item:IHistoryItem)=>{ + const name = item.url.toLowerCase() + return name.includes(value) + }) + } + setHistoryItems(content) + } + return
+ + + + + + {translateMessage('Your history includes queries made in the last 30 days')} + + + {historyItems.length === 0 && } + {`${historyItems.length} search results available.`} + +
+} \ No newline at end of file