Skip to content

Commit

Permalink
Remove timeout from user feedback
Browse files Browse the repository at this point in the history
  • Loading branch information
rebeccaalpert committed Jan 20, 2025
1 parent b682d17 commit ddfffae
Show file tree
Hide file tree
Showing 3 changed files with 3 additions and 330 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -9,36 +9,11 @@ export const MessageWithFeedbackTimeoutExample: React.FunctionComponent = () =>
return (
<>
<Button variant="secondary" onClick={() => setHasFeedback(true)}>
Show feedback cards
Show card
</Button>
<Button variant="secondary" onClick={() => setHasFeedback(false)}>
Remove all feedback cards
Remove card
</Button>
<Message
name="Bot"
role="bot"
avatar={patternflyAvatar}
content="Bot message with feedback form that times out"
userFeedbackForm={
/* eslint-disable indent */
hasFeedback
? {
quickResponses: [
{ id: '1', content: 'Correct' },
{ id: '2', content: 'Easy to understand' },
{ id: '3', content: 'Complete' }
],
onSubmit: (quickResponse, additionalFeedback) =>
alert(`Selected ${quickResponse} and received the additional feedback: ${additionalFeedback}`),
hasTextArea: true,
timeout: true
}
: undefined
/* eslint-enable indent */
}
isLiveRegion
/>
,
<Message
name="Bot"
role="bot"
Expand Down
207 changes: 0 additions & 207 deletions packages/module/src/Message/UserFeedback/UserFeedback.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,213 +87,6 @@ describe('UserFeedback', () => {
render(<UserFeedback onSubmit={jest.fn} quickResponses={MOCK_RESPONSES} id="test" data-testid="card" />);
expect(screen.getByTestId('card').parentElement).toHaveAttribute('id', 'test');
});
it('renders with no timeout by default', () => {
jest.useFakeTimers();
render(<UserFeedback onSubmit={jest.fn} quickResponses={MOCK_RESPONSES} />);
act(() => {
jest.advanceTimersByTime(8000);
});
expect(screen.getByText('Why did you choose this rating?')).toBeVisible();
jest.useRealTimers();
});
it('should handle timeout correctly after 8000ms when timeout = true', async () => {
jest.useFakeTimers();
render(<UserFeedback onSubmit={jest.fn} quickResponses={MOCK_RESPONSES} timeout />);
act(() => {
jest.advanceTimersByTime(7999);
});
expect(screen.getByText('Why did you choose this rating?')).toBeVisible();
act(() => {
jest.advanceTimersByTime(1);
});
expect(screen.queryByText('Why did you choose this rating?')).not.toBeInTheDocument();
jest.useRealTimers();
});
it('should handle timeout correctly when timeout = numeric value', async () => {
jest.useFakeTimers();
render(<UserFeedback onSubmit={jest.fn} quickResponses={MOCK_RESPONSES} timeout={300} />);
act(() => {
jest.advanceTimersByTime(299);
});
expect(screen.getByText('Why did you choose this rating?')).toBeVisible();
act(() => {
jest.advanceTimersByTime(1);
});
expect(screen.queryByText('Why did you choose this rating?')).not.toBeInTheDocument();
jest.useRealTimers();
});
it('does not get removed on timeout if the user is focused on the card', async () => {
const user = userEvent.setup({
advanceTimers: (delay) => jest.advanceTimersByTime(delay)
});
jest.useFakeTimers();
render(<UserFeedback onSubmit={jest.fn} quickResponses={MOCK_RESPONSES} timeout data-testid="card" />);
expect(screen.getByText('Why did you choose this rating?')).toBeTruthy();
await user.click(screen.getByTestId('card'));
act(() => {
jest.advanceTimersByTime(8000);
});
expect(screen.getByText('Why did you choose this rating?')).toBeTruthy();
jest.useRealTimers();
});
it('does not remove the card on timeout if the user is hovered over it', async () => {
const user = userEvent.setup({
advanceTimers: (delay) => jest.advanceTimersByTime(delay)
});
jest.useFakeTimers();
render(<UserFeedback onSubmit={jest.fn} quickResponses={MOCK_RESPONSES} timeout data-testid="card" />);
const card = screen.getByTestId('card');
await user.hover(card);
act(() => {
jest.advanceTimersByTime(8000);
});
expect(card).toBeVisible();
jest.useRealTimers();
});
it('removes the card after the user removes focus from the card and 3000ms have passed', async () => {
const user = userEvent.setup({
advanceTimers: (delay) => jest.advanceTimersByTime(delay)
});
jest.useFakeTimers();
render(
<div>
<input />
<UserFeedback onSubmit={jest.fn} quickResponses={MOCK_RESPONSES} timeout data-testid="card" />
</div>
);
const card = screen.getByTestId('card');
await user.click(card);
act(() => {
jest.advanceTimersByTime(8000);
});
await user.click(screen.getByRole('textbox'));
act(() => {
jest.advanceTimersByTime(3000);
});
expect(screen.queryByText('Why did you choose this rating?')).not.toBeInTheDocument();
jest.useRealTimers();
});

it('removes the card after the user removes hover from the card and 3000ms have passed', async () => {
const user = userEvent.setup({
advanceTimers: (delay) => jest.advanceTimersByTime(delay)
});
jest.useFakeTimers();
render(
<div>
<input />
<UserFeedback onSubmit={jest.fn} quickResponses={MOCK_RESPONSES} timeout data-testid="card" />
</div>
);
const card = screen.getByTestId('card');
await user.hover(card);
act(() => {
jest.advanceTimersByTime(8000);
});
await user.hover(screen.getByRole('textbox'));
act(() => {
jest.advanceTimersByTime(3000);
});
expect(screen.queryByText('Why did you choose this rating?')).not.toBeInTheDocument();
jest.useRealTimers();
});

it('removes the card after the user removes hover from the card and timeoutAnimation time has passed', async () => {
const user = userEvent.setup({
advanceTimers: (delay) => jest.advanceTimersByTime(delay)
});
jest.useFakeTimers();
render(
<div>
<input />
<UserFeedback
onSubmit={jest.fn}
quickResponses={MOCK_RESPONSES}
timeout
data-testid="card"
timeoutAnimation={1000}
/>
</div>
);
const card = screen.getByTestId('card');
await user.hover(card);
act(() => {
jest.advanceTimersByTime(8000);
});
await user.hover(screen.getByRole('textbox'));
act(() => {
jest.advanceTimersByTime(1000);
});
expect(screen.queryByText('Why did you choose this rating?')).not.toBeInTheDocument();
jest.useRealTimers();
});
it('does not call the onTimeout callback before the timeout period has expired', () => {
const spy = jest.fn();
jest.useFakeTimers();
render(
<UserFeedback onSubmit={jest.fn} quickResponses={MOCK_RESPONSES} timeout data-testid="card" onTimeout={spy} />
);
act(() => {
jest.advanceTimersByTime(7999);
});
expect(spy).not.toHaveBeenCalled();
jest.useRealTimers();
});
it('calls the onTimeout callback after the timeout period has expired', () => {
jest.useFakeTimers();
const spy = jest.fn();
render(
<UserFeedback onSubmit={jest.fn} quickResponses={MOCK_RESPONSES} timeout data-testid="card" onTimeout={spy} />
);
act(() => {
jest.advanceTimersByTime(8000);
});
expect(spy).toHaveBeenCalledTimes(1);
jest.useRealTimers();
});

it('renders without aria-live and aria-atomic attributes by default', () => {
render(<UserFeedback onSubmit={jest.fn} quickResponses={MOCK_RESPONSES} timeout data-testid="card" />);
const card = screen.getByTestId('card').parentElement;
expect(card).not.toHaveAttribute('aria-live');
expect(card).not.toHaveAttribute('aria-atomic');
});

it('has an aria-live value of polite and aria-atomic value of false when isLiveRegion = true', () => {
render(<UserFeedback onSubmit={jest.fn} quickResponses={MOCK_RESPONSES} timeout data-testid="card" isLiveRegion />);
const card = screen.getByTestId('card').parentElement;
expect(card).toHaveAttribute('aria-live', 'polite');
expect(card).toHaveAttribute('aria-atomic', 'false');
});
it('calls onMouseEnter appropriately', async () => {
const spy = jest.fn();
const user = userEvent.setup();
render(
<div>
<input />
<UserFeedback onSubmit={jest.fn} quickResponses={MOCK_RESPONSES} data-testid="card" onMouseEnter={spy} />
</div>
);
const card = screen.getByTestId('card');
expect(spy).toHaveBeenCalledTimes(0);
await user.hover(card);
expect(spy).toHaveBeenCalledTimes(1);
});
it('calls onMouseExit appropriately', async () => {
const spy = jest.fn();
const user = userEvent.setup();
render(
<div>
<input />
<UserFeedback onSubmit={jest.fn} quickResponses={MOCK_RESPONSES} data-testid="card" onMouseLeave={spy} />
</div>
);
const card = screen.getByTestId('card');
expect(spy).toHaveBeenCalledTimes(0);
await user.hover(card);
await user.hover(screen.getByRole('textbox'));
expect(spy).toHaveBeenCalledTimes(1);
});
it('should handle submit correctly when nothing is selected', async () => {
const spy = jest.fn();
render(<UserFeedback onSubmit={spy} quickResponses={MOCK_RESPONSES} />);
Expand Down
97 changes: 1 addition & 96 deletions packages/module/src/Message/UserFeedback/UserFeedback.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,26 +46,6 @@ export interface UserFeedbackProps extends Omit<CardProps, 'onSubmit'>, OUIAProp
submitWord?: string;
/** Label for English word "optional" */
optionalWord?: string;
/** Function to be executed on timeout. Relevant when the timeout prop is set. */
onTimeout?: () => void;
/** If set to true, the timeout is 8000 milliseconds. If a number is provided, card will
* be dismissed after that amount of time in milliseconds.
*/
timeout?: number | boolean;
/** If the user hovers over the card and `timeout` expires, this is how long to wait
* before finally dismissing the alert.
*/
timeoutAnimation?: number;
/** Callback for when mouse hovers over card */
onMouseEnter?: (e: React.MouseEvent<HTMLDivElement>) => void;
/** Callback for when mouse stops hovering over card */
onMouseLeave?: (e: React.MouseEvent<HTMLDivElement>) => void;
/** Value to overwrite the randomly generated data-ouia-component-id.*/
ouiaId?: number | string;
/** Set the value of data-ouia-safe. Only set to true when the component is in a static state, i.e. no animations are occurring. At all other times, this value must be false. */
ouiaSafe?: boolean;
/** Flag to indicate if the card is in a live region. */
isLiveRegion?: boolean;
/** Uniquely identifies the card. */
id?: string;
/** The heading level to use, default is h1 */
Expand All @@ -88,99 +68,24 @@ const UserFeedback: React.FunctionComponent<UserFeedbackProps> = ({
onClose,
closeButtonAriaLabel = 'Close',
optionalWord = 'optional',
timeout = false,
timeoutAnimation = 3000,
onTimeout,
onMouseEnter,
onMouseLeave,
ouiaId,
ouiaSafe,
isLiveRegion = false,
id,
headingLevel: HeadingLevel = 'h1',
focusOnLoad = true,
...props
}: UserFeedbackProps) => {
const [selectedResponse, setSelectedResponse] = React.useState<string>();
const [value, setValue] = React.useState('');
const [timedOut, setTimedOut] = React.useState(false);
const [timedOutAnimation, setTimedOutAnimation] = React.useState(true);
const [isMouseOver, setIsMouseOver] = React.useState<boolean | undefined>();
const [containsFocus, setContainsFocus] = React.useState<boolean | undefined>();
const dismissed = timedOut && timedOutAnimation && !isMouseOver && !containsFocus;
const divRef = React.useRef<HTMLDivElement>(null);
const ouiaProps = useOUIAProps('User Feedback', ouiaId, ouiaSafe);

React.useEffect(() => {
if (focusOnLoad) {
divRef.current?.focus();
}
}, []);

React.useEffect(() => {
const calculatedTimeout = timeout === true ? 8000 : Number(timeout);
if (calculatedTimeout > 0) {
const timer = setTimeout(() => setTimedOut(true), calculatedTimeout);
return () => clearTimeout(timer);
}
}, [timeout]);

React.useEffect(() => {
const onDocumentFocus = () => {
if (divRef.current) {
if (divRef.current.contains(document.activeElement)) {
setContainsFocus(true);
setTimedOutAnimation(false);
} else if (containsFocus) {
setContainsFocus(false);
}
}
};
document.addEventListener('focus', onDocumentFocus, true);
return () => document.removeEventListener('focus', onDocumentFocus, true);
}, [containsFocus]);

React.useEffect(() => {
if (containsFocus === false || isMouseOver === false) {
const timer = setTimeout(() => setTimedOutAnimation(true), timeoutAnimation);
return () => clearTimeout(timer);
}
}, [containsFocus, isMouseOver, timeoutAnimation]);

React.useEffect(() => {
dismissed && onTimeout && onTimeout();
}, [dismissed, onTimeout]);

if (dismissed) {
return null;
}

const myOnMouseEnter = (ev: React.MouseEvent<HTMLDivElement>) => {
setIsMouseOver(true);
setTimedOutAnimation(false);
onMouseEnter && onMouseEnter(ev);
};

const myOnMouseLeave = (ev: React.MouseEvent<HTMLDivElement>) => {
setIsMouseOver(false);
onMouseLeave && onMouseLeave(ev);
};

return (
/* card does not have ref forwarding; hence wrapper div */
<div
ref={divRef}
onMouseEnter={myOnMouseEnter}
onMouseLeave={myOnMouseLeave}
{...(isLiveRegion && {
'aria-live': 'polite',
'aria-atomic': 'false'
})}
id={id}
tabIndex={0}
aria-label={title}
{...ouiaProps}
>
<div ref={divRef} id={id} tabIndex={0} aria-label={title}>
<Card className={`pf-chatbot__feedback-card ${className ? className : ''}`} {...props}>
<CardHeader
actions={
Expand Down

0 comments on commit ddfffae

Please sign in to comment.