Merge branch 'group-tests' into 'develop'

Group tests

See merge request soapbox-pub/soapbox!2502
environments/review-develop-3zknud/deployments/3342
Chewbacca 1 year ago
commit a35d95e9e3

@ -0,0 +1,46 @@
import React from 'react';
import { buildGroup } from 'soapbox/jest/factory';
import { render, screen } from 'soapbox/jest/test-helpers';
import { Group } from 'soapbox/types/entities';
import GroupHeader from '../group-header';
let group: Group;
describe('<GroupHeader />', () => {
describe('without a group', () => {
it('should render the blankslate', () => {
render(<GroupHeader group={null} />);
expect(screen.getByTestId('group-header-missing')).toBeInTheDocument();
});
});
describe('when the Group has been deleted', () => {
it('only shows name, header, and avatar', () => {
group = buildGroup({ display_name: 'my group', deleted_at: new Date().toISOString() });
render(<GroupHeader group={group} />);
expect(screen.queryAllByTestId('group-header-missing')).toHaveLength(0);
expect(screen.queryAllByTestId('group-actions')).toHaveLength(0);
expect(screen.queryAllByTestId('group-meta')).toHaveLength(0);
expect(screen.getByTestId('group-header-image')).toBeInTheDocument();
expect(screen.getByTestId('group-avatar')).toBeInTheDocument();
expect(screen.getByTestId('group-name')).toBeInTheDocument();
});
});
describe('with a valid Group', () => {
it('only shows all fields', () => {
group = buildGroup({ display_name: 'my group', deleted_at: null });
render(<GroupHeader group={group} />);
expect(screen.queryAllByTestId('group-header-missing')).toHaveLength(0);
expect(screen.getByTestId('group-actions')).toBeInTheDocument();
expect(screen.getByTestId('group-meta')).toBeInTheDocument();
expect(screen.getByTestId('group-header-image')).toBeInTheDocument();
expect(screen.getByTestId('group-avatar')).toBeInTheDocument();
expect(screen.getByTestId('group-name')).toBeInTheDocument();
});
});
});

