Skip to content

Commit

Permalink
feat(content-answers): retry button (#3451)
Browse files Browse the repository at this point in the history
* feat(content-answers): retry button

* feat(content-answers): feedback

* feat(content-answers): feedback

* feat(content-answers): lint

* feat(content-answers): retry connected

* feat(content-answers): feedback

* feat(content-answers): feedback

---------

Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
  • Loading branch information
greg-in-a-box and mergify[bot] committed Oct 26, 2023
1 parent 85d2521 commit e0976b8
Show file tree
Hide file tree
Showing 10 changed files with 204 additions and 32 deletions.
2 changes: 2 additions & 0 deletions i18n/en-US.properties
Expand Up @@ -928,6 +928,8 @@ boxui.contentAnswers.intelligenceUnavailableHeading = Box AI is unavailable
boxui.contentAnswers.intelligenceUnavailableTryAgain = Please try again later.
# Error tooltip to show inside text area if the user reached the character limit
boxui.contentAnswers.maxCharactersReachedError = Maximum of {characterLimit} characters reached
# Retry button label to send again the question to the service
boxui.contentAnswers.retryResponse = Retry
# Content Answers welcome message for asking questions
boxui.contentAnswers.welcomeAskQuestionText = Ask questions about {name}
# Content Answers welcome message for clearing the chat
Expand Down
20 changes: 16 additions & 4 deletions src/features/content-answers/ContentAnswersModal.tsx
Expand Up @@ -39,6 +39,7 @@ type Props = {

const ContentAnswersModal = ({ api, currentUser, file, isOpen, onRequestClose }: Props) => {
const fileName = file && file.name;
const [hasError, setHasError] = useState<boolean>(false);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [questions, setQuestions] = useState<QuestionType[]>([]);

Expand All @@ -55,22 +56,27 @@ const ContentAnswersModal = ({ api, currentUser, file, isOpen, onRequestClose }:
}, []);

const handleErrorCallback = useCallback((error: ElementsXhrError): void => {
setHasError(true);
setQuestions(prevState => {
const lastQuestion = prevState[prevState.length - 1];
return [...prevState.slice(0, -1), { ...lastQuestion, error }];
});
}, []);

const handleOnAsk = useCallback(
async (prompt: string) => {
const handleAsk = useCallback(
async (prompt: string, isRetry = false) => {
setHasError(false);
const id = file && file.id;
const items = [
{
id,
type: 'file',
},
];
setQuestions([...questions, { prompt }]);

const nextQuestions = [...(isRetry ? questions.slice(0, -1) : questions)];
setQuestions([...nextQuestions, { prompt }]);

setIsLoading(true);
try {
const response = await api.getIntelligenceAPI(true).ask(prompt, items);
Expand All @@ -83,6 +89,10 @@ const ContentAnswersModal = ({ api, currentUser, file, isOpen, onRequestClose }:
[api, file, handleErrorCallback, handleSuccessCallback, questions],
);

const handleRetry = useCallback(() => {
handleAsk(questions[questions.length - 1].prompt, true);
}, [handleAsk, questions]);

return (
<Modal
className="bdl-ContentAnswersModal"
Expand Down Expand Up @@ -111,8 +121,10 @@ const ContentAnswersModal = ({ api, currentUser, file, isOpen, onRequestClose }:
<ContentAnswersModalFooter
currentUser={currentUser}
data-testid="content-answers-modal-footer"
hasError={hasError}
isLoading={isLoading}
onAsk={handleOnAsk}
onAsk={handleAsk}
onRetry={handleRetry}
/>
</Modal>
);
Expand Down
@@ -1,6 +1,6 @@
@import '../../styles/variables';

.bdl-ContentAnswersModalFooter {
.bdl-ContentAnswersModalFooter-questionInput {
position: relative;
z-index: 1;
display: flex;
Expand Down
60 changes: 33 additions & 27 deletions src/features/content-answers/ContentAnswersModalFooter.tsx
Expand Up @@ -9,19 +9,22 @@ import { TEXT_AREA } from './constants';
// @ts-ignore: no ts definition
// eslint-disable-next-line import/named
import { User } from '../../../common/types/core';
import ContentAnswersModalFooterActions from './ContentAnswersModalFooterActions';

import messages from './messages';

import './ContentAnswersModalFooter.scss';

type Props = {
currentUser?: User;
hasError: boolean;
intl: IntlShape;
isLoading: boolean;
onAsk: Function;
onRetry: Function;
};

const ContentAnswersModalFooter = ({ currentUser, intl, isLoading, onAsk }: Props) => {
const ContentAnswersModalFooter = ({ currentUser, hasError, intl, isLoading, onAsk, onRetry }: Props) => {
const { formatMessage } = intl;
const { id, name } = currentUser || {};
const [prompt, setPrompt] = useState('');
Expand Down Expand Up @@ -63,33 +66,36 @@ const ContentAnswersModalFooter = ({ currentUser, intl, isLoading, onAsk }: Prop

return (
<div className="bdl-ContentAnswersModalFooter">
<div className="bdl-ContentAnswersModalFooter-avatar">
<Avatar id={id} name={name} />
<ContentAnswersModalFooterActions hasError={hasError} onRetry={onRetry} />
<div className="bdl-ContentAnswersModalFooter-questionInput">
<div className="bdl-ContentAnswersModalFooter-avatar">
<Avatar id={id} name={name} />
</div>
<TextArea
data-testid="content-answers-question-input"
error={
hasMaxCharacterError &&
formatMessage(messages.maxCharactersReachedError, {
characterLimit: TEXT_AREA.MAX_LENGTH,
})
}
hideLabel
label={formatMessage(messages.askQuestionPlaceholder)}
maxLength={TEXT_AREA.MAX_LENGTH}
onChange={handleInputChange}
placeholder={formatMessage(messages.askQuestionPlaceholder)}
value={prompt}
onKeyDown={handleKeyDown}
/>
<PrimaryButton
className="bdl-ContentAnswersModalFooter-submitButton"
data-testid="content-answers-submit-button"
isDisabled={isSubmitDisabled}
onClick={handleOnAsk}
>
<FormattedMessage {...messages.ask} />
</PrimaryButton>
</div>
<TextArea
data-testid="content-answers-question-input"
error={
hasMaxCharacterError &&
formatMessage(messages.maxCharactersReachedError, {
characterLimit: TEXT_AREA.MAX_LENGTH,
})
}
hideLabel
label={formatMessage(messages.askQuestionPlaceholder)}
maxLength={TEXT_AREA.MAX_LENGTH}
onChange={handleInputChange}
placeholder={formatMessage(messages.askQuestionPlaceholder)}
value={prompt}
onKeyDown={handleKeyDown}
/>
<PrimaryButton
className="bdl-ContentAnswersModalFooter-submitButton"
data-testid="content-answers-submit-button"
isDisabled={isSubmitDisabled}
onClick={handleOnAsk}
>
<FormattedMessage {...messages.ask} />
</PrimaryButton>
</div>
);
};
Expand Down
17 changes: 17 additions & 0 deletions src/features/content-answers/ContentAnswersModalFooterActions.scss
@@ -0,0 +1,17 @@
@import '../../styles/variables';

.bdl-ContentAnswersModalFooterActions {
position: relative;
top: -$bdl-grid-unit * 4;
display: flex;
padding: $bdl-grid-unit * 3 0 0 0;
}

.bdl-ContentAnswersModalFooterActions-button {
margin: auto;

&:not(.bdl-is-disabled):focus {
border: 1px solid #bcbcbc;
box-shadow: 0 0 0 1px $white, 0 0 0 3px $bdl-light-blue;
}
}
45 changes: 45 additions & 0 deletions src/features/content-answers/ContentAnswersModalFooterActions.tsx
@@ -0,0 +1,45 @@
import React from 'react';

import { FormattedMessage } from 'react-intl';
import Button from '../../components/button';

import messages from './messages';

import './ContentAnswersModalFooterActions.scss';

interface Props {
hasError: boolean;
onRetry: Function;
}

const ContentAnswersModalFooterActions = ({ hasError, onRetry }: Props) => {
const retryButtonRef = React.useRef<HTMLButtonElement | null>(null);

React.useEffect(() => {
if (retryButtonRef.current && hasError) {
retryButtonRef.current.focus();
}
}, [retryButtonRef, hasError]);

if (!hasError) {
return null;
}

return (
<div className="bdl-ContentAnswersModalFooterActions" data-testid="content-answers-modal-footer-actions">
<Button
setRef={(ref: HTMLButtonElement) => {
retryButtonRef.current = ref;
}}
className="bdl-ContentAnswersModalFooterActions-button"
data-testid="content-answers-retry-button"
onClick={onRetry}
size="large"
>
<FormattedMessage {...messages.retryResponse} />
</Button>
</div>
);
};

export default ContentAnswersModalFooterActions;
Expand Up @@ -65,4 +65,50 @@ describe('features/content-answers/ContentAnswersModal', () => {

expect(screen.getByTestId('InlineError')).toBeInTheDocument();
});

test('should render retry button when ask request fails', async () => {
const { prompt } = mockQuestionsWithError[0];
renderComponent(mockApiReturnError);

const textArea = screen.getByTestId('content-answers-question-input');
fireEvent.change(textArea, { target: { value: prompt } });

const submitButton = screen.getByTestId('content-answers-submit-button');
fireEvent.click(submitButton);

expect(screen.getByTestId('content-answers-retry-button')).toBeInTheDocument();
});

test('should render retry button when ask request fails', async () => {
const { prompt } = mockQuestionsWithError[0];
const apiMock = {
...mockApi,
getIntelligenceAPI: jest.fn().mockReturnValue({
ask: jest
.fn()
.mockImplementationOnce(() => {
throw new Error('error');
})
.mockResolvedValueOnce({
data: mockQuestionsWithAnswer[0],
}),
}),
};
renderComponent(apiMock);

const textArea = screen.getByTestId('content-answers-question-input');
fireEvent.change(textArea, { target: { value: prompt } });

const submitButton = screen.getByTestId('content-answers-submit-button');
fireEvent.click(submitButton);

const retryButton = screen.getByTestId('content-answers-retry-button');
fireEvent.click(retryButton);

await waitFor(() => {
expect(screen.getByTestId('content-answers-question')).toBeInTheDocument();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
expect(screen.getByText(mockQuestionsWithAnswer[0].answer!)).toBeInTheDocument();
});
});
});
Expand Up @@ -18,8 +18,10 @@ describe('features/content-answers/ContentAnswersModalFooter', () => {
<APIContext.Provider value={mockApi}>
<ContentAnswersModalFooter
currentUser={mockCurrentUser}
hasError={false}
isLoading={false}
onAsk={jest.fn()}
onRetry={jest.fn()}
{...props}
/>
</APIContext.Provider>,
Expand Down
@@ -0,0 +1,37 @@
import React from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import ContentAnswersModalFooterActions from '../ContentAnswersModalFooterActions';

describe('features/content-answers/ContentAnswersModalFooterActions', () => {
const stubs = {
onRetry: jest.fn(),
};

const renderComponent = (props = {}) => {
return render(<ContentAnswersModalFooterActions hasError={false} onRetry={stubs.onRetry} {...props} />);
};

test('should not show the retry button', () => {
renderComponent();
const retryButton = screen.queryByTestId('content-answers-retry-button');
expect(retryButton).not.toBeInTheDocument();
});

test('should show the retry button', async () => {
renderComponent({ hasError: true });
const retryButton = screen.getByTestId('content-answers-retry-button');
expect(retryButton).toBeInTheDocument();
await waitFor(() => {
expect(document.activeElement).toBe(retryButton);
});
});

test('should call the retry response function if the button is clicked', () => {
renderComponent({ hasError: true });
const retryButton = screen.getByTestId('content-answers-retry-button');

fireEvent.click(retryButton);

expect(stubs.onRetry).toBeCalled();
});
});
5 changes: 5 additions & 0 deletions src/features/content-answers/messages.ts
Expand Up @@ -57,6 +57,11 @@ const messages = defineMessages({
description: 'Error tooltip to show inside text area if the user reached the character limit',
id: 'boxui.contentAnswers.maxCharactersReachedError',
},
retryResponse: {
defaultMessage: 'Retry',
description: 'Retry button label to send again the question to the service',
id: 'boxui.contentAnswers.retryResponse',
},
welcomeAskQuestionText: {
defaultMessage: 'Ask questions about {name}',
description: 'Content Answers welcome message for asking questions',
Expand Down

0 comments on commit e0976b8

Please sign in to comment.