From 3cc4f8b64b0a0d874b15cde776aab37e07fba2fa Mon Sep 17 00:00:00 2001 From: Chewbacca Date: Thu, 2 Mar 2023 14:40:36 -0500 Subject: [PATCH] Improve Group Header with latest designs --- .../__tests__/group-action-button.test.tsx | 130 ++++++++++++++++++ .../__tests__/group-member-count.test.tsx | 69 ++++++++++ .../__tests__/group-privacy.test.tsx | 39 ++++++ .../group/components/group-action-button.tsx | 83 +++++++++++ .../group/components/group-header.tsx | 129 +++++------------ .../group/components/group-member-count.tsx | 32 +++++ .../group/components/group-privacy.tsx | 32 +++++ .../group/components/group-relationship.tsx | 39 ++++++ app/soapbox/features/group/group-timeline.tsx | 2 - .../groups/components/discover/group.tsx | 101 ++++++-------- .../components/discover/search/results.tsx | 2 +- app/soapbox/locales/en.json | 8 +- app/soapbox/pages/group-page.tsx | 44 +++--- app/soapbox/queries/groups.ts | 24 +++- 14 files changed, 551 insertions(+), 183 deletions(-) create mode 100644 app/soapbox/features/group/components/__tests__/group-action-button.test.tsx create mode 100644 app/soapbox/features/group/components/__tests__/group-member-count.test.tsx create mode 100644 app/soapbox/features/group/components/__tests__/group-privacy.test.tsx create mode 100644 app/soapbox/features/group/components/group-action-button.tsx create mode 100644 app/soapbox/features/group/components/group-member-count.tsx create mode 100644 app/soapbox/features/group/components/group-privacy.tsx create mode 100644 app/soapbox/features/group/components/group-relationship.tsx diff --git a/app/soapbox/features/group/components/__tests__/group-action-button.test.tsx b/app/soapbox/features/group/components/__tests__/group-action-button.test.tsx new file mode 100644 index 000000000..eb2cf670b --- /dev/null +++ b/app/soapbox/features/group/components/__tests__/group-action-button.test.tsx @@ -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('', () => { + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + expect(screen.getByRole('button')).toHaveTextContent('Leave Group'); + }); + }); +}); \ No newline at end of file diff --git a/app/soapbox/features/group/components/__tests__/group-member-count.test.tsx b/app/soapbox/features/group/components/__tests__/group-member-count.test.tsx new file mode 100644 index 000000000..86e9baac8 --- /dev/null +++ b/app/soapbox/features/group/components/__tests__/group-member-count.test.tsx @@ -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('', () => { + describe('without support for "members_count"', () => { + beforeEach(() => { + group = normalizeGroup({ + members_count: undefined, + }); + }); + + it('should return null', () => { + render(); + + 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(); + + 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(); + + 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(); + + expect(screen.getByTestId('group-member-count').textContent).toEqual('1k members'); + }); + }); + }); +}); \ No newline at end of file diff --git a/app/soapbox/features/group/components/__tests__/group-privacy.test.tsx b/app/soapbox/features/group/components/__tests__/group-privacy.test.tsx new file mode 100644 index 000000000..72e4454e7 --- /dev/null +++ b/app/soapbox/features/group/components/__tests__/group-privacy.test.tsx @@ -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('', () => { + describe('with a Private group', () => { + beforeEach(() => { + group = normalizeGroup({ + locked: true, + }); + }); + + it('should render the correct text', () => { + render(); + + expect(screen.getByTestId('group-privacy')).toHaveTextContent('Private'); + }); + }); + + describe('with a Public group', () => { + beforeEach(() => { + group = normalizeGroup({ + locked: false, + }); + }); + + it('should render the correct text', () => { + render(); + + expect(screen.getByTestId('group-privacy')).toHaveTextContent('Public'); + }); + }); +}); \ No newline at end of file diff --git a/app/soapbox/features/group/components/group-action-button.tsx b/app/soapbox/features/group/components/group-action-button.tsx new file mode 100644 index 000000000..53f27f709 --- /dev/null +++ b/app/soapbox/features/group/components/group-action-button.tsx @@ -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 ( + + ); + } + + if (isRequested) { + return ( + + ); + } + + if (isAdmin) { + return ( + + ); + } + + return ( + + ); +}; + +export default GroupActionButton; \ No newline at end of file diff --git a/app/soapbox/features/group/components/group-header.tsx b/app/soapbox/features/group/components/group-header.tsx index 0e9eb6548..7b532ddcc 100644 --- a/app/soapbox/features/group/components/group-header.tsx +++ b/app/soapbox/features/group/components/group-header.tsx @@ -1,22 +1,23 @@ import { List as ImmutableList } from 'immutable'; import React from 'react'; -import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; +import { defineMessages, useIntl } from 'react-intl'; -import { joinGroup, leaveGroup } from 'soapbox/actions/groups'; import { openModal } from 'soapbox/actions/modals'; import StillImage from 'soapbox/components/still-image'; -import { Avatar, Button, HStack, Icon, Stack, Text } from 'soapbox/components/ui'; +import { Avatar, HStack, Stack, Text } from 'soapbox/components/ui'; import { useAppDispatch } from 'soapbox/hooks'; import { normalizeAttachment } from 'soapbox/normalizers'; import { isDefaultHeader } from 'soapbox/utils/accounts'; +import GroupActionButton from './group-action-button'; +import GroupMemberCount from './group-member-count'; +import GroupPrivacy from './group-privacy'; +import GroupRelationship from './group-relationship'; + import type { Group } from 'soapbox/types/entities'; const messages = defineMessages({ header: { id: 'group.header.alt', defaultMessage: 'Group header' }, - 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' }, }); interface IGroupHeader { @@ -47,16 +48,6 @@ const GroupHeader: React.FC = ({ group }) => { ); } - 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)), - })); - const onAvatarClick = () => { const avatar = normalizeAttachment({ type: 'image', @@ -95,6 +86,7 @@ const GroupHeader: React.FC = ({ group }) => { ); @@ -110,95 +102,40 @@ const GroupHeader: React.FC = ({ group }) => { return header; }; - const makeActionButton = () => { - if (!group.relationship || !group.relationship.member) { - return ( - - ); - } - - if (group.relationship.requested) { - return ( - - ); - } - - if (group.relationship?.role === 'admin') { - return ( - - ); - } - - return ( - - ); - }; - - const actionButton = makeActionButton(); - return (
-
- {renderHeader()} -
+ {renderHeader()} +
- - - - {group.relationship?.role === 'admin' ? ( - - - - - ) : group.relationship?.role === 'moderator' && ( - - - - - )} - {group.locked ? ( - - - - - ) : ( - - - - - )} - - - {actionButton} + + + + + + + + + + + + + +
); diff --git a/app/soapbox/features/group/components/group-member-count.tsx b/app/soapbox/features/group/components/group-member-count.tsx new file mode 100644 index 000000000..e4dd33e54 --- /dev/null +++ b/app/soapbox/features/group/components/group-member-count.tsx @@ -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 ( + + {shortNumberFormat(group.members_count)} + {' '} + + + ); +}; + +export default GroupMemberCount; \ No newline at end of file diff --git a/app/soapbox/features/group/components/group-privacy.tsx b/app/soapbox/features/group/components/group-privacy.tsx new file mode 100644 index 000000000..fdbbe2977 --- /dev/null +++ b/app/soapbox/features/group/components/group-privacy.tsx @@ -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) => ( + + + + + {group.locked ? ( + + ) : ( + + )} + + +); + +export default GroupPrivacy; \ No newline at end of file diff --git a/app/soapbox/features/group/components/group-relationship.tsx b/app/soapbox/features/group/components/group-relationship.tsx new file mode 100644 index 000000000..6b79ecda5 --- /dev/null +++ b/app/soapbox/features/group/components/group-relationship.tsx @@ -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 ( + + + + + {isAdmin + ? + : } + + + ); +}; + +export default GroupRelationship; \ No newline at end of file diff --git a/app/soapbox/features/group/group-timeline.tsx b/app/soapbox/features/group/group-timeline.tsx index f4cf2f574..f80343c1e 100644 --- a/app/soapbox/features/group/group-timeline.tsx +++ b/app/soapbox/features/group/group-timeline.tsx @@ -3,7 +3,6 @@ import { FormattedMessage } from 'react-intl'; import { Link } from 'react-router-dom'; import { groupCompose } from 'soapbox/actions/compose'; -import { fetchGroup } from 'soapbox/actions/groups'; import { connectGroupStream } from 'soapbox/actions/streaming'; import { expandGroupTimeline } from 'soapbox/actions/timelines'; import { Avatar, HStack, Stack } from 'soapbox/components/ui'; @@ -31,7 +30,6 @@ const GroupTimeline: React.FC = (props) => { }; useEffect(() => { - dispatch(fetchGroup(groupId)); dispatch(expandGroupTimeline(groupId)); dispatch(groupCompose(`group:${groupId}`, groupId)); diff --git a/app/soapbox/features/groups/components/discover/group.tsx b/app/soapbox/features/groups/components/discover/group.tsx index 156605e71..a596f95f2 100644 --- a/app/soapbox/features/groups/components/discover/group.tsx +++ b/app/soapbox/features/groups/components/discover/group.tsx @@ -1,9 +1,11 @@ import React, { forwardRef } from 'react'; import { FormattedMessage } from 'react-intl'; +import { Link } from 'react-router-dom'; -import { Avatar, Button, HStack, Icon, Stack, Text } from 'soapbox/components/ui'; +import { Avatar, Button, HStack, Stack, Text } from 'soapbox/components/ui'; +import GroupMemberCount from 'soapbox/features/group/components/group-member-count'; +import GroupPrivacy from 'soapbox/features/group/components/group-privacy'; import { Group as GroupEntity } from 'soapbox/types/entities'; -import { shortNumberFormat } from 'soapbox/utils/numbers'; interface IGroup { group: GroupEntity @@ -21,75 +23,56 @@ const Group = forwardRef((props: IGroup, ref: React.ForwardedRef width, }} > - - {group.header && ( - Group cover - )} + + + {group.header && ( + Group cover + )} - - - - - + - - + - {typeof group.members_count === 'undefined' ? ( - - {group.locked ? ( - - ) : ( - - )} - - ) : ( - - {shortNumberFormat(group.members_count)} - {' '} - - - )} - + + + + + + - -
- +
+ +
); diff --git a/app/soapbox/features/groups/components/discover/search/results.tsx b/app/soapbox/features/groups/components/discover/search/results.tsx index 59af5aebc..cfbc74039 100644 --- a/app/soapbox/features/groups/components/discover/search/results.tsx +++ b/app/soapbox/features/groups/components/discover/search/results.tsx @@ -96,7 +96,7 @@ export default (props: Props) => { diff --git a/app/soapbox/locales/en.json b/app/soapbox/locales/en.json index aa3e0fc34..35c111786 100644 --- a/app/soapbox/locales/en.json +++ b/app/soapbox/locales/en.json @@ -745,7 +745,7 @@ "gdpr.title": "{siteTitle} uses cookies", "getting_started.open_source_notice": "{code_name} is open source software. You can contribute or report issues at {code_link} (v{code_version}).", "group.admin_subheading": "Group administrators", - "group.cancel_request": "Cancel request", + "group.cancel_request": "Cancel Request", "group.group_mod_authorize": "Accept", "group.group_mod_authorize.success": "Accepted @{name} to group", "group.group_mod_block": "Block @{name} from group", @@ -763,13 +763,13 @@ "group.group_mod_unblock": "Unblock", "group.group_mod_unblock.success": "Unblocked @{name} from group", "group.header.alt": "Group header", - "group.join.private": "Request to Join", + "group.join.private": "Request Access", "group.join.public": "Join Group", "group.join.request_success": "Requested to join the group", "group.join.success": "Joined the group", - "group.leave": "Leave group", + "group.leave": "Leave Group", "group.leave.success": "Left the group", - "group.manage": "Manage group", + "group.manage": "Manage Group", "group.moderator_subheading": "Group moderators", "group.privacy.locked": "Private", "group.privacy.public": "Public", diff --git a/app/soapbox/pages/group-page.tsx b/app/soapbox/pages/group-page.tsx index d48caafd0..92518a55d 100644 --- a/app/soapbox/pages/group-page.tsx +++ b/app/soapbox/pages/group-page.tsx @@ -1,10 +1,8 @@ -import React, { useCallback, useEffect } from 'react'; +import React from 'react'; import { defineMessages, useIntl } from 'react-intl'; import { useRouteMatch } from 'react-router-dom'; -import { fetchGroup } from 'soapbox/actions/groups'; -import MissingIndicator from 'soapbox/components/missing-indicator'; -import { Column, Layout } from 'soapbox/components/ui'; +import { Column, Icon, Layout, Stack, Text } from 'soapbox/components/ui'; import GroupHeader from 'soapbox/features/group/components/group-header'; import LinkFooter from 'soapbox/features/ui/components/link-footer'; import BundleContainer from 'soapbox/features/ui/containers/bundle-container'; @@ -13,8 +11,8 @@ import { GroupMediaPanel, SignUpPanel, } from 'soapbox/features/ui/util/async-components'; -import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; -import { makeGetGroup } from 'soapbox/selectors'; +import { useOwnAccount } from 'soapbox/hooks'; +import { useGroup } from 'soapbox/queries/groups'; import { Tabs } from '../components/ui'; @@ -34,23 +32,20 @@ interface IGroupPage { const GroupPage: React.FC = ({ params, children }) => { const intl = useIntl(); const match = useRouteMatch(); - const dispatch = useAppDispatch(); + const me = useOwnAccount(); const id = params?.id || ''; - const getGroup = useCallback(makeGetGroup(), []); - const group = useAppSelector(state => getGroup(state, id)); - const me = useAppSelector(state => state.me); + const { group } = useGroup(id); - useEffect(() => { - dispatch(fetchGroup(id)); - }, [id]); + const isNonMember = !group?.relationship || !group.relationship.member; + const isPrivate = group?.locked; - if ((group as any) === false) { - return ( - - ); - } + // if ((group as any) === false) { + // return ( + // + // ); + // } const items = [ { @@ -76,7 +71,18 @@ const GroupPage: React.FC = ({ params, children }) => { activeItem={match.path} /> - {children} + {(isNonMember && isPrivate) ? ( + +
+ +
+ + + Content is only visible to group members + +
+ + ) : children} {!me && ( diff --git a/app/soapbox/queries/groups.ts b/app/soapbox/queries/groups.ts index 612c60b43..5fb8ed2dc 100644 --- a/app/soapbox/queries/groups.ts +++ b/app/soapbox/queries/groups.ts @@ -9,6 +9,7 @@ import { Group } from 'soapbox/types/entities'; import { flattenPages, PaginatedResult } from 'soapbox/utils/queries'; const GroupKeys = { + group: (id: string) => ['groups', 'group', id] as const, myGroups: (userId: string) => ['groups', userId] as const, popularGroups: ['groups', 'popular'] as const, suggestedGroups: ['groups', 'suggested'] as const, @@ -70,7 +71,7 @@ const usePopularGroups = () => { const features = useFeatures(); const getQuery = async () => { - const { data } = await api.get('/api/mock/groups'); // '/api/v1/truth/trends/groups' + const { data } = await api.get('/api/v1/groups/search?q=group'); // '/api/v1/truth/trends/groups' const result = data.map(normalizeGroup); return result; @@ -109,4 +110,23 @@ const useSuggestedGroups = () => { }; }; -export { useGroups, usePopularGroups, useSuggestedGroups }; +const useGroup = (id: string) => { + const api = useApi(); + const features = useFeatures(); + + const getGroup = async () => { + const { data } = await api.get(`/api/v1/groups/${id}`); + return normalizeGroup(data); + }; + + const queryInfo = useQuery(GroupKeys.group(id), getGroup, { + enabled: features.groups && !!id, + }); + + return { + ...queryInfo, + group: queryInfo.data, + }; +}; + +export { useGroups, useGroup, usePopularGroups, useSuggestedGroups };