@ -0,0 +1,320 @@
import userEvent from '@testing-library/user-event';
import React from 'react';
import { __stub } from 'soapbox/api';
import { buildGroup, buildGroupMember, buildGroupRelationship } from 'soapbox/jest/factory';
import { render, screen, waitFor } from 'soapbox/jest/test-helpers';
import { GroupRoles } from 'soapbox/schemas/group-member';
import GroupMemberListItem from '../group-member-list-item';
describe('<GroupMemberListItem />', () => {
describe('account rendering', () => {
const accountId = '4';
const groupMember = buildGroupMember({}, {
id: accountId,
display_name: 'tiger woods',
});
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/accounts/${accountId}`).reply(200, groupMember.account);
});
});
it('should render the users avatar', async () => {
const group = buildGroup({
relationship: buildGroupRelationship(),
});
render(<GroupMemberListItem group={group} member={groupMember} canPromoteToAdmin />);
await waitFor(() => {
expect(screen.getByTestId('group-member-list-item')).toHaveTextContent(groupMember.account.display_name);
});
});
});
describe('role badge', () => {
const accountId = '4';
const group = buildGroup();
describe('when the user is an Owner', () => {
const groupMember = buildGroupMember({ role: GroupRoles.OWNER }, {
id: accountId,
display_name: 'tiger woods',
});
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/accounts/${accountId}`).reply(200, groupMember.account);
});
});
it('should render the correct badge', async () => {
render(<GroupMemberListItem group={group} member={groupMember} canPromoteToAdmin />);
await waitFor(() => {
expect(screen.getByTestId('role-badge')).toHaveTextContent('owner');
});
});
});
describe('when the user is an Admin', () => {
const groupMember = buildGroupMember({ role: GroupRoles.ADMIN }, {
id: accountId,
display_name: 'tiger woods',
});
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/accounts/${accountId}`).reply(200, groupMember.account);
});
});
it('should render the correct badge', async () => {
render(<GroupMemberListItem group={group} member={groupMember} canPromoteToAdmin />);
await waitFor(() => {
expect(screen.getByTestId('role-badge')).toHaveTextContent('admin');
});
});
});
describe('when the user is an User', () => {
const groupMember = buildGroupMember({ role: GroupRoles.USER }, {
id: accountId,
display_name: 'tiger woods',
});
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/accounts/${accountId}`).reply(200, groupMember.account);
});
});
it('should render no correct badge', async () => {
render(<GroupMemberListItem group={group} member={groupMember} canPromoteToAdmin />);
await waitFor(() => {
expect(screen.queryAllByTestId('role-badge')).toHaveLength(0);
});
});
});
});
describe('as a Group owner', () => {
const group = buildGroup({
relationship: buildGroupRelationship({
role: GroupRoles.OWNER,
member: true,
}),
});
describe('when the user has role of "user"', () => {
const accountId = '4';
const groupMember = buildGroupMember({}, {
id: accountId,
display_name: 'tiger woods',
username: 'tiger',
});
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/accounts/${accountId}`).reply(200, groupMember.account);
});
});
describe('when "canPromoteToAdmin is true', () => {
it('should render dropdown with correct Owner actions', async () => {
const user = userEvent.setup();
render(<GroupMemberListItem group={group} member={groupMember} canPromoteToAdmin />);
await waitFor(async() => {
await user.click(screen.getByTestId('icon-button'));
});
const dropdownMenu = screen.getByTestId('dropdown-menu');
expect(dropdownMenu).toHaveTextContent('Assign admin role');
expect(dropdownMenu).toHaveTextContent('Kick @tiger from group');
expect(dropdownMenu).toHaveTextContent('Ban from group');
});
});
describe('when "canPromoteToAdmin is false', () => {
it('should prevent promoting user to Admin', async () => {
const user = userEvent.setup();
render(<GroupMemberListItem group={group} member={groupMember} canPromoteToAdmin={false} />);
await waitFor(async() => {
await user.click(screen.getByTestId('icon-button'));
await user.click(screen.getByTitle('Assign admin role'));
});
expect(screen.getByTestId('toast')).toHaveTextContent('Admin limit reached');
});
});
});
describe('when the user has role of "admin"', () => {
const accountId = '4';
const groupMember = buildGroupMember(
{
role: GroupRoles.ADMIN,
},
{
id: accountId,
display_name: 'tiger woods',
username: 'tiger',
},
);
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/accounts/${accountId}`).reply(200, groupMember.account);
});
});
it('should render dropdown with correct Owner actions', async () => {
const user = userEvent.setup();
render(<GroupMemberListItem group={group} member={groupMember} canPromoteToAdmin />);
await waitFor(async() => {
await user.click(screen.getByTestId('icon-button'));
});
const dropdownMenu = screen.getByTestId('dropdown-menu');
expect(dropdownMenu).toHaveTextContent('Remove admin role');
expect(dropdownMenu).toHaveTextContent('Kick @tiger from group');
expect(dropdownMenu).toHaveTextContent('Ban from group');
});
});
});
describe('as a Group admin', () => {
const group = buildGroup({
relationship: buildGroupRelationship({
role: GroupRoles.ADMIN,
member: true,
}),
});
describe('when the user has role of "user"', () => {
const accountId = '4';
const groupMember = buildGroupMember({}, {
id: accountId,
display_name: 'tiger woods',
username: 'tiger',
});
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/accounts/${accountId}`).reply(200, groupMember.account);
});
});
it('should render dropdown with correct Admin actions', async () => {
const user = userEvent.setup();
render(<GroupMemberListItem group={group} member={groupMember} canPromoteToAdmin />);
await waitFor(async() => {
await user.click(screen.getByTestId('icon-button'));
});
const dropdownMenu = screen.getByTestId('dropdown-menu');
expect(dropdownMenu).not.toHaveTextContent('Assign admin role');
expect(dropdownMenu).toHaveTextContent('Kick @tiger from group');
expect(dropdownMenu).toHaveTextContent('Ban from group');
});
});
describe('when the user has role of "admin"', () => {
const accountId = '4';
const groupMember = buildGroupMember(
{
role: GroupRoles.ADMIN,
},
{
id: accountId,
display_name: 'tiger woods',
username: 'tiger',
},
);
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/accounts/${accountId}`).reply(200, groupMember.account);
});
});
it('should not render the dropdown', async () => {
render(<GroupMemberListItem group={group} member={groupMember} canPromoteToAdmin />);
await waitFor(async() => {
expect(screen.queryAllByTestId('icon-button')).toHaveLength(0);
});
});
});
describe('when the user has role of "owner"', () => {
const accountId = '4';
const groupMember = buildGroupMember(
{
role: GroupRoles.OWNER,
},
{
id: accountId,
display_name: 'tiger woods',
username: 'tiger',
},
);
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/accounts/${accountId}`).reply(200, groupMember.account);
});
});
it('should not render the dropdown', async () => {
render(<GroupMemberListItem group={group} member={groupMember} canPromoteToAdmin />);
await waitFor(async() => {
expect(screen.queryAllByTestId('icon-button')).toHaveLength(0);
});
});
});
});
describe('as a Group user', () => {
const group = buildGroup({
relationship: buildGroupRelationship({
role: GroupRoles.USER,
member: true,
}),
});
const accountId = '4';
const groupMember = buildGroupMember({}, {
id: accountId,
display_name: 'tiger woods',
username: 'tiger',
});
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/accounts/${accountId}`).reply(200, groupMember.account);
});
});
it('should not render the dropdown', async () => {
render(<GroupMemberListItem group={group} member={groupMember} canPromoteToAdmin />);
await waitFor(async() => {
expect(screen.queryAllByTestId('icon-button')).toHaveLength(0);
});
});
});
});

