MM-64960 ask users to fill Playbook run retrospective on web or desktop (#9044)

This commit is contained in:
Elias Nahum 2025-07-31 18:21:11 +08:00 committed by GitHub
parent 40d8a0cbb4
commit 1103ddb8f4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 132 additions and 31 deletions

View file

@ -9,6 +9,7 @@ const DeepLinkType = {
Permalink: 'permalink',
Playbooks: 'playbooks',
PlaybookRuns: 'playbook_runs',
PlaybookRunsRetrospective: 'playbook_runs_retrospective',
Redirect: '_redirect',
Server: 'server',
} as const;

View file

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

View file

@ -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<PlaybookRunsPathParams>(PLAYBOOK_RUNS_PATH);
const PLAYBOOK_RUNS_RETROSPECTIVE = '*serverUrl/playbooks/runs/:playbookRunId/retrospective';
export const matchPlaybookRunsRetrospectiveDeeplink = match<PlaybookRunsPathParams>(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;

View file

@ -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, youll see it here.",
"playbooks.runs.finished.title": "No finished runs",
"playbooks.runs.in_progress.description": "When a run starts in this channel, youll see it here.",