diff --git a/src/webviews/QueryEditor/QueryEditor.tsx b/src/webviews/QueryEditor/QueryEditor.tsx index 76e4ddd0d..5dab2c6ad 100644 --- a/src/webviews/QueryEditor/QueryEditor.tsx +++ b/src/webviews/QueryEditor/QueryEditor.tsx @@ -17,7 +17,7 @@ const useStyles = makeStyles({ root: { display: 'grid', gridTemplateRows: '100vh', - minWidth: '900px', + // minWidth: '520px', }, }); diff --git a/src/webviews/QueryEditor/QueryPanel/QueryPanel.tsx b/src/webviews/QueryEditor/QueryPanel/QueryPanel.tsx index 728ff15ee..e34d017c0 100644 --- a/src/webviews/QueryEditor/QueryPanel/QueryPanel.tsx +++ b/src/webviews/QueryEditor/QueryPanel/QueryPanel.tsx @@ -5,7 +5,7 @@ import { makeStyles } from '@fluentui/react-components'; import { QueryMonaco } from './QueryMonaco'; -import { QueryToolbar } from './QueryToolbar'; +import { QueryToolbarOverflow } from './QueryToolbarOverflow'; const useClasses = makeStyles({ monacoContainer: { @@ -26,7 +26,7 @@ export const QueryPanel = () => { return (
- +
diff --git a/src/webviews/QueryEditor/QueryPanel/QueryToolbar.tsx b/src/webviews/QueryEditor/QueryPanel/QueryToolbar.tsx deleted file mode 100644 index f88585658..000000000 --- a/src/webviews/QueryEditor/QueryPanel/QueryToolbar.tsx +++ /dev/null @@ -1,207 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { - makeStyles, - Menu, - MenuItem, - MenuItemLink, - MenuList, - MenuPopover, - MenuTrigger, - SplitButton, - tokens, - Toolbar, - ToolbarButton, - ToolbarDivider, - type MenuButtonProps, - type ToolbarProps, -} from '@fluentui/react-components'; -import { - DatabasePlugConnectedRegular, - FolderOpenRegular, - LibraryRegular, - PlayRegular, - SaveRegular, - StopRegular, - TabDesktopMultipleRegular, -} from '@fluentui/react-icons'; -import { useQueryEditorDispatcher, useQueryEditorState } from '../state/QueryEditorContext'; - -const useClasses = makeStyles({ - iconStop: { - color: tokens.colorStatusDangerBorderActive, - }, - iconDisconnect: { - color: tokens.colorStatusDangerBorderActive, - }, -}); - -const BaseActionsSection = () => { - const classes = useClasses(); - const state = useQueryEditorState(); - const dispatcher = useQueryEditorDispatcher(); - - const truncateString = (str: string, maxLength: number) => { - if (str.length > maxLength) { - return str.slice(0, maxLength - 3) + '...'; - } - return str; - }; - - const runQuery = () => { - if (state.querySelectedValue) { - return void dispatcher.runQuery(state.querySelectedValue, { countPerPage: state.pageSize }); - } - - void dispatcher.runQuery(state.queryValue, { countPerPage: state.pageSize }); - }; - - return ( - <> - - - {(triggerProps: MenuButtonProps) => ( - } - disabled={state.isExecuting || !state.isConnected} - appearance={'primary'} - menuButton={triggerProps} - primaryActionButton={{ onClick: () => runQuery() }} - > - Run - - )} - - - {state.queryHistory.length === 0 && No history} - {state.queryHistory.length > 0 && - state.queryHistory.map((query, index) => ( - dispatcher.insertText(query)} key={index}> - {truncateString(query, 50)} - - ))} - - - } - disabled={!state.isExecuting} - onClick={() => void dispatcher.stopQuery(state.currentExecutionId)} - > - Cancel - - } onClick={() => void dispatcher.openFile()}> - Open - - } - onClick={() => void dispatcher.saveToFile(state.queryValue, 'New query', 'nosql')} - > - Save - - } - onClick={() => void dispatcher.duplicateTab(state.queryValue)} - disabled={!state.isConnected} - > - Duplicate - - - ); -}; - -const LearnSection = () => { - const state = useQueryEditorState(); - const dispatcher = useQueryEditorDispatcher(); - const samples = ['SELECT * FROM c', 'SELECT * FROM c ORDER BY c.id', 'SELECT * FROM c OFFSET 0 LIMIT 10']; - const noSqlQuickReferenceUrl = 'https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/query/'; - const noSqlLearningCenterUrl = 'https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/'; - - return ( - - - }> - Learn - - - - - - - Query examples - - - {samples.map((sample, index) => ( - dispatcher.insertText(sample)} - key={index} - > - {sample} - - ))} - - - NoSQL quick reference - Learning center - - - - ); -}; - -const ConnectedActionsSection = () => { - const classes = useClasses(); - const state = useQueryEditorState(); - const dispatcher = useQueryEditorDispatcher(); - - return ( - <> - } - onClick={() => void dispatcher.disconnectFromDatabase()} - > - Disconnect - - - - Connected to {state.dbName}/{state.collectionName} - - - ); -}; - -const DisconnectedActionsSection = () => { - const dispatcher = useQueryEditorDispatcher(); - - return ( - } - onClick={() => void dispatcher.connectToDatabase()} - > - Connect - - ); -}; - -export const QueryToolbar = (props: Partial) => { - const state = useQueryEditorState(); - - return ( - - - - - {state.isConnected ? : } - - ); -}; diff --git a/src/webviews/QueryEditor/QueryPanel/QueryToolbarOverflow.tsx b/src/webviews/QueryEditor/QueryPanel/QueryToolbarOverflow.tsx new file mode 100644 index 000000000..eca136f9b --- /dev/null +++ b/src/webviews/QueryEditor/QueryPanel/QueryToolbarOverflow.tsx @@ -0,0 +1,405 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { + Button, + makeStyles, + Menu, + type MenuButtonProps, + MenuDivider, + MenuItem, + MenuItemLink, + type MenuItemProps, + MenuList, + MenuPopover, + MenuTrigger, + Overflow, + OverflowItem, + SplitButton, + tokens, + Toolbar, + ToolbarButton, + ToolbarDivider, + type ToolbarProps, + useIsOverflowGroupVisible, + useIsOverflowItemVisible, + useOverflowMenu, +} from '@fluentui/react-components'; +import { + DatabasePlugConnectedRegular, + FolderOpenRegular, + LibraryRegular, + MoreHorizontal20Filled, + PlayRegular, + SaveRegular, + StopRegular, + TabDesktopMultipleRegular, +} from '@fluentui/react-icons'; +import { type ForwardedRef, forwardRef, type PropsWithChildren } from 'react'; +import { useQueryEditorDispatcher, useQueryEditorState } from '../state/QueryEditorContext'; + +const useClasses = makeStyles({ + iconStop: { + color: tokens.colorStatusDangerBorderActive, + }, + iconDisconnect: { + color: tokens.colorStatusDangerBorderActive, + }, +}); + +type OverflowToolbarItemProps = { + type: 'button' | 'menuitem'; +}; + +const RunQueryButton = forwardRef((props: OverflowToolbarItemProps, ref: ForwardedRef) => { + const state = useQueryEditorState(); + const dispatcher = useQueryEditorDispatcher(); + + const truncateString = (str: string, maxLength: number) => { + if (str.length > maxLength) { + return str.slice(0, maxLength - 1) + '\u2026'; + } + return str; + }; + + const runQuery = () => { + if (state.querySelectedValue) { + return void dispatcher.runQuery(state.querySelectedValue, { countPerPage: state.pageSize }); + } + + void dispatcher.runQuery(state.queryValue, { countPerPage: state.pageSize }); + }; + + return ( + + + {props.type === 'button' ? ( + (triggerProps: MenuButtonProps) => ( + } + disabled={state.isExecuting || !state.isConnected} + appearance={'primary'} + menuButton={triggerProps} + primaryActionButton={{ onClick: () => runQuery() }} + > + Run + + ) + ) : ( + } + disabled={state.isExecuting || !state.isConnected} + onClick={() => runQuery()} + > + Run + + )} + + + {state.queryHistory.length === 0 && No history} + {state.queryHistory.length > 0 && + state.queryHistory.map((query, index) => ( + dispatcher.insertText(query)} key={index}> + {truncateString(query, 50)} + + ))} + + + ); +}); + +const CancelQueryButton = forwardRef( + (props: OverflowToolbarItemProps, ref: ForwardedRef) => { + const classes = useClasses(); + const state = useQueryEditorState(); + const dispatcher = useQueryEditorDispatcher(); + const Component = props.type === 'button' ? ToolbarButton : MenuItem; + + return ( + } + disabled={!state.isExecuting} + onClick={() => void dispatcher.stopQuery(state.currentExecutionId)} + > + Cancel + + ); + }, +); + +const OpenFileButton = forwardRef( + (props: OverflowToolbarItemProps, ref: ForwardedRef) => { + const dispatcher = useQueryEditorDispatcher(); + const Component = props.type === 'button' ? ToolbarButton : MenuItem; + + return ( + } + onClick={() => void dispatcher.openFile()} + > + Open + + ); + }, +); + +const SaveToFileButton = forwardRef( + (props: OverflowToolbarItemProps, ref: ForwardedRef) => { + const state = useQueryEditorState(); + const dispatcher = useQueryEditorDispatcher(); + const Component = props.type === 'button' ? ToolbarButton : MenuItem; + + return ( + } + onClick={() => void dispatcher.saveToFile(state.queryValue, 'New query', 'nosql')} + > + Save + + ); + }, +); + +const DuplicateTabButton = forwardRef( + (props: OverflowToolbarItemProps, ref: ForwardedRef) => { + const state = useQueryEditorState(); + const dispatcher = useQueryEditorDispatcher(); + const Component = props.type === 'button' ? ToolbarButton : MenuItem; + + return ( + } + onClick={() => void dispatcher.duplicateTab(state.queryValue)} + disabled={!state.isConnected} + > + Duplicate + + ); + }, +); + +const LearnButton = forwardRef((props: OverflowToolbarItemProps, ref: ForwardedRef) => { + const state = useQueryEditorState(); + const dispatcher = useQueryEditorDispatcher(); + const samples = ['SELECT * FROM c', 'SELECT * FROM c ORDER BY c.id', 'SELECT * FROM c OFFSET 0 LIMIT 10']; + const noSqlQuickReferenceUrl = 'https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/query/'; + const noSqlLearningCenterUrl = 'https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/'; + const cosmosDBLimitations = 'https://github.com/Azure/azure-sdk-for-js/tree/main/sdk/cosmosdb/cosmos#limitations'; + + return ( + + + {props.type === 'button' ? ( + }> + Learn + + ) : ( + }> + Learn + + )} + + + + + + Query examples + + + {samples.map((sample, index) => ( + dispatcher.insertText(sample)} + key={index} + > + {sample} + + ))} + + + NoSQL quick reference + Learning center + CosmosDB SDK limitations + + + + ); +}); + +const ConnectionButton = forwardRef( + (props: OverflowToolbarItemProps, ref: ForwardedRef) => { + const classes = useClasses(); + const state = useQueryEditorState(); + const dispatcher = useQueryEditorDispatcher(); + const Component = props.type === 'button' ? ToolbarButton : MenuItem; + + if (state.isConnected) { + return ( + } + onClick={() => void dispatcher.disconnectFromDatabase()} + > + Disconnect + + ); + } + + return ( + } + onClick={() => void dispatcher.connectToDatabase()} + > + Connect + + ); + }, +); + +interface ToolbarOverflowMenuItemProps extends Omit { + id: string; +} + +const ToolbarOverflowMenuItem = (props: PropsWithChildren) => { + const { id, children } = props; + const isVisible = useIsOverflowItemVisible(id); + + if (isVisible) { + return null; + } + + return children; +}; + +type ToolbarMenuOverflowDividerProps = { + id: string; +}; + +const ToolbarMenuOverflowDivider = (props: ToolbarMenuOverflowDividerProps) => { + const isGroupVisible = useIsOverflowGroupVisible(props.id); + + if (isGroupVisible === 'visible') { + return null; + } + + return ; +}; + +const OverflowMenu = () => { + const { ref, isOverflowing } = useOverflowMenu(); + + if (!isOverflowing) { + return null; + } + + return ( + + + + ); +}; + +type ToolbarOverflowDividerProps = { + groupId: string; +}; + +const ToolbarOverflowDivider = ({ groupId }: ToolbarOverflowDividerProps) => { + const groupVisibleState = useIsOverflowGroupVisible(groupId); + + if (groupVisibleState !== 'hidden') { + return ; + } + + return null; +}; + +export const QueryToolbarOverflow = (props: Partial) => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/src/webviews/QueryEditor/ResultPanel/ResultPanel.tsx b/src/webviews/QueryEditor/ResultPanel/ResultPanel.tsx index 1d67ca0d7..b9f80fcb6 100644 --- a/src/webviews/QueryEditor/ResultPanel/ResultPanel.tsx +++ b/src/webviews/QueryEditor/ResultPanel/ResultPanel.tsx @@ -5,7 +5,7 @@ import { makeStyles, Tab, TabList, type SelectTabData, type SelectTabEvent } from '@fluentui/react-components'; import { useState, type PropsWithChildren } from 'react'; -import { ResultPanelToolbar } from './ResultPanelToolbar'; +import { ResultPanelToolbarOverflow } from './ResultPanelToolbarOverflow'; import { ResultTab } from './ResultTab'; import { ResultTabToolbar } from './ResultTabToolbar'; import { StatsTab } from './StatsTab'; @@ -22,6 +22,42 @@ const useStyles = makeStyles({ }, tabs: { flexGrow: 1, + /** + * Flex should know basis size to calculate grow and shrink. + * This value is used to calculate the initial size of the tabs. + * This is the sum of the width of both tabs: 60px + 60px + */ + flexBasis: '120px', + /** + * To prevent tabs from shrinking, we set flexBasis to 120px. + * This is the sum of the width of both tabs: 60px + 60px + */ + minWidth: '120px', + }, + tabToolbar: { + /** + * Flex should know basis size to calculate grow and shrink. + * This value is used to calculate the initial size of the toolbar. + * This is the width of the toolbar: + * 4 buttons * 32px + 36px divider + 100px for the combobox + 8px padding (272px) + */ + flexBasis: '280px', + '& [role="toolbar"]': { + justifyContent: 'flex-end', + }, + }, + panelToolbar: { + /** + * Allow the toolbar to shrink to 0 if there is not enough space. + */ + minWidth: '0', + /** + * Flex should know basis size to calculate grow and shrink. + * This value is used to calculate the initial size of the toolbar. + * This is the width of the toolbar: + * 6 buttons * 32px + 3 dividers * 24px + 100px for the combobox + 100px for status bar + 8px padding (472px) + */ + flexBasis: '480px', }, tabContainer: { padding: '0 10px', @@ -65,8 +101,12 @@ export const ResultPanel = () => { - - +
+ +
+
+ +
{selectedTab === 'result__tab' && } diff --git a/src/webviews/QueryEditor/ResultPanel/ResultPanelToolbar.tsx b/src/webviews/QueryEditor/ResultPanel/ResultPanelToolbar.tsx deleted file mode 100644 index d41bcbf8b..000000000 --- a/src/webviews/QueryEditor/ResultPanel/ResultPanelToolbar.tsx +++ /dev/null @@ -1,365 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { type OptionOnSelectData } from '@fluentui/react-combobox'; -import { - Button, - Dialog, - DialogActions, - DialogBody, - DialogContent, - DialogSurface, - DialogTitle, - DialogTrigger, - Dropdown, - Label, - Menu, - MenuItem, - MenuList, - MenuPopover, - MenuTrigger, - Option, - Toolbar, - ToolbarButton, - ToolbarDivider, - Tooltip, - useRestoreFocusTarget, -} from '@fluentui/react-components'; -import { - ArrowClockwiseFilled, - ArrowDownloadRegular, - ArrowLeftFilled, - ArrowPreviousFilled, - ArrowRightFilled, - DocumentCopyRegular, -} from '@fluentui/react-icons'; -import { useEffect, useState } from 'react'; -import { DEFAULT_PAGE_SIZE } from '../../../docdb/types/queryResult'; -import { Timer } from '../../Timer'; -import { queryMetricsToCsv, queryMetricsToJSON, queryResultToCsv, queryResultToJSON } from '../../utils'; -import { useQueryEditorDispatcher, useQueryEditorState } from '../state/QueryEditorContext'; - -export type ResultToolbarProps = { selectedTab: string }; - -export type AlertDialogProps = { - open: boolean; - setOpen: (open: boolean) => void; - doAction: () => Promise; -}; - -const ToolbarDividerTransparent = () => { - return
; -}; - -const ToolbarGroupSave = ({ selectedTab }: ResultToolbarProps) => { - const state = useQueryEditorState(); - const dispatcher = useQueryEditorDispatcher(); - - const hasSelection = state.selectedRows.length > 1; // If one document selected, it's not a selection - const tooltipClipboardContent = hasSelection - ? 'Copy selected documents to clipboard' - : 'Copy all results from the current page to clipboard'; - const tooltipExportContent = hasSelection - ? 'Export selected documents' - : 'Export all results from the current page'; - - async function onSaveToClipboardAsCSV() { - if (selectedTab === 'result__tab') { - await dispatcher.copyToClipboard( - queryResultToCsv( - state.currentQueryResult, - state.partitionKey, - hasSelection ? state.selectedRows : undefined, - ), - ); - } - - if (selectedTab === 'stats__tab') { - await dispatcher.copyToClipboard(queryMetricsToCsv(state.currentQueryResult)); - } - } - - async function onSaveToClipboardAsJSON() { - if (selectedTab === 'result__tab') { - await dispatcher.copyToClipboard( - queryResultToJSON(state.currentQueryResult, hasSelection ? state.selectedRows : undefined), - ); - } - - if (selectedTab === 'stats__tab') { - await dispatcher.copyToClipboard(queryMetricsToJSON(state.currentQueryResult)); - } - } - - async function onSaveAsCSV() { - const filename = `${state.dbName}_${state.collectionName}_${state.currentQueryResult?.activityId ?? 'query'}`; - if (selectedTab === 'result__tab') { - await dispatcher.saveToFile( - queryResultToCsv(state.currentQueryResult, state.partitionKey), - `${filename}_result`, - 'csv', - ); - } - - if (selectedTab === 'stats__tab') { - await dispatcher.saveToFile(queryMetricsToCsv(state.currentQueryResult), `${filename}_stats`, 'csv'); - } - } - - async function onSaveAsJSON() { - const filename = `${state.dbName}_${state.collectionName}_${state.currentQueryResult?.activityId ?? 'query'}`; - if (selectedTab === 'result__tab') { - await dispatcher.saveToFile(queryResultToJSON(state.currentQueryResult), `${filename}_result`, 'json'); - } - - if (selectedTab === 'stats__tab') { - await dispatcher.saveToFile(queryMetricsToJSON(state.currentQueryResult), `${filename}_stats`, 'json'); - } - } - - return ( - <> - - - - } - disabled={!state.isConnected} - /> - - - - - void onSaveToClipboardAsCSV()}>CSV - void onSaveToClipboardAsJSON()}>JSON - - - - - - - - } - disabled={!state.isConnected} - /> - - - - - void onSaveAsCSV()}>CSV - void onSaveAsJSON()}>JSON - - - - - ); -}; - -// Shows the execution time and the number of records displayed in the result panel -const ToolbarStatusBar = () => { - const state = useQueryEditorState(); - - const [time, setTime] = useState(0); - - const recordRange = state.currentExecutionId - ? state.pageSize === -1 - ? state.currentQueryResult?.documents?.length - ? `0 - ${state.currentQueryResult?.documents?.length}` - : 'All' - : `${(state.pageNumber - 1) * state.pageSize} - ${state.pageNumber * state.pageSize}` - : `0 - 0`; - - useEffect(() => { - let interval: NodeJS.Timeout | undefined = undefined; - - if (state.isExecuting) { - interval = setInterval(() => { - setTime((time) => time + 10); - }, 10); - } else { - setTime(0); - clearInterval(interval); - } - return () => clearInterval(interval); - }, [state.isExecuting]); - - return ( -
- {state.isExecuting && } - {!state.isExecuting && } -
- ); -}; - -const AlertDialog = ({ open, setOpen, doAction }: AlertDialogProps) => { - return ( - setOpen(data.open)}> - - - Attention - -
All loaded data will be lost. The query will be executed again in new session.
-
Are you sure you want to continue?
-
- - - - - - - - -
-
-
- ); -}; - -export const ResultPanelToolbar = ({ selectedTab }: ResultToolbarProps) => { - const state = useQueryEditorState(); - const dispatcher = useQueryEditorDispatcher(); - const restoreFocusTargetAttribute = useRestoreFocusTarget(); - - const hasMoreResults = state.currentQueryResult?.hasMoreResults ?? false; - const toFirstPageDisabled = - state.pageNumber === 1 || !state.isConnected || state.isExecuting || !state.currentExecutionId; - const toPrevPageDisabled = - state.pageNumber === 1 || !state.isConnected || state.isExecuting || !state.currentExecutionId; - const toNextPageDisabled = - state.pageSize === -1 || // Disable if page size is set to 'All' - !state.isConnected || - state.isExecuting || - !state.currentExecutionId || - !hasMoreResults; - - const [open, setOpen] = useState(false); - const [doAction, setDoAction] = useState<() => Promise>(() => async () => {}); - - async function nextPage() { - await dispatcher.nextPage(state.currentExecutionId); - } - - async function prevPage() { - await dispatcher.prevPage(state.currentExecutionId); - } - - async function firstPage() { - await dispatcher.firstPage(state.currentExecutionId); - } - - function reloadData() { - setOpen(true); - setDoAction(() => async () => { - setOpen(false); - await dispatcher.runQuery(state.queryHistory[state.queryHistory.length - 1], { - countPerPage: state.pageSize, - }); - }); - } - - function onOptionSelect(data: OptionOnSelectData) { - const parsedValue = parseInt(data.optionValue ?? '', 10); - const countPerPage = isFinite(parsedValue) ? parsedValue : -1; - if (!state.currentExecutionId) { - // The result is not loaded yet, just set the page size - dispatcher.setPageSize(countPerPage); - return; - } - - setOpen(true); - setDoAction(() => async () => { - setOpen(false); - dispatcher.setPageSize(countPerPage); - await dispatcher.runQuery(state.queryHistory[state.queryHistory.length - 1], { countPerPage }); - }); - } - - return ( - <> - - - - reloadData()} - aria-label="Refresh" - icon={} - {...restoreFocusTargetAttribute} - disabled={!state.isConnected || !state.currentExecutionId} - /> - - - - - - void firstPage()} - aria-label="Go to start" - icon={} - disabled={toFirstPageDisabled} - /> - - - - void prevPage()} - aria-label="Go to previous page" - icon={} - disabled={toPrevPageDisabled} - /> - - - - void nextPage()} - aria-label="Go to next page" - icon={} - disabled={toNextPageDisabled} - /> - - - - - - onOptionSelect(data)} - style={{ minWidth: '100px', maxWidth: '100px' }} - defaultValue={DEFAULT_PAGE_SIZE.toString()} - defaultSelectedOptions={[DEFAULT_PAGE_SIZE.toString()]} - {...restoreFocusTargetAttribute} - > - - - - - - - - - - - - - - - - - - ); -}; diff --git a/src/webviews/QueryEditor/ResultPanel/ResultPanelToolbarOverflow.tsx b/src/webviews/QueryEditor/ResultPanel/ResultPanelToolbarOverflow.tsx new file mode 100644 index 000000000..0172a7205 --- /dev/null +++ b/src/webviews/QueryEditor/ResultPanel/ResultPanelToolbarOverflow.tsx @@ -0,0 +1,696 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type OptionOnSelectData } from '@fluentui/react-combobox'; +import { + Button, + Dialog, + DialogActions, + DialogBody, + DialogContent, + DialogSurface, + DialogTitle, + DialogTrigger, + Dropdown, + Label, + Menu, + MenuDivider, + MenuItem, + type MenuItemProps, + MenuList, + MenuPopover, + MenuTrigger, + Option, + Overflow, + OverflowItem, + Toolbar, + ToolbarButton, + ToolbarDivider, + Tooltip, + useIsOverflowGroupVisible, + useIsOverflowItemVisible, + useOverflowMenu, + useRestoreFocusTarget, +} from '@fluentui/react-components'; +import { + ArrowClockwiseFilled, + ArrowDownloadRegular, + ArrowLeftFilled, + ArrowPreviousFilled, + ArrowRightFilled, + Checkmark16Filled, + DocumentCopyRegular, + MoreHorizontal20Filled, + NumberSymbolSquareRegular, +} from '@fluentui/react-icons'; +import { type ForwardedRef, forwardRef, type PropsWithChildren, useEffect, useState } from 'react'; +import { queryMetricsToCsv, queryMetricsToJSON, queryResultToCsv, queryResultToJSON } from '../../../utils/convertors'; +import { Timer } from '../../Timer'; +import { useQueryEditorDispatcher, useQueryEditorState } from '../state/QueryEditorContext'; + +export type OpenAlertDialogProps = { + setOpen: (open: boolean) => void; + setDoAction: (doAction: () => () => Promise) => void; +}; + +export type AlertDialogProps = { + open: boolean; + setOpen: (open: boolean) => void; + doAction: () => Promise; +}; + +const AlertDialog = ({ open, setOpen, doAction }: AlertDialogProps) => { + return ( + setOpen(data.open)}> + + + Attention + +
All loaded data will be lost. The query will be executed again in new session.
+
Are you sure you want to continue?
+
+ + + + + + + + +
+
+
+ ); +}; + +type OverflowToolbarItemProps = { + type: 'button' | 'menuitem'; +}; + +const ReloadQueryButton = forwardRef( + (props: OverflowToolbarItemProps & OpenAlertDialogProps, ref: ForwardedRef) => { + const state = useQueryEditorState(); + const dispatcher = useQueryEditorDispatcher(); + const restoreFocusTargetAttribute = useRestoreFocusTarget(); + const { setOpen, setDoAction } = props; + + const reloadData = () => { + setOpen(true); + setDoAction(() => async () => { + setOpen(false); + await dispatcher.runQuery(state.queryHistory[state.queryHistory.length - 1], { + countPerPage: state.pageSize, + }); + }); + }; + + return ( + <> + {props.type === 'button' && ( + + reloadData()} + aria-label="Refresh" + icon={} + {...restoreFocusTargetAttribute} + disabled={!state.isConnected || !state.currentExecutionId} + /> + + )} + {props.type === 'menuitem' && ( + reloadData()} + aria-label="Refresh" + icon={} + disabled={!state.isConnected || !state.currentExecutionId} + > + Reload query results + + )} + + ); + }, +); + +const GoToFirstPageButton = forwardRef((props: OverflowToolbarItemProps, ref: ForwardedRef) => { + const state = useQueryEditorState(); + const dispatcher = useQueryEditorDispatcher(); + const toFirstPageDisabled = + state.pageNumber === 1 || !state.isConnected || state.isExecuting || !state.currentExecutionId; + const firstPage = async () => { + await dispatcher.firstPage(state.currentExecutionId); + }; + + if (props.type === 'button') { + return ( + + void firstPage()} + aria-label="Go to start" + icon={} + disabled={toFirstPageDisabled} + /> + + ); + } + + return ( + void firstPage()} + aria-label="Go to start" + icon={} + disabled={toFirstPageDisabled} + > + Go to first page + + ); +}); + +const GoToPrevPageButton = forwardRef((props: OverflowToolbarItemProps, ref: ForwardedRef) => { + const state = useQueryEditorState(); + const dispatcher = useQueryEditorDispatcher(); + const toPrevPageDisabled = + state.pageNumber === 1 || !state.isConnected || state.isExecuting || !state.currentExecutionId; + const prevPage = async () => { + await dispatcher.prevPage(state.currentExecutionId); + }; + + if (props.type === 'button') { + return ( + + void prevPage()} + aria-label="Go to previous page" + icon={} + disabled={toPrevPageDisabled} + /> + + ); + } + + return ( + void prevPage()} + aria-label="Go to previous page" + icon={} + disabled={toPrevPageDisabled} + > + Go to previous page + + ); +}); + +const GoToNextPageButton = forwardRef((props: OverflowToolbarItemProps, ref: ForwardedRef) => { + const state = useQueryEditorState(); + const dispatcher = useQueryEditorDispatcher(); + const hasMoreResults = state.currentQueryResult?.hasMoreResults ?? false; + const toNextPageDisabled = + state.pageSize === -1 || // Disable if page size is set to 'All' + !state.isConnected || + state.isExecuting || + !state.currentExecutionId || + !hasMoreResults; + const nextPage = async () => { + await dispatcher.nextPage(state.currentExecutionId); + }; + + if (props.type === 'button') { + return ( + + void nextPage()} + aria-label="Go to next page" + icon={} + disabled={toNextPageDisabled} + /> + + ); + } + + return ( + void nextPage()} + aria-label="Go to next page" + icon={} + disabled={toNextPageDisabled} + > + Go to next page + + ); +}); + +const ChangePageSizeButton = forwardRef( + (props: OverflowToolbarItemProps & OpenAlertDialogProps, ref: ForwardedRef) => { + const state = useQueryEditorState(); + const dispatcher = useQueryEditorDispatcher(); + const restoreFocusTargetAttribute = useRestoreFocusTarget(); + + const pageSize = state.pageSize; + const { setOpen, setDoAction } = props; + + const changePageSize = (countPerPage: number) => { + if (!state.currentExecutionId) { + // The result is not loaded yet, just set the page size + dispatcher.setPageSize(countPerPage); + return; + } + + setOpen(true); + setDoAction(() => async () => { + setOpen(false); + dispatcher.setPageSize(countPerPage); + await dispatcher.runQuery(state.queryHistory[state.queryHistory.length - 1], { countPerPage }); + }); + }; + + const onOptionSelect = (data: OptionOnSelectData) => { + const parsedValue = parseInt(data.optionValue ?? '', 10); + const countPerPage = isFinite(parsedValue) ? parsedValue : -1; + changePageSize(countPerPage); + }; + + if (props.type === 'button') { + return ( +
+ + onOptionSelect(data)} + style={{ minWidth: '100px', maxWidth: '100px' }} + value={pageSize.toString()} + selectedOptions={[pageSize.toString()]} + {...restoreFocusTargetAttribute} + > + + + + + + + +
+ ); + } + + return ( + <> + + + }> + Change page size + + + + + changePageSize(10)} + icon={ + + } + > + 10 + + changePageSize(50)} + icon={ + + } + > + 50 + + changePageSize(100)} + icon={ + + } + > + 100 + + changePageSize(500)} + icon={ + + } + > + 500 + + changePageSize(-1)} + icon={ + + } + > + All + + + + + + ); + }, +); + +const StatusBar = forwardRef((props: OverflowToolbarItemProps, ref: ForwardedRef) => { + const state = useQueryEditorState(); + + const [time, setTime] = useState(0); + + const recordRange = state.currentExecutionId + ? state.pageSize === -1 + ? state.currentQueryResult?.documents?.length + ? `0 - ${state.currentQueryResult?.documents?.length}` + : 'All' + : `${(state.pageNumber - 1) * state.pageSize} - ${state.pageNumber * state.pageSize}` + : `0 - 0`; + + useEffect(() => { + let interval: NodeJS.Timeout | undefined = undefined; + let now: number; + + if (state.isExecuting) { + now = Date.now(); + interval = setInterval(() => { + setTime(Date.now() - now); + }, 10); + } else { + now = 0; + setTime(0); + clearInterval(interval); + } + return () => clearInterval(interval); + }, [state.isExecuting]); + + if (props.type === 'button') { + return ( +
+ {state.isExecuting && } + {!state.isExecuting && } +
+ ); + } + + return ( + + {state.isExecuting && } + {!state.isExecuting && } + + ); +}); + +const CopyToClipboardButton = forwardRef( + (props: OverflowToolbarItemProps & ResultToolbarProps, ref: ForwardedRef) => { + const state = useQueryEditorState(); + const dispatcher = useQueryEditorDispatcher(); + const { selectedTab } = props; + const hasSelection = state.selectedRows.length > 1; // If one document selected, it's not a selection + const tooltipClipboardContent = hasSelection + ? 'Copy selected documents to clipboard' + : 'Copy all results from the current page to clipboard'; + + async function onSaveToClipboardAsCSV() { + if (selectedTab === 'result__tab') { + await dispatcher.copyToClipboard( + queryResultToCsv( + state.currentQueryResult, + state.partitionKey, + hasSelection ? state.selectedRows : undefined, + ), + ); + } + + if (selectedTab === 'stats__tab') { + await dispatcher.copyToClipboard(queryMetricsToCsv(state.currentQueryResult)); + } + } + + async function onSaveToClipboardAsJSON() { + if (selectedTab === 'result__tab') { + await dispatcher.copyToClipboard( + queryResultToJSON(state.currentQueryResult, hasSelection ? state.selectedRows : undefined), + ); + } + + if (selectedTab === 'stats__tab') { + await dispatcher.copyToClipboard(queryMetricsToJSON(state.currentQueryResult)); + } + } + + return ( + + + + {props.type === 'button' ? ( + } + disabled={!state.isConnected} + /> + ) : ( + } + disabled={!state.isConnected} + > + Copy to clipboard + + )} + + + + + void onSaveToClipboardAsCSV()}>CSV + void onSaveToClipboardAsJSON()}>JSON + + + + ); + }, +); + +const ExportButton = forwardRef( + (props: OverflowToolbarItemProps & ResultToolbarProps, ref: ForwardedRef) => { + const state = useQueryEditorState(); + const dispatcher = useQueryEditorDispatcher(); + const { selectedTab } = props; + const hasSelection = state.selectedRows.length > 1; // If one document selected, it's not a selection + const tooltipExportContent = hasSelection + ? 'Export selected documents' + : 'Export all results from the current page'; + + async function onSaveAsCSV() { + const filename = `${state.dbName}_${state.collectionName}_${state.currentQueryResult?.activityId ?? 'query'}`; + if (selectedTab === 'result__tab') { + await dispatcher.saveToFile( + queryResultToCsv(state.currentQueryResult, state.partitionKey), + `${filename}_result`, + 'csv', + ); + } + + if (selectedTab === 'stats__tab') { + await dispatcher.saveToFile(queryMetricsToCsv(state.currentQueryResult), `${filename}_stats`, 'csv'); + } + } + + async function onSaveAsJSON() { + const filename = `${state.dbName}_${state.collectionName}_${state.currentQueryResult?.activityId ?? 'query'}`; + if (selectedTab === 'result__tab') { + await dispatcher.saveToFile(queryResultToJSON(state.currentQueryResult), `${filename}_result`, 'json'); + } + + if (selectedTab === 'stats__tab') { + await dispatcher.saveToFile(queryMetricsToJSON(state.currentQueryResult), `${filename}_stats`, 'json'); + } + } + + return ( + + + + {props.type === 'button' ? ( + } + disabled={!state.isConnected} + /> + ) : ( + } disabled={!state.isConnected}> + Export + + )} + + + + + void onSaveAsCSV()}>CSV + void onSaveAsJSON()}>JSON + + + + ); + }, +); + +interface ToolbarOverflowMenuItemProps extends Omit { + id: string; +} + +const ToolbarOverflowMenuItem = (props: PropsWithChildren) => { + const { id, children } = props; + const isVisible = useIsOverflowItemVisible(id); + + if (isVisible) { + return null; + } + + return children; +}; + +type ToolbarMenuOverflowDividerProps = { + id: string; +}; + +const ToolbarMenuOverflowDivider = (props: ToolbarMenuOverflowDividerProps) => { + const isGroupVisible = useIsOverflowGroupVisible(props.id); + + if (isGroupVisible === 'visible') { + return null; + } + + return ; +}; + +const OverflowMenu = ({ selectedTab }: ResultToolbarProps & OpenAlertDialogProps) => { + const { ref, isOverflowing } = useOverflowMenu(); + const [open, setOpen] = useState(false); + const [doAction, setDoAction] = useState<() => Promise>(() => async () => {}); + + if (!isOverflowing) { + return null; + } + + return ( + <> + + + + + + ); +}; + +type ToolbarOverflowDividerProps = { + groupId: string; +}; + +const ToolbarOverflowDivider = ({ groupId }: ToolbarOverflowDividerProps) => { + const groupVisibleState = useIsOverflowGroupVisible(groupId); + + if (groupVisibleState !== 'hidden') { + return ; + } + + return null; +}; + +export type ResultToolbarProps = { selectedTab: string }; + +export const ResultPanelToolbarOverflow = ({ selectedTab }: ResultToolbarProps) => { + const [open, setOpen] = useState(false); + const [doAction, setDoAction] = useState<() => Promise>(() => async () => {}); + + return ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/src/webviews/QueryEditor/ResultPanel/ResultTabToolbar.tsx b/src/webviews/QueryEditor/ResultPanel/ResultTabToolbar.tsx index 7b315d61f..f80a35306 100644 --- a/src/webviews/QueryEditor/ResultPanel/ResultTabToolbar.tsx +++ b/src/webviews/QueryEditor/ResultPanel/ResultTabToolbar.tsx @@ -23,11 +23,13 @@ export const ResultTabToolbar = ({ selectedTab }: ResultToolbarProps) => { const dispatcher = useQueryEditorDispatcher(); const restoreFocusTargetAttribute = useRestoreFocusTarget(); - const isEditMode = useMemo( - () => isSelectStar(state.currentQueryResult?.query ?? ''), - [state.currentQueryResult], - ); + const isEditMode = useMemo(() => { + return state.isExecuting + ? isSelectStar(state.querySelectedValue || state.queryValue || '') + : isSelectStar(state.currentQueryResult?.query ?? ''); + }, [state.currentQueryResult, state.isExecuting]); + const visibility = state.isExecuting ? 'hidden' : 'visible'; const hasSelectedRows = state.selectedRows.length > 0; const getSelectedDocuments = () => { @@ -56,6 +58,7 @@ export const ResultTabToolbar = ({ selectedTab }: ResultToolbarProps) => { aria-label={'Add new document'} icon={} onClick={() => void dispatcher.openDocument('add')} + style={{ visibility }} /> @@ -64,6 +67,7 @@ export const ResultTabToolbar = ({ selectedTab }: ResultToolbarProps) => { icon={} onClick={() => void dispatcher.openDocuments('view', getSelectedDocuments())} disabled={!hasSelectedRows} + style={{ visibility }} /> @@ -72,6 +76,7 @@ export const ResultTabToolbar = ({ selectedTab }: ResultToolbarProps) => { icon={} onClick={() => void dispatcher.openDocuments('edit', getSelectedDocuments())} disabled={!hasSelectedRows} + style={{ visibility }} /> @@ -80,6 +85,7 @@ export const ResultTabToolbar = ({ selectedTab }: ResultToolbarProps) => { icon={} onClick={() => void dispatcher.deleteDocuments(getSelectedDocuments())} disabled={!hasSelectedRows} + style={{ visibility }} /> diff --git a/src/webviews/QueryEditor/ResultPanel/ResultTabViewTree.tsx b/src/webviews/QueryEditor/ResultPanel/ResultTabViewTree.tsx index d428bebfd..d283245a5 100644 --- a/src/webviews/QueryEditor/ResultPanel/ResultTabViewTree.tsx +++ b/src/webviews/QueryEditor/ResultPanel/ResultTabViewTree.tsx @@ -62,12 +62,6 @@ export const ResultTabViewTree = ({ data }: ResultTabViewTreeProps) => { }; return ( - console.log('Tree View created')} - /> + ); }; diff --git a/src/webviews/Timer.tsx b/src/webviews/Timer.tsx index af86099bf..fff4b88ff 100644 --- a/src/webviews/Timer.tsx +++ b/src/webviews/Timer.tsx @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ export type TimerProps = { - time: number; + time: number; // time in milliseconds }; export const Timer = (props: TimerProps) => { @@ -12,7 +12,7 @@ export const Timer = (props: TimerProps) => {
{('0' + Math.floor((props.time / 60000) % 60)).slice(-2)}: {('0' + Math.floor((props.time / 1000) % 60)).slice(-2)}. - {('0' + ((props.time / 10) % 100)).slice(-2)} + {('0' + Math.floor((props.time / 10) % 100)).slice(-2)}
); };