[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>
Этот коммит содержится в:
@@ -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",
|
||||
|
||||
Ссылка в новой задаче
Block a user