Skip to content
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
8 changes: 8 additions & 0 deletions .changeset/large-lands-worry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@lg-chat/input-bar': minor
---

[LG-5600](https://jira.mongodb.org/browse/LG-5600)
Fix send button disabled logic: the send button now remains enabled during loading state (even with empty message body) to allow users to stop the request. The `disabled` and `disableSend` props still take precedence.

Add `onClickStopButton` prop to handle stop actions during loading state. When triggered, the previous message body is restored to the input field (similar to error state behavior).
81 changes: 43 additions & 38 deletions chat/input-bar/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,54 +22,59 @@ npm install @lg-chat/input-bar

## Example

### Compact

```tsx
import { InputBar } from '@lg-chat/input-bar';
import {
LeafyGreenChatProvider,
Variant,
} from '@lg-chat/leafygreen-chat-provider';

return (
<LeafyGreenChatProvider variant={Variant.Compact}>
<InputBar />
</LeafyGreenChatProvider>
);
```

### Spacious

```tsx
import { InputBar } from '@lg-chat/input-bar';
import { useState } from 'react';
import { InputBar, State } from '@lg-chat/input-bar';
import {
LeafyGreenChatProvider,
Variant,
} from '@lg-chat/leafygreen-chat-provider';

return (
<LeafyGreenChatProvider variant={Variant.Spacious}>
<InputBar />
</LeafyGreenChatProvider>
);
const Example = () => {
const [state, setState] = useState<State>(State.Unset);

const handleMessageSend = (messageBody: string) => {
console.log('Sending:', messageBody);
setState(State.Loading);
// Simulate API call
};

const handleClickStopButton = () => {
console.log('Stopping request');
setState(State.Unset);
// Cancel your API request here
};

return (
<LeafyGreenChatProvider variant={Variant.Compact}>
<InputBar
onMessageSend={handleMessageSend}
onClickStopButton={handleClickStopButton}
state={state}
errorMessage="Failed to send message. Please try again."
/>
</LeafyGreenChatProvider>
);
};
```

## Properties

| Prop | Type | Description | Default |
| ----------------------------- | ---------------------------------------------- | ----------------------------------------------------------- | ------- |
| `badgeText` | `string` | Determines the text inside the rendered Badge | |
| `darkMode` | `boolean` | Determines if the component will render in dark mode | `false` |
| `disabled` | `boolean` | Determines whether the user can interact with the InputBar | `false` |
| `disableSend` | `boolean` | When defined as `true`, disables the send action and button | |
| `errorMessage` | `ReactNode` | Custom error message to display when `state='error'` | |
| `onMessageSend` | `(messageBody: string, e?: FormEvent) => void` | Submit event handler. | |
| `shouldRenderGradient` | `boolean` | Toggles the gradient animation around the input | `true` |
| `shouldRenderHotkeyIndicator` | `boolean` | Toggles the hotkey indicator on the right side of the input | `false` |
| `textareaProps` | `TextareaAutosizeProps` | Props passed to the TextareaAutosize component. | |
| `textareaRef` | `RefObject<HTMLTextAreaElement>` | Ref object to access the textarea element directly | |
| `state` | `'unset' \| 'error' \| 'loading'` | The current state of the InputBar. | |
| `...` | `HTMLElementProps<'form'>` | Props spread on the root element | |
| Prop | Type | Description | Default |
| ----------------------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| `badgeText` | `string` | Determines the text inside the rendered Badge | |
| `darkMode` | `boolean` | Determines if the component will render in dark mode | `false` |
| `disabled` | `boolean` | Determines whether the user can interact with the InputBar | `false` |
| `disableSend` | `boolean` | When defined as `true`, disables the send action and button | |
| `errorMessage` | `ReactNode` | Custom error message to display when `state='error'` | |
| `onClickStopButton` | `() => void` | Callback fired when the stop button is clicked during a loading state. Restores the previous message body. Only applies in compact variant. | |
| `onMessageSend` | `(messageBody: string, e?: FormEvent) => void` | Callback fired when the user sends a message. | |
| `shouldRenderGradient` | `boolean` | Toggles the gradient animation around the input | `true` |
| `shouldRenderHotkeyIndicator` | `boolean` | Toggles the hotkey indicator on the right side of the input | `false` |
| `textareaProps` | `TextareaAutosizeProps` | Props passed to the TextareaAutosize component. | |
| `textareaRef` | `RefObject<HTMLTextAreaElement>` | Ref object to access the textarea element directly | |
| `state` | `'unset' \| 'error' \| 'loading'` | The current state of the InputBar. | |
| `...` | `HTMLElementProps<'form'>` | Props spread on the root element | |

## TextareaAutosizeProps

Expand Down
119 changes: 119 additions & 0 deletions chat/input-bar/src/InputBar/InputBar.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -384,4 +384,123 @@ describe('packages/input-bar', () => {
);
});
});

