Add support for Group search See merge request soapbox-pub/soapbox!2314environments/review-my-groups-80rb64/deployments/2745
commit
721b5dafcd
@ -0,0 +1,130 @@
|
||||
import React from 'react';
|
||||
|
||||
import { render, screen } from 'soapbox/jest/test-helpers';
|
||||
import { normalizeGroup, normalizeGroupRelationship } from 'soapbox/normalizers';
|
||||
import { Group } from 'soapbox/types/entities';
|
||||
|
||||
import GroupActionButton from '../group-action-button';
|
||||
|
||||
let group: Group;
|
||||
|
||||
describe('<GroupActionButton />', () => {
|
||||
describe('with no group relationship', () => {
|
||||
beforeEach(() => {
|
||||
group = normalizeGroup({
|
||||
relationship: null,
|
||||
});
|
||||
});
|
||||
|
||||
describe('with a private group', () => {
|
||||
beforeEach(() => {
|
||||
group = group.set('locked', true);
|
||||
});
|
||||
|
||||
it('should render the Request Access button', () => {
|
||||
render(<GroupActionButton group={group} />);
|
||||
|
||||
expect(screen.getByRole('button')).toHaveTextContent('Request Access');
|
||||
});
|
||||
});
|
||||
|
||||
describe('with a public group', () => {
|
||||
beforeEach(() => {
|
||||
group = group.set('locked', false);
|
||||
});
|
||||
|
||||
it('should render the Join Group button', () => {
|
||||
render(<GroupActionButton group={group} />);
|
||||
|
||||
expect(screen.getByRole('button')).toHaveTextContent('Join Group');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with no group relationship member', () => {
|
||||
beforeEach(() => {
|
||||
group = normalizeGroup({
|
||||
relationship: normalizeGroupRelationship({
|
||||
member: null,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
describe('with a private group', () => {
|
||||
beforeEach(() => {
|
||||
group = group.set('locked', true);
|
||||
});
|
||||
|
||||
it('should render the Request Access button', () => {
|
||||
render(<GroupActionButton group={group} />);
|
||||
|
||||
expect(screen.getByRole('button')).toHaveTextContent('Request Access');
|
||||
});
|
||||
});
|
||||
|
||||
describe('with a public group', () => {
|
||||
beforeEach(() => {
|
||||
group = group.set('locked', false);
|
||||
});
|
||||
|
||||
it('should render the Join Group button', () => {
|
||||
render(<GroupActionButton group={group} />);
|
||||
|
||||
expect(screen.getByRole('button')).toHaveTextContent('Join Group');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the user has requested to join', () => {
|
||||
beforeEach(() => {
|
||||
group = normalizeGroup({
|
||||
relationship: normalizeGroupRelationship({
|
||||
requested: true,
|
||||
member: true,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('should render the Cancel Request button', () => {
|
||||
render(<GroupActionButton group={group} />);
|
||||
|
||||
expect(screen.getByRole('button')).toHaveTextContent('Cancel Request');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the user is an Admin', () => {
|
||||
beforeEach(() => {
|
||||
group = normalizeGroup({
|
||||
relationship: normalizeGroupRelationship({
|
||||
requested: false,
|
||||
member: true,
|
||||
role: 'admin',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('should render the Manage Group button', () => {
|
||||
render(<GroupActionButton group={group} />);
|
||||
|
||||
expect(screen.getByRole('button')).toHaveTextContent('Manage Group');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the user is just a member', () => {
|
||||
beforeEach(() => {
|
||||
group = normalizeGroup({
|
||||
relationship: normalizeGroupRelationship({
|
||||
requested: false,
|
||||
member: true,
|
||||
role: 'user',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('should render the Leave Group button', () => {
|
||||
render(<GroupActionButton group={group} />);
|
||||
|
||||
expect(screen.getByRole('button')).toHaveTextContent('Leave Group');
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,69 @@
|
||||
import React from 'react';
|
||||
|
||||
import { render, screen } from 'soapbox/jest/test-helpers';
|
||||
import { normalizeGroup } from 'soapbox/normalizers';
|
||||
import { Group } from 'soapbox/types/entities';
|
||||
|
||||
import GroupMemberCount from '../group-member-count';
|
||||
|
||||
let group: Group;
|
||||
|
||||
describe('<GroupMemberCount />', () => {
|
||||
describe('without support for "members_count"', () => {
|
||||
beforeEach(() => {
|
||||
group = normalizeGroup({
|
||||
members_count: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return null', () => {
|
||||
render(<GroupMemberCount group={group} />);
|
||||
|
||||
expect(screen.queryAllByTestId('group-member-count')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with support for "members_count"', () => {
|
||||
describe('with 1 member', () => {
|
||||
beforeEach(() => {
|
||||
group = normalizeGroup({
|
||||
members_count: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('should render correctly', () => {
|
||||
render(<GroupMemberCount group={group} />);
|
||||
|
||||
expect(screen.getByTestId('group-member-count').textContent).toEqual('1 member');
|
||||
});
|
||||
});
|
||||
|
||||
describe('with 2 members', () => {
|
||||
beforeEach(() => {
|
||||
group = normalizeGroup({
|
||||
members_count: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('should render correctly', () => {
|
||||
render(<GroupMemberCount group={group} />);
|
||||
|
||||
expect(screen.getByTestId('group-member-count').textContent).toEqual('2 members');
|
||||
});
|
||||
});
|
||||
|
||||
describe('with 1000 members', () => {
|
||||
beforeEach(() => {
|
||||
group = normalizeGroup({
|
||||
members_count: 1000,
|
||||
});
|
||||
});
|
||||
|
||||
it('should render correctly', () => {
|
||||
render(<GroupMemberCount group={group} />);
|
||||
|
||||
expect(screen.getByTestId('group-member-count').textContent).toEqual('1k members');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
|
||||
import { render, screen } from 'soapbox/jest/test-helpers';
|
||||
import { normalizeGroup } from 'soapbox/normalizers';
|
||||
import { Group } from 'soapbox/types/entities';
|
||||
|
||||
import GroupPrivacy from '../group-privacy';
|
||||
|
||||
let group: Group;
|
||||
|
||||
describe('<GroupPrivacy />', () => {
|
||||
describe('with a Private group', () => {
|
||||
beforeEach(() => {
|
||||
group = normalizeGroup({
|
||||
locked: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should render the correct text', () => {
|
||||
render(<GroupPrivacy group={group} />);
|
||||
|
||||
expect(screen.getByTestId('group-privacy')).toHaveTextContent('Private');
|
||||
});
|
||||
});
|
||||
|
||||
describe('with a Public group', () => {
|
||||
beforeEach(() => {
|
||||
group = normalizeGroup({
|
||||
locked: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should render the correct text', () => {
|
||||
render(<GroupPrivacy group={group} />);
|
||||
|
||||
expect(screen.getByTestId('group-privacy')).toHaveTextContent('Public');
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,83 @@
|
||||
import React from 'react';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import { joinGroup, leaveGroup } from 'soapbox/actions/groups';
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import { Button } from 'soapbox/components/ui';
|
||||
import { useAppDispatch } from 'soapbox/hooks';
|
||||
import { Group } from 'soapbox/types/entities';
|
||||
|
||||
interface IGroupActionButton {
|
||||
group: Group
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
confirmationHeading: { id: 'confirmations.leave_group.heading', defaultMessage: 'Leave group' },
|
||||
confirmationMessage: { id: 'confirmations.leave_group.message', defaultMessage: 'You are about to leave the group. Do you want to continue?' },
|
||||
confirmationConfirm: { id: 'confirmations.leave_group.confirm', defaultMessage: 'Leave' },
|
||||
});
|
||||
|
||||
const GroupActionButton = ({ group }: IGroupActionButton) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const intl = useIntl();
|
||||
|
||||
const isNonMember = !group.relationship || !group.relationship.member;
|
||||
const isRequested = group.relationship?.requested;
|
||||
const isAdmin = group.relationship?.role === 'admin';
|
||||
|
||||
const onJoinGroup = () => dispatch(joinGroup(group.id));
|
||||
|
||||
const onLeaveGroup = () =>
|
||||
dispatch(openModal('CONFIRM', {
|
||||
heading: intl.formatMessage(messages.confirmationHeading),
|
||||
message: intl.formatMessage(messages.confirmationMessage),
|
||||
confirm: intl.formatMessage(messages.confirmationConfirm),
|
||||
onConfirm: () => dispatch(leaveGroup(group.id)),
|
||||
}));
|
||||
|
||||
if (isNonMember) {
|
||||
return (
|
||||
<Button
|
||||
theme='primary'
|
||||
onClick={onJoinGroup}
|
||||
>
|
||||
{group.locked
|
||||
? <FormattedMessage id='group.join.private' defaultMessage='Request Access' />
|
||||
: <FormattedMessage id='group.join.public' defaultMessage='Join Group' />}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (isRequested) {
|
||||
return (
|
||||
<Button
|
||||
theme='secondary'
|
||||
onClick={onLeaveGroup}
|
||||
>
|
||||
<FormattedMessage id='group.cancel_request' defaultMessage='Cancel Request' />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (isAdmin) {
|
||||
return (
|
||||
<Button
|
||||
theme='secondary'
|
||||
to={`/groups/${group.id}/manage`}
|
||||
>
|
||||
<FormattedMessage id='group.manage' defaultMessage='Manage Group' />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
theme='secondary'
|
||||
onClick={onLeaveGroup}
|
||||
>
|
||||
<FormattedMessage id='group.leave' defaultMessage='Leave Group' />
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupActionButton;
|
@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Text } from 'soapbox/components/ui';
|
||||
import { Group } from 'soapbox/types/entities';
|
||||
import { shortNumberFormat } from 'soapbox/utils/numbers';
|
||||
|
||||
interface IGroupMemberCount {
|
||||
group: Group
|
||||
}
|
||||
|
||||
const GroupMemberCount = ({ group }: IGroupMemberCount) => {
|
||||
if (typeof group.members_count === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Text theme='inherit' tag='span' size='sm' weight='medium' data-testid='group-member-count'>
|
||||
{shortNumberFormat(group.members_count)}
|
||||
{' '}
|
||||
<FormattedMessage
|
||||
id='groups.discover.search.results.member_count'
|
||||
defaultMessage='{members, plural, one {member} other {members}}'
|
||||
values={{
|
||||
members: group.members_count,
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupMemberCount;
|
@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { HStack, Icon, Text } from 'soapbox/components/ui';
|
||||
import { Group } from 'soapbox/types/entities';
|
||||
|
||||
interface IGroupPolicy {
|
||||
group: Group
|
||||
}
|
||||
|
||||
const GroupPrivacy = ({ group }: IGroupPolicy) => (
|
||||
<HStack space={1} alignItems='center' data-testid='group-privacy'>
|
||||
<Icon
|
||||
className='h-4 w-4'
|
||||
src={
|
||||
group.locked
|
||||
? require('@tabler/icons/lock.svg')
|
||||
: require('@tabler/icons/world.svg')
|
||||
}
|
||||
/>
|
||||
|
||||
<Text theme='inherit' tag='span' size='sm' weight='medium'>
|
||||
{group.locked ? (
|
||||
<FormattedMessage id='group.privacy.locked' defaultMessage='Private' />
|
||||
) : (
|
||||
<FormattedMessage id='group.privacy.public' defaultMessage='Public' />
|
||||
)}
|
||||
</Text>
|
||||
</HStack>
|
||||
);
|
||||
|
||||
export default GroupPrivacy;
|
@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { HStack, Icon, Text } from 'soapbox/components/ui';
|
||||
import { Group } from 'soapbox/types/entities';
|
||||
|
||||
interface IGroupRelationship {
|
||||
group: Group
|
||||
}
|
||||
|
||||
const GroupRelationship = ({ group }: IGroupRelationship) => {
|
||||
const isAdmin = group.relationship?.role === 'admin';
|
||||
const isModerator = group.relationship?.role === 'moderator';
|
||||
|
||||
if (!isAdmin || !isModerator) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<HStack space={1} alignItems='center'>
|
||||
<Icon
|
||||
className='h-4 w-4'
|
||||
src={
|
||||
isAdmin
|
||||
? require('@tabler/icons/users.svg')
|
||||
: require('@tabler/icons/gavel.svg')
|
||||
}
|
||||
/>
|
||||
|
||||
<Text tag='span' weight='medium' size='sm' theme='inherit'>
|
||||
{isAdmin
|
||||
? <FormattedMessage id='group.role.admin' defaultMessage='Admin' />
|
||||
: <FormattedMessage id='group.role.moderator' defaultMessage='Moderator' />}
|
||||
</Text>
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupRelationship;
|
@ -0,0 +1,79 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { Map as ImmutableMap } from 'immutable';
|
||||
import React from 'react';
|
||||
import { VirtuosoMockContext } from 'react-virtuoso';
|
||||
|
||||
import { render, screen, waitFor } from 'soapbox/jest/test-helpers';
|
||||
import { normalizeAccount } from 'soapbox/normalizers';
|
||||
import { groupSearchHistory } from 'soapbox/settings';
|
||||
import { clearRecentGroupSearches, saveGroupSearch } from 'soapbox/utils/groups';
|
||||
|
||||
import RecentSearches from '../recent-searches';
|
||||
|
||||
const userId = '1';
|
||||
const store = {
|
||||
me: userId,
|
||||
accounts: ImmutableMap({
|
||||
[userId]: normalizeAccount({
|
||||
id: userId,
|
||||
acct: 'justin-username',
|
||||
display_name: 'Justin L',
|
||||
avatar: 'test.jpg',
|
||||
chats_onboarded: false,
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
||||
const renderApp = (children: React.ReactNode) => (
|
||||
render(
|
||||
<VirtuosoMockContext.Provider value={{ viewportHeight: 300, itemHeight: 100 }}>
|
||||
{children}
|
||||
</VirtuosoMockContext.Provider>,
|
||||
undefined,
|
||||
store,
|
||||
)
|
||||
);
|
||||
|
||||
describe('<RecentSearches />', () => {
|
||||
describe('with recent searches', () => {
|
||||
beforeEach(() => {
|
||||
saveGroupSearch(userId, 'foobar');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearRecentGroupSearches(userId);
|
||||
});
|
||||
|
||||
it('should render the recent searches', async () => {
|
||||
renderApp(<RecentSearches onSelect={jest.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('recent-search')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should support clearing recent searches', async () => {
|
||||
renderApp(<RecentSearches onSelect={jest.fn()} />);
|
||||
|
||||
expect(groupSearchHistory.get(userId)).toHaveLength(1);
|
||||
await userEvent.click(screen.getByTestId('clear-recent-searches'));
|
||||
expect(groupSearchHistory.get(userId)).toBeNull();
|
||||
});
|
||||
|
||||
it('should support click events on the results', async () => {
|
||||
const handler = jest.fn();
|
||||
renderApp(<RecentSearches onSelect={handler} />);
|
||||
expect(handler.mock.calls.length).toEqual(0);
|
||||
await userEvent.click(screen.getByTestId('recent-search-result'));
|
||||
expect(handler.mock.calls.length).toEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('without recent searches', () => {
|
||||
it('should render the blankslate', async () => {
|
||||
renderApp(<RecentSearches onSelect={jest.fn()} />);
|
||||
|
||||
expect(screen.getByTestId('recent-searches-blankslate')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,62 @@
|
||||
import React from 'react';
|
||||
|
||||
import { __stub } from 'soapbox/api';
|
||||
import { render, screen, waitFor } from 'soapbox/jest/test-helpers';
|
||||
import { normalizeGroup, normalizeInstance } from 'soapbox/normalizers';
|
||||
|
||||
import Search from '../search';
|
||||
|
||||
const store = {
|
||||
instance: normalizeInstance({
|
||||
version: '3.4.1 (compatible; TruthSocial 1.0.0+unreleased)',
|
||||
}),
|
||||
};
|
||||
|
||||
const renderApp = (children: React.ReactElement) => render(children, undefined, store);
|
||||
|
||||
describe('<Search />', () => {
|
||||
describe('with no results', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onGet('/api/v1/groups/search').reply(200, []);
|
||||
});
|
||||
});
|
||||
|
||||
it('should render the blankslate', async () => {
|
||||
renderApp(<Search searchValue={'some-search'} onSelect={jest.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('no-results')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with results', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onGet('/api/v1/groups/search').reply(200, [
|
||||
normalizeGroup({
|
||||
display_name: 'Group',
|
||||
id: '1',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('should render the results', async () => {
|
||||
renderApp(<Search searchValue={'some-search'} onSelect={jest.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('results')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('before starting a search', () => {
|
||||
it('should render the RecentSearches component', () => {
|
||||
renderApp(<Search searchValue={''} onSelect={jest.fn()} />);
|
||||
|
||||
expect(screen.getByTestId('recent-searches')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Stack, Text } from 'soapbox/components/ui';
|
||||
|
||||
export default () => (
|
||||
<Stack space={2} className='px-4 py-2' data-testid='no-results'>
|
||||
<Text weight='bold' size='lg'>
|
||||
<FormattedMessage
|
||||
id='groups.discover.search.no_results.title'
|
||||
defaultMessage='No matches found'
|
||||
/>
|
||||
</Text>
|
||||
|
||||
<Text theme='muted'>
|
||||
<FormattedMessage
|
||||
id='groups.discover.search.no_results.subtitle'
|
||||
defaultMessage='Try searching for another group.'
|
||||
/>
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
@ -0,0 +1,90 @@
|
||||
import React, { useState } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
|
||||
import { HStack, Icon, Stack, Text } from 'soapbox/components/ui';
|
||||
import { useOwnAccount } from 'soapbox/hooks';
|
||||
import { groupSearchHistory } from 'soapbox/settings';
|
||||
import { clearRecentGroupSearches } from 'soapbox/utils/groups';
|
||||
|
||||
interface Props {
|
||||
onSelect(value: string): void
|
||||
}
|
||||
|
||||
export default (props: Props) => {
|
||||
const { onSelect } = props;
|
||||
|
||||
const me = useOwnAccount();
|
||||
|
||||
const [recentSearches, setRecentSearches] = useState<string[]>(groupSearchHistory.get(me?.id as string) || []);
|
||||
|
||||
const onClearRecentSearches = () => {
|
||||
clearRecentGroupSearches(me?.id as string);
|
||||
setRecentSearches([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack space={2} data-testid='recent-searches'>
|
||||
{recentSearches.length > 0 ? (
|
||||
<>
|
||||
<HStack
|
||||
alignItems='center'
|
||||
justifyContent='between'
|
||||
className='bg-white dark:bg-gray-900'
|
||||
>
|
||||
<Text theme='muted' weight='semibold' size='sm'>
|
||||
<FormattedMessage
|
||||
id='groups.discover.search.recent_searches.title'
|
||||
defaultMessage='Recent searches'
|
||||
/>
|
||||
</Text>
|
||||
|
||||
<button onClick={onClearRecentSearches} data-testid='clear-recent-searches'>
|
||||
<Text theme='primary' size='sm' className='hover:underline'>
|
||||
<FormattedMessage
|
||||
id='groups.discover.search.recent_searches.clear_all'
|
||||
defaultMessage='Clear all'
|
||||
/>
|
||||
</Text>
|
||||
</button>
|
||||
</HStack>
|
||||
|
||||
<Virtuoso
|
||||
useWindowScroll
|
||||
data={recentSearches}
|
||||
itemContent={(_index, recentSearch) => (
|
||||
<div key={recentSearch} data-testid='recent-search'>
|
||||
<button
|
||||
onClick={() => onSelect(recentSearch)}
|
||||
className='group flex w-full flex-col rounded-lg p-2 hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||
data-testid='recent-search-result'
|
||||
>
|
||||
<HStack alignItems='center' space={2}>
|
||||
<div className='flex h-10 w-10 items-center justify-center rounded-full bg-gray-200 p-2 dark:bg-gray-800 dark:group-hover:bg-gray-700/20'>
|
||||
<Icon
|
||||
src={require('@tabler/icons/hash.svg')}
|
||||
className='h-5 w-5 text-gray-600'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Text weight='bold' size='sm' align='left'>{recentSearch}</Text>
|
||||
</HStack>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Stack space={2} data-testid='recent-searches-blankslate'>
|
||||
<Text weight='bold' size='lg'>
|
||||
<FormattedMessage id='groups.discover.search.recent_searches.blankslate.title' defaultMessage='No recent searches' />
|
||||
</Text>
|
||||
|
||||
<Text theme='muted'>
|
||||
<FormattedMessage id='groups.discover.search.recent_searches.blankslate.subtitle' defaultMessage='Search group names, topics or keywords' />
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
@ -0,0 +1,169 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { Components, Virtuoso, VirtuosoGrid } from 'react-virtuoso';
|
||||
|
||||
import { Avatar, Button, HStack, Icon, Stack, Text } from 'soapbox/components/ui';
|
||||
import { useGroupSearch } from 'soapbox/queries/groups/search';
|
||||
import { Group } from 'soapbox/types/entities';
|
||||
import { shortNumberFormat } from 'soapbox/utils/numbers';
|
||||
|
||||
import GroupComp from '../group';
|
||||
|
||||
interface Props {
|
||||
groupSearchResult: ReturnType<typeof useGroupSearch>
|
||||
}
|
||||
|
||||
enum Layout {
|
||||
LIST = 'LIST',
|
||||
GRID = 'GRID'
|
||||
}
|
||||
|
||||
const GridList: Components['List'] = React.forwardRef((props, ref) => {
|
||||
const { context, ...rest } = props;
|
||||
return <div ref={ref} {...rest} className='flex flex-wrap' />;
|
||||
});
|
||||
|
||||
export default (props: Props) => {
|
||||
const { groupSearchResult } = props;
|
||||
|
||||
const [layout, setLayout] = useState<Layout>(Layout.LIST);
|
||||
|
||||
const { groups, hasNextPage, isFetching, fetchNextPage } = groupSearchResult;
|
||||
|
||||
const handleLoadMore = () => {
|
||||
if (hasNextPage && !isFetching) {
|
||||
fetchNextPage();
|
||||
}
|
||||
};
|
||||
|
||||
const renderGroupList = useCallback((group: Group, index: number) => (
|
||||
<HStack
|
||||
alignItems='center'
|
||||
justifyContent='between'
|
||||
className={
|
||||
clsx({
|
||||
'pt-4': index !== 0,
|
||||
})
|
||||
}
|
||||
>
|
||||
<HStack alignItems='center' space={2}>
|
||||
<Avatar
|
||||
className='ring-2 ring-white dark:ring-primary-900'
|
||||
src={group.avatar}
|
||||
size={44}
|
||||
/>
|
||||
|
||||
<Stack>
|
||||
<Text
|
||||
weight='bold'
|
||||
dangerouslySetInnerHTML={{ __html: group.display_name_html }}
|
||||
/>
|
||||
|
||||
<HStack className='text-gray-700 dark:text-gray-600' space={1} alignItems='center'>
|
||||
<Icon
|
||||
className='h-4.5 w-4.5'
|
||||
src={group.locked ? require('@tabler/icons/lock.svg') : require('@tabler/icons/world.svg')}
|
||||
/>
|
||||
|
||||
<Text theme='inherit' tag='span' size='sm' weight='medium'>
|
||||
{group.locked ? (
|
||||
<FormattedMessage id='group.privacy.locked' defaultMessage='Private' />
|
||||
) : (
|
||||
<FormattedMessage id='group.privacy.public' defaultMessage='Public' />
|
||||
)}
|
||||
</Text>
|
||||
|
||||
{typeof group.members_count !== 'undefined' && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<Text theme='inherit' tag='span' size='sm' weight='medium'>
|
||||
{shortNumberFormat(group.members_count)}
|
||||
{' '}
|
||||
<FormattedMessage
|
||||
id='groups.discover.search.results.member_count'
|
||||
defaultMessage='{members, plural, one {member} other {members}}'
|
||||
values={{
|
||||
members: group.members_count,
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</HStack>
|
||||
</Stack>
|
||||
</HStack>
|
||||
|
||||
<Button theme='primary'>
|
||||
{group.locked
|
||||
? <FormattedMessage id='group.join.private' defaultMessage='Request Access' />
|
||||
: <FormattedMessage id='group.join.public' defaultMessage='Join Group' />}
|
||||
</Button>
|
||||
</HStack>
|
||||
), []);
|
||||
|
||||
const renderGroupGrid = useCallback((group: Group, index: number) => (
|
||||
<div className='pb-4'>
|
||||
<GroupComp group={group} />
|
||||
</div>
|
||||
), []);
|
||||
|
||||
return (
|
||||
<Stack space={4} data-testid='results'>
|
||||
<HStack alignItems='center' justifyContent='between'>
|
||||
<Text weight='semibold'>
|
||||
<FormattedMessage
|
||||
id='groups.discover.search.results.groups'
|
||||
defaultMessage='Groups'
|
||||
/>
|
||||
</Text>
|
||||
|
||||
<HStack alignItems='center'>
|
||||
<button onClick={() => setLayout(Layout.LIST)}>
|
||||
<Icon
|
||||
src={require('@tabler/icons/layout-list.svg')}
|
||||
className={
|
||||
clsx('h-5 w-5 text-gray-600', {
|
||||
'text-primary-600': layout === Layout.LIST,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button onClick={() => setLayout(Layout.GRID)}>
|
||||
<Icon
|
||||
src={require('@tabler/icons/layout-grid.svg')}
|
||||
className={
|
||||
clsx('h-5 w-5 text-gray-600', {
|
||||
'text-primary-600': layout === Layout.GRID,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
{layout === Layout.LIST ? (
|
||||
<Virtuoso
|
||||
useWindowScroll
|
||||
data={groups}
|
||||
itemContent={(index, group) => renderGroupList(group, index)}
|
||||
endReached={handleLoadMore}
|
||||
/>
|
||||
) : (
|
||||
<VirtuosoGrid
|
||||
useWindowScroll
|
||||
data={groups}
|
||||
itemContent={(index, group) => renderGroupGrid(group, index)}
|
||||
components={{
|
||||
Item: (props) => (
|
||||
<div {...props} className='w-1/2 flex-none' />
|
||||
),
|
||||
List: GridList,
|
||||
}}
|
||||
endReached={handleLoadMore}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
@ -0,0 +1,64 @@
|
||||
import React, { useEffect } from 'react';
|
||||
|
||||
import { Stack } from 'soapbox/components/ui';
|
||||
import PlaceholderGroupSearch from 'soapbox/features/placeholder/components/placeholder-group-search';
|
||||
import { useDebounce, useOwnAccount } from 'soapbox/hooks';
|
||||
import { useGroupSearch } from 'soapbox/queries/groups/search';
|
||||
import { saveGroupSearch } from 'soapbox/utils/groups';
|
||||
|
||||
import NoResultsBlankslate from './no-results-blankslate';
|
||||
import RecentSearches from './recent-searches';
|
||||
import Results from './results';
|
||||
|
||||
interface Props {
|
||||
onSelect(value: string): void
|
||||
searchValue: string
|
||||
}
|
||||
|
||||
export default (props: Props) => {
|
||||
const { onSelect, searchValue } = props;
|
||||
|
||||
const me = useOwnAccount();
|
||||
const debounce = useDebounce;
|
||||
|
||||
const debouncedValue = debounce(searchValue as string, 300);
|
||||
const debouncedValueToSave = debounce(searchValue as string, 1000);
|
||||
|
||||
const groupSearchResult = useGroupSearch(debouncedValue);
|
||||
const { groups, isFetching, isFetched } = groupSearchResult;
|
||||
|
||||
const hasSearchResults = isFetched && groups.length > 0;
|
||||
const hasNoSearchResults = isFetched && groups.length === 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedValueToSave && debouncedValueToSave.length >= 0) {
|
||||
saveGroupSearch(me?.id as string, debouncedValueToSave);
|
||||
}
|
||||
}, [debouncedValueToSave]);
|
||||
|
||||
if (isFetching) {
|
||||
return (
|
||||
<Stack space={4}>
|
||||
<PlaceholderGroupSearch />
|
||||
<PlaceholderGroupSearch />
|
||||
<PlaceholderGroupSearch />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (hasNoSearchResults) {
|
||||
return <NoResultsBlankslate />;
|
||||
}
|
||||
|
||||
if (hasSearchResults) {
|
||||
return (
|
||||
<Results
|
||||
groupSearchResult={groupSearchResult}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<RecentSearches onSelect={onSelect} />
|
||||
);
|
||||
};
|
@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
|
||||
import { HStack, Stack, Text } from 'soapbox/components/ui';
|
||||
|
||||
import { generateText, randomIntFromInterval } from '../utils';
|
||||
|
||||
export default () => {
|
||||
const groupNameLength = randomIntFromInterval(12, 20);
|
||||
|
||||
return (
|
||||
<HStack
|
||||
alignItems='center'
|
||||
justifyContent='between'
|
||||
className='animate-pulse'
|
||||
>
|
||||
<HStack alignItems='center' space={2}>
|
||||
{/* Group Avatar */}
|
||||
<div className='h-11 w-11 rounded-full bg-gray-500 dark:bg-gray-700 dark:ring-primary-900' />
|
||||
|
||||
<Stack className='text-gray-500 dark:text-gray-700'>
|
||||
<Text theme='inherit' weight='bold'>
|
||||
{generateText(groupNameLength)}
|
||||
</Text>
|
||||
|
||||
<HStack space={1} alignItems='center'>
|
||||
<Text theme='inherit' tag='span' size='sm' weight='medium'>
|
||||
{generateText(6)}
|
||||
</Text>
|
||||
|
||||
<span>•</span>
|
||||
|
||||
<Text theme='inherit' tag='span' size='sm' weight='medium'>
|
||||
{generateText(6)}
|
||||
</Text>
|
||||
</HStack>
|
||||
</Stack>
|
||||
</HStack>
|
||||
|
||||
{/* Join Group Button */}
|
||||
<div className='h-10 w-36 rounded-full bg-gray-300 dark:bg-gray-800' />
|
||||
</HStack>
|
||||
);
|
||||
};
|
@ -0,0 +1,73 @@
|
||||
import { Map as ImmutableMap } from 'immutable';
|
||||
|
||||
import { __stub } from 'soapbox/api';
|
||||
import { renderHook, waitFor } from 'soapbox/jest/test-helpers';
|
||||
import { normalizeAccount, normalizeGroup, normalizeInstance } from 'soapbox/normalizers';
|
||||
|
||||
import { useGroupsPath } from '../useGroupsPath';
|
||||
|
||||
describe('useGroupsPath()', () => {
|
||||
test('without the groupsDiscovery feature', () => {
|
||||
const store = {
|
||||
instance: normalizeInstance({
|
||||
version: '2.7.2 (compatible; Pleroma 2.3.0)',
|
||||
}),
|
||||
};
|
||||
|
||||
const { result } = renderHook(useGroupsPath, undefined, store);
|
||||
|
||||
expect(result.current).toEqual('/groups');
|
||||
});
|
||||
|
||||
describe('with the "groupsDiscovery" feature', () => {
|
||||
let store: any;
|
||||
|
||||
beforeEach(() => {
|
||||
const userId = '1';
|
||||
store = {
|
||||
instance: normalizeInstance({
|
||||
version: '3.4.1 (compatible; TruthSocial 1.0.0+unreleased)',
|
||||
}),
|
||||
me: userId,
|
||||
accounts: ImmutableMap({
|
||||
[userId]: normalizeAccount({
|
||||
id: userId,
|
||||
acct: 'justin-username',
|
||||
display_name: 'Justin L',
|
||||
avatar: 'test.jpg',
|
||||
chats_onboarded: false,
|
||||
}),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe('when the user has no groups', () => {
|
||||
test('should default to the discovery page', () => {
|
||||
const { result } = renderHook(useGroupsPath, undefined, store);
|
||||
|
||||
expect(result.current).toEqual('/groups/discover');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the user has groups', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onGet('/api/v1/groups').reply(200, [
|
||||
normalizeGroup({
|
||||
display_name: 'Group',
|
||||
id: '1',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
test('should default to the discovery page', async () => {
|
||||
const { result } = renderHook(useGroupsPath, undefined, store);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current).toEqual('/groups');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,23 @@
|
||||
import { useGroups } from 'soapbox/queries/groups';
|
||||
|
||||
import { useFeatures } from './useFeatures';
|
||||
|
||||
/**
|
||||
* Determine the correct URL to use for /groups.
|
||||
* If the user does not have any Groups, let's default to the discovery tab.
|
||||
* Otherwise, let's default to My Groups.
|
||||
*
|
||||
* @returns String (as link)
|
||||
*/
|
||||
const useGroupsPath = () => {
|
||||
const features = useFeatures();
|
||||
const { groups } = useGroups();
|
||||
|
||||
if (!features.groupsDiscovery) {
|
||||
return '/groups';
|
||||
}
|
||||
|
||||
return groups.length > 0 ? '/groups' : '/groups/discover';
|
||||
};
|
||||
|
||||
export { useGroupsPath };
|
@ -0,0 +1,67 @@
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
|
||||
import { getNextLink } from 'soapbox/api';
|
||||
import { useApi, useFeatures } from 'soapbox/hooks';
|
||||
import { normalizeGroup } from 'soapbox/normalizers';
|
||||
import { Group } from 'soapbox/types/entities';
|
||||
import { flattenPages, PaginatedResult } from 'soapbox/utils/queries';
|
||||
|
||||
const GroupSearchKeys = {
|
||||
search: (query?: string) => query ? ['groups', 'search', query] : ['groups', 'search'] as const,
|
||||
};
|
||||
|
||||
type PageParam = {
|
||||
link: string
|
||||
}
|
||||
|
||||
const useGroupSearch = (search?: string) => {
|
||||
const api = useApi();
|
||||
const features = useFeatures();
|
||||
|
||||
const getSearchResults = async (pageParam: PageParam): Promise<PaginatedResult<Group>> => {
|
||||
const nextPageLink = pageParam?.link;
|
||||
const uri = nextPageLink || '/api/v1/groups/search';
|
||||
const response = await api.get<Group[]>(uri, {
|
||||
params: search ? {
|
||||
q: search,
|
||||
} : undefined,
|
||||
});
|
||||
const { data } = response;
|
||||
|
||||
const link = getNextLink(response);
|
||||
const hasMore = !!link;
|
||||
const result = data.map(normalizeGroup);
|
||||
|
||||
return {
|
||||
result,
|
||||
hasMore,
|
||||
link,
|
||||
};
|
||||
};
|
||||
|
||||
const queryInfo = useInfiniteQuery(
|
||||
GroupSearchKeys.search(search),
|
||||
({ pageParam }) => getSearchResults(pageParam),
|
||||
{
|
||||
keepPreviousData: true,
|
||||
enabled: features.groups && !!search,
|
||||
getNextPageParam: (config) => {
|
||||
if (config.hasMore) {
|
||||
return { link: config.link };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
|
||||
const data = flattenPages(queryInfo.data);
|
||||
|
||||
return {
|
||||
...queryInfo,
|
||||
groups: data || [],
|
||||
};
|
||||
};
|
||||
|
||||
export {
|
||||
useGroupSearch,
|
||||
};
|
@ -0,0 +1,35 @@
|
||||
import { groupSearchHistory } from 'soapbox/settings';
|
||||
|
||||
const RECENT_SEARCHES_KEY = 'soapbox:recent-group-searches';
|
||||
|
||||
const clearRecentGroupSearches = (currentUserId: string) => groupSearchHistory.remove(currentUserId);
|
||||
|
||||
const saveGroupSearch = (currentUserId: string, search: string) => {
|
||||
let currentSearches: string[] = [];
|
||||
|
||||
if (groupSearchHistory.get(currentUserId)) {
|
||||
currentSearches = groupSearchHistory.get(currentUserId);
|
||||
}
|
||||
|
||||
if (currentSearches.indexOf(search) === -1) {
|
||||
currentSearches.unshift(search);
|
||||
if (currentSearches.length > 10) {
|
||||
currentSearches.pop();
|
||||
}
|
||||
|
||||
groupSearchHistory.set(currentUserId, currentSearches);
|
||||
|
||||
return currentSearches;
|
||||
} else {
|
||||
// The search term has already been searched. Move it to the beginning
|
||||
// of the cached list.
|
||||
const indexOfSearch = currentSearches.indexOf(search);
|
||||
const nextCurrentSearches = [...currentSearches];
|
||||
nextCurrentSearches.splice(0, 0, ...nextCurrentSearches.splice(indexOfSearch, 1));
|
||||
localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(nextCurrentSearches));
|
||||
|
||||
return nextCurrentSearches;
|
||||
}
|
||||
};
|
||||
|
||||
export { clearRecentGroupSearches, saveGroupSearch };
|
Loading…
Reference in new issue