Skip to content

Commit

Permalink
feat(js,react): Export InboxContent component (#6531)
Browse files Browse the repository at this point in the history
  • Loading branch information
desiprisg authored Sep 24, 2024
1 parent 13317c4 commit 7225753
Show file tree
Hide file tree
Showing 33 changed files with 1,231 additions and 766 deletions.
11 changes: 6 additions & 5 deletions packages/js/src/ui/components/ExternalElementRenderer.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import { onCleanup, createEffect, ParentProps } from 'solid-js';
import { createEffect, JSX, onCleanup, splitProps } from 'solid-js';

type ExternalElementMounterProps = ParentProps<{
type ExternalElementMounterProps = JSX.HTMLAttributes<HTMLDivElement> & {
render: (el: HTMLDivElement) => () => void;
}>;
};

export const ExternalElementRenderer = ({ render, ...rest }: ExternalElementMounterProps) => {
export const ExternalElementRenderer = (props: ExternalElementMounterProps) => {
let ref: HTMLDivElement;
const [local, rest] = splitProps(props, ['render']);

createEffect(() => {
const unmount = render(ref);
const unmount = local.render(ref);

onCleanup(() => {
unmount();
Expand Down
2 changes: 1 addition & 1 deletion packages/js/src/ui/components/Inbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ export const Inbox = (props: InboxProps) => {
</Button>
)}
/>
<Popover.Content appearanceKey="inbox__popoverContent">
<Popover.Content appearanceKey="inbox__popoverContent" portal>
<InboxContent
renderNotification={props.renderNotification}
onNotificationClick={props.onNotificationClick}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ export const DefaultNotification = (props: DefaultNotificationProps) => {
'notificationDefaultActions',
`nt-transition nt-duration-100 nt-ease-out nt-gap-2 nt-flex nt-shrink-0
nt-opacity-0 group-hover:nt-opacity-100 nt-justify-center nt-items-center
nt-absolute nt-top-0 nt-right-0 nt-bg-neutral-alpha-50 nt-py-0.5 nt-rounded nt-z-50`
nt-absolute nt-top-0 nt-right-0 nt-bg-neutral-alpha-50 nt-py-0.5 nt-rounded`
)}
>
<Show when={status() !== NotificationStatus.ARCHIVED}>
Expand Down
4 changes: 3 additions & 1 deletion packages/js/src/ui/components/Renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { Inbox, InboxContent, InboxContentProps, InboxPage } from './Inbox';

export const novuComponents = {
Inbox,
// InboxContent, //enable this to also allow the whole inbox content as a component
InboxContent,
Bell,
Notifications: (props: Omit<InboxContentProps, 'hideNav' | 'initialPage'>) => (
<InboxContent {...props} hideNav={true} initialPage={InboxPage.Notifications} />
Expand Down Expand Up @@ -88,6 +88,8 @@ export const Renderer = (props: RendererProps) => {
const Component = novuComponents[novuComponent().name];

onMount(() => {
// return here if not `<Notifications /> or `<Preferences />` since we only want to override some styles for those to work properly
// due to the extra divs being introduces by the renderer/mounter
if (!['Notifications', 'Preferences'].includes(novuComponent().name)) return;

if (node instanceof HTMLElement) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,9 @@ const PopoverContentBody = (props: PopoverContentProps) => {
);
};

type PopoverContentProps = JSX.IntrinsicElements['div'] & { appearanceKey?: AppearanceKey };
type PopoverContentProps = JSX.IntrinsicElements['div'] & { appearanceKey?: AppearanceKey; portal?: boolean };
export const PopoverContent = (props: PopoverContentProps) => {
const [local, rest] = splitProps(props, ['portal']);
const { open, onClose, reference, floating } = usePopover();
const { active } = useFocusManager();

Expand Down Expand Up @@ -81,11 +82,13 @@ export const PopoverContent = (props: PopoverContentProps) => {

return (
<Show when={open()}>
<Portal>
<Root>
<PopoverContentBody {...props} />
</Root>
</Portal>
<Show when={local.portal} fallback={<PopoverContentBody {...rest} />}>
<Portal>
<Root>
<PopoverContentBody {...rest} />
</Root>
</Portal>
</Show>
</Show>
);
};
21 changes: 14 additions & 7 deletions packages/js/src/ui/components/primitives/Popover/PopoverRoot.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { autoUpdate, flip, offset, Placement, shift } from '@floating-ui/dom';
import { useFloating } from 'solid-floating-ui';
import { Accessor, createContext, createSignal, JSX, Setter, useContext } from 'solid-js';
import { Accessor, createContext, createMemo, createSignal, JSX, Setter, useContext } from 'solid-js';

type PopoverRootProps = {
open?: boolean;
Expand Down Expand Up @@ -32,7 +32,13 @@ export function PopoverRoot(props: PopoverRootProps) {

const position = useFloating(reference, floating, {
placement: props.placement || 'bottom-start',
whileElementsMounted: autoUpdate,
whileElementsMounted: (reference, floating, update) =>
autoUpdate(reference, floating, update, {
elementResize: false,
ancestorScroll: false,
animationFrame: false,
layoutShift: false,
}),
middleware: [
offset(10),
flip({
Expand All @@ -41,6 +47,11 @@ export function PopoverRoot(props: PopoverRootProps) {
shift(),
],
});
const floatingStyles = createMemo(() => ({
position: position.strategy,
top: `${position.y ?? 0}px`,
left: `${position.x ?? 0}px`,
}));

const onClose = () => {
onOpenChange()(false);
Expand All @@ -60,11 +71,7 @@ export function PopoverRoot(props: PopoverRootProps) {
floating,
setFloating,
open,
floatingStyles: () => ({
position: position.strategy,
top: `${position.y ?? 0}px`,
left: `${position.x ?? 0}px`,
}),
floatingStyles,
}}
>
{props.children}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { Root } from '../../elements';
import { useTooltip } from './TooltipRoot';

export const tooltipContentVariants = () =>
'nt-bg-foreground nt-p-2 nt-shadow-tooltip nt-z-10 nt-rounded-lg nt-text-background nt-text-xs';
'nt-bg-foreground nt-p-2 nt-shadow-tooltip nt-rounded-lg nt-text-background nt-text-xs';

const TooltipContentBody = (props: TooltipContentProps) => {
const { open, setFloating, floating, floatingStyles } = useTooltip();
Expand All @@ -28,7 +28,7 @@ const TooltipContentBody = (props: TooltipContentProps) => {
<div
ref={setFloating}
class={local.class ? local.class : style(local.appearanceKey || 'tooltipContent', tooltipContentVariants())}
style={floatingStyles()}
style={{ ...floatingStyles(), 'z-index': 99999 }}
data-open={open()}
{...rest}
/>
Expand All @@ -41,6 +41,7 @@ export const TooltipContent = (props: TooltipContentProps) => {

return (
<Show when={open()}>
{/* we can safely use portal here as this element won't be focused and close other portals (outside solid world) as a result */}
<Portal>
<Root>
<TooltipContentBody {...props} />
Expand Down
11 changes: 8 additions & 3 deletions packages/react/src/components/Bell.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
import React from 'react';
import { useRenderer } from '../context/RenderContext';
import { Mounter } from './Mounter';
import { BellRenderer } from '../utils/types';
import { withRenderer } from './Renderer';
import { useNovuUI } from '../context/NovuUIContext';
import { useRenderer } from '../context/RendererContext';

export type BellProps = {
renderBell?: BellRenderer;
};

export const Bell = React.memo((props: BellProps) => {
const _Bell = React.memo((props: BellProps) => {
const { renderBell } = props;
const { novuUI, mountElement } = useRenderer();
const { novuUI } = useNovuUI();
const { mountElement } = useRenderer();

const mount = React.useCallback(
(element: HTMLElement) => {
Expand All @@ -24,3 +27,5 @@ export const Bell = React.memo((props: BellProps) => {

return <Mounter mount={mount} />;
});

export const Bell = withRenderer(_Bell);
23 changes: 14 additions & 9 deletions packages/react/src/components/Inbox.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
import React, { useMemo } from 'react';
import { useRenderer } from '../context/RenderContext';
import { DefaultProps, DefaultInboxProps, WithChildrenProps } from '../utils/types';
import { NovuProvider, useNovu, useUnsafeNovu } from './index';
import { Mounter } from './Mounter';
import { Renderer } from './Renderer';
import { useNovuUI } from '../context/NovuUIContext';
import { useRenderer } from '../context/RendererContext';
import { NovuProvider, useNovu, useUnsafeNovu } from '../hooks/NovuProvider';
import { NovuUI } from './NovuUI';
import { withRenderer } from './Renderer';

export type InboxProps = DefaultProps | WithChildrenProps;

const DefaultInbox = (props: DefaultInboxProps) => {
const _DefaultInbox = (props: DefaultInboxProps) => {
const { open, renderNotification, renderBell, onNotificationClick, onPrimaryActionClick, onSecondaryActionClick } =
props;
const { novuUI, mountElement } = useRenderer();
const { novuUI } = useNovuUI();
const { mountElement } = useRenderer();

const mount = React.useCallback(
(element: HTMLElement) => {
Expand All @@ -35,6 +38,8 @@ const DefaultInbox = (props: DefaultInboxProps) => {
return <Mounter mount={mount} />;
};

export const DefaultInbox = withRenderer(_DefaultInbox);

export const Inbox = React.memo((props: InboxProps) => {
const { applicationIdentifier, subscriberId, subscriberHash, backendUrl, socketUrl } = props;
const novu = useUnsafeNovu();
Expand Down Expand Up @@ -94,17 +99,17 @@ export const InboxChild = React.memo((props: InboxProps) => {

if (isWithChildrenProps(props)) {
return (
<Renderer options={options} novu={novu}>
<NovuUI options={options} novu={novu}>
{props.children}
</Renderer>
</NovuUI>
);
}

const { open, renderNotification, renderBell, onNotificationClick, onPrimaryActionClick, onSecondaryActionClick } =
props;

return (
<Renderer options={options} novu={novu}>
<NovuUI options={options} novu={novu}>
<DefaultInbox
open={open}
renderNotification={renderNotification}
Expand All @@ -113,7 +118,7 @@ export const InboxChild = React.memo((props: InboxProps) => {
onPrimaryActionClick={onPrimaryActionClick}
onSecondaryActionClick={onSecondaryActionClick}
/>
</Renderer>
</NovuUI>
);
});

Expand Down
53 changes: 53 additions & 0 deletions packages/react/src/components/InboxContent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import React from 'react';
import type { NotificationClickHandler, NotificationActionClickHandler, InboxPage } from '@novu/js/ui';
import { Mounter } from './Mounter';
import { NotificationsRenderer } from '../utils/types';
import { useRenderer } from '../context/RendererContext';
import { useNovuUI } from '../context/NovuUIContext';
import { withRenderer } from './Renderer';

export type InboxContentProps = {
renderNotification?: NotificationsRenderer;
onNotificationClick?: NotificationClickHandler;
onPrimaryActionClick?: NotificationActionClickHandler;
onSecondaryActionClick?: NotificationActionClickHandler;
initialPage?: InboxPage;
hideNav?: boolean;
};

const _InboxContent = React.memo((props: InboxContentProps) => {
const {
onNotificationClick,
onPrimaryActionClick,
renderNotification,
onSecondaryActionClick,
initialPage,
hideNav,
} = props;
const { novuUI } = useNovuUI();
const { mountElement } = useRenderer();

const mount = React.useCallback(
(element: HTMLElement) => {
return novuUI.mountComponent({
name: 'InboxContent',
element,
props: {
renderNotification: renderNotification
? (el, notification) => mountElement(el, renderNotification(notification))
: undefined,
onNotificationClick,
onPrimaryActionClick,
onSecondaryActionClick,
initialPage,
hideNav,
},
});
},
[renderNotification, onNotificationClick, onPrimaryActionClick, onSecondaryActionClick]
);

return <Mounter mount={mount} />;
});

export const InboxContent = withRenderer(_InboxContent);
6 changes: 3 additions & 3 deletions packages/react/src/components/Mounter.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React from 'react';
import { useEffect, useRef } from 'react';

type MounterProps = {
mount: (node: HTMLElement) => ((node: HTMLElement) => void) | void;
Expand All @@ -8,9 +8,9 @@ type MounterProps = {
* Mounter allows you to mount a component to a DOM node.
*/
export function Mounter({ mount }: MounterProps) {
const ref = React.useRef<HTMLDivElement>(null);
const ref = useRef<HTMLDivElement>(null);

React.useEffect(() => {
useEffect(() => {
let unmount: (node: HTMLDivElement) => void | undefined;
const element = ref.current;
if (element && mount) {
Expand Down
11 changes: 8 additions & 3 deletions packages/react/src/components/Notifications.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import React from 'react';
import type { NotificationClickHandler, NotificationActionClickHandler } from '@novu/js/ui';
import { useRenderer } from '../context/RenderContext';
import { Mounter } from './Mounter';
import { NotificationsRenderer } from '../utils/types';
import { useRenderer } from '../context/RendererContext';
import { useNovuUI } from '../context/NovuUIContext';
import { withRenderer } from './Renderer';

export type NotificationProps = {
renderNotification?: NotificationsRenderer;
Expand All @@ -11,9 +13,10 @@ export type NotificationProps = {
onSecondaryActionClick?: NotificationActionClickHandler;
};

export const Notifications = React.memo((props: NotificationProps) => {
const _Notifications = React.memo((props: NotificationProps) => {
const { onNotificationClick, onPrimaryActionClick, renderNotification, onSecondaryActionClick } = props;
const { novuUI, mountElement } = useRenderer();
const { novuUI } = useNovuUI();
const { mountElement } = useRenderer();

const mount = React.useCallback(
(element: HTMLElement) => {
Expand All @@ -35,3 +38,5 @@ export const Notifications = React.memo((props: NotificationProps) => {

return <Mounter mount={mount} />;
});

export const Notifications = withRenderer(_Notifications);
43 changes: 43 additions & 0 deletions packages/react/src/components/NovuUI.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { Novu } from '@novu/js';
import type { NovuUIOptions } from '@novu/js/ui';
import { NovuUI as NovuUIClass } from '@novu/js/ui';
import React, { useEffect, useState } from 'react';
import { NovuUIProvider } from '../context/NovuUIContext';
import { useDataRef } from '../hooks/internal/useDataRef';

type RendererProps = React.PropsWithChildren<{
options: NovuUIOptions;
novu?: Novu;
}>;

export const NovuUI = ({ options, novu, children }: RendererProps) => {
const optionsRef = useDataRef({ ...options, novu });
const [novuUI, setNovuUI] = useState<NovuUIClass | undefined>();

useEffect(() => {
const novu = new NovuUIClass(optionsRef.current);
setNovuUI(novu);

return () => {
novu.unmount();
};
}, []);

useEffect(() => {
if (!novuUI) {
return;
}

novuUI.updateAppearance(options.appearance);
novuUI.updateLocalization(options.localization);
novuUI.updateTabs(options.tabs);
novuUI.updateOptions(options.options);
novuUI.updateRouterPush(options.routerPush);
}, [options]);

if (!novuUI) {
return null;
}

return <NovuUIProvider value={{ novuUI }}>{children}</NovuUIProvider>;
};
Loading

0 comments on commit 7225753

Please sign in to comment.