Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
26 changes: 17 additions & 9 deletions packages/code-studio/src/main/AppMainContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
InfoModal,
LoadingSpinner,
BasicModal,
DebouncedModal,
} from '@deephaven/components';
import {
IrisGridModel,
Expand Down Expand Up @@ -550,14 +551,17 @@ export class AppMainContainer extends Component<
}

handleDisconnect() {
log.info('Disconnected from server');
this.setState({ isDisconnected: true });
}

handleReconnect() {
log.info('Reconnected to server');
this.setState({ isDisconnected: false });
}

handleReconnectAuthFailed() {
log.warn('Reconnect authentication failed');
this.setState({ isAuthFailed: true });
}

Expand Down Expand Up @@ -942,16 +946,20 @@ export class AppMainContainer extends Component<
style={{ display: 'none' }}
onChange={this.handleImportLayoutFiles}
/>
<InfoModal
<DebouncedModal
isOpen={isDisconnected && !isAuthFailed}
icon={vsDebugDisconnect}
title={
<>
<LoadingSpinner /> Attempting to reconnect...
</>
}
subtitle="Please check your network connection."
/>
debounceMs={5000}
>
<InfoModal
icon={vsDebugDisconnect}
title={
<>
<LoadingSpinner /> Attempting to reconnect...
</>
}
subtitle="Please check your network connection."
/>
</DebouncedModal>
<BasicModal
confirmButtonText="Refresh"
onConfirm={AppMainContainer.handleRefresh}
Expand Down
68 changes: 68 additions & 0 deletions packages/components/src/modal/DebouncedModal.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import React from 'react';
import { DEFAULT_DEBOUNCE_MS } from '@deephaven/react-hooks';
import { act, render, screen } from '@testing-library/react';
import DebouncedModal from './DebouncedModal';
import Modal from './Modal';

const mockChildText = 'Mock Child';
const children = (
<Modal>
<div>{mockChildText}</div>
</Modal>
);

beforeAll(() => {
jest.useFakeTimers();
});

afterAll(() => {
jest.useRealTimers();
});

describe('display modal after debounce', () => {
it('should render the modal after the debounce time has passed', () => {
const { rerender } = render(
<DebouncedModal isOpen={false}>{children}</DebouncedModal>
);
expect(screen.queryByTestId('debounced-modal-backdrop')).toBeNull();
expect(screen.queryByText(mockChildText)).toBeNull();

act(() => {
rerender(<DebouncedModal isOpen>{children}</DebouncedModal>);
});
expect(screen.queryByTestId('debounced-modal-backdrop')).not.toBeNull();
expect(screen.queryByText(mockChildText)).toBeNull();
Comment thread
mofojed marked this conversation as resolved.
Outdated

act(() => {
jest.advanceTimersByTime(DEFAULT_DEBOUNCE_MS);
});
expect(screen.queryByTestId('debounced-modal-backdrop')).not.toBeNull();
expect(screen.queryByText(mockChildText)).not.toBeNull();
});

it('should not block interaction if set to false', () => {
const { rerender } = render(
<DebouncedModal isOpen={false} blockInteraction={false}>
{children}
</DebouncedModal>
);
expect(screen.queryByTestId('debounced-modal-backdrop')).toBeNull();
expect(screen.queryByText(mockChildText)).toBeNull();

act(() => {
rerender(
<DebouncedModal isOpen blockInteraction={false}>
{children}
</DebouncedModal>
);
});
expect(screen.queryByTestId('debounced-modal-backdrop')).toBeNull();
expect(screen.queryByText(mockChildText)).toBeNull();

act(() => {
jest.advanceTimersByTime(DEFAULT_DEBOUNCE_MS + 5);
});
expect(screen.queryByTestId('debounced-modal-backdrop')).toBeNull();
expect(screen.queryByText(mockChildText)).not.toBeNull();
});
});
47 changes: 47 additions & 0 deletions packages/components/src/modal/DebouncedModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import React from 'react';
import { DEFAULT_DEBOUNCE_MS, useDebouncedValue } from '@deephaven/react-hooks';

export type DebouncedModalProps = {
/** Whether to block interaction immediately */
blockInteraction?: boolean;

/** Children to render after the alloted debounce time */
children: React.ReactElement;

/** Time to debounce */
debounceMs?: number;

/**
* Will render the `children` `debounceMs` after `isOpen` is set to `true.
* Will stop rendering immediately after `isOpen` is set to `false`.
*/
isOpen?: boolean;
};

