diff --git a/package.json b/package.json index ba24e3bf6b..3a3efe91d8 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "lint:packages": "eslint \"packages/*/src/**/*.{ts,tsx,js,jsx}\"", "preview": "lerna run --scope=@deephaven/{code-studio,embed-widget} preview --stream", "preview:app": "lerna run --scope=@deephaven/code-studio preview --stream", + "preview:embed-widget": "lerna run --scope=@deephaven/embed-widget preview --stream", "prestart": "npm run build:necessary", "start": "run-p watch:types start:*", "start:app": "lerna run start --scope=@deephaven/code-studio --stream", diff --git a/packages/app-utils/src/components/AppBootstrap.tsx b/packages/app-utils/src/components/AppBootstrap.tsx index b5644baa45..a803f615ab 100644 --- a/packages/app-utils/src/components/AppBootstrap.tsx +++ b/packages/app-utils/src/components/AppBootstrap.tsx @@ -5,11 +5,12 @@ import '@deephaven/components/scss/BaseStyleSheet.scss'; import { ClientBootstrap } from '@deephaven/jsapi-bootstrap'; import { useBroadcastLoginListener } from '@deephaven/jsapi-components'; import { type Plugin } from '@deephaven/plugin'; +import { ContextActions, ContextMenuRoot } from '@deephaven/components'; import FontBootstrap from './FontBootstrap'; import PluginsBootstrap from './PluginsBootstrap'; import AuthBootstrap from './AuthBootstrap'; import ConnectionBootstrap from './ConnectionBootstrap'; -import { getConnectOptions } from '../utils'; +import { getConnectOptions, createExportLogsContextAction } from '../utils'; import FontsLoaded from './FontsLoaded'; import UserBootstrap from './UserBootstrap'; import ServerConfigBootstrap from './ServerConfigBootstrap'; @@ -19,6 +20,9 @@ export type AppBootstrapProps = { /** URL of the server. */ serverUrl: string; + /** Properties included in support logs. */ + logMetadata?: Record; + /** URL of the plugins to load. */ pluginsUrl: string; @@ -43,6 +47,7 @@ export function AppBootstrap({ pluginsUrl, getCorePlugins, serverUrl, + logMetadata, children, }: AppBootstrapProps): JSX.Element { const clientOptions = useMemo(() => getConnectOptions(), []); @@ -56,6 +61,12 @@ export function AppBootstrap({ }); }, []); useBroadcastLoginListener(onLogin, onLogout); + + const contextActions = useMemo( + () => [createExportLogsContextAction(logMetadata, true)], + [logMetadata] + ); + return ( @@ -78,10 +89,12 @@ export function AppBootstrap({ + + ); } diff --git a/packages/app-utils/src/utils/createExportLogsContextAction.ts b/packages/app-utils/src/utils/createExportLogsContextAction.ts new file mode 100644 index 0000000000..e2d1a84319 --- /dev/null +++ b/packages/app-utils/src/utils/createExportLogsContextAction.ts @@ -0,0 +1,25 @@ +import { type ContextAction, GLOBAL_SHORTCUTS } from '@deephaven/components'; +import { exportLogs, logHistory } from '@deephaven/log'; +import { store } from '@deephaven/redux'; + +export function createExportLogsContextAction( + metadata?: Record, + isGlobal = false +): ContextAction { + return { + action: () => { + exportLogs( + logHistory, + { + ...metadata, + userAgent: navigator.userAgent, + }, + store.getState() + ); + }, + shortcut: GLOBAL_SHORTCUTS.EXPORT_LOGS, + isGlobal, + }; +} + +export default createExportLogsContextAction; diff --git a/packages/app-utils/src/utils/index.ts b/packages/app-utils/src/utils/index.ts index 0cbc0d265c..2b5f0ab6d3 100644 --- a/packages/app-utils/src/utils/index.ts +++ b/packages/app-utils/src/utils/index.ts @@ -1 +1,2 @@ export * from './ConnectUtils'; +export * from './createExportLogsContextAction'; diff --git a/packages/code-studio/src/index.tsx b/packages/code-studio/src/index.tsx index 8cfe0063c9..d623865a0f 100644 --- a/packages/code-studio/src/index.tsx +++ b/packages/code-studio/src/index.tsx @@ -34,6 +34,10 @@ const pluginsURL = new URL( document.baseURI ); +const logMetadata: Record = { + uiVersion: import.meta.env.npm_package_version, +}; + // Lazy load the configs because it breaks initial page loads otherwise async function getCorePlugins() { const dashboardCorePlugins = await import( @@ -69,6 +73,7 @@ ReactDOM.render( getCorePlugins={getCorePlugins} serverUrl={apiURL.origin} pluginsUrl={pluginsURL.href} + logMetadata={logMetadata} > diff --git a/packages/code-studio/src/main/App.tsx b/packages/code-studio/src/main/App.tsx index 0663ddd8f0..1c66823d89 100644 --- a/packages/code-studio/src/main/App.tsx +++ b/packages/code-studio/src/main/App.tsx @@ -1,12 +1,11 @@ import React, { type ReactElement } from 'react'; -import { ContextMenuRoot, ToastContainer } from '@deephaven/components'; +import { ToastContainer } from '@deephaven/components'; import AppMainContainer from './AppMainContainer'; function App(): ReactElement { return (
-
); diff --git a/packages/code-studio/src/main/AppMainContainer.test.tsx b/packages/code-studio/src/main/AppMainContainer.test.tsx index b48dd74e97..da0b8e6dea 100644 --- a/packages/code-studio/src/main/AppMainContainer.test.tsx +++ b/packages/code-studio/src/main/AppMainContainer.test.tsx @@ -69,7 +69,7 @@ function renderAppMainContainer({ setActiveTool = jest.fn(), setDashboardIsolatedLinkerPanelId = jest.fn(), client = new (dh as any).Client({}), - serverConfigValues = {}, + serverConfigValues = new Map(), dashboardOpenedPanelMaps = {}, connection = makeConnection(), session = makeSession(), diff --git a/packages/code-studio/src/main/AppMainContainer.tsx b/packages/code-studio/src/main/AppMainContainer.tsx index 02718496b2..ab949bcc8e 100644 --- a/packages/code-studio/src/main/AppMainContainer.tsx +++ b/packages/code-studio/src/main/AppMainContainer.tsx @@ -85,6 +85,7 @@ import { AppDashboards, type LayoutStorage, UserLayoutUtils, + createExportLogsContextAction, } from '@deephaven/app-utils'; import JSZip from 'jszip'; import SettingsMenu from '../settings/SettingsMenu'; @@ -92,7 +93,10 @@ import AppControlsMenu from './AppControlsMenu'; import { getLayoutStorage, getServerConfigValues } from '../redux'; import './AppMainContainer.scss'; import WidgetList, { type WindowMouseEvent } from './WidgetList'; -import { getFormattedVersionInfo } from '../settings/SettingsUtils'; +import { + getFormattedPluginInfo, + getFormattedVersionInfo, +} from '../settings/SettingsUtils'; import EmptyDashboard from './EmptyDashboard'; const log = Log.module('AppMainContainer'); @@ -186,7 +190,7 @@ export class AppMainContainer extends Component< this.importElement = React.createRef(); - const { allDashboardData } = this.props; + const { allDashboardData, serverConfigValues, plugins } = this.props; this.dashboardLayouts = new Map(); this.createDashboardListenerRemovers = new Map(); @@ -194,10 +198,18 @@ export class AppMainContainer extends Component< this.state = { contextActions: [ + createExportLogsContextAction( + { + uiVersion: import.meta.env.npm_package_version, + userAgent: navigator.userAgent, + ...Object.fromEntries(serverConfigValues), + pluginInfo: getFormattedPluginInfo(plugins), + }, + false // Not global to prevent conflict with export logs action with same shortcut in AppBootstrap.tsx + ), { action: () => { // Copies the version info to the clipboard for easy pasting into a ticket - const { serverConfigValues } = this.props; const versionInfo = getFormattedVersionInfo(serverConfigValues); const versionInfoText = Object.entries(versionInfo) .map(([key, value]) => `${key}: ${value}`) diff --git a/packages/components/src/shortcuts/GlobalShortcuts.ts b/packages/components/src/shortcuts/GlobalShortcuts.ts index 3f7f0b14d6..21d0ea7c76 100644 --- a/packages/components/src/shortcuts/GlobalShortcuts.ts +++ b/packages/components/src/shortcuts/GlobalShortcuts.ts @@ -58,6 +58,13 @@ const GLOBAL_SHORTCUTS = { macShortcut: [MODIFIER.CMD, MODIFIER.SHIFT, KEY.I], isEditable: true, }), + EXPORT_LOGS: ShortcutRegistry.createAndAdd({ + id: 'GLOBAL.EXPORT_LOGS', + name: 'Export Logs', + shortcut: [MODIFIER.CTRL, MODIFIER.ALT, MODIFIER.SHIFT, KEY.L], + macShortcut: [MODIFIER.CMD, MODIFIER.OPTION, MODIFIER.SHIFT, KEY.L], + isEditable: true, + }), NEXT: ShortcutRegistry.createAndAdd({ id: 'GLOBAL.NEXT', name: 'Next', diff --git a/packages/embed-widget/src/App.tsx b/packages/embed-widget/src/App.tsx index 4a469a1a6d..aad12d85d3 100644 --- a/packages/embed-widget/src/App.tsx +++ b/packages/embed-widget/src/App.tsx @@ -12,7 +12,6 @@ import { import type GoldenLayout from '@deephaven/golden-layout'; import type { ItemConfig } from '@deephaven/golden-layout'; import { - ContextMenuRoot, ErrorBoundary, LoadingOverlay, Shortcut, @@ -241,7 +240,6 @@ function App(): JSX.Element { errorMessage={error ?? null} /> )} - ); diff --git a/packages/embed-widget/src/index.tsx b/packages/embed-widget/src/index.tsx index 0e9cf98ce7..3848d3422f 100644 --- a/packages/embed-widget/src/index.tsx +++ b/packages/embed-widget/src/index.tsx @@ -33,6 +33,9 @@ const pluginsURL = new URL( document.baseURI ); +const logMetadata: Record = { + uiVersion: import.meta.env.npm_package_version, +}; // Lazy load the configs because it breaks initial page loads otherwise async function getCorePlugins() { const dashboardCorePlugins = await import( @@ -59,6 +62,7 @@ ReactDOM.render( getCorePlugins={getCorePlugins} serverUrl={apiURL.origin} pluginsUrl={pluginsURL.href} + logMetadata={logMetadata} > diff --git a/packages/embed-widget/vite.config.ts b/packages/embed-widget/vite.config.ts index d70d76c276..16c8c2ed84 100644 --- a/packages/embed-widget/vite.config.ts +++ b/packages/embed-widget/vite.config.ts @@ -11,7 +11,7 @@ export default defineConfig(({ mode }) => { let port = Number.parseInt(env.PORT, 10); if (Number.isNaN(port) || port <= 0) { - port = 4030; + port = 4010; } const baseURL = new URL(env.BASE_URL, `http://localhost:${port}/`); diff --git a/playwright-ci.config.ts b/playwright-ci.config.ts index 9e9d6da68e..4443e05b9b 100644 --- a/playwright-ci.config.ts +++ b/playwright-ci.config.ts @@ -3,14 +3,22 @@ import DefaultConfig from './playwright.config'; const config: PlaywrightTestConfig = { ...DefaultConfig, - webServer: { - // Only start the main code-studio server right now - // To test embed-widget, should have an array set for `webServer` and run them all separately as there's a port check - command: 'BASE_URL=/ide/ npm run preview:app -- -- -- --no-open', // Passing flags through npm is fun - port: 4000, - timeout: 60 * 1000, - reuseExistingServer: false, - }, + webServer: [ + { + command: 'BASE_URL=/ide/ npm run preview:app -- -- -- --no-open', // Passing flags through npm is fun + port: 4000, + timeout: 60 * 1000, + reuseExistingServer: false, + }, + { + command: + 'BASE_URL=/iframe/widget/ npm run preview:embed-widget -- -- -- --no-open', + port: 4010, + timeout: 60 * 1000, + reuseExistingServer: false, + }, + ], + // Applies to the npm command and CI, but CI will get overwritten in the CI config reporter: [['github'], ['html']], }; diff --git a/tests/shortcuts.spec.ts b/tests/shortcuts.spec.ts new file mode 100644 index 0000000000..1d81937b0e --- /dev/null +++ b/tests/shortcuts.spec.ts @@ -0,0 +1,43 @@ +import { test, expect } from '@playwright/test'; +import { gotoPage } from './utils'; + +test('shortcut downloads logs', async ({ page }) => { + await gotoPage(page, ''); + + const downloadPromise = page.waitForEvent('download'); + await page.keyboard.press('ControlOrMeta+Alt+Shift+KeyL'); + const download = await downloadPromise; + + expect(download).not.toBeNull(); +}); + +test('shortcut downloads logs in full screen error', async ({ page }) => { + // Go to embed-widget page without url parameter to trigger a full screen error + await gotoPage(page, 'http://localhost:4010/'); + + const downloadPromise = page.waitForEvent('download'); + await page.keyboard.press('ControlOrMeta+Alt+Shift+KeyL'); + const download = await downloadPromise; + + expect(download).not.toBeNull(); +}); + +test('shortcut downloads logs in embeded-widget', async ({ page }) => { + test.slow(true, "Extend timeout to prevent a failure before page loads"); + + // The embed-widgets page and the table itself have separate loading spinners, + // causing a strict mode violation intermittently when using the goToPage helper + await gotoPage(page, 'http://localhost:4010?name=all_types'); + await expect( + page.getByRole('progressbar', { + name: 'Loading...', + exact: true, + }) + ).toHaveCount(0); + + const downloadPromise = page.waitForEvent('download'); + await page.keyboard.press('ControlOrMeta+Alt+Shift+KeyL'); + const download = await downloadPromise; + + expect(download).not.toBeNull(); +});