From 1e7b31d199b32c2afbf9ea098b4bbe54d05e534e Mon Sep 17 00:00:00 2001 From: Tasso Evangelista Date: Wed, 21 Oct 2020 10:31:17 -0700 Subject: [PATCH] Non-idiomatic React code (#19303) --- .eslintrc | 4 +- .../client/components/ThreadComponent.js | 171 ++++++++++-------- .../client/components/ThreadSkeleton.tsx | 43 +++++ app/threads/client/components/ThreadView.tsx | 88 +++++++++ client/admin/apps/AppsContext.tsx | 13 ++ .../{AppProvider.tsx => AppsProvider.tsx} | 56 +++--- client/admin/apps/AppsRoute.js | 6 +- client/admin/apps/AppsTable.js | 19 +- client/admin/apps/MarketplaceTable.js | 21 +-- client/admin/apps/hooks/useAppInfo.js | 75 -------- client/admin/apps/hooks/useAppInfo.ts | 88 +++++++++ client/admin/apps/hooks/useFilteredApps.js | 34 ---- client/admin/apps/hooks/useFilteredApps.ts | 40 ++++ client/admin/customEmoji/CustomEmoji.js | 8 +- client/admin/customEmoji/CustomEmojiRoute.js | 112 +++++------- client/admin/customEmoji/EditCustomEmoji.tsx | 88 +++++---- .../customEmoji/EditCustomEmojiWithData.tsx | 16 +- client/admin/customSounds/AdminSounds.js | 10 +- client/admin/customSounds/AdminSoundsRoute.js | 120 ++++++------ client/admin/customSounds/EditCustomSound.js | 71 +++++--- .../customUserStatus/CustomUserStatus.js | 8 +- .../customUserStatus/CustomUserStatusRoute.js | 117 ++++++------ .../customUserStatus/EditCustomUserStatus.js | 54 ++++-- .../EditCustomUserStatusWithData.tsx | 17 +- .../integrations/edit/EditIncomingWebhook.js | 11 +- .../integrations/edit/EditOutgoingWebhook.js | 11 +- .../integrations/new/NewIncomingWebhook.js | 4 +- .../integrations/new/NewOutgoingWebhook.js | 8 +- client/admin/oauthApps/OAuthEditApp.js | 14 +- client/admin/users/AddUser.js | 9 +- client/admin/users/EditUser.js | 6 +- client/admin/users/UserInfoActions.js | 23 ++- .../channel/Discussions/ContextualBar/List.js | 67 ++++--- client/channel/Threads/ContextualBar/List.js | 83 ++++++--- client/channel/UserCard/index.js | 46 ++++- .../channel/UserInfo/actions/UserActions.js | 23 ++- client/channel/hooks/useUserInfoActions.js | 2 +- client/components/CustomFieldsForm.js | 29 ++- client/components/GenericTable/HeaderCell.tsx | 6 +- client/components/basic/PlanTag.js | 14 +- client/hooks/useComponentDidUpdate.js | 1 + client/hooks/useFileInput.js | 31 +++- client/hooks/useResizeInlineBreakpoint.js | 5 +- .../omnichannel/departments/DepartmentEdit.js | 12 +- client/types/fuselage.d.ts | 2 + client/views/blocks/ModalBlock.js | 4 +- client/views/setupWizard/SetupWizardState.js | 6 +- .../MessagesTab/MessagesPerChannelSection.js | 2 +- 48 files changed, 1023 insertions(+), 675 deletions(-) create mode 100644 app/threads/client/components/ThreadSkeleton.tsx create mode 100644 app/threads/client/components/ThreadView.tsx create mode 100644 client/admin/apps/AppsContext.tsx rename client/admin/apps/{AppProvider.tsx => AppsProvider.tsx} (80%) delete mode 100644 client/admin/apps/hooks/useAppInfo.js create mode 100644 client/admin/apps/hooks/useAppInfo.ts delete mode 100644 client/admin/apps/hooks/useFilteredApps.js create mode 100644 client/admin/apps/hooks/useFilteredApps.ts diff --git a/.eslintrc b/.eslintrc index ff4471548fc..d1d22fdb7fa 100644 --- a/.eslintrc +++ b/.eslintrc @@ -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": { diff --git a/app/threads/client/components/ThreadComponent.js b/app/threads/client/components/ThreadComponent.js index 014df6730c2..1559b83d7e6 100644 --- a/app/threads/client/components/ThreadComponent.js +++ b/app/threads/client/components/ThreadComponent.js @@ -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 && } - - - - ; + if (!ref.current || !hasThreadMessage) { + return; + } + + const view = Blaze.renderWithData(Template.thread, viewDataRef.current, ref.current); + + return () => { + Blaze.remove(view); + }; + }, [hasThreadMessage, mid]); + + if (!threadMessage) { + return ; } - return <> - {expanded && } - - - - - - - - - - - - - - ; + return setExpand(!expanded)} + onToggleFollow={(following) => setFollowing(!following)} + onClose={handleClose} + />; } + +export default ThreadComponent; diff --git a/app/threads/client/components/ThreadSkeleton.tsx b/app/threads/client/components/ThreadSkeleton.tsx new file mode 100644 index 00000000000..2c9c4ecdac6 --- /dev/null +++ b/app/threads/client/components/ThreadSkeleton.tsx @@ -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 = ({ expanded, onClose }) => { + const style = useMemo(() => (document.dir === 'rtl' + ? { + left: 0, + borderTopRightRadius: 4, + } + : { + right: 0, + borderTopLeftRadius: 4, + }), []); + + return <> + {expanded && } + + + + ; +}; + +export default ThreadSkeleton; diff --git a/app/threads/client/components/ThreadView.tsx b/app/threads/client/components/ThreadView.tsx new file mode 100644 index 00000000000..d63a91bba96 --- /dev/null +++ b/app/threads/client/components/ThreadView.tsx @@ -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 = forwardRef(({ + 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 && } + + + + + + + + + + + + + + ; +}); + +export default ThreadView; diff --git a/client/admin/apps/AppsContext.tsx b/client/admin/apps/AppsContext.tsx new file mode 100644 index 00000000000..739fa6df43c --- /dev/null +++ b/client/admin/apps/AppsContext.tsx @@ -0,0 +1,13 @@ +import { createContext } from 'react'; + +import { App } from './types'; + +type AppsContextValue = { + apps: App[]; + finishedLoading: boolean; +} + +export const AppsContext = createContext({ + apps: [], + finishedLoading: false, +}); diff --git a/client/admin/apps/AppProvider.tsx b/client/admin/apps/AppsProvider.tsx similarity index 80% rename from client/admin/apps/AppProvider.tsx rename to client/admin/apps/AppsProvider.tsx index 8bb63c44730..abf28def0e8 100644 --- a/client/admin/apps/AppProvider.tsx +++ b/client/admin/apps/AppsProvider.tsx @@ -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({ - 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(() => []); +const AppsProvider: FC = ({ children }) => { + const [apps, setApps] = useState(() => []); const [finishedLoading, setFinishedLoading] = useState(() => false); - const [dataCache, setDataCache] = useState(() => []); - 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 => { + const fetchData = async (): Promise => { 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 ; + return ; }; -export default AppProvider; +export default AppsProvider; diff --git a/client/admin/apps/AppsRoute.js b/client/admin/apps/AppsRoute.js index ee38688fb71..c46bff3414e 100644 --- a/client/admin/apps/AppsRoute.js +++ b/client/admin/apps/AppsRoute.js @@ -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 ; } - return + return { (!context && isMarketPlace && ) || (!context && !isMarketPlace && ) @@ -69,7 +69,7 @@ function AppsRoute() { || (context === 'logs' && ) || (context === 'install' && ) } - ; + ; } export default AppsRoute; diff --git a/client/admin/apps/AppsTable.js b/client/admin/apps/AppsTable.js index 3537f9246ac..255b5f29b25 100644 --- a/client/admin/apps/AppsTable.js +++ b/client/admin/apps/AppsTable.js @@ -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; diff --git a/client/admin/apps/MarketplaceTable.js b/client/admin/apps/MarketplaceTable.js index 0b38acba91d..70d3090cd86 100644 --- a/client/admin/apps/MarketplaceTable.js +++ b/client/admin/apps/MarketplaceTable.js @@ -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( diff --git a/client/admin/apps/hooks/useAppInfo.js b/client/admin/apps/hooks/useAppInfo.js deleted file mode 100644 index bd66faa5a70..00000000000 --- a/client/admin/apps/hooks/useAppInfo.js +++ /dev/null @@ -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; -}; diff --git a/client/admin/apps/hooks/useAppInfo.ts b/client/admin/apps/hooks/useAppInfo.ts new file mode 100644 index 00000000000..6c29312dfd0 --- /dev/null +++ b/client/admin/apps/hooks/useAppInfo.ts @@ -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 => { + 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> => { + if (!installed) { + return {}; + } + + try { + return Apps.getAppSettings(appId); + } catch (e) { + handleAPIError(e); + return {}; + } +}; + +const getApis = async (appId: string, installed: boolean): Promise> => { + if (!installed) { + return {}; + } + + try { + return Apps.getAppApis(appId); + } catch (e) { + handleAPIError(e); + return {}; + } +}; + +type AppInfo = Partial; + apis: Record; +}>; + +export const useAppInfo = (appId: string): AppInfo => { + const { apps } = useContext(AppsContext); + + const [appData, setAppData] = useState({}); + + useEffect(() => { + const fetchAppInfo = async (): Promise => { + 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; +}; diff --git a/client/admin/apps/hooks/useFilteredApps.js b/client/admin/apps/hooks/useFilteredApps.js deleted file mode 100644 index 3ce564c8bbc..00000000000 --- a/client/admin/apps/hooks/useFilteredApps.js +++ /dev/null @@ -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]; -}; diff --git a/client/admin/apps/hooks/useFilteredApps.ts b/client/admin/apps/hooks/useFilteredApps.ts new file mode 100644 index 00000000000..36376707d33 --- /dev/null +++ b/client/admin/apps/hooks/useFilteredApps.ts @@ -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]; +}; diff --git a/client/admin/customEmoji/CustomEmoji.js b/client/admin/customEmoji/CustomEmoji.js index f739dcb7ae0..53a950c056d 100644 --- a/client/admin/customEmoji/CustomEmoji.js +++ b/client/admin/customEmoji/CustomEmoji.js @@ -16,8 +16,8 @@ function CustomEmoji({ const t = useTranslation(); const header = useMemo(() => [ - {t('Name')}, - {t('Aliases')}, + {t('Name')}, + {t('Aliases')}, ], [onHeaderClick, sort, t]); const renderRow = (emojis) => { @@ -31,8 +31,8 @@ function CustomEmoji({ return } diff --git a/client/admin/customEmoji/CustomEmojiRoute.js b/client/admin/customEmoji/CustomEmojiRoute.js index 86ecb1e2ae1..5398bdb6a12 100644 --- a/client/admin/customEmoji/CustomEmojiRoute.js +++ b/client/admin/customEmoji/CustomEmojiRoute.js @@ -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 ; } - return + return - - + - { context - && - - { context === 'edit' && t('Custom_Emoji_Info') } - { context === 'new' && t('Custom_Emoji_Add') } - - - {context === 'edit' && } - {context === 'new' && } - } + {context && + + {context === 'edit' && t('Custom_Emoji_Info')} + {context === 'new' && t('Custom_Emoji_Add')} + + + {context === 'edit' && } + {context === 'new' && } + } ; } - export default CustomEmojiRoute; diff --git a/client/admin/customEmoji/EditCustomEmoji.tsx b/client/admin/customEmoji/EditCustomEmoji.tsx index 2ae34caac4b..f0e99b70043 100644 --- a/client/admin/customEmoji/EditCustomEmoji.tsx +++ b/client/admin/customEmoji/EditCustomEmoji.tsx @@ -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 = ({ 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(); - 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 = ({ 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(() => { setModal(undefined); close(); onChange(); }} - />); - } - }, [close, deleteAction, onChange, setModal, t]); - - const openConfirmDelete = useCallback(() => setModal(() => setModal(undefined)} - />), [onDeleteConfirm, setModal, t]); + const handleDeleteButtonClick = useCallback(() => { + const handleClose = (): void => { + setModal(null); + close(); + onChange(); + }; + + const handleDelete = async (): Promise => { + try { + await deleteAction(); + setModal(() => ); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + onChange(); + } + }; + + const handleCancel = (): void => { + setModal(null); + }; + + setModal(() => ); + }, [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 @@ -100,11 +122,11 @@ const EditCustomEmoji: FC = ({ close, onChange, data, ...p {t('Custom_Emoji')} - { newEmojiPreview && + {newEmojiPreview && - } + } @@ -117,7 +139,9 @@ const EditCustomEmoji: FC = ({ close, onChange, data, ...p - + diff --git a/client/admin/customEmoji/EditCustomEmojiWithData.tsx b/client/admin/customEmoji/EditCustomEmojiWithData.tsx index f8b442a4721..1e363d7132b 100644 --- a/client/admin/customEmoji/EditCustomEmojiWithData.tsx +++ b/client/admin/customEmoji/EditCustomEmojiWithData.tsx @@ -8,17 +8,13 @@ import { EmojiDescriptor } from './types'; type EditCustomEmojiWithDataProps = { _id: string; - cache: unknown; close: () => void; onChange: () => void; }; -const EditCustomEmojiWithData: FC = ({ _id, cache, onChange, ...props }) => { +const EditCustomEmojiWithData: FC = ({ _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 = ({ _id, cache, }, state, error, + reload, } = useEndpointDataExperimental<{ emojis?: { update: EmojiDescriptor[]; @@ -54,7 +51,12 @@ const EditCustomEmojiWithData: FC = ({ _id, cache, return {t('Custom_User_Status_Error_Invalid_User_Status')}; } - return ; + const handleChange = (): void => { + onChange && onChange(); + reload && reload(); + }; + + return ; }; export default EditCustomEmojiWithData; diff --git a/client/admin/customSounds/AdminSounds.js b/client/admin/customSounds/AdminSounds.js index 720b28cee51..20e6e6e681d 100644 --- a/client/admin/customSounds/AdminSounds.js +++ b/client/admin/customSounds/AdminSounds.js @@ -17,15 +17,15 @@ function AdminSounds({ const t = useTranslation(); const header = useMemo(() => [ - {t('Name')}, + {t('Name')}, , - ], [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 } diff --git a/client/admin/customSounds/AdminSoundsRoute.js b/client/admin/customSounds/AdminSoundsRoute.js index 33c9ec20aaf..e02547aae71 100644 --- a/client/admin/customSounds/AdminSoundsRoute.js +++ b/client/admin/customSounds/AdminSoundsRoute.js @@ -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 ; } - return + return - - + - { context - && - - { context === 'edit' && t('Custom_Sound_Edit') } - { context === 'new' && t('Custom_Sound_Add') } - - - {context === 'edit' && } - {context === 'new' && } - } + {context && + + {context === 'edit' && t('Custom_Sound_Edit')} + {context === 'new' && t('Custom_Sound_Add')} + + + {context === 'edit' && } + {context === 'new' && } + } ; } + +export default CustomSoundsRoute; diff --git a/client/admin/customSounds/EditCustomSound.js b/client/admin/customSounds/EditCustomSound.js index c123f50ab07..03e4bba1b30 100644 --- a/client/admin/customSounds/EditCustomSound.js +++ b/client/admin/customSounds/EditCustomSound.js @@ -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 @@ -39,19 +37,24 @@ function EditCustomSound({ _id, cache, ...props }) { return {error}; } - return ; + const handleChange = () => { + onChange && onChange(); + reload && reload(); + }; + + return ; } 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(() => { 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(() => ); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + onChange(); + } + }; + + const handleCancel = () => { + setModal(null); + }; - const openConfirmDelete = () => setModal(() => setModal(undefined)} - />); + setModal(() => ); + }, [_id, close, deleteCustomSound, dispatchToastMessage, onChange, setModal, t]); const [clickUpload] = useFileInput(handleChangeFile, 'audio/mp3'); @@ -156,7 +171,9 @@ function EditSound({ close, onChange, data, ...props }) { - + diff --git a/client/admin/customUserStatus/CustomUserStatus.js b/client/admin/customUserStatus/CustomUserStatus.js index 07d27d4643f..83cc462ff2e 100644 --- a/client/admin/customUserStatus/CustomUserStatus.js +++ b/client/admin/customUserStatus/CustomUserStatus.js @@ -18,8 +18,8 @@ function CustomUserStatus({ const t = useTranslation(); const header = useMemo(() => [ - {t('Name')}, - {t('Presence')}, + {t('Name')}, + {t('Presence')}, ].filter(Boolean), [onHeaderClick, sort, t]); const renderRow = (status) => { @@ -33,8 +33,8 @@ function CustomUserStatus({ return } diff --git a/client/admin/customUserStatus/CustomUserStatusRoute.js b/client/admin/customUserStatus/CustomUserStatusRoute.js index 499045f0905..702c6cd800b 100644 --- a/client/admin/customUserStatus/CustomUserStatusRoute.js +++ b/client/admin/customUserStatus/CustomUserStatusRoute.js @@ -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 ; } - return + return - - + - { context - && - - { context === 'edit' && t('Custom_User_Status_Edit') } - { context === 'new' && t('Custom_User_Status_Add') } - - - - {context === 'edit' && } - {context === 'new' && } - } + {context && + + {context === 'edit' && t('Custom_User_Status_Edit')} + {context === 'new' && t('Custom_User_Status_Add')} + + + + {context === 'edit' && } + {context === 'new' && } + } ; } diff --git a/client/admin/customUserStatus/EditCustomUserStatus.js b/client/admin/customUserStatus/EditCustomUserStatus.js index 6e222ac2e29..767d3634fb4 100644 --- a/client/admin/customUserStatus/EditCustomUserStatus.js +++ b/client/admin/customUserStatus/EditCustomUserStatus.js @@ -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(() => { 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(() => ); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + onChange(); + } + }; + + const handleCancel = () => { + setModal(null); + }; - const openConfirmDelete = () => setModal(() => setModal(undefined)} - />); + setModal(() => ); + }, [_id, close, deleteStatus, dispatchToastMessage, onChange, setModal, t]); const presenceOptions = [ ['online', t('Online')], @@ -94,7 +106,9 @@ export function EditCustomUserStatus({ close, onChange, data, ...props }) { - + diff --git a/client/admin/customUserStatus/EditCustomUserStatusWithData.tsx b/client/admin/customUserStatus/EditCustomUserStatusWithData.tsx index 71d2b9ca6c6..cf832962d47 100644 --- a/client/admin/customUserStatus/EditCustomUserStatusWithData.tsx +++ b/client/admin/customUserStatus/EditCustomUserStatusWithData.tsx @@ -7,19 +7,15 @@ import EditCustomUserStatus from './EditCustomUserStatus'; type EditCustomUserStatusWithDataProps = { _id: string; - cache: unknown; close: () => void; onChange: () => void; }; -export const EditCustomUserStatusWithData: FC = ({ _id, cache, ...props }) => { +export const EditCustomUserStatusWithData: FC = ({ _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 return {t('Custom_User_Status_Error_Invalid_User_Status')}; } - return ; + const handleChange = (): void => { + onChange && onChange(); + reload && reload(); + }; + + return ; }; export default EditCustomUserStatusWithData; diff --git a/client/admin/integrations/edit/EditIncomingWebhook.js b/client/admin/integrations/edit/EditIncomingWebhook.js index 0acdee03c58..bfd209ddbd6 100644 --- a/client/admin/integrations/edit/EditIncomingWebhook.js +++ b/client/admin/integrations/edit/EditIncomingWebhook.js @@ -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 diff --git a/client/admin/integrations/edit/EditOutgoingWebhook.js b/client/admin/integrations/edit/EditOutgoingWebhook.js index 2d402a685d3..173470a19aa 100644 --- a/client/admin/integrations/edit/EditOutgoingWebhook.js +++ b/client/admin/integrations/edit/EditOutgoingWebhook.js @@ -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 diff --git a/client/admin/integrations/new/NewIncomingWebhook.js b/client/admin/integrations/new/NewIncomingWebhook.js index a04bd5aaa3d..60a74cc20be 100644 --- a/client/admin/integrations/new/NewIncomingWebhook.js +++ b/client/admin/integrations/new/NewIncomingWebhook.js @@ -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(); diff --git a/client/admin/integrations/new/NewOutgoingWebhook.js b/client/admin/integrations/new/NewOutgoingWebhook.js index c37665494c8..6e5569b58f2 100644 --- a/client/admin/integrations/new/NewOutgoingWebhook.js +++ b/client/admin/integrations/new/NewOutgoingWebhook.js @@ -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(); diff --git a/client/admin/oauthApps/OAuthEditApp.js b/client/admin/oauthApps/OAuthEditApp.js index 4d346d2ff0a..aa62b9d5cc0 100644 --- a/client/admin/oauthApps/OAuthEditApp.js +++ b/client/admin/oauthApps/OAuthEditApp.js @@ -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 diff --git a/client/admin/users/AddUser.js b/client/admin/users/AddUser.js index e5717f29354..6c34c5dcfd1 100644 --- a/client/admin/users/AddUser.js +++ b/client/admin/users/AddUser.js @@ -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(() => diff --git a/client/admin/users/EditUser.js b/client/admin/users/EditUser.js index 03e1867585b..3880f379a03 100644 --- a/client/admin/users/EditUser.js +++ b/client/admin/users/EditUser.js @@ -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, diff --git a/client/admin/users/UserInfoActions.js b/client/admin/users/UserInfoActions.js index 4d8bc1a276f..0d53f2e950c 100644 --- a/client/admin/users/UserInfoActions.js +++ b/client/admin/users/UserInfoActions.js @@ -206,9 +206,28 @@ export const UserInfoActions = ({ username, _id, isActive, isAdmin, onChange }) const { actions: actionsDefinition, menu: menuOptions } = useUserInfoActionsSpread(options); - const menu = menuOptions &&