Skip to content

Commit d5b463d

Browse files
authored
Add a type-safe option to use when calling pages.navigateToApp (#2480)
* Start converting * Type-safe conversions * Update docs and unit tests * add unit tests * Deprecate old interface * Doc changes * changefile
1 parent ecbd562 commit d5b463d

File tree

6 files changed

+153
-28
lines changed

6 files changed

+153
-28
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "minor",
3+
"comment": "Updated `pages.navigateToApp` to now optionally accept a more type-safe input object",
4+
"packageName": "@microsoft/teams-js",
5+
"email": "[email protected]",
6+
"dependentChangeType": "patch"
7+
}

packages/teams-js/src/internal/utils.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -272,16 +272,16 @@ export function runWithTimeout<TResult, TError>(
272272
* @internal
273273
* Limited to Microsoft-internal use
274274
*/
275-
export function createTeamsAppLink(params: pages.NavigateToAppParams): string {
275+
export function createTeamsAppLink(params: pages.AppNavigationParameters): string {
276276
const url = new URL(
277277
'https://teams.microsoft.com/l/entity/' +
278-
encodeURIComponent(params.appId) +
278+
encodeURIComponent(params.appId.toString()) +
279279
'/' +
280280
encodeURIComponent(params.pageId),
281281
);
282282

283283
if (params.webUrl) {
284-
url.searchParams.append('webUrl', params.webUrl);
284+
url.searchParams.append('webUrl', params.webUrl.toString());
285285
}
286286
if (params.chatId || params.channelId || params.subPageId) {
287287
url.searchParams.append(

packages/teams-js/src/public/appId.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ import { validateStringAsAppId } from '../internal/appIdValidation';
88
* However, there are some older internal/hard-coded apps which violate this schema and use names like
99
* com.microsoft.teamspace.tab.youtube. For compatibility with these legacy apps, we unfortunately cannot
1010
* securely and completely validate app ids as UUIDs. Based on this, the validation is limited to checking
11-
* for script tags, length, and non-printable characters.
11+
* for script tags, length, and non-printable characters. Validation will be updated in the future to ensure
12+
* the app id is a valid UUID as legacy apps update.
1213
*/
1314
export class AppId {
1415
/**

packages/teams-js/src/public/pages.ts

Lines changed: 80 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { ApiName, ApiVersionNumber, getApiVersionTag } from '../internal/telemet
1313
import { isNullOrUndefined } from '../internal/typeCheckUtilities';
1414
import { createTeamsAppLink } from '../internal/utils';
1515
import { prefetchOriginsFromCDN } from '../internal/validOrigins';
16+
import { AppId } from '../public/appId';
1617
import { appInitializeHelper } from './app';
1718
import { errorNotSupportedOnPlatform, FrameContexts } from './constants';
1819
import { FrameInfo, ShareDeepLinkParameters, TabInformation, TabInstance, TabInstanceParameters } from './interfaces';
@@ -383,8 +384,9 @@ export namespace pages {
383384
*
384385
* @param params Parameters for the navigation
385386
* @returns a `Promise` that will resolve if the navigation was successful or reject if it was not
387+
* @throws `Error` if the app ID is not valid or `params.webUrl` is defined but not a valid URL
386388
*/
387-
export function navigateToApp(params: NavigateToAppParams): Promise<void> {
389+
export function navigateToApp(params: AppNavigationParameters | NavigateToAppParams): Promise<void> {
388390
return new Promise<void>((resolve) => {
389391
ensureInitialized(
390392
runtime,
@@ -399,10 +401,17 @@ export namespace pages {
399401
throw errorNotSupportedOnPlatform;
400402
}
401403
const apiVersionTag: string = getApiVersionTag(pagesTelemetryVersionNumber, ApiName.Pages_NavigateToApp);
404+
402405
if (runtime.isLegacyTeams) {
403-
resolve(sendAndHandleStatusAndReason(apiVersionTag, 'executeDeepLink', createTeamsAppLink(params)));
406+
const typeSafeParameters: AppNavigationParameters = !isAppNavigationParametersObject(params)
407+
? convertNavigateToAppParamsToAppNavigationParameters(params)
408+
: params;
409+
resolve(sendAndHandleStatusAndReason(apiVersionTag, 'executeDeepLink', createTeamsAppLink(typeSafeParameters)));
404410
} else {
405-
resolve(sendAndHandleStatusAndReason(apiVersionTag, 'pages.navigateToApp', params));
411+
const serializedParameters: NavigateToAppParams = isAppNavigationParametersObject(params)
412+
? convertAppNavigationParametersToNavigateToAppParams(params)
413+
: params;
414+
resolve(sendAndHandleStatusAndReason(apiVersionTag, 'pages.navigateToApp', serializedParameters));
406415
}
407416
});
408417
}
@@ -452,7 +461,10 @@ export namespace pages {
452461
}
453462

454463
/**
455-
* Parameters for the NavigateToApp API
464+
* @deprecated
465+
* This interface has been deprecated in favor of a more type-safe interface using {@link pages.AppNavigationParameters}
466+
*
467+
* Parameters for the {@link pages.navigateToApp} function
456468
*/
457469
export interface NavigateToAppParams {
458470
/**
@@ -488,6 +500,44 @@ export namespace pages {
488500
chatId?: string;
489501
}
490502

503+
/**
504+
* Type-safer version of parameters for the {@link pages.navigateToApp} function
505+
*/
506+
export interface AppNavigationParameters {
507+
/**
508+
* ID of the app to navigate to
509+
*/
510+
appId: AppId;
511+
512+
/**
513+
* Developer-defined ID of the page to navigate to within the app (formerly called `entityId`)
514+
*/
515+
pageId: string;
516+
517+
/**
518+
* Fallback URL to open if the navigation cannot be completed within the host (e.g., if the target app is not installed)
519+
*/
520+
webUrl?: URL;
521+
522+
/**
523+
* Developer-defined ID describing the content to navigate to within the page. This ID is passed to the application
524+
* via the {@link app.PageInfo.subPageId} property on the {@link app.Context} object (retrieved by calling {@link app.getContext})
525+
*/
526+
subPageId?: string;
527+
528+
/**
529+
* For apps installed as a channel tab, this ID can be supplied to indicate in which Teams channel the app should be opened
530+
* This property has no effect in hosts where apps cannot be opened in channels
531+
*/
532+
channelId?: string;
533+
534+
/**
535+
* Optional ID of the chat or meeting where the app should be opened
536+
* This property has no effect in hosts where apps cannot be opened in chats or meetings
537+
*/
538+
chatId?: string;
539+
}
540+
491541
/**
492542
* Provides APIs for querying and navigating between contextual tabs of an application. Unlike personal tabs,
493543
* contextual tabs are pages associated with a specific context, such as channel or chat.
@@ -1197,3 +1247,29 @@ export namespace pages {
11971247
}
11981248
}
11991249
}
1250+
1251+
export function isAppNavigationParametersObject(
1252+
obj: pages.AppNavigationParameters | pages.NavigateToAppParams,
1253+
): obj is pages.AppNavigationParameters {
1254+
return obj.appId instanceof AppId;
1255+
}
1256+
1257+
export function convertNavigateToAppParamsToAppNavigationParameters(
1258+
params: pages.NavigateToAppParams,
1259+
): pages.AppNavigationParameters {
1260+
return {
1261+
...params,
1262+
appId: new AppId(params.appId),
1263+
webUrl: params.webUrl ? new URL(params.webUrl) : undefined,
1264+
};
1265+
}
1266+
1267+
export function convertAppNavigationParametersToNavigateToAppParams(
1268+
params: pages.AppNavigationParameters,
1269+
): pages.NavigateToAppParams {
1270+
return {
1271+
...params,
1272+
appId: params.appId.toString(),
1273+
webUrl: params.webUrl ? params.webUrl.toString() : undefined,
1274+
};
1275+
}

packages/teams-js/test/internal/utils.spec.ts

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
validateUuid,
99
} from '../../src/internal/utils';
1010
import { UUID } from '../../src/internal/uuidObject';
11-
import { pages } from '../../src/public';
11+
import { AppId, pages } from '../../src/public';
1212
import { ClipboardSupportedMimeType } from '../../src/public/interfaces';
1313

1414
describe('utils', () => {
@@ -24,26 +24,26 @@ describe('utils', () => {
2424
});
2525
describe('createTeamsAppLink', () => {
2626
it('builds a basic URL with an appId and pageId', () => {
27-
const params: pages.NavigateToAppParams = {
28-
appId: 'fe4a8eba-2a31-4737-8e33-e5fae6fee194',
27+
const params: pages.AppNavigationParameters = {
28+
appId: new AppId('fe4a8eba-2a31-4737-8e33-e5fae6fee194'),
2929
pageId: 'tasklist123',
3030
};
3131
const expected = 'https://teams.microsoft.com/l/entity/fe4a8eba-2a31-4737-8e33-e5fae6fee194/tasklist123';
3232
expect(createTeamsAppLink(params)).toBe(expected);
3333
});
3434
it('builds a URL with a webUrl parameter', () => {
35-
const params: pages.NavigateToAppParams = {
36-
appId: 'fe4a8eba-2a31-4737-8e33-e5fae6fee194',
35+
const params: pages.AppNavigationParameters = {
36+
appId: new AppId('fe4a8eba-2a31-4737-8e33-e5fae6fee194'),
3737
pageId: 'tasklist123',
38-
webUrl: 'https://tasklist.example.com/123',
38+
webUrl: new URL('https://tasklist.example.com/123'),
3939
};
4040
const expected =
4141
'https://teams.microsoft.com/l/entity/fe4a8eba-2a31-4737-8e33-e5fae6fee194/tasklist123?webUrl=https%3A%2F%2Ftasklist.example.com%2F123';
4242
expect(createTeamsAppLink(params)).toBe(expected);
4343
});
4444
it('builds a URL with a subPageUrl parameter', () => {
45-
const params: pages.NavigateToAppParams = {
46-
appId: 'fe4a8eba-2a31-4737-8e33-e5fae6fee194',
45+
const params: pages.AppNavigationParameters = {
46+
appId: new AppId('fe4a8eba-2a31-4737-8e33-e5fae6fee194'),
4747
pageId: 'tasklist123',
4848
subPageId: 'task456',
4949
};
@@ -52,8 +52,8 @@ describe('utils', () => {
5252
expect(createTeamsAppLink(params)).toBe(expected);
5353
});
5454
it('builds a URL with a channelId parameter', () => {
55-
const params: pages.NavigateToAppParams = {
56-
appId: 'fe4a8eba-2a31-4737-8e33-e5fae6fee194',
55+
const params: pages.AppNavigationParameters = {
56+
appId: new AppId('fe4a8eba-2a31-4737-8e33-e5fae6fee194'),
5757
pageId: 'tasklist123',
5858
channelId: '19:[email protected]',
5959
};
@@ -63,8 +63,8 @@ describe('utils', () => {
6363
});
6464

6565
it('builds a URL with a chatId parameter', () => {
66-
const params: pages.NavigateToAppParams = {
67-
appId: 'fe4a8eba-2a31-4737-8e33-e5fae6fee194',
66+
const params: pages.AppNavigationParameters = {
67+
appId: new AppId('fe4a8eba-2a31-4737-8e33-e5fae6fee194'),
6868
pageId: 'tasklist123',
6969
chatId: '19:[email protected]',
7070
};
@@ -73,10 +73,10 @@ describe('utils', () => {
7373
expect(createTeamsAppLink(params)).toBe(expected);
7474
});
7575
it('builds a URL with all optional properties', () => {
76-
const params: pages.NavigateToAppParams = {
77-
appId: 'fe4a8eba-2a31-4737-8e33-e5fae6fee194',
76+
const params: pages.AppNavigationParameters = {
77+
appId: new AppId('fe4a8eba-2a31-4737-8e33-e5fae6fee194'),
7878
pageId: 'tasklist123',
79-
webUrl: 'https://tasklist.example.com/123',
79+
webUrl: new URL('https://tasklist.example.com/123'),
8080
channelId: '19:[email protected]',
8181
subPageId: 'task456',
8282
};

packages/teams-js/test/public/pages.spec.ts

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,12 @@ import { getGenericOnCompleteHandler } from '../../src/internal/utils';
66
import { app } from '../../src/public/app';
77
import { errorNotSupportedOnPlatform, FrameContexts } from '../../src/public/constants';
88
import { FrameInfo, ShareDeepLinkParameters, TabInstance, TabInstanceParameters } from '../../src/public/interfaces';
9-
import { pages } from '../../src/public/pages';
9+
import {
10+
convertAppNavigationParametersToNavigateToAppParams,
11+
convertNavigateToAppParamsToAppNavigationParameters,
12+
isAppNavigationParametersObject,
13+
pages,
14+
} from '../../src/public/pages';
1015
import { latestRuntimeApiVersion } from '../../src/public/runtime';
1116
import { version } from '../../src/public/version';
1217
import {
@@ -451,6 +456,11 @@ describe('Testing pages module', () => {
451456
subPageId: 'task456',
452457
};
453458

459+
const typeSafeAppNavigationParams: pages.AppNavigationParameters =
460+
convertNavigateToAppParamsToAppNavigationParameters(navigateToAppParams);
461+
const typeSafeAppNavigationParamsWithChat: pages.AppNavigationParameters =
462+
convertNavigateToAppParamsToAppNavigationParameters(navigateToAppParamsWithChat);
463+
454464
it('pages.navigateToApp should not allow calls before initialization', async () => {
455465
await expect(pages.navigateToApp(navigateToAppParams)).rejects.toThrowError(
456466
new Error(errorLibraryNotInitialized),
@@ -489,7 +499,9 @@ describe('Testing pages module', () => {
489499
await expect(promise).resolves.toBe(undefined);
490500
});
491501

492-
it('pages.navigateToApp should successfully send the navigateToApp message', async () => {
502+
async function validateNavigateToAppMessage(
503+
navigateToAppParams: pages.NavigateToAppParams | pages.AppNavigationParameters,
504+
) {
493505
await utils.initializeWithContext(context);
494506
utils.setRuntimeConfig({ apiVersion: 1, supports: { pages: {} } });
495507

@@ -500,14 +512,26 @@ describe('Testing pages module', () => {
500512
navigateToAppMessage,
501513
'pages.navigateToApp',
502514
MatcherType.ToStrictEqual,
503-
navigateToAppParams,
515+
isAppNavigationParametersObject(navigateToAppParams)
516+
? convertAppNavigationParametersToNavigateToAppParams(navigateToAppParams)
517+
: navigateToAppParams,
504518
);
505519

506520
await utils.respondToMessage(navigateToAppMessage!, true);
507521
await promise;
522+
}
523+
524+
it('pages.navigateToApp should successfully send the navigateToApp message using serialized parameter', async () => {
525+
validateNavigateToAppMessage(navigateToAppParams);
508526
});
509527

510-
it('pages.navigateToApp should successfully send an executeDeepLink message for legacy teams clients', async () => {
528+
it('pages.navigateToApp should successfully send the navigateToApp message using type-safe parameter', async () => {
529+
validateNavigateToAppMessage(typeSafeAppNavigationParams);
530+
});
531+
532+
async function validateNavigateToAppMessageForLegacyTeams(
533+
navigateToAppParams: pages.NavigateToAppParams | pages.AppNavigationParameters,
534+
) {
511535
await utils.initializeWithContext(context);
512536
utils.setRuntimeConfig({
513537
apiVersion: 1,
@@ -529,9 +553,19 @@ describe('Testing pages module', () => {
529553

530554
await utils.respondToMessage(executeDeepLinkMessage!, true);
531555
await promise;
556+
}
557+
558+
it('pages.navigateToApp should successfully send an executeDeepLink message for legacy teams clients using a serialized parameter', async () => {
559+
validateNavigateToAppMessageForLegacyTeams(navigateToAppParams);
560+
});
561+
562+
it('pages.navigateToApp should successfully send an executeDeepLink message for legacy teams clients using a type-safe parameter', async () => {
563+
validateNavigateToAppMessageForLegacyTeams(typeSafeAppNavigationParams);
532564
});
533565

534-
it('pages.navigateToApp should successfully send an executeDeepLink message with chat id for legacy teams clients', async () => {
566+
async function validateNavigateToAppMessageForLegacyTeamsWithChat(
567+
navigateToAppParamsWithChat: pages.NavigateToAppParams | pages.AppNavigationParameters,
568+
) {
535569
await utils.initializeWithContext(context);
536570
utils.setRuntimeConfig({
537571
apiVersion: 1,
@@ -553,6 +587,13 @@ describe('Testing pages module', () => {
553587

554588
await utils.respondToMessage(executeDeepLinkMessage!, true);
555589
await promise;
590+
}
591+
592+
it('pages.navigateToApp should successfully send an executeDeepLink message with chat id for legacy teams clients using serialized parameter', async () => {
593+
validateNavigateToAppMessageForLegacyTeamsWithChat(navigateToAppParamsWithChat);
594+
});
595+
it('pages.navigateToApp should successfully send an executeDeepLink message with chat id for legacy teams clients using type-safe parameter', async () => {
596+
validateNavigateToAppMessageForLegacyTeamsWithChat(typeSafeAppNavigationParamsWithChat);
556597
});
557598
} else {
558599
it(`pages.navigateToApp should not allow calls from ${context} context`, async () => {

0 commit comments

Comments
 (0)