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: Adding UI for NL2F Expression Assistant and Copilot Service Interface #5107

Merged
merged 31 commits into from
Jul 23, 2024
Merged
Show file tree
Hide file tree
Changes from 29 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
c3580b2
improvements to logger service and interface
maturpi Mar 29, 2023
25b026e
updating logger tests
maturpi Mar 29, 2023
a2ae641
making eventData required so that status is always needed for end tra…
maturpi Mar 29, 2023
163579c
Merge branch 'Azure:main' into main
maturpi Apr 3, 2023
01f159c
Merge branch 'Azure:main' into main
maturpi Apr 5, 2023
2deb8c7
Merge branch 'Azure:main' into main
maturpi Apr 17, 2023
57a5f16
Merge branch 'Azure:main' into main
maturpi Apr 21, 2023
cc0d05b
Merge branch 'Azure:main' into main
maturpi May 1, 2023
aa3c9ec
Merge branch 'Azure:main' into main
maturpi May 2, 2023
6cfbd17
Merge branch 'Azure:main' into main
maturpi May 4, 2023
ff7d875
Merge branch 'Azure:main' into main
maturpi May 5, 2023
46d415b
Merge branch 'Azure:main' into main
maturpi May 8, 2023
e071882
Merge branch 'Azure:main' into main
maturpi May 9, 2023
7e0b13b
Merge branch 'Azure:main' into main
maturpi May 10, 2023
f3a22ba
Merge branch 'main' of https://github.com/maturpi/LogicAppsUX
maturpi Apr 25, 2024
d3a7892
fixing merge conflicts
maturpi Apr 25, 2024
a6ddd36
Merge remote-tracking branch 'upstream/main'
maturpi Apr 25, 2024
9ce2f4e
adding nl2f expressions feature and copilot client service
maturpi May 10, 2024
0fc249e
first iteration of NL2F Expression Assistant UI + fixing lint errors
maturpi May 30, 2024
344c555
resolving merge conflict from merging master into fork
maturpi May 30, 2024
3a40526
Merge branch 'main' into main
hartra344 May 31, 2024
702141c
merging upstream repo to forked repo
maturpi Jul 10, 2024
8925745
Merge branch 'main' of https://github.com/maturpi/LogicAppsUX
maturpi Jul 10, 2024
90e87f4
fixing npm problem by removing package-lock
maturpi Jul 11, 2024
46262e5
Merge remote-tracking branch 'upstream/main'
maturpi Jul 11, 2024
85bdffd
adding unit tests
maturpi Jul 23, 2024
6b87ecb
Merge branch 'main' into main
maturpi Jul 23, 2024
bc62f29
Merge remote-tracking branch 'upstream/main'
maturpi Jul 23, 2024
28fea81
Merge branch 'main' of https://github.com/maturpi/LogicAppsUX
maturpi Jul 23, 2024
dafa910
fixing file import syntax
maturpi Jul 23, 2024
b190e73
Merge remote-tracking branch 'upstream/main'
maturpi Jul 23, 2024
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
36 changes: 36 additions & 0 deletions Localize/lang/strings.json

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import {
ConsumptionConnectionService,
StandardCustomCodeService,
ResourceIdentityType,
// Uncomment to use dummy version of copilot expression assistant
// BaseCopilotService,
BaseTenantService,
} from '@microsoft/logic-apps-shared';
import type { ContentType } from '@microsoft/logic-apps-shared';
Expand Down Expand Up @@ -58,6 +60,9 @@ const operationManifestServiceStandard = new StandardOperationManifestService({
httpClient,
});

// Uncomment to use dummy version of copilot expression assistant
// const baseCopilotService = new BaseCopilotService({isDev: true});

