Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(581): open extension links in extension #585

Merged
merged 4 commits into from
Dec 28, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/cuddly-ligers-prove.md
Original file line number Diff line number Diff line change
@@ -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
28 changes: 28 additions & 0 deletions packages/ide-extension/src/panel/guidedAnswersPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<void> {
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.
*
Expand All @@ -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;
Expand Down
79 changes: 75 additions & 4 deletions packages/ide-extension/test/panel/guidedAnswersPanel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
EXECUTE_COMMAND,
SEARCH_TREE,
SELECT_NODE,
NAVIGATE,
SET_ACTIVE_TREE,
UPDATE_ACTIVE_NODE,
UPDATE_GUIDED_ANSWER_TREES,
Expand All @@ -18,7 +19,6 @@ import {
RESTORE_STATE,
SEND_TELEMETRY,
GET_BOOKMARKS,
AppState,
SYNCHRONIZE_BOOKMARK,
UPDATE_BOOKMARKS,
GET_LAST_VISITED_GUIDES,
Expand All @@ -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';
Expand All @@ -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 =>
({
Expand All @@ -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));
Expand Down Expand Up @@ -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 = () => {};
Expand Down
7 changes: 7 additions & 0 deletions packages/types/src/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
RestartAnswer,
SearchTree,
SelectNode,
Navigate,
NetworkStatus,
SetActiveTree,
SetQueryValue,
Expand Down Expand Up @@ -56,6 +57,7 @@ import {
GO_TO_ALL_ANSWERS,
RESTART_ANSWER,
SELECT_NODE,
NAVIGATE,
SET_ACTIVE_TREE,
SEARCH_TREE,
SET_QUERY_VALUE,
Expand Down Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions packages/types/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ export type GuidedAnswerActions =
| GuideFeedback
| SearchTree
| SelectNode
| Navigate
| SendFeedbackComment
| SendFeedbackOutcome
| SendTelemetry
Expand Down Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions packages/webapp/src/webview/state/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export {
updateActiveNodeSharing,
updateNetworkStatus,
selectNode,
navigate,
goToPreviousPage,
goToAllAnswers,
restartAnswer,
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -7,47 +7,87 @@ 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 <a>
* 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 (<a>) 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 = (
<button
title={command.description}
className="enhancement-link"
onClick={(): void => {
actions.executeCommand(command);
}}>
{textContent}
</button>
);
}
} catch (error) {
console.error(error);
}

result = replaceDataCommand(domElement);

if (!result) {
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 (
<button
title={command.description}
className="enhancement-link"
onClick={(): void => {
actions.executeCommand(command);
}}>
{textContent}
</button>
);
}
} catch (error) {
console.error(error);
}
}
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 (
<a
href=""
onClick={(): void => {
actions.navigate({ treeId: linkInfo.treeId, nodeIdPath: linkInfo.nodeIdPath });
}}>
{domToReact(element.children)}
</a>
);
}
}
return undefined;
}

/**
* Process the enhancements in an HTML string, which includes parsing the HTML and replacing the respective nodes.
*
Expand All @@ -64,16 +104,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.
*
Expand All @@ -85,7 +115,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 (
<section className="guided-answer__node__body">
Expand Down
Loading
Loading