Add support for dynamic fetching of preview modal content from S3 bucket (#33380)

* Add support for dynamic fetching of preview modal content from S3 bucket

* Update server/channels/api4/cloud.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update webapp/channels/src/components/cloud_preview_modal/cloud_preview_modal_controller.test.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Fixes for CI pipelines

* Add definitions for openapi spec

* Use any instead of interface{}

* Update translations

* Add the translations

* Hook should only run fetch when in cloud preview

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Nick Misasi 2025-07-15 12:58:18 -04:00 committed by GitHub
parent 69e483f32b
commit e402db875c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 421 additions and 20 deletions

View file

@ -384,4 +384,35 @@
$ref: "#/components/responses/Forbidden"
"501":
$ref: "#/components/responses/NotImplemented"
/api/v4/cloud/preview/modal_data:
get:
tags:
- cloud
summary: Get cloud preview modal data
description: >
Retrieves modal content data from the configured S3 bucket for displaying cloud product preview modals.
##### Permissions
Must be authenticated.
Must be in a Cloud Preview environment.
__Minimum server version__: 10.0
__Note:__ This is intended for internal use and is subject to change.
operationId: GetPreviewModalData
responses:
"200":
description: Preview modal data returned successfully
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/PreviewModalContentData"
"401":
$ref: "#/components/responses/Unauthorized"
"404":
$ref: "#/components/responses/NotFound"
"500":
$ref: "#/components/responses/InternalServerError"

View file

@ -3956,6 +3956,37 @@ components:
state:
description: The current state of the installation
type: string
MessageDescriptor:
type: object
properties:
id:
description: The i18n message ID
type: string
defaultMessage:
description: The default message text
type: string
values:
description: Optional values for message interpolation
type: object
additionalProperties: true
PreviewModalContentData:
type: object
properties:
skuLabel:
$ref: "#/components/schemas/MessageDescriptor"
title:
$ref: "#/components/schemas/MessageDescriptor"
subtitle:
$ref: "#/components/schemas/MessageDescriptor"
videoUrl:
description: URL of the video content
type: string
videoPoster:
description: URL of the video poster/thumbnail image
type: string
useCase:
description: The use case category for this content
type: string
ServerLimits:
type: object
properties:

View file

@ -46,6 +46,9 @@ func (api *API) InitCloud() {
// GET /api/v4/cloud/cws-health-check
api.BaseRoutes.Cloud.Handle("/check-cws-connection", api.APIHandler(handleCheckCWSConnection)).Methods(http.MethodGet)
// GET /api/v4/cloud/preview/modal_data
api.BaseRoutes.Cloud.Handle("/preview/modal_data", api.APISessionRequired(getPreviewModalData)).Methods(http.MethodGet)
}
func ensureCloudInterface(c *Context, where string) bool {
@ -609,3 +612,21 @@ func handleCheckCWSConnection(c *Context, w http.ResponseWriter, r *http.Request
ReturnStatusOK(w)
}
func getPreviewModalData(c *Context, w http.ResponseWriter, r *http.Request) {
modalData, err := c.App.GetPreviewModalData()
if err != nil {
c.Err = err
return
}
responseData, jsonErr := json.Marshal(modalData)
if jsonErr != nil {
c.Err = model.NewAppError("Api4.getPreviewModalData", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(jsonErr)
return
}
if _, writeErr := w.Write(responseData); writeErr != nil {
c.Logger.Warn("Error while writing response", mlog.Err(writeErr))
}
}

View file

@ -4,6 +4,9 @@
package app
import (
"encoding/json"
"net/http"
"github.com/mattermost/mattermost/server/public/model"
)
@ -35,3 +38,33 @@ func (a *App) SendSubscriptionHistoryEvent(userID string) (*model.SubscriptionHi
return a.Cloud().CreateOrUpdateSubscriptionHistoryEvent(userID, int(userCount))
}
// GetPreviewModalData fetches modal content data from the configured S3 bucket
func (a *App) GetPreviewModalData() ([]model.PreviewModalContentData, *model.AppError) {
bucketURL := a.Config().CloudSettings.PreviewModalBucketURL
if bucketURL == nil || *bucketURL == "" {
return nil, model.NewAppError("GetPreviewModalData", "app.cloud.preview_modal_bucket_url_not_configured", nil, "", http.StatusNotFound)
}
// Construct the full URL to the modal_content.json file
fileURL := *bucketURL + "/modal_content.json"
// Make HTTP request to S3
resp, err := http.Get(fileURL)
if err != nil {
return nil, model.NewAppError("GetPreviewModalData", "app.cloud.preview_modal_data_fetch_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, model.NewAppError("GetPreviewModalData", "app.cloud.preview_modal_data_fetch_error", nil, "", resp.StatusCode)
}
// Parse the JSON response
var modalData []model.PreviewModalContentData
if err := json.NewDecoder(resp.Body).Decode(&modalData); err != nil {
return nil, model.NewAppError("GetPreviewModalData", "app.cloud.preview_modal_data_parse_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return modalData, nil
}

View file

@ -5002,6 +5002,18 @@
"id": "app.channel_member_history.log_leave_event.internal_error",
"translation": "Failed to record channel member history. Failed to update existing join record"
},
{
"id": "app.cloud.preview_modal_bucket_url_not_configured",
"translation": "Preview bucket URL is not configured"
},
{
"id": "app.cloud.preview_modal_data_fetch_error",
"translation": "Failed to fetch preview modal data"
},
{
"id": "app.cloud.preview_modal_data_parse_error",
"translation": "Failed to parse preview modal data"
},
{
"id": "app.cloud.trial_plan_bot_message",
"translation": "{{.UsersNum}} members of the {{.WorkspaceName}} workspace have requested starting the Enterprise trial for access to: "

View file

@ -344,6 +344,23 @@ type WorkspaceDeletionRequest struct {
Feedback *Feedback `json:"delete_feedback"`
}
// MessageDescriptor represents an i18n message descriptor
type MessageDescriptor struct {
ID string `json:"id"`
DefaultMessage string `json:"defaultMessage"`
Values map[string]any `json:"values,omitempty"`
}
// PreviewModalContentData represents the structure of modal content data from S3
type PreviewModalContentData struct {
SKULabel MessageDescriptor `json:"skuLabel"`
Title MessageDescriptor `json:"title"`
Subtitle MessageDescriptor `json:"subtitle"`
VideoURL string `json:"videoUrl"`
VideoPoster string `json:"videoPoster,omitempty"`
UseCase string `json:"useCase"`
}
func (p *Product) IsYearly() bool {
return p.RecurringInterval == RecurringIntervalYearly
}

View file

@ -3298,10 +3298,11 @@ func (s *JobSettings) SetDefaults() {
}
type CloudSettings struct {
CWSURL *string `access:"write_restrictable"`
CWSAPIURL *string `access:"write_restrictable"`
CWSMock *bool `access:"write_restrictable"`
Disable *bool `access:"write_restrictable,cloud_restrictable"`
CWSURL *string `access:"write_restrictable"`
CWSAPIURL *string `access:"write_restrictable"`
CWSMock *bool `access:"write_restrictable"`
Disable *bool `access:"write_restrictable,cloud_restrictable"`
PreviewModalBucketURL *string `access:"write_restrictable"`
}
func (s *CloudSettings) SetDefaults() {
@ -3331,6 +3332,10 @@ func (s *CloudSettings) SetDefaults() {
if s.Disable == nil {
s.Disable = NewPointer(false)
}
if s.PreviewModalBucketURL == nil {
s.PreviewModalBucketURL = NewPointer("")
}
}
type PluginState struct {

View file

@ -124,6 +124,20 @@ export function getTeamsUsage(): ThunkActionFunc<Promise<boolean | ServerError>>
};
}
export function getCloudPreviewModalData(): ThunkActionFunc<Promise<boolean | ServerError>> {
return async () => {
try {
const result = await Client4.getCloudPreviewModalData();
if (result) {
return {data: result};
}
} catch (error) {
return error;
}
return true;
};
}
export function retryFailedCloudFetches(): ActionFunc<boolean> {
return (dispatch, getState) => {
const errors = getCloudErrors(getState());

View file

@ -5,12 +5,23 @@ import {fireEvent, screen, waitFor} from '@testing-library/react';
import React from 'react';
import * as reactRedux from 'react-redux';
import type {Subscription} from '@mattermost/types/cloud';
import type {Subscription, PreviewModalContentData} from '@mattermost/types/cloud';
import type {TeamType} from '@mattermost/types/teams';
import {renderWithContext} from 'tests/react_testing_utils';
import CloudPreviewModal from './cloud_preview_modal_controller';
import {modalContent} from './preview_modal_content_data';
// Mock the useGetCloudPreviewModalContent hook
const mockUseGetCloudPreviewModalContent = jest.fn();
jest.mock('hooks/useGetCloudPreviewModalContent', () => ({
useGetCloudPreviewModalContent: () => mockUseGetCloudPreviewModalContent(),
}));
// Variable to track contentData passed to PreviewModalController
let lastContentData: PreviewModalContentData[] = [];
// Mock the async_load module to return components synchronously
jest.mock('components/async_load', () => ({
@ -19,9 +30,12 @@ jest.mock('components/async_load', () => ({
const Component = (props: any) => {
// Mock the preview modal controller
if (displayName === 'PreviewModalController') {
// Capture contentData for testing
lastContentData = props.contentData || [];
return props.show ? (
<div data-testid='preview-modal-controller'>
<button onClick={props.onClose}>{'Close'}</button>
<div data-testid='content-data-length'>{props.contentData?.length || 0}</div>
</div>
) : null;
}
@ -50,6 +64,12 @@ describe('CloudPreviewModal', () => {
beforeEach(() => {
useDispatchMock.mockClear();
lastContentData = [];
mockUseGetCloudPreviewModalContent.mockReturnValue({
data: null,
loading: false,
error: false,
});
});
const baseSubscription: Subscription = {
@ -308,4 +328,126 @@ describe('CloudPreviewModal', () => {
expect(fabButton).toHaveAttribute('aria-label', 'Open cloud preview overview');
expect(fabButton).toHaveClass('cloud-preview-modal-fab__button');
});
it('should use dynamic content when available', () => {
const dynamicContent: PreviewModalContentData[] = [
{
skuLabel: {
id: 'dynamic.sku.label',
defaultMessage: 'Dynamic SKU',
},
title: {
id: 'dynamic.title',
defaultMessage: 'Dynamic Title',
},
subtitle: {
id: 'dynamic.subtitle',
defaultMessage: 'Dynamic Subtitle',
},
videoUrl: 'https://example.com/dynamic-video.mp4',
videoPoster: 'https://example.com/dynamic-poster.jpg',
useCase: 'mission-ops',
},
];
mockUseGetCloudPreviewModalContent.mockReturnValue({
data: dynamicContent,
loading: false,
error: false,
});
const dummyDispatch = jest.fn();
useDispatchMock.mockReturnValue(dummyDispatch);
renderWithContext(
<CloudPreviewModal/>,
initialState,
);
expect(screen.getByTestId('preview-modal-controller')).toBeInTheDocument();
expect(lastContentData).toHaveLength(1);
expect(lastContentData[0]).toEqual(dynamicContent[0]);
expect(lastContentData[0].title.defaultMessage).toBe('Dynamic Title');
});
it('should fallback to hardcoded content when dynamic content is not available', () => {
mockUseGetCloudPreviewModalContent.mockReturnValue({
data: null,
loading: false,
error: false,
});
const dummyDispatch = jest.fn();
useDispatchMock.mockReturnValue(dummyDispatch);
renderWithContext(
<CloudPreviewModal/>,
initialState,
);
expect(screen.getByTestId('preview-modal-controller')).toBeInTheDocument();
const missionOpsContent = modalContent.filter((content) => content.useCase === 'mission-ops');
expect(lastContentData).toHaveLength(missionOpsContent.length);
expect(lastContentData[0].title.defaultMessage).toBe('Welcome to your Mattermost preview');
});
it('should fallback to hardcoded content when dynamic content is empty', () => {
mockUseGetCloudPreviewModalContent.mockReturnValue({
data: [],
loading: false,
error: false,
});
const dummyDispatch = jest.fn();
useDispatchMock.mockReturnValue(dummyDispatch);
renderWithContext(
<CloudPreviewModal/>,
initialState,
);
expect(screen.getByTestId('preview-modal-controller')).toBeInTheDocument();
const missionOpsContent = modalContent.filter((content) => content.useCase === 'mission-ops');
expect(lastContentData).toHaveLength(missionOpsContent.length);
expect(lastContentData[0].title.defaultMessage).toBe('Welcome to your Mattermost preview');
});
it('should not show modal when content is loading', () => {
mockUseGetCloudPreviewModalContent.mockReturnValue({
data: null,
loading: true,
error: false,
});
const dummyDispatch = jest.fn();
useDispatchMock.mockReturnValue(dummyDispatch);
renderWithContext(
<CloudPreviewModal/>,
initialState,
);
expect(screen.queryByTestId('preview-modal-controller')).not.toBeInTheDocument();
});
it('should fallback to hardcoded content when there is an error fetching dynamic content', () => {
mockUseGetCloudPreviewModalContent.mockReturnValue({
data: null,
loading: false,
error: true,
});
const dummyDispatch = jest.fn();
useDispatchMock.mockReturnValue(dummyDispatch);
renderWithContext(
<CloudPreviewModal/>,
initialState,
);
expect(screen.getByTestId('preview-modal-controller')).toBeInTheDocument();
const missionOpsContent = modalContent.filter((content) => content.useCase === 'mission-ops');
expect(lastContentData).toHaveLength(missionOpsContent.length);
expect(lastContentData[0].title.defaultMessage).toBe('Welcome to your Mattermost preview');
});
});

View file

@ -15,6 +15,8 @@ import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import {makeAsyncComponent} from 'components/async_load';
import WithTooltip from 'components/with_tooltip';
import {useGetCloudPreviewModalContent} from 'hooks/useGetCloudPreviewModalContent';
import type {GlobalState} from 'types/store';
import type {PreviewModalContentData} from './preview_modal_content_data';
@ -46,9 +48,12 @@ const CloudPreviewModal: React.FC = () => {
const [showModal, setShowModal] = useState(false);
const filteredContentByUseCase = (content: PreviewModalContentData[]) => {
// Fetch dynamic content from the backend
const {data: dynamicModalContent, loading: contentLoading} = useGetCloudPreviewModalContent();
const filteredContentByUseCase = React.useCallback((content: PreviewModalContentData[]) => {
return content.filter((content) => content.useCase === team?.name.replace('-hq', ''));
};
}, [team?.name]);
useEffect(() => {
// Show modal only if:
@ -56,13 +61,17 @@ const CloudPreviewModal: React.FC = () => {
// 2. Modal hasn't been shown before
// 3. We have the necessary data loaded
// 4. There's content to display for the current team
const filteredContent = team?.name ? filteredContentByUseCase(modalContent) : [];
if (isCloud && isCloudPreview && !hasModalBeenShown && currentUserId && team?.name && filteredContent.length > 0) {
// 5. Content is not loading
// Use dynamic content if available and not empty, fallback to hardcoded content
const contentToUse = (dynamicModalContent && dynamicModalContent.length > 0) ? dynamicModalContent : modalContent;
const filteredContent = team?.name ? filteredContentByUseCase(contentToUse) : [];
if (isCloud && isCloudPreview && !hasModalBeenShown && currentUserId && team?.name && filteredContent.length > 0 && !contentLoading) {
setShowModal(true);
} else if (hasModalBeenShown) {
setShowModal(false);
}
}, [isCloud, isCloudPreview, hasModalBeenShown, currentUserId, team?.name]);
}, [isCloud, isCloudPreview, hasModalBeenShown, currentUserId, team?.name, dynamicModalContent, contentLoading, filteredContentByUseCase]);
const handleClose = () => {
setShowModal(false);
@ -103,7 +112,14 @@ const CloudPreviewModal: React.FC = () => {
const shouldShowFAB = hasModalBeenShown && !showModal;
// Only render the controller if we pass the license checks
const contentData = team?.name ? filteredContentByUseCase(modalContent) : [];
// Use dynamic content if available and not empty, fallback to hardcoded content
const contentToUse = (dynamicModalContent && dynamicModalContent.length > 0) ? dynamicModalContent : modalContent;
const contentData = team?.name ? filteredContentByUseCase(contentToUse) : [];
// Show loading state while fetching dynamic content
if (contentLoading) {
return null;
}
return (
<>

View file

@ -2,16 +2,10 @@
// See LICENSE.txt for license information.
import {defineMessage} from 'react-intl';
import type {MessageDescriptor} from 'react-intl';
export type PreviewModalContentData = {
skuLabel: MessageDescriptor;
title: MessageDescriptor;
subtitle: MessageDescriptor;
videoUrl: string;
videoPoster?: string;
useCase: string;
};
import type {PreviewModalContentData} from '@mattermost/types/cloud';
export type {PreviewModalContentData};
export const modalContent: PreviewModalContentData[] = [
{

View file

@ -0,0 +1,62 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useEffect, useState} from 'react';
import {useDispatch, useSelector} from 'react-redux';
import type {PreviewModalContentData} from '@mattermost/types/cloud';
import {getCloudSubscription} from 'mattermost-redux/selectors/entities/cloud';
import {getLicense} from 'mattermost-redux/selectors/entities/general';
import {getCloudPreviewModalData} from 'actions/cloud';
export type UseGetCloudPreviewModalContentResult = {
data: PreviewModalContentData[] | null;
loading: boolean;
error: boolean;
};
export const useGetCloudPreviewModalContent = (): UseGetCloudPreviewModalContentResult => {
const dispatch = useDispatch();
const subscription = useSelector(getCloudSubscription);
const license = useSelector(getLicense);
const [data, setData] = useState<PreviewModalContentData[] | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const isCloud = license?.Cloud === 'true';
const isCloudPreview = subscription?.is_cloud_preview === true;
useEffect(() => {
const fetchData = async () => {
setLoading(true);
setError(false);
try {
const result = await dispatch(getCloudPreviewModalData());
if (result && typeof result === 'object' && 'data' in result) {
setData(result.data as PreviewModalContentData[]);
} else {
setError(true);
}
} catch (err) {
setError(true);
} finally {
setLoading(false);
}
};
// Only fetch data if this is a cloud preview workspace
if (isCloud && isCloudPreview) {
fetchData();
} else {
// Not a cloud preview workspace, set loading to false and data to null
setLoading(false);
setData(null);
setError(false);
}
}, [dispatch, isCloud, isCloudPreview]);
return {data, loading, error};
};

View file

@ -39,6 +39,7 @@ import type {
ValidBusinessEmail,
NewsletterRequestBody,
Installation,
PreviewModalContentData,
} from '@mattermost/types/cloud';
import type {Compliance} from '@mattermost/types/compliance';
import type {
@ -4139,6 +4140,13 @@ export default class Client4 {
);
};
getCloudPreviewModalData = () => {
return this.doFetch<PreviewModalContentData[]>(
`${this.getCloudRoute()}/preview/modal_data`,
{method: 'get'},
);
};
getInvoices = () => {
return this.doFetch<Invoice[]>(
`${this.getCloudRoute()}/subscription/invoices`,

View file

@ -206,3 +206,18 @@ export type Feedback = {
reason: string;
comments: string;
}
export type MessageDescriptor = {
id: string;
defaultMessage: string;
values?: Record<string, any>;
};
export type PreviewModalContentData = {
skuLabel: MessageDescriptor;
title: MessageDescriptor;
subtitle: MessageDescriptor;
videoUrl: string;
videoPoster?: string;
useCase: string;
};