[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 <nickmisasi@users.noreply.github.com>

* 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 <nickmisasi@users.noreply.github.com>

* 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 <mattbirtch@gmail.com>

* Update webapp/channels/src/components/cloud_preview_modal/preview_modal_content.scss

Co-authored-by: Matthew Birtch <mattbirtch@gmail.com>

* Update webapp/channels/src/components/cloud_preview_modal/preview_modal_content.scss

Co-authored-by: Matthew Birtch <mattbirtch@gmail.com>

* Update webapp/channels/src/components/cloud_preview_modal/preview_modal_content.tsx

Co-authored-by: Matthew Birtch <mattbirtch@gmail.com>

* Update webapp/channels/src/components/cloud_preview_modal/preview_modal_content.scss

Co-authored-by: Matthew Birtch <mattbirtch@gmail.com>

* Update webapp/channels/src/components/cloud_preview_modal/preview_modal_content.scss

Co-authored-by: Matthew Birtch <mattbirtch@gmail.com>

* Update webapp/channels/src/components/cloud_preview_modal/preview_modal_controller.tsx

Co-authored-by: Matthew Birtch <mattbirtch@gmail.com>

* Update webapp/channels/src/components/cloud_preview_modal/preview_modal_controller.tsx

Co-authored-by: Matthew Birtch <mattbirtch@gmail.com>

* Update webapp/channels/src/components/cloud_preview_modal/preview_modal_controller.scss

Co-authored-by: Matthew Birtch <mattbirtch@gmail.com>

* Update webapp/channels/src/components/cloud_preview_modal/preview_modal_controller.tsx

Co-authored-by: Matthew Birtch <mattbirtch@gmail.com>

* Update webapp/channels/src/components/cloud_preview_modal/preview_modal_controller.tsx

Co-authored-by: Matthew Birtch <mattbirtch@gmail.com>

* Update webapp/channels/src/components/cloud_preview_modal/preview_modal_controller.tsx

Co-authored-by: Matthew Birtch <mattbirtch@gmail.com>

* Update webapp/channels/src/components/cloud_preview_modal/preview_modal_controller.tsx

Co-authored-by: Matthew Birtch <mattbirtch@gmail.com>

* Update webapp/channels/src/components/cloud_preview_modal/preview_modal_controller.scss

Co-authored-by: Matthew Birtch <mattbirtch@gmail.com>

* Update webapp/channels/src/components/cloud_preview_modal/preview_modal_controller.scss

Co-authored-by: Matthew Birtch <mattbirtch@gmail.com>

* Update webapp/channels/src/components/cloud_preview_modal/preview_modal_content.scss

Co-authored-by: Matthew Birtch <mattbirtch@gmail.com>

* Update webapp/channels/src/components/cloud_preview_modal/preview_modal_content.scss

Co-authored-by: Matthew Birtch <mattbirtch@gmail.com>

* Update webapp/channels/src/components/cloud_preview_modal/preview_modal_content.scss

Co-authored-by: Matthew Birtch <mattbirtch@gmail.com>

* Update webapp/channels/src/components/cloud_preview_modal/preview_modal_content.scss

Co-authored-by: Matthew Birtch <mattbirtch@gmail.com>

* Update webapp/channels/src/components/cloud_preview_modal/preview_modal_content.scss

Co-authored-by: Matthew Birtch <mattbirtch@gmail.com>

* Update webapp/channels/src/components/cloud_preview_modal/preview_modal_content.scss

Co-authored-by: Matthew Birtch <mattbirtch@gmail.com>

* 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 <nickmisasi@users.noreply.github.com>

* 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 <nickmisasi@users.noreply.github.com>
Co-authored-by: Mattermost Build <build@mattermost.com>
Co-authored-by: Matthew Birtch <mattbirtch@gmail.com>
Этот коммит содержится в:
Nick Misasi
2025-06-27 10:35:47 -04:00
коммит произвёл GitHub
родитель 037bff9aa3
Коммит 62a4ce920e
4 изменённых файлов: 180 добавлений и 57 удалений

Просмотреть файл

@@ -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;
}
}
}

Просмотреть файл

@@ -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}) => (
<div
data-testid='with-tooltip'
title={title}
>
{children}
</div>
),
}));
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(
<CloudPreviewModal/>,
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(
<CloudPreviewModal/>,
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(
<CloudPreviewModal/>,
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(
<CloudPreviewModal/>,
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(
<CloudPreviewModal/>,
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(
<CloudPreviewModal/>,
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(
<CloudPreviewModal/>,
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');
});
});

Просмотреть файл

@@ -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 (
<PreviewModalController
show={showModal}
onClose={handleClose}
contentData={contentData}
/>
<>
<PreviewModalController
show={showModal}
onClose={handleClose}
contentData={contentData}
/>
{shouldShowFAB && (
<div
className='cloud-preview-modal-fab'
data-testid='cloud-preview-fab'
>
<WithTooltip
title={intl.formatMessage({
id: 'cloud_preview_modal.fab.tooltip',
defaultMessage: 'Open overview',
})}
>
<button
className='cloud-preview-modal-fab__button'
onClick={handleOpenModal}
aria-label={intl.formatMessage({
id: 'cloud_preview_modal.fab.aria_label',
defaultMessage: 'Open cloud preview overview',
})}
>
<i className='icon icon-play-box-multiple-outline'/>
</button>
</WithTooltip>
</div>
)}
</>
);
};

Просмотреть файл

@@ -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",