diff --git a/public/app/percona/shared/components/PerconaBootstrapper/PerconaBootstrapper.tsx b/public/app/percona/shared/components/PerconaBootstrapper/PerconaBootstrapper.tsx index cd4b2fd301b95..c107e57b1a0e8 100644 --- a/public/app/percona/shared/components/PerconaBootstrapper/PerconaBootstrapper.tsx +++ b/public/app/percona/shared/components/PerconaBootstrapper/PerconaBootstrapper.tsx @@ -14,7 +14,9 @@ import { fetchUserStatusAction, setAuthorized, } from 'app/percona/shared/core/reducers/user/user'; +import { getUpdatesInfo } from 'app/percona/shared/core/selectors'; import { useAppDispatch } from 'app/store/store'; +import { useSelector } from 'app/types'; import { Telemetry } from '../../../ui-events/components/Telemetry'; import usePerconaTour from '../../core/hooks/tour'; @@ -27,11 +29,13 @@ import { getStyles } from './PerconaBootstrapper.styles'; import { PerconaBootstrapperProps } from './PerconaBootstrapper.types'; import PerconaNavigation from './PerconaNavigation/PerconaNavigation'; import PerconaTourBootstrapper from './PerconaTour'; +import PerconaUpdateVersion from './PerconaUpdateVersion/PerconaUpdateVersion'; // This component is only responsible for populating the store with Percona's settings initially export const PerconaBootstrapper = ({ onReady }: PerconaBootstrapperProps) => { const dispatch = useAppDispatch(); const { setSteps, startTour: startPerconaTour, endTour } = usePerconaTour(); + const { updateAvailable, isLoading: isLoadingUpdates, showUpdateModal } = useSelector(getUpdatesInfo); const [modalIsOpen, setModalIsOpen] = useState(true); const [showTour, setShowTour] = useState(false); const styles = useStyles2(getStyles); @@ -104,49 +108,54 @@ export const PerconaBootstrapper = ({ onReady }: PerconaBootstrapperProps) => { {isSignedIn && } - {isSignedIn && showTour && ( - -
- -
-

- {Messages.pmm} - {Messages.pmmIs} -

-

- {Messages.pmmEnables} -

    -
  • {Messages.spotCriticalPerformance}
  • -
  • {Messages.ensureDbPerformance}
  • -
  • {Messages.backup}
  • -
-

-

- {Messages.moreInfo} - - {Messages.pmmOnlineHelp} - - . -

