Skip to content

Commit

Permalink
feat(clerk-js): Reject a membership request as an organization admin (#…
Browse files Browse the repository at this point in the history
…1612)

* feat(clerk-js): Add reject method to OrganizationMembershipRequest

* feat(clerk-js): Organization admins can reject membership requests

* test(clerk-js): Update snapshot of OrganizationMembership class

* chore(clerk-js): Add changeset
  • Loading branch information
panteliselef authored Aug 21, 2023
1 parent 435d2cf commit e02a1af
Show file tree
Hide file tree
Showing 10 changed files with 121 additions and 80 deletions.
10 changes: 10 additions & 0 deletions .changeset/rotten-rules-argue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@clerk/localizations': patch
'@clerk/clerk-js': patch
'@clerk/types': patch
---

A OrganizationMembershipRequest can now be rejected

- New `OrganizationMembershipRequest.reject` method alongside `accept`
- As an organization admin, navigate to `Organization Profile` > `Members` > `Requests`. You can now reject a request from the table.
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ export class OrganizationMembershipRequest extends BaseResource implements Organ
});
};

reject = async (): Promise<OrganizationMembershipRequestResource> => {
return await this._basePost({
path: `/organizations/${this.organizationId}/membership_requests/${this.id}/reject`,
});
};

protected fromJSON(data: OrganizationMembershipRequestJSON | null): this {
if (data) {
this.id = data.id;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ OrganizationMembershipRequest {
"profileImageUrl": "test_url",
"userId": undefined,
},
"reject": [Function],
"status": "pending",
"updatedAt": 1970-01-01T00:00:05.678Z,
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,6 @@ const InvitationRow = (props: { invitation: OrganizationInvitationResource; onRe
<Td>
<UserPreview
sx={{ maxWidth: '30ch' }}
showAvatar={false}
user={{ primaryEmailAddress: { emailAddress: invitation.emailAddress } } as any}
/>
</Td>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { OrganizationMembershipRequestResource } from '@clerk/types';

import { useCoreOrganization } from '../../contexts';
import { Button, Flex, localizationKeys, Td } from '../../customizables';
import { useCardState, UserPreview } from '../../elements';
import { useCardState, UserPreview, withCardStateProvider } from '../../elements';
import { handleError } from '../../utils';
import { DataTable, RowContainer } from './MemberListTable';

Expand All @@ -15,82 +15,101 @@ export const RequestToJoinList = () => {
},
});

const mutateSwrState = () => {
const unstable__mutate = (membershipRequests as any).unstable__mutate;
if (unstable__mutate && typeof unstable__mutate === 'function') {
unstable__mutate();
}
};

if (!organization) {
return null;
}

const approve = (request: OrganizationMembershipRequestResource) => () => {
return card
.runAsync(request.accept)
.then(mutateSwrState)
.catch(err => handleError(err, [], card.setError));
};

return (
<DataTable
page={membershipRequests?.page || 1}
onPageChange={membershipRequests?.fetchPage ?? (() => null)}
itemCount={membershipRequests?.count ?? 0}
itemsPerPage={ITEMS_PER_PAGE}
isLoading={membershipRequests?.isFetching}
isLoading={membershipRequests?.isLoading}
emptyStateLocalizationKey={localizationKeys('organizationProfile.membersPage.requestsTab.table__emptyRow')}
headers={[
localizationKeys('organizationProfile.membersPage.activeMembersTab.tableHeader__user'),
localizationKeys('organizationProfile.membersPage.requestsTab.tableHeader__requested'),
localizationKeys('organizationProfile.membersPage.activeMembersTab.tableHeader__actions'),
]}
rows={(membershipRequests?.data || []).map(i => (
rows={(membershipRequests?.data || []).map(request => (
<RequestRow
key={i.id}
request={i}
onAccept={approve(i)}
key={request.id}
request={request}
onError={card.setError}
/>
))}
/>
);
};

const RequestRow = (props: { request: OrganizationMembershipRequestResource; onAccept: () => unknown }) => {
const { request, onAccept } = props;
const RequestRow = withCardStateProvider(
(props: { request: OrganizationMembershipRequestResource; onError: ReturnType<typeof useCardState>['setError'] }) => {
const { request, onError } = props;
const card = useCardState();
const { membershipRequests } = useCoreOrganization();

return (
<RowContainer>
<Td>
<UserPreview
sx={{ maxWidth: '30ch' }}
showAvatar={false}
user={{ primaryEmailAddress: { emailAddress: request.publicUserData.identifier } } as any}
/>
</Td>
<Td>{request.createdAt.toLocaleDateString()}</Td>
const mutateSwrState = () => {
const unstable__mutate = (membershipRequests as any).unstable__mutate;
if (unstable__mutate && typeof unstable__mutate === 'function') {
unstable__mutate();
}
};

<Td>
<Flex>
<AcceptRejectRequestButtons onAccept={onAccept} />
</Flex>
</Td>
</RowContainer>
);
};
const onAccept = () => {
return card
.runAsync(request.accept, 'accept')
.then(mutateSwrState)
.catch(err => handleError(err, [], onError));
};

const onReject = () => {
return card
.runAsync(request.reject, 'reject')
.then(mutateSwrState)
.catch(err => handleError(err, [], onError));
};

const AcceptRejectRequestButtons = (props: { onAccept: () => unknown }) => {
return (
<RowContainer>
<Td>
<UserPreview
sx={{ maxWidth: '30ch' }}
showAvatar={false}
user={{ primaryEmailAddress: { emailAddress: request.publicUserData.identifier } } as any}
/>
</Td>
<Td>{request.createdAt.toLocaleDateString()}</Td>

<Td>
<AcceptRejectRequestButtons {...{ onAccept, onReject }} />
</Td>
</RowContainer>
);
},
);

const AcceptRejectRequestButtons = (props: { onAccept: () => unknown; onReject: () => unknown }) => {
const card = useCardState();
return (
<>
<Flex gap={2}>
<Button
textVariant='buttonExtraSmallBold'
variant='ghost'
isLoading={card.isLoading && card.loadingMetadata === 'reject'}
isDisabled={card.isLoading && card.loadingMetadata !== 'reject'}
onClick={props.onReject}
localizationKey={localizationKeys('organizationProfile.membersPage.requestsTab.menuAction__reject')}
/>

<Button
textVariant='buttonExtraSmallBold'
variant='solid'
isLoading={card.isLoading}
isLoading={card.isLoading && card.loadingMetadata === 'accept'}
isDisabled={card.isLoading && card.loadingMetadata !== 'accept'}
onClick={props.onAccept}
localizationKey={localizationKeys('organizationProfile.membersPage.requestsTab.menuAction__approve')}
/>
</>
</Flex>
);
};
67 changes: 35 additions & 32 deletions packages/clerk-js/src/ui/elements/UserPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,39 +77,42 @@ export const UserPreview = (props: UserPreviewProps) => {
sx={[{ minWidth: '0px', width: '100%' }, sx]}
{...rest}
>
{showAvatar ? (
<Flex
elementDescriptor={descriptors.userPreviewAvatarContainer}
elementId={descriptors.userPreviewAvatarContainer.setId(elementId)}
justify='center'
sx={{ position: 'relative' }}
>
<UserAvatar
boxElementDescriptor={descriptors.userPreviewAvatarBox}
imageElementDescriptor={descriptors.userPreviewAvatarImage}
{...user}
{...externalAccount}
{...samlAccount}
name={name}
avatarUrl={imageUrl}
size={getAvatarSizes}
sx={avatarSx}
rounded={rounded}
/>
{/*Do not attempt to render or reserve space based on height if image url is not defined*/}
{imageUrl ? (
showAvatar ? (
<Flex
elementDescriptor={descriptors.userPreviewAvatarContainer}
elementId={descriptors.userPreviewAvatarContainer.setId(elementId)}
justify='center'
sx={{ position: 'relative' }}
>
<UserAvatar
boxElementDescriptor={descriptors.userPreviewAvatarBox}
imageElementDescriptor={descriptors.userPreviewAvatarImage}
{...user}
{...externalAccount}
{...samlAccount}
name={name}
avatarUrl={imageUrl}
size={getAvatarSizes}
sx={avatarSx}
rounded={rounded}
/>

{icon && <Flex sx={{ position: 'absolute', left: 0, bottom: 0 }}>{icon}</Flex>}
</Flex>
) : (
// Reserve layout space when avatar is not visible
<Flex
elementDescriptor={descriptors.userPreviewAvatarContainer}
elementId={descriptors.userPreviewAvatarContainer.setId(elementId)}
justify='center'
sx={t => ({
height: getAvatarSizes(t),
})}
/>
)}
{icon && <Flex sx={{ position: 'absolute', left: 0, bottom: 0 }}>{icon}</Flex>}
</Flex>
) : (
// Reserve layout space when avatar is not visible
<Flex
elementDescriptor={descriptors.userPreviewAvatarContainer}
elementId={descriptors.userPreviewAvatarContainer.setId(elementId)}
justify='center'
sx={t => ({
height: getAvatarSizes(t),
})}
/>
)
) : null}

<Flex
elementDescriptor={descriptors.userPreviewTextContainer}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,13 @@ const useCardState = () => {
const setIdle = (metadata?: Metadata) => setState(s => ({ ...s, status: 'idle', metadata }));
const setError = (metadata: ClerkAPIError | Metadata) => setState(s => ({ ...s, error: translateError(metadata) }));
const setLoading = (metadata?: Metadata) => setState(s => ({ ...s, status: 'loading', metadata }));
const runAsync = async <T = unknown,>(cb: Promise<T> | (() => Promise<T>)) => {
setLoading();
const runAsync = async <T = unknown,>(cb: Promise<T> | (() => Promise<T>), metadata?: Metadata) => {
setLoading(metadata);
return (typeof cb === 'function' ? cb() : cb)
.then(res => {
return res;
})
.finally(() => setIdle());
.finally(() => setIdle(metadata));
};

return {
Expand Down
1 change: 1 addition & 0 deletions packages/localizations/src/en-US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -657,6 +657,7 @@ export const enUS: LocalizationResource = {
requestsTab: {
tableHeader__requested: 'Requested access',
menuAction__approve: 'Approve',
menuAction__reject: 'Reject',
table__emptyRow: 'No requests to display',
requests: {
headerTitle: 'Requests',
Expand Down
1 change: 1 addition & 0 deletions packages/types/src/localization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -678,6 +678,7 @@ type _LocalizationResource = {
requestsTab: {
tableHeader__requested: LocalizationValue;
menuAction__approve: LocalizationValue;
menuAction__reject: LocalizationValue;
table__emptyRow: LocalizationValue;
requests: {
headerTitle: LocalizationValue;
Expand Down
1 change: 1 addition & 0 deletions packages/types/src/organizationMembershipRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,5 @@ export interface OrganizationMembershipRequestResource extends ClerkResource {
updatedAt: Date;

accept: () => Promise<OrganizationMembershipRequestResource>;
reject: () => Promise<OrganizationMembershipRequestResource>;
}

0 comments on commit e02a1af

Please sign in to comment.