@ -0,0 +1,123 @@
import React from 'react';
import { buildGroup, buildGroupTag, buildGroupRelationship } from 'soapbox/jest/factory';
import { render, screen } from 'soapbox/jest/test-helpers';
import { GroupRoles } from 'soapbox/schemas/group-member';
import GroupTagListItem from '../group-tag-list-item';
describe('<GroupTagListItem />', () => {
describe('tag name', () => {
const name = 'hello';
it('should render the tag name', () => {
const group = buildGroup();
const tag = buildGroupTag({ name });
render(<GroupTagListItem group={group} tag={tag} isPinnable />);
expect(screen.getByTestId('group-tag-list-item')).toHaveTextContent(`#${name}`);
});
describe('when the tag is "visible"', () => {
const group = buildGroup();
const tag = buildGroupTag({ name, visible: true });
it('renders the default name', () => {
render(<GroupTagListItem group={group} tag={tag} isPinnable />);
expect(screen.getByTestId('group-tag-name')).toHaveClass('text-gray-900');
});
});
describe('when the tag is not "visible" and user is Owner', () => {
const group = buildGroup({
relationship: buildGroupRelationship({
role: GroupRoles.OWNER,
member: true,
}),
});
const tag = buildGroupTag({
name,
visible: false,
});
it('renders the subtle name', () => {
render(<GroupTagListItem group={group} tag={tag} isPinnable />);
expect(screen.getByTestId('group-tag-name')).toHaveClass('text-gray-400');
});
});
describe('when the tag is not "visible" and user is Admin or User', () => {
const group = buildGroup({
relationship: buildGroupRelationship({
role: GroupRoles.ADMIN,
member: true,
}),
});
const tag = buildGroupTag({
name,
visible: false,
});
it('renders the subtle name', () => {
render(<GroupTagListItem group={group} tag={tag} isPinnable />);
expect(screen.getByTestId('group-tag-name')).toHaveClass('text-gray-900');
});
});
});
describe('pinning', () => {
describe('as an owner', () => {
const group = buildGroup({
relationship: buildGroupRelationship({
role: GroupRoles.OWNER,
member: true,
}),
});
describe('when the tag is visible', () => {
const tag = buildGroupTag({ visible: true });
it('renders the pin icon', () => {
render(<GroupTagListItem group={group} tag={tag} isPinnable />);
expect(screen.getByTestId('pin-icon')).toBeInTheDocument();
});
});
describe('when the tag is not visible', () => {
const tag = buildGroupTag({ visible: false });
it('does not render the pin icon', () => {
render(<GroupTagListItem group={group} tag={tag} isPinnable />);
expect(screen.queryAllByTestId('pin-icon')).toHaveLength(0);
});
});
describe('as a non-owner', () => {
const group = buildGroup({
relationship: buildGroupRelationship({
role: GroupRoles.ADMIN,
member: true,
}),
});
describe('when the tag is visible', () => {
const tag = buildGroupTag({ visible: true });
it('does not render the pin icon', () => {
render(<GroupTagListItem group={group} tag={tag} isPinnable />);
expect(screen.queryAllByTestId('pin-icon')).toHaveLength(0);
});
});
describe('when the tag is not visible', () => {
const tag = buildGroupTag({ visible: false });
it('does not render the pin icon', () => {
render(<GroupTagListItem group={group} tag={tag} isPinnable />);
expect(screen.queryAllByTestId('pin-icon')).toHaveLength(0);
});
});
});
});
});
});

