Skip to content

Commit

Permalink
feat(clerk-js): List invitations in <OrganizationSwitcher /> (#1554)
Browse files Browse the repository at this point in the history
* feat(clerk-js): Load invitation with infinite scrolling

* chore(clerk-js): Custom useInView

* feat(localizations): New invitation Org switcher keys

* feat(clerk-js,types): Users can accept invitations within <OrganizationSwitcher/>

* fix(clerk-js,types): UserOrganizationInvitation nullish slug

* fix(clerk-js): Update OrganizationPreviewProps to accept only the public organization data

* test(clerk-js): Display list of invitation in OrganizationSwitcher

* chore(repo): Add changeset

+ Add default values in useLoadingStatus

* chore(clerk-js): Split `OrganizationActionList` into smaller components
  • Loading branch information
panteliselef authored Aug 10, 2023
1 parent 34da40a commit 09bfb79
Show file tree
Hide file tree
Showing 17 changed files with 428 additions and 70 deletions.
8 changes: 8 additions & 0 deletions .changeset/shaggy-terms-train.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@clerk/localizations': patch
'@clerk/clerk-js': patch
'@clerk/types': patch
---

Introduces an invitation list within <OrganizationSwitcher/>
+ Users can accept the invitation that is sent to them
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -12,6 +12,12 @@ const _OrganizationSwitcher = withFloatingTree(() => {
offset: 8,
});

useCoreOrganizationList({
userInvitations: {
infinite: true,
},
});

return (
<Flow.Root flow='organizationSwitcher'>
<OrganizationSwitcherTrigger
Expand Down
Original file line number Diff line number Diff line change
@@ -1,35 +1,27 @@
import type { OrganizationResource } from '@clerk/types';
import React from 'react';

import { Plus, SwitchArrows } from '../../../ui/icons';
import {
useCoreOrganization,
useCoreOrganizationList,
useCoreUser,
useOrganizationSwitcherContext,
} from '../../contexts';
import { Box, descriptors, localizationKeys } from '../../customizables';
import { Action, OrganizationPreview, PersonalWorkspacePreview, PreviewButton, SecondaryActions } from '../../elements';
import { common } from '../../styledSystem';
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 } = useCoreOrganizationList();
const { organization: currentOrg } = useCoreOrganization();
const CreateOrganizationButton = ({
onCreateOrganizationClick,
}: Pick<OrganizationActionListProps, 'onCreateOrganizationClick'>) => {
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 (
<Action
elementDescriptor={descriptors.organizationSwitcherPopoverActionButton}
elementId={descriptors.organizationSwitcherPopoverActionButton.setId('createOrganization')}
Expand All @@ -44,49 +36,16 @@ export const OrganizationActionList = (props: OrganizationActionListProps) => {
onClick={onCreateOrganizationClick}
/>
);
};

export const OrganizationActionList = (props: OrganizationActionListProps) => {
const { onCreateOrganizationClick, onPersonalWorkspaceClick, onOrganizationClick } = props;

return (
<SecondaryActions elementDescriptor={descriptors.organizationSwitcherPopoverActions}>
<Box
sx={t => ({
maxHeight: `calc(4 * ${t.sizes.$12})`,
overflowY: 'auto',
...common.unstyledScrollbar(t),
})}
>
{currentOrg && !hidePersonal && (
<PreviewButton
elementDescriptor={descriptors.organizationSwitcherPreviewButton}
icon={SwitchArrows}
sx={{ borderRadius: 0 }}
onClick={onPersonalWorkspaceClick}
>
<PersonalWorkspacePreview
user={userWithoutIdentifiers}
size='sm'
avatarSx={t => ({ margin: `0 calc(${t.space.$3}/2)` })}
title={localizationKeys('organizationSwitcher.personalWorkspace')}
/>
</PreviewButton>
)}
{otherOrgs.map(organization => (
<PreviewButton
key={organization.id}
elementDescriptor={descriptors.organizationSwitcherPreviewButton}
icon={SwitchArrows}
sx={{ borderRadius: 0 }}
onClick={() => onOrganizationClick(organization)}
>
<OrganizationPreview
elementId={'organizationSwitcher'}
avatarSx={t => ({ margin: `0 calc(${t.space.$3}/2)` })}
organization={organization}
size='sm'
/>
</PreviewButton>
))}
</Box>
{user.createOrganizationEnabled && createOrganizationButton}
<UserMembershipList {...{ onPersonalWorkspaceClick, onOrganizationClick }} />
<UserInvitationList />
<CreateOrganizationButton {...{ onCreateOrganizationClick }} />
</SecondaryActions>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import type { UserOrganizationInvitationResource } from '@clerk/types';

import { useCoreOrganizationList } from '../../contexts';
import { Box, Button, descriptors, Flex, localizationKeys, Spinner, Text } from '../../customizables';
import { OrganizationPreview, useCardState, withCardStateProvider } from '../../elements';
import { useInView } from '../../hooks';
import { common } from '../../styledSystem';
import { handleError } from '../../utils';

export const UserInvitationList = () => {
const { ref } = useInView({
threshold: 0,
onChange: inView => {
if (inView) {
userInvitations.fetchNext?.();
}
},
});

const { userInvitations } = useCoreOrganizationList({
userInvitations: {
infinite: true,
},
});

if ((userInvitations.count ?? 0) === 0) {
return null;
}

return (
<Flex
direction='col'
elementDescriptor={descriptors.organizationSwitcherPopoverInvitationActions}
>
<Text
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
? 'organizationSwitcher.invitationCountLabel_many'
: 'organizationSwitcher.invitationCountLabel_single',
{
count: userInvitations.count,
},
)}
/>
<Box
sx={t => ({
maxHeight: `calc(4 * ${t.sizes.$12})`,
overflowY: 'auto',
...common.unstyledScrollbar(t),
})}
>
{userInvitations?.data?.map(inv => {
return (
<InvitationPreview
key={inv.id}
{...inv}
/>
);
})}

{(userInvitations.hasNextPage || userInvitations.isFetching) && (
<Box
ref={ref}
sx={t => ({
width: '100%',
height: t.space.$12,
position: 'relative',
})}
>
<Box
sx={{
margin: 'auto',
position: 'absolute',
left: '50%',
top: '50%',
transform: 'translateY(-50%) translateX(-50%)',
}}
>
<Spinner
size='md'
colorScheme='primary'
/>
</Box>
</Box>
)}
</Box>
</Flex>
);
};

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 (
<>
<Button
elementDescriptor={descriptors.organizationSwitcherInvitationAcceptButton}
textVariant='buttonExtraSmallBold'
variant='solid'
isLoading={card.isLoading}
onClick={handleAccept}
localizationKey={localizationKeys('organizationSwitcher.invitationAccept')}
/>
</>
);
};

const InvitationPreview = withCardStateProvider((props: UserOrganizationInvitationResource) => {
return (
<Flex
align='center'
gap={2}
sx={t => ({
minHeight: 'unset',
height: t.space.$12,
justifyContent: 'space-between',
padding: `0 ${t.space.$6}`,
})}
>
<OrganizationPreview
elementId='organizationSwitcher'
avatarSx={t => ({ margin: `0 calc(${t.space.$3}/2)` })}
organization={props.publicOrganizationData}
size='sm'
/>

<AcceptRejectInvitationButtons {...props} />
</Flex>
);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import type { OrganizationResource } from '@clerk/types';
import React from 'react';

import {
useCoreOrganization,
useCoreOrganizationList,
useCoreUser,
useOrganizationSwitcherContext,
} from '../../contexts';
import { Box, descriptors, localizationKeys } from '../../customizables';
import { OrganizationPreview, PersonalWorkspacePreview, PreviewButton } from '../../elements';
import { SwitchArrows } from '../../icons';
import { common } from '../../styledSystem';

export type UserMembershipListProps = {
onPersonalWorkspaceClick: React.MouseEventHandler;
onOrganizationClick: (org: OrganizationResource) => unknown;
};
export const UserMembershipList = (props: UserMembershipListProps) => {
const { onPersonalWorkspaceClick, onOrganizationClick } = props;

const { hidePersonal } = useOrganizationSwitcherContext();
const { organization: currentOrg } = useCoreOrganization();
const { organizationList } = useCoreOrganizationList();
const user = useCoreUser();

const otherOrgs = (organizationList || []).map(e => e.organization).filter(o => o.id !== currentOrg?.id);

// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { username, primaryEmailAddress, primaryPhoneNumber, ...userWithoutIdentifiers } = user;

return (
<Box
sx={t => ({
maxHeight: `calc(4 * ${t.sizes.$12})`,
overflowY: 'auto',
...common.unstyledScrollbar(t),
})}
>
{currentOrg && !hidePersonal && (
<PreviewButton
elementDescriptor={descriptors.organizationSwitcherPreviewButton}
icon={SwitchArrows}
sx={{ borderRadius: 0 }}
onClick={onPersonalWorkspaceClick}
>
<PersonalWorkspacePreview
user={userWithoutIdentifiers}
size='sm'
avatarSx={t => ({ margin: `0 calc(${t.space.$3}/2)` })}
title={localizationKeys('organizationSwitcher.personalWorkspace')}
/>
</PreviewButton>
)}
{otherOrgs.map(organization => (
<PreviewButton
key={organization.id}
elementDescriptor={descriptors.organizationSwitcherPreviewButton}
icon={SwitchArrows}
sx={{ borderRadius: 0 }}
onClick={() => onOrganizationClick(organization)}
>
<OrganizationPreview
elementId='organizationSwitcher'
avatarSx={t => ({ margin: `0 calc(${t.space.$3}/2)` })}
organization={organization}
size='sm'
/>
</PreviewButton>
))}
</Box>
);
};
Loading

0 comments on commit 09bfb79

Please sign in to comment.