describe('onClickStopButton', () => {
test('renders stop button during loading state', () => {
renderInputBar({ state: State.Loading });

const stopButton = screen.getByRole('button', { name: 'Stop message' });
expect(stopButton).toBeInTheDocument();
expect(stopButton).not.toHaveAttribute('aria-disabled', 'true');
});

test('does not render send button during loading state', () => {
renderInputBar({ state: State.Loading });

const sendButton = screen.queryByRole('button', { name: 'Send message' });
expect(sendButton).not.toBeInTheDocument();
});

test('calls onClickStopButton when button is clicked during loading state', () => {
const onClickStopButton = jest.fn();
renderInputBar({ state: State.Loading, onClickStopButton });

const stopButton = screen.getByRole('button', { name: 'Stop message' });
userEvent.click(stopButton);

expect(onClickStopButton).toHaveBeenCalledTimes(1);
});

test('restores previous message when stop is clicked in uncontrolled mode', () => {
const onClickStopButton = jest.fn();
const onMessageSend = jest.fn();
const { rerender } = renderInputBar({ onClickStopButton, onMessageSend });

const textarea = screen.getByRole('textbox');
const sendButton = screen.getByRole('button', { name: 'Send message' });

userEvent.type(textarea, TEST_INPUT_TEXT);
expect(textarea).toHaveValue(TEST_INPUT_TEXT);

userEvent.click(sendButton);
expect(onMessageSend).toHaveBeenCalledTimes(1);
expect(textarea).toHaveValue('');

rerender(
<LeafyGreenChatProvider variant={Variant.Compact}>
<InputBar
state={State.Loading}
onClickStopButton={onClickStopButton}
onMessageSend={onMessageSend}
/>
</LeafyGreenChatProvider>,
);

const stopButton = screen.getByRole('button', { name: 'Stop message' });

userEvent.click(stopButton);

expect(onClickStopButton).toHaveBeenCalledTimes(1);
expect(textarea).toHaveValue(TEST_INPUT_TEXT);
});

test('does not call onMessageSend when stopping during loading state', () => {
const onMessageSend = jest.fn();
const onClickStopButton = jest.fn();

renderInputBar({
state: State.Loading,
onMessageSend,
onClickStopButton,
textareaProps: { value: TEST_INPUT_TEXT },
});

const stopButton = screen.getByRole('button', { name: 'Stop message' });
userEvent.click(stopButton);

expect(onClickStopButton).toHaveBeenCalledTimes(1);
expect(onMessageSend).not.toHaveBeenCalled();
});

test('disabled prop takes precedence over loading state', () => {
const onClickStopButton = jest.fn();
renderInputBar({
state: State.Loading,
disabled: true,
onClickStopButton,
});

const stopButton = screen.getByRole('button', { name: 'Stop message' });
expect(stopButton).toHaveAttribute('aria-disabled', 'true');
});

test('disableSend prop takes precedence over loading state', () => {
const onClickStopButton = jest.fn();
renderInputBar({
state: State.Loading,
disableSend: true,
onClickStopButton,
});

const stopButton = screen.getByRole('button', { name: 'Stop message' });
expect(stopButton).toHaveAttribute('aria-disabled', 'true');
});

test('does not call onClickStopButton if not in loading state', () => {
const onClickStopButton = jest.fn();
const onMessageSend = jest.fn();

renderInputBar({
onClickStopButton,
onMessageSend,
textareaProps: { value: TEST_INPUT_TEXT },
});

const sendButton = screen.getByRole('button', { name: 'Send message' });
userEvent.click(sendButton);

expect(onClickStopButton).not.toHaveBeenCalled();
expect(onMessageSend).toHaveBeenCalledTimes(1);
});
});
});
52 changes: 38 additions & 14 deletions chat/input-bar/src/InputBar/InputBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import { breakpoints } from '@leafygreen-ui/tokens';
import { DisclaimerText } from '../DisclaimerText';
import { InputBarFeedback } from '../InputBarFeedback';
import { InputBarSendButton } from '../InputBarSendButton';
import { InputBarStopButton } from '../InputBarStopButton';
import { State } from '../shared.types';
import { setReactTextAreaValue } from '../utils/setReactTextAreaValue';

