Skip to content

Commit

Permalink
Add a type-safe option to use when calling pages.navigateToApp (#2480)
Browse files Browse the repository at this point in the history
* Start converting

* Type-safe conversions

* Update docs and unit tests

* add unit tests

* Deprecate old interface

* Doc changes

* changefile
  • Loading branch information
AE-MS authored Sep 20, 2024
1 parent ecbd562 commit d5b463d
Show file tree
Hide file tree
Showing 6 changed files with 153 additions and 28 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "minor",
"comment": "Updated `pages.navigateToApp` to now optionally accept a more type-safe input object",
"packageName": "@microsoft/teams-js",
"email": "[email protected]",
"dependentChangeType": "patch"
}
6 changes: 3 additions & 3 deletions packages/teams-js/src/internal/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,16 +272,16 @@ export function runWithTimeout<TResult, TError>(
* @internal
* Limited to Microsoft-internal use
*/
export function createTeamsAppLink(params: pages.NavigateToAppParams): string {
export function createTeamsAppLink(params: pages.AppNavigationParameters): string {
const url = new URL(
'https://teams.microsoft.com/l/entity/' +
encodeURIComponent(params.appId) +
encodeURIComponent(params.appId.toString()) +
'/' +
encodeURIComponent(params.pageId),
);

if (params.webUrl) {
url.searchParams.append('webUrl', params.webUrl);
url.searchParams.append('webUrl', params.webUrl.toString());
}
if (params.chatId || params.channelId || params.subPageId) {
url.searchParams.append(
Expand Down
3 changes: 2 additions & 1 deletion packages/teams-js/src/public/appId.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import { validateStringAsAppId } from '../internal/appIdValidation';
* However, there are some older internal/hard-coded apps which violate this schema and use names like
* com.microsoft.teamspace.tab.youtube. For compatibility with these legacy apps, we unfortunately cannot
* securely and completely validate app ids as UUIDs. Based on this, the validation is limited to checking
* for script tags, length, and non-printable characters.
* for script tags, length, and non-printable characters. Validation will be updated in the future to ensure
* the app id is a valid UUID as legacy apps update.
*/
export class AppId {
/**
Expand Down
84 changes: 80 additions & 4 deletions packages/teams-js/src/public/pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { ApiName, ApiVersionNumber, getApiVersionTag } from '../internal/telemet
import { isNullOrUndefined } from '../internal/typeCheckUtilities';
import { createTeamsAppLink } from '../internal/utils';
import { prefetchOriginsFromCDN } from '../internal/validOrigins';
import { AppId } from '../public/appId';
import { appInitializeHelper } from './app';
import { errorNotSupportedOnPlatform, FrameContexts } from './constants';
import { FrameInfo, ShareDeepLinkParameters, TabInformation, TabInstance, TabInstanceParameters } from './interfaces';
Expand Down Expand Up @@ -383,8 +384,9 @@ export namespace pages {
*
* @param params Parameters for the navigation
* @returns a `Promise` that will resolve if the navigation was successful or reject if it was not
* @throws `Error` if the app ID is not valid or `params.webUrl` is defined but not a valid URL
*/
export function navigateToApp(params: NavigateToAppParams): Promise<void> {
export function navigateToApp(params: AppNavigationParameters | NavigateToAppParams): Promise<void> {
return new Promise<void>((resolve) => {
ensureInitialized(
runtime,
Expand All @@ -399,10 +401,17 @@ export namespace pages {
throw errorNotSupportedOnPlatform;
}
const apiVersionTag: string = getApiVersionTag(pagesTelemetryVersionNumber, ApiName.Pages_NavigateToApp);

if (runtime.isLegacyTeams) {
resolve(sendAndHandleStatusAndReason(apiVersionTag, 'executeDeepLink', createTeamsAppLink(params)));
const typeSafeParameters: AppNavigationParameters = !isAppNavigationParametersObject(params)
? convertNavigateToAppParamsToAppNavigationParameters(params)
: params;
resolve(sendAndHandleStatusAndReason(apiVersionTag, 'executeDeepLink', createTeamsAppLink(typeSafeParameters)));
} else {
resolve(sendAndHandleStatusAndReason(apiVersionTag, 'pages.navigateToApp', params));
const serializedParameters: NavigateToAppParams = isAppNavigationParametersObject(params)
? convertAppNavigationParametersToNavigateToAppParams(params)
: params;
resolve(sendAndHandleStatusAndReason(apiVersionTag, 'pages.navigateToApp', serializedParameters));
}
});
}
Expand Down Expand Up @@ -452,7 +461,10 @@ export namespace pages {
}

/**
* Parameters for the NavigateToApp API
* @deprecated
* This interface has been deprecated in favor of a more type-safe interface using {@link pages.AppNavigationParameters}
*
* Parameters for the {@link pages.navigateToApp} function
*/
export interface NavigateToAppParams {
/**
Expand Down Expand Up @@ -488,6 +500,44 @@ export namespace pages {
chatId?: string;
}

/**
* Type-safer version of parameters for the {@link pages.navigateToApp} function
*/
export interface AppNavigationParameters {
/**
* ID of the app to navigate to
*/
appId: AppId;

/**
* Developer-defined ID of the page to navigate to within the app (formerly called `entityId`)
*/
pageId: string;

/**
* Fallback URL to open if the navigation cannot be completed within the host (e.g., if the target app is not installed)
*/
webUrl?: URL;

/**
* Developer-defined ID describing the content to navigate to within the page. This ID is passed to the application
* via the {@link app.PageInfo.subPageId} property on the {@link app.Context} object (retrieved by calling {@link app.getContext})
*/
subPageId?: string;

/**
* For apps installed as a channel tab, this ID can be supplied to indicate in which Teams channel the app should be opened
* This property has no effect in hosts where apps cannot be opened in channels
*/
channelId?: string;

/**
* Optional ID of the chat or meeting where the app should be opened
* This property has no effect in hosts where apps cannot be opened in chats or meetings
*/
chatId?: string;
}

/**
* Provides APIs for querying and navigating between contextual tabs of an application. Unlike personal tabs,
* contextual tabs are pages associated with a specific context, such as channel or chat.
Expand Down Expand Up @@ -1197,3 +1247,29 @@ export namespace pages {
}
}
}

export function isAppNavigationParametersObject(
obj: pages.AppNavigationParameters | pages.NavigateToAppParams,
): obj is pages.AppNavigationParameters {
return obj.appId instanceof AppId;
}

export function convertNavigateToAppParamsToAppNavigationParameters(
params: pages.NavigateToAppParams,
): pages.AppNavigationParameters {
return {
...params,
appId: new AppId(params.appId),
webUrl: params.webUrl ? new URL(params.webUrl) : undefined,
};
}

export function convertAppNavigationParametersToNavigateToAppParams(
params: pages.AppNavigationParameters,
): pages.NavigateToAppParams {
return {
...params,
appId: params.appId.toString(),
webUrl: params.webUrl ? params.webUrl.toString() : undefined,
};
}
30 changes: 15 additions & 15 deletions packages/teams-js/test/internal/utils.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
validateUuid,
} from '../../src/internal/utils';
import { UUID } from '../../src/internal/uuidObject';
import { pages } from '../../src/public';
import { AppId, pages } from '../../src/public';
import { ClipboardSupportedMimeType } from '../../src/public/interfaces';

describe('utils', () => {
Expand All @@ -24,26 +24,26 @@ describe('utils', () => {
});
describe('createTeamsAppLink', () => {
it('builds a basic URL with an appId and pageId', () => {
const params: pages.NavigateToAppParams = {
appId: 'fe4a8eba-2a31-4737-8e33-e5fae6fee194',
const params: pages.AppNavigationParameters = {
appId: new AppId('fe4a8eba-2a31-4737-8e33-e5fae6fee194'),
pageId: 'tasklist123',
};
const expected = 'https://teams.microsoft.com/l/entity/fe4a8eba-2a31-4737-8e33-e5fae6fee194/tasklist123';
expect(createTeamsAppLink(params)).toBe(expected);
});
it('builds a URL with a webUrl parameter', () => {
const params: pages.NavigateToAppParams = {
appId: 'fe4a8eba-2a31-4737-8e33-e5fae6fee194',
const params: pages.AppNavigationParameters = {
appId: new AppId('fe4a8eba-2a31-4737-8e33-e5fae6fee194'),
pageId: 'tasklist123',
webUrl: 'https://tasklist.example.com/123',
webUrl: new URL('https://tasklist.example.com/123'),
};
const expected =
'https://teams.microsoft.com/l/entity/fe4a8eba-2a31-4737-8e33-e5fae6fee194/tasklist123?webUrl=https%3A%2F%2Ftasklist.example.com%2F123';
expect(createTeamsAppLink(params)).toBe(expected);
});
it('builds a URL with a subPageUrl parameter', () => {
const params: pages.NavigateToAppParams = {
appId: 'fe4a8eba-2a31-4737-8e33-e5fae6fee194',
const params: pages.AppNavigationParameters = {
appId: new AppId('fe4a8eba-2a31-4737-8e33-e5fae6fee194'),
pageId: 'tasklist123',
subPageId: 'task456',
};
Expand All @@ -52,8 +52,8 @@ describe('utils', () => {
expect(createTeamsAppLink(params)).toBe(expected);
});
it('builds a URL with a channelId parameter', () => {
const params: pages.NavigateToAppParams = {
appId: 'fe4a8eba-2a31-4737-8e33-e5fae6fee194',
const params: pages.AppNavigationParameters = {
appId: new AppId('fe4a8eba-2a31-4737-8e33-e5fae6fee194'),
pageId: 'tasklist123',
channelId: '19:[email protected]',
};
Expand All @@ -63,8 +63,8 @@ describe('utils', () => {
});

it('builds a URL with a chatId parameter', () => {
const params: pages.NavigateToAppParams = {
appId: 'fe4a8eba-2a31-4737-8e33-e5fae6fee194',
const params: pages.AppNavigationParameters = {
appId: new AppId('fe4a8eba-2a31-4737-8e33-e5fae6fee194'),
pageId: 'tasklist123',
chatId: '19:[email protected]',
};
Expand All @@ -73,10 +73,10 @@ describe('utils', () => {
expect(createTeamsAppLink(params)).toBe(expected);
});
it('builds a URL with all optional properties', () => {
const params: pages.NavigateToAppParams = {
appId: 'fe4a8eba-2a31-4737-8e33-e5fae6fee194',
const params: pages.AppNavigationParameters = {
appId: new AppId('fe4a8eba-2a31-4737-8e33-e5fae6fee194'),
pageId: 'tasklist123',
webUrl: 'https://tasklist.example.com/123',
webUrl: new URL('https://tasklist.example.com/123'),
channelId: '19:[email protected]',
subPageId: 'task456',
};
Expand Down
51 changes: 46 additions & 5 deletions packages/teams-js/test/public/pages.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@ import { getGenericOnCompleteHandler } from '../../src/internal/utils';
import { app } from '../../src/public/app';
import { errorNotSupportedOnPlatform, FrameContexts } from '../../src/public/constants';
import { FrameInfo, ShareDeepLinkParameters, TabInstance, TabInstanceParameters } from '../../src/public/interfaces';
import { pages } from '../../src/public/pages';
import {
convertAppNavigationParametersToNavigateToAppParams,
convertNavigateToAppParamsToAppNavigationParameters,
isAppNavigationParametersObject,
pages,
} from '../../src/public/pages';
import { latestRuntimeApiVersion } from '../../src/public/runtime';
import { version } from '../../src/public/version';
import {
Expand Down Expand Up @@ -451,6 +456,11 @@ describe('Testing pages module', () => {
subPageId: 'task456',
};

const typeSafeAppNavigationParams: pages.AppNavigationParameters =
convertNavigateToAppParamsToAppNavigationParameters(navigateToAppParams);
const typeSafeAppNavigationParamsWithChat: pages.AppNavigationParameters =
convertNavigateToAppParamsToAppNavigationParameters(navigateToAppParamsWithChat);

it('pages.navigateToApp should not allow calls before initialization', async () => {
await expect(pages.navigateToApp(navigateToAppParams)).rejects.toThrowError(
new Error(errorLibraryNotInitialized),
Expand Down Expand Up @@ -489,7 +499,9 @@ describe('Testing pages module', () => {
await expect(promise).resolves.toBe(undefined);
});

it('pages.navigateToApp should successfully send the navigateToApp message', async () => {
async function validateNavigateToAppMessage(
navigateToAppParams: pages.NavigateToAppParams | pages.AppNavigationParameters,
) {
await utils.initializeWithContext(context);
utils.setRuntimeConfig({ apiVersion: 1, supports: { pages: {} } });

Expand All @@ -500,14 +512,26 @@ describe('Testing pages module', () => {
navigateToAppMessage,
'pages.navigateToApp',
MatcherType.ToStrictEqual,
navigateToAppParams,
isAppNavigationParametersObject(navigateToAppParams)
? convertAppNavigationParametersToNavigateToAppParams(navigateToAppParams)
: navigateToAppParams,
);

await utils.respondToMessage(navigateToAppMessage!, true);
await promise;
}

it('pages.navigateToApp should successfully send the navigateToApp message using serialized parameter', async () => {
validateNavigateToAppMessage(navigateToAppParams);
});

it('pages.navigateToApp should successfully send an executeDeepLink message for legacy teams clients', async () => {
it('pages.navigateToApp should successfully send the navigateToApp message using type-safe parameter', async () => {
validateNavigateToAppMessage(typeSafeAppNavigationParams);
});

async function validateNavigateToAppMessageForLegacyTeams(
navigateToAppParams: pages.NavigateToAppParams | pages.AppNavigationParameters,
) {
await utils.initializeWithContext(context);
utils.setRuntimeConfig({
apiVersion: 1,
Expand All @@ -529,9 +553,19 @@ describe('Testing pages module', () => {

await utils.respondToMessage(executeDeepLinkMessage!, true);
await promise;
}

it('pages.navigateToApp should successfully send an executeDeepLink message for legacy teams clients using a serialized parameter', async () => {
validateNavigateToAppMessageForLegacyTeams(navigateToAppParams);
});

it('pages.navigateToApp should successfully send an executeDeepLink message for legacy teams clients using a type-safe parameter', async () => {
validateNavigateToAppMessageForLegacyTeams(typeSafeAppNavigationParams);
});

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

await utils.respondToMessage(executeDeepLinkMessage!, true);
await promise;
}

it('pages.navigateToApp should successfully send an executeDeepLink message with chat id for legacy teams clients using serialized parameter', async () => {
validateNavigateToAppMessageForLegacyTeamsWithChat(navigateToAppParamsWithChat);
});
it('pages.navigateToApp should successfully send an executeDeepLink message with chat id for legacy teams clients using type-safe parameter', async () => {
validateNavigateToAppMessageForLegacyTeamsWithChat(typeSafeAppNavigationParamsWithChat);
});
} else {
it(`pages.navigateToApp should not allow calls from ${context} context`, async () => {
Expand Down

0 comments on commit d5b463d

Please sign in to comment.