From 1103ddb8f4977505ce2818f75a42013ce41fef21 Mon Sep 17 00:00:00 2001 From: Elias Nahum Date: Thu, 31 Jul 2025 18:21:11 +0800 Subject: [PATCH] MM-64960 ask users to fill Playbook run retrospective on web or desktop (#9044) --- app/constants/deep_linking.ts | 1 + app/utils/deep_link/index.test.ts | 140 +++++++++++++++++++++++------- app/utils/deep_link/index.ts | 19 ++++ assets/base/i18n/en.json | 3 + 4 files changed, 132 insertions(+), 31 deletions(-) diff --git a/app/constants/deep_linking.ts b/app/constants/deep_linking.ts index 9ef744353..f1a30c913 100644 --- a/app/constants/deep_linking.ts +++ b/app/constants/deep_linking.ts @@ -9,6 +9,7 @@ const DeepLinkType = { Permalink: 'permalink', Playbooks: 'playbooks', PlaybookRuns: 'playbook_runs', + PlaybookRunsRetrospective: 'playbook_runs_retrospective', Redirect: '_redirect', Server: 'server', } as const; diff --git a/app/utils/deep_link/index.test.ts b/app/utils/deep_link/index.test.ts index 4185c7fb1..011bf1984 100644 --- a/app/utils/deep_link/index.test.ts +++ b/app/utils/deep_link/index.test.ts @@ -2,6 +2,7 @@ // See LICENSE.txt for license information. import {createIntl} from 'react-intl'; +import {Alert} from 'react-native'; import {Navigation} from 'react-native-navigation'; import {makeDirectChannel, switchToChannelByName} from '@actions/remote/channel'; @@ -11,10 +12,15 @@ import {DeepLink, Launch, Preferences, Screens} from '@constants'; import DatabaseManager from '@database/manager'; import {t} from '@i18n'; import WebsocketManager from '@managers/websocket_manager'; +import {fetchPlaybookRun} from '@playbooks/actions/remote/runs'; +import {getPlaybookRunById} from '@playbooks/database/queries/run'; +import {fetchIsPlaybooksEnabled} from '@playbooks/database/queries/version'; +import {goToPlaybookRun} from '@playbooks/screens/navigation'; import {getActiveServerUrl} from '@queries/app/servers'; import {queryUsersByUsername} from '@queries/servers/user'; import {dismissAllModalsAndPopToRoot} from '@screens/navigation'; import NavigationStore from '@store/navigation_store'; +import TestHelper from '@test/test_helper'; import {logError} from '@utils/log'; import {addNewServer} from '@utils/server'; @@ -81,6 +87,11 @@ jest.mock('@i18n', () => ({ t: jest.fn((id) => id), })); +jest.mock('@playbooks/database/queries/version'); +jest.mock('@playbooks/database/queries/run'); +jest.mock('@playbooks/actions/remote/runs'); +jest.mock('@playbooks/screens/navigation'); + describe('extractServerUrl', () => { it('should extract the sanitized server url', () => { expect(extractServerUrl('example.com:8080//path/to///login')).toEqual('example.com:8080/path/to'); @@ -107,8 +118,8 @@ describe('parseAndHandleDeepLink', () => { }); it('should add new server if not existing', async () => { - (getActiveServerUrl as jest.Mock).mockResolvedValueOnce('https://currentserver.com'); - (DatabaseManager.searchUrl as jest.Mock).mockReturnValueOnce(''); + jest.mocked(getActiveServerUrl).mockResolvedValueOnce('https://currentserver.com'); + jest.mocked(DatabaseManager.searchUrl).mockReturnValueOnce(''); const result = await parseAndHandleDeepLink('https://newserver.com/team/channels/town-square'); expect(addNewServer).toHaveBeenCalledWith(Preferences.THEMES.denim, 'newserver.com', undefined, {type: DeepLink.Channel, data: { @@ -122,8 +133,8 @@ describe('parseAndHandleDeepLink', () => { }); it('should handle existing server and switch to home screen', async () => { - (getActiveServerUrl as jest.Mock).mockResolvedValueOnce('https://currentserver.com'); - (DatabaseManager.searchUrl as jest.Mock).mockReturnValueOnce('https://existingserver.com'); + jest.mocked(getActiveServerUrl).mockResolvedValueOnce('https://currentserver.com'); + jest.mocked(DatabaseManager.searchUrl).mockReturnValueOnce('https://existingserver.com'); const result = await parseAndHandleDeepLink('https://existingserver.com/team/channels/town-square'); expect(dismissAllModalsAndPopToRoot).toHaveBeenCalled(); expect(DatabaseManager.setActiveServerDatabase).toHaveBeenCalledWith('https://existingserver.com'); @@ -132,76 +143,75 @@ describe('parseAndHandleDeepLink', () => { }); it('should update the server url in the server url screen', async () => { - (getActiveServerUrl as jest.Mock).mockResolvedValueOnce('https://currentserver.com'); - (DatabaseManager.searchUrl as jest.Mock).mockReturnValueOnce(null); + jest.mocked(getActiveServerUrl).mockResolvedValueOnce('https://currentserver.com'); + jest.mocked(DatabaseManager.searchUrl).mockReturnValueOnce(undefined); - (NavigationStore.getVisibleScreen as jest.Mock).mockReturnValueOnce(Screens.SERVER); + jest.mocked(NavigationStore.getVisibleScreen).mockReturnValueOnce(Screens.SERVER); const result = await parseAndHandleDeepLink('https://currentserver.com/team/channels/town-square', undefined, undefined, true); - console.log('result', JSON.stringify(result, null, 2)); const spyOnUpdateProps = jest.spyOn(Navigation, 'updateProps'); expect(spyOnUpdateProps).toHaveBeenCalledWith(Screens.SERVER, {serverUrl: 'currentserver.com'}); expect(result).toEqual({error: false}); }); it('should not display the new server modal if the server screen is on the stack but not as the visible screen', async () => { - (getActiveServerUrl as jest.Mock).mockResolvedValueOnce('https://currentserver.com'); - (DatabaseManager.searchUrl as jest.Mock).mockReturnValueOnce(null); + jest.mocked(getActiveServerUrl).mockResolvedValueOnce('https://currentserver.com'); + jest.mocked(DatabaseManager.searchUrl).mockReturnValueOnce(undefined); - (NavigationStore.getVisibleScreen as jest.Mock).mockReturnValueOnce(Screens.LOGIN); - (NavigationStore.getScreensInStack as jest.Mock).mockReturnValueOnce([Screens.SERVER, Screens.LOGIN]); + jest.mocked(NavigationStore.getVisibleScreen).mockReturnValueOnce(Screens.LOGIN); + jest.mocked(NavigationStore.getScreensInStack).mockReturnValueOnce([Screens.SERVER, Screens.LOGIN]); const result = await parseAndHandleDeepLink('https://currentserver.com/team/channels/town-square', undefined, undefined, true); expect(addNewServer).not.toHaveBeenCalled(); expect(result).toEqual({error: false}); }); it('should switch to channel by name for Channel deep link', async () => { - (DatabaseManager.searchUrl as jest.Mock).mockReturnValueOnce('https://existingserver.com'); - (getActiveServerUrl as jest.Mock).mockResolvedValueOnce('https://existingserver.com'); + jest.mocked(DatabaseManager.searchUrl).mockReturnValueOnce('https://existingserver.com'); + jest.mocked(getActiveServerUrl).mockResolvedValueOnce('https://existingserver.com'); const result = await parseAndHandleDeepLink('https://existingserver.com/team/channels/town-square', intl); expect(switchToChannelByName).toHaveBeenCalledWith('https://existingserver.com', 'town-square', 'team', errorBadChannel, intl); expect(result).toEqual({error: false}); }); it('should create direct message for DirectMessage deep link', async () => { - (DatabaseManager.searchUrl as jest.Mock).mockReturnValueOnce('https://existingserver.com'); - (getActiveServerUrl as jest.Mock).mockResolvedValueOnce('https://existingserver.com'); - (queryUsersByUsername as jest.Mock).mockReturnValueOnce({fetchIds: jest.fn(() => ['user-id'])}); + jest.mocked(DatabaseManager.searchUrl).mockReturnValueOnce('https://existingserver.com'); + jest.mocked(getActiveServerUrl).mockResolvedValueOnce('https://existingserver.com'); + (queryUsersByUsername as jest.Mock).mockReturnValueOnce(TestHelper.fakeQuery([TestHelper.fakeUserModel({id: 'user-id'})])); const result = await parseAndHandleDeepLink('https://existingserver.com/team/messages/@user-id', intl); expect(makeDirectChannel).toHaveBeenCalledWith('https://existingserver.com', 'user-id', '', true); expect(result).toEqual({error: false}); }); it('should fetch user and create direct message if user not found locally', async () => { - (DatabaseManager.searchUrl as jest.Mock).mockReturnValueOnce('https://existingserver.com'); - (getActiveServerUrl as jest.Mock).mockResolvedValueOnce('https://existingserver.com'); - (queryUsersByUsername as jest.Mock).mockReturnValueOnce({fetchIds: jest.fn(() => [])}); - (fetchUsersByUsernames as jest.Mock).mockResolvedValueOnce({users: [{id: 'user-id'}]}); + jest.mocked(DatabaseManager.searchUrl).mockReturnValueOnce('https://existingserver.com'); + jest.mocked(getActiveServerUrl).mockResolvedValueOnce('https://existingserver.com'); + jest.mocked(fetchUsersByUsernames).mockResolvedValueOnce({users: [TestHelper.fakeUser({id: 'user-id'})]}); + jest.mocked(queryUsersByUsername).mockReturnValueOnce(TestHelper.fakeQuery([])); const result = await parseAndHandleDeepLink('https://existingserver.com/team/messages/@user-id', intl); expect(makeDirectChannel).toHaveBeenCalledWith('https://existingserver.com', 'user-id', '', true); expect(result).toEqual({error: false}); }); it('should show unknown user error if user not found', async () => { - (DatabaseManager.searchUrl as jest.Mock).mockReturnValueOnce('https://existingserver.com'); - (getActiveServerUrl as jest.Mock).mockResolvedValueOnce('https://existingserver.com'); - (queryUsersByUsername as jest.Mock).mockReturnValueOnce({fetchIds: jest.fn(() => [])}); - (fetchUsersByUsernames as jest.Mock).mockResolvedValueOnce({users: []}); + jest.mocked(DatabaseManager.searchUrl).mockReturnValueOnce('https://existingserver.com'); + jest.mocked(getActiveServerUrl).mockResolvedValueOnce('https://existingserver.com'); + jest.mocked(queryUsersByUsername).mockReturnValueOnce(TestHelper.fakeQuery([])); + jest.mocked(fetchUsersByUsernames).mockResolvedValueOnce({users: []}); const result = await parseAndHandleDeepLink('https://existingserver.com/team/messages/@user-id', intl); expect(errorUnkownUser).toHaveBeenCalledWith(intl); expect(result).toEqual({error: false}); }); it('should switch to group message channel for GroupMessage deep link', async () => { - (DatabaseManager.searchUrl as jest.Mock).mockReturnValueOnce('https://existingserver.com'); - (getActiveServerUrl as jest.Mock).mockResolvedValueOnce('https://existingserver.com'); + jest.mocked(DatabaseManager.searchUrl).mockReturnValueOnce('https://existingserver.com'); + jest.mocked(getActiveServerUrl).mockResolvedValueOnce('https://existingserver.com'); const result = await parseAndHandleDeepLink('https://existingserver.com/team/messages/7b35c77a645e1906e03a2c330f89203385db102f', intl); expect(switchToChannelByName).toHaveBeenCalledWith('https://existingserver.com', '7b35c77a645e1906e03a2c330f89203385db102f', 'team', errorBadChannel, intl); expect(result).toEqual({error: false}); }); it('should show permalink for Permalink deep link', async () => { - (DatabaseManager.searchUrl as jest.Mock).mockReturnValueOnce('https://existingserver.com'); - (getActiveServerUrl as jest.Mock).mockResolvedValueOnce('https://existingserver.com'); + jest.mocked(DatabaseManager.searchUrl).mockReturnValueOnce('https://existingserver.com'); + jest.mocked(getActiveServerUrl).mockResolvedValueOnce('https://existingserver.com'); const postid = '7b35c77a645e1906e03a2c330f'; const result = await parseAndHandleDeepLink(`https://existingserver.com/team/pl/${postid}`, intl); expect(showPermalink).toHaveBeenCalledWith('https://existingserver.com', 'team', postid); @@ -209,13 +219,81 @@ describe('parseAndHandleDeepLink', () => { }); it('should log error and return error true on failure', async () => { - (getActiveServerUrl as jest.Mock).mockImplementationOnce(() => { + jest.mocked(getActiveServerUrl).mockImplementationOnce(() => { throw new Error('DB does not exist error'); }); const result = await parseAndHandleDeepLink('https://existingserver.com/team/messages/7b35c77a645e1906e03a2c330f89203385db102f'); expect(logError).toHaveBeenCalledWith('Failed to open channel from deeplink', expect.any(Error), undefined); expect(result).toEqual({error: true}); }); + + it('should alert when Playbooks deep link is used', async () => { + const alertSpy = jest.spyOn(Alert, 'alert'); + jest.mocked(DatabaseManager.searchUrl).mockReturnValueOnce('https://existingserver.com'); + jest.mocked(getActiveServerUrl).mockResolvedValueOnce('https://existingserver.com'); + await parseAndHandleDeepLink('https://existingserver.com/playbooks/playbooks/7b35c77a645e1906e03a2c330f', intl); + expect(alertSpy).toHaveBeenCalledWith( + intl.formatMessage({id: 'playbooks.only_runs_available.title', defaultMessage: 'Playbooks not available'}), + intl.formatMessage({id: 'playbooks.only_runs_available.description', defaultMessage: 'Only Playbook Runs are available on mobile. To access the Playbook, please use the desktop or web app.'}), + [{text: intl.formatMessage({id: 'playbooks.only_runs_available.ok', defaultMessage: 'OK'})}], + ); + }); + + it('should alert when PlaybookRunsRetrospective deep link is used', async () => { + const alertSpy = jest.spyOn(Alert, 'alert'); + jest.mocked(DatabaseManager.searchUrl).mockReturnValueOnce('https://existingserver.com'); + jest.mocked(getActiveServerUrl).mockResolvedValueOnce('https://existingserver.com'); + await parseAndHandleDeepLink('https://existingserver.com/playbooks/runs/7b35c77a645e1906e03a2c330f/retrospective', intl); + expect(alertSpy).toHaveBeenCalledWith( + intl.formatMessage({id: 'playbooks.retrospective_not_available.title', defaultMessage: 'Playbooks Run Retrospective not available'}), + intl.formatMessage({id: 'playbooks.retrospective_not_available.description', defaultMessage: 'Only Playbook Runs are available on mobile. To fill the Run Retrospective, please use the desktop or web app.'}), + [{text: intl.formatMessage({id: 'playbooks.retrospective_not_available.ok', defaultMessage: 'OK'})}], + ); + }); + + it('should go to playbook run if enabled and playbook exists', async () => { + jest.mocked(DatabaseManager.searchUrl).mockReturnValueOnce('https://existingserver.com'); + jest.mocked(getActiveServerUrl).mockResolvedValueOnce('https://existingserver.com'); + jest.mocked(fetchIsPlaybooksEnabled).mockResolvedValue(true); + jest.mocked(getPlaybookRunById).mockResolvedValue(TestHelper.fakePlaybookRunModel({id: '7b35c77a645e1906e03a2c330f'})); + jest.mocked(goToPlaybookRun).mockImplementation(jest.fn()); + + // Re-import to apply mocks + await parseAndHandleDeepLink('https://existingserver.com/playbooks/runs/7b35c77a645e1906e03a2c330f', intl); + expect(goToPlaybookRun).toHaveBeenCalledWith(intl, '7b35c77a645e1906e03a2c330f'); + }); + + it('should fetch playbook run if not found locally and show error if fetch fails', async () => { + const alertSpy = jest.spyOn(Alert, 'alert'); + jest.mocked(DatabaseManager.searchUrl).mockReturnValueOnce('https://existingserver.com'); + jest.mocked(getActiveServerUrl).mockResolvedValueOnce('https://existingserver.com'); + jest.mocked(fetchIsPlaybooksEnabled).mockResolvedValue(true); + jest.mocked(getPlaybookRunById).mockResolvedValue(undefined); + jest.mocked(fetchPlaybookRun).mockResolvedValue({error: true}); + + // Re-import to apply mocks + await parseAndHandleDeepLink('https://existingserver.com/playbooks/runs/7b35c77a645e1906e03a2c330f', intl); + expect(alertSpy).toHaveBeenCalledWith( + intl.formatMessage({id: 'playbooks.fetch_error.title', defaultMessage: 'Unable to open Run'}), + intl.formatMessage({id: 'playbooks.fetch_error.description', defaultMessage: "You don't have permission to view this run, or it may no longer exist."}), + [{text: intl.formatMessage({id: 'playbooks.fetch_error.OK', defaultMessage: 'Okay'})}], + ); + }); + + it('should alert if playbooks are not enabled or version not supported', async () => { + const alertSpy = jest.spyOn(Alert, 'alert'); + jest.mocked(DatabaseManager.searchUrl).mockReturnValueOnce('https://existingserver.com'); + jest.mocked(getActiveServerUrl).mockResolvedValueOnce('https://existingserver.com'); + jest.mocked(fetchIsPlaybooksEnabled).mockResolvedValue(false); + + // Re-import to apply mocks + await parseAndHandleDeepLink('https://existingserver.com/playbooks/runs/7b35c77a645e1906e03a2c330f', intl); + expect(alertSpy).toHaveBeenCalledWith( + intl.formatMessage({id: 'playbooks.not_enabled_or_unsupported.title', defaultMessage: 'Playbooks not available'}), + intl.formatMessage({id: 'playbooks.not_enabled_or_unsupported.description', defaultMessage: 'Playbooks are either not enabled on this server or the Playbooks version is not supported. Please contact your system administrator.'}), + [{text: intl.formatMessage({id: 'playbooks.not_enabled_or_unsupported.OK', defaultMessage: 'OK'})}], + ); + }); }); describe('getLaunchPropsFromDeepLink', () => { @@ -278,7 +356,7 @@ describe('alertInvalidDeepLink', () => { defaultMessage: 'This link you are trying to open is invalid.', }; - (t as jest.Mock).mockReturnValue(message.id); + jest.mocked(t).mockReturnValue(message.id); alertInvalidDeepLink(intl); diff --git a/app/utils/deep_link/index.ts b/app/utils/deep_link/index.ts index 24342eea5..cc670a3aa 100644 --- a/app/utils/deep_link/index.ts +++ b/app/utils/deep_link/index.ts @@ -125,6 +125,16 @@ export async function handleDeepLink(deepLink: DeepLinkWithData, intlShape?: Int ); break; } + case DeepLink.PlaybookRunsRetrospective: { + Alert.alert( + intl.formatMessage({id: 'playbooks.retrospective_not_available.title', defaultMessage: 'Playbooks Run Retrospective not available'}), + intl.formatMessage({id: 'playbooks.retrospective_not_available.description', defaultMessage: 'Only Playbook Runs are available on mobile. To fill the Run Retrospective, please use the desktop or web app.'}), + [{ + text: intl.formatMessage({id: 'playbooks.retrospective_not_available.ok', defaultMessage: 'OK'}), + }], + ); + break; + } case DeepLink.PlaybookRuns: { const deepLinkData = deepLink.data as DeepLinkPlaybookRuns; const playbookEnabled = await fetchIsPlaybooksEnabled(database); @@ -197,6 +207,9 @@ type PlaybookRunsPathParams = { const PLAYBOOK_RUNS_PATH = '*serverUrl/playbooks/runs/:playbookRunId'; export const matchPlaybookRunsDeeplink = match(PLAYBOOK_RUNS_PATH); +const PLAYBOOK_RUNS_RETROSPECTIVE = '*serverUrl/playbooks/runs/:playbookRunId/retrospective'; +export const matchPlaybookRunsRetrospectiveDeeplink = match(PLAYBOOK_RUNS_RETROSPECTIVE); + type PermalinkPathParams = { serverUrl: string[]; teamName: string; @@ -312,6 +325,12 @@ export function parseDeepLink(deepLinkUrl: string, asServer = false): DeepLinkWi return {type: DeepLink.Playbooks, url: deepLinkUrl, data: {serverUrl: serverUrl.join('/'), playbookId}}; } + const playbooksRunsRetrospectiveMatch = matchPlaybookRunsRetrospectiveDeeplink(url); + if (playbooksRunsRetrospectiveMatch && isValidId(playbooksRunsRetrospectiveMatch.params.playbookRunId)) { + const {params: {serverUrl, playbookRunId}} = playbooksRunsRetrospectiveMatch; + return {type: DeepLink.PlaybookRunsRetrospective, url: deepLinkUrl, data: {serverUrl: serverUrl.join('/'), playbookRunId}}; + } + const playbooksRunsMatch = matchPlaybookRunsDeeplink(url); if (playbooksRunsMatch && isValidId(playbooksRunsMatch.params.playbookRunId)) { const {params: {serverUrl, playbookRunId}} = playbooksRunsMatch; diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 97ebf0a51..39de5d367 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -1019,6 +1019,9 @@ "playbooks.playbook_run.tasks": "Tasks", "playbooks.playbook_run.title": "Playbook run", "playbooks.playbooks_runs.title": "Playbook runs", + "playbooks.retrospective_not_available.description": "Only Playbook Runs are available on mobile. To fill the Run Retrospective, please use the desktop or web app.", + "playbooks.retrospective_not_available.ok": "OK", + "playbooks.retrospective_not_available.title": "Playbooks Run Retrospective not available", "playbooks.runs.finished.description": "When a run in this channel finishes, you’ll see it here.", "playbooks.runs.finished.title": "No finished runs", "playbooks.runs.in_progress.description": "When a run starts in this channel, you’ll see it here.",