From 784aacd0a4f8ac5f33898bd969a2b3ed4ed6bc01 Mon Sep 17 00:00:00 2001 From: Olivers Berzs Date: Fri, 22 Dec 2023 12:10:37 +0200 Subject: [PATCH 1/3] feat(581): open extension links in extension --- .../src/panel/guidedAnswersPanel.ts | 28 ++++++ .../test/panel/guidedAnswersPanel.test.ts | 79 +++++++++++++++- packages/types/src/actions.ts | 7 ++ packages/types/src/types.ts | 10 ++ packages/webapp/src/webview/state/actions.ts | 1 + .../GuidedAnswerNode/GuidedAnswerNode.tsx | 94 ++++++++++++------- .../webapp/src/webview/ui/components/utils.ts | 33 +++++++ .../GuidedAnswerNode.test.tsx | 2 +- packages/webapp/test/Utils.test.tsx | 15 ++- 9 files changed, 229 insertions(+), 40 deletions(-) diff --git a/packages/ide-extension/src/panel/guidedAnswersPanel.ts b/packages/ide-extension/src/panel/guidedAnswersPanel.ts index d7462c30..f22e19c4 100644 --- a/packages/ide-extension/src/panel/guidedAnswersPanel.ts +++ b/packages/ide-extension/src/panel/guidedAnswersPanel.ts @@ -5,13 +5,16 @@ import type { Bookmark, GuidedAnswerActions, GuidedAnswerAPI, + GuidedAnswerNodeId, GuidedAnswersQueryOptions, + GuidedAnswerTreeId, GuidedAnswerTreeSearchResult, IDE } from '@sap/guided-answers-extension-types'; import { FILL_SHARE_LINKS, SELECT_NODE, + NAVIGATE, SEND_TELEMETRY, SEND_FEEDBACK_OUTCOME, SEND_FEEDBACK_COMMENT, @@ -289,6 +292,27 @@ export class GuidedAnswersPanel { } } + /** + * + * @param treeId - tree to navigate to + * @param nodeIdPath - nodes in tree to navigate to + */ + async navigate(treeId: GuidedAnswerTreeId, nodeIdPath: GuidedAnswerNodeId[]): Promise { + this.postActionToWebview(updateNetworkStatus('LOADING')); + try { + const tree = await this.guidedAnswerApi.getTreeById(treeId); + const nodePath = await this.guidedAnswerApi.getNodePath(nodeIdPath); + this.postActionToWebview(setActiveTree(tree)); + for (const node of nodePath) { + this.postActionToWebview(updateActiveNode(node)); + } + this.postActionToWebview(updateNetworkStatus('OK')); + } catch (e) { + this.postActionToWebview(updateNetworkStatus('ERROR')); + throw e; + } + } + /** * Handler for actions coming from webview. This should be primarily commands with arguments. * @@ -303,6 +327,10 @@ export class GuidedAnswersPanel { this.postActionToWebview(updateActiveNode(node)); break; } + case NAVIGATE: { + await this.navigate(action.payload.treeId, action.payload.nodeIdPath); + break; + } case SEND_FEEDBACK_OUTCOME: { await this.guidedAnswerApi.sendFeedbackOutcome(action.payload); break; diff --git a/packages/ide-extension/test/panel/guidedAnswersPanel.test.ts b/packages/ide-extension/test/panel/guidedAnswersPanel.test.ts index 59bfbf26..9a66b245 100644 --- a/packages/ide-extension/test/panel/guidedAnswersPanel.test.ts +++ b/packages/ide-extension/test/panel/guidedAnswersPanel.test.ts @@ -5,6 +5,7 @@ import { EXECUTE_COMMAND, SEARCH_TREE, SELECT_NODE, + NAVIGATE, SET_ACTIVE_TREE, UPDATE_ACTIVE_NODE, UPDATE_GUIDED_ANSWER_TREES, @@ -18,7 +19,6 @@ import { RESTORE_STATE, SEND_TELEMETRY, GET_BOOKMARKS, - AppState, SYNCHRONIZE_BOOKMARK, UPDATE_BOOKMARKS, GET_LAST_VISITED_GUIDES, @@ -34,7 +34,8 @@ import type { GuidedAnswerTree, GuidedAnswerTreeId, GuidedAnswerTreeSearchResult, - LastVisitedGuide + LastVisitedGuide, + AppState } from '@sap/guided-answers-extension-types'; import { GuidedAnswersPanel, GuidedAnswersSerializer } from '../../src/panel/guidedAnswersPanel'; import * as logger from '../../src/logger/logger'; @@ -58,7 +59,7 @@ const getWebViewPanelMock = (onDidReceiveMessage: (callback: WebviewMessageCallb onDidChangeViewState: jest.fn(), onDidDispose: jest.fn(), reveal: jest.fn() - } as unknown as WebviewPanel); + }) as unknown as WebviewPanel; const getApiMock = (firstNodeId?: number): GuidedAnswerAPI => ({ @@ -78,7 +79,7 @@ const getApiMock = (firstNodeId?: number): GuidedAnswerAPI => getTrees: () => Promise.resolve([{ TREE_ID: 1 }, { TREE_ID: 2 }, { TREE_ID: 3 }]), sendFeedbackOutcome: jest.fn(), sendFeedbackComment: jest.fn() - } as unknown as GuidedAnswerAPI); + }) as unknown as GuidedAnswerAPI; const delay = async (ms: number) => { return new Promise((resolve) => setTimeout(resolve, ms)); @@ -325,6 +326,76 @@ describe('GuidedAnswersPanel', () => { expect(webViewPanelMock.webview.postMessage).not.toBeCalled(); }); + test('GuidedAnswersPanel communication NAVIGATE', async () => { + // Mock setup + let onDidReceiveMessageMock: WebviewMessageCallback = () => {}; + const webViewPanelMock = getWebViewPanelMock((callback: WebviewMessageCallback) => { + onDidReceiveMessageMock = callback; + }); + jest.spyOn(window, 'createWebviewPanel').mockImplementation(() => webViewPanelMock); + jest.spyOn(coreMock, 'getGuidedAnswerApi').mockImplementation(() => getApiMock()); + + // Test execution + const panel = new GuidedAnswersPanel(); + panel.show(); + await onDidReceiveMessageMock({ type: NAVIGATE, payload: { treeId: 1, nodeIdPath: [1, 2, 3] } }); + + // Result check + expect(webViewPanelMock.webview.postMessage).toHaveBeenNthCalledWith(1, { + type: UPDATE_NETWORK_STATUS, + payload: 'LOADING' + }); + expect(webViewPanelMock.webview.postMessage).toHaveBeenNthCalledWith(2, { + type: SET_ACTIVE_TREE, + payload: { TREE_ID: 1 } + }); + expect(webViewPanelMock.webview.postMessage).toHaveBeenNthCalledWith(3, { + type: UPDATE_ACTIVE_NODE, + payload: { NODE_ID: 1 } + }); + expect(webViewPanelMock.webview.postMessage).toHaveBeenNthCalledWith(4, { + type: UPDATE_ACTIVE_NODE, + payload: { NODE_ID: 2 } + }); + expect(webViewPanelMock.webview.postMessage).toHaveBeenNthCalledWith(5, { + type: UPDATE_ACTIVE_NODE, + payload: { NODE_ID: 3 } + }); + expect(webViewPanelMock.webview.postMessage).toHaveBeenNthCalledWith(6, { + type: UPDATE_NETWORK_STATUS, + payload: 'OK' + }); + }); + + test('GuidedAnswersPanel communication NAVIGATE throws error', async () => { + // Mock setup + let onDidReceiveMessageMock: WebviewMessageCallback = () => {}; + const webViewPanelMock = getWebViewPanelMock((callback: WebviewMessageCallback) => { + onDidReceiveMessageMock = callback; + }); + jest.spyOn(window, 'createWebviewPanel').mockImplementation(() => webViewPanelMock); + const localApiMock = getApiMock(); + localApiMock.getTreeById = () => { + throw Error('GET_TREE_API_ERROR'); + }; + jest.spyOn(coreMock, 'getGuidedAnswerApi').mockImplementation(() => localApiMock); + + // Test execution + const panel = new GuidedAnswersPanel(); + panel.show(); + await onDidReceiveMessageMock({ type: NAVIGATE, payload: { treeId: 1, nodeIdPath: [1, 2, 3] } }); + + // Result check + expect(webViewPanelMock.webview.postMessage).toHaveBeenNthCalledWith(1, { + type: UPDATE_NETWORK_STATUS, + payload: 'LOADING' + }); + expect(webViewPanelMock.webview.postMessage).toHaveBeenNthCalledWith(2, { + type: UPDATE_NETWORK_STATUS, + payload: 'ERROR' + }); + }); + test('GuidedAnswersPanel communication EXECUTE_COMMAND', async () => { // Mock setup let onDidReceiveMessageMock: WebviewMessageCallback = () => {}; diff --git a/packages/types/src/actions.ts b/packages/types/src/actions.ts index 96123c77..1e95d201 100644 --- a/packages/types/src/actions.ts +++ b/packages/types/src/actions.ts @@ -11,6 +11,7 @@ import type { RestartAnswer, SearchTree, SelectNode, + Navigate, NetworkStatus, SetActiveTree, SetQueryValue, @@ -56,6 +57,7 @@ import { GO_TO_ALL_ANSWERS, RESTART_ANSWER, SELECT_NODE, + NAVIGATE, SET_ACTIVE_TREE, SEARCH_TREE, SET_QUERY_VALUE, @@ -94,6 +96,11 @@ export const updateGuidedAnswerTrees = (payload: UpdateGuidedAnswerTrees['payloa export const selectNode = (payload: GuidedAnswerNodeId): SelectNode => ({ type: SELECT_NODE, payload }); +export const navigate = (payload: { treeId: GuidedAnswerTreeId; nodeIdPath: GuidedAnswerNodeId[] }): Navigate => ({ + type: NAVIGATE, + payload +}); + export const updateActiveNode = (payload: GuidedAnswerNode): UpdateActiveNode => ({ type: UPDATE_ACTIVE_NODE, payload diff --git a/packages/types/src/types.ts b/packages/types/src/types.ts index 46d76163..6ce5c672 100644 --- a/packages/types/src/types.ts +++ b/packages/types/src/types.ts @@ -199,6 +199,7 @@ export type GuidedAnswerActions = | GuideFeedback | SearchTree | SelectNode + | Navigate | SendFeedbackComment | SendFeedbackOutcome | SendTelemetry @@ -258,6 +259,15 @@ export interface SelectNode { payload: GuidedAnswerNodeId; } +export const NAVIGATE = 'NAVIGATE'; +export interface Navigate { + type: typeof NAVIGATE; + payload: { + treeId: GuidedAnswerTreeId; + nodeIdPath: GuidedAnswerNodeId[]; + }; +} + export const UPDATE_ACTIVE_NODE = 'UPDATE_ACTIVE_NODE'; export interface UpdateActiveNode { type: typeof UPDATE_ACTIVE_NODE; diff --git a/packages/webapp/src/webview/state/actions.ts b/packages/webapp/src/webview/state/actions.ts index 0c21cae6..a1548d21 100644 --- a/packages/webapp/src/webview/state/actions.ts +++ b/packages/webapp/src/webview/state/actions.ts @@ -5,6 +5,7 @@ export { updateActiveNodeSharing, updateNetworkStatus, selectNode, + navigate, goToPreviousPage, goToAllAnswers, restartAnswer, diff --git a/packages/webapp/src/webview/ui/components/GuidedAnswerNode/GuidedAnswerNode.tsx b/packages/webapp/src/webview/ui/components/GuidedAnswerNode/GuidedAnswerNode.tsx index 1c1789d9..55e911ac 100644 --- a/packages/webapp/src/webview/ui/components/GuidedAnswerNode/GuidedAnswerNode.tsx +++ b/packages/webapp/src/webview/ui/components/GuidedAnswerNode/GuidedAnswerNode.tsx @@ -1,4 +1,4 @@ -import { default as parse } from 'html-react-parser'; +import { default as parse, domToReact } from 'html-react-parser'; import type { DOMNode, Element } from 'html-react-parser'; import React from 'react'; import type { ReactElement } from 'react'; @@ -7,45 +7,81 @@ import { HTML_ENHANCEMENT_DATA_ATTR_MARKER } from '@sap/guided-answers-extension import { useSelector } from 'react-redux'; import { actions } from '../../../state'; import type { AppState } from '../../../types'; +import { extractLinkInfo } from '../utils'; import './GuidedAnswerNode.scss'; import { GuidedAnswerNavPath } from '../GuidedAnswerNavPath'; import { Middle } from './Middle'; import { Right } from './Right'; /** - * Replacer function for html-react-parser's replace function. If an element was marked, replace it with link + * Replacer function for html-react-parser's replace function. * The command JSON is in the data-* attribute. * * @param domNode - current DOM node from html-react-parser - * @returns - undefined if nothing to replace; the new node () in case of replacement + * @returns - undefined if nothing to replace; the new node in case of replacement */ function replace(domNode: DOMNode): ReactElement | undefined { let result: ReactElement | undefined; if (domNode.type === 'tag') { const domElement: Element = domNode as Element; - const dataCommandString = domElement?.attribs?.[HTML_ENHANCEMENT_DATA_ATTR_MARKER]; - if (dataCommandString) { - try { - const command = JSON.parse(decodeURIComponent(dataCommandString)) as Command; - const textContent = domElement?.firstChild?.type === 'text' ? domElement.firstChild.data : ''; - if (command) { - result = ( - - ); - } - } catch (error) { - console.error(error); + result = replaceDataCommand(domElement); + result = replaceNodeLink(domElement); + } + return result; +} + +/** + * + * @param element - current Element from html-react-parser + * @returns - undefined if nothing to replace; the new node in case of replacement + */ +function replaceDataCommand(element: Element): ReactElement | undefined { + const dataCommandString = element?.attribs?.[HTML_ENHANCEMENT_DATA_ATTR_MARKER]; + if (dataCommandString) { + try { + const command = JSON.parse(decodeURIComponent(dataCommandString)) as Command; + const textContent = element?.firstChild?.type === 'text' ? element.firstChild.data : ''; + if (command) { + return ( + + ); } + } catch (error) { + console.error(error); } } - return result; + return undefined; +} + +/** + * + * @param element - current Element from html-react-parser + * @returns - undefined if nothing to replace; the new node in case of replacement + */ +function replaceNodeLink(element: Element): ReactElement | undefined { + if (element.name === 'a') { + const href = element.attribs.href; + const linkInfo = extractLinkInfo(href); + if (linkInfo && linkInfo.nodeIdPath.length > 0) { + return ( + { + actions.navigate({ treeId: linkInfo.treeId, nodeIdPath: linkInfo.nodeIdPath }); + }}> + {domToReact(element.children)} + + ); + } + } + return undefined; } /** @@ -64,16 +100,6 @@ function enhanceBodyHtml(htmlString: string): ReactElement | undefined { return result as ReactElement; } -/** - * Check if an HTML string contains an enhancement. - * - * @param htmlString - HTML string - * @returns true: has enhancementsl; false: no enhancements - */ -function hasEnhancements(htmlString: string): boolean { - return typeof htmlString === 'string' && htmlString.includes(HTML_ENHANCEMENT_DATA_ATTR_MARKER); -} - /** * Render the react elements to display a Guided Answers node. * @@ -85,7 +111,7 @@ export function GuidedAnswerNode(): ReactElement { const activeNode = nodes[nodes.length - 1]; if (activeNode) { - const enhancedBody = hasEnhancements(activeNode.BODY) ? enhanceBodyHtml(activeNode.BODY) : null; + const enhancedBody = enhanceBodyHtml(activeNode.BODY); return (
diff --git a/packages/webapp/src/webview/ui/components/utils.ts b/packages/webapp/src/webview/ui/components/utils.ts index f7ecc1c9..7e4d277f 100644 --- a/packages/webapp/src/webview/ui/components/utils.ts +++ b/packages/webapp/src/webview/ui/components/utils.ts @@ -1,3 +1,36 @@ +import type { GuidedAnswerNodeId, GuidedAnswerTreeId } from '@sap/guided-answers-extension-types'; + +export interface LinkInfo { + treeId: GuidedAnswerTreeId; + nodeIdPath: GuidedAnswerNodeId[]; +} + +/** + * Extract tree id and node path from link. + * + * @param link - web or vscode link to Guided Answers + * @returns - tree id and node path. In case the info can not be extracted undefined is returned + */ +export function extractLinkInfo(link: string): LinkInfo | undefined { + const fragmentRegExp = /#\/tree\/(\d*)($|\/$|\/actions\/([\d:]*))$/; + + try { + const match = fragmentRegExp.exec(link.trim()); + if (Array.isArray(match) && match.length > 1 && typeof match[1] === 'string') { + const treeId = parseInt(match[1], 10); + const nodeIdPath = + match.length > 3 && typeof match[3] === 'string' + ? match[3].split(':').map((node) => parseInt(node, 10)) + : []; + return { treeId, nodeIdPath }; + } + } catch { + // if information can't be extracted we return undefined + } + + return undefined; +} + export const focusOnElement = (buttonSelector: string): void => { requestAnimationFrame(() => { const button = document.querySelector(buttonSelector) as HTMLElement; diff --git a/packages/webapp/test/GuidedAnswerNode/GuidedAnswerNode.test.tsx b/packages/webapp/test/GuidedAnswerNode/GuidedAnswerNode.test.tsx index 99de8cfe..4ed50f4a 100644 --- a/packages/webapp/test/GuidedAnswerNode/GuidedAnswerNode.test.tsx +++ b/packages/webapp/test/GuidedAnswerNode/GuidedAnswerNode.test.tsx @@ -11,7 +11,7 @@ jest.mock('react-redux', () => ({ .mockReturnValue({ activeGuidedAnswerNode: [] }) .mockReturnValueOnce([ { - BODY: `

SAP Fiori Tools is a set of extensions for SAP Business Application Studio and Visual Studio Code Fiori: Archive Project

`, + BODY: `

SAP Fiori Tools is a set of extensions link for SAP Business Application Studio and Visual Studio Code Fiori: Archive Project

`, EDGES: [ { LABEL: 'Deployment', TARGET_NODE: 45996, ORD: 1 }, { LABEL: 'Fiori Generator', TARGET_NODE: 48363, ORD: 2 } diff --git a/packages/webapp/test/Utils.test.tsx b/packages/webapp/test/Utils.test.tsx index 5d732bc8..c25e02c3 100644 --- a/packages/webapp/test/Utils.test.tsx +++ b/packages/webapp/test/Utils.test.tsx @@ -1,4 +1,4 @@ -import { focusOnElement } from '../src/webview/ui/components/utils'; +import { focusOnElement, extractLinkInfo } from '../src/webview/ui/components/utils'; describe('Test utilities', () => { beforeEach(() => { @@ -33,4 +33,17 @@ describe('Test utilities', () => { // Act focusOnElement(nonExistentSelector); }); + + it('should extract link info', () => { + expect( + extractLinkInfo('vscode://saposs.sap-guided-answers-extension#/tree/2827/actions/41344:41346:57775:57776') + ).toStrictEqual({ + treeId: 2827, + nodeIdPath: [41344, 41346, 57775, 57776] + }); + }); + + it('should extract link info, not a node link', () => { + expect(extractLinkInfo('vscode://saposs.sap-guided-answers-extension#/some/fake/link')).toBeUndefined(); + }); }); From 449fc8274d3335069f9196e1930afa5092843741 Mon Sep 17 00:00:00 2001 From: Olivers Berzs <105420446+oliversberzs@users.noreply.github.com> Date: Fri, 22 Dec 2023 12:48:49 +0200 Subject: [PATCH 2/3] Create cuddly-ligers-prove.md --- .changeset/cuddly-ligers-prove.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/cuddly-ligers-prove.md diff --git a/.changeset/cuddly-ligers-prove.md b/.changeset/cuddly-ligers-prove.md new file mode 100644 index 00000000..e5a85919 --- /dev/null +++ b/.changeset/cuddly-ligers-prove.md @@ -0,0 +1,7 @@ +--- +"sap-guided-answers-extension": patch +"@sap/guided-answers-extension-types": patch +"@sap/guided-answers-extension-webapp": patch +--- + +feat(581): open extension links in extension From f88b3d7805ad952c4c99defeb61ae2fcc169608b Mon Sep 17 00:00:00 2001 From: Olivers Berzs Date: Fri, 22 Dec 2023 12:53:29 +0200 Subject: [PATCH 3/3] feat(581): fix sonarcloud issue --- .../ui/components/GuidedAnswerNode/GuidedAnswerNode.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/webapp/src/webview/ui/components/GuidedAnswerNode/GuidedAnswerNode.tsx b/packages/webapp/src/webview/ui/components/GuidedAnswerNode/GuidedAnswerNode.tsx index 55e911ac..62eca6ef 100644 --- a/packages/webapp/src/webview/ui/components/GuidedAnswerNode/GuidedAnswerNode.tsx +++ b/packages/webapp/src/webview/ui/components/GuidedAnswerNode/GuidedAnswerNode.tsx @@ -24,8 +24,12 @@ function replace(domNode: DOMNode): ReactElement | undefined { let result: ReactElement | undefined; if (domNode.type === 'tag') { const domElement: Element = domNode as Element; + result = replaceDataCommand(domElement); - result = replaceNodeLink(domElement); + + if (!result) { + result = replaceNodeLink(domElement); + } } return result; }