Skip to content

Commit

Permalink
feat(clerk-js,types,localizations): Search members on `OrganizationPr…
Browse files Browse the repository at this point in the history
…ofile` (#4942)
  • Loading branch information
LauraBeatris authored Jan 24, 2025
1 parent 9dc8a67 commit a26cf0f
Show file tree
Hide file tree
Showing 49 changed files with 243 additions and 33 deletions.
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();
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;
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

0 comments on commit a26cf0f

Please sign in to comment.