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