From d86bcc3f18450460f5b9291919bfaeabbba27a01 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Tue, 25 Jul 2023 14:10:02 +0300 Subject: [PATCH 01/11] feat(clerk-js): Load invitation with infinite scrolling --- packages/clerk-js/package.json | 1 + .../OrganizationSwitcher.tsx | 8 +- .../OtherOrganizationActions.tsx | 143 +++++++++++++++++- .../ui/customizables/elementDescriptors.ts | 1 + packages/types/src/appearance.ts | 1 + 5 files changed, 150 insertions(+), 4 deletions(-) diff --git a/packages/clerk-js/package.json b/packages/clerk-js/package.json index 27725918df4..1ffe360b986 100644 --- a/packages/clerk-js/package.json +++ b/packages/clerk-js/package.json @@ -55,6 +55,7 @@ "dequal": "2.0.3", "qrcode.react": "3.1.0", "qs": "6.11.0", + "react-intersection-observer": "^9.5.2", "regenerator-runtime": "0.13.11" }, "peerDependencies": { diff --git a/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcher.tsx b/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcher.tsx index f2dc5c28a22..a272e6de7c3 100644 --- a/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcher.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcher.tsx @@ -1,5 +1,5 @@ import { withOrganizationsEnabledGuard } from '../../common'; -import { withCoreUserGuard } from '../../contexts'; +import { useCoreOrganizationList, withCoreUserGuard } from '../../contexts'; import { Flow } from '../../customizables'; import { Popover, withCardStateProvider, withFloatingTree } from '../../elements'; import { usePopover } from '../../hooks'; @@ -12,6 +12,12 @@ const _OrganizationSwitcher = withFloatingTree(() => { offset: 8, }); + useCoreOrganizationList({ + userInvitations: { + aggregate: true, + }, + }); + return ( { const { onCreateOrganizationClick, onPersonalWorkspaceClick, onOrganizationClick } = props; - const { organizationList } = useCoreOrganizationList(); + const { organizationList, userInvitations } = useCoreOrganizationList({ + userInvitations: { + aggregate: true, + }, + }); + + const { ref } = useInView({ + threshold: 0, + onChange: inView => { + if (inView) { + void userInvitations?.setSize?.(n => n + 1); + } + }, + }); + const { organization: currentOrg } = useCoreOrganization(); const user = useCoreUser(); // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -86,7 +101,129 @@ export const OrganizationActionList = (props: OrganizationActionListProps) => { ))} + + {userInvitations.isLoading &&

is loading

} + + {userInvitations.count > 0 && ( + <> + ({ + minHeight: 'unset', + height: t.space.$12, + padding: `${t.space.$3} ${t.space.$6}`, + display: 'flex', + alignItems: 'center', + }), + ]} + > + 1 pending invitation to join: + + ({ + maxHeight: `calc(4 * ${t.sizes.$12})`, + overflowY: 'auto', + ...common.unstyledScrollbar(t), + })} + > + {userInvitations?.data?.map(inv => { + return ( + + ); + })} + + {userInvitations.count > (userInvitations.data?.length || 0) && ( + ({ + width: '100%', + height: t.sizes.$8, + position: 'relative', + }), + ]} + > + + + + + )} + + + )} {user.createOrganizationEnabled && createOrganizationButton} ); }; + +const InvitationPreview = (props: UserOrganizationInvitationResource) => { + return ( + ({ + minHeight: 'unset', + height: t.space.$12, + justifyContent: 'space-between', + padding: `0 ${t.space.$6}`, + ':hover > .cl-organizationSwitcherPreviewButton': { + opacity: 1, + transform: 'translateY(0px)', + tabIndex: 1, + }, + ':focus-within > .cl-organizationSwitcherPreviewButton': { + opacity: 1, + transform: 'translateY(0px)', + tabIndex: 1, + }, + }), + ]} + > + ({ margin: `0 calc(${t.space.$3}/2)` })} + organization={props.organization} + size='sm' + /> + + + + + ); +}; diff --git a/packages/clerk-js/src/ui/customizables/elementDescriptors.ts b/packages/clerk-js/src/ui/customizables/elementDescriptors.ts index a499a98e805..48b83b8aea3 100644 --- a/packages/clerk-js/src/ui/customizables/elementDescriptors.ts +++ b/packages/clerk-js/src/ui/customizables/elementDescriptors.ts @@ -129,6 +129,7 @@ export const APPEARANCE_KEYS = containsAllElementsConfigKeys([ 'organizationSwitcherPopoverActions', 'organizationSwitcherPopoverActionButton', 'organizationSwitcherPreviewButton', + 'organizationSwitcherInvitationRejectButton', 'organizationSwitcherPopoverActionButtonIconBox', 'organizationSwitcherPopoverActionButtonIcon', 'organizationSwitcherPopoverActionButtonText', diff --git a/packages/types/src/appearance.ts b/packages/types/src/appearance.ts index 9768adf3956..2eb1e7b89f0 100644 --- a/packages/types/src/appearance.ts +++ b/packages/types/src/appearance.ts @@ -273,6 +273,7 @@ export type ElementsConfig = { never >; organizationSwitcherPreviewButton: WithOptions; + organizationSwitcherInvitationRejectButton: WithOptions; organizationSwitcherPopoverActionButtonIconBox: WithOptions< 'manageOrganization' | 'createOrganization', never, From f8cea983dc176df0accc9a7fc0be66e750ab6136 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Wed, 26 Jul 2023 16:32:43 +0300 Subject: [PATCH 02/11] chore(clerk-js): Custom useInView --- packages/clerk-js/package.json | 1 - .../OrganizationSwitcher.tsx | 2 +- .../OtherOrganizationActions.tsx | 64 ++++++++++++++++--- 3 files changed, 57 insertions(+), 10 deletions(-) diff --git a/packages/clerk-js/package.json b/packages/clerk-js/package.json index 1ffe360b986..27725918df4 100644 --- a/packages/clerk-js/package.json +++ b/packages/clerk-js/package.json @@ -55,7 +55,6 @@ "dequal": "2.0.3", "qrcode.react": "3.1.0", "qs": "6.11.0", - "react-intersection-observer": "^9.5.2", "regenerator-runtime": "0.13.11" }, "peerDependencies": { diff --git a/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcher.tsx b/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcher.tsx index a272e6de7c3..b3187127360 100644 --- a/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcher.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcher.tsx @@ -14,7 +14,7 @@ const _OrganizationSwitcher = withFloatingTree(() => { useCoreOrganizationList({ userInvitations: { - aggregate: true, + infinite: true, }, }); diff --git a/packages/clerk-js/src/ui/components/OrganizationSwitcher/OtherOrganizationActions.tsx b/packages/clerk-js/src/ui/components/OrganizationSwitcher/OtherOrganizationActions.tsx index 2f98665d8c8..678b346a821 100644 --- a/packages/clerk-js/src/ui/components/OrganizationSwitcher/OtherOrganizationActions.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationSwitcher/OtherOrganizationActions.tsx @@ -1,6 +1,5 @@ import type { OrganizationResource, UserOrganizationInvitationResource } from '@clerk/types'; -import React from 'react'; -import { useInView } from 'react-intersection-observer'; +import React, { useCallback, useRef, useState } from 'react'; import { Plus, SwitchArrows } from '../../../ui/icons'; import { @@ -19,11 +18,62 @@ type OrganizationActionListProps = { onOrganizationClick: (org: OrganizationResource) => unknown; }; +export interface IntersectionOptions extends IntersectionObserverInit { + /** Only trigger the inView callback once */ + triggerOnce?: boolean; + /** Call this function whenever the in view state changes */ + onChange?: (inView: boolean, entry: IntersectionObserverEntry) => void; +} + +const useInView = (params: IntersectionOptions) => { + const [inView, setInView] = useState(false); + const observerRef = useRef(null); + const thresholds = Array.isArray(params.threshold) ? params.threshold : [params.threshold || 0]; + const internalOnChange = React.useRef(); + + internalOnChange.current = params.onChange; + + const ref = useCallback((element: HTMLElement | null) => { + if (!element) { + if (observerRef.current) { + observerRef.current.disconnect(); + } + return; + } + + observerRef.current = new IntersectionObserver( + entries => { + entries.forEach(entry => { + const _inView = entry.isIntersecting && thresholds.some(threshold => entry.intersectionRatio >= threshold); + + setInView(_inView); + + if (internalOnChange.current) { + internalOnChange.current(_inView, entry); + } + }); + }, + { + root: params.root, + rootMargin: params.rootMargin, + threshold: thresholds, + }, + ); + + observerRef.current.observe(element); + }, []); + + return { + inView, + ref, + }; +}; + export const OrganizationActionList = (props: OrganizationActionListProps) => { const { onCreateOrganizationClick, onPersonalWorkspaceClick, onOrganizationClick } = props; const { organizationList, userInvitations } = useCoreOrganizationList({ userInvitations: { - aggregate: true, + infinite: true, }, }); @@ -31,7 +81,7 @@ export const OrganizationActionList = (props: OrganizationActionListProps) => { threshold: 0, onChange: inView => { if (inView) { - void userInvitations?.setSize?.(n => n + 1); + void userInvitations.fetchNext?.(); } }, }); @@ -102,9 +152,7 @@ export const OrganizationActionList = (props: OrganizationActionListProps) => { ))} - {userInvitations.isLoading &&

is loading

} - - {userInvitations.count > 0 && ( + {(userInvitations.count ?? 0) > 0 && ( <> { ); })} - {userInvitations.count > (userInvitations.data?.length || 0) && ( + {(userInvitations.hasNextPage || userInvitations.isLoading) && ( Date: Fri, 4 Aug 2023 12:21:47 +0300 Subject: [PATCH 03/11] feat(localizations): New invitation Org switcher keys --- packages/localizations/src/en-US.ts | 3 +++ packages/types/src/localization.ts | 3 +++ 2 files changed, 6 insertions(+) diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index 05178d905a4..1e9dfd68d9e 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -524,6 +524,9 @@ export const enUS: LocalizationResource = { notSelected: 'No organization selected', action__createOrganization: 'Create Organization', action__manageOrganization: 'Manage Organization', + invitationCountLabel_single: '1 pending invitation to join:', + invitationCountLabel_many: '{{count}} pending invitations to join:', + invitationAccept: 'Join', }, impersonationFab: { title: 'Signed in as {{identifier}}', diff --git a/packages/types/src/localization.ts b/packages/types/src/localization.ts index 9e0ddc144b2..6cedec83873 100644 --- a/packages/types/src/localization.ts +++ b/packages/types/src/localization.ts @@ -545,6 +545,9 @@ type _LocalizationResource = { notSelected: LocalizationValue; action__createOrganization: LocalizationValue; action__manageOrganization: LocalizationValue; + invitationCountLabel_single: LocalizationValue; + invitationCountLabel_many: LocalizationValue; + invitationAccept: LocalizationValue; }; impersonationFab: { title: LocalizationValue; From 921c927043b6a74659cc5aa1123462891d7e1749 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Fri, 4 Aug 2023 12:31:03 +0300 Subject: [PATCH 04/11] feat(clerk-js,types): Users can accept invitations within --- .../OtherOrganizationActions.tsx | 109 +++++++++++------- .../ui/customizables/elementDescriptors.ts | 2 + packages/types/src/appearance.ts | 2 + 3 files changed, 71 insertions(+), 42 deletions(-) diff --git a/packages/clerk-js/src/ui/components/OrganizationSwitcher/OtherOrganizationActions.tsx b/packages/clerk-js/src/ui/components/OrganizationSwitcher/OtherOrganizationActions.tsx index 678b346a821..caa92c61354 100644 --- a/packages/clerk-js/src/ui/components/OrganizationSwitcher/OtherOrganizationActions.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationSwitcher/OtherOrganizationActions.tsx @@ -9,8 +9,17 @@ import { useOrganizationSwitcherContext, } from '../../contexts'; import { Box, Button, descriptors, Flex, localizationKeys, Spinner, Text } from '../../customizables'; -import { Action, OrganizationPreview, PersonalWorkspacePreview, PreviewButton, SecondaryActions } from '../../elements'; +import { + Action, + OrganizationPreview, + PersonalWorkspacePreview, + PreviewButton, + SecondaryActions, + useCardState, + withCardStateProvider, +} from '../../elements'; import { common } from '../../styledSystem'; +import { handleError } from '../../utils'; type OrganizationActionListProps = { onCreateOrganizationClick: React.MouseEventHandler; @@ -153,7 +162,15 @@ export const OrganizationActionList = (props: OrganizationActionListProps) => { {(userInvitations.count ?? 0) > 0 && ( - <> + ({ + backgroundColor: t.colors.$blackAlpha50, + }), + ]} + > { alignItems: 'center', }), ]} - > - 1 pending invitation to join: - + localizationKey={localizationKeys( + (userInvitations.count ?? 0) > 1 + ? 'organizationSwitcher.invitationCountLabel_many' + : 'organizationSwitcher.invitationCountLabel_single', + { + count: userInvitations.count, + }, + )} + /> ({ maxHeight: `calc(4 * ${t.sizes.$12})`, @@ -184,13 +207,13 @@ export const OrganizationActionList = (props: OrganizationActionListProps) => { ); })} - {(userInvitations.hasNextPage || userInvitations.isLoading) && ( + {(userInvitations.hasNextPage || userInvitations.isFetching) && ( ({ width: '100%', - height: t.sizes.$8, + height: t.space.$12, position: 'relative', }), ]} @@ -212,14 +235,47 @@ export const OrganizationActionList = (props: OrganizationActionListProps) => { )} - + )} {user.createOrganizationEnabled && createOrganizationButton} ); }; -const InvitationPreview = (props: UserOrganizationInvitationResource) => { +const AcceptRejectInvitationButtons = (props: UserOrganizationInvitationResource) => { + const card = useCardState(); + const { userInvitations } = useCoreOrganizationList({ + userInvitations: { + infinite: true, + }, + }); + + const mutateSwrState = () => { + (userInvitations as any)?.unstable__mutate?.(); + }; + + const handleAccept = () => { + return card + .runAsync(props.accept()) + .then(mutateSwrState) + .catch(err => handleError(err, [], card.setError)); + }; + + return ( + <> + - + ); -}; +}); diff --git a/packages/clerk-js/src/ui/customizables/elementDescriptors.ts b/packages/clerk-js/src/ui/customizables/elementDescriptors.ts index 48b83b8aea3..6e8c2ee4af5 100644 --- a/packages/clerk-js/src/ui/customizables/elementDescriptors.ts +++ b/packages/clerk-js/src/ui/customizables/elementDescriptors.ts @@ -127,8 +127,10 @@ export const APPEARANCE_KEYS = containsAllElementsConfigKeys([ 'organizationSwitcherPopoverCard', 'organizationSwitcherPopoverMain', 'organizationSwitcherPopoverActions', + 'organizationSwitcherPopoverInvitationActions', 'organizationSwitcherPopoverActionButton', 'organizationSwitcherPreviewButton', + 'organizationSwitcherInvitationAcceptButton', 'organizationSwitcherInvitationRejectButton', 'organizationSwitcherPopoverActionButtonIconBox', 'organizationSwitcherPopoverActionButtonIcon', diff --git a/packages/types/src/appearance.ts b/packages/types/src/appearance.ts index 2eb1e7b89f0..7615e514d1a 100644 --- a/packages/types/src/appearance.ts +++ b/packages/types/src/appearance.ts @@ -267,12 +267,14 @@ export type ElementsConfig = { organizationSwitcherPopoverCard: WithOptions; organizationSwitcherPopoverMain: WithOptions; organizationSwitcherPopoverActions: WithOptions; + organizationSwitcherPopoverInvitationActions: WithOptions; organizationSwitcherPopoverActionButton: WithOptions< 'manageOrganization' | 'createOrganization' | 'switchOrganization', never, never >; organizationSwitcherPreviewButton: WithOptions; + organizationSwitcherInvitationAcceptButton: WithOptions; organizationSwitcherInvitationRejectButton: WithOptions; organizationSwitcherPopoverActionButtonIconBox: WithOptions< 'manageOrganization' | 'createOrganization', From 41831714d790953ae4e4534a0e1d2d4add2d1536 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Fri, 4 Aug 2023 15:04:00 +0300 Subject: [PATCH 05/11] fix(clerk-js,types): UserOrganizationInvitation nullish slug --- packages/types/src/json.ts | 2 +- packages/types/src/userOrganizationInvitation.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/types/src/json.ts b/packages/types/src/json.ts index f634f755d52..30b2ea24fbb 100644 --- a/packages/types/src/json.ts +++ b/packages/types/src/json.ts @@ -370,7 +370,7 @@ export interface UserOrganizationInvitationJSON extends ClerkResourceJSON { public_organization_data: { id: string; name: string; - slug: string; + slug: string | null; has_image: boolean; image_url: string; }; diff --git a/packages/types/src/userOrganizationInvitation.ts b/packages/types/src/userOrganizationInvitation.ts index 50e1a5c6181..baff1f268d4 100644 --- a/packages/types/src/userOrganizationInvitation.ts +++ b/packages/types/src/userOrganizationInvitation.ts @@ -25,7 +25,7 @@ export interface UserOrganizationInvitationResource extends ClerkResource { imageUrl: string; name: string; id: string; - slug: string; + slug: string | null; }; publicMetadata: UserOrganizationInvitationPublicMetadata; role: MembershipRole; From 9eb5002170bd16833db277cc27b097de63de7ee7 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Fri, 4 Aug 2023 15:05:28 +0300 Subject: [PATCH 06/11] fix(clerk-js): Update OrganizationPreviewProps to accept only the public organization data --- .../OrganizationSwitcher/OtherOrganizationActions.tsx | 2 +- packages/clerk-js/src/ui/elements/OrganizationPreview.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/clerk-js/src/ui/components/OrganizationSwitcher/OtherOrganizationActions.tsx b/packages/clerk-js/src/ui/components/OrganizationSwitcher/OtherOrganizationActions.tsx index caa92c61354..8a3610a0729 100644 --- a/packages/clerk-js/src/ui/components/OrganizationSwitcher/OtherOrganizationActions.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationSwitcher/OtherOrganizationActions.tsx @@ -292,7 +292,7 @@ const InvitationPreview = withCardStateProvider((props: UserOrganizationInvitati ({ margin: `0 calc(${t.space.$3}/2)` })} - organization={props.organization} + organization={props.publicOrganizationData} size='sm' /> diff --git a/packages/clerk-js/src/ui/elements/OrganizationPreview.tsx b/packages/clerk-js/src/ui/elements/OrganizationPreview.tsx index 9346840ef21..7036e34df19 100644 --- a/packages/clerk-js/src/ui/elements/OrganizationPreview.tsx +++ b/packages/clerk-js/src/ui/elements/OrganizationPreview.tsx @@ -1,4 +1,4 @@ -import type { OrganizationPreviewId, OrganizationResource, UserResource } from '@clerk/types'; +import type { OrganizationPreviewId, UserOrganizationInvitationResource, UserResource } from '@clerk/types'; import React from 'react'; import { descriptors, Flex, Text } from '../customizables'; @@ -7,7 +7,7 @@ import { roleLocalizationKey } from '../utils'; import { OrganizationAvatar } from './OrganizationAvatar'; export type OrganizationPreviewProps = Omit, 'elementId'> & { - organization: OrganizationResource; + organization: UserOrganizationInvitationResource['publicOrganizationData']; user?: UserResource; size?: 'lg' | 'md' | 'sm'; avatarSx?: ThemableCssProp; From fc728f3204182e7e03bd0a3423f8649e0577a956 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Mon, 7 Aug 2023 11:09:33 +0300 Subject: [PATCH 07/11] test(clerk-js): Display list of invitation in OrganizationSwitcher --- .../OtherOrganizationActions.tsx | 6 +-- .../__tests__/OrganizationSwitcher.test.tsx | 45 +++++++++++++++++++ .../OrganizationSwitcher/__tests__/utlis.ts | 36 +++++++++++++++ 3 files changed, 82 insertions(+), 5 deletions(-) create mode 100644 packages/clerk-js/src/ui/components/OrganizationSwitcher/__tests__/utlis.ts diff --git a/packages/clerk-js/src/ui/components/OrganizationSwitcher/OtherOrganizationActions.tsx b/packages/clerk-js/src/ui/components/OrganizationSwitcher/OtherOrganizationActions.tsx index 8a3610a0729..d3a863c5626 100644 --- a/packages/clerk-js/src/ui/components/OrganizationSwitcher/OtherOrganizationActions.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationSwitcher/OtherOrganizationActions.tsx @@ -165,11 +165,6 @@ export const OrganizationActionList = (props: OrganizationActionListProps) => { ({ - backgroundColor: t.colors.$blackAlpha50, - }), - ]} > { alignItems: 'center', }), ]} + // Handle plurals localizationKey={localizationKeys( (userInvitations.count ?? 0) > 1 ? 'organizationSwitcher.invitationCountLabel_many' diff --git a/packages/clerk-js/src/ui/components/OrganizationSwitcher/__tests__/OrganizationSwitcher.test.tsx b/packages/clerk-js/src/ui/components/OrganizationSwitcher/__tests__/OrganizationSwitcher.test.tsx index 2f6e403e559..0e87e2df5d6 100644 --- a/packages/clerk-js/src/ui/components/OrganizationSwitcher/__tests__/OrganizationSwitcher.test.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationSwitcher/__tests__/OrganizationSwitcher.test.tsx @@ -5,6 +5,7 @@ import React from 'react'; import { render } from '../../../../testUtils'; import { bindCreateFixtures } from '../../../utils/test/createFixtures'; import { OrganizationSwitcher } from '../OrganizationSwitcher'; +import { createFakeUserOrganizationInvitations } from './utlis'; const { createFixtures } = bindCreateFixtures('OrganizationSwitcher'); @@ -131,6 +132,50 @@ describe('OrganizationSwitcher', () => { expect(queryByRole('button', { name: 'Create Organization' })).not.toBeInTheDocument(); }); + it('displays a list of user invitations', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withOrganizations(); + f.withUser({ + email_addresses: ['test@clerk.dev'], + organization_memberships: [{ name: 'Org1', role: 'basic_member' }], + create_organization_enabled: false, + }); + }); + fixtures.clerk.user?.getOrganizationInvitations.mockReturnValueOnce( + Promise.resolve({ + data: [ + createFakeUserOrganizationInvitations({ + id: '1', + emailAddress: 'one@clerk.com', + publicOrganizationData: { + name: 'OrgOne', + }, + }), + createFakeUserOrganizationInvitations({ + id: '2', + emailAddress: 'two@clerk.com', + publicOrganizationData: { name: 'OrgTwo' }, + }), + ], + total_count: 11, + }), + ); + const { queryByText, userEvent, getByRole } = render(, { + wrapper, + }); + + await userEvent.click(getByRole('button')); + + expect(fixtures.clerk.user?.getOrganizationInvitations).toHaveBeenCalledWith({ + initialPage: 1, + pageSize: 10, + status: 'pending', + }); + expect(queryByText('OrgOne')).toBeInTheDocument(); + expect(queryByText('OrgTwo')).toBeInTheDocument(); + }); + + it.todo('switches between active organizations when one is clicked'); it("switches between active organizations when one is clicked'", async () => { const { wrapper, props, fixtures } = await createFixtures(f => { f.withOrganizations(); diff --git a/packages/clerk-js/src/ui/components/OrganizationSwitcher/__tests__/utlis.ts b/packages/clerk-js/src/ui/components/OrganizationSwitcher/__tests__/utlis.ts new file mode 100644 index 00000000000..ab252101784 --- /dev/null +++ b/packages/clerk-js/src/ui/components/OrganizationSwitcher/__tests__/utlis.ts @@ -0,0 +1,36 @@ +import { MembershipRole, OrganizationInvitationStatus, UserOrganizationInvitationResource } from '@clerk/types'; +import { jest } from '@jest/globals'; + +type FakeOrganizationParams = { + id: string; + createdAt?: Date; + emailAddress: string; + role?: MembershipRole; + status?: OrganizationInvitationStatus; + publicOrganizationData?: { hasImage?: boolean; id?: string; imageUrl?: string; name?: string; slug?: string }; +}; + +export const createFakeUserOrganizationInvitations = ( + params: FakeOrganizationParams, +): UserOrganizationInvitationResource => { + return { + pathRoot: '', + emailAddress: params.emailAddress, + publicOrganizationData: { + hasImage: false, + id: '', + imageUrl: '', + name: '', + slug: '', + ...params.publicOrganizationData, + }, + role: params.role || 'basic_member', + status: params.status || 'pending', + id: params.id, + createdAt: params?.createdAt || new Date(), + updatedAt: new Date(), + publicMetadata: {}, + accept: jest.fn() as any, + reload: jest.fn() as any, + }; +}; From c88d6089ad2d7ea5c44ef4d29b1c94d2f356c9e0 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Wed, 9 Aug 2023 16:09:42 +0300 Subject: [PATCH 08/11] chore(repo): Add changeset --- .changeset/shaggy-terms-train.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .changeset/shaggy-terms-train.md diff --git a/.changeset/shaggy-terms-train.md b/.changeset/shaggy-terms-train.md new file mode 100644 index 00000000000..909b9d7c802 --- /dev/null +++ b/.changeset/shaggy-terms-train.md @@ -0,0 +1,8 @@ +--- +'@clerk/localizations': patch +'@clerk/clerk-js': patch +'@clerk/types': patch +--- + +Introduces an invitation list within ++ Users can accept the invitation that is sent to them From da4becd4ed3e9395b561b4ff1e60d44cafe33a8f Mon Sep 17 00:00:00 2001 From: panteliselef Date: Tue, 8 Aug 2023 12:20:22 +0300 Subject: [PATCH 09/11] chore(clerk-js): Move useInView inside `/hooks` + Add default values in useLoadingStatus --- .../OtherOrganizationActions.tsx | 46 +----------------- packages/clerk-js/src/ui/hooks/index.ts | 1 + packages/clerk-js/src/ui/hooks/useInView.ts | 47 +++++++++++++++++++ .../clerk-js/src/ui/hooks/useLoadingStatus.ts | 3 +- 4 files changed, 51 insertions(+), 46 deletions(-) create mode 100644 packages/clerk-js/src/ui/hooks/useInView.ts diff --git a/packages/clerk-js/src/ui/components/OrganizationSwitcher/OtherOrganizationActions.tsx b/packages/clerk-js/src/ui/components/OrganizationSwitcher/OtherOrganizationActions.tsx index d3a863c5626..8405d7bdeb3 100644 --- a/packages/clerk-js/src/ui/components/OrganizationSwitcher/OtherOrganizationActions.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationSwitcher/OtherOrganizationActions.tsx @@ -1,5 +1,4 @@ import type { OrganizationResource, UserOrganizationInvitationResource } from '@clerk/types'; -import React, { useCallback, useRef, useState } from 'react'; import { Plus, SwitchArrows } from '../../../ui/icons'; import { @@ -18,6 +17,7 @@ import { useCardState, withCardStateProvider, } from '../../elements'; +import { useInView } from '../../hooks'; import { common } from '../../styledSystem'; import { handleError } from '../../utils'; @@ -34,50 +34,6 @@ export interface IntersectionOptions extends IntersectionObserverInit { onChange?: (inView: boolean, entry: IntersectionObserverEntry) => void; } -const useInView = (params: IntersectionOptions) => { - const [inView, setInView] = useState(false); - const observerRef = useRef(null); - const thresholds = Array.isArray(params.threshold) ? params.threshold : [params.threshold || 0]; - const internalOnChange = React.useRef(); - - internalOnChange.current = params.onChange; - - const ref = useCallback((element: HTMLElement | null) => { - if (!element) { - if (observerRef.current) { - observerRef.current.disconnect(); - } - return; - } - - observerRef.current = new IntersectionObserver( - entries => { - entries.forEach(entry => { - const _inView = entry.isIntersecting && thresholds.some(threshold => entry.intersectionRatio >= threshold); - - setInView(_inView); - - if (internalOnChange.current) { - internalOnChange.current(_inView, entry); - } - }); - }, - { - root: params.root, - rootMargin: params.rootMargin, - threshold: thresholds, - }, - ); - - observerRef.current.observe(element); - }, []); - - return { - inView, - ref, - }; -}; - export const OrganizationActionList = (props: OrganizationActionListProps) => { const { onCreateOrganizationClick, onPersonalWorkspaceClick, onOrganizationClick } = props; const { organizationList, userInvitations } = useCoreOrganizationList({ diff --git a/packages/clerk-js/src/ui/hooks/index.ts b/packages/clerk-js/src/ui/hooks/index.ts index 451e54d5da7..579be2932ff 100644 --- a/packages/clerk-js/src/ui/hooks/index.ts +++ b/packages/clerk-js/src/ui/hooks/index.ts @@ -4,6 +4,7 @@ export * from './useWindowEventListener'; export * from './useMagicLink'; export * from './useClipboard'; export * from './useEnabledThirdPartyProviders'; +export * from './useInView'; export * from './useLoadingStatus'; export * from './usePassword'; export * from './usePasswordComplexity'; diff --git a/packages/clerk-js/src/ui/hooks/useInView.ts b/packages/clerk-js/src/ui/hooks/useInView.ts new file mode 100644 index 00000000000..07d2138dd63 --- /dev/null +++ b/packages/clerk-js/src/ui/hooks/useInView.ts @@ -0,0 +1,47 @@ +import React, { useCallback, useRef, useState } from 'react'; + +import type { IntersectionOptions } from '../components/OrganizationSwitcher/OtherOrganizationActions'; + +export const useInView = (params: IntersectionOptions) => { + const [inView, setInView] = useState(false); + const observerRef = useRef(null); + const thresholds = Array.isArray(params.threshold) ? params.threshold : [params.threshold || 0]; + const internalOnChange = React.useRef(); + + internalOnChange.current = params.onChange; + + const ref = useCallback((element: HTMLElement | null) => { + if (!element) { + if (observerRef.current) { + observerRef.current.disconnect(); + } + return; + } + + observerRef.current = new IntersectionObserver( + entries => { + entries.forEach(entry => { + const _inView = entry.isIntersecting && thresholds.some(threshold => entry.intersectionRatio >= threshold); + + setInView(_inView); + + if (internalOnChange.current) { + internalOnChange.current(_inView, entry); + } + }); + }, + { + root: params.root, + rootMargin: params.rootMargin, + threshold: thresholds, + }, + ); + + observerRef.current.observe(element); + }, []); + + return { + inView, + ref, + }; +}; diff --git a/packages/clerk-js/src/ui/hooks/useLoadingStatus.ts b/packages/clerk-js/src/ui/hooks/useLoadingStatus.ts index 03b842fe5a1..95f9aec950d 100644 --- a/packages/clerk-js/src/ui/hooks/useLoadingStatus.ts +++ b/packages/clerk-js/src/ui/hooks/useLoadingStatus.ts @@ -2,10 +2,11 @@ import { useSafeState } from './useSafeState'; type Status = 'idle' | 'loading' | 'error'; -export const useLoadingStatus = () => { +export const useLoadingStatus = (initialState?: { status: Status; metadata?: Metadata | undefined }) => { const [state, setState] = useSafeState<{ status: Status; metadata?: Metadata | undefined }>({ status: 'idle', metadata: undefined, + ...initialState, }); return { From 736b06f4e38f4f0c8f9dd49db2af0c67d79ce215 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Thu, 10 Aug 2023 09:47:53 +0300 Subject: [PATCH 10/11] chore(clerk-js): Fix minor discrepancies --- .../OtherOrganizationActions.tsx | 57 +++++++------------ .../__tests__/OrganizationSwitcher.test.tsx | 1 - packages/clerk-js/src/ui/hooks/useInView.ts | 26 +++++++-- 3 files changed, 43 insertions(+), 41 deletions(-) diff --git a/packages/clerk-js/src/ui/components/OrganizationSwitcher/OtherOrganizationActions.tsx b/packages/clerk-js/src/ui/components/OrganizationSwitcher/OtherOrganizationActions.tsx index 8405d7bdeb3..43a7e75923f 100644 --- a/packages/clerk-js/src/ui/components/OrganizationSwitcher/OtherOrganizationActions.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationSwitcher/OtherOrganizationActions.tsx @@ -27,13 +27,6 @@ type OrganizationActionListProps = { onOrganizationClick: (org: OrganizationResource) => unknown; }; -export interface IntersectionOptions extends IntersectionObserverInit { - /** Only trigger the inView callback once */ - triggerOnce?: boolean; - /** Call this function whenever the in view state changes */ - onChange?: (inView: boolean, entry: IntersectionObserverEntry) => void; -} - export const OrganizationActionList = (props: OrganizationActionListProps) => { const { onCreateOrganizationClick, onPersonalWorkspaceClick, onOrganizationClick } = props; const { organizationList, userInvitations } = useCoreOrganizationList({ @@ -46,7 +39,7 @@ export const OrganizationActionList = (props: OrganizationActionListProps) => { threshold: 0, onChange: inView => { if (inView) { - void userInvitations.fetchNext?.(); + userInvitations.fetchNext?.(); } }, }); @@ -119,20 +112,18 @@ export const OrganizationActionList = (props: OrganizationActionListProps) => { {(userInvitations.count ?? 0) > 0 && ( ({ - minHeight: 'unset', - height: t.space.$12, - padding: `${t.space.$3} ${t.space.$6}`, - display: 'flex', - alignItems: 'center', - }), - ]} + variant='smallRegular' + sx={t => ({ + minHeight: 'unset', + height: t.space.$12, + padding: `${t.space.$3} ${t.space.$6}`, + display: 'flex', + alignItems: 'center', + })} // Handle plurals localizationKey={localizationKeys( (userInvitations.count ?? 0) > 1 @@ -162,13 +153,11 @@ export const OrganizationActionList = (props: OrganizationActionListProps) => { {(userInvitations.hasNextPage || userInvitations.isFetching) && ( ({ - width: '100%', - height: t.space.$12, - position: 'relative', - }), - ]} + sx={t => ({ + width: '100%', + height: t.space.$12, + position: 'relative', + })} > { return ( ({ - minHeight: 'unset', - height: t.space.$12, - justifyContent: 'space-between', - padding: `0 ${t.space.$6}`, - }), - ]} + sx={t => ({ + minHeight: 'unset', + height: t.space.$12, + justifyContent: 'space-between', + padding: `0 ${t.space.$6}`, + })} > { expect(queryByText('OrgTwo')).toBeInTheDocument(); }); - it.todo('switches between active organizations when one is clicked'); it("switches between active organizations when one is clicked'", async () => { const { wrapper, props, fixtures } = await createFixtures(f => { f.withOrganizations(); diff --git a/packages/clerk-js/src/ui/hooks/useInView.ts b/packages/clerk-js/src/ui/hooks/useInView.ts index 07d2138dd63..f9e9258f443 100644 --- a/packages/clerk-js/src/ui/hooks/useInView.ts +++ b/packages/clerk-js/src/ui/hooks/useInView.ts @@ -1,16 +1,32 @@ -import React, { useCallback, useRef, useState } from 'react'; - -import type { IntersectionOptions } from '../components/OrganizationSwitcher/OtherOrganizationActions'; - +import { useCallback, useRef, useState } from 'react'; + +interface IntersectionOptions extends IntersectionObserverInit { + /** Only trigger the inView callback once */ + triggerOnce?: boolean; + /** Call this function whenever the in view state changes */ + onChange?: (inView: boolean, entry: IntersectionObserverEntry) => void; +} + +/** + * A custom React hook that provides the ability to track whether an element is in view + * based on the IntersectionObserver API. + * + * @param {IntersectionOptions} params - IntersectionObserver configuration options. + * @returns {{ + * inView: boolean, + * ref: (element: HTMLElement | null) => void + * }} An object containing the current inView status and a ref function to attach to the target element. + */ export const useInView = (params: IntersectionOptions) => { const [inView, setInView] = useState(false); const observerRef = useRef(null); const thresholds = Array.isArray(params.threshold) ? params.threshold : [params.threshold || 0]; - const internalOnChange = React.useRef(); + const internalOnChange = useRef(); internalOnChange.current = params.onChange; const ref = useCallback((element: HTMLElement | null) => { + // Callback refs are called with null to clear the value, so we rely on that to cleanup the observer. (ref: https://react.dev/learn/manipulating-the-dom-with-refs#how-to-manage-a-list-of-refs-using-a-ref-callback) if (!element) { if (observerRef.current) { observerRef.current.disconnect(); From 386ce2ef166cd5d39de9440b00b1bc8246ea1817 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Thu, 10 Aug 2023 12:06:59 +0300 Subject: [PATCH 11/11] chore(clerk-js): Split `OrganizationActionList` into smaller components --- .../OtherOrganizationActions.tsx | 238 ++---------------- .../UserInvitationList.tsx | 155 ++++++++++++ .../UserMembershipList.tsx | 73 ++++++ 3 files changed, 252 insertions(+), 214 deletions(-) create mode 100644 packages/clerk-js/src/ui/components/OrganizationSwitcher/UserInvitationList.tsx create mode 100644 packages/clerk-js/src/ui/components/OrganizationSwitcher/UserMembershipList.tsx diff --git a/packages/clerk-js/src/ui/components/OrganizationSwitcher/OtherOrganizationActions.tsx b/packages/clerk-js/src/ui/components/OrganizationSwitcher/OtherOrganizationActions.tsx index 43a7e75923f..1acae816509 100644 --- a/packages/clerk-js/src/ui/components/OrganizationSwitcher/OtherOrganizationActions.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationSwitcher/OtherOrganizationActions.tsx @@ -1,58 +1,27 @@ -import type { OrganizationResource, UserOrganizationInvitationResource } from '@clerk/types'; +import React from 'react'; -import { Plus, SwitchArrows } from '../../../ui/icons'; -import { - useCoreOrganization, - useCoreOrganizationList, - useCoreUser, - useOrganizationSwitcherContext, -} from '../../contexts'; -import { Box, Button, descriptors, Flex, localizationKeys, Spinner, Text } from '../../customizables'; -import { - Action, - OrganizationPreview, - PersonalWorkspacePreview, - PreviewButton, - SecondaryActions, - useCardState, - withCardStateProvider, -} from '../../elements'; -import { useInView } from '../../hooks'; -import { common } from '../../styledSystem'; -import { handleError } from '../../utils'; +import { Plus } from '../../../ui/icons'; +import { useCoreUser } from '../../contexts'; +import { descriptors, localizationKeys } from '../../customizables'; +import { Action, SecondaryActions } from '../../elements'; +import { UserInvitationList } from './UserInvitationList'; +import type { UserMembershipListProps } from './UserMembershipList'; +import { UserMembershipList } from './UserMembershipList'; -type OrganizationActionListProps = { +export interface OrganizationActionListProps extends UserMembershipListProps { onCreateOrganizationClick: React.MouseEventHandler; - onPersonalWorkspaceClick: React.MouseEventHandler; - onOrganizationClick: (org: OrganizationResource) => unknown; -}; - -export const OrganizationActionList = (props: OrganizationActionListProps) => { - const { onCreateOrganizationClick, onPersonalWorkspaceClick, onOrganizationClick } = props; - const { organizationList, userInvitations } = useCoreOrganizationList({ - userInvitations: { - infinite: true, - }, - }); - - const { ref } = useInView({ - threshold: 0, - onChange: inView => { - if (inView) { - userInvitations.fetchNext?.(); - } - }, - }); +} - const { organization: currentOrg } = useCoreOrganization(); +const CreateOrganizationButton = ({ + onCreateOrganizationClick, +}: Pick) => { const user = useCoreUser(); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { username, primaryEmailAddress, primaryPhoneNumber, ...userWithoutIdentifiers } = user; - const { hidePersonal } = useOrganizationSwitcherContext(); - const otherOrgs = (organizationList || []).map(e => e.organization).filter(o => o.id !== currentOrg?.id); + if (!user.createOrganizationEnabled) { + return null; + } - const createOrganizationButton = ( + return ( { onClick={onCreateOrganizationClick} /> ); - - return ( - - ({ - maxHeight: `calc(4 * ${t.sizes.$12})`, - overflowY: 'auto', - ...common.unstyledScrollbar(t), - })} - > - {currentOrg && !hidePersonal && ( - - ({ margin: `0 calc(${t.space.$3}/2)` })} - title={localizationKeys('organizationSwitcher.personalWorkspace')} - /> - - )} - {otherOrgs.map(organization => ( - onOrganizationClick(organization)} - > - ({ margin: `0 calc(${t.space.$3}/2)` })} - organization={organization} - size='sm' - /> - - ))} - - - {(userInvitations.count ?? 0) > 0 && ( - - ({ - minHeight: 'unset', - height: t.space.$12, - padding: `${t.space.$3} ${t.space.$6}`, - display: 'flex', - alignItems: 'center', - })} - // Handle plurals - localizationKey={localizationKeys( - (userInvitations.count ?? 0) > 1 - ? 'organizationSwitcher.invitationCountLabel_many' - : 'organizationSwitcher.invitationCountLabel_single', - { - count: userInvitations.count, - }, - )} - /> - ({ - maxHeight: `calc(4 * ${t.sizes.$12})`, - overflowY: 'auto', - ...common.unstyledScrollbar(t), - })} - > - {userInvitations?.data?.map(inv => { - return ( - - ); - })} - - {(userInvitations.hasNextPage || userInvitations.isFetching) && ( - ({ - width: '100%', - height: t.space.$12, - position: 'relative', - })} - > - - - - - )} - - - )} - {user.createOrganizationEnabled && createOrganizationButton} - - ); }; -const AcceptRejectInvitationButtons = (props: UserOrganizationInvitationResource) => { - const card = useCardState(); - const { userInvitations } = useCoreOrganizationList({ - userInvitations: { - infinite: true, - }, - }); - - const mutateSwrState = () => { - (userInvitations as any)?.unstable__mutate?.(); - }; - - const handleAccept = () => { - return card - .runAsync(props.accept()) - .then(mutateSwrState) - .catch(err => handleError(err, [], card.setError)); - }; +export const OrganizationActionList = (props: OrganizationActionListProps) => { + const { onCreateOrganizationClick, onPersonalWorkspaceClick, onOrganizationClick } = props; return ( - <> -