+ ),
+}));
+
describe('CloudPreviewModal', () => {
const useDispatchMock = jest.spyOn(reactRedux, 'useDispatch');
@@ -100,7 +113,23 @@ describe('CloudPreviewModal', () => {
},
};
- it('should show modal when in cloud preview and modal has not been shown before', async () => {
+ const stateWithModalShown = {
+ ...initialState,
+ entities: {
+ ...initialState.entities,
+ preferences: {
+ myPreferences: {
+ 'cloud_preview_modal_shown--cloud_preview_modal_shown': {
+ category: 'cloud_preview_modal_shown',
+ name: 'cloud_preview_modal_shown',
+ value: 'true',
+ },
+ },
+ },
+ },
+ };
+
+ it('should show modal when in cloud preview and modal has not been shown before', () => {
const dummyDispatch = jest.fn();
useDispatchMock.mockReturnValue(dummyDispatch);
@@ -109,9 +138,8 @@ describe('CloudPreviewModal', () => {
initialState,
);
- await waitFor(() => {
- expect(screen.getByTestId('preview-modal-controller')).toBeInTheDocument();
- });
+ expect(screen.getByTestId('preview-modal-controller')).toBeInTheDocument();
+ expect(screen.queryByTestId('cloud-preview-fab')).not.toBeInTheDocument();
});
it('should not show modal when not in cloud preview', () => {
@@ -162,12 +190,38 @@ describe('CloudPreviewModal', () => {
renderWithContext(
,
- state,
+ stateWithModalShown,
);
expect(screen.queryByTestId('preview-modal-controller')).not.toBeInTheDocument();
});
+ it('should show FAB when modal has been shown before and modal is not open', () => {
+ const dummyDispatch = jest.fn();
+ useDispatchMock.mockReturnValue(dummyDispatch);
+
+ renderWithContext(
+ ,
+ stateWithModalShown,
+ );
+
+ expect(screen.getByTestId('cloud-preview-fab')).toBeInTheDocument();
+ expect(screen.queryByTestId('preview-modal-controller')).not.toBeInTheDocument();
+ });
+
+ it('should not show FAB when modal has not been shown before', () => {
+ const dummyDispatch = jest.fn();
+ useDispatchMock.mockReturnValue(dummyDispatch);
+
+ renderWithContext(
+ ,
+ initialState,
+ );
+
+ expect(screen.queryByTestId('cloud-preview-fab')).not.toBeInTheDocument();
+ expect(screen.getByTestId('preview-modal-controller')).toBeInTheDocument();
+ });
+
it('should save preference when modal is closed', async () => {
const dummyDispatch = jest.fn();
useDispatchMock.mockReturnValue(dummyDispatch);
@@ -190,6 +244,28 @@ describe('CloudPreviewModal', () => {
expect(dummyDispatch).toHaveBeenCalled();
});
+ it('should reset preference and reopen modal when FAB is clicked', () => {
+ const dummyDispatch = jest.fn();
+ useDispatchMock.mockReturnValue(dummyDispatch);
+
+ renderWithContext(
+ ,
+ stateWithModalShown,
+ );
+
+ // FAB should be visible
+ const fabButton = screen.getByTestId('cloud-preview-fab');
+ expect(fabButton).toBeInTheDocument();
+
+ // Click the FAB button
+ const button = fabButton.querySelector('button');
+ expect(button).toBeInTheDocument();
+ fireEvent.click(button!);
+
+ // Check that dispatch was called to reset the preference
+ expect(dummyDispatch).toHaveBeenCalled();
+ });
+
it('should not render anything when subscription is undefined', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.cloud.subscription = undefined;
@@ -203,70 +279,33 @@ describe('CloudPreviewModal', () => {
);
expect(screen.queryByTestId('preview-modal-controller')).not.toBeInTheDocument();
+ expect(screen.queryByTestId('cloud-preview-fab')).not.toBeInTheDocument();
});
- it('should not show modal when no team is present', () => {
- const state = JSON.parse(JSON.stringify(initialState));
- delete state.entities.teams;
-
+ it('should have proper tooltip on FAB button', () => {
const dummyDispatch = jest.fn();
useDispatchMock.mockReturnValue(dummyDispatch);
renderWithContext(
,
- state,
+ stateWithModalShown,
);
- expect(screen.queryByTestId('preview-modal-controller')).not.toBeInTheDocument();
+ const tooltip = screen.getByTestId('with-tooltip');
+ expect(tooltip).toHaveAttribute('title', 'Open overview');
});
- it('should filter content based on team use case', () => {
+ it('should have proper accessibility attributes on FAB button', () => {
const dummyDispatch = jest.fn();
useDispatchMock.mockReturnValue(dummyDispatch);
- // Test with dev-sec-ops team
- const devSecOpsState = JSON.parse(JSON.stringify(initialState));
- devSecOpsState.entities.teams.currentTeamId = 'dev-sec-ops-hq';
- devSecOpsState.entities.teams.teams = {
- 'dev-sec-ops-hq': {
- ...initialState.entities.teams.teams['mission-ops-hq'],
- id: 'dev-sec-ops-hq',
- name: 'dev-sec-ops-hq',
- display_name: 'DevSecOps HQ',
- },
- };
-
renderWithContext(
,
- devSecOpsState,
+ stateWithModalShown,
);
- // Should show modal for dev-sec-ops team
- expect(screen.getByTestId('preview-modal-controller')).toBeInTheDocument();
- });
-
- it('should not show modal when team name does not match any use case', () => {
- const dummyDispatch = jest.fn();
- useDispatchMock.mockReturnValue(dummyDispatch);
-
- // Test with unknown team
- const unknownTeamState = JSON.parse(JSON.stringify(initialState));
- unknownTeamState.entities.teams.currentTeamId = 'unknown-team-hq';
- unknownTeamState.entities.teams.teams = {
- 'unknown-team-hq': {
- ...initialState.entities.teams.teams['mission-ops-hq'],
- id: 'unknown-team-hq',
- name: 'unknown-team-hq',
- display_name: 'Unknown Team HQ',
- },
- };
-
- renderWithContext(
- ,
- unknownTeamState,
- );
-
- // Should not show modal for unknown team
- expect(screen.queryByTestId('preview-modal-controller')).not.toBeInTheDocument();
+ const fabButton = screen.getByTestId('cloud-preview-fab').querySelector('button');
+ expect(fabButton).toHaveAttribute('aria-label', 'Open cloud preview overview');
+ expect(fabButton).toHaveClass('cloud-preview-modal-fab__button');
});
});
diff --git a/webapp/channels/src/components/cloud_preview_modal/cloud_preview_modal_controller.tsx b/webapp/channels/src/components/cloud_preview_modal/cloud_preview_modal_controller.tsx
index 4380f2694f..60c5b729bd 100644
--- a/webapp/channels/src/components/cloud_preview_modal/cloud_preview_modal_controller.tsx
+++ b/webapp/channels/src/components/cloud_preview_modal/cloud_preview_modal_controller.tsx
@@ -2,6 +2,7 @@
// See LICENSE.txt for license information.
import React, {useState, useEffect, lazy} from 'react';
+import {useIntl} from 'react-intl';
import {useSelector, useDispatch} from 'react-redux';
import {savePreferences} from 'mattermost-redux/actions/preferences';
@@ -12,12 +13,15 @@ import {getCurrentTeam} from 'mattermost-redux/selectors/entities/teams';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import {makeAsyncComponent} from 'components/async_load';
+import WithTooltip from 'components/with_tooltip';
import type {GlobalState} from 'types/store';
import type {PreviewModalContentData} from './preview_modal_content_data';
import {modalContent} from './preview_modal_content_data';
+import './cloud_preview_modal.scss';
+
const CLOUD_PREVIEW_MODAL_SHOWN_PREF = 'cloud_preview_modal_shown';
// Lazy load the controller component and content data together
@@ -27,6 +31,7 @@ const PreviewModalController = makeAsyncComponent(
);
const CloudPreviewModal: React.FC = () => {
+ const intl = useIntl();
const dispatch = useDispatch();
const subscription = useSelector(getCloudSubscription);
const license = useSelector(getLicense);
@@ -74,20 +79,64 @@ const CloudPreviewModal: React.FC = () => {
}
};
+ const handleOpenModal = () => {
+ setShowModal(true);
+
+ // Reset preference to show modal again
+ if (currentUserId) {
+ const preference = {
+ user_id: currentUserId,
+ category: CLOUD_PREVIEW_MODAL_SHOWN_PREF,
+ name: CLOUD_PREVIEW_MODAL_SHOWN_PREF,
+ value: 'false',
+ };
+ dispatch(savePreferences(currentUserId, [preference]));
+ }
+ };
+
// Early return if not cloud or not cloud preview - don't load anything else
if (!isCloud || !isCloudPreview) {
return null;
}
+ // Show FAB only if modal has been shown before and modal is not currently open
+ const shouldShowFAB = hasModalBeenShown && !showModal;
+
// Only render the controller if we pass the license checks
const contentData = team?.name ? filteredContentByUseCase(modalContent) : [];
return (
-
+ <>
+
+ {shouldShowFAB && (
+
+
+
+
+
+ )}
+ >
);
};
diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json
index a6d3dd7161..53196aab4b 100644
--- a/webapp/channels/src/i18n/en.json
+++ b/webapp/channels/src/i18n/en.json
@@ -3770,6 +3770,8 @@
"cloud_preview_modal.devsecops.zero_trust.subtitle": "Define granular access to content using attribute-based policies, and display classification banners and labels to guide user behavior. Limit exposure based on role, clearance level, or operational context.",
"cloud_preview_modal.devsecops.zero_trust.title": "Enforce Zero Trust collaboration",
"cloud_preview_modal.done": "Finish",
+ "cloud_preview_modal.fab.aria_label": "Open cloud preview overview",
+ "cloud_preview_modal.fab.tooltip": "Open overview",
"cloud_preview_modal.missionops.ai.sku_label": "ENTERPRISE",
"cloud_preview_modal.missionops.ai.subtitle": "Supercharge collaboration with Agents. Instantly summarize calls, surface action items, and find answers fast—all using the model you trust.",
"cloud_preview_modal.missionops.ai.title": "Bring your own AI model with Mattermost Agents",