Skip to content

Commit

Permalink
feat: Improve UX from "Action Buttons" in RoomInfo (#32632)
Browse files Browse the repository at this point in the history
  • Loading branch information
tiagoevanp authored Jul 22, 2024
1 parent 703af95 commit 5bfde8d
Show file tree
Hide file tree
Showing 9 changed files with 232 additions and 97 deletions.
5 changes: 5 additions & 0 deletions .changeset/weak-insects-sort.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@rocket.chat/meteor": patch
---

Improving UX by change the position of room info actions buttons and menu order to avoid missclick in destructive actions.
60 changes: 60 additions & 0 deletions apps/meteor/client/components/GenericMenu/GenericMenu.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';

import GenericMenu from './GenericMenu';

const mockedFunction = jest.fn();
const regular = {
items: [
{
id: 'edit',
content: 'Edit',
icon: 'pencil' as const,
onClick: mockedFunction,
},
],
};
const danger = {
items: [
{
id: 'delete',
content: 'Delete',
icon: 'trash' as const,
onClick: () => null,
variant: 'danger',
},
],
};

const sections = [regular, danger];

describe('Room Actions Menu', () => {
it('should render kebab menu with the list content', async () => {
render(<GenericMenu title='Kebab' sections={sections} />);

userEvent.click(screen.getByRole('button'));

expect(await screen.findByText('Edit')).toBeInTheDocument();
expect(await screen.findByText('Delete')).toBeInTheDocument();
});

it('should have two different sections, regular and danger', async () => {
render(<GenericMenu title='Kebab' sections={sections} />);

userEvent.click(screen.getByRole('button'));

expect(screen.getAllByRole('presentation')).toHaveLength(2);
expect(screen.getByRole('separator')).toBeInTheDocument();
});

it('should call the action when item clicked', async () => {
render(<GenericMenu title='Kebab' sections={sections} />);

userEvent.click(screen.getByRole('button'));
userEvent.click(screen.getAllByRole('menuitem')[0]);

expect(mockedFunction).toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export type GenericMenuItemProps = {
description?: ReactNode;
gap?: boolean;
tooltip?: string;
variant?: string;
};

const GenericMenuItem = ({ icon, content, addon, status, gap, tooltip }: GenericMenuItemProps) => (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { Box, Callout, Menu, Option } from '@rocket.chat/fuselage';
import { Box, Callout, IconButton } from '@rocket.chat/fuselage';
import { RoomAvatar } from '@rocket.chat/ui-avatar';
import { useTranslation } from '@rocket.chat/ui-contexts';
import React, { useMemo } from 'react';
import React from 'react';

import {
ContextualbarHeader,
Expand All @@ -12,9 +12,9 @@ import {
ContextualbarClose,
ContextualbarTitle,
} from '../../../../../components/Contextualbar';
import GenericMenu from '../../../../../components/GenericMenu/GenericMenu';
import {
InfoPanel,
InfoPanelAction,
InfoPanelActionGroup,
InfoPanelAvatar,
InfoPanelField,
Expand All @@ -25,10 +25,10 @@ import {
} from '../../../../../components/InfoPanel';
import RetentionPolicyCallout from '../../../../../components/InfoPanel/RetentionPolicyCallout';
import MarkdownText from '../../../../../components/MarkdownText';
import type { Action } from '../../../../hooks/useActionSpread';
import { useActionSpread } from '../../../../hooks/useActionSpread';
import { useRetentionPolicy } from '../../../hooks/useRetentionPolicy';
import { useRoomActions } from '../hooks/useRoomActions';
import { useSplitRoomActions } from '../hooks/useSplitRoomActions';
import RoomInfoActions from './RoomInfoActions';

type RoomInfoProps = {
room: IRoom;
Expand All @@ -47,35 +47,8 @@ const RoomInfo = ({ room, icon, onClickBack, onClickClose, onClickEnterRoom, onC
const isDiscussion = 'prid' in room;

const retentionPolicy = useRetentionPolicy(room);
const memoizedActions = useRoomActions(room, { onClickEnterRoom, onClickEdit }, resetState);
const { actions: actionsDefinition, menu: menuOptions } = useActionSpread(memoizedActions);

const menu = useMemo(() => {
if (!menuOptions) {
return null;
}

return (
<Menu
small={false}
flexShrink={0}
flexGrow={0}
key='menu'
maxHeight='initial'
secondary
renderItem={({ label: { label, icon }, ...props }) => <Option {...props} label={label} icon={icon} />}
options={menuOptions}
/>
);
}, [menuOptions]);

const actions = useMemo(() => {
const mapAction = ([key, { label, icon, action }]: [string, Action]) => (
<InfoPanelAction key={key} label={label} onClick={action} icon={icon} />
);

return [...actionsDefinition.map(mapAction), menu].filter(Boolean);
}, [actionsDefinition, menu]);
const actions = useRoomActions(room, { onClickEnterRoom, onClickEdit, resetState });
const { buttons, menu } = useSplitRoomActions(actions);

return (
<>
Expand All @@ -91,8 +64,17 @@ const RoomInfo = ({ room, icon, onClickBack, onClickClose, onClickEnterRoom, onC
<InfoPanelAvatar>
<RoomAvatar size='x332' room={room} />
</InfoPanelAvatar>

<InfoPanelActionGroup>{actions}</InfoPanelActionGroup>
<InfoPanelActionGroup>
<RoomInfoActions actions={buttons} />
{menu && (
<GenericMenu
title={t('More')}
placement='bottom-end'
button={<IconButton icon='kebab' secondary flexShrink={0} flexGrow={0} maxHeight='initial' />}
sections={menu}
/>
)}
</InfoPanelActionGroup>
</InfoPanelSection>

{archived && (
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { Keys as IconKeys } from '@rocket.chat/icons';
import React from 'react';

import { InfoPanelAction } from '../../../../../components/InfoPanel';

type Action = {
id: string;
content: string;
icon: IconKeys;
onClick: () => void;
variant?: string;
};

export type RoomInfoActionsProps = {
actions: { items: Action[] };
className?: string;
};

const RoomInfoActions = ({ actions, className }: RoomInfoActionsProps) => {
return (
<>
{actions.items.map(({ id, content, icon, onClick }) => (
<InfoPanelAction className={className} key={id} label={content} onClick={onClick} icon={icon} />
))}
</>
);
};

export default RoomInfoActions;
Original file line number Diff line number Diff line change
Expand Up @@ -8,75 +8,96 @@ import { useRoomHide } from './actions/useRoomHide';
import { useRoomLeave } from './actions/useRoomLeave';
import { useRoomMoveToTeam } from './actions/useRoomMoveToTeam';

type RoomActions = {
type UseRoomActionsOptions = {
onClickEnterRoom?: () => void;
onClickEdit?: () => void;
resetState?: () => void;
};

export const useRoomActions = (room: IRoom, { onClickEnterRoom, onClickEdit }: RoomActions, resetState?: () => void) => {
const t = useTranslation();
export const useRoomActions = (room: IRoom, options: UseRoomActionsOptions) => {
const { onClickEnterRoom, onClickEdit, resetState } = options;

const t = useTranslation();
const handleHide = useRoomHide(room);
const handleLeave = useRoomLeave(room);
const { handleDelete, canDeleteRoom } = useDeleteRoom(room, { reload: resetState });
const handleMoveToTeam = useRoomMoveToTeam(room);
const handleConvertToTeam = useRoomConvertToTeam(room);

const memoizedActions = useMemo(
() => ({
...(onClickEnterRoom && {
enter: {
label: t('Enter'),
icon: 'login' as const,
action: onClickEnterRoom,
},
}),
...(onClickEdit && {
edit: {
label: t('Edit'),
icon: 'edit' as const,
action: onClickEdit,
},
}),
...(canDeleteRoom &&
handleDelete && {
delete: {
label: t('Delete'),
icon: 'trash' as const,
action: handleDelete,
},
}),
...(handleMoveToTeam && {
move: {
label: t('Teams_move_channel_to_team'),
icon: 'team-arrow-right' as const,
action: handleMoveToTeam,
},
}),
...(handleConvertToTeam && {
convert: {
label: t('Teams_convert_channel_to_team'),
icon: 'team' as const,
action: handleConvertToTeam,
},
}),
...(handleHide && {
hide: {
label: t('Hide'),
action: handleHide,
return useMemo(() => {
const memoizedActions = {
items: [
{
id: 'hide',
content: t('Hide'),
icon: 'eye-off' as const,
onClick: handleHide,
},
}),
...(handleLeave && {
leave: {
label: t('Leave'),
action: handleLeave,
icon: 'sign-out' as const,
},
}),
}),
[onClickEdit, t, handleDelete, handleMoveToTeam, handleConvertToTeam, handleHide, handleLeave, onClickEnterRoom, canDeleteRoom],
);

return memoizedActions;
...(onClickEnterRoom
? [
{
id: 'enter',
content: t('Enter'),
icon: 'login' as const,
onClick: onClickEnterRoom,
},
]
: []),
...(onClickEdit
? [
{
id: 'edit',
content: t('Edit'),
icon: 'edit' as const,
onClick: onClickEdit,
},
]
: []),
...(handleLeave
? [
{
id: 'leave',
content: t('Leave'),
icon: 'sign-out' as const,
onClick: handleLeave,
},
]
: []),
...(handleMoveToTeam
? [
{
id: 'move_channel_team',
content: t('Teams_move_channel_to_team'),
icon: 'team-arrow-right' as const,
onClick: handleMoveToTeam,
},
]
: []),
...(handleConvertToTeam
? [
{
id: 'convert_channel_team',
content: t('Teams_convert_channel_to_team'),
icon: 'team' as const,
onClick: handleConvertToTeam,
},
]
: []),
...(canDeleteRoom
? [
{
id: 'delete',
content: t('Delete'),
icon: 'trash' as const,
onClick: handleDelete,
variant: 'danger',
},
]
: []),
],
};

return memoizedActions;
}, [canDeleteRoom, handleConvertToTeam, handleDelete, handleHide, handleLeave, handleMoveToTeam, onClickEdit, onClickEnterRoom, t]);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { RoomInfoActionsProps } from '../RoomInfo/RoomInfoActions';

type UseSplitRoomActionsOptions = {
size?: number;
};

/**
*
* @param room
* @param options
* @returns If more than two room actions are enabled `menu.regular` will be a non-empty array
*/
export const useSplitRoomActions = (actions: RoomInfoActionsProps['actions'], options?: UseSplitRoomActionsOptions) => {
const size = options?.size || 2;

if (actions.items.length <= size) {
return { buttons: actions };
}

const buttons = { items: actions.items.slice(0, size) };
const regular = actions.items.slice(size);
const firstDanger = regular.findIndex((item) => item.variant);
const danger = regular.splice(firstDanger);

const menu = [{ items: regular }, { items: danger }];

return { buttons, menu };
};
Loading

0 comments on commit 5bfde8d

Please sign in to comment.