From 62a4ce920e633ef26504e4405e3ffb934ac64a38 Mon Sep 17 00:00:00 2001 From: Nick Misasi Date: Fri, 27 Jun 2025 10:35:47 -0400 Subject: [PATCH] [CLD-9318] Button to re-open Preview Modal after closing (#32121) * Remove pricing modal. Adjust everywhere to instead open mattermost.com/pricing. When air gapped, don't show buttons to view plans. * Fix lint * Further clean up of unused code. Fixes for linter * Remove onboarding tasklist for previews, add Cloud previer banner * Fixes for linter, i18n * Revert dev lines * Fix lint * When below one minute, switch to seconds * fix linter * Add scaffolding for new Cloud Preview Modal * Style updates * Fix tests * fixes for PR feedback * useExternalLink for opening pricing modal with enriched params * Fix i17n * fix style * Fix style, tests * Fix linter, types * Add file * Make types even more fixed * fix: correct test case for SKU label not provided scenario The test "should not render SKU label when not provided" was incorrectly using baseContent which includes a SKU label. Fixed by creating contentWithoutSku that explicitly sets skuLabel to undefined to properly test the scenario where no SKU label is provided. Co-authored-by: Nick Misasi * Fine I'll do it myself * fix linter * Refactors * Adjustments from PR review. Adjustments to video experience (poster/play button) and starting to translate * Fix i18n * Wrap translation strings with defineMessage for i18n extraction - Add import for defineMessage and MessageDescriptor from react-intl - Update type definition to use MessageDescriptor for better type safety - Wrap all skuLabel, title, and subtitle objects with defineMessage() calls - This ensures the i18n-extract tool can properly detect translation strings Co-authored-by: Nick Misasi * Fix i18n * Use regular modal close button * Fix pipelines * Fix i18n * Update webapp/channels/src/components/cloud_preview_modal/preview_modal_content.scss Co-authored-by: Matthew Birtch * Update webapp/channels/src/components/cloud_preview_modal/preview_modal_content.scss Co-authored-by: Matthew Birtch * Update webapp/channels/src/components/cloud_preview_modal/preview_modal_content.scss Co-authored-by: Matthew Birtch * Update webapp/channels/src/components/cloud_preview_modal/preview_modal_content.tsx Co-authored-by: Matthew Birtch * Update webapp/channels/src/components/cloud_preview_modal/preview_modal_content.scss Co-authored-by: Matthew Birtch * Update webapp/channels/src/components/cloud_preview_modal/preview_modal_content.scss Co-authored-by: Matthew Birtch * Update webapp/channels/src/components/cloud_preview_modal/preview_modal_controller.tsx Co-authored-by: Matthew Birtch * Update webapp/channels/src/components/cloud_preview_modal/preview_modal_controller.tsx Co-authored-by: Matthew Birtch * Update webapp/channels/src/components/cloud_preview_modal/preview_modal_controller.scss Co-authored-by: Matthew Birtch * Update webapp/channels/src/components/cloud_preview_modal/preview_modal_controller.tsx Co-authored-by: Matthew Birtch * Update webapp/channels/src/components/cloud_preview_modal/preview_modal_controller.tsx Co-authored-by: Matthew Birtch * Update webapp/channels/src/components/cloud_preview_modal/preview_modal_controller.tsx Co-authored-by: Matthew Birtch * Update webapp/channels/src/components/cloud_preview_modal/preview_modal_controller.tsx Co-authored-by: Matthew Birtch * Update webapp/channels/src/components/cloud_preview_modal/preview_modal_controller.scss Co-authored-by: Matthew Birtch * Update webapp/channels/src/components/cloud_preview_modal/preview_modal_controller.scss Co-authored-by: Matthew Birtch * Update webapp/channels/src/components/cloud_preview_modal/preview_modal_content.scss Co-authored-by: Matthew Birtch * Update webapp/channels/src/components/cloud_preview_modal/preview_modal_content.scss Co-authored-by: Matthew Birtch * Update webapp/channels/src/components/cloud_preview_modal/preview_modal_content.scss Co-authored-by: Matthew Birtch * Update webapp/channels/src/components/cloud_preview_modal/preview_modal_content.scss Co-authored-by: Matthew Birtch * Update webapp/channels/src/components/cloud_preview_modal/preview_modal_content.scss Co-authored-by: Matthew Birtch * Update webapp/channels/src/components/cloud_preview_modal/preview_modal_content.scss Co-authored-by: Matthew Birtch * Remove unnecessary CSS properties from preview modal content Remove display: flex, height: 100%, and flex-direction: column from .preview-modal-content selector as they have no effect per code review feedback. Co-authored-by: Nick Misasi * Fixes for PR review * Fix linter * Fix i18n * fix linter * sticky button in bottom left to re-open preview modal when previously closed * fix linter * Resize to better fit in place when multiple teams are present * Changes to address Harrison's feedback * change file name, remove index.tsx * Add the new files * Rename to fix conflicts * Add the new files --------- Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> Co-authored-by: Nick Misasi Co-authored-by: Mattermost Build Co-authored-by: Matthew Birtch --- .../cloud_preview_modal.scss | 33 ++++ .../cloud_preview_modal_controller.test.tsx | 143 +++++++++++------- .../cloud_preview_modal_controller.tsx | 59 +++++++- webapp/channels/src/i18n/en.json | 2 + 4 files changed, 180 insertions(+), 57 deletions(-) create mode 100644 webapp/channels/src/components/cloud_preview_modal/cloud_preview_modal.scss diff --git a/webapp/channels/src/components/cloud_preview_modal/cloud_preview_modal.scss b/webapp/channels/src/components/cloud_preview_modal/cloud_preview_modal.scss new file mode 100644 index 0000000000..2b8d9f6f5e --- /dev/null +++ b/webapp/channels/src/components/cloud_preview_modal/cloud_preview_modal.scss @@ -0,0 +1,33 @@ +.cloud-preview-modal-fab { + position: fixed; + z-index: 999; + bottom: 16px; + left: 16px; + + &__button { + display: flex; + width: 36px; + height: 36px; + align-items: center; + justify-content: center; + border: 1px solid rgba(var(--center-channel-color-rgb), 0.24); + border-radius: 50%; + background-color: var(--center-channel-bg); + box-shadow: 0px 8px 24px 0px rgba(0, 0, 0, 0.16); + cursor: pointer; + + &:active { + box-shadow: 0px 4px 16px 0px rgba(0, 0, 0, 0.12); + } + + &:focus { + box-shadow: 0px 8px 24px 0px rgba(0, 0, 0, 0.16), 0 0 0 2px var(--button-bg); + outline: none; + } + + .icon { + color: rgba(var(--center-channel-color-rgb), 0.72); + font-size: 24px; + } + } +} diff --git a/webapp/channels/src/components/cloud_preview_modal/cloud_preview_modal_controller.test.tsx b/webapp/channels/src/components/cloud_preview_modal/cloud_preview_modal_controller.test.tsx index 89f0780597..e223292f24 100644 --- a/webapp/channels/src/components/cloud_preview_modal/cloud_preview_modal_controller.test.tsx +++ b/webapp/channels/src/components/cloud_preview_modal/cloud_preview_modal_controller.test.tsx @@ -32,6 +32,19 @@ jest.mock('components/async_load', () => ({ }, })); +// Mock WithTooltip to avoid complex tooltip testing +jest.mock('components/with_tooltip', () => ({ + __esModule: true, + default: ({children, title}: {children: React.ReactNode; title: string}) => ( +
+ {children} +
+ ), +})); + 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",