Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/poor-rockets-look.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@clerk/localizations': patch
'@clerk/clerk-js': patch
'@clerk/shared': patch
'@clerk/types': patch
---

Introduced searching for members list on `OrganizationProfile`
10 changes: 6 additions & 4 deletions packages/clerk-js/src/ui/common/NotificationCountBadge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,21 @@ import { animations } from '../styledSystem';
type NotificationCountBadgeProps = PropsOfComponent<typeof NotificationBadge> & {
notificationCount: number;
containerSx?: ThemableCssProp;
shouldAnimate?: boolean;
};

export const NotificationCountBadge = (props: NotificationCountBadgeProps) => {
const { notificationCount, containerSx, ...restProps } = props;
const { notificationCount, containerSx, shouldAnimate = true, ...restProps } = props;
const prefersReducedMotion = usePrefersReducedMotion();
const { t } = useLocalizations();
const localeKey = t(localizationKeys('locale'));
const formattedNotificationCount = formatToCompactNumber(notificationCount, localeKey);

const enterExitAnimation: ThemableCssProp = t => ({
animation: prefersReducedMotion
? 'none'
: `${animations.notificationAnimation} ${t.transitionDuration.$textField} ${t.transitionTiming.$slowBezier} 0s 1 normal forwards`,
animation:
shouldAnimate && !prefersReducedMotion
? `${animations.notificationAnimation} ${t.transitionDuration.$textField} ${t.transitionTiming.$slowBezier} 0s 1 normal forwards`
: 'none',
});

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,14 @@ import { useFetchRoles, useLocalizeCustomRoles } from '../../hooks/useFetchRoles
import { handleError } from '../../utils';
import { DataTable, RoleSelect, RowContainer } from './MemberListTable';

const membershipsParams = {
memberships: {
pageSize: 10,
keepPreviousData: true,
},
type ActiveMembersListProps = {
memberships: ReturnType<typeof useOrganization>['memberships'];
pageSize: number;
};

export const ActiveMembersList = () => {
export const ActiveMembersList = ({ memberships, pageSize }: ActiveMembersListProps) => {
const card = useCardState();
const { organization, memberships } = useOrganization(membershipsParams);
const { organization } = useOrganization();

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

Expand All @@ -44,8 +42,8 @@ export const ActiveMembersList = () => {
onPageChange={n => memberships?.fetchPage?.(n)}
itemCount={memberships?.count || 0}
pageCount={memberships?.pageCount || 0}
itemsPerPage={membershipsParams.memberships.pageSize}
isLoading={memberships?.isLoading || loadingRoles}
itemsPerPage={pageSize}
isLoading={(memberships?.isLoading && !memberships?.data.length) || loadingRoles}
emptyStateLocalizationKey={localizationKeys('organizationProfile.membersPage.detailsTitle__emptyRow')}
headers={[
localizationKeys('organizationProfile.membersPage.activeMembersTab.tableHeader__user'),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,31 @@ import { Animated } from '../../elements';
import { Action } from '../../elements/Action';
import { InviteMembersScreen } from './InviteMembersScreen';

export const MembersActionsRow = () => {
type MembersActionsRowProps = {
actionSlot?: React.ReactNode;
};

export const MembersActionsRow = ({ actionSlot }: MembersActionsRowProps) => {
const canManageMemberships = useProtect({ permission: 'org:sys_memberships:manage' });

return (
<Action.Root animate={false}>
<Animated asChild>
<Flex
justify='end'
justify={actionSlot ? 'between' : 'end'}
sx={t => ({
width: '100%',
marginLeft: 'auto',
padding: `${t.space.$none} ${t.space.$1}`,
})}
gap={actionSlot ? 2 : undefined}
>
{actionSlot}
{canManageMemberships && (
<Action.Trigger value='invite'>
<Action.Trigger
value='invite'
hideOnActive={!actionSlot}
>
<Button
elementDescriptor={descriptors.membersPageInviteButton}
aria-label='Invite'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import type { useOrganization } from '@clerk/shared/react';
import type { GetMembersParams } from '@clerk/types';
import { useEffect, useRef } from 'react';

import { descriptors, Flex, Icon, localizationKeys, useLocalizations } from '../../../ui/customizables';
import { Animated, InputWithIcon } from '../../../ui/elements';
import { MagnifyingGlass } from '../../../ui/icons';
import { Spinner } from '../../../ui/primitives';
import { ACTIVE_MEMBERS_PAGE_SIZE } from './OrganizationMembers';

type MembersSearchProps = {
/**
* Controlled query param state by parent component
*/
query: GetMembersParams['query'];
/**
* Controlled input field value by parent component
*/
value: string;
/**
* Paginated organization memberships
*/
memberships: ReturnType<typeof useOrganization>['memberships'];
/**
* Handler for change event on input field
*/
onSearchChange: (value: string) => void;
/**
* Handler for `query` value changes
*/
onQueryTrigger: (query: string) => void;
};

const membersSearchDebounceMs = 500;

export const MembersSearch = ({ query, value, memberships, onSearchChange, onQueryTrigger }: MembersSearchProps) => {
const { t } = useLocalizations();

const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);

const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const eventValue = event.target.value;
onSearchChange(eventValue);

const shouldClearQuery = eventValue === '';
if (shouldClearQuery) {
onQueryTrigger(eventValue);
}
};

// Debounce the input value changes until the user stops typing
// to trigger the `query` param setter
function handleKeyUp() {
if (debounceTimer.current) {
clearTimeout(debounceTimer.current);
}

debounceTimer.current = setTimeout(() => {
onQueryTrigger(value.trim());
}, membersSearchDebounceMs);
}

// If search is not performed on a initial page, resets pagination offset
// based on the response count
useEffect(() => {
if (!query || !memberships?.data) {
return;
}

const hasOnePageLeft = (memberships?.count ?? 0) <= ACTIVE_MEMBERS_PAGE_SIZE;
if (hasOnePageLeft) {
memberships?.fetchPage?.(1);
}
}, [query, memberships]);

const isFetchingNewData = value && !!memberships?.isLoading && !!memberships.data?.length;

return (
<Animated asChild>
<Flex sx={{ width: '100%' }}>
<InputWithIcon
value={value}
type='search'
autoCapitalize='none'
spellCheck={false}
aria-label='Search'
placeholder={t(localizationKeys('organizationProfile.membersPage.action__search'))}
leftIcon={
isFetchingNewData ? (
<Spinner size='xs' />
) : (
<Icon
icon={MagnifyingGlass}
elementDescriptor={descriptors.organizationProfileMembersSearchInputIcon}
/>
)
}
onKeyUp={handleKeyUp}
onChange={handleChange}
elementDescriptor={descriptors.organizationProfileMembersSearchInput}
/>
</Flex>
</Animated>
);
};
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useOrganization } from '@clerk/shared/react';
import { useState } from 'react';

import { NotificationCountBadge, useProtect } from '../../common';
import { useEnvironment, useOrganizationProfileContext } from '../../contexts';
Expand All @@ -20,19 +21,31 @@ import { mqu } from '../../styledSystem';
import { ActiveMembersList } from './ActiveMembersList';
import { MembersActionsRow } from './MembersActions';
import { MembershipWidget } from './MembershipWidget';
import { MembersSearch } from './MembersSearch';
import { OrganizationMembersTabInvitations } from './OrganizationMembersTabInvitations';
import { OrganizationMembersTabRequests } from './OrganizationMembersTabRequests';

export const ACTIVE_MEMBERS_PAGE_SIZE = 10;

export const OrganizationMembers = withCardStateProvider(() => {
const { organizationSettings } = useEnvironment();
const card = useCardState();
const canManageMemberships = useProtect({ permission: 'org:sys_memberships:manage' });
const canReadMemberships = useProtect({ permission: 'org:sys_memberships:read' });
const isDomainsEnabled = organizationSettings?.domains?.enabled && canManageMemberships;

const [query, setQuery] = useState('');
const [search, setSearch] = useState('');

const { membershipRequests, memberships, invitations } = useOrganization({
membershipRequests: isDomainsEnabled || undefined,
invitations: canManageMemberships || undefined,
memberships: canReadMemberships || undefined,
memberships: canReadMemberships
? {
keepPreviousData: true,
query: query || undefined,
}
: undefined,
});

// @ts-expect-error This property is not typed. It is used by our dashboard in order to render a billing widget.
Expand Down Expand Up @@ -74,8 +87,9 @@ export const OrganizationMembers = withCardStateProvider(() => {
<TabsList sx={t => ({ gap: t.space.$2 })}>
{canReadMemberships && (
<Tab localizationKey={localizationKeys('organizationProfile.membersPage.start.headerTitle__members')}>
{memberships?.data && !memberships.isLoading && (
{!!memberships?.count && (
<NotificationCountBadge
shouldAnimate={!query}
notificationCount={memberships.count}
colorScheme='outline'
/>
Expand Down Expand Up @@ -123,8 +137,21 @@ export const OrganizationMembers = withCardStateProvider(() => {
width: '100%',
}}
>
<MembersActionsRow />
<ActiveMembersList />
<MembersActionsRow
actionSlot={
<MembersSearch
query={query}
value={search}
memberships={memberships}
onSearchChange={query => setSearch(query)}
onQueryTrigger={query => setQuery(query)}
/>
}
/>
<ActiveMembersList
pageSize={ACTIVE_MEMBERS_PAGE_SIZE}
memberships={memberships}
/>
</Flex>
</Flex>
</TabPanel>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -518,7 +518,7 @@ describe('OrganizationMembers', () => {
await waitFor(async () =>
expect(await findByRole('heading', { name: /invite new members/i })).toBeInTheDocument(),
);
expect(inviteButton).not.toBeInTheDocument();
expect(inviteButton).toBeInTheDocument();
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

await userEvent.click(getByRole('button', { name: 'Cancel' }));

await waitFor(async () => expect(await findByRole('button', { name: 'Invite' })).toBeInTheDocument());
Expand Down
3 changes: 3 additions & 0 deletions packages/clerk-js/src/ui/customizables/elementDescriptors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,9 @@ export const APPEARANCE_KEYS = containsAllElementsConfigKeys([
'organizationSwitcherPopoverActionButtonIcon',
'organizationSwitcherPopoverFooter',

'organizationProfileMembersSearchInputIcon',
'organizationProfileMembersSearchInput',

'organizationListPreviewItems',
'organizationListPreviewItem',
'organizationListPreviewButton',
Expand Down
5 changes: 3 additions & 2 deletions packages/clerk-js/src/ui/elements/Action/ActionTrigger.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,19 @@ import { useActionContext } from './ActionRoot';

type ActionTriggerProps = PropsWithChildren<{
value: string;
hideOnActive?: boolean;
}>;

export const ActionTrigger = (props: ActionTriggerProps) => {
const { children, value } = props;
const { children, value, hideOnActive = true } = props;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This preserves the "invite" action button alongside the search input, otherwise, it'll lead to a layout height jump:

CleanShot.2025-01-21.at.08.30.04.mp4

const { active, open } = useActionContext();

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

if (active === value) {
if (hideOnActive && active === value) {
return null;
}

Expand Down
35 changes: 25 additions & 10 deletions packages/clerk-js/src/ui/elements/InputWithIcon.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React from 'react';

import { Flex, Input } from '../customizables';
import { Box, Flex, Input } from '../customizables';
import type { PropsOfComponent } from '../styledSystem';

type InputWithIcon = PropsOfComponent<typeof Input> & { leftIcon?: React.ReactElement };
Expand All @@ -10,18 +10,33 @@ export const InputWithIcon = React.forwardRef<HTMLInputElement, InputWithIcon>((
return (
<Flex
center
sx={theme => ({
sx={{
width: '100%',
position: 'relative',
'& .cl-internal-icon': {
position: 'absolute',
left: theme.space.$4,
width: theme.sizes.$3x5,
height: theme.sizes.$3x5,
},
})}
}}
>
{leftIcon && React.cloneElement(leftIcon, { className: 'cl-internal-icon' })}
{leftIcon ? (
<Box
sx={theme => [
{
position: 'absolute',
left: theme.space.$3x5,
width: theme.sizes.$3x5,
height: theme.sizes.$3x5,
pointerEvents: 'none',
display: 'grid',
placeContent: 'center',
'& svg': {
position: 'absolute',
width: '100%',
height: '100%',
},
},
]}
>
{leftIcon}
</Box>
) : null}
<Input
{...rest}
sx={[
Expand Down
1 change: 1 addition & 0 deletions packages/localizations/src/ar-SA.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ export const arSA: LocalizationResource = {
},
membersPage: {
action__invite: 'دعوة',
action__search: undefined,
activeMembersTab: {
menuAction__remove: 'إزالة عضو',
tableHeader__actions: undefined,
Expand Down
1 change: 1 addition & 0 deletions packages/localizations/src/be-BY.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ export const beBY: LocalizationResource = {
},
membersPage: {
action__invite: 'Пригласить',
action__search: undefined,
activeMembersTab: {
menuAction__remove: 'Удалить удзельніка',
tableHeader__actions: 'Дзеянні',
Expand Down
1 change: 1 addition & 0 deletions packages/localizations/src/bg-BG.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ export const bgBG: LocalizationResource = {
},
membersPage: {
action__invite: 'Покани',
action__search: undefined,
activeMembersTab: {
menuAction__remove: 'Премахване на член',
tableHeader__actions: undefined,
Expand Down
Loading
Loading