Skip to content

Commit a26cf0f

Browse files
authored
feat(clerk-js,types,localizations): Search members on OrganizationProfile (#4942)
1 parent 9dc8a67 commit a26cf0f

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

49 files changed

+243
-33
lines changed

.changeset/poor-rockets-look.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@clerk/localizations': patch
3+
'@clerk/clerk-js': patch
4+
'@clerk/shared': patch
5+
'@clerk/types': patch
6+
---
7+
8+
Introduced searching for members list on `OrganizationProfile`

packages/clerk-js/src/ui/common/NotificationCountBadge.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,21 @@ import { animations } from '../styledSystem';
77
type NotificationCountBadgeProps = PropsOfComponent<typeof NotificationBadge> & {
88
notificationCount: number;
99
containerSx?: ThemableCssProp;
10+
shouldAnimate?: boolean;
1011
};
1112

1213
export const NotificationCountBadge = (props: NotificationCountBadgeProps) => {
13-
const { notificationCount, containerSx, ...restProps } = props;
14+
const { notificationCount, containerSx, shouldAnimate = true, ...restProps } = props;
1415
const prefersReducedMotion = usePrefersReducedMotion();
1516
const { t } = useLocalizations();
1617
const localeKey = t(localizationKeys('locale'));
1718
const formattedNotificationCount = formatToCompactNumber(notificationCount, localeKey);
1819

1920
const enterExitAnimation: ThemableCssProp = t => ({
20-
animation: prefersReducedMotion
21-
? 'none'
22-
: `${animations.notificationAnimation} ${t.transitionDuration.$textField} ${t.transitionTiming.$slowBezier} 0s 1 normal forwards`,
21+
animation:
22+
shouldAnimate && !prefersReducedMotion
23+
? `${animations.notificationAnimation} ${t.transitionDuration.$textField} ${t.transitionTiming.$slowBezier} 0s 1 normal forwards`
24+
: 'none',
2325
});
2426

2527
return (

packages/clerk-js/src/ui/components/OrganizationProfile/ActiveMembersList.tsx

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,14 @@ import { useFetchRoles, useLocalizeCustomRoles } from '../../hooks/useFetchRoles
88
import { handleError } from '../../utils';
99
import { DataTable, RoleSelect, RowContainer } from './MemberListTable';
1010

11-
const membershipsParams = {
12-
memberships: {
13-
pageSize: 10,
14-
keepPreviousData: true,
15-
},
11+
type ActiveMembersListProps = {
12+
memberships: ReturnType<typeof useOrganization>['memberships'];
13+
pageSize: number;
1614
};
1715

18-
export const ActiveMembersList = () => {
16+
export const ActiveMembersList = ({ memberships, pageSize }: ActiveMembersListProps) => {
1917
const card = useCardState();
20-
const { organization, memberships } = useOrganization(membershipsParams);
18+
const { organization } = useOrganization();
2119

2220
const { options, isLoading: loadingRoles } = useFetchRoles();
2321

@@ -44,8 +42,8 @@ export const ActiveMembersList = () => {
4442
onPageChange={n => memberships?.fetchPage?.(n)}
4543
itemCount={memberships?.count || 0}
4644
pageCount={memberships?.pageCount || 0}
47-
itemsPerPage={membershipsParams.memberships.pageSize}
48-
isLoading={memberships?.isLoading || loadingRoles}
45+
itemsPerPage={pageSize}
46+
isLoading={(memberships?.isLoading && !memberships?.data.length) || loadingRoles}
4947
emptyStateLocalizationKey={localizationKeys('organizationProfile.membersPage.detailsTitle__emptyRow')}
5048
headers={[
5149
localizationKeys('organizationProfile.membersPage.activeMembersTab.tableHeader__user'),

packages/clerk-js/src/ui/components/OrganizationProfile/MembersActions.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,31 @@ import { Animated } from '../../elements';
44
import { Action } from '../../elements/Action';
55
import { InviteMembersScreen } from './InviteMembersScreen';
66

7-
export const MembersActionsRow = () => {
7+
type MembersActionsRowProps = {
8+
actionSlot?: React.ReactNode;
9+
};
10+
11+
export const MembersActionsRow = ({ actionSlot }: MembersActionsRowProps) => {
812
const canManageMemberships = useProtect({ permission: 'org:sys_memberships:manage' });
913

1014
return (
1115
<Action.Root animate={false}>
1216
<Animated asChild>
1317
<Flex
14-
justify='end'
18+
justify={actionSlot ? 'between' : 'end'}
1519
sx={t => ({
1620
width: '100%',
1721
marginLeft: 'auto',
1822
padding: `${t.space.$none} ${t.space.$1}`,
1923
})}
24+
gap={actionSlot ? 2 : undefined}
2025
>
26+
{actionSlot}
2127
{canManageMemberships && (
22-
<Action.Trigger value='invite'>
28+
<Action.Trigger
29+
value='invite'
30+
hideOnActive={!actionSlot}
31+
>
2332
<Button
2433
elementDescriptor={descriptors.membersPageInviteButton}
2534
aria-label='Invite'
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import type { useOrganization } from '@clerk/shared/react';
2+
import type { GetMembersParams } from '@clerk/types';
3+
import { useEffect, useRef } from 'react';
4+
5+
import { descriptors, Flex, Icon, localizationKeys, useLocalizations } from '../../../ui/customizables';
6+
import { Animated, InputWithIcon } from '../../../ui/elements';
7+
import { MagnifyingGlass } from '../../../ui/icons';
8+
import { Spinner } from '../../../ui/primitives';
9+
import { ACTIVE_MEMBERS_PAGE_SIZE } from './OrganizationMembers';
10+
11+
type MembersSearchProps = {
12+
/**
13+
* Controlled query param state by parent component
14+
*/
15+
query: GetMembersParams['query'];
16+
/**
17+
* Controlled input field value by parent component
18+
*/
19+
value: string;
20+
/**
21+
* Paginated organization memberships
22+
*/
23+
memberships: ReturnType<typeof useOrganization>['memberships'];
24+
/**
25+
* Handler for change event on input field
26+
*/
27+
onSearchChange: (value: string) => void;
28+
/**
29+
* Handler for `query` value changes
30+
*/
31+
onQueryTrigger: (query: string) => void;
32+
};
33+
34+
const membersSearchDebounceMs = 500;
35+
36+
export const MembersSearch = ({ query, value, memberships, onSearchChange, onQueryTrigger }: MembersSearchProps) => {
37+
const { t } = useLocalizations();
38+
39+
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
40+
41+
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
42+
const eventValue = event.target.value;
43+
onSearchChange(eventValue);
44+
45+
const shouldClearQuery = eventValue === '';
46+
if (shouldClearQuery) {
47+
onQueryTrigger(eventValue);
48+
}
49+
};
50+
51+
// Debounce the input value changes until the user stops typing
52+
// to trigger the `query` param setter
53+
function handleKeyUp() {
54+
if (debounceTimer.current) {
55+
clearTimeout(debounceTimer.current);
56+
}
57+
58+
debounceTimer.current = setTimeout(() => {
59+
onQueryTrigger(value.trim());
60+
}, membersSearchDebounceMs);
61+
}
62+
63+
// If search is not performed on a initial page, resets pagination offset
64+
// based on the response count
65+
useEffect(() => {
66+
if (!query || !memberships?.data) {
67+
return;
68+
}
69+
70+
const hasOnePageLeft = (memberships?.count ?? 0) <= ACTIVE_MEMBERS_PAGE_SIZE;
71+
if (hasOnePageLeft) {
72+
memberships?.fetchPage?.(1);
73+
}
74+
}, [query, memberships]);
75+
76+
const isFetchingNewData = value && !!memberships?.isLoading && !!memberships.data?.length;
77+
78+
return (
79+
<Animated asChild>
80+
<Flex sx={{ width: '100%' }}>
81+
<InputWithIcon
82+
value={value}
83+
type='search'
84+
autoCapitalize='none'
85+
spellCheck={false}
86+
aria-label='Search'
87+
placeholder={t(localizationKeys('organizationProfile.membersPage.action__search'))}
88+
leftIcon={
89+
isFetchingNewData ? (
90+
<Spinner size='xs' />
91+
) : (
92+
<Icon
93+
icon={MagnifyingGlass}
94+
elementDescriptor={descriptors.organizationProfileMembersSearchInputIcon}
95+
/>
96+
)
97+
}
98+
onKeyUp={handleKeyUp}
99+
onChange={handleChange}
100+
elementDescriptor={descriptors.organizationProfileMembersSearchInput}
101+
/>
102+
</Flex>
103+
</Animated>
104+
);
105+
};

packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationMembers.tsx

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useOrganization } from '@clerk/shared/react';
2+
import { useState } from 'react';
23

34
import { NotificationCountBadge, useProtect } from '../../common';
45
import { useEnvironment, useOrganizationProfileContext } from '../../contexts';
@@ -20,19 +21,31 @@ import { mqu } from '../../styledSystem';
2021
import { ActiveMembersList } from './ActiveMembersList';
2122
import { MembersActionsRow } from './MembersActions';
2223
import { MembershipWidget } from './MembershipWidget';
24+
import { MembersSearch } from './MembersSearch';
2325
import { OrganizationMembersTabInvitations } from './OrganizationMembersTabInvitations';
2426
import { OrganizationMembersTabRequests } from './OrganizationMembersTabRequests';
2527

28+
export const ACTIVE_MEMBERS_PAGE_SIZE = 10;
29+
2630
export const OrganizationMembers = withCardStateProvider(() => {
2731
const { organizationSettings } = useEnvironment();
2832
const card = useCardState();
2933
const canManageMemberships = useProtect({ permission: 'org:sys_memberships:manage' });
3034
const canReadMemberships = useProtect({ permission: 'org:sys_memberships:read' });
3135
const isDomainsEnabled = organizationSettings?.domains?.enabled && canManageMemberships;
36+
37+
const [query, setQuery] = useState('');
38+
const [search, setSearch] = useState('');
39+
3240
const { membershipRequests, memberships, invitations } = useOrganization({
3341
membershipRequests: isDomainsEnabled || undefined,
3442
invitations: canManageMemberships || undefined,
35-
memberships: canReadMemberships || undefined,
43+
memberships: canReadMemberships
44+
? {
45+
keepPreviousData: true,
46+
query: query || undefined,
47+
}
48+
: undefined,
3649
});
3750

3851
// @ts-expect-error This property is not typed. It is used by our dashboard in order to render a billing widget.
@@ -74,8 +87,9 @@ export const OrganizationMembers = withCardStateProvider(() => {
7487
<TabsList sx={t => ({ gap: t.space.$2 })}>
7588
{canReadMemberships && (
7689
<Tab localizationKey={localizationKeys('organizationProfile.membersPage.start.headerTitle__members')}>
77-
{memberships?.data && !memberships.isLoading && (
90+
{!!memberships?.count && (
7891
<NotificationCountBadge
92+
shouldAnimate={!query}
7993
notificationCount={memberships.count}
8094
colorScheme='outline'
8195
/>
@@ -123,8 +137,21 @@ export const OrganizationMembers = withCardStateProvider(() => {
123137
width: '100%',
124138
}}
125139
>
126-
<MembersActionsRow />
127-
<ActiveMembersList />
140+
<MembersActionsRow
141+
actionSlot={
142+
<MembersSearch
143+
query={query}
144+
value={search}
145+
memberships={memberships}
146+
onSearchChange={query => setSearch(query)}
147+
onQueryTrigger={query => setQuery(query)}
148+
/>
149+
}
150+
/>
151+
<ActiveMembersList
152+
pageSize={ACTIVE_MEMBERS_PAGE_SIZE}
153+
memberships={memberships}
154+
/>
128155
</Flex>
129156
</Flex>
130157
</TabPanel>

packages/clerk-js/src/ui/components/OrganizationProfile/__tests__/OrganizationMembers.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -518,7 +518,7 @@ describe('OrganizationMembers', () => {
518518
await waitFor(async () =>
519519
expect(await findByRole('heading', { name: /invite new members/i })).toBeInTheDocument(),
520520
);
521-
expect(inviteButton).not.toBeInTheDocument();
521+
expect(inviteButton).toBeInTheDocument();
522522
await userEvent.click(getByRole('button', { name: 'Cancel' }));
523523

524524
await waitFor(async () => expect(await findByRole('button', { name: 'Invite' })).toBeInTheDocument());

packages/clerk-js/src/ui/customizables/elementDescriptors.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,9 @@ export const APPEARANCE_KEYS = containsAllElementsConfigKeys([
155155
'organizationSwitcherPopoverActionButtonIcon',
156156
'organizationSwitcherPopoverFooter',
157157

158+
'organizationProfileMembersSearchInputIcon',
159+
'organizationProfileMembersSearchInput',
160+
158161
'organizationListPreviewItems',
159162
'organizationListPreviewItem',
160163
'organizationListPreviewButton',

packages/clerk-js/src/ui/elements/Action/ActionTrigger.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,19 @@ import { useActionContext } from './ActionRoot';
44

55
type ActionTriggerProps = PropsWithChildren<{
66
value: string;
7+
hideOnActive?: boolean;
78
}>;
89

910
export const ActionTrigger = (props: ActionTriggerProps) => {
10-
const { children, value } = props;
11+
const { children, value, hideOnActive = true } = props;
1112
const { active, open } = useActionContext();
1213

1314
const validChildren = Children.only(children);
1415
if (!isValidElement(validChildren)) {
1516
throw new Error('Children of ActionTrigger must be a valid element');
1617
}
1718

18-
if (active === value) {
19+
if (hideOnActive && active === value) {
1920
return null;
2021
}
2122

packages/clerk-js/src/ui/elements/InputWithIcon.tsx

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import React from 'react';
22

3-
import { Flex, Input } from '../customizables';
3+
import { Box, Flex, Input } from '../customizables';
44
import type { PropsOfComponent } from '../styledSystem';
55

66
type InputWithIcon = PropsOfComponent<typeof Input> & { leftIcon?: React.ReactElement };
@@ -10,18 +10,33 @@ export const InputWithIcon = React.forwardRef<HTMLInputElement, InputWithIcon>((
1010
return (
1111
<Flex
1212
center
13-
sx={theme => ({
13+
sx={{
1414
width: '100%',
1515
position: 'relative',
16-
'& .cl-internal-icon': {
17-
position: 'absolute',
18-
left: theme.space.$4,
19-
width: theme.sizes.$3x5,
20-
height: theme.sizes.$3x5,
21-
},
22-
})}
16+
}}
2317
>
24-
{leftIcon && React.cloneElement(leftIcon, { className: 'cl-internal-icon' })}
18+
{leftIcon ? (
19+
<Box
20+
sx={theme => [
21+
{
22+
position: 'absolute',
23+
left: theme.space.$3x5,
24+
width: theme.sizes.$3x5,
25+
height: theme.sizes.$3x5,
26+
pointerEvents: 'none',
27+
display: 'grid',
28+
placeContent: 'center',
29+
'& svg': {
30+
position: 'absolute',
31+
width: '100%',
32+
height: '100%',
33+
},
34+
},
35+
]}
36+
>
37+
{leftIcon}
38+
</Box>
39+
) : null}
2540
<Input
2641
{...rest}
2742
sx={[

0 commit comments

Comments
 (0)