diff --git a/apps/meteor/client/lib/asyncState/AsyncState.ts b/apps/meteor/client/lib/asyncState/AsyncState.ts index 827c1048c3275..4b3292eabdad5 100644 --- a/apps/meteor/client/lib/asyncState/AsyncState.ts +++ b/apps/meteor/client/lib/asyncState/AsyncState.ts @@ -4,6 +4,6 @@ export type AsyncState = | { phase: AsyncStatePhase.LOADING; value: undefined; error: undefined } | { phase: AsyncStatePhase.LOADING; value: T; error: undefined } | { phase: AsyncStatePhase.LOADING; value: undefined; error: Error } - | { phase: AsyncStatePhase.RESOLVED; value: T; error: undefined } + | { phase: AsyncStatePhase.RESOLVED; value: T; error?: undefined } | { phase: AsyncStatePhase.UPDATING; value: T; error: undefined } | { phase: AsyncStatePhase.REJECTED; value: undefined; error: Error }; diff --git a/apps/meteor/client/views/marketplace/AppsProvider.tsx b/apps/meteor/client/views/marketplace/AppsProvider.tsx index 0f0b809adb217..276d750ab6f0e 100644 --- a/apps/meteor/client/views/marketplace/AppsProvider.tsx +++ b/apps/meteor/client/views/marketplace/AppsProvider.tsx @@ -1,15 +1,14 @@ import type { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus'; -import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; import { usePermission } from '@rocket.chat/ui-contexts'; -import type { FC, Reducer } from 'react'; -import React, { useEffect, useReducer, useCallback } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import type { FC } from 'react'; +import React, { useEffect } from 'react'; import { AppEvents } from '../../../ee/client/apps/communication'; import { Apps } from '../../../ee/client/apps/orchestrator'; -import type { AsyncState } from '../../lib/asyncState'; +import PageSkeleton from '../../components/PageSkeleton'; import { AsyncStatePhase } from '../../lib/asyncState'; import { AppsContext } from './AppsContext'; -import { handleAPIError } from './helpers'; import { useInvalidateAppsCountQueryCallback } from './hooks/useAppsCountQuery'; import type { App } from './types'; @@ -34,501 +33,144 @@ const registerListeners = (listeners: ListenersMapping): (() => void) => { }; }; -type Action = - | { type: 'request'; reload: () => Promise } - | { type: 'update'; app: App; reload: () => Promise } - | { type: 'delete'; appId: string; reload: () => Promise } - | { type: 'invalidate'; appId: string; reload: () => Promise } - | { type: 'success'; apps: App[]; reload: () => Promise } - | { type: 'failure'; error: Error; reload: () => Promise }; - const sortByName = (apps: App[]): App[] => apps.sort((a, b) => (a.name.toLowerCase() > b.name.toLowerCase() ? 1 : -1)); -const reducer = ( - state: AsyncState<{ apps: App[] }> & { - reload: () => Promise; - }, - action: Action, -): AsyncState<{ apps: App[] }> & { - reload: () => Promise; -} => { - switch (action.type) { - case 'invalidate': - if (state.phase !== AsyncStatePhase.RESOLVED) { - return state; - } - return { - phase: AsyncStatePhase.RESOLVED, - reload: action.reload, - value: { - apps: sortByName( - state.value.apps.map((app) => { - if (app.id === action.appId) { - return { ...app }; - } - return app; - }), - ), - }, - error: undefined, - }; - case 'update': - if (state.phase !== AsyncStatePhase.RESOLVED) { - return state; - } - return { - phase: AsyncStatePhase.RESOLVED, - reload: async (): Promise => undefined, - value: { - apps: sortByName( - state.value.apps.map((app) => { - if (app.id === action.app.id) { - return action.app; - } - return app; - }), - ), - }, - error: undefined, - }; - case 'request': - return { - reload: async (): Promise => undefined, - phase: AsyncStatePhase.LOADING, - value: undefined, - error: undefined, - }; - case 'success': - return { - reload: action.reload, - phase: AsyncStatePhase.RESOLVED, - value: { apps: sortByName(action.apps) }, - error: undefined, - }; - case 'delete': - if (state.phase !== AsyncStatePhase.RESOLVED) { - return state; - } - return { - reload: action.reload, - phase: AsyncStatePhase.RESOLVED, - value: { apps: state.value.apps.filter(({ id }) => id !== action.appId) }, - error: undefined, - }; - case 'failure': - return { - reload: action.reload, - phase: AsyncStatePhase.REJECTED, - value: undefined, - error: action.error, - }; - default: - return state; - } -}; - const AppsProvider: FC = ({ children }) => { - const [marketplaceAppsState, dispatchMarketplaceApps] = useReducer< - Reducer< - AsyncState<{ apps: App[] }> & { - reload: () => Promise; - }, - Action - > - >(reducer, { - phase: AsyncStatePhase.LOADING, - value: undefined, - error: undefined, - reload: async () => undefined, - }); - - const [installedAppsState, dispatchInstalledApps] = useReducer< - Reducer< - AsyncState<{ apps: App[] }> & { - reload: () => Promise; - }, - Action - > - >(reducer, { - phase: AsyncStatePhase.LOADING, - value: undefined, - error: undefined, - reload: async () => undefined, - }); + const isAdminUser = usePermission('manage-apps'); - const [privateAppsState, dispatchPrivateApps] = useReducer< - Reducer< - AsyncState<{ apps: App[] }> & { - reload: () => Promise; - }, - Action - > - >(reducer, { - phase: AsyncStatePhase.LOADING, - value: undefined, - error: undefined, - reload: async () => undefined, - }); + const queryClient = useQueryClient(); - const isAdminUser = usePermission('manage-apps'); + const invalidateAppsCountQuery = useInvalidateAppsCountQueryCallback(); - const fetch = useCallback(async (isAdminUser?: string): Promise => { - dispatchMarketplaceApps({ type: 'request', reload: async () => undefined }); - dispatchInstalledApps({ type: 'request', reload: async () => undefined }); - dispatchPrivateApps({ type: 'request', reload: async () => undefined }); + useEffect(() => { + const listeners = { + APP_ADDED: (): void => { + queryClient.invalidateQueries(['apps-instance', isAdminUser]); + queryClient.invalidateQueries(['apps-stored', isAdminUser]); + }, + APP_UPDATED: (): void => { + queryClient.invalidateQueries(['apps-instance', isAdminUser]); + queryClient.invalidateQueries(['apps-stored', isAdminUser]); + }, + APP_REMOVED: (appId: string): void => { + queryClient.setQueryData(['apps-instance', isAdminUser], (data) => { + return data?.filter((app) => app.id !== appId); + }); + queryClient.invalidateQueries(['apps-stored', isAdminUser]); + }, + APP_STATUS_CHANGE: ({ appId, status }: { appId: string; status: AppStatus }): void => { + queryClient.setQueryData(['apps-instance', isAdminUser], (data) => { + return data?.map((app) => { + if (app.id !== appId) { + return app; + } + return { + ...app, + status, + }; + }); + }); + queryClient.invalidateQueries(['apps-stored', isAdminUser]); + }, + APP_SETTING_UPDATED: (): void => { + queryClient.invalidateQueries(['apps-instance', isAdminUser]); + queryClient.invalidateQueries(['apps-stored', isAdminUser]); + }, + }; + const unregisterListeners = registerListeners(listeners); - let allInstalledApps: App[] = []; - let installedApps: App[] = []; - let marketplaceApps: App[] = []; - let privateApps: App[] = []; - let marketplaceError = false; - let installedAppsError = false; - let privateAppsError = false; + // eslint-disable-next-line no-unsafe-finally + return unregisterListeners; + }, [invalidateAppsCountQuery, isAdminUser, queryClient]); - try { - marketplaceApps = (await Apps.getAppsFromMarketplace(isAdminUser)) as unknown as App[]; - } catch (e) { - dispatchMarketplaceApps({ - type: 'failure', - error: e instanceof Error ? e : new Error(String(e)), - reload: fetch, - }); - marketplaceError = true; - } + const marketplace = useQuery(['apps-marketplace', isAdminUser], () => Apps.getAppsFromMarketplace(isAdminUser ? 'true' : 'false'), { + staleTime: Infinity, + refetchOnWindowFocus: false, + keepPreviousData: true, + }); - try { - allInstalledApps = await Apps.getInstalledApps().then((result: App[]) => + const instance = useQuery( + ['apps-instance', isAdminUser], + () => + Apps.getInstalledApps().then((result: App[]) => result.map((current: App) => ({ ...current, installed: true, })), - ); - } catch (e) { - dispatchInstalledApps({ - type: 'failure', - error: e instanceof Error ? e : new Error(String(e)), - reload: fetch, - }); - installedAppsError = true; - } - - try { - installedApps = allInstalledApps.filter((app: App) => !app.private); - } catch (e) { - dispatchInstalledApps({ - type: 'failure', - error: e instanceof Error ? e : new Error(String(e)), - reload: fetch, - }); - installedAppsError = true; - } - - try { - privateApps = allInstalledApps.filter((app: App) => app.private); - } catch (e) { - dispatchPrivateApps({ - type: 'failure', - error: e instanceof Error ? e : new Error(String(e)), - reload: fetch, - }); + ), + { staleTime: Infinity, refetchOnWindowFocus: false, keepPreviousData: true }, + ); - privateAppsError = true; - } + const store = useQuery( + ['apps-stored', isAdminUser], + () => { + if (!marketplace.isSuccess || !instance.isSuccess) { + throw new Error('Apps not loaded'); + } - const installedAppsData: App[] = []; - const marketplaceAppsData: App[] = []; - const privateAppsData: App[] = []; + const marketplaceApps: App[] = []; + const installedApps: App[] = []; + const privateApps: App[] = []; - if (!marketplaceError) { - marketplaceApps.forEach((app) => { - const appIndex = installedApps.findIndex(({ id }) => id === app.id); - if (!installedApps[appIndex]) { - marketplaceAppsData.push({ - ...app, - status: undefined, - marketplaceVersion: app.version, - bundledIn: app.bundledIn, - }); + sortByName(marketplace.data).forEach((app) => { + const appIndex = instance.data.findIndex(({ id }) => id === app.id); + const [installedApp] = appIndex > -1 ? instance.data.splice(appIndex, 1) : []; - return; - } - const [installedApp] = installedApps.splice(appIndex, 1); - const appData = { + const record = { ...app, - installed: true, ...(installedApp && { + private: installedApp.private, + installed: true, status: installedApp.status, version: installedApp.version, licenseValidation: installedApp.licenseValidation, + migrated: installedApp.migrated, }), bundledIn: app.bundledIn, marketplaceVersion: app.version, - migrated: installedApp.migrated, - }; - - installedAppsData.push(appData); - marketplaceAppsData.push(appData); - }); - dispatchMarketplaceApps({ - type: 'success', - reload: fetch, - apps: marketplaceAppsData, - }); - } - - if (!installedAppsError) { - if (installedApps.length > 0) { - installedAppsData.push(...installedApps); - } - - dispatchInstalledApps({ - type: 'success', - reload: fetch, - apps: installedAppsData, - }); - } - - if (!privateAppsError) { - if (privateApps.length > 0) { - privateAppsData.push(...privateApps); - } - - dispatchPrivateApps({ - type: 'success', - reload: fetch, - apps: privateAppsData, - }); - } - }, []); - - const getCurrentData = useMutableCallback(function getCurrentData() { - return [marketplaceAppsState, installedAppsState, privateAppsState]; - }); - - const invalidateAppsCountQuery = useInvalidateAppsCountQueryCallback(); - - useEffect(() => { - const handleAppAddedOrUpdated = async (appId: string): Promise => { - let marketplaceApp: { app: App; success: boolean } | undefined; - let installedApp: App; - let privateApp: App | undefined; - - invalidateAppsCountQuery(); - - try { - const app = await Apps.getApp(appId); - - if (app.private) { - privateApp = app; - } - - installedApp = app; - } catch (error: any) { - handleAPIError(error); - throw error; - } - - try { - marketplaceApp = await Apps.getAppFromMarketplace(appId, installedApp.version); - } catch (error: any) { - handleAPIError(error); - } - - const [, installedApps, privateApps] = getCurrentData(); - - if (marketplaceApp !== undefined) { - const { status, version, licenseValidation } = installedApp; - const record = { - ...marketplaceApp.app, - success: marketplaceApp.success, - installed: true, - status, - version, - licenseValidation, - marketplaceVersion: marketplaceApp.app.version, }; - dispatchMarketplaceApps({ - type: 'update', - app: record, - reload: fetch, - }); - - if (installedApps.value) { - if (installedApps.value.apps.some((app) => app.id === appId)) { - dispatchInstalledApps({ - type: 'update', - app: record, - reload: fetch, - }); - return; - } - dispatchInstalledApps({ - type: 'success', - apps: [...installedApps.value.apps, record], - reload: fetch, - }); - return; + if (installedApp?.private) { + privateApps.push(record); } - dispatchInstalledApps({ - type: 'success', - apps: [record], - reload: fetch, - }); - - return; - } - - if (privateApp !== undefined) { - const { status, version } = privateApp; - - const record = { - ...privateApp, - success: true, - installed: true, - status, - version, - }; - - if (privateApps.value) { - if (privateApps.value.apps.some((app) => app.id === appId)) { - dispatchPrivateApps({ - type: 'update', - app: record, - reload: fetch, - }); - return; - } - dispatchPrivateApps({ - type: 'success', - apps: [...privateApps.value.apps, record], - reload: fetch, - }); - return; + if (installedApp && !installedApp.private) { + installedApps.push(record); } + marketplaceApps.push(record); + }); - dispatchPrivateApps({ type: 'success', apps: [record], reload: fetch }); - return; - } - - // TODO: Reevaluate the necessity of this dispatch - dispatchInstalledApps({ type: 'update', app: installedApp, reload: fetch }); - }; - const listeners = { - APP_ADDED: handleAppAddedOrUpdated, - APP_UPDATED: handleAppAddedOrUpdated, - APP_REMOVED: (appId: string): void => { - const updatedData = getCurrentData(); - - // TODO: This forEach is not ideal, it will be improved in the future during the refactor of this provider; - updatedData.forEach((appsList) => { - const app = appsList.value?.apps.find(({ id }: { id: string }) => id === appId); - - dispatchInstalledApps({ - type: 'delete', - appId, - reload: fetch, - }); - - if (!app) { - return; - } - - if (app.private) { - dispatchPrivateApps({ - type: 'delete', - appId, - reload: fetch, - }); - } - - dispatchMarketplaceApps({ - type: 'update', - reload: fetch, - app: { - ...app, - version: app?.marketplaceVersion, - installed: false, - marketplaceVersion: app?.marketplaceVersion, - }, - }); - }); - - invalidateAppsCountQuery(); - }, - APP_STATUS_CHANGE: ({ appId, status }: { appId: string; status: AppStatus }): void => { - const updatedData = getCurrentData(); - - if (!Array.isArray(updatedData)) { - return; + sortByName(instance.data).forEach((app) => { + if (app.private) { + privateApps.push(app); } + }); - // TODO: This forEach is not ideal, it will be improved in the future during the refactor of this provider; - updatedData.forEach((appsList) => { - const app = appsList.value?.apps.find(({ id }: { id: string }) => id === appId); - - if (!app) { - return; - } - - app.status = status; - - dispatchInstalledApps({ - type: 'update', - app: { - ...app, - status, - }, - reload: fetch, - }); - - if (app.private) { - dispatchPrivateApps({ - type: 'update', - app: { - ...app, - status, - }, - reload: fetch, - }); - } - - dispatchMarketplaceApps({ - type: 'update', - app: { - ...app, - status, - }, - reload: fetch, - }); - }); + return [marketplaceApps, installedApps, privateApps]; + }, + { + enabled: marketplace.isSuccess && instance.isSuccess, + keepPreviousData: true, + }, + ); - invalidateAppsCountQuery(); - }, - APP_SETTING_UPDATED: ({ appId }: { appId: string }): void => { - dispatchInstalledApps({ type: 'invalidate', appId, reload: fetch }); - dispatchMarketplaceApps({ type: 'invalidate', appId, reload: fetch }); - dispatchPrivateApps({ type: 'invalidate', appId, reload: fetch }); - }, - }; - const unregisterListeners = registerListeners(listeners); - try { - fetch(isAdminUser ? 'true' : 'false'); - } finally { - // eslint-disable-next-line no-unsafe-finally - return unregisterListeners; - } - }, [fetch, getCurrentData, invalidateAppsCountQuery, isAdminUser]); + if (!store.isSuccess) { + return ; + } return ( { + await Promise.all([ + queryClient.invalidateQueries(['apps-marketplace', isAdminUser]), + queryClient.invalidateQueries(['apps-instance', isAdminUser]), + ]); + }, }} /> );