@ -34,7 +34,7 @@ const GroupHeader: React.FC<IGroupHeader> = ({ group }) => {
if (!group) { if (!group) {
return ( return (
<div className='-mx-4 -mt-4 sm:-mx-6 sm:-mt-6'> <div className='-mx-4 -mt-4 sm:-mx-6 sm:-mt-6' data-testid='group-header-missing'>
<div> <div>
<div className='relative h-32 w-full bg-gray-200 dark:bg-gray-900/50 md:rounded-t-xl lg:h-48' /> <div className='relative h-32 w-full bg-gray-200 dark:bg-gray-900/50 md:rounded-t-xl lg:h-48' />
</div> </div>
@ -107,7 +107,10 @@ const GroupHeader: React.FC<IGroupHeader> = ({ group }) => {
} }
return ( return (
<div className='flex h-32 w-full items-center justify-center bg-gray-200 dark:bg-gray-800/30 md:rounded-t-xl lg:h-52'> <div
data-testid='group-header-image'
className='flex h-32 w-full items-center justify-center bg-gray-200 dark:bg-gray-800/30 md:rounded-t-xl lg:h-52'
>
{isHeaderMissing ? ( {isHeaderMissing ? (
<Icon src={require('@tabler/icons/photo-off.svg')} className='h-6 w-6 text-gray-500 dark:text-gray-700' /> <Icon src={require('@tabler/icons/photo-off.svg')} className='h-6 w-6 text-gray-500 dark:text-gray-700' />
) : header} ) : header}
@ -120,7 +123,7 @@ const GroupHeader: React.FC<IGroupHeader> = ({ group }) => {
<div className='relative'> <div className='relative'>
{renderHeader()} {renderHeader()}
<div className='absolute bottom-0 left-1/2 -translate-x-1/2 translate-y-1/2'> <div className='absolute bottom-0 left-1/2 -translate-x-1/2 translate-y-1/2' data-testid='group-avatar'>
<a href={group.avatar} onClick={handleAvatarClick} target='_blank'> <a href={group.avatar} onClick={handleAvatarClick} target='_blank'>
<GroupAvatar <GroupAvatar
group={group} group={group}
@ -136,11 +139,12 @@ const GroupHeader: React.FC<IGroupHeader> = ({ group }) => {
size='xl' size='xl'
weight='bold' weight='bold'
dangerouslySetInnerHTML={{ __html: group.display_name_html }} dangerouslySetInnerHTML={{ __html: group.display_name_html }}
data-testid='group-name'
/> />
{!isDeleted && ( {!isDeleted && (
<> <>
<Stack space={1} alignItems='center'> <Stack data-testid='group-meta' space={1} alignItems='center'>
<HStack className='text-gray-700 dark:text-gray-600' space={2} wrap> <HStack className='text-gray-700 dark:text-gray-600' space={2} wrap>
<GroupRelationship group={group} /> <GroupRelationship group={group} />
<GroupPrivacy group={group} /> <GroupPrivacy group={group} />
@ -154,7 +158,7 @@ const GroupHeader: React.FC<IGroupHeader> = ({ group }) => {
/> />
</Stack> </Stack>
<HStack alignItems='center' space={2}> <HStack alignItems='center' space={2} data-testid='group-actions'>
<GroupOptionsButton group={group} /> <GroupOptionsButton group={group} />
<GroupActionButton group={group} /> <GroupActionButton group={group} />
</HStack> </HStack>

@ -180,7 +180,11 @@ const GroupMemberListItem = (props: IGroupMemberListItem) => {
} }
return ( return (
<HStack alignItems='center' justifyContent='between'> <HStack
alignItems='center'
justifyContent='between'
data-testid='group-member-list-item'
>
<div className='w-full'> <div className='w-full'>
<Account account={member.account} withRelationship={false} /> <Account account={member.account} withRelationship={false} />
</div> </div>
@ -188,6 +192,7 @@ const GroupMemberListItem = (props: IGroupMemberListItem) => {
<HStack alignItems='center' space={2}> <HStack alignItems='center' space={2}>
{(isMemberOwner || isMemberAdmin) ? ( {(isMemberOwner || isMemberAdmin) ? (
<span <span
data-testid='role-badge'
className={ className={
clsx('inline-flex items-center rounded px-2 py-1 text-xs font-medium capitalize', { clsx('inline-flex items-center rounded px-2 py-1 text-xs font-medium capitalize', {
'bg-primary-200 text-primary-500': isMemberOwner, 'bg-primary-200 text-primary-500': isMemberOwner,

@ -102,6 +102,7 @@ const GroupTagListItem = (props: IGroupMemberListItem) => {
require('@tabler/icons/pin.svg') require('@tabler/icons/pin.svg')
} }
iconClassName='h-5 w-5 text-primary-500 dark:text-accent-blue' iconClassName='h-5 w-5 text-primary-500 dark:text-accent-blue'
data-testid='pin-icon'
/> />
</Tooltip> </Tooltip>
); );
@ -123,13 +124,18 @@ const GroupTagListItem = (props: IGroupMemberListItem) => {
}; };
return ( return (
<HStack alignItems='center' justifyContent='between'> <HStack
alignItems='center'
justifyContent='between'
data-testid='group-tag-list-item'
>
<Link to={`/group/${group.slug}/tag/${tag.id}`} className='group grow'> <Link to={`/group/${group.slug}/tag/${tag.id}`} className='group grow'>
<Stack> <Stack>
<Text <Text
weight='bold' weight='bold'
theme={(tag.visible || !isOwner) ? 'default' : 'subtle'} theme={(tag.visible || !isOwner) ? 'default' : 'subtle'}
className='group-hover:underline' className='group-hover:underline'
data-testid='group-tag-name'
> >
#{tag.name} #{tag.name}
</Text> </Text>
@ -137,7 +143,7 @@ const GroupTagListItem = (props: IGroupMemberListItem) => {
{intl.formatMessage(messages.total)}: {intl.formatMessage(messages.total)}:
{' '} {' '}
<Text size='sm' theme='inherit' weight='semibold' tag='span'> <Text size='sm' theme='inherit' weight='semibold' tag='span'>
{shortNumberFormat(tag.groups)} {shortNumberFormat(tag.uses)}
</Text> </Text>
</Text> </Text>
</Stack> </Stack>

@ -1,23 +1,34 @@
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { import {
accountSchema,
adSchema, adSchema,
cardSchema, cardSchema,
groupSchema, groupMemberSchema,
groupRelationshipSchema, groupRelationshipSchema,
groupSchema,
groupTagSchema, groupTagSchema,
relationshipSchema, relationshipSchema,
type Account,
type Ad, type Ad,
type Card, type Card,
type Group, type Group,
type GroupMember,
type GroupRelationship, type GroupRelationship,
type GroupTag, type GroupTag,
type Relationship, type Relationship,
} from 'soapbox/schemas'; } from 'soapbox/schemas';
import { GroupRoles } from 'soapbox/schemas/group-member';
// TODO: there's probably a better way to create these factory functions. // TODO: there's probably a better way to create these factory functions.
// This looks promising but didn't work on my first attempt: https://github.com/anatine/zod-plugins/tree/main/packages/zod-mock // This looks promising but didn't work on my first attempt: https://github.com/anatine/zod-plugins/tree/main/packages/zod-mock
function buildAccount(props: Partial<Account> = {}): Account {
return accountSchema.parse(Object.assign({
id: uuidv4(),
}, props));
}
function buildCard(props: Partial<Card> = {}): Card { function buildCard(props: Partial<Card> = {}): Card {
return cardSchema.parse(Object.assign({ return cardSchema.parse(Object.assign({
url: 'https://soapbox.test', url: 'https://soapbox.test',
@ -39,6 +50,18 @@ function buildGroupRelationship(props: Partial<GroupRelationship> = {}): GroupRe
function buildGroupTag(props: Partial<GroupTag> = {}): GroupTag { function buildGroupTag(props: Partial<GroupTag> = {}): GroupTag {
return groupTagSchema.parse(Object.assign({ return groupTagSchema.parse(Object.assign({
id: uuidv4(), id: uuidv4(),
name: uuidv4(),
}, props));
}
function buildGroupMember(
props: Partial<GroupMember> = {},
accountProps: Partial<Account> = {},
): GroupMember {
return groupMemberSchema.parse(Object.assign({
id: uuidv4(),
account: buildAccount(accountProps),
role: GroupRoles.USER,
}, props)); }, props));
} }
@ -55,10 +78,11 @@ function buildRelationship(props: Partial<Relationship> = {}): Relationship {
} }
export { export {
buildAd,
buildCard, buildCard,
buildGroup, buildGroup,
buildGroupMember,
buildGroupRelationship, buildGroupRelationship,
buildGroupTag, buildGroupTag,
buildAd,
buildRelationship, buildRelationship,
}; };

@ -123,17 +123,19 @@ const GroupPage: React.FC<IGroupPage> = ({ params, children }) => {
}); });
} }
items.push({ items.push(
text: intl.formatMessage(messages.members), {
to: `/group/${group?.slug}/members`, text: intl.formatMessage(messages.media),
name: '/group/:groupSlug/members', to: `/group/${group?.slug}/media`,
count: pending.length, name: '/group/:groupSlug/media',
}, },
{ {
text: intl.formatMessage(messages.media), text: intl.formatMessage(messages.members),
to: `/group/${group?.slug}/media`, to: `/group/${group?.slug}/members`,
name: '/group/:groupSlug/media', name: '/group/:groupSlug/members',
}); count: pending.length,
},
);
return items; return items;
}, [features.groupsTags, pending.length]); }, [features.groupsTags, pending.length]);

@ -5,6 +5,7 @@ const groupTagSchema = z.object({
name: z.string(), name: z.string(),
groups: z.number().optional(), groups: z.number().optional(),
url: z.string().optional(), url: z.string().optional(),
uses: z.number().optional(),
pinned: z.boolean().optional().catch(false), pinned: z.boolean().optional().catch(false),
visible: z.boolean().optional().default(true), visible: z.boolean().optional().default(true),
}); });

Loading…
Cancel
Save