Expand Down Expand Up @@ -76,6 +77,7 @@ export const InputBar = forwardRef<HTMLFormElement, InputBarProps>(
dropdownFooterSlot,
dropdownProps,
errorMessage,
onClickStopButton,
onMessageSend,
onSubmit,
shouldRenderGradient: shouldRenderGradientProp = true,
Expand Down Expand Up @@ -142,8 +144,10 @@ export const InputBar = forwardRef<HTMLFormElement, InputBarProps>(
const [shouldRenderButtonText, setShouldRenderButtonText] =
useState<boolean>(false);

const isLoading = state === State.Loading;
const isSendButtonDisabled =
disableSend || disabled || messageBody?.trim() === '';
disabled || disableSend || messageBody?.trim() === '';
const isStopButtonDisabled = disabled || !!disableSend;
const shouldRenderGradient =
!isCompact && shouldRenderGradientProp && isFocused && !disabled;
const showHotkeyIndicator =
Expand Down Expand Up @@ -377,6 +381,13 @@ export const InputBar = forwardRef<HTMLFormElement, InputBarProps>(
onSubmit?.(e);
};

const handleStop = () => {
if (onClickStopButton) {
onClickStopButton();
}
restorePreviousMessage();
};

const handleFocus: FocusEventHandler<HTMLTextAreaElement> = _ => {
setIsFocused(true);
openMenu();
Expand All @@ -390,6 +401,18 @@ export const InputBar = forwardRef<HTMLFormElement, InputBarProps>(
closeMenu();
};

/**
* Helper function to restore the previous message body.
* Used when stopping during loading or when an error occurs.
*/
const restorePreviousMessage = useCallback(() => {
if (!isControlled) {
updateValue(prevMessageBody, internalTextareaRef);
setPrevMessageBody('');
}
internalTextareaRef.current?.focus();
}, [isControlled, prevMessageBody, updateValue]);

useAutoScroll(highlightedElementRef, menuRef, 12);
useBackdropClick(handleBackdropClick, [focusContainerRef, menuRef], {
enabled: isOpen && withTypeAhead,
Expand Down Expand Up @@ -418,13 +441,8 @@ export const InputBar = forwardRef<HTMLFormElement, InputBarProps>(
return;
}

if (!isControlled) {
updateValue(prevMessageBody, internalTextareaRef);
setPrevMessageBody('');
}

internalTextareaRef.current?.focus();
}, [state, prevState, isControlled, prevMessageBody, updateValue]);
restorePreviousMessage();
}, [state, prevState, restorePreviousMessage]);

return (
<LeafyGreenProvider darkMode={darkMode}>
Expand Down Expand Up @@ -490,12 +508,18 @@ export const InputBar = forwardRef<HTMLFormElement, InputBarProps>(
/
</div>
)}
<InputBarSendButton
disabled={isSendButtonDisabled}
isCompact={isCompact}
shouldRenderButtonText={shouldRenderButtonText}
state={state}
/>
{isLoading && isCompact ? (
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: not a huge deal, but maybe a more explicit shouldRenderStopButton?
Or even a utility component with a switch if this ever gets more complex

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Going to leave as-is. I don't foresee it getting more complicated than this, and it will be further simplified with this ticket to remove spacious variant code

<InputBarStopButton
disabled={isStopButtonDisabled}
onClick={handleStop}
/>
) : (
<InputBarSendButton
disabled={isSendButtonDisabled}
isCompact={isCompact}
shouldRenderButtonText={shouldRenderButtonText}
/>
)}
</div>
</div>
</div>
Expand Down
8 changes: 7 additions & 1 deletion chat/input-bar/src/InputBar/InputBar.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,13 @@ export type InputBarProps = React.ComponentPropsWithoutRef<'form'> &
>;

/**
* Submit event handler.
* Callback fired when the stop button is clicked during a loading state.
* When triggered, the message input will be restored to the previous message body.
*/
onClickStopButton?: () => void;

/**
* Callback fired when the user sends a message.
*/
onMessageSend?: (
messageBody: string,
Expand Down
Loading
Loading