diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index 12db09c6..7bf7319e 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -23,7 +23,7 @@ jobs: with: node-version: ${{ matrix.node-version }} - name: npm install, build, and test - timeout-minutes: 15 + timeout-minutes: 25 run: | npm ci --legacy-peer-deps npm run build --if-present diff --git a/package-lock.json b/package-lock.json index a670acb7..b408f2ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "azure-iot-explorer", - "version": "0.15.18", + "version": "0.15.19", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "azure-iot-explorer", - "version": "0.15.18", + "version": "0.15.19", "license": "MIT", "dependencies": { "@azure/core-amqp": "^4.5.0", diff --git a/package.json b/package.json index 13810227..0162a510 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "azure-iot-explorer", - "version": "0.15.18", + "version": "0.15.19", "description": "This project welcomes contributions and suggestions. Most contributions require you to agree to a\r Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us\r the rights to use your contribution. For details, visit https://cla.microsoft.com.", "main": "host/electron.js", "build": { @@ -61,7 +61,7 @@ "start:dev": "concurrently \"npm run start:web:dev\" \"npm run start:electron:dev\"", "start:web:dev": "npm run localization && npm run webpack:compile && webpack-dev-server --config webpack.dev.js --mode development --hot --port 3000 --host 127.0.0.1", "start:electron:dev": "wait-on http://localhost:3000 && npm run electron:compile && npm run preload:compile && cross-env NODE_ENV=development electron .", - "test": "npm run localization && jest --coverage", + "test": "npm run localization && node --max-old-space-size=4096 ./node_modules/jest/bin/jest.js --coverage", "test:debug": "node --inspect-brk ./node_modules/jest/bin/jest.js --runInBand -i --watch", "test:e2e": "npm run test:e2e:typecheck && npm run build && playwright test", "test:e2e:cleanup": "tsx e2e/scripts/cleanupDevices.ts", diff --git a/src/app/connectionStrings/components/commandBar.tsx b/src/app/connectionStrings/components/commandBar.tsx index a9f3c562..d47d5b09 100644 --- a/src/app/connectionStrings/components/commandBar.tsx +++ b/src/app/connectionStrings/components/commandBar.tsx @@ -9,6 +9,7 @@ import { useAuthenticationStateContext } from '../../authentication/context/auth interface ConnectionStringCommandBarProps { onAddConnectionStringClick: () => void; + addButtonRef?: React.Ref; } export const ConnectionStringCommandBar: React.FC = props => { @@ -25,6 +26,7 @@ export const ConnectionStringCommandBar: React.FC= CONNECTION_STRING_LIST_MAX_LENGTH, icon: , key: 'add', diff --git a/src/app/connectionStrings/components/connectionString.spec.tsx b/src/app/connectionStrings/components/connectionString.spec.tsx index 7140438e..24220f1e 100644 --- a/src/app/connectionStrings/components/connectionString.spec.tsx +++ b/src/app/connectionStrings/components/connectionString.spec.tsx @@ -41,8 +41,9 @@ describe('ConnectionString', () => { const onEdit = jest.fn(); render(); - fireEvent.click(screen.getByLabelText('connectionStrings.editConnectionCommand.ariaLabel')); - expect(onEdit).toHaveBeenCalledWith(testConnectionString); + const editButton = screen.getByLabelText('connectionStrings.editConnectionCommand.ariaLabel'); + fireEvent.click(editButton); + expect(onEdit).toHaveBeenCalledWith(testConnectionString, editButton); }); it('renders visit button that calls onSelectConnectionString', () => { diff --git a/src/app/connectionStrings/components/connectionString.tsx b/src/app/connectionStrings/components/connectionString.tsx index 9258ee9c..5bfc2cc3 100644 --- a/src/app/connectionStrings/components/connectionString.tsx +++ b/src/app/connectionStrings/components/connectionString.tsx @@ -19,7 +19,7 @@ import './connectionString.scss'; export interface ConnectionStringProps { connectionStringWithExpiry: ConnectionStringWithExpiry; - onEditConnectionString(connectionString: string): void; + onEditConnectionString(connectionString: string, invoker?: HTMLElement): void; onDeleteConnectionString(connectionString: string): void; onSelectConnectionString(connectionString: string): void; } @@ -34,8 +34,9 @@ export const ConnectionString: React.FC = (props: Connect const [ confirmingDelete, setConfirmingDelete ] = React.useState(false); const { t } = useTranslation(); - const onEditConnectionStringClick = () => { - onEditConnectionString(connectionString); + const onEditConnectionStringClick = (event: React.MouseEvent) => { + // Pass the button along so the edit drawer can return focus to it on close. + onEditConnectionString(connectionString, event.currentTarget); }; const onDeleteConnectionStringClick = () => { diff --git a/src/app/connectionStrings/components/connectionStringsView.spec.tsx b/src/app/connectionStrings/components/connectionStringsView.spec.tsx index 5896bc8f..c04f36e4 100644 --- a/src/app/connectionStrings/components/connectionStringsView.spec.tsx +++ b/src/app/connectionStrings/components/connectionStringsView.spec.tsx @@ -3,7 +3,7 @@ * Licensed under the MIT License **********************************************************/ import * as React from 'react'; -import { render, screen } from '@testing-library/react'; +import { render, screen, fireEvent } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import { ConnectionStringsView } from './connectionStringsView'; import * as connectionStringContext from '../context/connectionStringStateContext'; @@ -73,4 +73,42 @@ describe('ConnectionStringsView', () => { // Should NOT show empty state expect(screen.queryByText('connectionStrings.empty.header')).toBeNull(); }); + + it('returns focus to the add button when the add drawer is dismissed', () => { + render(); + + const addButton = screen.getByLabelText('connectionStrings.addConnectionCommand.ariaLabel'); + addButton.focus(); + fireEvent.click(addButton); + + expect(screen.getByText('connectionStrings.editConnection.title.add')).toBeInTheDocument(); + + fireEvent.click(screen.getAllByLabelText('connectionStrings.editConnection.cancel.ariaLabel.add')[0]); + + expect(document.activeElement).toBe(addButton); + }); + + it('returns focus to the edit button when the edit drawer is dismissed', () => { + (connectionStringContext.useConnectionStringContext as jest.Mock).mockReturnValue([ + { + payload: [ + { connectionString: 'HostName=hub1.azure-devices.net;SharedAccessKeyName=iothubowner;SharedAccessKey=key1', expiration: new Date(Date.now() + 365 * 86400000).toISOString() } + ], + synchronizationStatus: 'fetched' + }, + { setConnectionStrings: jest.fn(), upsertConnectionString: jest.fn(), deleteConnectionString: jest.fn(), getConnectionStrings: mockGetConnectionStrings } + ]); + + render(); + + const editButton = screen.getByLabelText('connectionStrings.editConnectionCommand.ariaLabel'); + editButton.focus(); + fireEvent.click(editButton); + + expect(screen.getByText('connectionStrings.editConnection.title.edit')).toBeInTheDocument(); + + fireEvent.click(screen.getAllByLabelText('connectionStrings.editConnection.cancel.ariaLabel.edit')[0]); + + expect(document.activeElement).toBe(editButton); + }); }); diff --git a/src/app/connectionStrings/components/connectionStringsView.tsx b/src/app/connectionStrings/components/connectionStringsView.tsx index 3c3fb751..5660dda0 100644 --- a/src/app/connectionStrings/components/connectionStringsView.tsx +++ b/src/app/connectionStrings/components/connectionStringsView.tsx @@ -29,6 +29,11 @@ export const ConnectionStringsView: React.FC = () => { useBreadcrumbEntry({name: t(ResourceKeys.breadcrumb.resources)}); const [ state, api ] = useConnectionStringContext(); const [ connectionStringUnderEdit, setConnectionStringUnderEdit ] = React.useState(undefined); + const addButtonRef = React.useRef(null); + // The element that opened the drawer, so focus can be returned to it on close. + // The drawer is shared by the add button and every row's edit button, so this is + // assigned per invocation rather than being tied to a single control. + const drawerInvokerRef = React.useRef(null); const connectionStringsWithExpiry = state.payload; const synchronizationStatus = state.synchronizationStatus; @@ -56,20 +61,29 @@ export const ConnectionStringsView: React.FC = () => { }; const onAddConnectionStringClick = () => { + drawerInvokerRef.current = addButtonRef.current; setConnectionStringUnderEdit(''); }; - const onEditConnectionStringClick = (connectionString: string) => { + const onEditConnectionStringClick = (connectionString: string, invoker?: HTMLElement) => { + drawerInvokerRef.current = invoker ?? null; setConnectionStringUnderEdit(connectionString); }; + const restoreFocusToDrawerInvoker = () => { + drawerInvokerRef.current?.focus(); + drawerInvokerRef.current = null; + }; + const onConnectionStringEditCommit = (connectionString: string) => { onUpsertConnectionString(connectionString, connectionStringUnderEdit); setConnectionStringUnderEdit(undefined); + restoreFocusToDrawerInvoker(); }; const onConnectionStringEditDismiss = () => { setConnectionStringUnderEdit(undefined); + restoreFocusToDrawerInvoker(); }; React.useEffect(() => { @@ -95,7 +109,7 @@ export const ConnectionStringsView: React.FC = () => { return (
- +
{connectionStringsWithExpiry.map(connectionStringWithExpiry => { expect(screen.getByText('cloudToDeviceMessage.properties.addCustomProperty')).toBeInTheDocument(); }); - it('keeps a system property selection after the property list rerenders', async () => { - const user = userEvent.setup(); + it('keeps a system property selection after the property list rerenders', () => { render(); - await user.click(screen.getByRole('button', { + fireEvent.click(screen.getByRole('button', { name: 'cloudToDeviceMessage.properties.addSystemProperty' })); - await user.click(screen.getByRole('menuitem', { + fireEvent.click(screen.getByRole('menuitem', { name: 'cloudToDeviceMessage.properties.systemProperties.ack.displayName' })); const ackDropdown = screen.getByRole('combobox'); - await user.click(ackDropdown); - await user.click(screen.getByRole('option', { + fireEvent.click(ackDropdown); + fireEvent.click(screen.getByRole('option', { name: 'cloudToDeviceMessage.properties.systemProperties.ack.full' })); - await user.click(screen.getByRole('button', { + fireEvent.click(screen.getByRole('button', { name: 'cloudToDeviceMessage.properties.addCustomProperty' })); diff --git a/src/app/devices/deviceIdentity/components/deviceContentNav.scss b/src/app/devices/deviceIdentity/components/deviceContentNav.scss index b5711a66..93b6fa27 100644 --- a/src/app/devices/deviceIdentity/components/deviceContentNav.scss +++ b/src/app/devices/deviceIdentity/components/deviceContentNav.scss @@ -16,14 +16,12 @@ a:link, a:visited, a:active { text-decoration: none; - @include themify($themes) { - color: themed('menuLinkColor'); - } } + // Only the background is themed here. Fluent v9's Tab sets `color` directly on + // its `.fui-Tab__content` span, so a `color` on the `` root never inherits. a:hover { @include themify($themes) { - color: themed('menuLinkColor_Hover'); background-color: themed('menuLinkBackground_Hover'); } } diff --git a/src/app/devices/deviceList/components/deviceQueryClause.spec.tsx b/src/app/devices/deviceList/components/deviceQueryClause.spec.tsx index 6f9ce2f9..2fd725f9 100644 --- a/src/app/devices/deviceList/components/deviceQueryClause.spec.tsx +++ b/src/app/devices/deviceList/components/deviceQueryClause.spec.tsx @@ -5,7 +5,7 @@ import * as React from 'react'; import { render, screen, fireEvent } from '@testing-library/react'; import { DeviceQueryClause, DeviceQueryClauseProps } from './deviceQueryClause'; -import { ParameterType, OperationType } from '../../../api/models/deviceQuery'; +import { ParameterType } from '../../../api/models/deviceQuery'; describe('DeviceQueryClause', () => { const defaultProps: DeviceQueryClauseProps = { @@ -51,7 +51,7 @@ describe('DeviceQueryClause', () => { 'deviceLists.query.searchPills.clause.parameterType.ariaLabel' ).textContent).toContain('deviceLists.query.searchPills.clause.parameterType.items.status'); expect(screen.getByLabelText( - 'deviceLists.query.searchPills.clause.value.placeholder' + 'deviceLists.query.searchPills.clause.value.ariaLabel' ).textContent).toContain('deviceLists.query.searchPills.clause.value.deviceStatus.enabled'); }); }); diff --git a/src/app/devices/deviceList/components/deviceQueryClause.tsx b/src/app/devices/deviceList/components/deviceQueryClause.tsx index 9159c36f..0dfbde80 100644 --- a/src/app/devices/deviceList/components/deviceQueryClause.tsx +++ b/src/app/devices/deviceList/components/deviceQueryClause.tsx @@ -138,6 +138,10 @@ export const DeviceQueryClause: React.FC { + const valueAriaLabel = t( + ResourceKeys.deviceLists.query.searchPills.clause.value.ariaLabel, + { parameter: getParameterTypeText(parameterType) } + ); switch (parameterType) { case ParameterType.edge: return ( @@ -145,7 +149,7 @@ export const DeviceQueryClause: React.FC @@ -158,7 +162,7 @@ export const DeviceQueryClause: React.FC diff --git a/src/app/devices/shared/components/sasTokenGenerationView.spec.tsx b/src/app/devices/shared/components/sasTokenGenerationView.spec.tsx index 2e43d973..012da07a 100644 --- a/src/app/devices/shared/components/sasTokenGenerationView.spec.tsx +++ b/src/app/devices/shared/components/sasTokenGenerationView.spec.tsx @@ -10,8 +10,7 @@ import { DeviceIdentity } from '../../../api/models/deviceIdentity'; import * as deviceIdentityHelper from '../../deviceIdentity/components/deviceIdentityHelper'; import { SAS_EXPIRES_MINUTES } from '../../../constants/devices'; -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { fireEvent, render, screen } from '@testing-library/react'; const moduleIdentityTwinDataProps: SasTokenGenerationDataProps = { activeAzureResourceHostName: 'testHub.azure-devices.net' }; @@ -74,32 +73,30 @@ describe('devices/components/moduleIdentityTwin', () => { }); }); - it('keeps the selected key and a committed expiration value', async () => { - const user = userEvent.setup(); + it('keeps the selected key and a committed expiration value', () => { const generateSpy = jest.spyOn(deviceIdentityHelper, 'generateSASTokenConnectionStringForModuleIdentity') .mockReturnValue('connection-string'); render(getComponent({ moduleIdentity })); - await user.click(screen.getByTitle('collapsibleSection.open')); + fireEvent.click(screen.getByTitle('collapsibleSection.open')); const keyDropdown = screen.getByRole('combobox', { name: 'deviceIdentity.authenticationType.sasToken.symmetricKey' }); - await user.click(keyDropdown); - await user.click(screen.getByRole('option', { + fireEvent.click(keyDropdown); + fireEvent.click(screen.getByRole('option', { name: 'deviceIdentity.authenticationType.symmetricKey.primaryKey' })); const expiration = screen.getByRole('spinbutton', { name: 'deviceIdentity.authenticationType.sasToken.expiration' }); - await user.clear(expiration); - await user.type(expiration, '60'); - await user.keyboard('{Enter}'); + fireEvent.change(expiration, { target: { value: '60' } }); + fireEvent.keyDown(expiration, { key: 'Enter' }); expect(expiration).toHaveValue('60'); expect(keyDropdown.textContent).toContain('deviceIdentity.authenticationType.symmetricKey.primaryKey'); - await user.click(screen.getByRole('button', { + fireEvent.click(screen.getByRole('button', { name: 'deviceIdentity.authenticationType.sasToken.generateButton.text' })); expect(generateSpy).toHaveBeenCalledWith( @@ -111,17 +108,15 @@ describe('devices/components/moduleIdentityTwin', () => { ); }); - it('rejects an expiration value below one minute', async () => { - const user = userEvent.setup(); + it('rejects an expiration value below one minute', () => { render(getComponent({ moduleIdentity })); - await user.click(screen.getByTitle('collapsibleSection.open')); + fireEvent.click(screen.getByTitle('collapsibleSection.open')); const expiration = screen.getByRole('spinbutton', { name: 'deviceIdentity.authenticationType.sasToken.expiration' }); - await user.clear(expiration); - await user.type(expiration, '0'); - await user.keyboard('{Enter}'); + fireEvent.change(expiration, { target: { value: '0' } }); + fireEvent.keyDown(expiration, { key: 'Enter' }); expect(expiration).toHaveValue(SAS_EXPIRES_MINUTES.toString()); }); diff --git a/src/app/home/components/homeViewNavigation.scss b/src/app/home/components/homeViewNavigation.scss index 73b45484..83b7a3ec 100644 --- a/src/app/home/components/homeViewNavigation.scss +++ b/src/app/home/components/homeViewNavigation.scss @@ -16,14 +16,12 @@ a:link, a:visited, a:active { text-decoration: none; - @include themify($themes) { - color: themed('menuLinkColor'); - } } + // Only the background is themed here. Fluent v9's Tab sets `color` directly on + // its `.fui-Tab__content` span, so a `color` on the `` root never inherits. a:hover { @include themify($themes) { - color: themed('menuLinkColor_Hover'); background-color: themed('menuLinkBackground_Hover'); } } diff --git a/src/app/iotHub/components/iotHubDevices.spec.tsx b/src/app/iotHub/components/iotHubDevices.spec.tsx index 2beb0fab..d89b8e24 100644 --- a/src/app/iotHub/components/iotHubDevices.spec.tsx +++ b/src/app/iotHub/components/iotHubDevices.spec.tsx @@ -5,17 +5,18 @@ import * as React from 'react'; import { IotHubDevices } from './iotHubDevices'; -import { render, act } from '@testing-library/react'; +import { render } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; +jest.mock('../../devices/deviceList/components/deviceList', () => ({ + DeviceList: () => null +})); jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), useLocation: () => ({ pathname: '', search: '', hash: '', state: null, key: 'default' }) })); describe('IotHubDevices', () => { - it('matches snapshot', async () => { - await act(async () => { - expect(render()).toBeDefined(); - }); + it('matches snapshot', () => { + expect(render()).toBeDefined(); }); }); \ No newline at end of file diff --git a/src/app/notifications/components/notificationListEntry.tsx b/src/app/notifications/components/notificationListEntry.tsx index d130eb3a..b7082ee5 100644 --- a/src/app/notifications/components/notificationListEntry.tsx +++ b/src/app/notifications/components/notificationListEntry.tsx @@ -29,6 +29,12 @@ export const NotificationListEntry: React.FC = (prop const message = t(notification.text.translationKey, notification.text.translationOptions); const friendlyMessage = <>{message.split('. ').map((m: React.ReactNode, index: number) => (
{m + '.'}
))}; const longMessageLength = 300; + const ariaLabelMessageLength = 80; + // Keep the accessible name short: the full text is already in the list item, and a + // multi-sentence name is re-read on every pass through the notification list. + const shortMessage = message?.length > ariaLabelMessageLength ? + `${message.substring(0, ariaLabelMessageLength)}...` : + message; const navigateToNotificationCenter = () => { const path = `/${ROUTE_PARTS.HOME}/${ROUTE_PARTS.NOTIFICATIONS}?${ROUTE_PARAMS.NAV_FROM}`; @@ -44,8 +50,8 @@ export const NotificationListEntry: React.FC = (prop