Skip to content
Merged
2 changes: 1 addition & 1 deletion packages/compass-components/src/hooks/use-theme.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const ThemeProvider = ({
children,
theme,
}: {
children: React.ReactChildren;
children: React.ReactNode;
theme: ThemeState;
}): React.ReactElement => {
return (
Expand Down
107 changes: 107 additions & 0 deletions packages/compass-components/src/hooks/use-toast.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { expect } from 'chai';
import React from 'react';
import sinon from 'sinon';

import { ToastArea, ToastVariant, useToast } from '..';

const OpenToastButton = ({
namespace,
id,
title,
variant,
timeout,
body,
}: {
namespace: string;
variant: ToastVariant;
id: string;
title: string;
timeout?: number;
body?: string;
}) => {
const { openToast } = useToast(namespace);
return (
<button onClick={() => openToast(id, { title, variant, body, timeout })}>
Open Toast
</button>
);
};

const CloseToastButton = ({
namespace,
id,
}: {
namespace: string;
id: string;
}) => {
const { closeToast } = useToast(namespace);
return <button onClick={() => closeToast(id)}>Close Toast</button>;
};

describe('useToast', function () {
afterEach(cleanup);

it('opens and closes a toast', async function () {
render(
<ToastArea>
<OpenToastButton
namespace="ns-1"
id="toast-1"
title="My Toast"
body="Toast body"
variant={ToastVariant.Success}
/>
<CloseToastButton namespace="ns-1" id="toast-1" />
</ToastArea>
);

fireEvent.click(screen.getByText('Open Toast'));

await screen.findByText('My Toast');
screen.getByText('Toast body');

fireEvent.click(screen.getByText('Close Toast'));

expect(screen.queryByText('My Toast')).to.not.exist;
});

describe('with timeout', function () {
let clock;

beforeEach(function () {
clock = sinon.useFakeTimers();
});

afterEach(function () {
clock.restore();
});

it('closes a toast after timeout expires', async function () {
render(
<ToastArea>
<OpenToastButton
namespace="ns-1"
id="toast-1"
title="My Toast"
body="Toast body"
timeout={5000}
variant={ToastVariant.Success}
/>
</ToastArea>
);

fireEvent.click(screen.getByText('Open Toast'));

await screen.findByText('My Toast');

clock.tick(2000);

await screen.findByText('My Toast');

clock.tick(3001);

expect(screen.queryByText('My Toast')).to.not.exist;
});
});
});
164 changes: 164 additions & 0 deletions packages/compass-components/src/hooks/use-toast.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import React, {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from 'react';
import type { ToastVariant } from '..';
import { css } from '..';
import { Toast } from '..';

type ToastProperties = {
title?: React.ReactNode;
body: React.ReactNode;
variant: ToastVariant;
progress?: number;
timeout?: number;
};

interface ToastActions {
openToast: (id: string, toastProperties: ToastProperties) => void;
closeToast: (id: string) => void;
}

const ToastContext = createContext<ToastActions>({
openToast: () => {
//
},
closeToast: () => {
//
},
});

const toastStyles = css({
button: {
position: 'absolute',
},
});

/**
* @example
*
* ```
* const MyButton = () => {
* const { openToast } = useToast('namespace');
* return <button onClick={() => openToast(
* 'myToast1', {title: 'This is a notification'})} />
* };
*
* <ToastArea><MyButton/><ToastArea>
* ```
*
* @returns
*/
export const ToastArea: React.FunctionComponent = ({ children }) => {
const [toasts, setToasts] = useState<Record<string, ToastProperties>>({});
const timeouts = useRef<Record<string, ReturnType<typeof setTimeout>>>({});

useEffect(() => {
return () => {
Object.values(timeouts).forEach(clearTimeout);
};
}, [timeouts]);

const clearTimeoutRef = useCallback(
(id) => {
clearTimeout(timeouts.current[id]);
delete timeouts.current[id];
},
[timeouts]
);

const setTimeoutRef = useCallback(
(id: string, callback: () => void, timeout: number) => {
clearTimeoutRef(id);
timeouts.current[id] = setTimeout(callback, timeout);
},
[timeouts, clearTimeoutRef]
);

const closeToast = useCallback(
(toastId: string): void => {
clearTimeoutRef(toastId);

setToasts((prevToasts) => {
const newToasts = { ...prevToasts };
delete newToasts[toastId];
return newToasts;
});
},
[setToasts, clearTimeoutRef]
);

const openToast = useCallback(
(toastId: string, toastProperties: ToastProperties): void => {
clearTimeoutRef(toastId);

if (toastProperties.timeout) {
setTimeoutRef(
toastId,
() => {
closeToast(toastId);
},
toastProperties.timeout
);
}

setToasts((prevToasts) => ({
...prevToasts,
[toastId]: {
...toastProperties,
},
}));
},
[setToasts, setTimeoutRef, clearTimeoutRef, closeToast]
);

return (
<ToastContext.Provider value={{ closeToast, openToast }}>
<>{children}</>
<>
{Object.entries(toasts).map(
([id, { title, body, variant, progress }]) => (
<Toast
className={toastStyles}
key={id}
title={title}
body={body}
variant={variant}
progress={progress}
open={true}
close={() => closeToast(id)}
/>
)
)}
</>
</ToastContext.Provider>
);
};

export function useToast(namespace: string): ToastActions {
const { openToast: openGlobalToast, closeToast: closeGlobalToast } =
useContext(ToastContext);

const openToast = useCallback(
(toastId: string, toastProperties: ToastProperties): void => {
openGlobalToast(`${namespace}--${toastId}`, toastProperties);
},
[namespace, openGlobalToast]
);

const closeToast = useCallback(
(toastId: string): void => {
closeGlobalToast(`${namespace}--${toastId}`);
},
[namespace, closeGlobalToast]
);

return {
openToast,
closeToast,
};
}
3 changes: 3 additions & 0 deletions packages/compass-components/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ export {
default as Toast,
Variant as ToastVariant,
} from '@leafygreen-ui/toast';

export { useToast, ToastArea } from './hooks/use-toast';

export { Toggle } from './components/toggle';

export { breakpoints, spacing } from '@leafygreen-ui/tokens';
Expand Down
27 changes: 16 additions & 11 deletions packages/compass-home/src/components/home.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,29 @@
import {
css,
Theme,
ThemeProvider,
ToastArea,
} from '@mongodb-js/compass-components';
import Connections from '@mongodb-js/compass-connections';
import ipc from 'hadron-ipc';
import type { ConnectionInfo, DataService } from 'mongodb-data-service';
import { getConnectionTitle } from 'mongodb-data-service';
import toNS from 'mongodb-ns';
import React, {
useCallback,
useEffect,
useReducer,
useRef,
useState,
} from 'react';
import { ThemeProvider, css } from '@mongodb-js/compass-components';
import { Theme } from '@mongodb-js/compass-components';
import { getConnectionTitle } from 'mongodb-data-service';
import type { ConnectionInfo, DataService } from 'mongodb-data-service';
import toNS from 'mongodb-ns';
import Connections from '@mongodb-js/compass-connections';

import Workspace from './workspace';
import type Namespace from '../types/namespace';
import {
AppRegistryRoles,
useAppRegistryContext,
useAppRegistryRole,
} from '../contexts/app-registry-context';
import updateTitle from '../modules/update-title';
import ipc from 'hadron-ipc';
import type Namespace from '../types/namespace';
import Workspace from './workspace';

const homeViewStyles = css({
display: 'flex',
Expand Down Expand Up @@ -309,7 +312,9 @@ function ThemedHome(

return (
<ThemeProvider theme={theme}>
<Home {...props}></Home>
<ToastArea>
<Home {...props}></Home>
</ToastArea>
</ThemeProvider>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import sinon from 'sinon';

import ConnectionMenu from './connection-menu';
import type { ConnectionInfo } from 'mongodb-data-service';
import { ToastArea } from '@mongodb-js/compass-components';

describe('ConnectionMenu Component', function () {
describe('on non-favorite item', function () {
Expand Down Expand Up @@ -68,13 +69,15 @@ describe('ConnectionMenu Component', function () {
},
};
render(
<ConnectionMenu
connectionString={'mongodb://kaleesi'}
duplicateConnection={() => true}
removeConnection={() => true}
connectionInfo={connectionInfo}
iconColor="#EAEAEA"
/>
<ToastArea>
<ConnectionMenu
connectionString={'mongodb://kaleesi'}
duplicateConnection={() => true}
removeConnection={() => true}
connectionInfo={connectionInfo}
iconColor="#EAEAEA"
/>
</ToastArea>
);
});

Expand Down Expand Up @@ -160,7 +163,7 @@ describe('ConnectionMenu Component', function () {

it('opens a toast with a success message', async function () {
await waitFor(
() => expect(screen.getByText('Success!')).to.be.visible
() => expect(screen.getByText('Success')).to.be.visible
);
await waitFor(
() => expect(screen.getByText('Copied to clipboard.')).to.be.visible
Expand Down Expand Up @@ -239,7 +242,12 @@ describe('ConnectionMenu Component', function () {
it('opens a toast with an error message', async function () {
await waitFor(() => expect(screen.getByText('Error')).to.be.visible);
await waitFor(
() => expect(screen.getByText('Test error')).to.be.visible
() =>
expect(
screen.getByText(
'An error occurred when copying to clipboard. Please try again.'
)
).to.be.visible
);
});
});
Expand Down
Loading