Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions apps/meteor/app/api/server/v1/user-room-categories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,16 @@ const addRoomToCategoryBodySchema = ajv.compile<{ categoryName: string; roomId:
additionalProperties: false,
});

const renameCategoryBodySchema = ajv.compile<{ oldName: string; newName: string }>({
type: 'object',
properties: {
oldName: { type: 'string' },
newName: { type: 'string' },
},
required: ['oldName', 'newName'],
additionalProperties: false,
});

const categoriesResponseSchema = ajv.compile<{ categories: IUserRoomCategory[] }>({
type: 'object',
properties: {
Expand Down Expand Up @@ -154,3 +164,29 @@ API.v1.post(
return API.v1.success({});
},
);

API.v1.post(
'user-room-categories/rename-category',
{
authRequired: true,
body: renameCategoryBodySchema,
response: { 200: emptyResponseSchema, 400: emptyResponseSchema },
},
async function action() {
const { oldName, newName } = this.bodyParams;

check(oldName, String);
check(newName, String);

const trimmedOldName = oldName.trim();
const trimmedNewName = newName.trim();

if (!trimmedOldName || !trimmedNewName) {
return API.v1.failure('oldName and newName are required');
}

await UserRoomCategories.renameCategory(this.userId, trimmedOldName, trimmedNewName);

return API.v1.success({});
},
);
153 changes: 153 additions & 0 deletions apps/meteor/client/hooks/UserRoomCategoryForRoomModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import type { SelectOption } from '@rocket.chat/fuselage';
import {
Box,
Button,
Callout,
Field,
FieldGroup,
FieldLabel,
FieldRow,
Modal,
ModalClose,
ModalContent,
ModalFooter,
ModalFooterControllers,
ModalHeader,
ModalTitle,
Select,
} from '@rocket.chat/fuselage';
import { useSetModal, useToastMessageDispatch, useTranslation } from '@rocket.chat/ui-contexts';
import { useCallback, useEffect, useId, useMemo, useState } from 'react';

import { useUserRoomCategories } from './useUserRoomCategories';
import CreateUserRoomCategoryModal from '../navbar/NavBarPagesGroup/actions/CreateUserRoomCategoryModal';

type UserRoomCategoryForRoomModalProps = {
roomId: string;
roomName?: string;
onClose: () => void;
};

const UserRoomCategoryForRoomModal = ({ roomId, roomName, onClose }: UserRoomCategoryForRoomModalProps) => {
const t = useTranslation();
const titleId = useId();
const dispatchToastMessage = useToastMessageDispatch();
const setModal = useSetModal();

const { data: categories = [], addRoomToCategory, removeRoomFromCategory, refetch } = useUserRoomCategories();

const containingCategory = useMemo(() => categories.find((c) => (c.roomIds ?? []).includes(roomId)), [categories, roomId]);

const selectOptions = useMemo<Array<SelectOption>>(() => categories.map((c) => [c.name, c.name]), [categories]);
const [selectedCategory, setSelectedCategory] = useState('');
const [submitting, setSubmitting] = useState(false);

useEffect(() => {
if (containingCategory?.name) {
setSelectedCategory(containingCategory.name);
return;
}
if (categories[0]?.name) {
setSelectedCategory((prev) => (categories.some((c) => c.name === prev) ? prev : categories[0].name));
}
}, [categories, containingCategory?.name]);

const openCreateCategoryModal = useCallback(() => {
setModal(
<CreateUserRoomCategoryModal
onClose={() => {
void refetch();
setModal(<UserRoomCategoryForRoomModal roomId={roomId} roomName={roomName} onClose={onClose} />);
}}
/>,
);
}, [onClose, refetch, roomId, roomName, setModal]);

const handleApply = async () => {
if (!selectedCategory) {
return;
}
setSubmitting(true);
try {
await addRoomToCategory(selectedCategory, roomId);
dispatchToastMessage({ type: 'success', message: t('User_room_category_assign_success', { name: selectedCategory }) });
onClose();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
dispatchToastMessage({ type: 'error', message: message || t('Something_went_wrong') });
} finally {
setSubmitting(false);
}
};

const handleRemove = async () => {
if (!containingCategory) {
return;
}
setSubmitting(true);
try {
await removeRoomFromCategory(containingCategory.name, roomId);
dispatchToastMessage({ type: 'success', message: t('User_room_category_remove_success') });
onClose();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
dispatchToastMessage({ type: 'error', message: message || t('Something_went_wrong') });
} finally {
setSubmitting(false);
}
};

return (
<Modal aria-labelledby={titleId}>
<ModalHeader>
<ModalTitle id={titleId}>
{t('Add_to_user_room_category')}
{roomName ? `: ${roomName}` : ''}
</ModalTitle>
<ModalClose tabIndex={-1} onClick={onClose} />
</ModalHeader>
<ModalContent mbe={2}>
<FieldGroup>
<Box mbe={12}>{t('User_room_category_add_description')}</Box>
{categories.length === 0 ? (
<>
<Callout type='info' mbe={12}>
{t('User_room_category_empty_hint')}
</Callout>
<Button onClick={openCreateCategoryModal}>{t('Create_user_room_category')}</Button>
</>
) : (
<>
<Field>
<FieldLabel>{t('User_room_category_select')}</FieldLabel>
<FieldRow>
<Select options={selectOptions} value={selectedCategory} onChange={(value) => setSelectedCategory(String(value))} />
</FieldRow>
</Field>
<Box display='flex' flexDirection='column' gap={8} mbs={12}>
<Button primary loading={submitting} onClick={() => void handleApply()}>
{t('Apply')}
</Button>
{containingCategory && (
<Button secondary danger loading={submitting} onClick={() => void handleRemove()}>
{t('User_room_category_remove_from', { name: containingCategory.name })}
</Button>
)}
<Button secondary small onClick={openCreateCategoryModal}>
{t('Create_user_room_category')}
</Button>
</Box>
</>
)}
</FieldGroup>
</ModalContent>
<ModalFooter>
<ModalFooterControllers>
<Button onClick={onClose}>{t('Close')}</Button>
</ModalFooterControllers>
</ModalFooter>
</Modal>
);
};

export default UserRoomCategoryForRoomModal;
50 changes: 48 additions & 2 deletions apps/meteor/client/hooks/useRoomMenuActions.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import type { RoomType } from '@rocket.chat/core-typings';
import type { GenericMenuItemProps } from '@rocket.chat/ui-client';
import { usePermission, useSetting, useUserSubscription } from '@rocket.chat/ui-contexts';
import { useMemo } from 'react';
import { usePermission, useSetModal, useSetting, useToastMessageDispatch, useUserSubscription, useUserId } from '@rocket.chat/ui-contexts';
import { createElement, useMemo } from 'react';
import { useTranslation } from 'react-i18next';

import UserRoomCategoryForRoomModal from './UserRoomCategoryForRoomModal';
import { useLeaveRoomAction } from './menuActions/useLeaveRoom';
import { useToggleFavoriteAction } from './menuActions/useToggleFavoriteAction';
import { useToggleReadAction } from './menuActions/useToggleReadAction';
import { useHideRoomAction } from './useHideRoomAction';
import { useUserRoomCategories } from './useUserRoomCategories';
import { useOmnichannelPrioritiesMenu } from '../views/omnichannel/hooks/useOmnichannelPrioritiesMenu';

type RoomMenuActionsProps = {
Expand All @@ -30,7 +32,13 @@ export const useRoomMenuActions = ({
hideDefaultOptions,
}: RoomMenuActionsProps): { title: string; items: GenericMenuItemProps[] }[] => {
const { t } = useTranslation();
const dispatchToastMessage = useToastMessageDispatch();
const setModal = useSetModal();
const userId = useUserId();
const subscription = useUserSubscription(rid);
const { data: userRoomCategories = [], removeRoomFromCategory } = useUserRoomCategories();

const userCategoryContainingRoom = userRoomCategories.find((c) => (c.roomIds ?? []).includes(rid));

const isFavorite = Boolean(subscription?.f);
const canLeaveChannel = usePermission('leave-c');
Expand Down Expand Up @@ -83,6 +91,37 @@ export const useRoomMenuActions = ({
content: t('Leave_room'),
onClick: handleLeave,
},
userId &&
!isOmnichannelRoom && {
id: 'addUserRoomCategory',
icon: 'sort-amount-down',
content: t('Add_to_user_room_category'),
onClick: () => {
setModal(
createElement(UserRoomCategoryForRoomModal, {
roomId: rid,
roomName: name,
onClose: () => setModal(null),
}),
);
},
},
userCategoryContainingRoom && {
id: 'removeUserRoomCategory',
icon: 'cross',
content: t('User_room_category_remove_room'),
onClick: () => {
void (async () => {
try {
await removeRoomFromCategory(userCategoryContainingRoom.name, rid);
dispatchToastMessage({ type: 'success', message: t('User_room_category_remove_success') });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
dispatchToastMessage({ type: 'error', message: message || t('Something_went_wrong') });
}
})();
},
},
].filter(Boolean) as GenericMenuItemProps[])
: [],
[
Expand All @@ -97,6 +136,13 @@ export const useRoomMenuActions = ({
canLeave,
handleLeave,
isOmnichannelRoom,
userCategoryContainingRoom,
removeRoomFromCategory,
rid,
name,
dispatchToastMessage,
setModal,
userId,
],
);

Expand Down
29 changes: 11 additions & 18 deletions apps/meteor/client/hooks/useUserRoomCategories.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { useEndpoint, useUserId } from '@rocket.chat/ui-contexts';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect } from 'react';

export const useUserRoomCategories = () => {
const userId = useUserId();
Expand All @@ -10,6 +9,7 @@ export const useUserRoomCategories = () => {
const addRoomEndpoint = useEndpoint('POST', '/v1/user-room-categories/add-room');
const removeRoomEndpoint = useEndpoint('POST', '/v1/user-room-categories/remove-room');
const removeCategoryEndpoint = useEndpoint('POST', '/v1/user-room-categories/remove-category');
const renameCategoryEndpoint = useEndpoint('POST', '/v1/user-room-categories/rename-category');

const queryKey = ['userRoomCategories', userId];

Expand Down Expand Up @@ -61,31 +61,24 @@ export const useUserRoomCategories = () => {
onSuccess: invalidate,
});

const removeRoomFromCategory = async (categoryName: string, roomId: string) =>
removeRoomMutation.mutateAsync({ categoryName, roomId });

const removeCategory = async (name: string) => removeCategoryMutation.mutateAsync(name);
const renameCategoryMutation = useMutation({
mutationFn: async ({ oldName, newName }: { oldName: string; newName: string }) => {
await renameCategoryEndpoint({ oldName, newName });
},
onSuccess: invalidate,
});

useEffect(() => {
(globalThis as Record<string, unknown>).__sidebarCustomCategories = {
addCategory,
addRoomToCategory,
removeRoomFromCategory,
removeCategory,
refresh: query.refetch,
getCategories: () => query.data ?? [],
};
const removeRoomFromCategory = async (categoryName: string, roomId: string) => removeRoomMutation.mutateAsync({ categoryName, roomId });

return () => {
delete (globalThis as Record<string, unknown>).__sidebarCustomCategories;
};
}, [addCategory, addRoomToCategory, removeRoomFromCategory, removeCategory, query.refetch, query.data]);
const removeCategory = async (name: string) => removeCategoryMutation.mutateAsync(name);
const renameCategory = async (oldName: string, newName: string) => renameCategoryMutation.mutateAsync({ oldName, newName });

return {
...query,
addCategory,
addRoomToCategory,
removeRoomFromCategory,
removeCategory,
renameCategory,
};
};
Loading