- - - - - - - -
+ {updateAvailable && showUpdateModal && !isLoadingUpdates ? ( + + ) : ( + isSignedIn && + showTour && ( + +
+ +
+

+ {Messages.pmm} + {Messages.pmmIs} +

+

+ {Messages.pmmEnables} +

    +
  • {Messages.spotCriticalPerformance}
  • +
  • {Messages.ensureDbPerformance}
  • +
  • {Messages.backup}
  • +
+

+

+ {Messages.moreInfo} + + {Messages.pmmOnlineHelp} + + . +

+ + + + + + + +
+ ) )} ); diff --git a/public/app/percona/shared/components/PerconaBootstrapper/PerconaUpdateVersion/PerconaUpdateVersion.constants.ts b/public/app/percona/shared/components/PerconaBootstrapper/PerconaUpdateVersion/PerconaUpdateVersion.constants.ts new file mode 100644 index 0000000000000..2f35d18455960 --- /dev/null +++ b/public/app/percona/shared/components/PerconaBootstrapper/PerconaUpdateVersion/PerconaUpdateVersion.constants.ts @@ -0,0 +1,12 @@ +export const Messages = { + titleOneUpdate: 'New update available', + titleMultipleUpdates: 'New updates available', + howToUpdate: 'How to update', + howToUpdateDescription: + "We are inaugurating a new process for updating PMM. It's a new interface with an improved user experience and is ready for the future of PMM. Click on Go to Updates Page to find out more.", + newVersions: 'New Versions', + readMore: 'Read more', + fullReleaseNotes: 'Full release notes here', + goToUpdatesPage: 'Go to updates page', + snooze: 'Dismiss', +}; diff --git a/public/app/percona/shared/components/PerconaBootstrapper/PerconaUpdateVersion/PerconaUpdateVersion.styles.ts b/public/app/percona/shared/components/PerconaBootstrapper/PerconaUpdateVersion/PerconaUpdateVersion.styles.ts new file mode 100644 index 0000000000000..45dd1980d2bf0 --- /dev/null +++ b/public/app/percona/shared/components/PerconaBootstrapper/PerconaUpdateVersion/PerconaUpdateVersion.styles.ts @@ -0,0 +1,43 @@ +import { css } from '@emotion/css'; + +import { GrafanaTheme2 } from '@grafana/data'; + +export const getStyles = ({ v1: { spacing, colors, typography } }: GrafanaTheme2) => ({ + updateVersionModal: css` + display: flex; + flex-direction: column; + width: 480px; + `, + version: css` + margin-bottom: ${spacing.md}; + `, + releaseNotesText: css` + a { + color: ${colors.textBlue}; + } + `, + newVersionsTitle: css` + font-weight: ${typography.weight.semibold}; + font-size: ${typography.heading.h5}; + margin-bottom: 8px; + `, + howToUpdate: css` + font-weight: ${typography.weight.semibold}; + font-size: ${typography.heading.h5}; + margin-top: 27px; + `, + updateButtons: css` + margin-top: 35px; + display: flex; + justify-content: flex-end; + `, + snoozeButton: css` + margin-right: 20px; + `, + listOfReleaseNotes: css` + margin-left: 20px; + li a, li { + color: ${colors.textBlue}; + }, + `, +}); diff --git a/public/app/percona/shared/components/PerconaBootstrapper/PerconaUpdateVersion/PerconaUpdateVersion.test.tsx b/public/app/percona/shared/components/PerconaBootstrapper/PerconaUpdateVersion/PerconaUpdateVersion.test.tsx new file mode 100644 index 0000000000000..b4b5e435cdb1b --- /dev/null +++ b/public/app/percona/shared/components/PerconaBootstrapper/PerconaUpdateVersion/PerconaUpdateVersion.test.tsx @@ -0,0 +1,164 @@ +import { EnhancedStore } from '@reduxjs/toolkit'; +import { fireEvent, render, screen } from '@testing-library/react'; +import React from 'react'; +import { Provider } from 'react-redux'; +import { waitFor } from 'test/test-utils'; + +import { Messages } from 'app/percona/shared/components/PerconaBootstrapper/PerconaUpdateVersion/PerconaUpdateVersion.constants'; +import * as GrafanaUpdates from 'app/percona/shared/core/reducers/updates/updates'; +import * as User from 'app/percona/shared/core/reducers/user/user'; +import { UpdatesService } from 'app/percona/shared/services/updates'; +import { configureStore } from 'app/store/configureStore'; +import { StoreState } from 'app/types'; + +import PerconaUpdateVersion from './PerconaUpdateVersion'; + +const checkUpdatesChangeLogsSpy = jest.spyOn(GrafanaUpdates, 'checkUpdatesChangeLogs'); +const setSnoozedVersionSpy = jest.spyOn(User, 'setSnoozedVersion'); + +describe('PerconaUpdateVersion', () => { + const setup = (store: EnhancedStore) => + render( + + + + ); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render modal with one update', async () => { + const changeLogsAPIResponse = { + last_check: '', + updates: [ + { + version: 'PMM 3.0.1', + tag: 'string', + timestamp: '2024-09-23T09:12:31.488Z', + release_notes_url: 'http://localhost:3000', + release_notes_text: 'text1', + }, + ], + }; + const state = { + updates: { + isLoading: false, + updateAvailable: true, + latest: { version: '3.0.1' }, + lastChecked: '', + showUpdateModal: true, + }, + }; + jest.spyOn(UpdatesService, 'getUpdatesChangelogs').mockReturnValue(Promise.resolve({ ...changeLogsAPIResponse })); + + const defaultState = configureStore().getState(); + const store = configureStore({ + ...defaultState, + percona: { + ...defaultState.percona, + ...state, + }, + } as StoreState); + setup(store); + await waitFor(() => { + expect(checkUpdatesChangeLogsSpy).toHaveBeenCalled(); + }); + + expect(screen.queryByTestId('one-update-modal')).toBeInTheDocument(); + expect(screen.queryByTestId('multiple-updates-modal')).not.toBeInTheDocument(); + }); + + it('should render modal with multiple updates', async () => { + const changeLogsAPIResponse = { + last_check: '', + updates: [ + { + version: 'PMM 3.0.1', + tag: 'string', + timestamp: '2024-09-27T09:12:31.488Z', + release_notes_url: 'http://localhost:3000', + release_notes_text: 'text1', + }, + { + version: 'PMM 3.0.2', + tag: 'string', + timestamp: '2024-09-23T09:12:31.488Z', + release_notes_url: 'http://localhost:3000', + release_notes_text: 'text2', + }, + ], + }; + const state = { + updates: { + isLoading: false, + updateAvailable: true, + latest: { version: '3.0.1' }, + lastChecked: '', + showUpdateModal: true, + }, + }; + jest.spyOn(UpdatesService, 'getUpdatesChangelogs').mockReturnValue(Promise.resolve({ ...changeLogsAPIResponse })); + + const defaultState = configureStore().getState(); + const store = configureStore({ + ...defaultState, + percona: { + ...defaultState.percona, + ...state, + }, + } as StoreState); + + setup(store); + await waitFor(() => { + expect(checkUpdatesChangeLogsSpy).toHaveBeenCalled(); + }); + + expect(screen.queryByTestId('one-update-modal')).not.toBeInTheDocument(); + expect(screen.queryByTestId('multiple-updates-modal')).toBeInTheDocument(); + }); + + it('should dispatch setSnoozedVersion when pressing button', async () => { + const changeLogsAPIResponse = { + last_check: '', + updates: [ + { + version: 'PMM 3.0.1', + tag: 'string', + timestamp: '2024-09-23T09:12:31.488Z', + release_notes_url: 'http://localhost:3000', + release_notes_text: 'text1', + }, + ], + }; + const state = { + updates: { + isLoading: false, + updateAvailable: true, + latest: { version: '3.0.1' }, + lastChecked: '', + showUpdateModal: true, + }, + }; + jest.spyOn(UpdatesService, 'getUpdatesChangelogs').mockReturnValue(Promise.resolve({ ...changeLogsAPIResponse })); + + const defaultState = configureStore().getState(); + const store = configureStore({ + ...defaultState, + percona: { + ...defaultState.percona, + ...state, + }, + } as StoreState); + setup(store); + await waitFor(() => { + expect(checkUpdatesChangeLogsSpy).toHaveBeenCalled(); + }); + + fireEvent.click(screen.getByRole('button', { name: Messages.snooze })); + + await waitFor(() => { + expect(setSnoozedVersionSpy).toHaveBeenCalled(); + }); + }); +}); diff --git a/public/app/percona/shared/components/PerconaBootstrapper/PerconaUpdateVersion/PerconaUpdateVersion.tsx b/public/app/percona/shared/components/PerconaBootstrapper/PerconaUpdateVersion/PerconaUpdateVersion.tsx new file mode 100644 index 0000000000000..e5ad28d961162 --- /dev/null +++ b/public/app/percona/shared/components/PerconaBootstrapper/PerconaUpdateVersion/PerconaUpdateVersion.tsx @@ -0,0 +1,116 @@ +import React, { useEffect } from 'react'; + +import { dateTimeFormat } from '@grafana/data'; +import { Modal, useStyles2, Button } from '@grafana/ui'; +import { PMM_UPDATES_LINK } from 'app/percona/shared/components/PerconaBootstrapper/PerconaNavigation'; +import { + checkUpdatesChangeLogs, + setShowUpdateModal, + UpdatesChangeLogs, +} from 'app/percona/shared/core/reducers/updates'; +import { setSnoozedVersion } from 'app/percona/shared/core/reducers/user/user'; +import { getPerconaUser, getUpdatesInfo } from 'app/percona/shared/core/selectors'; +import { useAppDispatch } from 'app/store/store'; +import { useSelector } from 'app/types'; + +import { Messages } from './PerconaUpdateVersion.constants'; +import { getStyles } from './PerconaUpdateVersion.styles'; + +const PerconaUpdateVersion = () => { + const { updateAvailable, installed, latest, changeLogs, showUpdateModal } = useSelector(getUpdatesInfo); + const { snoozedPmmVersion } = useSelector(getPerconaUser); + const dispatch = useAppDispatch(); + const styles = useStyles2(getStyles); + + useEffect(() => { + const prepareModal = async () => { + if (installed?.version === latest?.version || snoozedPmmVersion === latest?.version) { + dispatch(setShowUpdateModal(false)); + } else { + await dispatch(checkUpdatesChangeLogs()); + } + }; + + if (updateAvailable) { + prepareModal(); + } + }, [dispatch, updateAvailable, installed, latest, snoozedPmmVersion]); + + const snoozeUpdate = async () => { + if (latest && latest.version) { + await dispatch(setSnoozedVersion(latest.version)); + } + dispatch(setShowUpdateModal(false)); + }; + + const onDismiss = () => { + dispatch(setShowUpdateModal(false)); + }; + + const onUpdateClick = () => { + dispatch(setShowUpdateModal(false)); + window.location.assign(PMM_UPDATES_LINK.url!); + }; + + return ( + <> + +
+
{latest?.version || ''}
+

+ + {Messages.fullReleaseNotes} + +

+
{Messages.howToUpdate}
+

{Messages.howToUpdateDescription}

+
+ + +
+
+
+ 1} + className={styles.updateVersionModal} + > +
+
{Messages.newVersions}
+ +
{Messages.howToUpdate}
+

{Messages.howToUpdateDescription}

+
+ + +
+
+
+ + ); +}; + +export default PerconaUpdateVersion; diff --git a/public/app/percona/shared/core/reducers/updates/updates.ts b/public/app/percona/shared/core/reducers/updates/updates.ts index 966377b8714f9..a157227af1aef 100644 --- a/public/app/percona/shared/core/reducers/updates/updates.ts +++ b/public/app/percona/shared/core/reducers/updates/updates.ts @@ -1,12 +1,13 @@ -import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; +import { createAction, createAsyncThunk, createSlice } from '@reduxjs/toolkit'; import { UpdatesService } from 'app/percona/shared/services/updates'; import { CheckUpdatesPayload, UpdatesState } from './updates.types'; -import { responseToPayload } from './updates.utils'; +import { mapUpdatesChangeLogs, responseToPayload } from './updates.utils'; const initialState: UpdatesState = { isLoading: false, + showUpdateModal: true, }; export const updatesSlice = createSlice({ @@ -18,17 +19,29 @@ export const updatesSlice = createSlice({ ...initialState, isLoading: true, })); - builder.addCase(checkUpdatesAction.fulfilled, (state, { payload }) => ({ ...state, ...payload, isLoading: false, })); - - builder.addCase(checkUpdatesAction.rejected, () => ({ - ...initialState, + builder.addCase(checkUpdatesAction.rejected, (state) => ({ + ...state, isLoading: false, })); + builder.addCase(checkUpdatesChangeLogs.pending, (state) => ({ + ...state, + })); + builder.addCase(checkUpdatesChangeLogs.fulfilled, (state, { payload }) => ({ + ...state, + changeLogs: payload, + })); + builder.addCase(checkUpdatesChangeLogs.rejected, (state) => ({ + ...state, + })); + builder.addCase(setShowUpdateModal, (state, { payload }) => ({ + ...state, + showUpdateModal: payload, + })); }, }); @@ -42,4 +55,10 @@ export const checkUpdatesAction = createAsyncThunk('percona/checkUpdates', async } }); +export const checkUpdatesChangeLogs = createAsyncThunk('percona/checkUpdatesChangelogs', async () => { + return mapUpdatesChangeLogs(await UpdatesService.getUpdatesChangelogs()); +}); + +export const setShowUpdateModal = createAction('percona/setShowUpdateModal'); + export default updatesSlice.reducer; diff --git a/public/app/percona/shared/core/reducers/updates/updates.types.ts b/public/app/percona/shared/core/reducers/updates/updates.types.ts index 5c6fb31a09cc0..15090bac21396 100644 --- a/public/app/percona/shared/core/reducers/updates/updates.types.ts +++ b/public/app/percona/shared/core/reducers/updates/updates.types.ts @@ -17,6 +17,8 @@ export interface UpdatesState { latest?: LatestInformation; latestNewsUrl?: string; lastChecked?: string; + changeLogs?: CheckUpdatesChangeLogs; + showUpdateModal: boolean; } export interface CheckUpdatesPayload { @@ -26,3 +28,29 @@ export interface CheckUpdatesPayload { lastChecked?: string; updateAvailable: boolean; } + +export interface UpdatesChangeLogsResponse { + version: string; + tag: string; + timestamp: string; + release_notes_url: string; + release_notes_text: string; +} + +export interface UpdatesChangeLogs { + version: string; + tag: string; + timestamp: string; + releaseNotesUrl: string; + releaseNotesText: string; +} + +export interface CheckUpdatesChangeLogs { + updates: UpdatesChangeLogs[]; + lastCheck: string; +} + +export interface CheckUpdatesChangeLogsResponse { + updates: UpdatesChangeLogsResponse[]; + last_check: string; +} diff --git a/public/app/percona/shared/core/reducers/updates/updates.utils.ts b/public/app/percona/shared/core/reducers/updates/updates.utils.ts index 10dd6c6756a7d..cf99f4bcfdc6e 100644 --- a/public/app/percona/shared/core/reducers/updates/updates.utils.ts +++ b/public/app/percona/shared/core/reducers/updates/updates.utils.ts @@ -1,6 +1,6 @@ import { CheckUpdatesResponse } from 'app/percona/shared/services/updates/Updates.types'; -import { CheckUpdatesPayload } from './updates.types'; +import { CheckUpdatesPayload, CheckUpdatesChangeLogs, CheckUpdatesChangeLogsResponse } from './updates.types'; export const responseToPayload = (response: CheckUpdatesResponse): CheckUpdatesPayload => ({ installed: response.installed @@ -21,3 +21,17 @@ export const responseToPayload = (response: CheckUpdatesResponse): CheckUpdatesP latestNewsUrl: response.latest_news_url, updateAvailable: !!response.update_available, }); + +export const mapUpdatesChangeLogs = (response: CheckUpdatesChangeLogsResponse): CheckUpdatesChangeLogs => { + const responseMapping = response.updates.map((item) => ({ + version: item.version, + tag: item.tag, + timestamp: item.timestamp, + releaseNotesUrl: item.release_notes_url, + releaseNotesText: item.release_notes_text, + })); + return { + lastCheck: response.last_check, + updates: responseMapping, + }; +}; diff --git a/public/app/percona/shared/core/reducers/user/user.ts b/public/app/percona/shared/core/reducers/user/user.ts index c9bb28e015d7a..d8391e0f948fe 100644 --- a/public/app/percona/shared/core/reducers/user/user.ts +++ b/public/app/percona/shared/core/reducers/user/user.ts @@ -13,6 +13,7 @@ export const initialUserState: PerconaUserState = { alertingTourCompleted: true, isAuthorized: false, isPlatformUser: false, + snoozedPmmVersion: '', }; const perconaUserSlice = createSlice({ @@ -65,6 +66,16 @@ export const setProductTourCompleted = createAsyncThunk( } ); +export const setSnoozedVersion = createAsyncThunk( + 'percona/setProductTourCompleted', + async (version: string, thunkAPI): Promise => { + const res = await UserService.setSnoozedVersion(version); + const details = toUserDetailsModel(res); + thunkAPI.dispatch(setUserDetails(details)); + return details; + } +); + export const setAlertingTourCompleted = createAsyncThunk( 'percona/setAlertingTourCompleted', async (alertingTourCompleted: boolean, thunkAPI): Promise => { diff --git a/public/app/percona/shared/core/reducers/user/user.types.ts b/public/app/percona/shared/core/reducers/user/user.types.ts index 8b1734333170d..c8a950d900c5a 100644 --- a/public/app/percona/shared/core/reducers/user/user.types.ts +++ b/public/app/percona/shared/core/reducers/user/user.types.ts @@ -2,6 +2,7 @@ export interface UserDetails { userId: number; productTourCompleted: boolean; alertingTourCompleted: boolean; + snoozedPmmVersion?: string; } export interface PerconaUserState extends UserDetails { diff --git a/public/app/percona/shared/core/reducers/user/user.utils.ts b/public/app/percona/shared/core/reducers/user/user.utils.ts index d4c10723bf56f..5e972077d1bd8 100644 --- a/public/app/percona/shared/core/reducers/user/user.utils.ts +++ b/public/app/percona/shared/core/reducers/user/user.utils.ts @@ -6,4 +6,5 @@ export const toUserDetailsModel = (res: UserDetailsResponse): UserDetails => ({ userId: res.user_id, productTourCompleted: !!res.product_tour_completed, alertingTourCompleted: !!res.alerting_tour_completed, + snoozedPmmVersion: res.snoozed_pmm_version, }); diff --git a/public/app/percona/shared/services/updates/Updates.service.ts b/public/app/percona/shared/services/updates/Updates.service.ts index b4ef6cb681ef5..9de9719150369 100644 --- a/public/app/percona/shared/services/updates/Updates.service.ts +++ b/public/app/percona/shared/services/updates/Updates.service.ts @@ -1,8 +1,12 @@ +import { CheckUpdatesChangeLogsResponse } from 'app/percona/shared/core/reducers/updates'; + import { api } from '../../helpers/api'; import { CheckUpdatesParams, CheckUpdatesResponse } from './Updates.types'; export const UpdatesService = { - getCurrentVersion: (params: CheckUpdatesParams = { force: false }) => - api.get('/v1/server/updates', true, { params }), + getCurrentVersion: (body: CheckUpdatesParams = { force: false }) => + api.get('/v1/server/updates', true, { params: body }), + + getUpdatesChangelogs: () => api.get('/v1/server/updates/changelogs', false), }; diff --git a/public/app/percona/shared/services/user/User.service.ts b/public/app/percona/shared/services/user/User.service.ts index 2759cc2c6495d..eeefbe6be117c 100644 --- a/public/app/percona/shared/services/user/User.service.ts +++ b/public/app/percona/shared/services/user/User.service.ts @@ -22,6 +22,10 @@ export const UserService = { const payload: UserDetailsPutPayload = { alerting_tour_completed: completed }; return await api.put('/v1/users/me', payload); }, + async setSnoozedVersion(version: string): Promise { + const payload: UserDetailsPutPayload = { snoozed_pmm_version: version }; + return await api.put('/v1/users/me', payload); + }, async getUsersList(): Promise { return await api.get('/v1/users'); }, diff --git a/public/app/percona/shared/services/user/User.types.ts b/public/app/percona/shared/services/user/User.types.ts index 86c22adb08803..a31cadb6fc239 100644 --- a/public/app/percona/shared/services/user/User.types.ts +++ b/public/app/percona/shared/services/user/User.types.ts @@ -6,11 +6,13 @@ export interface UserDetailsResponse { user_id: number; product_tour_completed?: boolean; alerting_tour_completed?: boolean; + snoozed_pmm_version?: string; } export interface UserDetailsPutPayload { product_tour_completed?: boolean; alerting_tour_completed?: boolean; + snoozed_pmm_version?: string; } export interface UserListItemResponse {