From 4b503e2c01896b47ba45fcd849fde419693af03a Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Thu, 5 Jan 2023 19:13:01 -0500 Subject: [PATCH 01/24] fix(applayout): fix nested anchor warnings --- src/app/AppLayout/AppLayout.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app/AppLayout/AppLayout.tsx b/src/app/AppLayout/AppLayout.tsx index f5fcf187b..953675f78 100644 --- a/src/app/AppLayout/AppLayout.tsx +++ b/src/app/AppLayout/AppLayout.tsx @@ -480,11 +480,12 @@ const AppLayout: React.FC = ({ children }) => { - + + {HeaderToolbar} From ed016465ab8e67d3773cd3536559244be7ec490e Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Thu, 5 Jan 2023 19:19:23 -0500 Subject: [PATCH 02/24] feat(settings): categorize settings --- .../AutomatedAnalysisConfigForm.tsx | 64 +++- src/app/Settings/AutoRefresh.tsx | 1 + src/app/Settings/AutomatedAnalysisConfig.tsx | 3 +- src/app/Settings/CredentialsStorage.tsx | 9 +- src/app/Settings/DeletionDialogControl.tsx | 20 +- src/app/Settings/FeatureLevels.tsx | 2 + src/app/Settings/NotificationControl.tsx | 51 +-- src/app/Settings/Settings.tsx | 141 +++++-- src/app/Settings/WebSocketDebounce.tsx | 1 + src/app/TargetSelect/TargetSelect.tsx | 3 + src/app/app.css | 8 + src/test/Settings/CredentialsStorage.test.tsx | 9 - .../CredentialsStorage.test.tsx.snap | 55 --- .../__snapshots__/Settings.test.tsx.snap | 348 ++---------------- src/test/TargetSelect/TargetSelect.test.tsx | 18 - .../__snapshots__/TargetSelect.test.tsx.snap | 215 ----------- 16 files changed, 254 insertions(+), 694 deletions(-) delete mode 100644 src/test/Settings/__snapshots__/CredentialsStorage.test.tsx.snap delete mode 100644 src/test/TargetSelect/__snapshots__/TargetSelect.test.tsx.snap diff --git a/src/app/Dashboard/AutomatedAnalysis/AutomatedAnalysisConfigForm.tsx b/src/app/Dashboard/AutomatedAnalysis/AutomatedAnalysisConfigForm.tsx index 68b7c426a..40e4e8692 100644 --- a/src/app/Dashboard/AutomatedAnalysis/AutomatedAnalysisConfigForm.tsx +++ b/src/app/Dashboard/AutomatedAnalysis/AutomatedAnalysisConfigForm.tsx @@ -277,18 +277,9 @@ export const AutomatedAnalysisConfigForm: React.FC - ); - } - return ( -
- + const formContent = React.useMemo( + () => ( + <> )} - -
+ + ), + [ + templateName, + templateType, + templates, + isLoading, + isSaveLoading, + selectedSpecifier, + maxSize, + maxSizeUnits, + maxAge, + maxAgeUnits, + isFormInvalid, + showHelperMessage, + createButtonLoadingProps, + handleMaxSizeChange, + handleMaxAgeChange, + handleMaxSizeUnitChange, + handleMaxAgeUnitChange, + handleSaveConfig, + handleSubmit, + handleTemplateChange, + props.isSettingsForm, + saveButtonLoadingProps, + ] + ); + + if (errorMessage != '') { + return ( + + ); + } + return ( + <> + {props.isSettingsForm ? ( + formContent + ) : ( +
+ {formContent} +
+ )} + ); }; diff --git a/src/app/Settings/AutoRefresh.tsx b/src/app/Settings/AutoRefresh.tsx index 647ec36e4..1df31cde8 100644 --- a/src/app/Settings/AutoRefresh.tsx +++ b/src/app/Settings/AutoRefresh.tsx @@ -108,4 +108,5 @@ export const AutoRefresh: UserSetting = { description: 'Set the refresh period for content views. Views normally update dynamically via WebSocket notifications, so this should not be needed unless WebSockets are not working.', content: Component, + category: 'General', }; diff --git a/src/app/Settings/AutomatedAnalysisConfig.tsx b/src/app/Settings/AutomatedAnalysisConfig.tsx index 09798b59c..a85fdfc6e 100644 --- a/src/app/Settings/AutomatedAnalysisConfig.tsx +++ b/src/app/Settings/AutomatedAnalysisConfig.tsx @@ -72,7 +72,7 @@ const Component = () => { - + <Title headingLevel="h3" size="md"> Current configuration @@ -108,4 +108,5 @@ export const AutomatedAnalysisConfig: UserSetting = { description: 'Set the recording configuration for automated analysis recordings. You may want smaller or larger values for max-age and max-size depending on how recent you want events to be recorded from the analysis.', content: Component, + category: 'Dashboard', }; diff --git a/src/app/Settings/CredentialsStorage.tsx b/src/app/Settings/CredentialsStorage.tsx index 956705da3..e4a1efb2f 100644 --- a/src/app/Settings/CredentialsStorage.tsx +++ b/src/app/Settings/CredentialsStorage.tsx @@ -37,7 +37,7 @@ */ import { getFromLocalStorage, saveToLocalStorage } from '@app/utils/LocalStorage'; -import { Select, SelectOption, SelectVariant, Text } from '@patternfly/react-core'; +import { Select, SelectOption, SelectVariant } from '@patternfly/react-core'; import * as React from 'react'; import { Link } from 'react-router-dom'; import { UserSetting } from './Settings'; @@ -93,6 +93,8 @@ const Component = () => { <> + {Object.keys(i18nResources).map((l) => ( + + {localeReadable(l)} + + ))} + + ); +}; + +export const Language: UserSetting = { + title: 'Language', + description: 'Set the current language for web console.', + content: Component, + category: 'Language & Region', + orderInGroup: 1, + featureLevel: FeatureLevel.BETA, +}; diff --git a/src/app/Settings/Settings.tsx b/src/app/Settings/Settings.tsx index 9bddb586d..a8f10c98f 100644 --- a/src/app/Settings/Settings.tsx +++ b/src/app/Settings/Settings.tsx @@ -37,16 +37,20 @@ */ import { BreadcrumbPage } from '@app/BreadcrumbPage/BreadcrumbPage'; +import { FeatureFlag } from '@app/Shared/FeatureFlag/FeatureFlag'; +import { FeatureLevel } from '@app/Shared/Services/Settings.service'; import { Card, Form, FormGroup, HelperText, HelperTextItem, + Label, Sidebar, SidebarContent, SidebarPanel, Tab, + TabProps, Tabs, TabTitleText, Title, @@ -57,15 +61,24 @@ import { AutoRefresh } from './AutoRefresh'; import { CredentialsStorage } from './CredentialsStorage'; import { DeletionDialogControl } from './DeletionDialogControl'; import { FeatureLevels } from './FeatureLevels'; +import { Language } from './Language'; import { NotificationControl } from './NotificationControl'; import { WebSocketDebounce } from './WebSocketDebounce'; export interface SettingGroup { groupLabel: SettingCategory; + featureLevel: FeatureLevel; disabled?: boolean; settings: _TransformedUserSetting[]; } +const _getGroupFeatureLevel = (settings: _TransformedUserSetting[]): FeatureLevel => { + if (!settings.length) { + return FeatureLevel.DEVELOPMENT; + } + return settings.slice().sort((a, b) => b.featureLevel - a.featureLevel)[0].featureLevel; +}; + const _SettingCategories = [ 'General', 'Language & Region', @@ -83,11 +96,13 @@ export interface UserSetting { content: React.FunctionComponent; category: SettingCategory; orderInGroup?: number; // default -1 + featureLevel?: FeatureLevel; // default PRODUCTION } interface _TransformedUserSetting extends Omit { element: React.FunctionComponentElement>; orderInGroup: number; + featureLevel: FeatureLevel; } export interface SettingsProps {} @@ -101,6 +116,7 @@ export const Settings: React.FC = (_) => { WebSocketDebounce, AutoRefresh, FeatureLevels, + Language, ].map( (c) => ({ @@ -110,6 +126,7 @@ export const Settings: React.FC = (_) => { category: c.category, disabled: c.disabled, orderInGroup: c.orderInGroup || -1, + featureLevel: c.featureLevel || FeatureLevel.PRODUCTION, } as _TransformedUserSetting) ); @@ -122,16 +139,20 @@ export const Settings: React.FC = (_) => { ); const settingGroups = React.useMemo(() => { - return _SettingCategories.map((cat) => ({ - groupLabel: cat, - settings: settings.filter((s) => s.category === cat).sort((a, b) => b.orderInGroup - a.orderInGroup), - })) as SettingGroup[]; + return _SettingCategories.map((cat) => { + const panels = settings.filter((s) => s.category === cat).sort((a, b) => b.orderInGroup - a.orderInGroup); + return { + groupLabel: cat, + settings: panels, + featureLevel: _getGroupFeatureLevel(panels), + }; + }) as SettingGroup[]; }, [settings]); return ( <> - + = (_) => { activeKey={activeTab} onSelect={onTabSelect} > - {settingGroups - .filter((grp) => grp.settings.length) // Hide groups with empty settings - .map((grp) => ( - {grp.groupLabel}} - /> - ))} + {settingGroups.map((grp) => ( + {grp.groupLabel}} + featureLevelConfig={{ + level: grp.featureLevel, + }} + /> + ))} @@ -158,31 +180,68 @@ export const Settings: React.FC = (_) => { .map((grp) => (
{grp.settings.map((s, index) => ( - - {s.title} - - } - helperText={ - - {s.description} - - } - isHelperTextBeforeField - key={`${grp.groupLabel}-${s.title}-${index}`} - > - {s.element} - + + + {s.title} + {s.featureLevel !== FeatureLevel.PRODUCTION && ( + + )} + + } + helperText={ + + {s.description} + + } + isHelperTextBeforeField + key={`${grp.groupLabel}-${s.title}-${index}`} + > + {s.element} + + ))}
))}
+ <> + { + // Need this fragment to correct bottom margin. + } +
); }; +interface SettingTabProps extends TabProps { + featureLevelConfig: { + level: FeatureLevel; + strict?: boolean; + }; +} + +// Workaround to the Tabs component requiring children to be React.FC +const SettingTab: React.FC = ({ featureLevelConfig, eventKey, title, children }) => { + return ( + + + {children} + + + ); +}; + export default Settings; diff --git a/src/app/app.css b/src/app/app.css index d123d2824..beaa91dea 100644 --- a/src/app/app.css +++ b/src/app/app.css @@ -248,7 +248,3 @@ html, body, #root { .setting-content { padding: 2ch !important; } - -.setting-tabs { - width:fit-content -} \ No newline at end of file diff --git a/src/i18n/config.ts b/src/i18n/config.ts index 69f78e7a2..04e581bc3 100644 --- a/src/i18n/config.ts +++ b/src/i18n/config.ts @@ -53,11 +53,11 @@ export const i18nResources = { public: en_public, common: en_common, }, - zh: { - // TODO: add zh translation (and other languages)? - // public: zh_public, - // common: zh_common, - }, + // zh: { + // // TODO: add zh translation (and other languages)? + // // public: zh_public, + // // common: zh_common, + // }, } as const; export const i18nNamespaces = ['public', 'common']; From 3e036a5989b16f098c7f700e9e49554887d39985 Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Mon, 9 Jan 2023 17:23:29 -0500 Subject: [PATCH 05/24] test(settings): add tests to setting view --- src/app/Settings/Settings.tsx | 2 +- src/test/Settings/Settings.test.tsx | 65 ++++- .../__snapshots__/Settings.test.tsx.snap | 254 +++++++++++++++++- 3 files changed, 313 insertions(+), 8 deletions(-) diff --git a/src/app/Settings/Settings.tsx b/src/app/Settings/Settings.tsx index a8f10c98f..d0c9040a8 100644 --- a/src/app/Settings/Settings.tsx +++ b/src/app/Settings/Settings.tsx @@ -237,7 +237,7 @@ interface SettingTabProps extends TabProps { const SettingTab: React.FC = ({ featureLevelConfig, eventKey, title, children }) => { return ( - + {children} diff --git a/src/test/Settings/Settings.test.tsx b/src/test/Settings/Settings.test.tsx index 86c9ab5ba..12c31263c 100644 --- a/src/test/Settings/Settings.test.tsx +++ b/src/test/Settings/Settings.test.tsx @@ -35,17 +35,23 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -import { Settings } from '@app/Settings/Settings'; + import { Text } from '@patternfly/react-core'; import '@testing-library/jest-dom'; -import { cleanup } from '@testing-library/react'; +import { cleanup, screen } from '@testing-library/react'; import * as React from 'react'; import renderer, { act } from 'react-test-renderer'; +import { of } from 'rxjs'; +import { renderWithServiceContext } from '../Common'; +import { FeatureLevel } from '@app/Shared/Services/Settings.service'; +import { Settings } from '@app/Settings/Settings'; +import { defaultServices, ServiceContext } from '@app/Shared/Services/Services'; jest.mock('@app/Settings/NotificationControl', () => ({ NotificationControl: { title: 'Notification Control Title', description: 'Notification Control Description', + category: 'Notifications & Messages', content: () => Notification Control Component, }, })); @@ -54,6 +60,7 @@ jest.mock('@app/Settings/AutomatedAnalysisConfig', () => ({ AutomatedAnalysisConfig: { title: 'Automated Analysis Config Title', description: 'Automated Analysis Config Description', + category: 'Dashboard', content: () => Automated Analysis Config Component, }, })); @@ -62,6 +69,7 @@ jest.mock('@app/Settings/CredentialsStorage', () => ({ CredentialsStorage: { title: 'Credentials Storage Title', description: 'Credentials Storage Description', + category: 'Advanced', content: () => Credentials Storage Component, }, })); @@ -70,6 +78,7 @@ jest.mock('@app/Settings/DeletionDialogControl', () => ({ DeletionDialogControl: { title: 'Deletion Dialog Control Title', description: 'Deletion Dialog Control Description', + category: 'Notifications & Messages', content: () => Deletion Dialog Control Component, }, })); @@ -78,6 +87,7 @@ jest.mock('@app/Settings/WebSocketDebounce', () => ({ WebSocketDebounce: { title: 'WebSocket Debounce Title', description: 'WebSocket Debounce Description', + category: 'General', content: () => WebSocket Debounce Component, }, })); @@ -86,6 +96,7 @@ jest.mock('@app/Settings/AutoRefresh', () => ({ AutoRefresh: { title: 'AutoRefresh Title', description: 'AutoRefresh Description', + category: 'General', content: () => AutoRefresh Component, }, })); @@ -94,18 +105,66 @@ jest.mock('@app/Settings/FeatureLevels', () => ({ FeatureLevels: { title: 'Feature Levels Title', description: 'Feature Levels Description', + category: 'General', content: () => Feature Levels Component, }, })); +jest.mock('@app/Settings/Language', () => ({ + Language: { + title: 'Language Title', + description: 'Language Description', + category: 'Language & Region', + featureLevel: FeatureLevel.BETA, + content: () => Language Component, + }, +})); + +jest.spyOn(defaultServices.settings, 'featureLevel').mockReturnValue(of(FeatureLevel.PRODUCTION)); + describe('', () => { afterEach(cleanup); it('renders correctly', async () => { let tree; await act(async () => { - tree = renderer.create(); + tree = renderer.create( + + + + ); }); expect(tree.toJSON()).toMatchSnapshot(); }); + + // This test will check if language setting (BETA) is being hidden. + // Update this test when language setting is in PRODUCTION. + it('should not show tabs with featureLevel lower than current', async () => { + renderWithServiceContext(); + + const hiddenTab = screen.queryByText('Language & Region'); + expect(hiddenTab).not.toBeInTheDocument(); + }); + + it('should select General tab as default', async () => { + renderWithServiceContext(); + + const generalTab = screen.getByRole('tab', { name: 'General' }); + expect(generalTab).toBeInTheDocument(); + expect(generalTab).toBeVisible(); + expect(generalTab.getAttribute('aria-selected')).toBe('true'); + }); + + it('should update setting content when a tab is selected', async () => { + const { user } = renderWithServiceContext(); + + const dashboardTab = screen.getByRole('tab', { name: 'Dashboard' }); + expect(dashboardTab).toBeInTheDocument(); + expect(dashboardTab).toBeVisible(); + expect(dashboardTab.getAttribute('aria-selected')).toBe('false'); + + await user.click(dashboardTab); + + expect(dashboardTab.getAttribute('aria-selected')).toBe('true'); + }); }); diff --git a/src/test/Settings/__snapshots__/Settings.test.tsx.snap b/src/test/Settings/__snapshots__/Settings.test.tsx.snap index a2d8b0d96..a31017b75 100644 --- a/src/test/Settings/__snapshots__/Settings.test.tsx.snap +++ b/src/test/Settings/__snapshots__/Settings.test.tsx.snap @@ -29,10 +29,10 @@ exports[` renders correctly 1`] = ` className="pf-l-stack pf-m-gutter" >
renders correctly 1`] = ` className="pf-c-tabs__list" onScroll={[Function]} role="tablist" - /> + > +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
    renders correctly 1`] = `
    + > +
    +
    + + +
    +
    +
    +
    + + WebSocket Debounce Description + +
    +
    +

    + WebSocket Debounce Component +

    +
    +
    +
    +
    + + +
    +
    +
    +
    + + AutoRefresh Description + +
    +
    +

    + AutoRefresh Component +

    +
    +
    +
    +
    + + +
    +
    +
    +
    + + Feature Levels Description + +
    +
    +

    + Feature Levels Component +

    +
    +
    +
    +
    `; From df1a2125c8b807ea6488af1864585ff335cade6b Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Mon, 9 Jan 2023 21:25:05 -0500 Subject: [PATCH 06/24] test(settings): add tests for auto-refresh setting --- src/app/DurationPicker/DurationPicker.tsx | 2 +- src/test/Settings/AutoRefresh.test.tsx | 130 ++++++++++++++++++ .../__snapshots__/AutoRefresh.test.tsx.snap | 94 +++++++++++++ 3 files changed, 225 insertions(+), 1 deletion(-) create mode 100644 src/test/Settings/AutoRefresh.test.tsx create mode 100644 src/test/Settings/__snapshots__/AutoRefresh.test.tsx.snap diff --git a/src/app/DurationPicker/DurationPicker.tsx b/src/app/DurationPicker/DurationPicker.tsx index c0b9773cf..b7121c8e2 100644 --- a/src/app/DurationPicker/DurationPicker.tsx +++ b/src/app/DurationPicker/DurationPicker.tsx @@ -56,7 +56,7 @@ export const DurationPicker: React.FC = (props) => { isRequired type="number" id="duration-picker-period" - aria-describedby="recording-duration-helper" + aria-label="Duration Picker Period Input" onChange={(v) => props.onPeriodChange(Number(v))} isDisabled={!props.enabled} min="0" diff --git a/src/test/Settings/AutoRefresh.test.tsx b/src/test/Settings/AutoRefresh.test.tsx new file mode 100644 index 000000000..b0e47cb6c --- /dev/null +++ b/src/test/Settings/AutoRefresh.test.tsx @@ -0,0 +1,130 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import * as React from 'react'; +import { defaultServices, ServiceContext } from '@app/Shared/Services/Services'; +import { cleanup, screen } from '@testing-library/react'; +import renderer, { act } from 'react-test-renderer'; +import { AutoRefresh } from '@app/Settings/AutoRefresh'; +import { renderWithServiceContext } from '../Common'; + +jest.spyOn(defaultServices.settings, 'autoRefreshEnabled').mockReturnValue(false); +jest.spyOn(defaultServices.settings, 'autoRefreshPeriod').mockReturnValue(30); +jest.spyOn(defaultServices.settings, 'autoRefreshUnits').mockReturnValue(1000); + +describe('', () => { + beforeEach(() => { + // Clear mock instances, calls, and results + jest.mocked(defaultServices.settings.setAutoRefreshEnabled).mockClear(); + jest.mocked(defaultServices.settings.setAutoRefreshPeriod).mockClear(); + jest.mocked(defaultServices.settings.setAutoRefreshUnits).mockClear(); + }); + afterEach(cleanup); + + it('renders correctly', async () => { + let tree; + await act(async () => { + tree = renderer.create( + + {React.createElement(AutoRefresh.content, null)} + + ); + }); + expect(tree.toJSON()).toMatchSnapshot(); + }); + + it('should default to have auto-refresh disabled', async () => { + renderWithServiceContext(React.createElement(AutoRefresh.content, null)); + + const enableCheckbox = screen.getByLabelText('Enabled'); + expect(enableCheckbox).toBeInTheDocument(); + expect(enableCheckbox).toBeVisible(); + expect(enableCheckbox).not.toBeChecked(); + }); + + it('should enable selections if checkbox is checked', async () => { + const { user } = renderWithServiceContext(React.createElement(AutoRefresh.content, null)); + + const enableCheckbox = screen.getByLabelText('Enabled'); + expect(enableCheckbox).toBeInTheDocument(); + expect(enableCheckbox).toBeVisible(); + + await user.click(enableCheckbox); + + [ + screen.getByLabelText('Duration Picker Period Input'), + screen.getByLabelText('Duration Picker Units Input'), + ].forEach((input) => { + expect(input).toBeInTheDocument(); + expect(input).toBeVisible(); + expect(input).not.toBeDisabled(); + }); + + expect(defaultServices.settings.setAutoRefreshEnabled).toHaveBeenCalledTimes(1); + expect(defaultServices.settings.setAutoRefreshEnabled).toHaveBeenCalledWith(true); + }); + + it('should set value to local storage when congfigured', async () => { + const { user } = renderWithServiceContext(React.createElement(AutoRefresh.content, null)); + + const periodInput = screen.getByLabelText('Duration Picker Period Input'); + expect(periodInput).toBeInTheDocument(); + expect(periodInput).toBeVisible(); + + const enableCheckbox = screen.getByLabelText('Enabled'); + expect(enableCheckbox).toBeInTheDocument(); + expect(enableCheckbox).toBeVisible(); + + await user.click(enableCheckbox); + await user.clear(periodInput as HTMLInputElement); + await user.type(periodInput, '20'); + + // Clear + typing 2 + typing 0 + expect(defaultServices.settings.setAutoRefreshPeriod).toHaveBeenCalledTimes(3); + expect(defaultServices.settings.setAutoRefreshPeriod).toHaveBeenLastCalledWith(20); + + const unitSelect = screen.getByLabelText('Duration Picker Units Input'); + expect(unitSelect).toBeInTheDocument(); + expect(unitSelect).toBeVisible(); + + await user.selectOptions(unitSelect, 'Minutes'); + + expect(defaultServices.settings.setAutoRefreshUnits).toHaveBeenCalledTimes(1); + expect(defaultServices.settings.setAutoRefreshUnits).toHaveBeenLastCalledWith(60 * 1000); + }); +}); diff --git a/src/test/Settings/__snapshots__/AutoRefresh.test.tsx.snap b/src/test/Settings/__snapshots__/AutoRefresh.test.tsx.snap new file mode 100644 index 000000000..a1ea003a9 --- /dev/null +++ b/src/test/Settings/__snapshots__/AutoRefresh.test.tsx.snap @@ -0,0 +1,94 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders correctly 1`] = ` +Array [ +
    +
    + +
    +
    + +
    +
    , +
    + + +
    , +] +`; From cee9fb1237648a06f6ad4179b439c2c01655d84b Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Mon, 9 Jan 2023 21:38:23 -0500 Subject: [PATCH 07/24] chore(settings): apply eslint fixes --- src/test/Settings/AutoRefresh.test.tsx | 4 ++-- src/test/Settings/Settings.test.tsx | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/test/Settings/AutoRefresh.test.tsx b/src/test/Settings/AutoRefresh.test.tsx index b0e47cb6c..378394c58 100644 --- a/src/test/Settings/AutoRefresh.test.tsx +++ b/src/test/Settings/AutoRefresh.test.tsx @@ -36,11 +36,11 @@ * SOFTWARE. */ -import * as React from 'react'; +import { AutoRefresh } from '@app/Settings/AutoRefresh'; import { defaultServices, ServiceContext } from '@app/Shared/Services/Services'; import { cleanup, screen } from '@testing-library/react'; +import * as React from 'react'; import renderer, { act } from 'react-test-renderer'; -import { AutoRefresh } from '@app/Settings/AutoRefresh'; import { renderWithServiceContext } from '../Common'; jest.spyOn(defaultServices.settings, 'autoRefreshEnabled').mockReturnValue(false); diff --git a/src/test/Settings/Settings.test.tsx b/src/test/Settings/Settings.test.tsx index 12c31263c..8a1cf9ceb 100644 --- a/src/test/Settings/Settings.test.tsx +++ b/src/test/Settings/Settings.test.tsx @@ -36,6 +36,9 @@ * SOFTWARE. */ +import { FeatureLevel } from '@app/Shared/Services/Settings.service'; // Must import before @app/Settings/Settings +import { Settings } from '@app/Settings/Settings'; +import { defaultServices, ServiceContext } from '@app/Shared/Services/Services'; import { Text } from '@patternfly/react-core'; import '@testing-library/jest-dom'; import { cleanup, screen } from '@testing-library/react'; @@ -43,9 +46,6 @@ import * as React from 'react'; import renderer, { act } from 'react-test-renderer'; import { of } from 'rxjs'; import { renderWithServiceContext } from '../Common'; -import { FeatureLevel } from '@app/Shared/Services/Settings.service'; -import { Settings } from '@app/Settings/Settings'; -import { defaultServices, ServiceContext } from '@app/Shared/Services/Services'; jest.mock('@app/Settings/NotificationControl', () => ({ NotificationControl: { From fcd744ff67eb1b9ee43be7b3fe8e29271f1eecd9 Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Mon, 9 Jan 2023 22:08:03 -0500 Subject: [PATCH 08/24] test(recordings): update snapshots --- .../__snapshots__/CustomRecordingForm.test.tsx.snap | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/test/CreateRecording/__snapshots__/CustomRecordingForm.test.tsx.snap b/src/test/CreateRecording/__snapshots__/CustomRecordingForm.test.tsx.snap index ce75c1b9e..97c5cbf70 100644 --- a/src/test/CreateRecording/__snapshots__/CustomRecordingForm.test.tsx.snap +++ b/src/test/CreateRecording/__snapshots__/CustomRecordingForm.test.tsx.snap @@ -166,9 +166,8 @@ Array [ className="pf-l-split__item pf-m-fill" > Date: Tue, 10 Jan 2023 04:30:35 -0500 Subject: [PATCH 09/24] test(settings): add tests for deletion dialog control --- src/app/Settings/DeletionDialogControl.tsx | 2 +- .../Settings/DeletionDialogControl.test.tsx | 125 +++++ src/test/Settings/Settings.test.tsx | 3 +- .../DeletionDialogControl.test.tsx.snap | 465 ++++++++++++++++++ 4 files changed, 593 insertions(+), 2 deletions(-) create mode 100644 src/test/Settings/DeletionDialogControl.test.tsx create mode 100644 src/test/Settings/__snapshots__/DeletionDialogControl.test.tsx.snap diff --git a/src/app/Settings/DeletionDialogControl.tsx b/src/app/Settings/DeletionDialogControl.tsx index adeed8e45..79f3675b9 100644 --- a/src/app/Settings/DeletionDialogControl.tsx +++ b/src/app/Settings/DeletionDialogControl.tsx @@ -38,7 +38,7 @@ import { DeleteOrDisableWarningType, getFromWarningMap } from '@app/Modal/DeleteWarningUtils'; import { ServiceContext } from '@app/Shared/Services/Services'; -import { ExpandableSection, Switch, Stack, StackItem, FormGroup } from '@patternfly/react-core'; +import { ExpandableSection, FormGroup, Stack, StackItem, Switch } from '@patternfly/react-core'; import * as React from 'react'; import { UserSetting } from './Settings'; diff --git a/src/test/Settings/DeletionDialogControl.test.tsx b/src/test/Settings/DeletionDialogControl.test.tsx new file mode 100644 index 000000000..d7f9861f4 --- /dev/null +++ b/src/test/Settings/DeletionDialogControl.test.tsx @@ -0,0 +1,125 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { DeleteOrDisableWarningType } from '@app/Modal/DeleteWarningUtils'; +import { DeletionDialogControl } from '@app/Settings/DeletionDialogControl'; +import { defaultServices, ServiceContext } from '@app/Shared/Services/Services'; +import { cleanup, screen } from '@testing-library/react'; +import * as React from 'react'; +import renderer, { act } from 'react-test-renderer'; +import { renderWithServiceContext } from '../Common'; + +const defaults = new Map(); +for (const cat in DeleteOrDisableWarningType) { + defaults.set(DeleteOrDisableWarningType[cat], true); +} + +jest.spyOn(defaultServices.settings, 'deletionDialogsEnabled').mockReturnValue(defaults); + +describe('', () => { + beforeEach(() => { + jest.mocked(defaultServices.settings.setDeletionDialogsEnabled).mockClear(); + }); + + afterEach(cleanup); + + it('renders correctly', async () => { + let tree; + await act(async () => { + tree = renderer.create( + + {React.createElement(DeletionDialogControl.content, null)} + + ); + }); + expect(tree.toJSON()).toMatchSnapshot(); + }); + + it('should default to enable all deletion dialog', async () => { + renderWithServiceContext(React.createElement(DeletionDialogControl.content, null)); + + const enableSwitch = screen.getByLabelText('All Deletion Warnings'); + expect(enableSwitch).toBeInTheDocument(); + expect(enableSwitch).toBeVisible(); + expect(enableSwitch).toBeChecked(); + }); + + it('should disable all deletion dialog if switch is off', async () => { + const { user } = renderWithServiceContext(React.createElement(DeletionDialogControl.content, null)); + + const enableSwitch = screen.getByLabelText('All Deletion Warnings'); + expect(enableSwitch).toBeInTheDocument(); + expect(enableSwitch).toBeVisible(); + expect(enableSwitch).toBeChecked(); + + await user.click(enableSwitch); + + expect(enableSwitch).not.toBeChecked(); + + expect(defaultServices.settings.setDeletionDialogsEnabled).toHaveBeenCalledTimes(1); + + const expectedParams = new Map(); + defaults.forEach((_, k) => expectedParams.set(k, false)); + expect(defaultServices.settings.setDeletionDialogsEnabled).toHaveBeenCalledWith(expectedParams); + }); + + it('should turn off switch if any child switch is turned off', async () => { + const { user } = renderWithServiceContext(React.createElement(DeletionDialogControl.content, null)); + + const enableSwitch = screen.getByLabelText('All Deletion Warnings'); + expect(enableSwitch).toBeInTheDocument(); + expect(enableSwitch).toBeVisible(); + expect(enableSwitch).toBeChecked(); + + const expandButton = screen.getByText('Show more'); + expect(expandButton).toBeInTheDocument(); + expect(expandButton).toBeVisible(); + + await user.click(expandButton); + + const activeRecordingSwitch = screen.getByLabelText('Delete Active Recording'); + expect(activeRecordingSwitch).toBeInTheDocument(); + expect(activeRecordingSwitch).toBeVisible(); + expect(activeRecordingSwitch).toBeChecked(); + + await user.click(activeRecordingSwitch); + + expect(activeRecordingSwitch).not.toBeChecked(); + expect(enableSwitch).not.toBeChecked(); + }); +}); diff --git a/src/test/Settings/Settings.test.tsx b/src/test/Settings/Settings.test.tsx index 8a1cf9ceb..ac3d9aa62 100644 --- a/src/test/Settings/Settings.test.tsx +++ b/src/test/Settings/Settings.test.tsx @@ -36,7 +36,8 @@ * SOFTWARE. */ -import { FeatureLevel } from '@app/Shared/Services/Settings.service'; // Must import before @app/Settings/Settings +// Must import before @app/Settings/Settings (circular deps) +import { FeatureLevel } from '@app/Shared/Services/Settings.service'; import { Settings } from '@app/Settings/Settings'; import { defaultServices, ServiceContext } from '@app/Shared/Services/Services'; import { Text } from '@patternfly/react-core'; diff --git a/src/test/Settings/__snapshots__/DeletionDialogControl.test.tsx.snap b/src/test/Settings/__snapshots__/DeletionDialogControl.test.tsx.snap new file mode 100644 index 000000000..615785fda --- /dev/null +++ b/src/test/Settings/__snapshots__/DeletionDialogControl.test.tsx.snap @@ -0,0 +1,465 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders correctly 1`] = ` +
    +
    +
    +
    + + +
    +
    + + +
    +
    +
    +
    + + +
    +
    +`; From d46def9500b31429dbb2edc04f7ae8b7e198e9b2 Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Tue, 10 Jan 2023 05:01:48 -0500 Subject: [PATCH 10/24] test(settings): add tests for feature level panel --- src/test/Settings/FeatureLevels.test.tsx | 89 ++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 src/test/Settings/FeatureLevels.test.tsx diff --git a/src/test/Settings/FeatureLevels.test.tsx b/src/test/Settings/FeatureLevels.test.tsx new file mode 100644 index 000000000..89ead9db7 --- /dev/null +++ b/src/test/Settings/FeatureLevels.test.tsx @@ -0,0 +1,89 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import { FeatureLevels } from '@app/Settings/FeatureLevels'; +import { defaultServices } from '@app/Shared/Services/Services'; +import { FeatureLevel } from '@app/Shared/Services/Settings.service'; +import { cleanup, screen, act } from '@testing-library/react'; +import * as React from 'react'; +import { of } from 'rxjs'; +import { renderWithServiceContext } from '../Common'; + +jest.spyOn(defaultServices.settings, 'featureLevel').mockReturnValue(of(FeatureLevel.PRODUCTION)); + +describe('', () => { + beforeEach(() => { + jest.mocked(defaultServices.settings.setFeatureLevel).mockClear(); + }); + + afterEach(cleanup); + + it('should show PRODUCTION as default', async () => { + renderWithServiceContext(React.createElement(FeatureLevels.content, null)); + + const productionOption = screen.getByText(FeatureLevel[FeatureLevel.PRODUCTION]); + expect(productionOption).toBeInTheDocument(); + expect(productionOption).toBeVisible(); + }); + + it('should set value to local storage when congfigured', async () => { + const { user } = renderWithServiceContext(React.createElement(FeatureLevels.content, null)); + + const featureLevelSelect = screen.getByLabelText('Options menu'); + expect(featureLevelSelect).toBeInTheDocument(); + expect(featureLevelSelect).toBeVisible(); + + await act(async () => { + await user.click(featureLevelSelect); + }); + + const ul = screen.getByRole('listbox'); + expect(ul).toBeInTheDocument(); + expect(ul).toBeVisible(); + + await user.selectOptions(ul, FeatureLevel[FeatureLevel.BETA]); + + expect(ul).not.toBeInTheDocument(); // Should close menu + + const betaOption = screen.getByText(FeatureLevel[FeatureLevel.BETA]); + expect(betaOption).toBeInTheDocument(); + expect(betaOption).toBeVisible(); + + const productionOption = screen.queryByText(FeatureLevel[FeatureLevel.PRODUCTION]); + expect(productionOption).not.toBeInTheDocument(); + }); +}); From ae058fcf32f78dce648d4be6ca45583b5697703b Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Tue, 10 Jan 2023 05:33:15 -0500 Subject: [PATCH 11/24] test(settings): add tests for language select --- jest.config.js | 3 +- src/test/Settings/Language.test.tsx | 76 +++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 src/test/Settings/Language.test.tsx diff --git a/jest.config.js b/jest.config.js index c45553aa2..0724ffd0a 100644 --- a/jest.config.js +++ b/jest.config.js @@ -25,7 +25,8 @@ module.exports = { moduleNameMapper: { '\\.(css|less)$': '/__mocks__/styleMock.js', "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/__mocks__/fileMock.js", - "@app/(.*)": '/src/app/$1' + "@app/(.*)": '/src/app/$1', + "@i18n/(.*)": '/src/i18n/$1', }, // A preset that is used as a base for Jest's configuration diff --git a/src/test/Settings/Language.test.tsx b/src/test/Settings/Language.test.tsx new file mode 100644 index 000000000..c69caf8f1 --- /dev/null +++ b/src/test/Settings/Language.test.tsx @@ -0,0 +1,76 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { cleanup, screen } from '@testing-library/react'; +import * as React from 'react'; +import { Language } from '../../app/Settings/Language'; +import { renderDefault } from '../Common'; + +const mockDetectedLocale = 'en'; +const mockDetectedLocaleAsReadable = 'English'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => [ + jest.fn((str: string) => str), + { + changeLanguage: () => new Promise(() => {}), + language: mockDetectedLocale, + }, + ], +})); + +jest.mock('@i18n/config', () => ({ + i18nResources: { + en: { + public: {}, + common: {}, + }, + }, +})); + +describe('', () => { + afterEach(cleanup); + + it('should default to detected language', async () => { + renderDefault(React.createElement(Language.content, null)); + + const defaultLocale = screen.getByText(mockDetectedLocaleAsReadable); + expect(defaultLocale).toBeInTheDocument(); + expect(defaultLocale).toBeVisible(); + }); +}); From a650631fd2bee2f817aefb47449930fc66023ce3 Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Tue, 10 Jan 2023 05:53:35 -0500 Subject: [PATCH 12/24] test(settings): add tests for websocket debounce input --- src/test/Settings/WebSocketDebounce.test.tsx | 97 +++++++++++++++++ .../WebSocketDebounce.test.tsx.snap | 103 ++++++++++++++++++ 2 files changed, 200 insertions(+) create mode 100644 src/test/Settings/WebSocketDebounce.test.tsx create mode 100644 src/test/Settings/__snapshots__/WebSocketDebounce.test.tsx.snap diff --git a/src/test/Settings/WebSocketDebounce.test.tsx b/src/test/Settings/WebSocketDebounce.test.tsx new file mode 100644 index 000000000..ae227aaa1 --- /dev/null +++ b/src/test/Settings/WebSocketDebounce.test.tsx @@ -0,0 +1,97 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import * as React from 'react'; +import { ServiceContext, defaultServices } from '@app/Shared/Services/Services'; +import renderer, { act } from 'react-test-renderer'; +import { WebSocketDebounce } from '@app/Settings/WebSocketDebounce'; +import { cleanup, screen } from '@testing-library/react'; +import { renderDefault } from '../Common'; + +const defaultValue = 100; + +jest.spyOn(defaultServices.settings, 'webSocketDebounceMs').mockReturnValue(defaultValue); + +describe('', () => { + beforeEach(() => { + jest.mocked(defaultServices.settings.webSocketDebounceMs).mockClear(); + }); + + afterEach(cleanup); + + it('renders correctly', async () => { + let tree; + await act(async () => { + tree = renderer.create( + + {React.createElement(WebSocketDebounce.content, null)} + + ); + }); + expect(tree.toJSON()).toMatchSnapshot(); + }); + + it('should set correct default period', async () => { + renderDefault(React.createElement(WebSocketDebounce.content, null)); + + const defaultInput = document.querySelector("input[type='number']") as HTMLInputElement; + expect(defaultInput).toBeInTheDocument(); + expect(defaultInput).toBeVisible(); + expect(defaultInput.getAttribute('value')).toBe(`${defaultValue}`); + }); + + it('should save to local storage when config is changed', async () => { + const { user } = renderDefault(React.createElement(WebSocketDebounce.content, null)); + + const defaultInput = document.querySelector("input[type='number']") as HTMLInputElement; + expect(defaultInput).toBeInTheDocument(); + expect(defaultInput).toBeVisible(); + expect(defaultInput.getAttribute('value')).toBe(`${defaultValue}`); + + const plusButton = screen.getByLabelText('Plus'); + expect(plusButton).toBeInTheDocument(); + expect(plusButton).toBeVisible(); + + await user.click(plusButton); + + const newInput = document.querySelector("input[type='number']") as HTMLInputElement; + expect(newInput).toBeInTheDocument(); + expect(newInput).toBeVisible(); + expect(defaultInput.getAttribute('value')).toBe(`${defaultValue + 1}`); + }); +}); diff --git a/src/test/Settings/__snapshots__/WebSocketDebounce.test.tsx.snap b/src/test/Settings/__snapshots__/WebSocketDebounce.test.tsx.snap new file mode 100644 index 000000000..e73064184 --- /dev/null +++ b/src/test/Settings/__snapshots__/WebSocketDebounce.test.tsx.snap @@ -0,0 +1,103 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders correctly 1`] = ` +
    +
    + + + +
    +
    + ms +
    +
    +`; From d0e60a099e2dc80caf5dff3f818f3a78c157d37d Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Tue, 10 Jan 2023 06:51:34 -0500 Subject: [PATCH 13/24] test(settings): add tests for notification control --- .../Settings/NotificationControl.test.tsx | 143 ++ .../NotificationControl.test.tsx.snap | 1211 +++++++++++++++++ 2 files changed, 1354 insertions(+) create mode 100644 src/test/Settings/NotificationControl.test.tsx create mode 100644 src/test/Settings/__snapshots__/NotificationControl.test.tsx.snap diff --git a/src/test/Settings/NotificationControl.test.tsx b/src/test/Settings/NotificationControl.test.tsx new file mode 100644 index 000000000..55d623413 --- /dev/null +++ b/src/test/Settings/NotificationControl.test.tsx @@ -0,0 +1,143 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import { NotificationControl } from '@app/Settings/NotificationControl'; +import { NotificationCategory } from '@app/Shared/Services/NotificationChannel.service'; +import { defaultServices, ServiceContext } from '@app/Shared/Services/Services'; +import { act as doAct, cleanup, screen } from '@testing-library/react'; +import * as React from 'react'; +import renderer, { act } from 'react-test-renderer'; +import { BehaviorSubject } from 'rxjs'; +import { renderWithServiceContext } from '../Common'; + +const defaultNumOfNotifications = 5; +const defaults = new Map(); +for (const cat in NotificationCategory) { + defaults.set(NotificationCategory[cat], true); +} + +const notiCountSubj = new BehaviorSubject(defaultNumOfNotifications); + +jest.spyOn(defaultServices.settings, 'notificationsEnabled').mockReturnValue(defaults); +jest.spyOn(defaultServices.settings, 'visibleNotificationsCount').mockReturnValue(notiCountSubj.asObservable()); +jest.spyOn(defaultServices.settings, 'setVisibleNotificationCount').mockImplementation((c) => notiCountSubj.next(c)); + +describe('', () => { + beforeEach(() => { + jest.mocked(defaultServices.settings.notificationsEnabled).mockClear(); + jest.mocked(defaultServices.settings.visibleNotificationsCount).mockClear(); + jest.mocked(defaultServices.settings.setVisibleNotificationCount).mockClear(); + }); + + afterEach(cleanup); + + it('renders correctly', async () => { + let tree; + await act(async () => { + tree = renderer.create( + + {React.createElement(NotificationControl.content, null)} + + ); + }); + expect(tree.toJSON()).toMatchSnapshot(); + }); + + it('should default to enable all notifications', async () => { + renderWithServiceContext(React.createElement(NotificationControl.content, null)); + + const enableSwitch = screen.getByLabelText('All Notifications'); + expect(enableSwitch).toBeInTheDocument(); + expect(enableSwitch).toBeVisible(); + expect(enableSwitch).toBeChecked(); + }); + + it('should default to correct max number of notification alerts', async () => { + renderWithServiceContext(React.createElement(NotificationControl.content, null)); + + const maxInput = document.querySelector("input[type='number']"); + expect(maxInput).toBeInTheDocument(); + expect(maxInput).toBeVisible(); + + expect(maxInput?.getAttribute('value')).toBe(`${defaultNumOfNotifications}`); + }); + + it('should save to local storage when max number of alerts is changed', async () => { + const { user } = renderWithServiceContext(React.createElement(NotificationControl.content, null)); + + const maxInput = document.querySelector("input[type='number']"); + expect(maxInput).toBeInTheDocument(); + expect(maxInput).toBeVisible(); + + expect(maxInput?.getAttribute('value')).toBe(`${defaultNumOfNotifications}`); + + const plusButton = screen.getByLabelText('Plus'); + expect(plusButton).toBeInTheDocument(); + expect(plusButton).toBeVisible(); + + await doAct(async () => { + await user.click(plusButton); + }); + + expect(maxInput?.getAttribute('value')).toBe(`${defaultNumOfNotifications + 1}`); + }); + + it('should turn off enable-all switch if any child switch is off', async () => { + const { user } = renderWithServiceContext(React.createElement(NotificationControl.content, null)); + + const enableSwitch = screen.getByLabelText('All Notifications'); + expect(enableSwitch).toBeInTheDocument(); + expect(enableSwitch).toBeVisible(); + expect(enableSwitch).toBeChecked(); + + const expandButton = screen.getByText('Show more'); + expect(expandButton).toBeInTheDocument(); + expect(expandButton).toBeVisible(); + + await user.click(expandButton); + + const webSocketAct = screen.getByLabelText('WebSocket Client Activity'); + expect(webSocketAct).toBeInTheDocument(); + expect(webSocketAct).toBeVisible(); + expect(webSocketAct).toBeChecked(); + + await user.click(webSocketAct); + + expect(webSocketAct).not.toBeChecked(); + expect(enableSwitch).not.toBeChecked(); + }); +}); diff --git a/src/test/Settings/__snapshots__/NotificationControl.test.tsx.snap b/src/test/Settings/__snapshots__/NotificationControl.test.tsx.snap new file mode 100644 index 000000000..158583101 --- /dev/null +++ b/src/test/Settings/__snapshots__/NotificationControl.test.tsx.snap @@ -0,0 +1,1211 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders correctly 1`] = ` +
    +
    +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    + + + +
    +
    + +
    +
    +
    +
    + + +
    +
    +`; From a613eb3444d69b8fa3be236d33c8f4677ae25c8e Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Tue, 10 Jan 2023 07:25:54 -0500 Subject: [PATCH 14/24] test(settings): add tests for automatic analysis config --- .../Settings/AutomatedAnalysisConfig.test.tsx | 82 ++++++++++ .../AutomatedAnalysisConfig.test.tsx.snap | 144 ++++++++++++++++++ 2 files changed, 226 insertions(+) create mode 100644 src/test/Settings/AutomatedAnalysisConfig.test.tsx create mode 100644 src/test/Settings/__snapshots__/AutomatedAnalysisConfig.test.tsx.snap diff --git a/src/test/Settings/AutomatedAnalysisConfig.test.tsx b/src/test/Settings/AutomatedAnalysisConfig.test.tsx new file mode 100644 index 000000000..557fb4276 --- /dev/null +++ b/src/test/Settings/AutomatedAnalysisConfig.test.tsx @@ -0,0 +1,82 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/* eslint @typescript-eslint/no-explicit-any: 0 */ +import { AutomatedAnalysisConfig } from '@app/Settings/AutomatedAnalysisConfig'; +import { defaultAutomatedAnalysisRecordingConfig } from '@app/Shared/Services/Api.service'; +import { defaultServices, ServiceContext } from '@app/Shared/Services/Services'; +import { screen } from '@testing-library/react'; +import * as React from 'react'; +import renderer, { act } from 'react-test-renderer'; +import { renderWithServiceContext } from '../Common'; + +jest.mock('@app/Dashboard/AutomatedAnalysis/AutomatedAnalysisConfigForm', () => ({ + AutomatedAnalysisConfigForm: (_: any) => <>Automated Analysis Configuration Form, +})); + +jest.mock('@app/TargetSelect/TargetSelect', () => ({ + TargetSelect: (_: any) => <>Target Select, +})); + +jest + .spyOn(defaultServices.settings, 'automatedAnalysisRecordingConfig') + .mockReturnValue(defaultAutomatedAnalysisRecordingConfig); + +describe('', () => { + it('renders correctly', async () => { + let tree; + await act(async () => { + tree = renderer.create( + + {React.createElement(AutomatedAnalysisConfig.content, null)} + + ); + }); + expect(tree.toJSON()).toMatchSnapshot(); + }); + + it('should display current configurations', async () => { + renderWithServiceContext(React.createElement(AutomatedAnalysisConfig.content, null)); + + Object.values(defaultAutomatedAnalysisRecordingConfig).forEach((v) => { + const currentConfig = screen.getByText(v); + expect(currentConfig).toBeInTheDocument(); + expect(currentConfig).toBeVisible(); + }); + }); +}); diff --git a/src/test/Settings/__snapshots__/AutomatedAnalysisConfig.test.tsx.snap b/src/test/Settings/__snapshots__/AutomatedAnalysisConfig.test.tsx.snap new file mode 100644 index 000000000..e51bef445 --- /dev/null +++ b/src/test/Settings/__snapshots__/AutomatedAnalysisConfig.test.tsx.snap @@ -0,0 +1,144 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders correctly 1`] = ` +
    +
    + Target Select +
    +
    +

    + Current configuration +

    +
    +
    +
    +
    +
    + + Template + +
    +
    +
    + template=Continuous,type=TARGET +
    +
    +
    +
    +
    + + Max Size (B) + +
    +
    +
    + 2048 +
    +
    +
    +
    +
    + + Max Age (s) + +
    +
    +
    + 0 +
    +
    +
    +
    +
    +
    + + +
    +
    +`; From 7471d596e3016d6f38781a413396231c212cf517 Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Tue, 10 Jan 2023 07:56:22 -0500 Subject: [PATCH 15/24] chore(eslint): apply eslint fixes Signed-off-by: Thuan Vo --- src/test/Settings/Language.test.tsx | 2 +- src/test/Settings/Settings.test.tsx | 1 + src/test/Settings/WebSocketDebounce.test.tsx | 27 +++++++++----------- 3 files changed, 14 insertions(+), 16 deletions(-) diff --git a/src/test/Settings/Language.test.tsx b/src/test/Settings/Language.test.tsx index c69caf8f1..3b10b0992 100644 --- a/src/test/Settings/Language.test.tsx +++ b/src/test/Settings/Language.test.tsx @@ -48,7 +48,7 @@ jest.mock('react-i18next', () => ({ useTranslation: () => [ jest.fn((str: string) => str), { - changeLanguage: () => new Promise(() => {}), + changeLanguage: () => new Promise(() => undefined), language: mockDetectedLocale, }, ], diff --git a/src/test/Settings/Settings.test.tsx b/src/test/Settings/Settings.test.tsx index ac3d9aa62..83c53dd4e 100644 --- a/src/test/Settings/Settings.test.tsx +++ b/src/test/Settings/Settings.test.tsx @@ -37,6 +37,7 @@ */ // Must import before @app/Settings/Settings (circular deps) +/* eslint import/order: 0*/ import { FeatureLevel } from '@app/Shared/Services/Settings.service'; import { Settings } from '@app/Settings/Settings'; import { defaultServices, ServiceContext } from '@app/Shared/Services/Services'; diff --git a/src/test/Settings/WebSocketDebounce.test.tsx b/src/test/Settings/WebSocketDebounce.test.tsx index ae227aaa1..45a726031 100644 --- a/src/test/Settings/WebSocketDebounce.test.tsx +++ b/src/test/Settings/WebSocketDebounce.test.tsx @@ -36,11 +36,11 @@ * SOFTWARE. */ -import * as React from 'react'; -import { ServiceContext, defaultServices } from '@app/Shared/Services/Services'; -import renderer, { act } from 'react-test-renderer'; import { WebSocketDebounce } from '@app/Settings/WebSocketDebounce'; +import { ServiceContext, defaultServices } from '@app/Shared/Services/Services'; import { cleanup, screen } from '@testing-library/react'; +import * as React from 'react'; +import renderer, { act } from 'react-test-renderer'; import { renderDefault } from '../Common'; const defaultValue = 100; @@ -69,19 +69,19 @@ describe('', () => { it('should set correct default period', async () => { renderDefault(React.createElement(WebSocketDebounce.content, null)); - const defaultInput = document.querySelector("input[type='number']") as HTMLInputElement; - expect(defaultInput).toBeInTheDocument(); - expect(defaultInput).toBeVisible(); - expect(defaultInput.getAttribute('value')).toBe(`${defaultValue}`); + const webSocketDebounceInput = document.querySelector("input[type='number']") as HTMLInputElement; + expect(webSocketDebounceInput).toBeInTheDocument(); + expect(webSocketDebounceInput).toBeVisible(); + expect(webSocketDebounceInput.getAttribute('value')).toBe(`${defaultValue}`); }); it('should save to local storage when config is changed', async () => { const { user } = renderDefault(React.createElement(WebSocketDebounce.content, null)); - const defaultInput = document.querySelector("input[type='number']") as HTMLInputElement; - expect(defaultInput).toBeInTheDocument(); - expect(defaultInput).toBeVisible(); - expect(defaultInput.getAttribute('value')).toBe(`${defaultValue}`); + const webSocketDebounceInput = document.querySelector("input[type='number']") as HTMLInputElement; + expect(webSocketDebounceInput).toBeInTheDocument(); + expect(webSocketDebounceInput).toBeVisible(); + expect(webSocketDebounceInput.getAttribute('value')).toBe(`${defaultValue}`); const plusButton = screen.getByLabelText('Plus'); expect(plusButton).toBeInTheDocument(); @@ -89,9 +89,6 @@ describe('', () => { await user.click(plusButton); - const newInput = document.querySelector("input[type='number']") as HTMLInputElement; - expect(newInput).toBeInTheDocument(); - expect(newInput).toBeVisible(); - expect(defaultInput.getAttribute('value')).toBe(`${defaultValue + 1}`); + expect(webSocketDebounceInput.getAttribute('value')).toBe(`${defaultValue + 1}`); }); }); From 4d526dbea9f1c3823f33d7b0e10e1e3cd3525a97 Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Tue, 10 Jan 2023 12:28:44 -0500 Subject: [PATCH 16/24] chore(settings): remove unused classNames --- src/app/Settings/Settings.tsx | 2 +- src/test/Settings/__snapshots__/Settings.test.tsx.snap | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/Settings/Settings.tsx b/src/app/Settings/Settings.tsx index d0c9040a8..abee166ba 100644 --- a/src/app/Settings/Settings.tsx +++ b/src/app/Settings/Settings.tsx @@ -152,7 +152,7 @@ export const Settings: React.FC = (_) => { return ( <> - + renders correctly 1`] = ` className="pf-l-stack__item pf-m-fill" >
    Date: Tue, 10 Jan 2023 13:14:50 -0500 Subject: [PATCH 17/24] feat(settings): add link to language setting on user icon --- src/app/AppLayout/AppLayout.tsx | 12 +++++++++++- src/app/Settings/Settings.tsx | 7 +++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/app/AppLayout/AppLayout.tsx b/src/app/AppLayout/AppLayout.tsx index f766d5b3f..b8a11962f 100644 --- a/src/app/AppLayout/AppLayout.tsx +++ b/src/app/AppLayout/AppLayout.tsx @@ -265,6 +265,10 @@ const AppLayout: React.FC = ({ children }) => { addSubscription(serviceContext.login.setLoggedOut().subscribe()); }, [serviceContext.login, addSubscription]); + const handleLanguagePref = React.useCallback(() => { + routerHistory.push('/settings', { preSelectedTab: 'Language & Region' }); + }, [routerHistory]); + const handleUserInfoToggle = React.useCallback(() => setShowUserInfoDropdown((v) => !v), [setShowUserInfoDropdown]); React.useEffect(() => { @@ -273,11 +277,16 @@ const AppLayout: React.FC = ({ children }) => { const userInfoItems = React.useMemo( () => [ + + + Language preference + + , Log out , ], - [handleLogout] + [handleLogout, handleLanguagePref] ); const UserInfoToggle = React.useMemo( @@ -397,6 +406,7 @@ const AppLayout: React.FC = ({ children }) => { onSelect={() => setShowUserInfoDropdown(false)} isOpen={showUserInfoDropdown} toggle={UserInfoToggle} + position="right" dropdownItems={userInfoItems} /> diff --git a/src/app/Settings/Settings.tsx b/src/app/Settings/Settings.tsx index abee166ba..8c4425b38 100644 --- a/src/app/Settings/Settings.tsx +++ b/src/app/Settings/Settings.tsx @@ -64,7 +64,7 @@ import { FeatureLevels } from './FeatureLevels'; import { Language } from './Language'; import { NotificationControl } from './NotificationControl'; import { WebSocketDebounce } from './WebSocketDebounce'; - +import { useLocation } from 'react-router-dom'; export interface SettingGroup { groupLabel: SettingCategory; featureLevel: FeatureLevel; @@ -130,7 +130,10 @@ export const Settings: React.FC = (_) => { } as _TransformedUserSetting) ); - const [activeTab, setActiveTab] = React.useState('General'); + const location = useLocation(); + const [activeTab, setActiveTab] = React.useState( + (location.state?.preSelectedTab as SettingCategory) || 'General' + ); const onTabSelect = React.useCallback( (_: React.MouseEvent, eventKey: string | number) => From 440cdcb9f9f8ab7394c0e4c4916b2396904b07fd Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Tue, 10 Jan 2023 13:21:52 -0500 Subject: [PATCH 18/24] feat(login): add language selector for login page --- src/app/Login/Login.tsx | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/src/app/Login/Login.tsx b/src/app/Login/Login.tsx index 4f32fa1b7..45f0e63eb 100644 --- a/src/app/Login/Login.tsx +++ b/src/app/Login/Login.tsx @@ -38,13 +38,26 @@ import { AuthMethod } from '@app/Shared/Services/Login.service'; import { ServiceContext } from '@app/Shared/Services/Services'; import { useSubscriptions } from '@app/utils/useSubscriptions'; -import { Card, CardBody, CardFooter, CardTitle, PageSection, Text } from '@patternfly/react-core'; +import { + Card, + CardActions, + CardBody, + CardFooter, + CardHeader, + CardTitle, + PageSection, + Text, + Title, +} from '@patternfly/react-core'; import * as React from 'react'; import { NotificationsContext } from '../Notifications/Notifications'; import { BasicAuthDescriptionText, BasicAuthForm } from './BasicAuthForm'; import { ConnectionError } from './ConnectionError'; import { NoopAuthForm } from './NoopAuthForm'; import { OpenShiftAuthDescriptionText, OpenShiftPlaceholderAuthForm } from './OpenShiftPlaceholderAuthForm'; +import { Language } from '@app/Settings/Language'; +import { FeatureFlag } from '@app/Shared/FeatureFlag/FeatureFlag'; +import { FeatureLevel } from '@app/Shared/Services/Settings.service'; export interface LoginProps {} @@ -100,7 +113,12 @@ export const Login: React.FC = (_) => { return ( - Login + + Login + + {React.createElement(Language.content, null)} + + {loginForm} {descriptionText} From 5345919849ecee200290855ea9ebef00377e4b80 Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Tue, 10 Jan 2023 13:33:09 -0500 Subject: [PATCH 19/24] test(settings): wrap setting comp in router --- src/test/Rules/Rules.test.tsx | 2 +- src/test/Settings/Settings.test.tsx | 16 +++++++++++----- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/test/Rules/Rules.test.tsx b/src/test/Rules/Rules.test.tsx index cde7daefb..191b73ddd 100644 --- a/src/test/Rules/Rules.test.tsx +++ b/src/test/Rules/Rules.test.tsx @@ -83,7 +83,7 @@ jest.mock('react-router-dom', () => ({ useHistory: () => history, })); -const downloadSpy = jest.spyOn(defaultServices.api, 'downloadRule').mockReturnValue(); +const downloadSpy = jest.spyOn(defaultServices.api, 'downloadRule'); const uploadSpy = jest.spyOn(defaultServices.api, 'uploadRule').mockReturnValue(of(true)); const updateSpy = jest.spyOn(defaultServices.api, 'updateRule').mockReturnValue(of(true)); diff --git a/src/test/Settings/Settings.test.tsx b/src/test/Settings/Settings.test.tsx index 83c53dd4e..d58f14027 100644 --- a/src/test/Settings/Settings.test.tsx +++ b/src/test/Settings/Settings.test.tsx @@ -47,7 +47,9 @@ import { cleanup, screen } from '@testing-library/react'; import * as React from 'react'; import renderer, { act } from 'react-test-renderer'; import { of } from 'rxjs'; -import { renderWithServiceContext } from '../Common'; +import { renderWithServiceContextAndRouter } from '../Common'; +import { createMemoryHistory } from 'history'; +import { Router } from 'react-router-dom'; jest.mock('@app/Settings/NotificationControl', () => ({ NotificationControl: { @@ -124,6 +126,8 @@ jest.mock('@app/Settings/Language', () => ({ jest.spyOn(defaultServices.settings, 'featureLevel').mockReturnValue(of(FeatureLevel.PRODUCTION)); +const history = createMemoryHistory({ initialEntries: ['/settings'] }); + describe('', () => { afterEach(cleanup); @@ -132,7 +136,9 @@ describe('', () => { await act(async () => { tree = renderer.create( - + + + ); }); @@ -142,14 +148,14 @@ describe('', () => { // This test will check if language setting (BETA) is being hidden. // Update this test when language setting is in PRODUCTION. it('should not show tabs with featureLevel lower than current', async () => { - renderWithServiceContext(); + renderWithServiceContextAndRouter(); const hiddenTab = screen.queryByText('Language & Region'); expect(hiddenTab).not.toBeInTheDocument(); }); it('should select General tab as default', async () => { - renderWithServiceContext(); + renderWithServiceContextAndRouter(); const generalTab = screen.getByRole('tab', { name: 'General' }); expect(generalTab).toBeInTheDocument(); @@ -158,7 +164,7 @@ describe('', () => { }); it('should update setting content when a tab is selected', async () => { - const { user } = renderWithServiceContext(); + const { user } = renderWithServiceContextAndRouter(); const dashboardTab = screen.getByRole('tab', { name: 'Dashboard' }); expect(dashboardTab).toBeInTheDocument(); From 63bf19728df88749721c4bc139a9b3e1d268f263 Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Tue, 10 Jan 2023 13:49:54 -0500 Subject: [PATCH 20/24] chore(eslint): apply eslint fixes --- src/app/AppLayout/AppLayout.tsx | 2 +- src/app/Login/Login.tsx | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/app/AppLayout/AppLayout.tsx b/src/app/AppLayout/AppLayout.tsx index b8a11962f..cbe4facc0 100644 --- a/src/app/AppLayout/AppLayout.tsx +++ b/src/app/AppLayout/AppLayout.tsx @@ -277,7 +277,7 @@ const AppLayout: React.FC = ({ children }) => { const userInfoItems = React.useMemo( () => [ - + Language preference diff --git a/src/app/Login/Login.tsx b/src/app/Login/Login.tsx index 45f0e63eb..e1154d241 100644 --- a/src/app/Login/Login.tsx +++ b/src/app/Login/Login.tsx @@ -47,7 +47,6 @@ import { CardTitle, PageSection, Text, - Title, } from '@patternfly/react-core'; import * as React from 'react'; import { NotificationsContext } from '../Notifications/Notifications'; From f4e81fec54978f7c5626c062629755f06e992b81 Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Tue, 10 Jan 2023 13:58:46 -0500 Subject: [PATCH 21/24] !fixup(layout): should select language tab if on /settings route --- src/app/AppLayout/AppLayout.tsx | 7 ++++- src/app/Settings/Settings.tsx | 27 ++++++++++++------- .../__snapshots__/Settings.test.tsx.snap | 16 +++++------ 3 files changed, 31 insertions(+), 19 deletions(-) diff --git a/src/app/AppLayout/AppLayout.tsx b/src/app/AppLayout/AppLayout.tsx index cbe4facc0..c5a10654d 100644 --- a/src/app/AppLayout/AppLayout.tsx +++ b/src/app/AppLayout/AppLayout.tsx @@ -41,6 +41,7 @@ import build from '@app/build.json'; import { NotificationCenter } from '@app/Notifications/NotificationCenter'; import { Notification, NotificationsContext } from '@app/Notifications/Notifications'; import { IAppRoute, navGroups, routes } from '@app/routes'; +import { selectTab } from '@app/Settings/Settings'; import { DynamicFeatureFlag, FeatureFlag } from '@app/Shared/FeatureFlag/FeatureFlag'; import { SessionState } from '@app/Shared/Services/Login.service'; import { NotificationCategory } from '@app/Shared/Services/NotificationChannel.service'; @@ -266,7 +267,11 @@ const AppLayout: React.FC = ({ children }) => { }, [serviceContext.login, addSubscription]); const handleLanguagePref = React.useCallback(() => { - routerHistory.push('/settings', { preSelectedTab: 'Language & Region' }); + if (routerHistory.location.pathname === '/settings') { + selectTab('Language & Region'); + } else { + routerHistory.push('/settings', { preSelectedTab: 'Language & Region' }); + } }, [routerHistory]); const handleUserInfoToggle = React.useCallback(() => setShowUserInfoDropdown((v) => !v), [setShowUserInfoDropdown]); diff --git a/src/app/Settings/Settings.tsx b/src/app/Settings/Settings.tsx index 8c4425b38..c0cc64dad 100644 --- a/src/app/Settings/Settings.tsx +++ b/src/app/Settings/Settings.tsx @@ -65,6 +65,18 @@ import { Language } from './Language'; import { NotificationControl } from './NotificationControl'; import { WebSocketDebounce } from './WebSocketDebounce'; import { useLocation } from 'react-router-dom'; +import { hashCode } from '@app/utils/utils'; + +const _SettingCategories = [ + 'General', + 'Language & Region', + 'Notifications & Messages', + 'Dashboard', + 'Advanced', +] as const; + +export type SettingCategory = typeof _SettingCategories[number]; + export interface SettingGroup { groupLabel: SettingCategory; featureLevel: FeatureLevel; @@ -79,15 +91,10 @@ const _getGroupFeatureLevel = (settings: _TransformedUserSetting[]): FeatureLeve return settings.slice().sort((a, b) => b.featureLevel - a.featureLevel)[0].featureLevel; }; -const _SettingCategories = [ - 'General', - 'Language & Region', - 'Notifications & Messages', - 'Dashboard', - 'Advanced', -] as const; - -export type SettingCategory = typeof _SettingCategories[number]; +export const selectTab = (tabName: SettingCategory) => { + const tab = document.getElementById(`pf-tab-${tabName}-${hashCode(tabName)}`); + tab && tab.click(); +}; export interface UserSetting { title: string; @@ -240,7 +247,7 @@ interface SettingTabProps extends TabProps { const SettingTab: React.FC = ({ featureLevelConfig, eventKey, title, children }) => { return ( - + {children} diff --git a/src/test/Settings/__snapshots__/Settings.test.tsx.snap b/src/test/Settings/__snapshots__/Settings.test.tsx.snap index d99cdad16..0d0f53690 100644 --- a/src/test/Settings/__snapshots__/Settings.test.tsx.snap +++ b/src/test/Settings/__snapshots__/Settings.test.tsx.snap @@ -66,12 +66,12 @@ exports[` renders correctly 1`] = ` role="presentation" > @@ -261,57 +261,6 @@ exports[` renders correctly 1`] = `

    -
    -
    - - -
    -
    -
    -
    - - Feature Levels Description - -
    -
    -

    - Feature Levels Component -

    -
    -
    From 044eb08ae43f69b0a742dda0771363f1841351f0 Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Tue, 10 Jan 2023 15:10:32 -0500 Subject: [PATCH 23/24] fix(logout): redirect to / if on /settings --- src/app/Shared/Services/Login.service.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app/Shared/Services/Login.service.tsx b/src/app/Shared/Services/Login.service.tsx index 6f3e960fd..dbd5bf7f2 100644 --- a/src/app/Shared/Services/Login.service.tsx +++ b/src/app/Shared/Services/Login.service.tsx @@ -254,7 +254,8 @@ export class LoginService { private navigateToLoginPage(): void { this.authMethod.next(AuthMethod.UNKNOWN); this.removeCacheItem(this.AUTH_METHOD_KEY); - window.location.href = window.location.href.split('#')[0]; + const url = new URL(window.location.href.split('#')[0]); + window.location.href = url.pathname.match(/\/settings/i) ? '/' : url.pathname; } private getTokenFromUrlFragment(): string { From e7186ef0c0283debdd5a6ad2cb4ab0dba63a5ea8 Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Tue, 10 Jan 2023 18:56:43 -0500 Subject: [PATCH 24/24] chore(prettier): apply prettier --- src/app/Settings/Settings.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/Settings/Settings.tsx b/src/app/Settings/Settings.tsx index ef905ba42..1d0f1c87f 100644 --- a/src/app/Settings/Settings.tsx +++ b/src/app/Settings/Settings.tsx @@ -75,7 +75,7 @@ const _SettingCategories = [ 'Advanced', ] as const; -export type SettingCategory = typeof _SettingCategories[number]; +export type SettingCategory = (typeof _SettingCategories)[number]; export interface SettingGroup { groupLabel: SettingCategory;