Non-idiomatic React code (#19303)

pull/17649/merge
Tasso Evangelista 5 years ago committed by GitHub
parent 61e449870b
commit 1e7b31d199
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 4
      .eslintrc
  2. 171
      app/threads/client/components/ThreadComponent.js
  3. 43
      app/threads/client/components/ThreadSkeleton.tsx
  4. 88
      app/threads/client/components/ThreadView.tsx
  5. 13
      client/admin/apps/AppsContext.tsx
  6. 56
      client/admin/apps/AppsProvider.tsx
  7. 6
      client/admin/apps/AppsRoute.js
  8. 19
      client/admin/apps/AppsTable.js
  9. 21
      client/admin/apps/MarketplaceTable.js
  10. 75
      client/admin/apps/hooks/useAppInfo.js
  11. 88
      client/admin/apps/hooks/useAppInfo.ts
  12. 34
      client/admin/apps/hooks/useFilteredApps.js
  13. 40
      client/admin/apps/hooks/useFilteredApps.ts
  14. 8
      client/admin/customEmoji/CustomEmoji.js
  15. 112
      client/admin/customEmoji/CustomEmojiRoute.js
  16. 88
      client/admin/customEmoji/EditCustomEmoji.tsx
  17. 16
      client/admin/customEmoji/EditCustomEmojiWithData.tsx
  18. 10
      client/admin/customSounds/AdminSounds.js
  19. 120
      client/admin/customSounds/AdminSoundsRoute.js
  20. 71
      client/admin/customSounds/EditCustomSound.js
  21. 8
      client/admin/customUserStatus/CustomUserStatus.js
  22. 117
      client/admin/customUserStatus/CustomUserStatusRoute.js
  23. 54
      client/admin/customUserStatus/EditCustomUserStatus.js
  24. 17
      client/admin/customUserStatus/EditCustomUserStatusWithData.tsx
  25. 11
      client/admin/integrations/edit/EditIncomingWebhook.js
  26. 11
      client/admin/integrations/edit/EditOutgoingWebhook.js
  27. 4
      client/admin/integrations/new/NewIncomingWebhook.js
  28. 8
      client/admin/integrations/new/NewOutgoingWebhook.js
  29. 14
      client/admin/oauthApps/OAuthEditApp.js
  30. 9
      client/admin/users/AddUser.js
  31. 6
      client/admin/users/EditUser.js
  32. 23
      client/admin/users/UserInfoActions.js
  33. 67
      client/channel/Discussions/ContextualBar/List.js
  34. 83
      client/channel/Threads/ContextualBar/List.js
  35. 46
      client/channel/UserCard/index.js
  36. 23
      client/channel/UserInfo/actions/UserActions.js
  37. 2
      client/channel/hooks/useUserInfoActions.js
  38. 29
      client/components/CustomFieldsForm.js
  39. 6
      client/components/GenericTable/HeaderCell.tsx
  40. 14
      client/components/basic/PlanTag.js
  41. 1
      client/hooks/useComponentDidUpdate.js
  42. 31
      client/hooks/useFileInput.js
  43. 5
      client/hooks/useResizeInlineBreakpoint.js
  44. 12
      client/omnichannel/departments/DepartmentEdit.js
  45. 2
      client/types/fuselage.d.ts
  46. 4
      client/views/blocks/ModalBlock.js
  47. 6
      client/views/setupWizard/SetupWizardState.js
  48. 2
      ee/app/engagement-dashboard/client/components/MessagesTab/MessagesPerChannelSection.js

@ -27,7 +27,9 @@
"syntax"
],
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn"
"react-hooks/exhaustive-deps": ["warn", {
"additionalHooks": "(useComponentDidUpdate)"
}]
},
"settings": {
"import/resolver": {

@ -1,6 +1,4 @@
import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react';
import { Modal, Box } from '@rocket.chat/fuselage';
import { Meteor } from 'meteor/meteor';
import { Template } from 'meteor/templating';
import { Blaze } from 'meteor/blaze';
import { Tracker } from 'meteor/tracker';
@ -8,93 +6,118 @@ import { useLocalStorage } from '@rocket.chat/fuselage-hooks';
import { ChatMessage } from '../../../models/client';
import { useRoute } from '../../../../client/contexts/RouterContext';
import { roomTypes, APIClient } from '../../../utils/client';
import { call } from '../../../ui-utils/client';
import { useTranslation } from '../../../../client/contexts/TranslationContext';
import VerticalBar from '../../../../client/components/basic/VerticalBar';
import { roomTypes } from '../../../utils/client';
import { normalizeThreadTitle } from '../lib/normalizeThreadTitle';
import { useUserId } from '../../../../client/contexts/UserContext';
import { useEndpoint, useMethod } from '../../../../client/contexts/ServerContext';
import { useToastMessageDispatch } from '../../../../client/contexts/ToastMessagesContext';
import ThreadSkeleton from './ThreadSkeleton';
import ThreadView from './ThreadView';
export default function ThreadComponent({ mid, rid, jump, room, ...props }) {
const t = useTranslation();
const channelRoute = useRoute(roomTypes.getConfig(room.t).route.name);
const [mainMessage, setMainMessage] = useState({});
const useThreadMessage = (tmid) => {
const [message, setMessage] = useState(() => Tracker.nonreactive(() => ChatMessage.findOne({ _id: tmid })));
const getMessage = useEndpoint('GET', 'chat.getMessage');
const [expanded, setExpand] = useLocalStorage('expand-threads', false);
useEffect(() => {
const computation = Tracker.autorun(async (computation) => {
const msg = ChatMessage.findOne({ _id: tmid }) || (await getMessage({ msgId: tmid })).message;
const ref = useRef();
const uid = useMemo(() => Meteor.userId(), []);
if (!msg || computation.stopped) {
return;
}
setMessage((prevMsg) => (prevMsg._updatedAt?.getTime() === msg._updatedAt?.getTime() ? prevMsg : msg));
});
const style = useMemo(() => ({
top: 0,
right: 0,
maxWidth: '855px',
...document.dir === 'rtl' ? { borderTopRightRadius: '4px' } : { borderTopLeftRadius: '4px' },
overflow: 'hidden',
bottom: 0,
zIndex: 100,
}), [document.dir]);
return () => {
computation.stop();
};
}, [getMessage, tmid]);
const following = mainMessage.replies && mainMessage.replies.includes(uid);
const actionId = useMemo(() => (following ? 'unfollow' : 'follow'), [following]);
const button = useMemo(() => (actionId === 'follow' ? 'bell-off' : 'bell'), [actionId]);
const actionLabel = t(actionId === 'follow' ? 'Not_Following' : 'Following');
const headerTitle = useMemo(() => normalizeThreadTitle(mainMessage), [mainMessage._updatedAt]);
return message;
};
const expandLabel = expanded ? 'collapse' : 'expand';
const expandIcon = expanded ? 'arrow-collapse' : 'arrow-expand';
function ThreadComponent({
mid,
jump,
room,
subscription,
}) {
const channelRoute = useRoute(roomTypes.getConfig(room.t).route.name);
const threadMessage = useThreadMessage(mid);
const handleExpandButton = useCallback(() => {
setExpand(!expanded);
}, [expanded]);
const ref = useRef();
const uid = useUserId();
const handleFollowButton = useCallback(() => call(actionId === 'follow' ? 'followMessage' : 'unfollowMessage', { mid }), [actionId, mid]);
const handleClose = useCallback(() => {
channelRoute.push(room.t === 'd' ? { rid } : { name: room.name });
}, [channelRoute, room.t, room.name]);
const headerTitle = useMemo(() => (threadMessage ? normalizeThreadTitle(threadMessage) : null), [threadMessage]);
const [expanded, setExpand] = useLocalStorage('expand-threads', false);
const following = threadMessage?.replies?.includes(uid) ?? false;
useEffect(() => {
const tracker = Tracker.autorun(async () => {
const msg = ChatMessage.findOne({ _id: mid }) || (await APIClient.v1.get('chat.getMessage', { msgId: mid })).message;
if (!msg) {
const dispatchToastMessage = useToastMessageDispatch();
const followMessage = useMethod('followMessage');
const unfollowMessage = useMethod('unfollowMessage');
const setFollowing = useCallback(async (following) => {
try {
if (following) {
await followMessage({ mid });
return;
}
setMainMessage(msg);
});
return () => tracker.stop();
}, [mid]);
await unfollowMessage({ mid });
} catch (error) {
dispatchToastMessage({
type: 'error',
message: error,
});
}
}, [dispatchToastMessage, followMessage, unfollowMessage, mid]);
const handleClose = useCallback(() => {
channelRoute.push(room.t === 'd' ? { rid: room._id } : { name: room.name });
}, [channelRoute, room._id, room.t, room.name]);
const viewDataRef = useRef({
mainMessage: threadMessage,
jump,
following,
subscription,
});
useEffect(() => {
viewDataRef.mainMessage = threadMessage;
viewDataRef.jump = jump;
viewDataRef.following = following;
viewDataRef.subscription = subscription;
}, [following, jump, subscription, threadMessage]);
const hasThreadMessage = !!threadMessage;
useEffect(() => {
let view;
(async () => {
view = mainMessage.rid && ref.current && Blaze.renderWithData(Template.thread, { mainMessage: ChatMessage.findOne({ _id: mid }) || (await APIClient.v1.get('chat.getMessage', { msgId: mid })).message, jump, following, ...props }, ref.current);
})();
return () => view && Blaze.remove(view);
}, [mainMessage.rid, mid]);
if (!mainMessage.rid) {
return <>
{expanded && <Modal.Backdrop onClick={handleClose}/> }
<Box width='380px' flexGrow={1} { ...!expanded && { position: 'relative' }}>
<VerticalBar.Skeleton rcx-thread-view width='full' style={style} display='flex' flexDirection='column' position='absolute' { ...!expanded && { width: '380px' } }/>
</Box>
</>;
if (!ref.current || !hasThreadMessage) {
return;
}
const view = Blaze.renderWithData(Template.thread, viewDataRef.current, ref.current);
return () => {
Blaze.remove(view);
};
}, [hasThreadMessage, mid]);
if (!threadMessage) {
return <ThreadSkeleton expanded={expanded} onClose={handleClose} />;
}
return <>
{expanded && <Modal.Backdrop onClick={handleClose}/> }
<Box width='380px' flexGrow={1} { ...!expanded && { position: 'relative' }}>
<VerticalBar rcx-thread-view width='full' style={style} display='flex' flexDirection='column' position='absolute' { ...!expanded && { width: '380px' } }>
<VerticalBar.Header>
<VerticalBar.Icon name='thread' />
<VerticalBar.Text dangerouslySetInnerHTML={{ __html: headerTitle }} />
<VerticalBar.Action aria-label={expandLabel} onClick={handleExpandButton} name={expandIcon}/>
<VerticalBar.Action aria-label={actionLabel} onClick={handleFollowButton} name={button}/>
<VerticalBar.Close onClick={handleClose}/>
</VerticalBar.Header>
<VerticalBar.Content paddingInline={0} flexShrink={1} flexGrow={1} ref={ref}/>
</VerticalBar>
</Box>
</>;
return <ThreadView
ref={ref}
title={headerTitle}
expanded={expanded}
following={following}
onToggleExpand={(expanded) => setExpand(!expanded)}
onToggleFollow={(following) => setFollowing(!following)}
onClose={handleClose}
/>;
}
export default ThreadComponent;

@ -0,0 +1,43 @@
import React, { FC, useMemo } from 'react';
import { Modal, Box } from '@rocket.chat/fuselage';
import VerticalBar from '../../../../client/components/basic/VerticalBar';
type ThreadSkeletonProps = {
expanded: boolean;
onClose: () => void;
};
const ThreadSkeleton: FC<ThreadSkeletonProps> = ({ expanded, onClose }) => {
const style = useMemo(() => (document.dir === 'rtl'
? {
left: 0,
borderTopRightRadius: 4,
}
: {
right: 0,
borderTopLeftRadius: 4,
}), []);
return <>
{expanded && <Modal.Backdrop onClick={onClose} />}
<Box width='380px' flexGrow={1} position={expanded ? 'static' : 'relative'}>
<VerticalBar.Skeleton
className='rcx-thread-view'
position='absolute'
display='flex'
flexDirection='column'
width={expanded ? 'full' : 380}
maxWidth={855}
overflow='hidden'
zIndex={100}
insetBlock={0}
// insetInlineEnd={0}
// borderStartStartRadius={4}
style={style} // workaround due to a RTL bug in Fuselage
/>
</Box>
</>;
};
export default ThreadSkeleton;

@ -0,0 +1,88 @@
import React, { useCallback, useMemo, forwardRef, FC } from 'react';
import { Modal, Box } from '@rocket.chat/fuselage';
import { useTranslation } from '../../../../client/contexts/TranslationContext';
import VerticalBar from '../../../../client/components/basic/VerticalBar';
type ThreadViewProps = {
title: string;
expanded: boolean;
following: boolean;
onToggleExpand: (expanded: boolean) => void;
onToggleFollow: (following: boolean) => void;
onClose: () => void;
};
const ThreadView: FC<ThreadViewProps> = forwardRef<Element, ThreadViewProps>(({
title,
expanded,
following,
onToggleExpand,
onToggleFollow,
onClose,
}, ref) => {
const style = useMemo(() => (document.dir === 'rtl'
? {
left: 0,
borderTopRightRadius: 4,
}
: {
right: 0,
borderTopLeftRadius: 4,
}), []);
const t = useTranslation();
const expandLabel = expanded ? t('collapse') : t('expand');
const expandIcon = expanded ? 'arrow-collapse' : 'arrow-expand';
const handleExpandActionClick = useCallback(() => {
onToggleExpand(expanded);
}, [expanded, onToggleExpand]);
const followLabel = following ? t('Following') : t('Not_Following');
const followIcon = following ? 'bell' : 'bell-off';
const handleFollowActionClick = useCallback(() => {
onToggleFollow(following);
}, [following, onToggleFollow]);
return <>
{expanded && <Modal.Backdrop onClick={onClose}/>}
<Box width='380px' flexGrow={1} position={expanded ? 'static' : 'relative'}>
<VerticalBar
className='rcx-thread-view'
position='absolute'
display='flex'
flexDirection='column'
width={expanded ? 'full' : 380}
maxWidth={855}
overflow='hidden'
zIndex={100}
insetBlock={0}
// insetInlineEnd={0}
// borderStartStartRadius={4}
style={style} // workaround due to a RTL bug in Fuselage
>
<VerticalBar.Header>
<VerticalBar.Icon name='thread' />
<VerticalBar.Text dangerouslySetInnerHTML={{ __html: title }} />
<VerticalBar.Action aria-label={expandLabel} name={expandIcon} onClick={handleExpandActionClick} />
<VerticalBar.Action aria-label={followLabel} name={followIcon} onClick={handleFollowActionClick} />
<VerticalBar.Close onClick={onClose} />
</VerticalBar.Header>
<VerticalBar.Content
ref={ref}
{...{
flexShrink: 1,
flexGrow: 1,
paddingInline: 0,
}}
/>
</VerticalBar>
</Box>
</>;
});
export default ThreadView;

@ -0,0 +1,13 @@
import { createContext } from 'react';
import { App } from './types';
type AppsContextValue = {
apps: App[];
finishedLoading: boolean;
}
export const AppsContext = createContext<AppsContextValue>({
apps: [],
finishedLoading: false,
});

@ -1,34 +1,24 @@
import React, {
createContext,
useEffect,
useRef,
useState,
FunctionComponent,
FC,
} from 'react';
import { Apps } from '../../../app/apps/client/orchestrator';
import { AppEvents } from '../../../app/apps/client/communication';
import { handleAPIError } from './helpers';
import { App } from './types';
export type AppDataContextValue = {
data: App[];
dataCache: any;
finishedLoading: boolean;
}
export const AppDataContext = createContext<AppDataContextValue>({
data: [],
dataCache: [],
finishedLoading: false,
});
import { AppsContext } from './AppsContext';
type ListenersMapping = {
readonly [P in keyof typeof AppEvents]?: (...args: any[]) => void;
};
const registerListeners = (listeners: ListenersMapping): (() => void) => {
const entries = Object.entries(listeners) as [keyof typeof AppEvents, () => void][];
const entries = Object.entries(listeners) as Exclude<{
[K in keyof ListenersMapping]: [K, ListenersMapping[K]];
}[keyof ListenersMapping], undefined>[];
for (const [event, callback] of entries) {
Apps.getWsListener()?.registerListener(AppEvents[event], callback);
@ -41,23 +31,22 @@ const registerListeners = (listeners: ListenersMapping): (() => void) => {
};
};
const AppProvider: FunctionComponent = ({ children }) => {
const [data, setData] = useState<App[]>(() => []);
const AppsProvider: FC = ({ children }) => {
const [apps, setApps] = useState<App[]>(() => []);
const [finishedLoading, setFinishedLoading] = useState<boolean>(() => false);
const [dataCache, setDataCache] = useState<any>(() => []);
const ref = useRef(data);
ref.current = data;
const ref = useRef(apps);
ref.current = apps;
const invalidateData = (): void => {
setDataCache(() => []);
setApps((apps) => [...apps]);
};
const getDataCopy = (): typeof ref.current => ref.current.slice(0);
useEffect(() => {
const updateData = (data: App[]): void => {
setData(data.sort((a, b) => (a.name.toLowerCase() > b.name.toLowerCase() ? 1 : -1)));
setApps(data.sort((a, b) => (a.name.toLowerCase() > b.name.toLowerCase() ? 1 : -1)));
invalidateData();
};
@ -74,7 +63,7 @@ const AppProvider: FunctionComponent = ({ children }) => {
version,
marketplaceVersion: app.version,
};
setData(updatedData);
setApps(updatedData);
invalidateData();
} catch (error) {
handleAPIError(error);
@ -99,7 +88,7 @@ const AppProvider: FunctionComponent = ({ children }) => {
updatedData[index].version = updatedData[index].marketplaceVersion;
}
setData(updatedData);
setApps(updatedData);
invalidateData();
},
APP_STATUS_CHANGE: ({ appId, status }: {appId: string; status: unknown}): void => {
@ -110,7 +99,7 @@ const AppProvider: FunctionComponent = ({ children }) => {
return;
}
app.status = status;
setData(updatedData);
setApps(updatedData);
invalidateData();
},
APP_SETTING_UPDATED: (): void => {
@ -120,7 +109,7 @@ const AppProvider: FunctionComponent = ({ children }) => {
const unregisterListeners = registerListeners(listeners);
(async (): Promise<void> => {
const fetchData = async (): Promise<void> => {
try {
const installedApps = await Apps.getApps().then((result) => {
let apps: App[] = [];
@ -167,12 +156,19 @@ const AppProvider: FunctionComponent = ({ children }) => {
handleAPIError(e);
unregisterListeners();
}
})();
};
fetchData();
return unregisterListeners;
}, [setData, setFinishedLoading]);
}, []);
const value = {
apps,
finishedLoading,
};
return <AppDataContext.Provider children={children} value={{ data, dataCache, finishedLoading }} />;
return <AppsContext.Provider children={children} value={value} />;
};
export default AppProvider;
export default AppsProvider;

@ -9,7 +9,7 @@ import AppDetailsPage from './AppDetailsPage';
import MarketplacePage from './MarketplacePage';
import AppsPage from './AppsPage';
import AppInstallPage from './AppInstallPage';
import AppProvider from './AppProvider';
import AppsProvider from './AppsProvider';
import AppLogsPage from './AppLogsPage';
function AppsRoute() {
@ -61,7 +61,7 @@ function AppsRoute() {
return <PageSkeleton />;
}
return <AppProvider>
return <AppsProvider>
{
(!context && isMarketPlace && <MarketplacePage />)
|| (!context && !isMarketPlace && <AppsPage />)
@ -69,7 +69,7 @@ function AppsRoute() {
|| (context === 'logs' && <AppLogsPage id={id}/>)
|| (context === 'install' && <AppInstallPage />)
}
</AppProvider>;
</AppsProvider>;
}
export default AppsRoute;

@ -1,14 +1,21 @@
import { useDebouncedValue } from '@rocket.chat/fuselage-hooks';
import React, { useState, useContext, useMemo } from 'react';
import React, { useState } from 'react';
import GenericTable from '../../components/GenericTable';
import { useTranslation } from '../../contexts/TranslationContext';
import { useResizeInlineBreakpoint } from '../../hooks/useResizeInlineBreakpoint';
import { useFilteredApps } from './hooks/useFilteredApps';
import { AppDataContext } from './AppProvider';
import AppRow from './AppRow';
import FilterByText from '../../components/FilterByText';
const filterFunction = (text) => {
if (!text) {
return (app) => app.installed;
}
return (app) => app.installed && app.name.toLowerCase().indexOf(text.toLowerCase()) > -1;
};
function AppsTable() {
const t = useTranslation();
@ -18,17 +25,13 @@ function AppsTable() {
const [sort, setSort] = useState(() => ['name', 'asc']);
const { text, current, itemsPerPage } = params;
const { data, dataCache } = useContext(AppDataContext);
const [filteredApps, filteredAppsCount] = useFilteredApps({
filterFunction,
text: useDebouncedValue(text, 500),
current,
itemsPerPage,
sort: useDebouncedValue(sort, 200),
data: useMemo(
() => (data.length ? data.filter((current) => current.installed) : null),
[dataCache],
),
dataCache,
});
const [sortBy, sortDirection] = sort;

@ -1,14 +1,17 @@
import { useDebouncedValue } from '@rocket.chat/fuselage-hooks';
import React, { useCallback, useState, useContext, useMemo } from 'react';
import React, { useState, useContext } from 'react';
import GenericTable from '../../components/GenericTable';
import { useTranslation } from '../../contexts/TranslationContext';
import { useResizeInlineBreakpoint } from '../../hooks/useResizeInlineBreakpoint';
import { useFilteredApps } from './hooks/useFilteredApps';
import { AppDataContext } from './AppProvider';
import { AppsContext } from './AppsContext';
import MarketplaceRow from './MarketplaceRow';
import FilterByText from '../../components/FilterByText';
const filterFunction = (text) =>
({ name, marketplace }) => marketplace !== false && name.toLowerCase().indexOf(text.toLowerCase()) > -1;
function MarketplaceTable() {
const t = useTranslation();
@ -18,23 +21,17 @@ function MarketplaceTable() {
const [sort, setSort] = useState(['name', 'asc']);
const { text, current, itemsPerPage } = params;
const { data, dataCache, finishedLoading } = useContext(AppDataContext);
const [filteredApps, filteredAppsCount] = useFilteredApps({
filterFunction: useCallback(
(text) => ({ name, marketplace }) => marketplace !== false && name.toLowerCase().indexOf(text.toLowerCase()) > -1,
[],
),
filterFunction,
text: useDebouncedValue(text, 500),
current,
itemsPerPage,
sort: useDebouncedValue(sort, 200),
data: useMemo(
() => (data.length ? data : null),
[dataCache],
),
dataCache,
});
const { finishedLoading } = useContext(AppsContext);
const [sortBy, sortDirection] = sort;
const onHeaderCellClick = (id) => {
setSort(

@ -1,75 +0,0 @@
import { useState, useEffect, useContext } from 'react';
import { Apps } from '../../../../app/apps/client/orchestrator';
import { AppDataContext } from '../AppProvider';
import { handleAPIError } from '../helpers';
const getBundledIn = async (appId, appVersion) => {
try {
const { bundledIn } = await Apps.getLatestAppFromMarketplace(appId, appVersion);
bundledIn && await Promise.all(bundledIn.map((bundle, i) => Apps.getAppsOnBundle(bundle.bundleId).then((value) => {
bundle.apps = value.slice(0, 4);
bundledIn[i] = bundle;
})));
return bundledIn;
} catch (e) {
handleAPIError(e);
}
};
const getSettings = async (appId, installed) => {
if (!installed) { return {}; }
try {
const settings = await Apps.getAppSettings(appId);
return settings;
} catch (e) {
handleAPIError(e);
}
};
const getApis = async (appId, installed) => {
if (!installed) { return {}; }
try {
return await Apps.getAppApis(appId);
} catch (e) {
handleAPIError(e);
}
};
export const useAppInfo = (appId) => {
const { data, dataCache } = useContext(AppDataContext);
const [appData, setAppData] = useState({});
useEffect(() => {
(async () => {
if (!data.length || !appId) { return; }
let app = data.find(({ id }) => id === appId);
if (!app) {
const localApp = await Apps.getApp(appId);
app = { ...localApp, installed: true, marketplace: false };
}
if (app.marketplace === false) {
const [settings, apis] = await Promise.all([getSettings(app.id, app.installed), getApis(app.id, app.installed)]);
return setAppData({ ...app, settings, apis });
}
const [
bundledIn,
settings,
apis,
] = await Promise.all([
getBundledIn(app.id, app.version),
getSettings(app.id, app.installed),
getApis(app.id, app.installed),
]);
setAppData({ ...app, bundledIn, settings, apis });
})();
}, [appId, data, dataCache]);
return appData;
};

@ -0,0 +1,88 @@
import { useState, useEffect, useContext } from 'react';
import { Apps } from '../../../../app/apps/client/orchestrator';
import { AppsContext } from '../AppsContext';
import { handleAPIError } from '../helpers';
import { App } from '../types';
const getBundledIn = async (appId: string, appVersion: string): Promise<App['bundledIn']> => {
try {
const { bundledIn } = await Apps.getLatestAppFromMarketplace(appId, appVersion) as App;
if (!bundledIn) {
return [];
}
return await Promise.all(
bundledIn.map(async (bundle) => {
const apps = await Apps.getAppsOnBundle(bundle.bundleId);
bundle.apps = apps.slice(0, 4);
return bundle;
}),
);
} catch (e) {
handleAPIError(e);
return [];
}
};
const getSettings = async (appId: string, installed: boolean): Promise<Record<string, unknown>> => {
if (!installed) {
return {};
}
try {
return Apps.getAppSettings(appId);
} catch (e) {
handleAPIError(e);
return {};
}
};
const getApis = async (appId: string, installed: boolean): Promise<Record<string, unknown>> => {
if (!installed) {
return {};
}
try {
return Apps.getAppApis(appId);
} catch (e) {
handleAPIError(e);
return {};
}
};
type AppInfo = Partial<App & {
settings: Record<string, unknown>;
apis: Record<string, unknown>;
}>;
export const useAppInfo = (appId: string): AppInfo => {
const { apps } = useContext(AppsContext);
const [appData, setAppData] = useState({});
useEffect(() => {
const fetchAppInfo = async (): Promise<void> => {
if (!apps.length || !appId) {
return;
}
const app = apps.find((app) => app.id === appId) ?? {
...await Apps.getApp(appId),
installed: true,
marketplace: false,
};
const [bundledIn, settings, apis] = await Promise.all([
app.marketplace === false ? [] : getBundledIn(app.id, app.version),
getSettings(app.id, app.installed),
getApis(app.id, app.installed),
]);
setAppData({ ...app, bundledIn, settings, apis });
};
fetchAppInfo();
}, [appId, apps]);
return appData;
};

@ -1,34 +0,0 @@
import { useMemo } from 'react';
export const useFilteredApps = ({
filterFunction = (text) => {
if (!text) { return () => true; }
return (app) => app.name.toLowerCase().indexOf(text.toLowerCase()) > -1;
},
text,
sort,
current,
itemsPerPage,
data,
dataCache,
}) => {
const filteredValues = useMemo(() => {
if (Array.isArray(data)) {
const dataCopy = data.slice(0);
let filtered = sort[1] === 'asc' ? dataCopy : dataCopy.reverse();
filtered = filtered.filter(filterFunction(text));
const filteredLength = filtered.length;
const sliceStart = current > filteredLength ? 0 : current;
filtered = filtered.slice(sliceStart, current + itemsPerPage);
return [filtered, filteredLength];
}
return [null, 0];
}, [text, sort[1], dataCache, current, itemsPerPage]);
return [...filteredValues];
};

@ -0,0 +1,40 @@
import { useContext } from 'react';
import { AppsContext } from '../AppsContext';
import { App } from '../types';
export const useFilteredApps = ({
filterFunction = (text: string): ((app: App) => boolean) => {
if (!text) { return (): boolean => true; }
return (app: App): boolean => app.name.toLowerCase().indexOf(text.toLowerCase()) > -1;
},
text,
sort: [, sortDirection],
current,
itemsPerPage,
}: {
filterFunction: (text: string) => (app: App) => boolean;
text: string;
sort: [string, 'asc' | 'desc'];
current: number;
itemsPerPage: number;
apps: App[];
}): [App[] | null, number] => {
const { apps } = useContext(AppsContext);
if (!Array.isArray(apps) || apps.length === 0) {
return [null, 0];
}
const filtered = apps.filter(filterFunction(text));
if (sortDirection === 'desc') {
filtered.reverse();
}
const total = filtered.length;
const start = current > total ? 0 : current;
const end = current + itemsPerPage;
const slice = filtered.slice(start, end);
return [slice, total];
};

@ -16,8 +16,8 @@ function CustomEmoji({
const t = useTranslation();
const header = useMemo(() => [
<GenericTable.HeaderCell key={'name'} direction={sort[1]} active={sort[0] === 'name'} onClick={onHeaderClick} sort='name' w='x200'>{t('Name')}</GenericTable.HeaderCell>,
<GenericTable.HeaderCell key={'aliases'} w='x200'>{t('Aliases')}</GenericTable.HeaderCell>,
<GenericTable.HeaderCell key='name' direction={sort[1]} active={sort[0] === 'name'} onClick={onHeaderClick} sort='name' w='x200'>{t('Name')}</GenericTable.HeaderCell>,
<GenericTable.HeaderCell key='aliases' w='x200'>{t('Aliases')}</GenericTable.HeaderCell>,
], [onHeaderClick, sort, t]);
const renderRow = (emojis) => {
@ -31,8 +31,8 @@ function CustomEmoji({
return <GenericTable
header={header}
renderRow={renderRow}
results={data.emojis}
total={data.total}
results={data?.emojis ?? []}
total={data?.total ?? 0}
setParams={setParams}
params={params}
renderFilter={({ onChange, ...props }) => <FilterByText onChange={onChange} {...props} />}

@ -2,105 +2,93 @@ import React, { useMemo, useState, useCallback } from 'react';
import { Button, Icon } from '@rocket.chat/fuselage';
import { useDebouncedValue } from '@rocket.chat/fuselage-hooks';
import { usePermission } from '../../contexts/AuthorizationContext';
import { useTranslation } from '../../contexts/TranslationContext';
import Page from '../../components/basic/Page';
import VerticalBar from '../../components/basic/VerticalBar';
import NotAuthorizedPage from '../../components/NotAuthorizedPage';
import { usePermission } from '../../contexts/AuthorizationContext';
import { useRoute, useRouteParameter } from '../../contexts/RouterContext';
import { useTranslation } from '../../contexts/TranslationContext';
import { useEndpointDataExperimental } from '../../hooks/useEndpointDataExperimental';
import AddCustomEmoji from './AddCustomEmoji';
import CustomEmoji from './CustomEmoji';
import { useRoute, useRouteParameter } from '../../contexts/RouterContext';
import { useEndpointData } from '../../hooks/useEndpointData';
import VerticalBar from '../../components/basic/VerticalBar';
import EditCustomEmojiWithData from './EditCustomEmojiWithData';
const sortDir = (sortDir) => (sortDir === 'asc' ? 1 : -1);
export const useQuery = ({ text, itemsPerPage, current }, [column, direction], cache) => useMemo(() => ({
query: JSON.stringify({ name: { $regex: text || '', $options: 'i' } }),
sort: JSON.stringify({ [column]: sortDir(direction) }),
...itemsPerPage && { count: itemsPerPage },
...current && { offset: current },
// TODO: remove cache. Is necessary for data invalidation
}), [text, itemsPerPage, current, column, direction, cache]);
function CustomEmojiRoute({ props }) {
const t = useTranslation();
function CustomEmojiRoute() {
const route = useRoute('emoji-custom');
const context = useRouteParameter('context');
const id = useRouteParameter('id');
const canManageEmoji = usePermission('manage-emoji');
const routeName = 'emoji-custom';
const [params, setParams] = useState({ text: '', current: 0, itemsPerPage: 25 });
const [sort, setSort] = useState(['name', 'asc']);
const [cache, setCache] = useState();
const debouncedParams = useDebouncedValue(params, 500);
const debouncedSort = useDebouncedValue(sort, 500);
const query = useQuery(debouncedParams, debouncedSort, cache);
const t = useTranslation();
const data = useEndpointData('emoji-custom.all', query) || { emojis: { } };
const [params, setParams] = useState(() => ({ text: '', current: 0, itemsPerPage: 25 }));
const [sort, setSort] = useState(() => ['name', 'asc']);
const router = useRoute(routeName);
const { text, itemsPerPage, current } = useDebouncedValue(params, 500);
const [column, direction] = useDebouncedValue(sort, 500);
const query = useMemo(() => ({
query: JSON.stringify({ name: { $regex: text || '', $options: 'i' } }),
sort: JSON.stringify({ [column]: direction === 'asc' ? 1 : -1 }),
...itemsPerPage && { count: itemsPerPage },
...current && { offset: current },
}), [text, itemsPerPage, current, column, direction]);
const context = useRouteParameter('context');
const id = useRouteParameter('id');
const { data, reload } = useEndpointDataExperimental('emoji-custom.all', query);
const onClick = (_id) => () => {
router.push({
const handleItemClick = (_id) => () => {
route.push({
context: 'edit',
id: _id,
});
};
const onHeaderClick = (id) => {
const [sortBy, sortDirection] = sort;
const handleHeaderClick = (id) => {
setSort(([sortBy, sortDirection]) => {
if (sortBy === id) {
return [id, sortDirection === 'asc' ? 'desc' : 'asc'];
}
if (sortBy === id) {
setSort([id, sortDirection === 'asc' ? 'desc' : 'asc']);
return;
}
setSort([id, 'asc']);
return [id, 'asc'];
});
};
const handleHeaderButtonClick = useCallback((context) => () => {
router.push({ context });
}, [router]);
const handleNewButtonClick = useCallback(() => {
route.push({ context: 'new' });
}, [route]);
const close = () => {
router.push({});
const handleClose = () => {
route.push({});
};
const onChange = useCallback(() => {
setCache(new Date());
}, []);
const handleChange = useCallback(() => {
reload();
}, [reload]);
if (!canManageEmoji) {
return <NotAuthorizedPage />;
}
return <Page {...props} flexDirection='row'>
return <Page flexDirection='row'>
<Page name='admin-emoji-custom'>
<Page.Header title={t('Custom_Emoji')}>
<Button small onClick={handleHeaderButtonClick('new')} aria-label={t('New')}>
<Button small onClick={handleNewButtonClick} aria-label={t('New')}>
<Icon name='plus'/>
</Button>
</Page.Header>
<Page.Content>
<CustomEmoji setParams={setParams} params={params} onHeaderClick={onHeaderClick} data={data} onClick={onClick} sort={sort}/>
<CustomEmoji setParams={setParams} params={params} onHeaderClick={handleHeaderClick} data={data} onClick={handleItemClick} sort={sort}/>
</Page.Content>
</Page>
{ context
&& <VerticalBar className='contextual-bar' width='x380' qa-context-name={`admin-user-and-room-context-${ context }`} flexShrink={0}>
<VerticalBar.Header>
{ context === 'edit' && t('Custom_Emoji_Info') }
{ context === 'new' && t('Custom_Emoji_Add') }
<VerticalBar.Close onClick={close}/>
</VerticalBar.Header>
{context === 'edit' && <EditCustomEmojiWithData _id={id} close={close} onChange={onChange} cache={cache}/>}
{context === 'new' && <AddCustomEmoji close={close} onChange={onChange}/>}
</VerticalBar>}
{context && <VerticalBar className='contextual-bar' width='x380' flexShrink={0}>
<VerticalBar.Header>
{context === 'edit' && t('Custom_Emoji_Info')}
{context === 'new' && t('Custom_Emoji_Add')}
<VerticalBar.Close onClick={handleClose}/>
</VerticalBar.Header>
{context === 'edit' && <EditCustomEmojiWithData _id={id} close={handleClose} onChange={handleChange} />}
{context === 'new' && <AddCustomEmoji close={handleClose} onChange={handleChange}/>}
</VerticalBar>}
</Page>;
}
export default CustomEmojiRoute;

@ -1,6 +1,7 @@
import React, { useCallback, useState, useMemo, useEffect, FC, ChangeEvent } from 'react';
import { Box, Button, ButtonGroup, Margins, TextInput, Field, Icon } from '@rocket.chat/fuselage';
import { useToastMessageDispatch } from '../../contexts/ToastMessagesContext';
import { useTranslation } from '../../contexts/TranslationContext';
import { useFileInput } from '../../hooks/useFileInput';
import { useEndpointUpload } from '../../hooks/useEndpointUpload';
@ -10,7 +11,7 @@ import VerticalBar from '../../components/basic/VerticalBar';
import DeleteSuccessModal from '../../components/DeleteSuccessModal';
import DeleteWarningModal from '../../components/DeleteWarningModal';
import { EmojiDescriptor } from './types';
import { useAbsoluteUrl } from '../../contexts/ServerContext';
type EditCustomEmojiProps = {
close: () => void;
@ -20,25 +21,31 @@ type EditCustomEmojiProps = {
const EditCustomEmoji: FC<EditCustomEmojiProps> = ({ close, onChange, data, ...props }) => {
const t = useTranslation();
const dispatchToastMessage = useToastMessageDispatch();
const setModal = useSetModal();
const absoluteUrl = useAbsoluteUrl();
const { _id, name: previousName, aliases: previousAliases, extension: previousExtension } = data || {};
const previousEmoji = data || {};
const { _id, name: previousName, aliases: previousAliases } = data || {};
const [name, setName] = useState(previousName);
const [aliases, setAliases] = useState(previousAliases.join(', '));
const [name, setName] = useState(() => data?.name ?? '');
const [aliases, setAliases] = useState(() => data?.aliases?.join(', ') ?? '');
const [emojiFile, setEmojiFile] = useState<Blob>();
const setModal = useSetModal();
const [newEmojiPreview, setNewEmojiPreview] = useState(`/emoji-custom/${ encodeURIComponent(previousName) }.${ previousExtension }`);
const newEmojiPreview = useMemo(() => {
if (emojiFile) {
return URL.createObjectURL(emojiFile);
}
if (data) {
return absoluteUrl(`/emoji-custom/${ encodeURIComponent(data.name) }.${ data.extension }`);
}
return null;
}, [absoluteUrl, data, emojiFile]);
useEffect(() => {
setName(previousName || '');
setAliases((previousAliases && previousAliases.join(', ')) || '');
}, [previousName, previousAliases, previousEmoji, _id]);
const setEmojiPreview = useCallback(async (file) => {
setEmojiFile(file);
setNewEmojiPreview(URL.createObjectURL(file));
}, [setEmojiFile]);
}, [previousName, previousAliases, _id]);
const hasUnsavedChanges = useMemo(() => previousName !== name || aliases !== previousAliases.join(', ') || !!emojiFile, [previousName, name, aliases, previousAliases, emojiFile]);
@ -62,25 +69,40 @@ const EditCustomEmoji: FC<EditCustomEmojiProps> = ({ close, onChange, data, ...p
const deleteAction = useEndpointAction('POST', 'emoji-custom.delete', useMemo(() => ({ emojiId: _id }), [_id]));
const onDeleteConfirm = useCallback(async () => {
const result = await deleteAction();
if (result.success) {
setModal(() => <DeleteSuccessModal
children={t('Custom_Emoji_Has_Been_Deleted')}
onClose={(): void => { setModal(undefined); close(); onChange(); }}
/>);
}
}, [close, deleteAction, onChange, setModal, t]);
const openConfirmDelete = useCallback(() => setModal(() => <DeleteWarningModal
children={t('Custom_Emoji_Delete_Warning')}
onDelete={onDeleteConfirm}
onCancel={(): void => setModal(undefined)}
/>), [onDeleteConfirm, setModal, t]);
const handleDeleteButtonClick = useCallback(() => {
const handleClose = (): void => {
setModal(null);
close();
onChange();
};
const handleDelete = async (): Promise<void> => {
try {
await deleteAction();
setModal(() => <DeleteSuccessModal
children={t('Custom_Emoji_Has_Been_Deleted')}
onClose={handleClose}
/>);
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
onChange();
}
};
const handleCancel = (): void => {
setModal(null);
};
setModal(() => <DeleteWarningModal
children={t('Custom_Emoji_Delete_Warning')}
onDelete={handleDelete}
onCancel={handleCancel}
/>);
}, [close, deleteAction, dispatchToastMessage, onChange, setModal, t]);
const handleAliasesChange = useCallback((e) => setAliases(e.currentTarget.value), [setAliases]);
const [clickUpload] = useFileInput(setEmojiPreview, 'emoji');
const [clickUpload] = useFileInput(setEmojiFile, 'emoji');
return <VerticalBar.ScrollableContent {...(props as any)}>
<Field>
@ -100,11 +122,11 @@ const EditCustomEmoji: FC<EditCustomEmojiProps> = ({ close, onChange, data, ...p
{t('Custom_Emoji')}
<Button square onClick={clickUpload}><Icon name='upload' size='x20'/></Button>
</Field.Label>
{ newEmojiPreview && <Box display='flex' flexDirection='row' mbs='none' justifyContent='center'>
{newEmojiPreview && <Box display='flex' flexDirection='row' mbs='none' justifyContent='center'>
<Margins inline='x4'>
<Box is='img' style={{ objectFit: 'contain' }} w='x120' h='x120' src={newEmojiPreview}/>
</Margins>
</Box> }
</Box>}
</Field>
<Field>
<Field.Row>
@ -117,7 +139,9 @@ const EditCustomEmoji: FC<EditCustomEmojiProps> = ({ close, onChange, data, ...p
<Field>
<Field.Row>
<ButtonGroup stretch w='full'>
<Button primary danger onClick={openConfirmDelete}><Icon name='trash' mie='x4'/>{t('Delete')}</Button>
<Button primary danger onClick={handleDeleteButtonClick}>
<Icon name='trash' mie='x4'/>{t('Delete')}
</Button>
</ButtonGroup>
</Field.Row>
</Field>

@ -8,17 +8,13 @@ import { EmojiDescriptor } from './types';
type EditCustomEmojiWithDataProps = {
_id: string;
cache: unknown;
close: () => void;
onChange: () => void;
};
const EditCustomEmojiWithData: FC<EditCustomEmojiWithDataProps> = ({ _id, cache, onChange, ...props }) => {
const EditCustomEmojiWithData: FC<EditCustomEmojiWithDataProps> = ({ _id, onChange, ...props }) => {
const t = useTranslation();
const query = useMemo(() => ({
query: JSON.stringify({ _id }),
// TODO: remove cache. Is necessary for data invalidation
}), [_id, cache]);
const query = useMemo(() => ({ query: JSON.stringify({ _id }) }), [_id]);
const {
data = {
@ -28,6 +24,7 @@ const EditCustomEmojiWithData: FC<EditCustomEmojiWithDataProps> = ({ _id, cache,
},
state,
error,
reload,
} = useEndpointDataExperimental<{
emojis?: {
update: EmojiDescriptor[];
@ -54,7 +51,12 @@ const EditCustomEmojiWithData: FC<EditCustomEmojiWithDataProps> = ({ _id, cache,
return <Box fontScale='h1' pb='x20'>{t('Custom_User_Status_Error_Invalid_User_Status')}</Box>;
}
return <EditCustomEmoji data={data.emojis.update[0]} onChange={onChange} {...props}/>;
const handleChange = (): void => {
onChange && onChange();
reload && reload();
};
return <EditCustomEmoji data={data.emojis.update[0]} onChange={handleChange} {...props}/>;
};
export default EditCustomEmojiWithData;

@ -17,15 +17,15 @@ function AdminSounds({
const t = useTranslation();
const header = useMemo(() => [
<GenericTable.HeaderCell key={'name'} direction={sort[1]} active={sort[0] === 'name'} onClick={onHeaderClick} sort='name'>{t('Name')}</GenericTable.HeaderCell>,
<GenericTable.HeaderCell key='name' direction={sort[1]} active={sort[0] === 'name'} onClick={onHeaderClick} sort='name'>{t('Name')}</GenericTable.HeaderCell>,
<GenericTable.HeaderCell w='x40' key='action' />,
], [sort]);
], [onHeaderClick, sort, t]);
const customSound = useCustomSound();
const handlePlay = useCallback((sound) => {
customSound.play(sound);
}, []);
}, [customSound]);
const renderRow = (sound) => {
const { _id, name } = sound;
@ -43,8 +43,8 @@ function AdminSounds({
return <GenericTable
header={header}
renderRow={renderRow}
results={data.sounds}
total={data.total}
results={data?.sounds ?? []}
total={data?.total ?? 0}
setParams={setParams}
params={params}
renderFilter={({ onChange, ...props }) => <FilterByText onChange={onChange} {...props} />}

@ -1,106 +1,94 @@
// eslint-disable
import React, { useMemo, useState, useCallback } from 'react';
import { Button, Icon } from '@rocket.chat/fuselage';
import { useDebouncedValue } from '@rocket.chat/fuselage-hooks';
import Page from '../../components/basic/Page';
import VerticalBar from '../../components/basic/VerticalBar';
import NotAuthorizedPage from '../../components/NotAuthorizedPage';
import { usePermission } from '../../contexts/AuthorizationContext';
import { useRoute, useRouteParameter } from '../../contexts/RouterContext';
import { useTranslation } from '../../contexts/TranslationContext';
import Page from '../../components/basic/Page';
import { useEndpointDataExperimental } from '../../hooks/useEndpointDataExperimental';
import AdminSounds from './AdminSounds';
import AddCustomSound from './AddCustomSound';
import EditCustomSound from './EditCustomSound';
import { useRoute, useRouteParameter } from '../../contexts/RouterContext';
import { useEndpointData } from '../../hooks/useEndpointData';
import VerticalBar from '../../components/basic/VerticalBar';
import NotAuthorizedPage from '../../components/NotAuthorizedPage';
const sortDir = (sortDir) => (sortDir === 'asc' ? 1 : -1);
export const useQuery = ({ text, itemsPerPage, current }, [column, direction], cache) => useMemo(() => ({
query: JSON.stringify({ name: { $regex: text || '', $options: 'i' } }),
sort: JSON.stringify({ [column]: sortDir(direction) }),
...itemsPerPage && { count: itemsPerPage },
...current && { offset: current },
// TODO: remove cache. Is necessary for data invalidation
}), [text, itemsPerPage, current, column, direction, cache]);
export default function CustomSoundsRoute({ props }) {
const t = useTranslation();
function CustomSoundsRoute() {
const route = useRoute('custom-sounds');
const context = useRouteParameter('context');
const id = useRouteParameter('id');
const canManageCustomSounds = usePermission('manage-sounds');
const routeName = 'custom-sounds';
const [params, setParams] = useState({ text: '', current: 0, itemsPerPage: 25 });
const [sort, setSort] = useState(['name', 'asc']);
const [cache, setCache] = useState();
const debouncedParams = useDebouncedValue(params, 500);
const debouncedSort = useDebouncedValue(sort, 500);
const query = useQuery(debouncedParams, debouncedSort, cache);
const data = useEndpointData('custom-sounds.list', query) || {};
const t = useTranslation();
const [params, setParams] = useState(() => ({ text: '', current: 0, itemsPerPage: 25 }));
const [sort, setSort] = useState(() => ['name', 'asc']);
const router = useRoute(routeName);
const { text, itemsPerPage, current } = useDebouncedValue(params, 500);
const [column, direction] = useDebouncedValue(sort, 500);
const query = useMemo(() => ({
query: JSON.stringify({ name: { $regex: text || '', $options: 'i' } }),
sort: JSON.stringify({ [column]: direction === 'asc' ? 1 : -1 }),
...itemsPerPage && { count: itemsPerPage },
...current && { offset: current },
}), [text, itemsPerPage, current, column, direction]);
const context = useRouteParameter('context');
const id = useRouteParameter('id');
const { data, reload } = useEndpointDataExperimental('custom-sounds.list', query);
const onClick = useCallback((_id) => () => {
router.push({
const handleItemClick = useCallback((_id) => () => {
route.push({
context: 'edit',
id: _id,
});
}, [router]);
}, [route]);
const onHeaderClick = (id) => {
const [sortBy, sortDirection] = sort;
const handleHeaderClick = (id) => {
setSort(([sortBy, sortDirection]) => {
if (sortBy === id) {
return [id, sortDirection === 'asc' ? 'desc' : 'asc'];
}
if (sortBy === id) {
setSort([id, sortDirection === 'asc' ? 'desc' : 'asc']);
return;
}
setSort([id, 'asc']);
return [id, 'asc'];
});
};
const handleHeaderButtonClick = useCallback((context) => () => {
router.push({ context });
}, [router]);
const handleNewButtonClick = useCallback(() => {
route.push({ context: 'new' });
}, [route]);
const close = useCallback(() => {
router.push({});
}, [router]);
const handleClose = useCallback(() => {
route.push({});
}, [route]);
const onChange = useCallback(() => {
setCache(new Date());
}, []);
const handleChange = useCallback(() => {
reload();
}, [reload]);
if (!canManageCustomSounds) {
return <NotAuthorizedPage />;
}
return <Page {...props} flexDirection='row'>
return <Page flexDirection='row'>
<Page name='admin-custom-sounds'>
<Page.Header title={t('Custom_Sounds')}>
<Button small onClick={handleHeaderButtonClick('new')} aria-label={t('New')}>
<Button small onClick={handleNewButtonClick} aria-label={t('New')}>
<Icon name='plus'/>
</Button>
</Page.Header>
<Page.Content>
<AdminSounds setParams={setParams} params={params} onHeaderClick={onHeaderClick} data={data} onClick={onClick} sort={sort}/>
<AdminSounds setParams={setParams} params={params} onHeaderClick={handleHeaderClick} data={data} onClick={handleItemClick} sort={sort}/>
</Page.Content>
</Page>
{ context
&& <VerticalBar className='contextual-bar' width='x380' qa-context-name={`admin-user-and-room-context-${ context }`} flexShrink={0}>
<VerticalBar.Header>
{ context === 'edit' && t('Custom_Sound_Edit') }
{ context === 'new' && t('Custom_Sound_Add') }
<VerticalBar.Close onClick={close}/>
</VerticalBar.Header>
{context === 'edit' && <EditCustomSound _id={id} close={close} onChange={onChange} cache={cache}/>}
{context === 'new' && <AddCustomSound goToNew={onClick} close={close} onChange={onChange}/>}
</VerticalBar>}
{context && <VerticalBar className='contextual-bar' width='x380' flexShrink={0}>
<VerticalBar.Header>
{context === 'edit' && t('Custom_Sound_Edit')}
{context === 'new' && t('Custom_Sound_Add')}
<VerticalBar.Close onClick={handleClose}/>
</VerticalBar.Header>
{context === 'edit' && <EditCustomSound _id={id} close={handleClose} onChange={handleChange} />}
{context === 'new' && <AddCustomSound goToNew={handleItemClick} close={handleClose} onChange={handleChange}/>}
</VerticalBar>}
</Page>;
}
export default CustomSoundsRoute;

@ -12,12 +12,10 @@ import VerticalBar from '../../components/basic/VerticalBar';
import DeleteSuccessModal from '../../components/DeleteSuccessModal';
import DeleteWarningModal from '../../components/DeleteWarningModal';
function EditCustomSound({ _id, cache, ...props }) {
const query = useMemo(() => ({
query: JSON.stringify({ _id }),
}), [_id]);
function EditCustomSound({ _id, onChange, ...props }) {
const query = useMemo(() => ({ query: JSON.stringify({ _id }) }), [_id]);
const { data, state, error } = useEndpointDataExperimental('custom-sounds.list', query);
const { data, state, error, reload } = useEndpointDataExperimental('custom-sounds.list', query);
if (state === ENDPOINT_STATES.LOADING) {
return <Box pb='x20'>
@ -39,19 +37,24 @@ function EditCustomSound({ _id, cache, ...props }) {
return <Box fontScale='h1' pb='x20'>{error}</Box>;
}
return <EditSound data={data.sounds[0]} {...props}/>;
const handleChange = () => {
onChange && onChange();
reload && reload();
};
return <EditSound data={data.sounds[0]} onChange={handleChange} {...props}/>;
}
function EditSound({ close, onChange, data, ...props }) {
const t = useTranslation();
const dispatchToastMessage = useToastMessageDispatch();
const setModal = useSetModal();
const { _id, name: previousName } = data || {};
const previousSound = data || {};
const previousSound = useMemo(() => data || {}, [data]);
const [name, setName] = useState('');
const [sound, setSound] = useState();
const setModal = useSetModal();
const [name, setName] = useState(() => data?.name ?? '');
const [sound, setSound] = useState(() => data ?? {});
useEffect(() => {
setName(previousName || '');
@ -106,24 +109,36 @@ function EditSound({ close, onChange, data, ...props }) {
onChange();
}, [saveAction, sound, onChange]);
const onDeleteConfirm = useCallback(async () => {
try {
await deleteCustomSound(_id);
setModal(() => <DeleteSuccessModal
children={t('Custom_Sound_Has_Been_Deleted')}
onClose={() => { setModal(undefined); close(); onChange(); }}
/>);
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
const handleDeleteButtonClick = useCallback(() => {
const handleClose = () => {
setModal(null);
close();
onChange();
}
}, [_id, close, deleteCustomSound, dispatchToastMessage, onChange]);
};
const handleDelete = async () => {
try {
await deleteCustomSound(_id);
setModal(() => <DeleteSuccessModal
children={t('Custom_Sound_Has_Been_Deleted')}
onClose={handleClose}
/>);
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
onChange();
}
};
const handleCancel = () => {
setModal(null);
};
const openConfirmDelete = () => setModal(() => <DeleteWarningModal
children={t('Custom_Sound_Delete_Warning')}
onDelete={onDeleteConfirm}
onCancel={() => setModal(undefined)}
/>);
setModal(() => <DeleteWarningModal
children={t('Custom_Sound_Delete_Warning')}
onDelete={handleDelete}
onCancel={handleCancel}
/>);
}, [_id, close, deleteCustomSound, dispatchToastMessage, onChange, setModal, t]);
const [clickUpload] = useFileInput(handleChangeFile, 'audio/mp3');
@ -156,7 +171,9 @@ function EditSound({ close, onChange, data, ...props }) {
<Field>
<Field.Row>
<ButtonGroup stretch w='full'>
<Button primary danger onClick={openConfirmDelete}><Icon name='trash' mie='x4'/>{t('Delete')}</Button>
<Button primary danger onClick={handleDeleteButtonClick}>
<Icon name='trash' mie='x4'/>{t('Delete')}
</Button>
</ButtonGroup>
</Field.Row>
</Field>

@ -18,8 +18,8 @@ function CustomUserStatus({
const t = useTranslation();
const header = useMemo(() => [
<GenericTable.HeaderCell key={'name'} direction={sort[1]} active={sort[0] === 'name'} onClick={onHeaderClick} sort='name'>{t('Name')}</GenericTable.HeaderCell>,
<GenericTable.HeaderCell key={'presence'} direction={sort[1]} active={sort[0] === 'statusType'} onClick={onHeaderClick} sort='statusType'>{t('Presence')}</GenericTable.HeaderCell>,
<GenericTable.HeaderCell key='name' direction={sort[1]} active={sort[0] === 'name'} onClick={onHeaderClick} sort='name'>{t('Name')}</GenericTable.HeaderCell>,
<GenericTable.HeaderCell key='presence' direction={sort[1]} active={sort[0] === 'statusType'} onClick={onHeaderClick} sort='statusType'>{t('Presence')}</GenericTable.HeaderCell>,
].filter(Boolean), [onHeaderClick, sort, t]);
const renderRow = (status) => {
@ -33,8 +33,8 @@ function CustomUserStatus({
return <GenericTable
header={header}
renderRow={renderRow}
results={data.statuses}
total={data.total}
results={data?.statuses ?? []}
total={data?.total ?? 0}
setParams={setParams}
params={params}
renderFilter={({ onChange, ...props }) => <FilterByText onChange={onChange} {...props} />}

@ -2,104 +2,93 @@ import React, { useMemo, useState, useCallback } from 'react';
import { Button, Icon } from '@rocket.chat/fuselage';
import { useDebouncedValue } from '@rocket.chat/fuselage-hooks';
import { usePermission } from '../../contexts/AuthorizationContext';
import { useTranslation } from '../../contexts/TranslationContext';
import Page from '../../components/basic/Page';
import CustomUserStatus from './CustomUserStatus';
import AddCustomUserStatus from './AddCustomUserStatus';
import { useRoute, useRouteParameter } from '../../contexts/RouterContext';
import { useEndpointData } from '../../hooks/useEndpointData';
import VerticalBar from '../../components/basic/VerticalBar';
import NotAuthorizedPage from '../../components/NotAuthorizedPage';
import { usePermission } from '../../contexts/AuthorizationContext';
import { useRoute, useRouteParameter } from '../../contexts/RouterContext';
import { useTranslation } from '../../contexts/TranslationContext';
import { useEndpointDataExperimental } from '../../hooks/useEndpointDataExperimental';
import AddCustomUserStatus from './AddCustomUserStatus';
import CustomUserStatus from './CustomUserStatus';
import EditCustomUserStatusWithData from './EditCustomUserStatusWithData';
const sortDir = (sortDir) => (sortDir === 'asc' ? 1 : -1);
export const useQuery = ({ text, itemsPerPage, current }, [column, direction], cache) => useMemo(() => ({
query: JSON.stringify({ name: { $regex: text || '', $options: 'i' } }),
sort: JSON.stringify({ [column]: sortDir(direction) }),
...itemsPerPage && { count: itemsPerPage },
...current && { offset: current },
// TODO: remove cache. Is necessary for data invalidation
}), [text, itemsPerPage, current, column, direction, cache]);
function CustomUserStatusRoute({ props }) {
const t = useTranslation();
function CustomUserStatusRoute() {
const route = useRoute('custom-user-status');
const context = useRouteParameter('context');
const id = useRouteParameter('id');
const canManageUserStatus = usePermission('manage-user-status');
const routeName = 'custom-user-status';
const [params, setParams] = useState({ text: '', current: 0, itemsPerPage: 25 });
const [sort, setSort] = useState(['name', 'asc']);
const [cache, setCache] = useState();
const debouncedParams = useDebouncedValue(params, 500);
const debouncedSort = useDebouncedValue(sort, 500);
const t = useTranslation();
const query = useQuery(debouncedParams, debouncedSort, cache);
const [params, setParams] = useState(() => ({ text: '', current: 0, itemsPerPage: 25 }));
const [sort, setSort] = useState(() => ['name', 'asc']);
const data = useEndpointData('custom-user-status.list', query) || {};
const { text, itemsPerPage, current } = useDebouncedValue(params, 500);
const [column, direction] = useDebouncedValue(sort, 500);
const query = useMemo(() => ({
query: JSON.stringify({ name: { $regex: text || '', $options: 'i' } }),
sort: JSON.stringify({ [column]: direction === 'asc' ? 1 : -1 }),
...itemsPerPage && { count: itemsPerPage },
...current && { offset: current },
}), [text, itemsPerPage, current, column, direction]);
const router = useRoute(routeName);
const { data, reload } = useEndpointDataExperimental('custom-user-status.list', query);
const context = useRouteParameter('context');
const id = useRouteParameter('id');
const onClick = (_id) => () => {
router.push({
const handleItemClick = (_id) => () => {
route.push({
context: 'edit',
id: _id,
});
};
const onHeaderClick = (id) => {
const [sortBy, sortDirection] = sort;
const handleHeaderClick = (id) => {
setSort(([sortBy, sortDirection]) => {
if (sortBy === id) {
return [id, sortDirection === 'asc' ? 'desc' : 'asc'];
}
if (sortBy === id) {
setSort([id, sortDirection === 'asc' ? 'desc' : 'asc']);
return;
}
setSort([id, 'asc']);
return [id, 'asc'];
});
};
const handleHeaderButtonClick = useCallback((context) => () => {
router.push({ context });
}, [router]);
const handleNewButtonClick = useCallback(() => {
route.push({ context: 'new' });
}, [route]);
const close = useCallback(() => {
router.push({});
}, [router]);
const handleClose = useCallback(() => {
route.push({});
}, [route]);
const onChange = useCallback(() => {
setCache(new Date());
}, []);
const handleChange = useCallback(() => {
reload();
}, [reload]);
if (!canManageUserStatus) {
return <NotAuthorizedPage />;
}
return <Page {...props} flexDirection='row'>
return <Page flexDirection='row'>
<Page name='admin-custom-user-status'>
<Page.Header title={t('Custom_User_Status')}>
<Button small onClick={handleHeaderButtonClick('new')} aria-label={t('New')}>
<Button small onClick={handleNewButtonClick} aria-label={t('New')}>
<Icon name='plus'/>
</Button>
</Page.Header>
<Page.Content>
<CustomUserStatus setParams={setParams} params={params} onHeaderClick={onHeaderClick} data={data} onClick={onClick} sort={sort}/>
<CustomUserStatus setParams={setParams} params={params} onHeaderClick={handleHeaderClick} data={data} onClick={handleItemClick} sort={sort}/>
</Page.Content>
</Page>
{ context
&& <VerticalBar className='contextual-bar' width='x380' qa-context-name={`admin-user-and-room-context-${ context }`} flexShrink={0}>
<VerticalBar.Header>
{ context === 'edit' && t('Custom_User_Status_Edit') }
{ context === 'new' && t('Custom_User_Status_Add') }
<VerticalBar.Close onClick={close}/>
</VerticalBar.Header>
{context === 'edit' && <EditCustomUserStatusWithData _id={id} close={close} onChange={onChange} cache={cache}/>}
{context === 'new' && <AddCustomUserStatus goToNew={onClick} close={close} onChange={onChange}/>}
</VerticalBar>}
{context && <VerticalBar className='contextual-bar' width='x380' flexShrink={0}>
<VerticalBar.Header>
{context === 'edit' && t('Custom_User_Status_Edit')}
{context === 'new' && t('Custom_User_Status_Add')}
<VerticalBar.Close onClick={handleClose}/>
</VerticalBar.Header>
{context === 'edit' && <EditCustomUserStatusWithData _id={id} close={handleClose} onChange={handleChange} />}
{context === 'new' && <AddCustomUserStatus goToNew={handleItemClick} close={handleClose} onChange={handleChange}/>}
</VerticalBar>}
</Page>;
}

@ -12,12 +12,12 @@ import DeleteWarningModal from '../../components/DeleteWarningModal';
export function EditCustomUserStatus({ close, onChange, data, ...props }) {
const t = useTranslation();
const dispatchToastMessage = useToastMessageDispatch();
const setModal = useSetModal();
const { _id, name: previousName, statusType: previousStatusType } = data || {};
const [name, setName] = useState('');
const [statusType, setStatusType] = useState('');
const setModal = useSetModal();
const [name, setName] = useState(() => data?.name ?? '');
const [statusType, setStatusType] = useState(() => data?.statusType ?? '');
useEffect(() => {
setName(previousName || '');
@ -44,24 +44,36 @@ export function EditCustomUserStatus({ close, onChange, data, ...props }) {
}
}, [saveStatus, _id, previousName, previousStatusType, name, statusType, dispatchToastMessage, t, onChange]);
const onDeleteConfirm = useCallback(async () => {
try {
await deleteStatus(_id);
setModal(() => <DeleteSuccessModal
children={t('Custom_User_Status_Has_Been_Deleted')}
onClose={() => { setModal(undefined); close(); onChange(); }}
/>);
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
const handleDeleteButtonClick = useCallback(() => {
const handleClose = () => {
setModal(null);
close();
onChange();
}
}, [_id, close, deleteStatus, dispatchToastMessage, onChange]);
};
const handleDelete = async () => {
try {
await deleteStatus(_id);
setModal(() => <DeleteSuccessModal
children={t('Custom_User_Status_Has_Been_Deleted')}
onClose={handleClose}
/>);
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
onChange();
}
};
const handleCancel = () => {
setModal(null);
};
const openConfirmDelete = () => setModal(() => <DeleteWarningModal
children={t('Custom_User_Status_Delete_Warning')}
onDelete={onDeleteConfirm}
onCancel={() => setModal(undefined)}
/>);
setModal(() => <DeleteWarningModal
children={t('Custom_User_Status_Delete_Warning')}
onDelete={handleDelete}
onCancel={handleCancel}
/>);
}, [_id, close, deleteStatus, dispatchToastMessage, onChange, setModal, t]);
const presenceOptions = [
['online', t('Online')],
@ -94,7 +106,9 @@ export function EditCustomUserStatus({ close, onChange, data, ...props }) {
<Field>
<Field.Row>
<ButtonGroup stretch w='full'>
<Button primary danger onClick={openConfirmDelete}><Icon name='trash' mie='x4'/>{t('Delete')}</Button>
<Button primary danger onClick={handleDeleteButtonClick}>
<Icon name='trash' mie='x4'/>{t('Delete')}
</Button>
</ButtonGroup>
</Field.Row>
</Field>

@ -7,19 +7,15 @@ import EditCustomUserStatus from './EditCustomUserStatus';
type EditCustomUserStatusWithDataProps = {
_id: string;
cache: unknown;
close: () => void;
onChange: () => void;
};
export const EditCustomUserStatusWithData: FC<EditCustomUserStatusWithDataProps> = ({ _id, cache, ...props }) => {
export const EditCustomUserStatusWithData: FC<EditCustomUserStatusWithDataProps> = ({ _id, onChange, ...props }) => {
const t = useTranslation();
const query = useMemo(() => ({
query: JSON.stringify({ _id }),
// TODO: remove cache. Is necessary for data invalidation
}), [_id, cache]);
const query = useMemo(() => ({ query: JSON.stringify({ _id }) }), [_id]);
const { data, state, error } = useEndpointDataExperimental<{
const { data, state, error, reload } = useEndpointDataExperimental<{
statuses: unknown[];
}>('custom-user-status.list', query);
@ -43,7 +39,12 @@ export const EditCustomUserStatusWithData: FC<EditCustomUserStatusWithDataProps>
return <Box fontScale='h1' pb='x20'>{t('Custom_User_Status_Error_Invalid_User_Status')}</Box>;
}
return <EditCustomUserStatus data={data.statuses[0]} {...props}/>;
const handleChange = (): void => {
onChange && onChange();
reload && reload();
};
return <EditCustomUserStatus data={data.statuses[0]} onChange={handleChange} {...props}/>;
};
export default EditCustomUserStatusWithData;

@ -1,4 +1,4 @@
import React, { useMemo, useState, useCallback } from 'react';
import React, { useMemo, useCallback } from 'react';
import { Field, Box, Skeleton, Margins, Button } from '@rocket.chat/fuselage';
import { useTranslation } from '../../../contexts/TranslationContext';
@ -15,12 +15,13 @@ import DeleteWarningModal from '../../../components/DeleteWarningModal';
export default function EditIncomingWebhookWithData({ integrationId, ...props }) {
const t = useTranslation();
const [cache, setCache] = useState();
// TODO: remove cache. Is necessary for data validation
const { data, state, error } = useEndpointDataExperimental('integrations.get', useMemo(() => ({ integrationId }), [integrationId, cache]));
const params = useMemo(() => ({ integrationId }), [integrationId]);
const { data, state, error, reload } = useEndpointDataExperimental('integrations.get', params);
const onChange = () => setCache(new Date());
const onChange = () => {
reload();
};
if (state === ENDPOINT_STATES.LOADING) {
return <Box w='full' pb='x24' {...props}>

@ -1,4 +1,4 @@
import React, { useMemo, useState, useCallback } from 'react';
import React, { useMemo, useCallback } from 'react';
import {
Field,
Box,
@ -21,12 +21,13 @@ import DeleteWarningModal from '../../../components/DeleteWarningModal';
export default function EditOutgoingWebhookWithData({ integrationId, ...props }) {
const t = useTranslation();
const [cache, setCache] = useState();
// TODO: remove cache. Is necessary for data validation
const { data, state, error } = useEndpointDataExperimental('integrations.get', useMemo(() => ({ integrationId }), [integrationId, cache]));
const params = useMemo(() => ({ integrationId }), [integrationId]);
const { data, state, error, reload } = useEndpointDataExperimental('integrations.get', params);
const onChange = () => setCache(new Date());
const onChange = () => {
reload();
};
if (state === ENDPOINT_STATES.LOADING) {
return <Box w='full' pb='x24' {...props}>

@ -26,8 +26,8 @@ export default function NewIncomingWebhook(props) {
const { values: formValues, handlers: formHandlers, reset } = useForm(initialState);
// TODO: remove JSON.stringify. Is used to keep useEndpointAction from rerendering the page indefinitely.
const saveAction = useEndpointAction('POST', 'integrations.create', useMemo(() => ({ ...formValues, type: 'webhook-incoming' }), [JSON.stringify(formValues)]), t('Integration_added'));
const params = useMemo(() => ({ ...formValues, type: 'webhook-incoming' }), [formValues]);
const saveAction = useEndpointAction('POST', 'integrations.create', params, t('Integration_added'));
const handleSave = useCallback(async () => {
const result = await saveAction();

@ -42,14 +42,12 @@ export default function NewOutgoingWebhook({ data = defaultData, onChange, setSa
triggerWords,
} = formValues;
const query = useMemo(() => ({
const params = useMemo(() => ({
...formValues,
urls: urls.split('\n'),
triggerWords: triggerWords.split(';'),
// TODO: remove JSON.stringify. Is used to keep useEndpointAction from rerendering the page indefinitely.
}), [JSON.stringify(formValues)]);
const saveIntegration = useEndpointAction('POST', 'integrations.create', query, t('Integration_added'));
}), [formValues, triggerWords, urls]);
const saveIntegration = useEndpointAction('POST', 'integrations.create', params, t('Integration_added'));
const handleSave = useCallback(async () => {
const result = await saveIntegration();

@ -27,18 +27,12 @@ import DeleteWarningModal from '../../components/DeleteWarningModal';
export default function EditOauthAppWithData({ _id, ...props }) {
const t = useTranslation();
const [cache, setCache] = useState();
const params = useMemo(() => ({ appId: _id }), [_id]);
const { data, state, error, reload } = useEndpointDataExperimental('oauth-apps.get', params);
const onChange = useCallback(() => {
setCache(new Date());
}, []);
const query = useMemo(() => ({
appId: _id,
// TODO: remove cache. Is necessary for data invalidation
}), [_id, cache]);
const { data, state, error } = useEndpointDataExperimental('oauth-apps.get', query);
reload();
}, [reload]);
if (state === ENDPOINT_STATES.LOADING) {
return <Box pb='x20' maxWidth='x600' w='full' alignSelf='center'>

@ -14,7 +14,7 @@ export function AddUser({ roles, ...props }) {
const router = useRoute('admin-users');
const roleData = useEndpointData('roles.list', '') || {};
const roleData = useEndpointData('roles.list', '');
const {
values,
@ -43,10 +43,7 @@ export function AddUser({ roles, ...props }) {
id,
}), [router]);
// TODO: remove JSON.stringify. Is used to keep useEndpointAction from rerendering the page indefinitely.
const saveQuery = useMemo(() => values, [JSON.stringify(values)]);
const saveAction = useEndpointAction('POST', 'users.create', saveQuery, t('User_created_successfully'));
const saveAction = useEndpointAction('POST', 'users.create', values, t('User_created_successfully'));
const handleSave = useCallback(async () => {
const result = await saveAction();
@ -55,7 +52,7 @@ export function AddUser({ roles, ...props }) {
}
}, [goToUser, saveAction]);
const availableRoles = useMemo(() => (roleData && roleData.roles ? roleData.roles.map(({ _id, description }) => [_id, description || _id]) : []), [roleData]);
const availableRoles = useMemo(() => roleData?.roles?.map(({ _id, description }) => [_id, description || _id]) ?? [], [roleData]);
const append = useMemo(() => <Field>
<Field.Row>

@ -60,14 +60,12 @@ export function EditUser({ data, roles, ...props }) {
const saveQuery = useMemo(() => ({
userId: data._id,
data: values,
// TODO: remove JSON.stringify. Is used to keep useEndpointAction from rerendering the page indefinitely.
}), [data._id, JSON.stringify(values)]);
}), [data._id, values]);
const saveAvatarQuery = useMemo(() => ({
userId: data._id,
avatarUrl: avatarObj && avatarObj.avatarUrl,
// TODO: remove JSON.stringify. Is used to keep useEndpointAction from rerendering the page indefinitely.
}), [data._id, JSON.stringify(avatarObj)]);
}), [data._id, avatarObj]);
const resetAvatarQuery = useMemo(() => ({
userId: data._id,

@ -206,9 +206,28 @@ export const UserInfoActions = ({ username, _id, isActive, isAdmin, onChange })
const { actions: actionsDefinition, menu: menuOptions } = useUserInfoActionsSpread(options);
const menu = menuOptions && <Menu mi='x4' placement='bottom-start' small={false} ghost={false} flexShrink={0} key='menu' renderItem={({ label: { label, icon }, ...props }) => <Option label={label} title={label} icon={icon} {...props}/>} options={menuOptions}/>;
const menu = useMemo(() => {
if (!menuOptions) {
return null;
}
const actions = useMemo(() => [...actionsDefinition.map(([key, { label, icon, action }]) => <UserInfo.Action key={key} title={label} label={label} onClick={action} icon={icon}/>), menu].filter(Boolean), [actionsDefinition, menu]);
return <Menu
mi='x4'
placement='bottom-start'
small={false}
ghost={false}
flexShrink={0}
key='menu'
renderItem={({ label: { label, icon }, ...props }) => <Option label={label} title={label} icon={icon} {...props}/>}
options={menuOptions}
/>;
}, [menuOptions]);
const actions = useMemo(() => {
const mapAction = ([key, { label, icon, action }]) =>
<UserInfo.Action key={key} title={label} label={label} onClick={action} icon={icon}/>;
return [...actionsDefinition.map(mapAction), menu].filter(Boolean);
}, [actionsDefinition, menu]);
return <ButtonGroup flexGrow={0} justifyContent='center'>
{actions}

@ -2,7 +2,7 @@ import { Mongo } from 'meteor/mongo';
import { Tracker } from 'meteor/tracker';
import { FlowRouter } from 'meteor/kadira:flow-router';
import s from 'underscore.string';
import React, { useCallback, useMemo, useState, useEffect, useRef } from 'react';
import React, { useCallback, useMemo, useState, useEffect, useRef, memo } from 'react';
import { Box, Icon, TextInput, Callout } from '@rocket.chat/fuselage';
import { FixedSizeList as List } from 'react-window';
import InfiniteLoader from 'react-window-infinite-loader';
@ -142,6 +142,39 @@ export const normalizeThreadMessage = ({ ...message }) => {
}
};
const Row = memo(function Row({
data,
index,
style,
showRealNames,
userId,
onClick,
}) {
const t = useTranslation();
const formatDate = useTimeAgo();
if (!data[index]) {
return <Skeleton style={style}/>;
}
const discussion = data[index];
const msg = normalizeThreadMessage(discussion);
const { name = discussion.u.username } = discussion.u;
return <Discussion
{ ...discussion }
name={showRealNames ? name : discussion.u.username }
username={ discussion.u.username }
style={style}
following={discussion.replies && discussion.replies.includes(userId)}
data-drid={discussion.drid}
msg={msg}
t={t}
formatDate={formatDate}
onClick={onClick}
/>;
});
export function DiscussionList({ total = 10, discussions = [], loadMoreItems, loading, onClose, error, userId, text, setText }) {
const showRealNames = useSetting('UI_Use_Real_Name');
const discussionsRef = useRef();
@ -153,32 +186,16 @@ export function DiscussionList({ total = 10, discussions = [], loadMoreItems, lo
FlowRouter.goToRoomById(drid);
}, []);
const formatDate = useTimeAgo();
discussionsRef.current = discussions;
const rowRenderer = useCallback(React.memo(function rowRenderer({ data, index, style }) {
if (!data[index]) {
return <Skeleton style={style}/>;
}
const discussion = data[index];
const msg = normalizeThreadMessage(discussion);
const { name = discussion.u.username } = discussion.u;
return <Discussion
{ ...discussion }
name={showRealNames ? name : discussion.u.username }
username={ discussion.u.username }
style={style}
following={discussion.replies && discussion.replies.includes(userId)}
data-drid={discussion.drid}
msg={msg}
t={t}
formatDate={formatDate}
onClick={onClick}
/>;
}), [showRealNames]);
const rowRenderer = useCallback(({ data, index, style }) => <Row
data={data}
index={index}
style={style}
showRealNames={showRealNames}
userId={userId}
onClick={onClick}
/>, [onClick, showRealNames, userId]);
const isItemLoaded = useCallback((index) => index < discussionsRef.current.length, []);
const { ref, contentBoxSize: { inlineSize = 378, blockSize = 750 } = {} } = useResizeObserver();

@ -1,7 +1,7 @@
import { Mongo } from 'meteor/mongo';
import { Tracker } from 'meteor/tracker';
import s from 'underscore.string';
import React, { useCallback, useMemo, useState, useEffect, useRef } from 'react';
import React, { useCallback, useMemo, useState, useEffect, useRef, memo } from 'react';
import { Box, Icon, TextInput, Select, Margins, Callout } from '@rocket.chat/fuselage';
import { FixedSizeList as List } from 'react-window';
import InfiniteLoader from 'react-window-infinite-loader';
@ -153,6 +153,45 @@ export const normalizeThreadMessage = ({ ...message }) => {
}
};
const Row = memo(function Row({
data,
index,
style,
showRealNames,
unread,
unreadUser,
unreadGroup,
userId,
onClick,
}) {
const t = useTranslation();
const formatDate = useTimeAgo();
if (!data[index]) {
return <Skeleton style={style}/>;
}
const thread = data[index];
const msg = normalizeThreadMessage(thread);
const { name = thread.u.username } = thread.u;
return <Thread
{ ...thread }
name={showRealNames ? name : thread.u.username }
username={ thread.u.username }
style={style}
unread={unread.includes(thread._id)}
mention={unreadUser.includes(thread._id)}
all={unreadGroup.includes(thread._id)}
following={thread.replies && thread.replies.includes(userId)}
data-id={thread._id}
msg={msg}
t={t}
formatDate={formatDate}
handleFollowButton={handleFollowButton} onClick={onClick}
/>;
});
export function ThreadList({ total = 10, threads = [], room, unread = [], unreadUser = [], unreadGroup = [], type, setType, loadMoreItems, loading, onClose, error, userId, text, setText }) {
const showRealNames = useSetting('UI_Use_Real_Name');
const threadsRef = useRef();
@ -169,39 +208,23 @@ export function ThreadList({ total = 10, threads = [], room, unread = [], unread
rid: room._id,
name: room.name,
});
}, [room._id, room.name]);
}, [channelRoute, room._id, room.name]);
const formatDate = useTimeAgo();
const options = useMemo(() => [['all', t('All')], ['following', t('Following')], ['unread', t('Unread')]], []);
const options = useMemo(() => [['all', t('All')], ['following', t('Following')], ['unread', t('Unread')]], [t]);
threadsRef.current = threads;
const rowRenderer = useCallback(React.memo(function rowRenderer({ data, index, style }) {
if (!data[index]) {
return <Skeleton style={style}/>;
}
const thread = data[index];
const msg = normalizeThreadMessage(thread);
const { name = thread.u.username } = thread.u;
return <Thread
{ ...thread }
name={showRealNames ? name : thread.u.username }
username={ thread.u.username }
style={style}
unread={unread.includes(thread._id)}
mention={unreadUser.includes(thread._id)}
all={unreadGroup.includes(thread._id)}
following={thread.replies && thread.replies.includes(userId)}
data-id={thread._id}
msg={msg}
t={t}
formatDate={formatDate}
handleFollowButton={handleFollowButton} onClick={onClick}
/>;
}), [unread, unreadUser, unreadGroup, showRealNames]);
const rowRenderer = useCallback(({ data, index, style }) => <Row
data={data}
index={index}
style={style}
showRealNames={showRealNames}
unread={unread}
unreadUser={unreadUser}
unreadGroup={unreadGroup}
userId={userId}
onClick={onClick}
/>, [showRealNames, unread, unreadUser, unreadGroup, userId, onClick]);
const isItemLoaded = useCallback((index) => index < threadsRef.current.length, []);
const { ref, contentBoxSize: { inlineSize = 378, blockSize = 750 } = {} } = useResizeObserver({ debounceDelay: 100 });

@ -1,4 +1,4 @@
import React, { useMemo, useRef } from 'react';
import React, { useContext, useEffect, useMemo, useRef } from 'react';
import { PositionAnimated, AnimatedVisibility, Menu, Option } from '@rocket.chat/fuselage';
import { useMutableCallback } from '@rocket.chat/fuselage-hooks';
@ -10,8 +10,7 @@ import { Backdrop } from '../../components/basic/Backdrop';
import * as UserStatus from '../../components/basic/UserStatus';
import { LocalTime } from '../../components/basic/UTCClock';
import { useUserInfoActions, useUserInfoActionsSpread } from '../hooks/useUserInfoActions';
import { useComponentDidUpdate } from '../../hooks/useComponentDidUpdate';
import { useCurrentRoute } from '../../contexts/RouterContext';
import { RouterContext } from '../../contexts/RouterContext';
import { useRolesDescription } from '../../contexts/AuthorizationContext';
const UserCardWithData = ({ username, onClose, target, open, rid }) => {
@ -29,11 +28,20 @@ const UserCardWithData = ({ username, onClose, target, open, rid }) => {
ref.current = target;
const [route, params] = useCurrentRoute();
const { queryCurrentRoute } = useContext(RouterContext);
useComponentDidUpdate(() => {
onClose && onClose();
}, [route, JSON.stringify(params), onClose]);
useEffect(() => {
const subscription = queryCurrentRoute();
const unsubscribe = subscription.subscribe(() => {
onClose && onClose();
unsubscribe();
});
return () => {
unsubscribe();
};
}, [onClose, queryCurrentRoute]);
const user = useMemo(() => {
const loading = state === ENDPOINT_STATES.LOADING;
@ -78,9 +86,27 @@ const UserCardWithData = ({ username, onClose, target, open, rid }) => {
const { actions: actionsDefinition, menu: menuOptions } = useUserInfoActionsSpread(useUserInfoActions(user, rid));
const menu = menuOptions && <Menu flexShrink={0} mi='x2' key='menu' ghost={false} renderItem={({ label: { label, icon }, ...props }) => <Option {...props} label={label} icon={icon} />} options={menuOptions}/>;
const actions = useMemo(() => [...actionsDefinition.map(([key, { label, icon, action }]) => <UserCard.Action key={key} title={label} aria-label={label} onClick={action} icon={icon}/>), menu].filter(Boolean), [actionsDefinition, menu]);
const menu = useMemo(() => {
if (!menuOptions) {
return null;
}
return <Menu
flexShrink={0}
mi='x2'
key='menu'
ghost={false}
renderItem={({ label: { label, icon }, ...props }) => <Option {...props} label={label} icon={icon} />}
options={menuOptions}
/>;
}, [menuOptions]);
const actions = useMemo(() => {
const mapAction = ([key, { label, icon, action }]) =>
<UserCard.Action key={key} title={label} aria-label={label} onClick={action} icon={icon}/>;
return [...actionsDefinition.map(mapAction), menu].filter(Boolean);
}, [actionsDefinition, menu]);
return (<>
<Backdrop bg='transparent' onClick={onClose}/>

@ -7,9 +7,28 @@ import { useUserInfoActions, useUserInfoActionsSpread } from '../../hooks/useUse
const UserActions = ({ user, rid }) => {
const { actions: actionsDefinition, menu: menuOptions } = useUserInfoActionsSpread(useUserInfoActions(user, rid));
const menu = menuOptions && <Menu mi='x4' ghost={false} small={false} renderItem={({ label: { label, icon }, ...props }) => <Option {...props} label={label} icon={icon} />} flexShrink={0} key='menu' options={menuOptions} />;
const menu = useMemo(() => {
if (!menuOptions) {
return null;
}
const actions = useMemo(() => [...actionsDefinition.map(([key, { label, icon, action }]) => <UserInfo.Action key={key} title={label} label={label} onClick={action} icon={icon}/>), menu].filter(Boolean), [actionsDefinition, menu]);
return <Menu
key='menu'
mi='x4'
ghost={false}
small={false}
renderItem={({ label: { label, icon }, ...props }) => <Option {...props} label={label} icon={icon} />}
flexShrink={0}
options={menuOptions}
/>;
}, [menuOptions]);
const actions = useMemo(() => {
const mapAction = ([key, { label, icon, action }]) =>
<UserInfo.Action key={key} title={label} label={label} onClick={action} icon={icon}/>;
return [...actionsDefinition.map(mapAction), menu].filter(Boolean);
}, [actionsDefinition, menu]);
return <ButtonGroup mi='neg-x4' flexShrink={0} flexWrap='nowrap' withTruncatedText justifyContent='center' flexShrink={0}>
{actions}

@ -158,7 +158,7 @@ export const useUserInfoActions = (user = {}, rid) => {
const userCanDirectMessage = usePermission('create-d');
const shouldAllowCalls = getShouldAllowCalls(webRTCInstance);
const callInProgress = useReactiveValue(useCallback(() => webRTCInstance?.callInProgress?.get(), []));
const callInProgress = useReactiveValue(useCallback(() => webRTCInstance?.callInProgress.get(), [webRTCInstance]));
const shouldOpenDirectMessage = getShouldOpenDirectMessage(currentSubscription, usernameSubscription, userCanDirectMessage, user.username);
const openDirectDm = useMutableCallback(() => directRoute.push({

@ -8,11 +8,19 @@ import { capitalize } from '../lib/capitalize';
const CustomTextInput = ({ name, required, minLength, maxLength, setState, state, className }) => {
const t = useTranslation();
const verify = useMemo(() => {
const error = [];
if (!state && required) { error.push(t('Field_required')); }
if (state.length < minLength && state.length > 0) { error.push(t('Min_length_is', minLength)); }
return error.join(', ');
const errors = [];
if (!state && required) {
errors.push(t('Field_required'));
}
if (state.length < minLength && state.length > 0) {
errors.push(t('Min_length_is', minLength));
}
return errors.join(', ');
}, [state, required, minLength, t]);
return useMemo(() => <Field className={className}>
@ -21,7 +29,7 @@ const CustomTextInput = ({ name, required, minLength, maxLength, setState, state
<TextInput name={name} error={verify} maxLength={maxLength} flexGrow={1} value={state} required={required} onChange={(e) => setState(e.currentTarget.value)}/>
</Field.Row>
<Field.Error>{verify}</Field.Error>
</Field>, [name, verify, maxLength, state, required, setState, className]);
</Field>, [className, t, name, verify, maxLength, state, required, setState]);
};
const CustomSelect = ({ name, required, options, setState, state, className }) => {
@ -35,7 +43,7 @@ const CustomSelect = ({ name, required, options, setState, state, className }) =
<Select name={name} error={verify} flexGrow={1} value={state} options={mappedOptions} required={required} onChange={(val) => setState(val)}/>
</Field.Row>
<Field.Error>{verify}</Field.Error>
</Field>, [name, verify, state, mappedOptions, required, setState, className]);
</Field>, [className, t, name, verify, state, mappedOptions, required, setState]);
};
const CustomFieldsAssembler = ({ formValues, formHandlers, customFields, ...props }) => Object.entries(customFields).map(([key, value]) => {
@ -70,7 +78,11 @@ export default function CustomFieldsForm({ customFieldsData, setCustomFieldsData
});
const hasCustomFields = Boolean(Object.values(customFields).length);
const defaultFields = useMemo(() => Object.entries(customFields).reduce((data, [key, value]) => { data[key] = value.defaultValue ?? ''; return data; }, {}), []);
const defaultFields = useMemo(
() => Object.entries(customFields)
.reduce((data, [key, value]) => { data[key] = value.defaultValue ?? ''; return data; }, {}),
[customFields],
);
const { values, handlers } = useForm({ ...defaultFields, ...customFieldsData });
@ -79,8 +91,7 @@ export default function CustomFieldsForm({ customFieldsData, setCustomFieldsData
if (hasCustomFields) {
setCustomFieldsData(values);
}
// TODO: remove stringify. Is needed to avoid infinite rendering
}, [JSON.stringify(values)]);
}, [hasCustomFields, onLoadFields, setCustomFieldsData, values]);
if (!hasCustomFields) {
return null;

@ -6,8 +6,8 @@ import SortIcon from './SortIcon';
type HeaderCellProps = {
active?: boolean;
direction?: 'asc' | 'desc';
sort?: boolean;
onClick?: (sort: boolean) => void;
sort?: string;
onClick?: (sort: string) => void;
};
const HeaderCell: FC<HeaderCellProps> = ({
@ -18,7 +18,7 @@ const HeaderCell: FC<HeaderCellProps> = ({
onClick,
...props
}) => {
const fn = useCallback(() => onClick && onClick(!!sort), [sort, onClick]);
const fn = useCallback(() => onClick && sort && onClick(sort), [sort, onClick]);
return <Table.Cell clickable={!!sort} onClick={fn} { ...props }>
<Box display='flex' alignItems='center' wrap='no-wrap'>
{children}

@ -10,11 +10,17 @@ function PlanTag() {
const getTags = useMethod('license:getTags');
useEffect(() => {
(async () => {
const developmentTag = process.env.NODE_ENV === 'development'
? { name: 'development', color: 'primary-600' }
: null;
const fetchTags = async () => {
const tags = await getTags();
setPlans([process.env.NODE_ENV === 'development' && { name: 'development', color: 'primary-600' }, ...tags].filter(Boolean).map((plan) => ({ plan: plan.name, background: plan.color })));
})();
}, []);
setPlans([developmentTag, ...tags].filter(Boolean).map((plan) => ({ plan: plan.name, background: plan.color })));
};
fetchTags();
}, [getTags, setPlans]);
return plans.map(({ plan, background }) => <Tag key={plan} verticalAlign='middle' backgroundColor={background} marginInline='x4' color='#fff' textTransform='capitalize'>{plan}</Tag>);
}

@ -11,5 +11,6 @@ export const useComponentDidUpdate = (
return;
}
effect();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, dependencies);
};

@ -6,23 +6,44 @@ export const useFileInput = (onSetFile, fileType = 'image/*', fileField = 'image
useEffect(() => {
const fileInput = document.createElement('input');
const formData = new FormData();
fileInput.setAttribute('type', 'file');
fileInput.setAttribute('accept', fileType);
fileInput.setAttribute('style', 'display: none');
document.body.appendChild(fileInput);
ref.current = fileInput;
return () => {
ref.current = null;
fileInput.remove();
};
}, []);
useEffect(() => {
const fileInput = ref.current;
if (!fileInput) {
return;
}
fileInput.setAttribute('accept', fileType);
}, [fileType]);
useEffect(() => {
const fileInput = ref.current;
if (!fileInput) {
return;
}
const handleFiles = () => {
const formData = new FormData();
formData.append(fileField, fileInput.files[0]);
onSetFile(fileInput.files[0], formData);
};
fileInput.addEventListener('change', handleFiles, false);
return () => {
fileInput.parentNode.removeChild(fileInput);
fileInput.removeEventListener('change', handleFiles, false);
};
}, [fileType, onSetFile]);
}, [fileField, fileType, onSetFile]);
const onClick = useMutableCallback(() => ref.current.click());
const reset = useMutableCallback(() => {

@ -1,9 +1,10 @@
import { useMemo } from 'react';
import { useResizeObserver } from '@rocket.chat/fuselage-hooks';
import { useResizeObserver, useStableArray } from '@rocket.chat/fuselage-hooks';
export const useResizeInlineBreakpoint = (sizes = [], debounceDelay = 0) => {
const { ref, borderBoxSize } = useResizeObserver({ debounceDelay });
const inlineSize = borderBoxSize ? borderBoxSize.inlineSize : 0;
const newSizes = useMemo(() => sizes.map((current) => (inlineSize ? inlineSize > current : true)), [inlineSize]);
const stableSizes = useStableArray(sizes);
const newSizes = useMemo(() => stableSizes.map((current) => (inlineSize ? inlineSize > current : true)), [inlineSize, stableSizes]);
return [ref, ...newSizes];
};

@ -141,9 +141,15 @@ export function EditDepartment({ data, id, title, reload }) {
const [tagError, setTagError] = useState();
useComponentDidUpdate(() => setNameError(!name ? t('The_field_is_required', 'name') : ''), [t, name]);
useComponentDidUpdate(() => setEmailError(!email ? t('The_field_is_required', 'email') : ''), [t, email]);
useComponentDidUpdate(() => setTagError(requestTagBeforeClosingChat && (!tags || tags.length === 0) ? t('The_field_is_required', 'name') : ''), [t, tags]);
useComponentDidUpdate(() => {
setNameError(!name ? t('The_field_is_required', 'name') : '');
}, [t, name]);
useComponentDidUpdate(() => {
setEmailError(!email ? t('The_field_is_required', 'email') : '');
}, [t, email]);
useComponentDidUpdate(() => {
setTagError(requestTagBeforeClosingChat && (!tags || tags.length === 0) ? t('The_field_is_required', 'name') : '');
}, [requestTagBeforeClosingChat, t, tags]);
const handleSubmit = useMutableCallback(async (e) => {
e.preventDefault();

@ -234,12 +234,14 @@ declare module '@rocket.chat/fuselage' {
type ModalCloseProps = BoxProps;
type ModalContentProps = BoxProps;
type ModalFooterProps = BoxProps;
type ModalBackdropProps = BoxProps;
export const Modal: ForwardRefExoticComponent<ModalProps> & {
Header: ForwardRefExoticComponent<ModalHeaderProps>;
Title: ForwardRefExoticComponent<ModalTitleProps>;
Close: ForwardRefExoticComponent<ModalCloseProps>;
Content: ForwardRefExoticComponent<ModalContentProps>;
Footer: ForwardRefExoticComponent<ModalFooterProps>;
Backdrop: ForwardRefExoticComponent<ModalBackdropProps>;
};
type NumberInputProps = BoxProps;

@ -73,7 +73,7 @@ export function ModalBlock({
const element = ref.current.querySelector(focusableElementsString);
element && element.focus();
}
}, [ref.current, errors]);
}, [errors]);
// save focus to restore after close
const previousFocus = useMemo(() => document.activeElement, []);
// restore the focus after the component unmount
@ -183,7 +183,7 @@ const useActionManagerState = (initialState) => {
const handleUpdate = ({ type, ...data }) => {
if (type === 'errors') {
const { errors } = data;
setState({ ...state, errors });
setState((state) => ({ ...state, errors }));
return;
}

@ -98,9 +98,9 @@ function SetupWizardState() {
canDeclineServerRegistration,
} = useParameters();
const goToPreviousStep = useCallback(() => setCurrentStep((currentStep) => currentStep - 1), []);
const goToNextStep = useCallback(() => setCurrentStep((currentStep) => currentStep + 1), []);
const goToFinalStep = useCallback(() => setCurrentStep(finalStep), []);
const goToPreviousStep = useCallback(() => setCurrentStep((currentStep) => currentStep - 1), [setCurrentStep]);
const goToNextStep = useCallback(() => setCurrentStep((currentStep) => currentStep + 1), [setCurrentStep]);
const goToFinalStep = useCallback(() => setCurrentStep(finalStep), [setCurrentStep]);
const value = useMemo(() => ({
currentStep,

@ -64,7 +64,7 @@ export function MessagesPerChannelSection() {
[...entries, { i, t, name: name || usernames.join(' × '), messages }], []);
return [pie, table];
}, [period, pieData, tableData]);
}, [pieData, tableData]);
const downloadData = () => {
const data = pieData.origins.map(({ t, messages }) => [t, messages]);

Loading…
Cancel
Save