/**
* Display a modal after a debounce time. Blocks the screen from interaction immediately,
* but then waits the set debounce time before rendering the modal.
*/
function DebouncedModal({
blockInteraction = true,
children,
debounceMs = DEFAULT_DEBOUNCE_MS,
isOpen = false,
}: DebouncedModalProps) {
const debouncedIsOpen = useDebouncedValue(isOpen, debounceMs);

return (
<>
{blockInteraction && isOpen && (
<div
className="modal-backdrop"
style={{ backgroundColor: 'transparent' }}
data-testid="debounced-modal-backdrop"
/>
)}
{React.cloneElement(children, { isOpen: isOpen && debouncedIsOpen })}
</>
);
}

export default DebouncedModal;
17 changes: 15 additions & 2 deletions packages/components/src/modal/InfoModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,30 @@ import ModalBody from './ModalBody';
import './InfoModal.scss';

type InfoModalProps = {
/** Class name to give the info modal */
className?: string;

/** Icon to display in the modal */
icon?: IconProp;

/** Title to display in the modal */
title: React.ReactNode;

/** Subtitle/detail to display in the modal */
subtitle?: React.ReactNode;
isOpen: boolean;

/** Whether the modal is open/visible or not. */
isOpen?: boolean;
};

/**
* A modal that displays a message with an icon. Can be used for informational messages, warnings, or errors.
* Does not have any buttons and cannot be dismissed.
*/
function InfoModal({
className,
icon,
isOpen,
isOpen = false,
subtitle,
title,
}: InfoModalProps): JSX.Element {
Expand Down
1 change: 1 addition & 0 deletions packages/components/src/modal/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { default as DebouncedModal } from './DebouncedModal';
export { default as InfoModal } from './InfoModal';
export { default as Modal } from './Modal';
export { default as ModalBody } from './ModalBody';
Expand Down
1 change: 1 addition & 0 deletions packages/react-hooks/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ export type {
UsePromiseFactoryOptions,
UsePromiseFactoryResult,
} from './usePromiseFactory';
export * from './useDebouncedValue';
65 changes: 65 additions & 0 deletions packages/react-hooks/src/useDebouncedValue.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { act, renderHook } from '@testing-library/react-hooks';
import useDebouncedValue, { DEFAULT_DEBOUNCE_MS } from './useDebouncedValue';

beforeEach(() => {
jest.useFakeTimers();
});

afterAll(() => {
jest.useRealTimers();
});

it('should return the initial value', () => {
const value = 'mock value';
const { result } = renderHook(() => useDebouncedValue(value));
expect(result.current).toBe(value);
});

it('should return the initial value after the debounce time has elapsed', () => {
const value = 'mock value';
const { result, rerender } = renderHook(() => useDebouncedValue(value));
expect(result.current).toBe(value);
rerender();
act(() => {
jest.advanceTimersByTime(DEFAULT_DEBOUNCE_MS);
});
expect(result.current).toBe(value);
Comment thread
mofojed marked this conversation as resolved.
});

it('should return the updated value after the debounce time has elapsed', () => {
const value = 'mock value';
const newValue = 'mock new value';
const { result, rerender } = renderHook((val = value) =>
useDebouncedValue(val)
);
expect(result.current).toBe(value);
rerender(newValue);
act(() => {
jest.advanceTimersByTime(DEFAULT_DEBOUNCE_MS);
});
expect(result.current).toBe(newValue);
});

it('should not return an intermediate value if the debounce time has not elapsed', () => {
const value = 'mock value';
const intermediateValue = 'mock intermediate value';
const newValue = 'mock new value';
const { result, rerender } = renderHook((val = value) =>
useDebouncedValue(val)
);
expect(result.current).toBe(value);
rerender(intermediateValue);
act(() => {
jest.advanceTimersByTime(DEFAULT_DEBOUNCE_MS - 5);
});
expect(result.current).toBe(value);
rerender(newValue);
act(() => {
jest.advanceTimersByTime(DEFAULT_DEBOUNCE_MS - 5);
});
expect(result.current).toBe(value);
act(() => {
jest.advanceTimersByTime(DEFAULT_DEBOUNCE_MS);
});
expect(result.current).toBe(newValue);
});
28 changes: 28 additions & 0 deletions packages/react-hooks/src/useDebouncedValue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { useEffect, useState } from 'react';

export const DEFAULT_DEBOUNCE_MS = 250;

/**
* Debounces a value. Returns the value after the debounce time has elapsed.
Comment thread
mofojed marked this conversation as resolved.
Outdated
* @param value Value to debounce
* @param debounceMs Amount of time to debounce
* @returns The debounced value
*/
export function useDebouncedValue<T>(
value: T,
debounceMs = DEFAULT_DEBOUNCE_MS
Comment thread
mofojed marked this conversation as resolved.
Outdated
) {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const timeoutId = setTimeout(() => {
setDebouncedValue(value);
}, debounceMs);
return () => {
clearTimeout(timeoutId);
};
}, [value, debounceMs]);
Comment thread
mofojed marked this conversation as resolved.

return debouncedValue;
}

export default useDebouncedValue;