From e402db875c1abda79bf0a78d2e107d3f99fdd20d Mon Sep 17 00:00:00 2001 From: Nick Misasi Date: Tue, 15 Jul 2025 12:58:18 -0400 Subject: [PATCH] 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> --- api/v4/source/cloud.yaml | 31 ++++ api/v4/source/definitions.yaml | 31 ++++ server/channels/api4/cloud.go | 21 +++ server/channels/app/cloud.go | 33 ++++ server/i18n/en.json | 12 ++ server/public/model/cloud.go | 17 +++ server/public/model/config.go | 13 +- webapp/channels/src/actions/cloud.tsx | 14 ++ .../cloud_preview_modal_controller.test.tsx | 144 +++++++++++++++++- .../cloud_preview_modal_controller.tsx | 28 +++- .../preview_modal_content_data.ts | 12 +- .../hooks/useGetCloudPreviewModalContent.ts | 62 ++++++++ webapp/platform/client/src/client4.ts | 8 + webapp/platform/types/src/cloud.ts | 15 ++ 14 files changed, 421 insertions(+), 20 deletions(-) create mode 100644 webapp/channels/src/hooks/useGetCloudPreviewModalContent.ts diff --git a/api/v4/source/cloud.yaml b/api/v4/source/cloud.yaml index 71f03de08bb..14985775e3b 100644 --- a/api/v4/source/cloud.yaml +++ b/api/v4/source/cloud.yaml @@ -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" diff --git a/api/v4/source/definitions.yaml b/api/v4/source/definitions.yaml index ebaabb72e35..b043004a05a 100644 --- a/api/v4/source/definitions.yaml +++ b/api/v4/source/definitions.yaml @@ -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: diff --git a/server/channels/api4/cloud.go b/server/channels/api4/cloud.go index 19c8a1700ed..bdd4d1d3191 100644 --- a/server/channels/api4/cloud.go +++ b/server/channels/api4/cloud.go @@ -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)) + } +} diff --git a/server/channels/app/cloud.go b/server/channels/app/cloud.go index 24dc47235aa..61801da490a 100644 --- a/server/channels/app/cloud.go +++ b/server/channels/app/cloud.go @@ -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 +} diff --git a/server/i18n/en.json b/server/i18n/en.json index 0bbb034d703..1baf6f5e939 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -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: " diff --git a/server/public/model/cloud.go b/server/public/model/cloud.go index 5c88a35b127..a2da17c43c4 100644 --- a/server/public/model/cloud.go +++ b/server/public/model/cloud.go @@ -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 } diff --git a/server/public/model/config.go b/server/public/model/config.go index 41f4312f894..b67f9527dbf 100644 --- a/server/public/model/config.go +++ b/server/public/model/config.go @@ -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 { diff --git a/webapp/channels/src/actions/cloud.tsx b/webapp/channels/src/actions/cloud.tsx index f8910238965..cbc43f5bf5e 100644 --- a/webapp/channels/src/actions/cloud.tsx +++ b/webapp/channels/src/actions/cloud.tsx @@ -124,6 +124,20 @@ export function getTeamsUsage(): ThunkActionFunc> }; } +export function getCloudPreviewModalData(): ThunkActionFunc> { + return async () => { + try { + const result = await Client4.getCloudPreviewModalData(); + if (result) { + return {data: result}; + } + } catch (error) { + return error; + } + return true; + }; +} + export function retryFailedCloudFetches(): ActionFunc { return (dispatch, getState) => { const errors = getCloudErrors(getState()); 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 e223292f249..2d5fb1fc232 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 @@ -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 ? (
+
{props.contentData?.length || 0}
) : 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( + , + 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( + , + 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( + , + 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( + , + 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( + , + 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'); + }); }); 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 60c5b729bde..c6b728b7b88 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 @@ -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 ( <> diff --git a/webapp/channels/src/components/cloud_preview_modal/preview_modal_content_data.ts b/webapp/channels/src/components/cloud_preview_modal/preview_modal_content_data.ts index 183b299e971..e113b5e59cd 100644 --- a/webapp/channels/src/components/cloud_preview_modal/preview_modal_content_data.ts +++ b/webapp/channels/src/components/cloud_preview_modal/preview_modal_content_data.ts @@ -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[] = [ { diff --git a/webapp/channels/src/hooks/useGetCloudPreviewModalContent.ts b/webapp/channels/src/hooks/useGetCloudPreviewModalContent.ts new file mode 100644 index 00000000000..7e8a50813e6 --- /dev/null +++ b/webapp/channels/src/hooks/useGetCloudPreviewModalContent.ts @@ -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(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}; +}; diff --git a/webapp/platform/client/src/client4.ts b/webapp/platform/client/src/client4.ts index 93ce99c58da..1606655a1e1 100644 --- a/webapp/platform/client/src/client4.ts +++ b/webapp/platform/client/src/client4.ts @@ -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( + `${this.getCloudRoute()}/preview/modal_data`, + {method: 'get'}, + ); + }; + getInvoices = () => { return this.doFetch( `${this.getCloudRoute()}/subscription/invoices`, diff --git a/webapp/platform/types/src/cloud.ts b/webapp/platform/types/src/cloud.ts index ba7ceadd464..71fe1dca577 100644 --- a/webapp/platform/types/src/cloud.ts +++ b/webapp/platform/types/src/cloud.ts @@ -206,3 +206,18 @@ export type Feedback = { reason: string; comments: string; } + +export type MessageDescriptor = { + id: string; + defaultMessage: string; + values?: Record; +}; + +export type PreviewModalContentData = { + skuLabel: MessageDescriptor; + title: MessageDescriptor; + subtitle: MessageDescriptor; + videoUrl: string; + videoPoster?: string; + useCase: string; +};