const operationManifestServiceConsumption = new ConsumptionOperationManifestService({
apiVersion: '2018-11-01',
baseUrl: '/url',
Expand Down Expand Up @@ -183,6 +188,8 @@ export const LocalDesigner = () => {
connectionService: isConsumption ? connectionServiceConsumption : connectionServiceStandard,
operationManifestService: isConsumption ? operationManifestServiceConsumption : operationManifestServiceStandard,
searchService: isConsumption ? searchServiceConsumption : searchServiceStandard,
// Uncomment to use dummy version of copilot expression assistant
// copilotService: baseCopilotService,
oAuthService,
gatewayService,
tenantService,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,279 @@
import * as React from 'react';
import { describe, vi, it, expect } from 'vitest';
import { render, waitFor } from '@testing-library/react';
import { Nl2fExpressionAssistant } from '../nl2fExpressionAssistant';
import { IntlProvider } from '../../../../../logic-apps-shared/src/intl/src/IntlProvider';
import { getReactQueryClient } from '../../../../../designer/src/lib/core/ReactQueryProvider';
import { QueryClientProvider } from '@tanstack/react-query';
import { ExpressionEditorEvent } from '../../expressioneditor';
import { CopilotServiceOptions, ICopilotService, InitCopilotService, Nl2fExpressionResult } from '@microsoft/logic-apps-shared';

describe('lib/nl2fExpressionAssistant', () => {
const dataTestIds = {
panelHeaderBackButton: 'expression-assistant-panel-header-back-button',
panelHeaderTitle: 'expression-assistant-panel-header-title',
panelHeaderCloseButton: 'expression-assistant-panel-header-close-button',

inputBoxTextField: 'expression-assistant-input-box-text',
inputBoxSubmitButton: 'expression-assistant-input-box-submit',

progressCard: 'expression-assistant-progress-card',
resultSection: 'expression-assistant-result-section',
outputBoxTitle: 'expression-assistant-output-box-title',
outputBox: 'expression-assistant-output-box',

resultCarousel: 'expression-assistant-result-carousel',
resultFooter: 'expression-assistant-result-footer',
feedbackComponent: 'expression-assistant-result-feedback',

okButton: 'expression-assistant-ok-button',
};

const setExpression = vi.fn();
const setFullScreen = vi.fn();
const setExpressionEditorError = vi.fn();
const setSelectedMode = vi.fn();
const intlOnError = vi.fn();

const queryClient = getReactQueryClient();

it('Should have the expected header for expression assistant panel', () => {
const { getByTestId } = render(
<QueryClientProvider client={queryClient}>
<IntlProvider locale={''} defaultLocale={''} onError={intlOnError}>
<Nl2fExpressionAssistant
isFullScreen={false}
expression={''}
isFixErrorRequest={false}
setFullScreen={setFullScreen}
setSelectedMode={setSelectedMode}
setExpression={setExpression}
setExpressionEditorError={setExpressionEditorError}
/>
</IntlProvider>
</QueryClientProvider>
);

const backButton = getByTestId(dataTestIds.panelHeaderBackButton) as HTMLButtonElement;
const headerTitle = getByTestId(dataTestIds.panelHeaderTitle) as HTMLElement;
const closeButton = getByTestId(dataTestIds.panelHeaderCloseButton) as HTMLButtonElement;

expect(backButton).toBeDefined();
expect(headerTitle.textContent).toMatch('Create an expression with Copilot');
expect(closeButton).toBeDefined();
// NOTE: functionality should be tested in separate file for TokenPickerHeader component
});

it('Should open in create UX if the original expression was empty', () => {
const { getByTestId } = render(
<QueryClientProvider client={queryClient}>
<IntlProvider locale={''} defaultLocale={''} onError={intlOnError}>
<Nl2fExpressionAssistant
isFullScreen={false}
expression={''}
isFixErrorRequest={false}
setFullScreen={setFullScreen}
setSelectedMode={setSelectedMode}
setExpression={setExpression}
setExpressionEditorError={setExpressionEditorError}
/>
</IntlProvider>
</QueryClientProvider>
);

const inputBoxText = getByTestId(dataTestIds.inputBoxTextField) as HTMLInputElement;
expect(inputBoxText.placeholder).toMatch(
'Describe the expression you want Copilot to create. You can reference data from other actions in the flow. For example, “Combine the first and last name of the person who went the email” or “Check the status of the current job.”'
);
const inputBoxSubmitButton = getByTestId(dataTestIds.inputBoxSubmitButton) as HTMLButtonElement;
expect(inputBoxSubmitButton.title).toMatch('Create expression');
expect(inputBoxSubmitButton.disabled).toBeTruthy();

// these shouldn't exist at this point
expect(() => getByTestId(dataTestIds.progressCard)).toThrow();
expect(() => getByTestId(dataTestIds.resultSection)).toThrow();
expect(() => getByTestId(dataTestIds.okButton)).toThrow();
});

it('Should open in edit UX if the original expression was valid and non-empty', () => {
const dummyExpression: ExpressionEditorEvent = {
value: 'some valid expression',
selectionStart: 0,
selectionEnd: 0,
};
const { getByTestId } = render(
<QueryClientProvider client={queryClient}>
<IntlProvider locale={''} defaultLocale={''} onError={intlOnError}>
<Nl2fExpressionAssistant
isFullScreen={false}
expression={dummyExpression}
isFixErrorRequest={false} // this is determined by expression editor before going into expression assistant
setFullScreen={setFullScreen}
setSelectedMode={setSelectedMode}
setExpression={setExpression}
setExpressionEditorError={setExpressionEditorError}
/>
</IntlProvider>
</QueryClientProvider>
);

const inputBoxText = getByTestId(dataTestIds.inputBoxTextField) as HTMLInputElement;
expect(inputBoxText.placeholder).toMatch("Describe how you'd like to update your expression.");
const inputBoxSubmitButton = getByTestId(dataTestIds.inputBoxSubmitButton) as HTMLButtonElement;
expect(inputBoxSubmitButton.title).toMatch('Create expression');
expect(inputBoxSubmitButton.disabled).toBeTruthy();

const outputBoxTitle = getByTestId(dataTestIds.outputBoxTitle) as HTMLElement;
expect(outputBoxTitle.textContent).toMatch('Original expression');
const outputBoxTextArea = getByTestId(dataTestIds.outputBox) as HTMLTextAreaElement;
expect(outputBoxTextArea.textContent).toMatch(dummyExpression.value);
// this shouldn't be displayed as there's no actual results yet - we are just showing original expression
const resultFooter = getByTestId(dataTestIds.resultFooter);
expect(resultFooter.style['visibility']).toMatch('hidden');

// user should be able to 'accept' result, which is just original expression at this point
const okButton = getByTestId(dataTestIds.okButton) as HTMLButtonElement;
expect(okButton.disabled).toBeFalsy();

// this shouldn't exist at this point
expect(() => getByTestId(dataTestIds.progressCard)).toThrow();
});

it('Should open in fix UX if the original expression was invalid', async () => {
class TestCopilotService implements ICopilotService {
constructor(public readonly options: CopilotServiceOptions) {}
async getNl2fExpressions(query: string, originalExpression?: string): Promise<Nl2fExpressionResult> {
return Promise.resolve({
suggestions: [{ suggestedExpression: 'suggested fix' }],
});
}
}
InitCopilotService(new TestCopilotService({}));
const dummyExpression: ExpressionEditorEvent = {
value: 'some invalid expression',
selectionStart: 0,
selectionEnd: 0,
};

const { getByTestId } = render(
<QueryClientProvider client={queryClient}>
<IntlProvider locale={'en-us'} defaultLocale={''} onError={intlOnError}>
<Nl2fExpressionAssistant
isFullScreen={false}
expression={dummyExpression}
isFixErrorRequest={true} // this is determined by expression editor before going into expression assistant
setFullScreen={setFullScreen}
setSelectedMode={setSelectedMode}
setExpression={setExpression}
setExpressionEditorError={setExpressionEditorError}
/>
</IntlProvider>
</QueryClientProvider>
);

await waitFor(() => {
const inputBoxText = getByTestId(dataTestIds.inputBoxTextField) as HTMLInputElement;
// NOTE: it doesn't seem like intl formats/interpolates strings during unit tests
expect(inputBoxText.textContent).toMatch('Fix my expression: {expression}');
const inputBoxSubmitButton = getByTestId(dataTestIds.inputBoxSubmitButton) as HTMLButtonElement;
expect(inputBoxSubmitButton.title).toMatch('Create expression');

const outputBoxTitle = getByTestId(dataTestIds.outputBoxTitle) as HTMLElement;
expect(outputBoxTitle.textContent).toMatch('Suggested expression');
const outputBoxTextArea = getByTestId(dataTestIds.outputBox) as HTMLTextAreaElement;
expect(outputBoxTextArea.textContent).toMatch('suggested fix');

// result(s) are present, user should be able to provide feedback on them
const feedbackComponent = getByTestId(dataTestIds.feedbackComponent) as HTMLElement;
expect(feedbackComponent).toBeDefined();

// single result returned, this should be hidden
const resultCarousel = getByTestId(dataTestIds.resultCarousel) as HTMLDivElement;
expect(resultCarousel.style['visibility']).toMatch('hidden');

// result(s) are present, user should be able to accept one of them
const okButton = getByTestId(dataTestIds.okButton) as HTMLButtonElement;
expect(okButton.disabled).toBeFalsy();
});
});

it('Should show error messages in result box when encountering errors', async () => {
class TestCopilotService implements ICopilotService {
constructor(public readonly options: CopilotServiceOptions) {}
async getNl2fExpressions(query: string, originalExpression?: string): Promise<Nl2fExpressionResult> {
return Promise.resolve({
errorMessage: 'some error',
});
}
}
InitCopilotService(new TestCopilotService({}));

const { getByTestId } = render(
<QueryClientProvider client={queryClient}>
<IntlProvider locale={'en-us'} defaultLocale={''} onError={intlOnError}>
<Nl2fExpressionAssistant
isFullScreen={false}
expression={''}
isFixErrorRequest={true} // will start UI in fix state which automatically sends copilot request
setFullScreen={setFullScreen}
setSelectedMode={setSelectedMode}
setExpression={setExpression}
setExpressionEditorError={setExpressionEditorError}
/>
</IntlProvider>
</QueryClientProvider>
);

await waitFor(() => {
const outputBoxTitle = getByTestId(dataTestIds.outputBoxTitle) as HTMLElement;
expect(outputBoxTitle.textContent).toMatch('Suggested expression');
const outputBoxTextArea = getByTestId(dataTestIds.outputBox) as HTMLTextAreaElement;
expect(outputBoxTextArea.textContent).toMatch('some error');

// error implies single output of that error message - so no carousel in this case
const resultCarousel = getByTestId(dataTestIds.resultCarousel) as HTMLElement;
expect(resultCarousel.style['visibility']).toMatch('hidden');

// error is present, user should be able to provide feedback
const feedbackComponent = getByTestId(dataTestIds.feedbackComponent) as HTMLElement;
expect(feedbackComponent).toBeDefined();

// shouldn't be able to 'accept' an error
const okButton = getByTestId(dataTestIds.okButton) as HTMLButtonElement;
expect(okButton.disabled).toBeTruthy();
});
});

it('Should show carousel if copilot returns multiple suggestions', async () => {
class TestCopilotService implements ICopilotService {
constructor(public readonly options: CopilotServiceOptions) {}
async getNl2fExpressions(query: string, originalExpression?: string): Promise<Nl2fExpressionResult> {
return Promise.resolve({
suggestions: [{ suggestedExpression: 'suggested fix 1' }, { suggestedExpression: 'suggested fix 2' }],
});
}
}
InitCopilotService(new TestCopilotService({}));

const { getByTestId } = render(
<QueryClientProvider client={queryClient}>
<IntlProvider locale={'en-us'} defaultLocale={''} onError={intlOnError}>
<Nl2fExpressionAssistant
isFullScreen={false}
expression={''}
isFixErrorRequest={true} // will start UI in fix state which automatically sends copilot request
setFullScreen={setFullScreen}
setSelectedMode={setSelectedMode}
setExpression={setExpression}
setExpressionEditorError={setExpressionEditorError}
/>
</IntlProvider>
</QueryClientProvider>
);

await waitFor(() => {
const resultCarousel = getByTestId(dataTestIds.resultCarousel) as HTMLElement;
expect(resultCarousel.style['visibility']).toMatch('visible');
});
});
});
39 changes: 39 additions & 0 deletions libs/designer-ui/src/lib/tokenpicker/images/copilotLogo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading