snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
import {
|
||||
Modal,
|
||||
Button,
|
||||
Box,
|
||||
Icon,
|
||||
Label,
|
||||
CheckBox,
|
||||
ModalHeader,
|
||||
ModalHeaderText,
|
||||
ModalTitle,
|
||||
ModalClose,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalFooterAnnotation,
|
||||
ModalFooterControllers,
|
||||
} from '@rocket.chat/fuselage';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { useId, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export type AddMatrixUsersModalProps = {
|
||||
matrixIdVerifiedStatus: Map<string, string>;
|
||||
completeUserList: string[];
|
||||
onClose: () => void;
|
||||
onSave: (args_0: { users: string[]; unbanConfirmed?: boolean }) => Promise<void>;
|
||||
};
|
||||
|
||||
type FormValues = {
|
||||
usersToInvite: string[];
|
||||
};
|
||||
|
||||
const verificationStatusAsIcon = (verificationStatus: string): ComponentProps<typeof Icon>['name'] => {
|
||||
if (verificationStatus === 'VERIFIED') {
|
||||
return 'circle-check';
|
||||
}
|
||||
|
||||
if (verificationStatus === 'UNVERIFIED') {
|
||||
return 'circle-cross';
|
||||
}
|
||||
return 'circle-exclamation';
|
||||
};
|
||||
|
||||
const AddMatrixUsersModal = ({ onClose, matrixIdVerifiedStatus, onSave, completeUserList }: AddMatrixUsersModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
const checkboxId = useId();
|
||||
const [bannedError, setBannedError] = useState(false);
|
||||
const [unbanConfirmed, setUnbanConfirmed] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
const usersToInvite = completeUserList.filter(
|
||||
(user) => !(matrixIdVerifiedStatus.has(user) && matrixIdVerifiedStatus.get(user) === 'UNVERIFIED'),
|
||||
);
|
||||
const rocketChatUsers = usersToInvite.filter((user) => !matrixIdVerifiedStatus.has(user));
|
||||
|
||||
const { handleSubmit } = useForm<FormValues>({
|
||||
defaultValues: {
|
||||
usersToInvite,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (data: FormValues) => {
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
await onSave({ users: data.usersToInvite, unbanConfirmed });
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
if (err?.error === 'error-user-is-banned') {
|
||||
setBannedError(true);
|
||||
setUnbanConfirmed(false);
|
||||
} else {
|
||||
setError(err?.message || t('Something_went_wrong'));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal>
|
||||
<ModalHeader>
|
||||
<ModalHeaderText>
|
||||
<ModalTitle>{t('Continue_Adding')}</ModalTitle>
|
||||
</ModalHeaderText>
|
||||
<ModalClose title={t('Close')} onClick={onClose} />
|
||||
</ModalHeader>
|
||||
<ModalContent>
|
||||
<Box is='ul'>
|
||||
{[...matrixIdVerifiedStatus.entries()].map(([_matrixId, _verificationStatus]) => (
|
||||
<Box is='li' display='flex' key={_matrixId}>
|
||||
{_matrixId}{' '}
|
||||
<Icon marginInlineStart={4} name={verificationStatusAsIcon(_verificationStatus)} title={t(_verificationStatus)} size='x20' />
|
||||
</Box>
|
||||
))}
|
||||
{rocketChatUsers.map((_user) => (
|
||||
<Box is='li' key={`rocket-chat-${_user}`}>
|
||||
{_user}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
{bannedError && (
|
||||
<Box color='danger' fontScale='c1' marginBlockStart={8}>
|
||||
{t('User_is_banned_from_room_confirm_unban')}
|
||||
</Box>
|
||||
)}
|
||||
{error && (
|
||||
<Box color='danger' fontScale='c1' marginBlockStart={8}>
|
||||
{error}
|
||||
</Box>
|
||||
)}
|
||||
</ModalContent>
|
||||
<ModalFooter justifyContent={bannedError ? 'space-between' : 'flex-end'}>
|
||||
{bannedError && (
|
||||
<ModalFooterAnnotation>
|
||||
<Box display='flex' alignItems='center'>
|
||||
<CheckBox checked={unbanConfirmed} onChange={() => setUnbanConfirmed((prev) => !prev)} id={checkboxId} />
|
||||
<Label htmlFor={checkboxId} marginInlineStart={8}>
|
||||
{t('Yes_unban_user')}
|
||||
</Label>
|
||||
</Box>
|
||||
</ModalFooterAnnotation>
|
||||
)}
|
||||
<ModalFooterControllers>
|
||||
<Button onClick={onClose}>{t('Cancel')}</Button>
|
||||
<Button
|
||||
primary
|
||||
onClick={handleSubmit(onSubmit)}
|
||||
disabled={!(usersToInvite.length > 0) || (bannedError && !unbanConfirmed)}
|
||||
loading={loading}
|
||||
>
|
||||
{t('Yes_continue')}
|
||||
</Button>
|
||||
</ModalFooterControllers>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddMatrixUsersModal;
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
|
||||
import { useSetModal, useToastMessageDispatch, useEndpoint } from '@rocket.chat/ui-contexts';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import AddMatrixUsersModal from './AddMatrixUsersModal';
|
||||
|
||||
export type useAddMatrixUsersProps = {
|
||||
handleSave: (args_0: { users: string[]; unbanConfirmed?: boolean }) => Promise<void>;
|
||||
users: string[];
|
||||
};
|
||||
|
||||
export const useAddMatrixUsers = () => {
|
||||
const setModal = useSetModal();
|
||||
const dispatchToastMessage = useToastMessageDispatch();
|
||||
const handleClose = useStableCallback(() => setModal(null));
|
||||
const dispatchVerifyEndpoint = useEndpoint('GET', '/v1/federation/matrixIds.verify');
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ users, handleSave }: useAddMatrixUsersProps) => {
|
||||
try {
|
||||
let matrixIdVerificationMap = new Map();
|
||||
const matrixIds = users.filter((user) => user.startsWith('@'));
|
||||
if (matrixIds.length > 0) {
|
||||
const matrixIdsVerificationResponse = await dispatchVerifyEndpoint({ matrixIds });
|
||||
const { results: matrixIdsVerificationResults } = matrixIdsVerificationResponse;
|
||||
matrixIdVerificationMap = new Map(Object.entries(matrixIdsVerificationResults));
|
||||
}
|
||||
|
||||
setModal(
|
||||
<AddMatrixUsersModal
|
||||
completeUserList={users}
|
||||
onClose={handleClose}
|
||||
onSave={handleSave}
|
||||
matrixIdVerifiedStatus={matrixIdVerificationMap as Map<string, string>}
|
||||
/>,
|
||||
);
|
||||
} catch (error) {
|
||||
dispatchToastMessage({ type: 'error', message: error as Error });
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Contextualbar } from '@rocket.chat/ui-client';
|
||||
import type { StoryObj, Meta } from '@storybook/react';
|
||||
|
||||
import AddUsers from './AddUsers';
|
||||
|
||||
export default {
|
||||
component: AddUsers,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
actions: { argTypesRegex: '^on.*' },
|
||||
},
|
||||
decorators: [(fn) => <Contextualbar height='100vh'>{fn()}</Contextualbar>],
|
||||
} satisfies Meta<typeof AddUsers>;
|
||||
|
||||
export const Default: StoryObj<typeof AddUsers> = {
|
||||
name: 'AddUsers',
|
||||
};
|
||||
@@ -0,0 +1,175 @@
|
||||
import type { IRoom } from '@rocket.chat/core-typings';
|
||||
import { isRoomFederated, isRoomNativeFederated } from '@rocket.chat/core-typings';
|
||||
import { Field, FieldError, FieldLabel, Button, ButtonGroup, FieldGroup } from '@rocket.chat/fuselage';
|
||||
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
|
||||
import {
|
||||
ContextualbarHeader,
|
||||
ContextualbarBack,
|
||||
ContextualbarTitle,
|
||||
ContextualbarClose,
|
||||
ContextualbarScrollableContent,
|
||||
ContextualbarFooter,
|
||||
ContextualbarDialog,
|
||||
} from '@rocket.chat/ui-client';
|
||||
import { useToastMessageDispatch, useSetModal, useEndpoint, useRoomToolbox } from '@rocket.chat/ui-contexts';
|
||||
import { useId } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAddMatrixUsers } from './AddMatrixUsers/useAddMatrixUsers';
|
||||
import BannedUsersUnbanModal from './BannedUsersUnbanModal';
|
||||
import UserAutoCompleteMultiple from '../../../../../components/UserAutoCompleteMultiple';
|
||||
import { useRoom } from '../../../contexts/RoomContext';
|
||||
|
||||
const hasExternalUsers = (users: string[]): boolean => users.some((user) => user.startsWith('@'));
|
||||
|
||||
export type AddUsersProps = {
|
||||
rid: IRoom['_id'];
|
||||
onClickBack: () => void;
|
||||
reload: () => void;
|
||||
};
|
||||
|
||||
const AddUsers = ({ rid, onClickBack, reload }: AddUsersProps) => {
|
||||
const { t } = useTranslation();
|
||||
const dispatchToastMessage = useToastMessageDispatch();
|
||||
const room = useRoom();
|
||||
const usersFieldId = useId();
|
||||
const roomIsFederated = isRoomFederated(room);
|
||||
// we are dropping the non native federation for now
|
||||
const isFederationBlocked = room && !isRoomNativeFederated(room);
|
||||
const isFederated = roomIsFederated && !isFederationBlocked;
|
||||
|
||||
const setModal = useSetModal();
|
||||
const { closeTab } = useRoomToolbox();
|
||||
const inviteToChannel = useEndpoint('POST', '/v1/channels.invite');
|
||||
const inviteToGroup = useEndpoint('POST', '/v1/groups.invite');
|
||||
const getBannedUsers = useEndpoint('GET', '/v1/rooms.bannedUsers');
|
||||
const unbanUser = useEndpoint('POST', '/v1/rooms.unbanUser');
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
getValues,
|
||||
formState: { isDirty, isSubmitting, errors },
|
||||
} = useForm({ defaultValues: { users: [] } });
|
||||
|
||||
const handleSave = useStableCallback(async ({ users, unbanConfirmed }: { users: string[]; unbanConfirmed?: boolean }) => {
|
||||
if (unbanConfirmed) {
|
||||
const { bannedUsers } = await getBannedUsers({ roomId: rid });
|
||||
const bannedSet = new Set(bannedUsers.map((u) => u.username));
|
||||
const usersToUnban = users.filter((username) => bannedSet.has(username));
|
||||
|
||||
if (usersToUnban.length) {
|
||||
await Promise.all(usersToUnban.map((username) => unbanUser({ roomId: rid, username })));
|
||||
}
|
||||
}
|
||||
await Promise.all(
|
||||
users.map((username) => (room.t === 'c' ? inviteToChannel({ roomId: rid, username }) : inviteToGroup({ roomId: rid, username }))),
|
||||
);
|
||||
dispatchToastMessage({ type: 'success', message: t(roomIsFederated && !isFederationBlocked ? 'Users_invited' : 'Users_added') });
|
||||
onClickBack();
|
||||
reload();
|
||||
});
|
||||
|
||||
const handleSaveWithBannedCheck = useStableCallback(async ({ users }: { users: string[] }) => {
|
||||
try {
|
||||
await handleSave({ users });
|
||||
} catch (error: any) {
|
||||
if (error.error === 'error-user-is-banned') {
|
||||
const { bannedUsers } = await getBannedUsers({ roomId: rid });
|
||||
const bannedSet = new Set(bannedUsers.map((u) => u.username));
|
||||
const usersToUnban = users.filter((username) => bannedSet.has(username));
|
||||
|
||||
if (usersToUnban.length) {
|
||||
setModal(
|
||||
<BannedUsersUnbanModal
|
||||
onClose={() => setModal(null)}
|
||||
onConfirm={async () => {
|
||||
await Promise.all(usersToUnban.map((username) => unbanUser({ roomId: rid, username })));
|
||||
await Promise.all(
|
||||
users.map((username) =>
|
||||
room.t === 'c' ? inviteToChannel({ roomId: rid, username }) : inviteToGroup({ roomId: rid, username }),
|
||||
),
|
||||
);
|
||||
setModal(null);
|
||||
dispatchToastMessage({
|
||||
type: 'success',
|
||||
message: t(roomIsFederated && !isFederationBlocked ? 'Users_invited' : 'Users_added'),
|
||||
});
|
||||
onClickBack();
|
||||
reload();
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
dispatchToastMessage({ type: 'error', message: error as Error });
|
||||
}
|
||||
});
|
||||
|
||||
const addClickHandler = useAddMatrixUsers();
|
||||
|
||||
return (
|
||||
<ContextualbarDialog>
|
||||
<ContextualbarHeader>
|
||||
{onClickBack && <ContextualbarBack onClick={onClickBack} />}
|
||||
<ContextualbarTitle>{t('Add_users')}</ContextualbarTitle>
|
||||
{closeTab && <ContextualbarClose onClick={closeTab} />}
|
||||
</ContextualbarHeader>
|
||||
<ContextualbarScrollableContent>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel flexGrow={0}>{t('Choose_users')}</FieldLabel>
|
||||
<Controller
|
||||
name='users'
|
||||
control={control}
|
||||
rules={{
|
||||
validate: (users) => !isFederated && (!hasExternalUsers(users) || t('You_cannot_add_external_users_to_non_federated_room')),
|
||||
}}
|
||||
render={({ field }) => (
|
||||
<UserAutoCompleteMultiple
|
||||
federated={isFederated}
|
||||
placeholder={t('Choose_users')}
|
||||
aria-describedby={`${usersFieldId}-error`}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.users && (
|
||||
<FieldError role='alert' id={`${usersFieldId}-error`}>
|
||||
{errors.users.message}
|
||||
</FieldError>
|
||||
)}
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</ContextualbarScrollableContent>
|
||||
<ContextualbarFooter>
|
||||
<ButtonGroup stretch>
|
||||
{roomIsFederated ? (
|
||||
!isFederationBlocked && (
|
||||
<Button
|
||||
primary
|
||||
disabled={addClickHandler.isPending || !isDirty}
|
||||
onClick={() =>
|
||||
addClickHandler.mutate({
|
||||
users: getValues('users'),
|
||||
handleSave,
|
||||
})
|
||||
}
|
||||
>
|
||||
{t('Add_users')}
|
||||
</Button>
|
||||
)
|
||||
) : (
|
||||
<Button primary loading={isSubmitting} disabled={!isDirty} onClick={handleSubmit(handleSaveWithBannedCheck)}>
|
||||
{t('Add_users')}
|
||||
</Button>
|
||||
)}
|
||||
</ButtonGroup>
|
||||
</ContextualbarFooter>
|
||||
</ContextualbarDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddUsers;
|
||||
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
Modal,
|
||||
Box,
|
||||
Button,
|
||||
Label,
|
||||
CheckBox,
|
||||
ModalHeader,
|
||||
ModalHeaderText,
|
||||
ModalTitle,
|
||||
ModalClose,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalFooterAnnotation,
|
||||
ModalFooterControllers,
|
||||
} from '@rocket.chat/fuselage';
|
||||
import { useId, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export type BannedUsersUnbanModalProps = {
|
||||
onClose: () => void;
|
||||
onConfirm: () => Promise<void>;
|
||||
};
|
||||
|
||||
const BannedUsersUnbanModal = ({ onClose, onConfirm }: BannedUsersUnbanModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
const checkboxId = useId();
|
||||
const [unbanConfirmed, setUnbanConfirmed] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleConfirm = async () => {
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
await onConfirm();
|
||||
} catch (err: any) {
|
||||
setError(err?.message || t('Something_went_wrong'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal>
|
||||
<ModalHeader>
|
||||
<ModalHeaderText>
|
||||
<ModalTitle>{t('User_is_banned')}</ModalTitle>
|
||||
</ModalHeaderText>
|
||||
<ModalClose title={t('Close')} onClick={onClose} />
|
||||
</ModalHeader>
|
||||
<ModalContent>
|
||||
<Box>{t('User_is_banned_from_room_confirm_unban')}</Box>
|
||||
{error && (
|
||||
<Box color='danger' fontScale='c1' marginBlockStart={8}>
|
||||
{error}
|
||||
</Box>
|
||||
)}
|
||||
</ModalContent>
|
||||
<ModalFooter justifyContent='space-between'>
|
||||
<ModalFooterAnnotation>
|
||||
<Box display='flex' alignItems='center'>
|
||||
<CheckBox checked={unbanConfirmed} onChange={() => setUnbanConfirmed((prev) => !prev)} id={checkboxId} />
|
||||
<Label htmlFor={checkboxId} marginInlineStart={8}>
|
||||
{t('Yes_unban_user')}
|
||||
</Label>
|
||||
</Box>
|
||||
</ModalFooterAnnotation>
|
||||
<ModalFooterControllers>
|
||||
<Button onClick={onClose}>{t('Cancel')}</Button>
|
||||
<Button primary onClick={handleConfirm} disabled={!unbanConfirmed} loading={loading}>
|
||||
{t('Add_users')}
|
||||
</Button>
|
||||
</ModalFooterControllers>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default BannedUsersUnbanModal;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './AddUsers';
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { SelectOption } from '@rocket.chat/fuselage';
|
||||
import { Box, Field, FieldLabel, FieldRow, Select, Button } from '@rocket.chat/fuselage';
|
||||
import { useId, useMemo } from 'react';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export type EditInviteLinkProps = {
|
||||
daysAndMaxUses: { days: string; maxUses: string };
|
||||
onClickNewLink: (daysAndMaxUses: { days: string; maxUses: string }) => void;
|
||||
};
|
||||
|
||||
const EditInviteLink = ({ daysAndMaxUses, onClickNewLink }: EditInviteLinkProps) => {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
handleSubmit,
|
||||
formState: { isDirty, isSubmitting },
|
||||
control,
|
||||
} = useForm({ defaultValues: { days: daysAndMaxUses.days, maxUses: daysAndMaxUses.maxUses } });
|
||||
const expirationId = useId();
|
||||
const maxUsesId = useId();
|
||||
|
||||
const daysOptions: SelectOption[] = useMemo(
|
||||
() => [
|
||||
['1', '1'],
|
||||
['7', '7'],
|
||||
['15', '15'],
|
||||
['30', '30'],
|
||||
['0', t('Never')],
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
const maxUsesOptions: SelectOption[] = useMemo(
|
||||
() => [
|
||||
['5', '5'],
|
||||
['10', '10'],
|
||||
['25', '25'],
|
||||
['50', '50'],
|
||||
['100', '100'],
|
||||
['0', t('No_Limit')],
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Field>
|
||||
<FieldLabel htmlFor={expirationId} flexGrow={0}>
|
||||
{t('Expiration_(Days)')}
|
||||
</FieldLabel>
|
||||
<FieldRow>
|
||||
<Controller
|
||||
name='days'
|
||||
control={control}
|
||||
render={({ field: { onChange, value, name } }) => (
|
||||
<Select id={expirationId} name={name} value={value} onChange={onChange} options={daysOptions} />
|
||||
)}
|
||||
/>
|
||||
</FieldRow>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor={maxUsesId} flexGrow={0}>
|
||||
{t('Max_number_of_uses')}
|
||||
</FieldLabel>
|
||||
<FieldRow>
|
||||
<Controller
|
||||
name='maxUses'
|
||||
control={control}
|
||||
render={({ field: { onChange, value, name } }) => (
|
||||
<Select id={maxUsesId} name={name} value={value} onChange={onChange} options={maxUsesOptions} />
|
||||
)}
|
||||
/>
|
||||
</FieldRow>
|
||||
</Field>
|
||||
<Box marginBlockStart={8}>
|
||||
<Button loading={isSubmitting} disabled={!isDirty} primary onClick={handleSubmit(onClickNewLink)}>
|
||||
{t('Generate_New_Link')}
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditInviteLink;
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Box, Field, FieldLabel, FieldRow, UrlInput, Icon, Button, InputBoxSkeleton } from '@rocket.chat/fuselage';
|
||||
import { useId } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import useClipboardWithToast from '../../../../../hooks/useClipboardWithToast';
|
||||
|
||||
export type InviteLinkProps = {
|
||||
linkText: string;
|
||||
captionText: string;
|
||||
onClickEdit: () => void;
|
||||
};
|
||||
|
||||
const InviteLink = ({ linkText, captionText, onClickEdit }: InviteLinkProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { copy } = useClipboardWithToast(linkText);
|
||||
const inviteLinkId = useId();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Field>
|
||||
<FieldLabel htmlFor={inviteLinkId} flexGrow={0}>
|
||||
{t('Invite_Link')}
|
||||
</FieldLabel>
|
||||
<FieldRow>
|
||||
{!linkText && <InputBoxSkeleton />}
|
||||
{linkText && (
|
||||
<UrlInput id={inviteLinkId} value={linkText} endAddon={<Icon onClick={(): Promise<void> => copy()} name='copy' size='x16' />} />
|
||||
)}
|
||||
</FieldRow>
|
||||
{captionText && (
|
||||
<Box paddingBlock={8} color='annotation' fontScale='c2'>
|
||||
{captionText}
|
||||
</Box>
|
||||
)}
|
||||
</Field>
|
||||
{onClickEdit && (
|
||||
<Box marginBlockStart={8}>
|
||||
<Button onClick={onClickEdit}>{t('Edit_Invite')}</Button>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default InviteLink;
|
||||
@@ -0,0 +1,29 @@
|
||||
import { composeStories } from '@storybook/react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { axe } from 'jest-axe';
|
||||
|
||||
import * as stories from './InviteUsers.stories';
|
||||
|
||||
const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.mock('react', () => ({
|
||||
...jest.requireActual('react'),
|
||||
useId: () => 1,
|
||||
}));
|
||||
});
|
||||
|
||||
test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => {
|
||||
const view = render(<Story />);
|
||||
expect(view.baseElement).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => {
|
||||
const { container } = render(<Story />);
|
||||
|
||||
/**
|
||||
** Disable 'nested-interactive' rule because our `Select` component is still not a11y compliant
|
||||
**/
|
||||
const results = await axe(container, { rules: { 'nested-interactive': { enabled: false } } });
|
||||
expect(results).toHaveNoViolations();
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Box } from '@rocket.chat/fuselage';
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import type { StoryObj, Meta } from '@storybook/react';
|
||||
|
||||
import InviteUsers from './InviteUsers';
|
||||
import InviteUsersEdit from './InviteUsersEdit';
|
||||
import InviteUsersError from './InviteUsersError';
|
||||
import InviteUsersLoading from './InviteUsersLoading';
|
||||
import { links } from '../../../../../lib/links';
|
||||
|
||||
export default {
|
||||
component: InviteUsers,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
actions: { argTypesRegex: '^on.*' },
|
||||
},
|
||||
decorators: [
|
||||
(fn) => <Box height='100vh'>{fn()}</Box>,
|
||||
mockAppRoot()
|
||||
.withTranslations('en', 'core', {
|
||||
'Edit_Invite': 'Edit invite',
|
||||
'Invite_Users': 'Invite users',
|
||||
'Invite_Link': 'Invite link',
|
||||
'Expiration_(Days)': 'Expiration (Days)',
|
||||
'Max_number_of_uses': 'Max number of uses',
|
||||
})
|
||||
.buildStoryDecorator(),
|
||||
],
|
||||
} satisfies Meta<typeof InviteUsers>;
|
||||
|
||||
export const Default: StoryObj<typeof InviteUsers> = {
|
||||
name: 'Invite Link',
|
||||
|
||||
args: {
|
||||
linkText: links.go.invite,
|
||||
captionText: 'Expire on February 4, 2020 4:45 PM.',
|
||||
},
|
||||
};
|
||||
|
||||
export const InviteEdit: StoryObj<typeof InviteUsersEdit> = {
|
||||
render: (args) => <InviteUsersEdit {...args} daysAndMaxUses={{ days: '1', maxUses: '5' }} />,
|
||||
};
|
||||
|
||||
export const InviteLoading: StoryObj<typeof InviteUsersLoading> = {
|
||||
render: (args) => <InviteUsersLoading {...args} />,
|
||||
};
|
||||
|
||||
export const InviteError: StoryObj<typeof InviteUsersError> = {
|
||||
render: (args) => <InviteUsersError {...args} error={new Error('Error message')} />,
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import InviteLink from './InviteLink';
|
||||
import InviteUsersWrapper from './InviteUsersWrapper';
|
||||
|
||||
export type InviteUsersProps = {
|
||||
onClickBackMembers?: () => void;
|
||||
onClose: () => void;
|
||||
onClickEdit: () => void;
|
||||
captionText: string;
|
||||
linkText: string;
|
||||
};
|
||||
|
||||
const InviteUsers = ({ onClickBackMembers, onClose, onClickEdit, captionText, linkText }: InviteUsersProps) => (
|
||||
<InviteUsersWrapper onClickBack={onClickBackMembers} onClose={onClose}>
|
||||
<InviteLink captionText={captionText} onClickEdit={onClickEdit} linkText={linkText} />
|
||||
</InviteUsersWrapper>
|
||||
);
|
||||
|
||||
export default InviteUsers;
|
||||
@@ -0,0 +1,19 @@
|
||||
import EditInviteLink from './EditInviteLink';
|
||||
import InviteUsersWrapper from './InviteUsersWrapper';
|
||||
|
||||
export type InviteUsersEditProps = {
|
||||
onClickBackLink?: () => void;
|
||||
onClickNewLink: (daysAndMaxUses: { days: string; maxUses: string }) => void;
|
||||
onClose: () => void;
|
||||
daysAndMaxUses: { days: string; maxUses: string };
|
||||
};
|
||||
|
||||
const InviteUsersEdit = ({ onClickBackLink, onClickNewLink, onClose, daysAndMaxUses }: InviteUsersEditProps) => {
|
||||
return (
|
||||
<InviteUsersWrapper onClickBack={onClickBackLink} onClose={onClose}>
|
||||
<EditInviteLink onClickNewLink={onClickNewLink} daysAndMaxUses={daysAndMaxUses} />
|
||||
</InviteUsersWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default InviteUsersEdit;
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Callout } from '@rocket.chat/fuselage';
|
||||
|
||||
import InviteUsersWrapper from './InviteUsersWrapper';
|
||||
|
||||
type InviteUsersProps = {
|
||||
onClose: () => void;
|
||||
error: Error;
|
||||
onClickBack?: (() => void) | undefined;
|
||||
};
|
||||
|
||||
const InviteUsersError = ({ onClose, error, onClickBack }: InviteUsersProps) => (
|
||||
<InviteUsersWrapper onClose={onClose} onClickBack={onClickBack}>
|
||||
<Callout type='danger'>{(error || '').toString()}</Callout>
|
||||
</InviteUsersWrapper>
|
||||
);
|
||||
|
||||
export default InviteUsersError;
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Skeleton } from '@rocket.chat/fuselage';
|
||||
|
||||
import InviteUsersWrapper from './InviteUsersWrapper';
|
||||
|
||||
type InviteUsersProps = {
|
||||
onClose: () => void;
|
||||
onClickBack: (() => void) | undefined;
|
||||
};
|
||||
|
||||
const InviteUsersLoading = ({ onClose, onClickBack: onClickBackMembers }: InviteUsersProps) => (
|
||||
<InviteUsersWrapper onClose={onClose} onClickBack={onClickBackMembers}>
|
||||
<Skeleton width='full' />
|
||||
</InviteUsersWrapper>
|
||||
);
|
||||
|
||||
export default InviteUsersLoading;
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { IRoom } from '@rocket.chat/core-typings';
|
||||
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
|
||||
import { useEndpoint, useTranslation, useToastMessageDispatch, useRoomToolbox } from '@rocket.chat/ui-contexts';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
import InviteUsers from './InviteUsers';
|
||||
import InviteUsersEdit from './InviteUsersEdit';
|
||||
import InviteUsersError from './InviteUsersError';
|
||||
import InviteUsersLoading from './InviteUsersLoading';
|
||||
import { useFormatDateAndTime } from '../../../../../hooks/useFormatDateAndTime';
|
||||
|
||||
export type InviteUsersWithDataProps = {
|
||||
rid: IRoom['_id'];
|
||||
onClickBack: () => void;
|
||||
};
|
||||
|
||||
const InviteUsersWithData = ({ rid, onClickBack }: InviteUsersWithDataProps) => {
|
||||
const t = useTranslation();
|
||||
const dispatchToastMessage = useToastMessageDispatch();
|
||||
const [
|
||||
{
|
||||
isEditing,
|
||||
daysAndMaxUses: { days, maxUses },
|
||||
},
|
||||
setInviteState,
|
||||
] = useState({
|
||||
isEditing: false,
|
||||
daysAndMaxUses: { days: '1', maxUses: '0' },
|
||||
});
|
||||
|
||||
const { closeTab } = useRoomToolbox();
|
||||
const format = useFormatDateAndTime();
|
||||
const findOrCreateInvite = useEndpoint('POST', '/v1/findOrCreateInvite');
|
||||
|
||||
const handleEdit = useStableCallback(() => setInviteState((prevState) => ({ ...prevState, isEditing: true })));
|
||||
const handleBackToLink = useStableCallback(() => setInviteState((prevState) => ({ ...prevState, isEditing: false })));
|
||||
|
||||
const linkExpirationText = useStableCallback(
|
||||
(data?: {
|
||||
days: number;
|
||||
maxUses: number;
|
||||
rid: string;
|
||||
userId: string;
|
||||
createdAt: string;
|
||||
expires: string | null;
|
||||
uses: number;
|
||||
url: string;
|
||||
_id: string;
|
||||
_updatedAt: string;
|
||||
}) => {
|
||||
if (!data) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (data.expires) {
|
||||
const expiration = new Date(data.expires);
|
||||
if (data.maxUses) {
|
||||
const usesLeft = data.maxUses - data.uses;
|
||||
return t('Your_invite_link_will_expire_on__date__or_after__usesLeft__uses', {
|
||||
date: format(expiration),
|
||||
usesLeft,
|
||||
});
|
||||
}
|
||||
|
||||
return t('Your_invite_link_will_expire_on__date__', { date: format(expiration) });
|
||||
}
|
||||
|
||||
if (data.maxUses) {
|
||||
const usesLeft = data.maxUses - data.uses;
|
||||
return t('Your_invite_link_will_expire_after__usesLeft__uses', { usesLeft });
|
||||
}
|
||||
|
||||
return t('Your_invite_link_will_never_expire');
|
||||
},
|
||||
);
|
||||
|
||||
const { data, isSuccess, error, isError, isLoading } = useQuery({
|
||||
queryKey: ['findOrCreateInvite', days, maxUses],
|
||||
queryFn: async () => findOrCreateInvite({ rid, days: Number(days), maxUses: Number(maxUses) }),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isSuccess) {
|
||||
dispatchToastMessage({ type: 'success', message: t('Invite_link_generated') });
|
||||
}
|
||||
}, [dispatchToastMessage, isSuccess, t]);
|
||||
|
||||
const handleGenerateLink = useStableCallback((daysAndMaxUses: { days: string; maxUses: string }) => {
|
||||
setInviteState((prevState) => ({ ...prevState, daysAndMaxUses, isEditing: false }));
|
||||
});
|
||||
|
||||
if (isError) {
|
||||
return <InviteUsersError error={error} onClose={closeTab} onClickBack={onClickBack} />;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <InviteUsersLoading onClose={closeTab} onClickBack={onClickBack} />;
|
||||
}
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<InviteUsersEdit
|
||||
daysAndMaxUses={{ days, maxUses }}
|
||||
onClickBackLink={handleBackToLink}
|
||||
onClickNewLink={handleGenerateLink}
|
||||
onClose={closeTab}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isSuccess) {
|
||||
return (
|
||||
<InviteUsers
|
||||
linkText={data.url}
|
||||
captionText={linkExpirationText(data)}
|
||||
onClose={closeTab}
|
||||
onClickBackMembers={onClickBack}
|
||||
onClickEdit={handleEdit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <InviteUsersError error={new Error(t('Something_Went_Wrong'))} onClose={closeTab} onClickBack={onClickBack} />;
|
||||
};
|
||||
|
||||
export default InviteUsersWithData;
|
||||
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
ContextualbarHeader,
|
||||
ContextualbarTitle,
|
||||
ContextualbarBack,
|
||||
ContextualbarClose,
|
||||
ContextualbarScrollableContent,
|
||||
ContextualbarDialog,
|
||||
} from '@rocket.chat/ui-client';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export type InviteUsersWrapperProps = {
|
||||
children: ReactNode;
|
||||
onClickBack: (() => void) | undefined;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const InviteUsersWrapper = ({ children, onClickBack, onClose }: InviteUsersWrapperProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ContextualbarDialog>
|
||||
<ContextualbarHeader>
|
||||
<ContextualbarBack onClick={onClickBack} />
|
||||
<ContextualbarTitle>{t('Invite_Users')}</ContextualbarTitle>
|
||||
<ContextualbarClose onClick={onClose} />
|
||||
</ContextualbarHeader>
|
||||
<ContextualbarScrollableContent>{children}</ContextualbarScrollableContent>
|
||||
</ContextualbarDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default InviteUsersWrapper;
|
||||
@@ -0,0 +1,847 @@
|
||||
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
|
||||
|
||||
exports[`renders Default without crashing 1`] = `
|
||||
<body>
|
||||
<div>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-llf9s1"
|
||||
>
|
||||
<span
|
||||
data-focus-scope-start="true"
|
||||
hidden=""
|
||||
/>
|
||||
<div
|
||||
style="position: relative; user-select: auto; width: 380px; height: 100%; max-width: 50%; min-width: 380px; min-height: 100%; box-sizing: border-box; flex-shrink: 0;"
|
||||
>
|
||||
<div
|
||||
aria-labelledby="contextualbarTitle"
|
||||
class="rcx-box rcx-box--full rcx-vertical-bar rcx-css-nlfrt3"
|
||||
role="dialog"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-lm07yg"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-1bdyjik rcx-css-1l4fxfb"
|
||||
>
|
||||
<button
|
||||
class="rcx-box rcx-box--full rcx-button--small-square rcx-button--square rcx-button--icon rcx-button rcx-css-ue04py"
|
||||
title="Back"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="rcx-box rcx-box--full rcx-icon--name-arrow-back rcx-icon rcx-css-4pvxx3"
|
||||
>
|
||||
⦉
|
||||
</i>
|
||||
</button>
|
||||
<h5
|
||||
class="rcx-box rcx-box--full rcx-css-1spko5i"
|
||||
id="contextualbarTitle"
|
||||
>
|
||||
Invite users
|
||||
</h5>
|
||||
<button
|
||||
aria-label="Close"
|
||||
class="rcx-box rcx-box--full rcx-button--small-square rcx-button--square rcx-button--icon rcx-button rcx-css-ue04py"
|
||||
data-qa="ContextualbarActionClose"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="rcx-box rcx-box--full rcx-icon--name-cross rcx-icon rcx-css-4pvxx3"
|
||||
>
|
||||
|
||||
</i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-1svuzur"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-1pd039f rcx-css-1cb6i7s"
|
||||
data-overlayscrollbars="host"
|
||||
>
|
||||
<div
|
||||
class="os-size-observer"
|
||||
>
|
||||
<div
|
||||
class="os-size-observer-listener"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class=""
|
||||
data-overlayscrollbars-viewport="scrollbarHidden overflowXHidden overflowYHidden"
|
||||
style="margin-right: 0px; margin-bottom: 0px; margin-left: 0px; top: 0px; left: 0px; width: calc(100% + 0px); padding: 0px 0px 0px 0px;"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-zg7o7h"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-field rcx-css-1fuhgt0"
|
||||
>
|
||||
<label
|
||||
class="rcx-box rcx-box--full rcx-field__label rcx-label rcx-css-irxdij"
|
||||
for="_r_1_"
|
||||
>
|
||||
Invite link
|
||||
</label>
|
||||
<span
|
||||
class="rcx-box rcx-box--full rcx-field__row"
|
||||
>
|
||||
<label
|
||||
class="rcx-box rcx-box--full rcx-label rcx-box rcx-box--full rcx-box--animated rcx-input-box__wrapper"
|
||||
>
|
||||
<input
|
||||
class="rcx-box rcx-box--full rcx-box--animated rcx-input-box--undecorated rcx-input-box--type-url rcx-input-box"
|
||||
id="_r_1_"
|
||||
size="1"
|
||||
type="url"
|
||||
value="https://go.rocket.chat/invite?host=open.rocket.chat&path=invite%2F5sBs3a"
|
||||
/>
|
||||
<span
|
||||
class="rcx-box rcx-box--full rcx-input-box__addon"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="rcx-box rcx-box--full rcx-icon--name-copy rcx-icon rcx-css-1g87xs3"
|
||||
>
|
||||
|
||||
</i>
|
||||
</span>
|
||||
</label>
|
||||
</span>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-14258hb"
|
||||
>
|
||||
Expire on February 4, 2020 4:45 PM.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="os-scrollbar os-scrollbar-horizontal os-theme-dark os-scrollbar-auto-hide os-scrollbar-auto-hide-hidden os-scrollbar-handle-interactive os-scrollbar-cornerless os-scrollbar-unusable"
|
||||
style="--os-scroll-percent: 0; --os-viewport-percent: 0; --os-scroll-direction: 0;"
|
||||
>
|
||||
<div
|
||||
class="os-scrollbar-track"
|
||||
>
|
||||
<div
|
||||
class="os-scrollbar-handle"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="os-scrollbar os-scrollbar-vertical os-theme-dark os-scrollbar-auto-hide os-scrollbar-auto-hide-hidden os-scrollbar-handle-interactive os-scrollbar-cornerless os-scrollbar-unusable"
|
||||
style="--os-scroll-percent: 0; --os-viewport-percent: 0; --os-scroll-direction: 0;"
|
||||
>
|
||||
<div
|
||||
class="os-scrollbar-track"
|
||||
>
|
||||
<div
|
||||
class="os-scrollbar-handle"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
style="position: absolute; user-select: none; width: 100%; height: 10px; top: -5px; left: 0px; cursor: row-resize;"
|
||||
/>
|
||||
<div
|
||||
style="position: absolute; user-select: none; width: 10px; height: 100%; top: 0px; cursor: col-resize; right: -5px;"
|
||||
/>
|
||||
<div
|
||||
style="position: absolute; user-select: none; width: 100%; height: 10px; left: 0px; cursor: row-resize; bottom: -5px;"
|
||||
/>
|
||||
<div
|
||||
style="position: absolute; user-select: none; width: 3px; height: 100%; top: 0px; left: 0px; cursor: col-resize; z-index: 99;"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-124vum9"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
style="position: absolute; user-select: none; width: 20px; height: 20px; z-index: 1; right: -10px; top: -10px; cursor: ne-resize;"
|
||||
/>
|
||||
<div
|
||||
style="position: absolute; user-select: none; width: 20px; height: 20px; z-index: 1; right: -10px; bottom: -10px; cursor: se-resize;"
|
||||
/>
|
||||
<div
|
||||
style="position: absolute; user-select: none; width: 20px; height: 20px; z-index: 1; left: -10px; bottom: -10px; cursor: sw-resize;"
|
||||
/>
|
||||
<div
|
||||
style="position: absolute; user-select: none; width: 20px; height: 20px; z-index: 1; left: -10px; top: -10px; cursor: nw-resize;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
data-focus-scope-end="true"
|
||||
hidden=""
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
`;
|
||||
|
||||
exports[`renders InviteEdit without crashing 1`] = `
|
||||
<body>
|
||||
<div>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-llf9s1"
|
||||
>
|
||||
<span
|
||||
data-focus-scope-start="true"
|
||||
hidden=""
|
||||
/>
|
||||
<div
|
||||
style="position: relative; user-select: auto; width: 380px; height: 100%; max-width: 50%; min-width: 380px; min-height: 100%; box-sizing: border-box; flex-shrink: 0;"
|
||||
>
|
||||
<div
|
||||
aria-labelledby="contextualbarTitle"
|
||||
class="rcx-box rcx-box--full rcx-vertical-bar rcx-css-nlfrt3"
|
||||
role="dialog"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-lm07yg"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-1bdyjik rcx-css-1l4fxfb"
|
||||
>
|
||||
<button
|
||||
class="rcx-box rcx-box--full rcx-button--small-square rcx-button--square rcx-button--icon rcx-button rcx-css-ue04py"
|
||||
title="Back"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="rcx-box rcx-box--full rcx-icon--name-arrow-back rcx-icon rcx-css-4pvxx3"
|
||||
>
|
||||
⦉
|
||||
</i>
|
||||
</button>
|
||||
<h5
|
||||
class="rcx-box rcx-box--full rcx-css-1spko5i"
|
||||
id="contextualbarTitle"
|
||||
>
|
||||
Invite users
|
||||
</h5>
|
||||
<button
|
||||
aria-label="Close"
|
||||
class="rcx-box rcx-box--full rcx-button--small-square rcx-button--square rcx-button--icon rcx-button rcx-css-ue04py"
|
||||
data-qa="ContextualbarActionClose"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="rcx-box rcx-box--full rcx-icon--name-cross rcx-icon rcx-css-4pvxx3"
|
||||
>
|
||||
|
||||
</i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-1svuzur"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-1pd039f rcx-css-1cb6i7s"
|
||||
data-overlayscrollbars="host"
|
||||
>
|
||||
<div
|
||||
class="os-size-observer"
|
||||
>
|
||||
<div
|
||||
class="os-size-observer-listener"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class=""
|
||||
data-overlayscrollbars-viewport="scrollbarHidden overflowXHidden overflowYHidden"
|
||||
style="margin-right: 0px; margin-bottom: 0px; margin-left: 0px; top: 0px; left: 0px; width: calc(100% + 0px); padding: 0px 0px 0px 0px;"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-zg7o7h"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-field rcx-css-1fuhgt0"
|
||||
>
|
||||
<label
|
||||
class="rcx-box rcx-box--full rcx-field__label rcx-label rcx-css-irxdij"
|
||||
for="_r_3_"
|
||||
>
|
||||
Expiration (Days)
|
||||
</label>
|
||||
<span
|
||||
class="rcx-box rcx-box--full rcx-field__row"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-1mbgoc8"
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
data-a11y-ignore="aria-hidden-focus"
|
||||
data-react-aria-prevent-focus="true"
|
||||
data-testid="hidden-select-container"
|
||||
style="border: 0px; clip-path: inset(50%); height: 1px; margin: -1px; overflow: hidden; padding: 0px; position: absolute; width: 1px; white-space: nowrap;"
|
||||
>
|
||||
<label>
|
||||
<select
|
||||
name="days"
|
||||
tabindex="-1"
|
||||
>
|
||||
<option />
|
||||
<option
|
||||
value="1"
|
||||
>
|
||||
1
|
||||
</option>
|
||||
<option
|
||||
value="7"
|
||||
>
|
||||
7
|
||||
</option>
|
||||
<option
|
||||
value="15"
|
||||
>
|
||||
15
|
||||
</option>
|
||||
<option
|
||||
value="30"
|
||||
>
|
||||
30
|
||||
</option>
|
||||
<option
|
||||
value="0"
|
||||
>
|
||||
0
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
aria-expanded="false"
|
||||
aria-haspopup="listbox"
|
||||
aria-labelledby="react-aria-_r_c_"
|
||||
class="rcx-box rcx-box--full rcx-box--animated rcx-select rcx-css-1vw6rc6"
|
||||
id="_r_3_"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="rcx-box rcx-box--full rcx-css-1ah8hdc"
|
||||
id="react-aria-_r_c_"
|
||||
>
|
||||
1
|
||||
</span>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="rcx-box rcx-box--full rcx-icon--name-chevron-down rcx-icon rcx-css-l5txo2"
|
||||
>
|
||||
|
||||
</i>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-field rcx-css-1fuhgt0"
|
||||
>
|
||||
<label
|
||||
class="rcx-box rcx-box--full rcx-field__label rcx-label rcx-css-irxdij"
|
||||
for="_r_4_"
|
||||
>
|
||||
Max number of uses
|
||||
</label>
|
||||
<span
|
||||
class="rcx-box rcx-box--full rcx-field__row"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-1mbgoc8"
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
data-a11y-ignore="aria-hidden-focus"
|
||||
data-react-aria-prevent-focus="true"
|
||||
data-testid="hidden-select-container"
|
||||
style="border: 0px; clip-path: inset(50%); height: 1px; margin: -1px; overflow: hidden; padding: 0px; position: absolute; width: 1px; white-space: nowrap;"
|
||||
>
|
||||
<label>
|
||||
<select
|
||||
name="maxUses"
|
||||
tabindex="-1"
|
||||
>
|
||||
<option />
|
||||
<option
|
||||
value="5"
|
||||
>
|
||||
5
|
||||
</option>
|
||||
<option
|
||||
value="10"
|
||||
>
|
||||
10
|
||||
</option>
|
||||
<option
|
||||
value="25"
|
||||
>
|
||||
25
|
||||
</option>
|
||||
<option
|
||||
value="50"
|
||||
>
|
||||
50
|
||||
</option>
|
||||
<option
|
||||
value="100"
|
||||
>
|
||||
100
|
||||
</option>
|
||||
<option
|
||||
value="0"
|
||||
>
|
||||
0
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
aria-expanded="false"
|
||||
aria-haspopup="listbox"
|
||||
aria-labelledby="react-aria-_r_k_"
|
||||
class="rcx-box rcx-box--full rcx-box--animated rcx-select rcx-css-1vw6rc6"
|
||||
id="_r_4_"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="rcx-box rcx-box--full rcx-css-1ah8hdc"
|
||||
id="react-aria-_r_k_"
|
||||
>
|
||||
5
|
||||
</span>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="rcx-box rcx-box--full rcx-icon--name-chevron-down rcx-icon rcx-css-l5txo2"
|
||||
>
|
||||
|
||||
</i>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-31obua"
|
||||
>
|
||||
<button
|
||||
class="rcx-box rcx-box--full rcx-button--primary rcx-button"
|
||||
disabled=""
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="rcx-button--content"
|
||||
>
|
||||
Generate_New_Link
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="os-scrollbar os-scrollbar-horizontal os-theme-dark os-scrollbar-auto-hide os-scrollbar-auto-hide-hidden os-scrollbar-handle-interactive os-scrollbar-cornerless os-scrollbar-unusable"
|
||||
style="--os-scroll-percent: 0; --os-viewport-percent: 0; --os-scroll-direction: 0;"
|
||||
>
|
||||
<div
|
||||
class="os-scrollbar-track"
|
||||
>
|
||||
<div
|
||||
class="os-scrollbar-handle"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="os-scrollbar os-scrollbar-vertical os-theme-dark os-scrollbar-auto-hide os-scrollbar-auto-hide-hidden os-scrollbar-handle-interactive os-scrollbar-cornerless os-scrollbar-unusable"
|
||||
style="--os-scroll-percent: 0; --os-viewport-percent: 0; --os-scroll-direction: 0;"
|
||||
>
|
||||
<div
|
||||
class="os-scrollbar-track"
|
||||
>
|
||||
<div
|
||||
class="os-scrollbar-handle"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
style="position: absolute; user-select: none; width: 100%; height: 10px; top: -5px; left: 0px; cursor: row-resize;"
|
||||
/>
|
||||
<div
|
||||
style="position: absolute; user-select: none; width: 10px; height: 100%; top: 0px; cursor: col-resize; right: -5px;"
|
||||
/>
|
||||
<div
|
||||
style="position: absolute; user-select: none; width: 100%; height: 10px; left: 0px; cursor: row-resize; bottom: -5px;"
|
||||
/>
|
||||
<div
|
||||
style="position: absolute; user-select: none; width: 3px; height: 100%; top: 0px; left: 0px; cursor: col-resize; z-index: 99;"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-124vum9"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
style="position: absolute; user-select: none; width: 20px; height: 20px; z-index: 1; right: -10px; top: -10px; cursor: ne-resize;"
|
||||
/>
|
||||
<div
|
||||
style="position: absolute; user-select: none; width: 20px; height: 20px; z-index: 1; right: -10px; bottom: -10px; cursor: se-resize;"
|
||||
/>
|
||||
<div
|
||||
style="position: absolute; user-select: none; width: 20px; height: 20px; z-index: 1; left: -10px; bottom: -10px; cursor: sw-resize;"
|
||||
/>
|
||||
<div
|
||||
style="position: absolute; user-select: none; width: 20px; height: 20px; z-index: 1; left: -10px; top: -10px; cursor: nw-resize;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
data-focus-scope-end="true"
|
||||
hidden=""
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
`;
|
||||
|
||||
exports[`renders InviteError without crashing 1`] = `
|
||||
<body>
|
||||
<div>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-llf9s1"
|
||||
>
|
||||
<span
|
||||
data-focus-scope-start="true"
|
||||
hidden=""
|
||||
/>
|
||||
<div
|
||||
style="position: relative; user-select: auto; width: 380px; height: 100%; max-width: 50%; min-width: 380px; min-height: 100%; box-sizing: border-box; flex-shrink: 0;"
|
||||
>
|
||||
<div
|
||||
aria-labelledby="contextualbarTitle"
|
||||
class="rcx-box rcx-box--full rcx-vertical-bar rcx-css-nlfrt3"
|
||||
role="dialog"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-lm07yg"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-1bdyjik rcx-css-1l4fxfb"
|
||||
>
|
||||
<button
|
||||
class="rcx-box rcx-box--full rcx-button--small-square rcx-button--square rcx-button--icon rcx-button rcx-css-ue04py"
|
||||
title="Back"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="rcx-box rcx-box--full rcx-icon--name-arrow-back rcx-icon rcx-css-4pvxx3"
|
||||
>
|
||||
⦉
|
||||
</i>
|
||||
</button>
|
||||
<h5
|
||||
class="rcx-box rcx-box--full rcx-css-1spko5i"
|
||||
id="contextualbarTitle"
|
||||
>
|
||||
Invite users
|
||||
</h5>
|
||||
<button
|
||||
aria-label="Close"
|
||||
class="rcx-box rcx-box--full rcx-button--small-square rcx-button--square rcx-button--icon rcx-button rcx-css-ue04py"
|
||||
data-qa="ContextualbarActionClose"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="rcx-box rcx-box--full rcx-icon--name-cross rcx-icon rcx-css-4pvxx3"
|
||||
>
|
||||
|
||||
</i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-1svuzur"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-1pd039f rcx-css-1cb6i7s"
|
||||
data-overlayscrollbars="host"
|
||||
>
|
||||
<div
|
||||
class="os-size-observer"
|
||||
>
|
||||
<div
|
||||
class="os-size-observer-listener"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class=""
|
||||
data-overlayscrollbars-viewport="scrollbarHidden overflowXHidden overflowYHidden"
|
||||
style="margin-right: 0px; margin-bottom: 0px; margin-left: 0px; top: 0px; left: 0px; width: calc(100% + 0px); padding: 0px 0px 0px 0px;"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-zg7o7h"
|
||||
>
|
||||
<section
|
||||
class="rcx-box rcx-box--full rcx-callout rcx-callout--danger rcx-css-1jggbrp rcx-css-1fuhgt0"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="rcx-box rcx-box--full rcx-icon--name-ban rcx-icon rcx-callout__icon rcx-css-4pvxx3"
|
||||
>
|
||||
|
||||
</i>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-callout__wrapper"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-callout__wrapper-content"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-callout__content"
|
||||
>
|
||||
Error: Error message
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="os-scrollbar os-scrollbar-horizontal os-theme-dark os-scrollbar-auto-hide os-scrollbar-auto-hide-hidden os-scrollbar-handle-interactive os-scrollbar-cornerless os-scrollbar-unusable"
|
||||
style="--os-scroll-percent: 0; --os-viewport-percent: 0; --os-scroll-direction: 0;"
|
||||
>
|
||||
<div
|
||||
class="os-scrollbar-track"
|
||||
>
|
||||
<div
|
||||
class="os-scrollbar-handle"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="os-scrollbar os-scrollbar-vertical os-theme-dark os-scrollbar-auto-hide os-scrollbar-auto-hide-hidden os-scrollbar-handle-interactive os-scrollbar-cornerless os-scrollbar-unusable"
|
||||
style="--os-scroll-percent: 0; --os-viewport-percent: 0; --os-scroll-direction: 0;"
|
||||
>
|
||||
<div
|
||||
class="os-scrollbar-track"
|
||||
>
|
||||
<div
|
||||
class="os-scrollbar-handle"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
style="position: absolute; user-select: none; width: 100%; height: 10px; top: -5px; left: 0px; cursor: row-resize;"
|
||||
/>
|
||||
<div
|
||||
style="position: absolute; user-select: none; width: 10px; height: 100%; top: 0px; cursor: col-resize; right: -5px;"
|
||||
/>
|
||||
<div
|
||||
style="position: absolute; user-select: none; width: 100%; height: 10px; left: 0px; cursor: row-resize; bottom: -5px;"
|
||||
/>
|
||||
<div
|
||||
style="position: absolute; user-select: none; width: 3px; height: 100%; top: 0px; left: 0px; cursor: col-resize; z-index: 99;"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-124vum9"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
style="position: absolute; user-select: none; width: 20px; height: 20px; z-index: 1; right: -10px; top: -10px; cursor: ne-resize;"
|
||||
/>
|
||||
<div
|
||||
style="position: absolute; user-select: none; width: 20px; height: 20px; z-index: 1; right: -10px; bottom: -10px; cursor: se-resize;"
|
||||
/>
|
||||
<div
|
||||
style="position: absolute; user-select: none; width: 20px; height: 20px; z-index: 1; left: -10px; bottom: -10px; cursor: sw-resize;"
|
||||
/>
|
||||
<div
|
||||
style="position: absolute; user-select: none; width: 20px; height: 20px; z-index: 1; left: -10px; top: -10px; cursor: nw-resize;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
data-focus-scope-end="true"
|
||||
hidden=""
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
`;
|
||||
|
||||
exports[`renders InviteLoading without crashing 1`] = `
|
||||
<body>
|
||||
<div>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-llf9s1"
|
||||
>
|
||||
<span
|
||||
data-focus-scope-start="true"
|
||||
hidden=""
|
||||
/>
|
||||
<div
|
||||
style="position: relative; user-select: auto; width: 380px; height: 100%; max-width: 50%; min-width: 380px; min-height: 100%; box-sizing: border-box; flex-shrink: 0;"
|
||||
>
|
||||
<div
|
||||
aria-labelledby="contextualbarTitle"
|
||||
class="rcx-box rcx-box--full rcx-vertical-bar rcx-css-nlfrt3"
|
||||
role="dialog"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-lm07yg"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-1bdyjik rcx-css-1l4fxfb"
|
||||
>
|
||||
<button
|
||||
class="rcx-box rcx-box--full rcx-button--small-square rcx-button--square rcx-button--icon rcx-button rcx-css-ue04py"
|
||||
title="Back"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="rcx-box rcx-box--full rcx-icon--name-arrow-back rcx-icon rcx-css-4pvxx3"
|
||||
>
|
||||
⦉
|
||||
</i>
|
||||
</button>
|
||||
<h5
|
||||
class="rcx-box rcx-box--full rcx-css-1spko5i"
|
||||
id="contextualbarTitle"
|
||||
>
|
||||
Invite users
|
||||
</h5>
|
||||
<button
|
||||
aria-label="Close"
|
||||
class="rcx-box rcx-box--full rcx-button--small-square rcx-button--square rcx-button--icon rcx-button rcx-css-ue04py"
|
||||
data-qa="ContextualbarActionClose"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="rcx-box rcx-box--full rcx-icon--name-cross rcx-icon rcx-css-4pvxx3"
|
||||
>
|
||||
|
||||
</i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-1svuzur"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-1pd039f rcx-css-1cb6i7s"
|
||||
data-overlayscrollbars="host"
|
||||
>
|
||||
<div
|
||||
class="os-size-observer"
|
||||
>
|
||||
<div
|
||||
class="os-size-observer-listener"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class=""
|
||||
data-overlayscrollbars-viewport="scrollbarHidden overflowXHidden overflowYHidden"
|
||||
style="margin-right: 0px; margin-bottom: 0px; margin-left: 0px; top: 0px; left: 0px; width: calc(100% + 0px); padding: 0px 0px 0px 0px;"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-zg7o7h"
|
||||
>
|
||||
<span
|
||||
class="rcx-skeleton rcx-skeleton--text rcx-css-1jggbrp rcx-css-1qcz93u"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="os-scrollbar os-scrollbar-horizontal os-theme-dark os-scrollbar-auto-hide os-scrollbar-auto-hide-hidden os-scrollbar-handle-interactive os-scrollbar-cornerless os-scrollbar-unusable"
|
||||
style="--os-scroll-percent: 0; --os-viewport-percent: 0; --os-scroll-direction: 0;"
|
||||
>
|
||||
<div
|
||||
class="os-scrollbar-track"
|
||||
>
|
||||
<div
|
||||
class="os-scrollbar-handle"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="os-scrollbar os-scrollbar-vertical os-theme-dark os-scrollbar-auto-hide os-scrollbar-auto-hide-hidden os-scrollbar-handle-interactive os-scrollbar-cornerless os-scrollbar-unusable"
|
||||
style="--os-scroll-percent: 0; --os-viewport-percent: 0; --os-scroll-direction: 0;"
|
||||
>
|
||||
<div
|
||||
class="os-scrollbar-track"
|
||||
>
|
||||
<div
|
||||
class="os-scrollbar-handle"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
style="position: absolute; user-select: none; width: 100%; height: 10px; top: -5px; left: 0px; cursor: row-resize;"
|
||||
/>
|
||||
<div
|
||||
style="position: absolute; user-select: none; width: 10px; height: 100%; top: 0px; cursor: col-resize; right: -5px;"
|
||||
/>
|
||||
<div
|
||||
style="position: absolute; user-select: none; width: 100%; height: 10px; left: 0px; cursor: row-resize; bottom: -5px;"
|
||||
/>
|
||||
<div
|
||||
style="position: absolute; user-select: none; width: 3px; height: 100%; top: 0px; left: 0px; cursor: col-resize; z-index: 99;"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-124vum9"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
style="position: absolute; user-select: none; width: 20px; height: 20px; z-index: 1; right: -10px; top: -10px; cursor: ne-resize;"
|
||||
/>
|
||||
<div
|
||||
style="position: absolute; user-select: none; width: 20px; height: 20px; z-index: 1; right: -10px; bottom: -10px; cursor: se-resize;"
|
||||
/>
|
||||
<div
|
||||
style="position: absolute; user-select: none; width: 20px; height: 20px; z-index: 1; left: -10px; bottom: -10px; cursor: sw-resize;"
|
||||
/>
|
||||
<div
|
||||
style="position: absolute; user-select: none; width: 20px; height: 20px; z-index: 1; left: -10px; top: -10px; cursor: nw-resize;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
data-focus-scope-end="true"
|
||||
hidden=""
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
`;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './InviteUsersWithData';
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Box } from '@rocket.chat/fuselage';
|
||||
import type { TranslationKey } from '@rocket.chat/ui-contexts';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export type MembersListDividerProps = {
|
||||
title: TranslationKey;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export const MembersListDivider = ({ title, count }: MembersListDividerProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={title}
|
||||
backgroundColor='room'
|
||||
height={36}
|
||||
fontScale='p2m'
|
||||
color='defaut'
|
||||
paddingBlock={8}
|
||||
paddingInline={24}
|
||||
display='flex'
|
||||
flexDirection='row'
|
||||
justifyContent='space-between'
|
||||
borderBlockEndWidth={1}
|
||||
borderBlockEndColor='extra-light'
|
||||
>
|
||||
<Box>{t(title)}</Box>
|
||||
<Box>{count}</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { composeStories } from '@storybook/react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { axe } from 'jest-axe';
|
||||
|
||||
import * as stories from './RoomMembers.stories';
|
||||
|
||||
jest.mock('../../hooks/useUserInfoActions', () => ({
|
||||
useUserInfoActions: jest.fn(),
|
||||
}));
|
||||
|
||||
const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]);
|
||||
test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => {
|
||||
const { baseElement } = render(<Story />);
|
||||
expect(baseElement).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => {
|
||||
const { container } = render(<Story />);
|
||||
|
||||
// Disable 'nested-interactive' rule because our `Select` component is still not a11y compliant
|
||||
const results = await axe(container, { rules: { 'nested-interactive': { enabled: false } } });
|
||||
expect(results).toHaveNoViolations();
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { UserStatus } from '@rocket.chat/core-typings';
|
||||
import { Contextualbar } from '@rocket.chat/ui-client';
|
||||
import type { Meta } from '@storybook/react';
|
||||
import { action } from 'storybook/actions';
|
||||
|
||||
import RoomMembers from './RoomMembers';
|
||||
|
||||
export default {
|
||||
component: RoomMembers,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
actions: { argTypesRegex: '^on.*' },
|
||||
},
|
||||
decorators: [(fn) => <Contextualbar height='100vh'>{fn()}</Contextualbar>],
|
||||
args: {
|
||||
text: 'filter',
|
||||
type: 'online',
|
||||
setText: action('setText'),
|
||||
setType: action('setType'),
|
||||
loadMoreItems: action('loadMoreItems'),
|
||||
reload: action('reload'),
|
||||
rid: 'GENERAL',
|
||||
isTeam: false,
|
||||
isDirect: false,
|
||||
},
|
||||
} satisfies Meta<typeof RoomMembers>;
|
||||
|
||||
export const Default = {
|
||||
args: {
|
||||
isSuccess: true,
|
||||
members: [
|
||||
{
|
||||
_id: 'rocket.cat',
|
||||
username: 'rocket.cat',
|
||||
status: UserStatus.ONLINE,
|
||||
name: 'Rocket.Cat',
|
||||
roles: ['user'],
|
||||
subscription: {
|
||||
_id: 'sub-rocket.cat',
|
||||
ts: '2025-01-01T00:00:00Z',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const Loading = {
|
||||
args: {
|
||||
isPending: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithABACRoom = {
|
||||
args: {
|
||||
isSuccess: true,
|
||||
members: [
|
||||
{
|
||||
_id: 'rocket.cat',
|
||||
username: 'rocket.cat',
|
||||
status: UserStatus.ONLINE,
|
||||
name: 'Rocket.Cat',
|
||||
roles: ['user'],
|
||||
subscription: {
|
||||
_id: 'sub-rocket.cat',
|
||||
ts: '2025-01-01T00:00:00Z',
|
||||
},
|
||||
},
|
||||
],
|
||||
isABACRoom: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithInvitedMember = {
|
||||
args: {
|
||||
isSuccess: true,
|
||||
members: [
|
||||
{
|
||||
_id: 'rocket.cat',
|
||||
username: 'rocket.cat',
|
||||
roles: ['user'],
|
||||
subscription: {
|
||||
_id: 'sub-rocket.cat',
|
||||
status: 'INVITED',
|
||||
ts: '2025-01-01T00:00:00Z',
|
||||
},
|
||||
name: 'Rocket.Cat',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const Empty = {
|
||||
args: {
|
||||
isSuccess: true,
|
||||
members: [],
|
||||
total: 0,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,243 @@
|
||||
import type { IRoom } from '@rocket.chat/core-typings';
|
||||
import type { SelectOption } from '@rocket.chat/fuselage';
|
||||
import { Box, Icon, TextInput, Select, Throbber, ButtonGroup, Button, Callout } from '@rocket.chat/fuselage';
|
||||
import { useAutoFocus, useDebouncedCallback } from '@rocket.chat/fuselage-hooks';
|
||||
import {
|
||||
VirtualizedScrollbars,
|
||||
ContextualbarHeader,
|
||||
ContextualbarIcon,
|
||||
ContextualbarTitle,
|
||||
ContextualbarClose,
|
||||
ContextualbarContent,
|
||||
ContextualbarFooter,
|
||||
ContextualbarEmptyContent,
|
||||
ContextualbarSection,
|
||||
ContextualbarDialog,
|
||||
} from '@rocket.chat/ui-client';
|
||||
import { useSetting } from '@rocket.chat/ui-contexts';
|
||||
import type { ChangeEventHandler, ComponentProps, MouseEvent, ElementType } from 'react';
|
||||
import { useId, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { GroupedVirtuoso } from 'react-virtuoso';
|
||||
|
||||
import { MembersListDivider } from './MembersListDivider';
|
||||
import RoomMembersListWrapper from './RoomMembersListWrapper';
|
||||
import RoomMembersRow from './RoomMembersRow';
|
||||
import InfiniteListAnchor from '../../../../components/InfiniteListAnchor';
|
||||
import ResultsLiveRegion from '../../../../components/ResultsLiveRegion';
|
||||
import type { RoomMember } from '../../../hooks/useMembersList';
|
||||
|
||||
export type RoomMembersProps = {
|
||||
rid: IRoom['_id'];
|
||||
isTeam?: boolean;
|
||||
isDirect?: boolean;
|
||||
isPending: boolean;
|
||||
isSuccess: boolean;
|
||||
text: string;
|
||||
type: string;
|
||||
setText: ChangeEventHandler<HTMLInputElement>;
|
||||
setType: (type: 'online' | 'all') => void;
|
||||
members: RoomMember[];
|
||||
total: number;
|
||||
error?: Error;
|
||||
onClickClose: () => void;
|
||||
onClickView: (e: MouseEvent<HTMLElement>) => void;
|
||||
onClickAdd?: () => void;
|
||||
onClickInvite?: () => void;
|
||||
loadMoreItems: () => void;
|
||||
renderRow?: ElementType<ComponentProps<typeof RoomMembersRow>>;
|
||||
reload: () => void;
|
||||
isABACRoom?: boolean;
|
||||
};
|
||||
|
||||
const RoomMembers = ({
|
||||
isPending,
|
||||
isSuccess,
|
||||
members = [],
|
||||
text,
|
||||
type = 'online',
|
||||
setText,
|
||||
setType,
|
||||
onClickClose,
|
||||
onClickView,
|
||||
onClickAdd,
|
||||
onClickInvite,
|
||||
total,
|
||||
error,
|
||||
loadMoreItems,
|
||||
renderRow: RowComponent = RoomMembersRow,
|
||||
rid,
|
||||
isTeam,
|
||||
isDirect,
|
||||
reload,
|
||||
isABACRoom = false,
|
||||
}: RoomMembersProps) => {
|
||||
const { t } = useTranslation();
|
||||
const membersListId = useId();
|
||||
const inputRef = useAutoFocus<HTMLInputElement>(true);
|
||||
const itemData = useMemo(() => ({ onClickView, rid }), [onClickView, rid]);
|
||||
|
||||
const options: SelectOption[] = useMemo(
|
||||
() => [
|
||||
['online', t('Online')],
|
||||
['all', t('All')],
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
const loadMoreMembers = useDebouncedCallback(
|
||||
() => {
|
||||
loadMoreItems();
|
||||
},
|
||||
300,
|
||||
[loadMoreItems, members],
|
||||
);
|
||||
|
||||
const useRealName = useSetting('UI_Use_Real_Name', false);
|
||||
|
||||
const { counts, titles } = useMemo(() => {
|
||||
const owners: RoomMember[] = [];
|
||||
const leaders: RoomMember[] = [];
|
||||
const moderators: RoomMember[] = [];
|
||||
const normalMembers: RoomMember[] = [];
|
||||
|
||||
members.forEach((member) => {
|
||||
if (member.roles?.includes('owner')) {
|
||||
owners.push(member);
|
||||
} else if (member.roles?.includes('leader')) {
|
||||
leaders.push(member);
|
||||
} else if (member.roles?.includes('moderator')) {
|
||||
moderators.push(member);
|
||||
} else {
|
||||
normalMembers.push(member);
|
||||
}
|
||||
});
|
||||
|
||||
const counts = [];
|
||||
const titles = [];
|
||||
|
||||
if (owners.length > 0) {
|
||||
counts.push(owners.length);
|
||||
titles.push(<MembersListDivider title='Owners' count={owners.length} />);
|
||||
}
|
||||
|
||||
if (leaders.length > 0) {
|
||||
counts.push(leaders.length);
|
||||
titles.push(<MembersListDivider title='Leaders' count={leaders.length} />);
|
||||
}
|
||||
|
||||
if (moderators.length > 0) {
|
||||
counts.push(moderators.length);
|
||||
titles.push(<MembersListDivider title='Moderators' count={moderators.length} />);
|
||||
}
|
||||
|
||||
if (normalMembers.length > 0) {
|
||||
counts.push(normalMembers.length);
|
||||
titles.push(<MembersListDivider title='Members' count={normalMembers.length} />);
|
||||
}
|
||||
|
||||
return { counts, titles };
|
||||
}, [members]);
|
||||
|
||||
return (
|
||||
<ContextualbarDialog>
|
||||
<ContextualbarHeader>
|
||||
<ContextualbarIcon name='members' />
|
||||
<ContextualbarTitle>{isTeam ? t('Teams_members') : t('Members')}</ContextualbarTitle>
|
||||
{onClickClose && <ContextualbarClose onClick={onClickClose} />}
|
||||
</ContextualbarHeader>
|
||||
<ContextualbarSection>
|
||||
<TextInput
|
||||
placeholder={t('Search_by_username')}
|
||||
aria-label={t('Search_by_username')}
|
||||
aria-controls={isSuccess ? membersListId : undefined}
|
||||
value={text}
|
||||
ref={inputRef}
|
||||
onChange={setText}
|
||||
endAddon={<Icon name='magnifier' size='x20' />}
|
||||
/>
|
||||
<Box width='x144' marginInlineStart={8}>
|
||||
<Select
|
||||
aria-controls={isSuccess ? membersListId : undefined}
|
||||
options={options}
|
||||
value={type}
|
||||
onChange={(value): void => setType(value as 'online' | 'all')}
|
||||
/>
|
||||
</Box>
|
||||
</ContextualbarSection>
|
||||
<ContextualbarContent padding={0} paddingBlock={12}>
|
||||
<ResultsLiveRegion shouldAnnounce={isSuccess} itemCount={members.length} />
|
||||
{isPending && (
|
||||
<Box paddingInline={24} paddingBlock={12}>
|
||||
<Throbber size='x12' />
|
||||
</Box>
|
||||
)}
|
||||
{error && (
|
||||
<Box paddingInline={24} paddingBlock={12}>
|
||||
<Callout type='danger'>{error.message}</Callout>
|
||||
</Box>
|
||||
)}
|
||||
{isSuccess && (
|
||||
<>
|
||||
{members.length > 0 && (
|
||||
<Box paddingInline={24} paddingBlock={12}>
|
||||
<Box is='span' color='hint' fontScale='p2'>
|
||||
{t('Showing_current_of_total', { current: members.length, total })}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
<Box id={membersListId} width='full' height='full' overflow='hidden' flexShrink={1}>
|
||||
{members.length <= 0 && <ContextualbarEmptyContent title={t('No_members_found')} />}
|
||||
{members.length > 0 && (
|
||||
<VirtualizedScrollbars>
|
||||
<GroupedVirtuoso
|
||||
style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
}}
|
||||
overscan={50}
|
||||
groupCounts={counts}
|
||||
groupContent={(index) => titles[index]}
|
||||
components={{
|
||||
// eslint-disable-next-line react/no-multi-comp
|
||||
Footer: () => <InfiniteListAnchor loadMore={loadMoreMembers} />,
|
||||
List: RoomMembersListWrapper,
|
||||
}}
|
||||
itemContent={(index) => (
|
||||
<RowComponent useRealName={useRealName} data={itemData} user={members[index]} index={index} reload={reload} />
|
||||
)}
|
||||
/>
|
||||
</VirtualizedScrollbars>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</ContextualbarContent>
|
||||
{!isDirect && (onClickInvite || onClickAdd) && (
|
||||
<ContextualbarFooter>
|
||||
<ButtonGroup stretch>
|
||||
{onClickInvite && (
|
||||
<Button
|
||||
icon='link'
|
||||
onClick={onClickInvite}
|
||||
width='50%'
|
||||
disabled={isABACRoom}
|
||||
title={isABACRoom ? t('Not_available_for_ABAC_enabled_rooms') : undefined}
|
||||
aria-label={t('Invite_Link')}
|
||||
>
|
||||
{t('Invite_Link')}
|
||||
</Button>
|
||||
)}
|
||||
{onClickAdd && (
|
||||
<Button icon='user-plus' onClick={onClickAdd} width='50%' primary>
|
||||
{t('Add')}
|
||||
</Button>
|
||||
)}
|
||||
</ButtonGroup>
|
||||
</ContextualbarFooter>
|
||||
)}
|
||||
</ContextualbarDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default RoomMembers;
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { IUser, IRoom } from '@rocket.chat/core-typings';
|
||||
import { GenericMenu } from '@rocket.chat/ui-client';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useUserInfoActions } from '../../hooks/useUserInfoActions';
|
||||
|
||||
export type RoomMembersActionsProps = Pick<IUser, '_id' | 'name' | 'username' | 'freeSwitchExtension'> & {
|
||||
rid: IRoom['_id'];
|
||||
isInvited?: boolean;
|
||||
reload: () => void;
|
||||
};
|
||||
|
||||
const RoomMembersActions = ({ username, _id, name, rid, freeSwitchExtension, isInvited, reload }: RoomMembersActionsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { menuActions: menuOptions } = useUserInfoActions({
|
||||
rid,
|
||||
user: { _id, username, name, freeSwitchExtension },
|
||||
reload,
|
||||
size: 0,
|
||||
isMember: !isInvited,
|
||||
isInvited,
|
||||
});
|
||||
|
||||
if (!menuOptions) {
|
||||
return null;
|
||||
}
|
||||
return <GenericMenu detached title={t('More')} key='menu' sections={menuOptions} placement='bottom-end' />;
|
||||
};
|
||||
|
||||
export default RoomMembersActions;
|
||||
@@ -0,0 +1,83 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import RoomMembersItem from './RoomMembersItem';
|
||||
|
||||
jest.mock('@rocket.chat/ui-avatar', () => ({
|
||||
UserAvatar: () => null,
|
||||
}));
|
||||
|
||||
jest.mock('../../../../components/UserStatus', () => ({
|
||||
ReactiveUserStatus: () => null,
|
||||
}));
|
||||
|
||||
jest.mock('./RoomMembersActions', () => ({
|
||||
__esModule: true,
|
||||
default: () => <button>Actions</button>,
|
||||
}));
|
||||
|
||||
const defaultProps = {
|
||||
_id: 'user1',
|
||||
name: 'John Doe',
|
||||
username: 'johndoe',
|
||||
rid: 'GENERAL',
|
||||
useRealName: false,
|
||||
reload: jest.fn(),
|
||||
onClickView: jest.fn(),
|
||||
subscription: { _id: 'sub1', ts: '2025-01-01T00:00:00Z' },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('keyboard navigation', () => {
|
||||
it('should be focusable via keyboard', () => {
|
||||
render(<RoomMembersItem {...defaultProps} />);
|
||||
expect(screen.getByRole('listitem', { name: 'johndoe' })).toHaveAttribute('tabindex', '0');
|
||||
});
|
||||
|
||||
it('should call onClickView when Enter is pressed while focused', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClickView = jest.fn();
|
||||
render(<RoomMembersItem {...defaultProps} onClickView={onClickView} />);
|
||||
|
||||
await user.tab();
|
||||
await user.keyboard('{Enter}');
|
||||
|
||||
expect(onClickView).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should call onClickView when Space is pressed while focused', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClickView = jest.fn();
|
||||
render(<RoomMembersItem {...defaultProps} onClickView={onClickView} />);
|
||||
|
||||
await user.tab();
|
||||
await user.keyboard(' ');
|
||||
|
||||
expect(onClickView).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should reveal the actions menu when focused', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<RoomMembersItem {...defaultProps} />);
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Actions' })).not.toBeInTheDocument();
|
||||
|
||||
await user.tab();
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Actions' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply the focus style when focused via keyboard', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<RoomMembersItem {...defaultProps} />);
|
||||
|
||||
await user.tab();
|
||||
|
||||
expect(screen.getByRole('listitem', { name: /johndoe/ })).toHaveClass('rcx-option--focus');
|
||||
expect(screen.getByRole('listitem', { name: /johndoe/ })).toHaveFocus();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useFocusRing } from '@react-aria/focus';
|
||||
import type { IRoom } from '@rocket.chat/core-typings';
|
||||
import {
|
||||
Option,
|
||||
OptionAvatar,
|
||||
OptionColumn,
|
||||
OptionDescription,
|
||||
OptionMenu,
|
||||
OptionContent,
|
||||
Icon,
|
||||
IconButton,
|
||||
OptionSkeleton,
|
||||
} from '@rocket.chat/fuselage';
|
||||
import { usePrefersReducedMotion } from '@rocket.chat/fuselage-hooks';
|
||||
import { UserAvatar } from '@rocket.chat/ui-avatar';
|
||||
import type { KeyboardEvent, MouseEvent } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import UserActions from './RoomMembersActions';
|
||||
import { getUserDisplayNames } from '../../../../../lib/getUserDisplayNames';
|
||||
import InvitationBadge from '../../../../components/InvitationBadge';
|
||||
import { ReactiveUserStatus } from '../../../../components/UserStatus';
|
||||
import { usePreventPropagation } from '../../../../hooks/usePreventPropagation';
|
||||
import { useUserStatusTooltip } from '../../../../hooks/useUserStatusTooltip';
|
||||
import type { RoomMember } from '../../../hooks/useMembersList';
|
||||
|
||||
export type RoomMembersItemProps = Pick<RoomMember, 'federated' | 'username' | 'name' | '_id' | 'freeSwitchExtension' | 'subscription'> & {
|
||||
rid: IRoom['_id'];
|
||||
useRealName: boolean;
|
||||
reload: () => void;
|
||||
onClickView: (e: MouseEvent<HTMLElement>) => void;
|
||||
};
|
||||
|
||||
const RoomMembersItem = ({
|
||||
_id,
|
||||
name,
|
||||
username,
|
||||
federated,
|
||||
freeSwitchExtension,
|
||||
onClickView,
|
||||
rid,
|
||||
subscription,
|
||||
reload,
|
||||
useRealName,
|
||||
}: RoomMembersItemProps) => {
|
||||
const [showButton, setShowButton] = useState(false);
|
||||
const isReduceMotionEnabled = usePrefersReducedMotion();
|
||||
const { focusProps, isFocusVisible } = useFocusRing();
|
||||
const isInvited = subscription?.status === 'INVITED';
|
||||
const invitationDate = isInvited ? subscription?.ts : undefined;
|
||||
const preventPropagation = usePreventPropagation();
|
||||
const [nameOrUsername, displayUsername] = getUserDisplayNames(name, username, useRealName);
|
||||
|
||||
const statusTooltipHandlers = useUserStatusTooltip(_id);
|
||||
|
||||
const handleMenuEvent = isReduceMotionEnabled
|
||||
? {
|
||||
...statusTooltipHandlers,
|
||||
onMouseEnter: (e: MouseEvent<HTMLElement>) => {
|
||||
setShowButton(true);
|
||||
statusTooltipHandlers.onMouseEnter(e);
|
||||
},
|
||||
}
|
||||
: {
|
||||
...statusTooltipHandlers,
|
||||
onTransitionEnd: () => setShowButton(true),
|
||||
};
|
||||
|
||||
return (
|
||||
<Option
|
||||
{...focusProps}
|
||||
aria-label={nameOrUsername}
|
||||
onFocus={(e) => {
|
||||
focusProps.onFocus?.(e);
|
||||
setShowButton(true);
|
||||
}}
|
||||
focus={isFocusVisible}
|
||||
data-username={username}
|
||||
data-userid={_id}
|
||||
data-invitationdate={invitationDate}
|
||||
onClick={onClickView}
|
||||
onKeyDown={(e: KeyboardEvent<HTMLElement>) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
e.currentTarget.click();
|
||||
}
|
||||
}}
|
||||
tabIndex={0}
|
||||
style={{ paddingInline: 24 }}
|
||||
{...handleMenuEvent}
|
||||
>
|
||||
<OptionAvatar>
|
||||
<UserAvatar username={username || ''} size='x28' />
|
||||
</OptionAvatar>
|
||||
<OptionColumn>{federated ? <Icon name='globe' size='x16' /> : <ReactiveUserStatus uid={_id} />}</OptionColumn>
|
||||
<OptionContent data-qa={`MemberItem-${username}`}>
|
||||
{nameOrUsername} {displayUsername && <OptionDescription>@{displayUsername}</OptionDescription>}
|
||||
</OptionContent>
|
||||
{subscription?.status === 'INVITED' && (
|
||||
<OptionColumn>
|
||||
<InvitationBadge marginBlockStart={2} size='x20' invitationDate={subscription.ts} />
|
||||
</OptionColumn>
|
||||
)}
|
||||
<OptionMenu onClick={preventPropagation}>
|
||||
{showButton ? (
|
||||
<UserActions
|
||||
username={username}
|
||||
name={name}
|
||||
rid={rid}
|
||||
_id={_id}
|
||||
freeSwitchExtension={freeSwitchExtension}
|
||||
isInvited={isInvited}
|
||||
reload={reload}
|
||||
/>
|
||||
) : (
|
||||
<IconButton tiny icon='kebab' aria-hidden tabIndex={-1} />
|
||||
)}
|
||||
</OptionMenu>
|
||||
</Option>
|
||||
);
|
||||
};
|
||||
|
||||
export default Object.assign(RoomMembersItem, {
|
||||
Skeleton: OptionSkeleton,
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useMergedRefs } from '@rocket.chat/fuselage-hooks';
|
||||
import type { ForwardedRef, HTMLAttributes } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useMembersListNavigation } from './useMembersListNavigation';
|
||||
|
||||
export type RoomMembersListWrapperProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
const RoomMembersListWrapper = forwardRef(function RoomMembersListWrapper(
|
||||
props: RoomMembersListWrapperProps,
|
||||
ref: ForwardedRef<HTMLDivElement>,
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const { membersListRef } = useMembersListNavigation();
|
||||
const mergedRefs = useMergedRefs(ref, membersListRef);
|
||||
|
||||
return <div role='list' aria-label={t('Members')} ref={mergedRefs} {...props} />;
|
||||
});
|
||||
|
||||
export default RoomMembersListWrapper;
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { IRoom } from '@rocket.chat/core-typings';
|
||||
import type { MouseEvent } from 'react';
|
||||
import { memo } from 'react';
|
||||
|
||||
import RoomMembersItem from './RoomMembersItem';
|
||||
import type { RoomMember } from '../../../hooks/useMembersList';
|
||||
|
||||
export type RoomMembersRowProps = {
|
||||
user: Pick<RoomMember, 'federated' | 'username' | 'name' | '_id' | 'freeSwitchExtension' | 'subscription'>;
|
||||
data: {
|
||||
onClickView: (e: MouseEvent<HTMLElement>) => void;
|
||||
rid: IRoom['_id'];
|
||||
};
|
||||
index: number;
|
||||
reload: () => void;
|
||||
useRealName: boolean;
|
||||
};
|
||||
|
||||
const RoomMembersRow = ({ user, data: { onClickView, rid }, index, reload, useRealName }: RoomMembersRowProps) => {
|
||||
if (!user?._id) {
|
||||
return <RoomMembersItem.Skeleton />;
|
||||
}
|
||||
|
||||
return (
|
||||
<RoomMembersItem
|
||||
key={index}
|
||||
useRealName={useRealName}
|
||||
username={user.username}
|
||||
_id={user._id}
|
||||
rid={rid}
|
||||
name={user.name}
|
||||
federated={user.federated}
|
||||
freeSwitchExtension={user.freeSwitchExtension}
|
||||
subscription={user.subscription}
|
||||
onClickView={onClickView}
|
||||
reload={reload}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(RoomMembersRow);
|
||||
@@ -0,0 +1,142 @@
|
||||
import type { IRoom, IUser } from '@rocket.chat/core-typings';
|
||||
import { isRoomFederated, isDirectMessageRoom, isTeamRoom, isRoomNativeFederated } from '@rocket.chat/core-typings';
|
||||
import { useStableCallback, useDebouncedValue, useLocalStorage } from '@rocket.chat/fuselage-hooks';
|
||||
import {
|
||||
useUserRoom,
|
||||
useAtLeastOnePermission,
|
||||
useUser,
|
||||
usePermission,
|
||||
useUserSubscription,
|
||||
useRoomToolbox,
|
||||
} from '@rocket.chat/ui-contexts';
|
||||
import type { ChangeEvent, MouseEvent } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import AddUsers from './AddUsers';
|
||||
import InviteUsers from './InviteUsers';
|
||||
import RoomMembers from './RoomMembers';
|
||||
import * as Federation from '../../../../lib/federation/Federation';
|
||||
import { useMembersList } from '../../../hooks/useMembersList';
|
||||
import UserInfoWithData from '../UserInfo';
|
||||
|
||||
enum ROOM_MEMBERS_TABS {
|
||||
INFO = 'user-info',
|
||||
INVITE = 'invite-users',
|
||||
ADD = 'add-users',
|
||||
LIST = 'users-list',
|
||||
}
|
||||
|
||||
type validRoomType = 'd' | 'p' | 'c';
|
||||
|
||||
export type RoomMembersWithDataProps = { rid: IRoom['_id'] };
|
||||
|
||||
const RoomMembersWithData = ({ rid }: RoomMembersWithDataProps) => {
|
||||
const user = useUser();
|
||||
const room = useUserRoom(rid);
|
||||
const { closeTab } = useRoomToolbox();
|
||||
const [type, setType] = useLocalStorage<'online' | 'all'>('members-list-type', 'online');
|
||||
const [text, setText] = useState('');
|
||||
const subscription = useUserSubscription(rid);
|
||||
|
||||
const isTeam = room && isTeamRoom(room);
|
||||
const isDirect = room && isDirectMessageRoom(room);
|
||||
const hasPermissionToCreateInviteLinks = usePermission('create-invite-links', rid);
|
||||
const isFederated = room && isRoomFederated(room);
|
||||
// we are dropping the non native federation for now
|
||||
const isFederationBlocked = room && !isRoomNativeFederated(room);
|
||||
|
||||
const canCreateInviteLinks =
|
||||
room && user && isFederated && !isFederationBlocked
|
||||
? Federation.canCreateInviteLinks(user, room, subscription)
|
||||
: hasPermissionToCreateInviteLinks;
|
||||
|
||||
const [state, setState] = useState<{ tab: ROOM_MEMBERS_TABS; user?: { id?: IUser['_id']; invitationDate?: string } }>({
|
||||
tab: ROOM_MEMBERS_TABS.LIST,
|
||||
user: undefined,
|
||||
});
|
||||
|
||||
const debouncedText = useDebouncedValue(text, 800);
|
||||
|
||||
const { data, fetchNextPage, isPending, isSuccess, refetch, hasNextPage } = useMembersList(
|
||||
useMemo(() => ({ rid, type, limit: 20, debouncedText, roomType: room?.t as validRoomType }), [rid, type, debouncedText, room?.t]),
|
||||
);
|
||||
|
||||
const hasPermissionToAddUsers = useAtLeastOnePermission(
|
||||
useMemo(() => [room?.t === 'p' ? 'add-user-to-any-p-room' : 'add-user-to-any-c-room', 'add-user-to-joined-room'], [room?.t]),
|
||||
rid,
|
||||
);
|
||||
|
||||
const canAddUsers =
|
||||
room && user && isFederated && !isFederationBlocked
|
||||
? Federation.isEditableByTheUser(user, room, subscription)
|
||||
: hasPermissionToAddUsers;
|
||||
|
||||
const handleTextChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
|
||||
setText(event.currentTarget.value);
|
||||
}, []);
|
||||
|
||||
const openUserInfo = useStableCallback((e: MouseEvent<HTMLElement>) => {
|
||||
const { userid: userId, invitationdate: invitationDate } = e.currentTarget.dataset;
|
||||
setState({
|
||||
tab: ROOM_MEMBERS_TABS.INFO,
|
||||
user: { id: userId, invitationDate },
|
||||
});
|
||||
});
|
||||
|
||||
const openInvite = useStableCallback(() => {
|
||||
setState({ tab: ROOM_MEMBERS_TABS.INVITE });
|
||||
});
|
||||
|
||||
const openAddUser = useStableCallback(() => {
|
||||
setState({ tab: ROOM_MEMBERS_TABS.ADD });
|
||||
});
|
||||
|
||||
const handleBack = useStableCallback(() => {
|
||||
setState({ tab: ROOM_MEMBERS_TABS.LIST });
|
||||
});
|
||||
|
||||
if (state.tab === ROOM_MEMBERS_TABS.INFO && state.user?.id) {
|
||||
return (
|
||||
<UserInfoWithData
|
||||
rid={rid}
|
||||
uid={state.user.id}
|
||||
invitationDate={state.user.invitationDate}
|
||||
onClose={closeTab}
|
||||
onClickBack={handleBack}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.tab === ROOM_MEMBERS_TABS.INVITE) {
|
||||
return <InviteUsers rid={rid} onClickBack={handleBack} />;
|
||||
}
|
||||
|
||||
if (state.tab === ROOM_MEMBERS_TABS.ADD) {
|
||||
return <AddUsers rid={rid} onClickBack={handleBack} reload={refetch} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<RoomMembers
|
||||
rid={rid}
|
||||
isTeam={isTeam}
|
||||
isDirect={isDirect}
|
||||
isPending={isPending}
|
||||
isSuccess={isSuccess}
|
||||
type={type}
|
||||
text={text}
|
||||
setText={handleTextChange}
|
||||
setType={setType}
|
||||
members={data?.pages?.flatMap((page) => page.members) ?? []}
|
||||
total={data?.pages[data.pages.length - 1].total ?? 0}
|
||||
onClickClose={closeTab}
|
||||
onClickView={openUserInfo}
|
||||
loadMoreItems={hasNextPage ? fetchNextPage : () => undefined}
|
||||
reload={refetch}
|
||||
onClickInvite={canCreateInviteLinks && canAddUsers ? openInvite : undefined}
|
||||
onClickAdd={canAddUsers ? openAddUser : undefined}
|
||||
isABACRoom={Boolean(room?.abacAttributes)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default RoomMembersWithData;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
export { default } from './RoomMembersWithData';
|
||||
@@ -0,0 +1,200 @@
|
||||
import { createFocusManager } from '@react-aria/focus';
|
||||
import { renderHook, act, fireEvent, createEvent } from '@testing-library/react';
|
||||
|
||||
import { useMembersListNavigation } from './useMembersListNavigation';
|
||||
|
||||
jest.mock('@react-aria/focus', () => ({
|
||||
createFocusManager: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockFocusManager = {
|
||||
focusNext: jest.fn(),
|
||||
focusPrevious: jest.fn(),
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
jest.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
|
||||
cb(0);
|
||||
return 0;
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
(createFocusManager as jest.Mock).mockReturnValue(mockFocusManager);
|
||||
mockFocusManager.focusNext.mockReset().mockReturnValue(document.createElement('li'));
|
||||
mockFocusManager.focusPrevious.mockReset().mockReturnValue(document.createElement('li'));
|
||||
});
|
||||
|
||||
const createList = (itemCount = 2) => {
|
||||
const container = document.createElement('div');
|
||||
const items = Array.from({ length: itemCount }, () => {
|
||||
const li = document.createElement('li');
|
||||
container.appendChild(li);
|
||||
return li;
|
||||
});
|
||||
document.body.appendChild(container);
|
||||
return { container, items, cleanup: () => document.body.removeChild(container) };
|
||||
};
|
||||
|
||||
const setupHook = (container: HTMLElement) => {
|
||||
const { result } = renderHook(() => useMembersListNavigation());
|
||||
act(() => result.current.membersListRef(container));
|
||||
return result;
|
||||
};
|
||||
|
||||
it('creates a focus manager scoped to the list node', () => {
|
||||
const { container, cleanup } = createList();
|
||||
setupHook(container);
|
||||
|
||||
expect(createFocusManager).toHaveBeenCalledWith({ current: container });
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('calls focusNext with an LI-matching predicate on ArrowDown', () => {
|
||||
const { container, items, cleanup } = createList();
|
||||
setupHook(container);
|
||||
|
||||
fireEvent.keyDown(items[0], { key: 'ArrowDown' });
|
||||
|
||||
expect(mockFocusManager.focusNext).toHaveBeenCalledTimes(1);
|
||||
expect(mockFocusManager.focusPrevious).not.toHaveBeenCalled();
|
||||
|
||||
const { accept } = mockFocusManager.focusNext.mock.calls[0][0];
|
||||
expect(accept(document.createElement('li'))).toBe(true);
|
||||
expect(accept(document.createElement('div'))).toBe(false);
|
||||
expect(accept(document.createElement('button'))).toBe(false);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('calls focusPrevious with an LI-matching predicate on ArrowUp', () => {
|
||||
const { container, items, cleanup } = createList();
|
||||
setupHook(container);
|
||||
|
||||
fireEvent.keyDown(items[1], { key: 'ArrowUp' });
|
||||
|
||||
expect(mockFocusManager.focusPrevious).toHaveBeenCalledTimes(1);
|
||||
expect(mockFocusManager.focusNext).not.toHaveBeenCalled();
|
||||
|
||||
const { accept } = mockFocusManager.focusPrevious.mock.calls[0][0];
|
||||
expect(accept(document.createElement('li'))).toBe(true);
|
||||
expect(accept(document.createElement('div'))).toBe(false);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('ignores events whose target is not an LI element', () => {
|
||||
const { container, cleanup } = createList(0);
|
||||
setupHook(container);
|
||||
|
||||
const div = document.createElement('div');
|
||||
container.appendChild(div);
|
||||
fireEvent.keyDown(div, { key: 'ArrowDown' });
|
||||
|
||||
expect(mockFocusManager.focusNext).not.toHaveBeenCalled();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('ignores non-arrow keys', () => {
|
||||
const { container, items, cleanup } = createList();
|
||||
setupHook(container);
|
||||
|
||||
['Enter', 'Tab', ' ', 'Escape'].forEach((key) => fireEvent.keyDown(items[0], { key }));
|
||||
|
||||
expect(mockFocusManager.focusNext).not.toHaveBeenCalled();
|
||||
expect(mockFocusManager.focusPrevious).not.toHaveBeenCalled();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('prevents default and stops propagation on arrow keys', () => {
|
||||
const { container, items, cleanup } = createList();
|
||||
setupHook(container);
|
||||
|
||||
const event = createEvent.keyDown(items[0], { key: 'ArrowDown' });
|
||||
jest.spyOn(event, 'stopPropagation');
|
||||
fireEvent(items[0], event);
|
||||
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(event.stopPropagation).toHaveBeenCalled();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('removes the event listener when called with null', () => {
|
||||
const { container, items, cleanup } = createList();
|
||||
const result = setupHook(container);
|
||||
|
||||
act(() => result.current.membersListRef(null));
|
||||
const afterNull = createEvent.keyDown(items[0], { key: 'ArrowDown' });
|
||||
fireEvent(items[0], afterNull);
|
||||
|
||||
expect(mockFocusManager.focusNext).not.toHaveBeenCalled();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('replaces the listener without duplicating it when called twice with the same node', () => {
|
||||
const { container, items, cleanup } = createList();
|
||||
const result = setupHook(container);
|
||||
|
||||
act(() => result.current.membersListRef(container));
|
||||
const secondCall = createEvent.keyDown(items[0], { key: 'ArrowDown' });
|
||||
fireEvent(items[0], secondCall);
|
||||
|
||||
expect(mockFocusManager.focusNext).toHaveBeenCalledTimes(1);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe('when the adjacent item is outside the render window', () => {
|
||||
beforeEach(() => {
|
||||
mockFocusManager.focusNext.mockReturnValue(null);
|
||||
mockFocusManager.focusPrevious.mockReturnValue(null);
|
||||
});
|
||||
|
||||
const createScrollableSetup = (initialScrollTop = 0) => {
|
||||
const scroller = document.createElement('div');
|
||||
scroller.scrollTop = initialScrollTop;
|
||||
const listNode = document.createElement('div');
|
||||
scroller.appendChild(listNode);
|
||||
document.body.appendChild(scroller);
|
||||
|
||||
const li = document.createElement('li');
|
||||
Object.defineProperty(li, 'offsetHeight', { configurable: true, get: () => 36 });
|
||||
listNode.appendChild(li);
|
||||
|
||||
setupHook(listNode);
|
||||
|
||||
return { scroller, li, cleanup: () => document.body.removeChild(scroller) };
|
||||
};
|
||||
|
||||
it('scrolls down by one item height and retries focusNext', () => {
|
||||
const { scroller, li, cleanup } = createScrollableSetup(0);
|
||||
|
||||
fireEvent.keyDown(li, { key: 'ArrowDown' });
|
||||
|
||||
expect(scroller.scrollTop).toBe(36);
|
||||
expect(mockFocusManager.focusNext).toHaveBeenCalledTimes(2);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('scrolls up by one item height and retries focusPrevious', () => {
|
||||
const { scroller, li, cleanup } = createScrollableSetup(100);
|
||||
|
||||
fireEvent.keyDown(li, { key: 'ArrowUp' });
|
||||
|
||||
expect(scroller.scrollTop).toBe(64);
|
||||
expect(mockFocusManager.focusPrevious).toHaveBeenCalledTimes(2);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('does not scroll or retry when the item has no height', () => {
|
||||
const { container, items, cleanup } = createList();
|
||||
setupHook(container);
|
||||
|
||||
// offsetHeight defaults to 0 in jsdom — the fallback is skipped
|
||||
fireEvent.keyDown(items[0], { key: 'ArrowDown' });
|
||||
|
||||
expect(mockFocusManager.focusNext).toHaveBeenCalledTimes(1);
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { createFocusManager } from '@react-aria/focus';
|
||||
import type { RefCallback } from 'react';
|
||||
import { useCallback, useRef } from 'react';
|
||||
|
||||
const isMemberItem = (node: Element) => node.tagName === 'LI';
|
||||
|
||||
export const useMembersListNavigation = (): { membersListRef: RefCallback<HTMLElement> } => {
|
||||
const cleanupRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const membersListRef: RefCallback<HTMLElement> = useCallback((node) => {
|
||||
cleanupRef.current?.();
|
||||
cleanupRef.current = null;
|
||||
|
||||
if (!node) return;
|
||||
|
||||
const focusManager = createFocusManager({ current: node });
|
||||
|
||||
const onKeyDown = (e: Event) => {
|
||||
if (!(e.target instanceof Element) || !isMemberItem(e.target)) return;
|
||||
if (!(e instanceof KeyboardEvent) || (e.key !== 'ArrowUp' && e.key !== 'ArrowDown')) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const isUp = e.key === 'ArrowUp';
|
||||
const moved = isUp ? focusManager.focusPrevious({ accept: isMemberItem }) : focusManager.focusNext({ accept: isMemberItem });
|
||||
|
||||
if (moved) return;
|
||||
|
||||
// Adjacent item is outside the render window — scroll by one row height and retry
|
||||
let scroller: HTMLElement | null = node.parentElement;
|
||||
while (scroller && getComputedStyle(scroller).overflowY === 'visible') {
|
||||
scroller = scroller.parentElement;
|
||||
}
|
||||
const itemHeight = (e.target as HTMLElement).offsetHeight;
|
||||
if (!scroller || itemHeight <= 0) return;
|
||||
|
||||
scroller.scrollTop = isUp ? Math.max(0, scroller.scrollTop - itemHeight) : scroller.scrollTop + itemHeight;
|
||||
requestAnimationFrame(() => {
|
||||
if (!node.isConnected) return;
|
||||
if (isUp) {
|
||||
focusManager.focusPrevious({ accept: isMemberItem });
|
||||
} else {
|
||||
focusManager.focusNext({ accept: isMemberItem });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
node.addEventListener('keydown', onKeyDown);
|
||||
cleanupRef.current = () => node.removeEventListener('keydown', onKeyDown);
|
||||
}, []);
|
||||
|
||||
return { membersListRef };
|
||||
};
|
||||
Reference in New Issue
Block a user