diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 0a6df42d77..b2a7812e63 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1023,14 +1023,6 @@ "count": 2 } }, - "web/app/components/base/ga/index.tsx": { - "erasable-syntax-only/enums": { - "count": 1 - }, - "react-refresh/only-export-components": { - "count": 1 - } - }, "web/app/components/base/icons/src/public/avatar/index.ts": { "no-barrel-files/no-barrel-files": { "count": 2 diff --git a/web/app/(commonLayout)/layout.tsx b/web/app/(commonLayout)/layout.tsx index 2467f35b7b..6f80c447b5 100644 --- a/web/app/(commonLayout)/layout.tsx +++ b/web/app/(commonLayout)/layout.tsx @@ -3,7 +3,7 @@ import * as React from 'react' import { AppInitializer } from '@/app/components/app-initializer' import InSiteMessageNotification from '@/app/components/app/in-site-message/notification' import AmplitudeProvider from '@/app/components/base/amplitude' -import GA, { GaType } from '@/app/components/base/ga' +import { GoogleAnalyticsScripts } from '@/app/components/base/ga' import Zendesk from '@/app/components/base/zendesk' import { GotoAnything } from '@/app/components/goto-anything' import Header from '@/app/components/header' @@ -19,7 +19,7 @@ import RoleRouteGuard from './role-route-guard' const Layout = ({ children }: { children: ReactNode }) => { return ( <> - + diff --git a/web/app/account/(commonLayout)/layout.tsx b/web/app/account/(commonLayout)/layout.tsx index 8fdbd8a238..4d344c3f78 100644 --- a/web/app/account/(commonLayout)/layout.tsx +++ b/web/app/account/(commonLayout)/layout.tsx @@ -2,7 +2,7 @@ import type { ReactNode } from 'react' import * as React from 'react' import { AppInitializer } from '@/app/components/app-initializer' import AmplitudeProvider from '@/app/components/base/amplitude' -import GA, { GaType } from '@/app/components/base/ga' +import { GoogleAnalyticsScripts } from '@/app/components/base/ga' import HeaderWrapper from '@/app/components/header/header-wrapper' import { AppContextProvider } from '@/context/app-context-provider' import { EventEmitterContextProvider } from '@/context/event-emitter-provider' @@ -13,7 +13,7 @@ import Header from './header' const Layout = ({ children }: { children: ReactNode }) => { return ( <> - + diff --git a/web/app/components/apps/hooks/__tests__/use-workflow-online-users.spec.ts b/web/app/components/apps/hooks/__tests__/use-workflow-online-users.spec.ts new file mode 100644 index 0000000000..bdaefc67ba --- /dev/null +++ b/web/app/components/apps/hooks/__tests__/use-workflow-online-users.spec.ts @@ -0,0 +1,141 @@ +import type { WorkflowOnlineUser, WorkflowOnlineUsersResponse } from '@/models/app' +import { renderHook } from '@testing-library/react' +import { useWorkflowOnlineUsers } from '../use-workflow-online-users' + +type QueryOptions = { + input: { + body: { + app_ids: string[] + } + } + enabled: boolean + select: (response?: WorkflowOnlineUsersResponse) => Record + refetchInterval: false | number +} + +const { mockUseQuery, mockQueryOptions } = vi.hoisted(() => ({ + mockUseQuery: vi.fn(), + mockQueryOptions: vi.fn((options: QueryOptions) => options), +})) + +vi.mock('@tanstack/react-query', () => ({ + useQuery: mockUseQuery, +})) + +vi.mock('@/service/client', () => ({ + consoleQuery: { + apps: { + workflowOnlineUsers: { + queryOptions: mockQueryOptions, + }, + }, + }, +})) + +const getLastQueryOptions = () => { + const lastCall = mockQueryOptions.mock.lastCall + if (!lastCall) + throw new Error('workflowOnlineUsers.queryOptions was not called') + return lastCall[0] as QueryOptions +} + +describe('useWorkflowOnlineUsers', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseQuery.mockReturnValue({ data: undefined }) + }) + + describe('Query Options', () => { + it('should disable query with a valid empty input when app ids are empty', () => { + renderHook(() => useWorkflowOnlineUsers({ + appIds: [], + enabled: true, + })) + + expect(mockQueryOptions).toHaveBeenCalledWith(expect.objectContaining({ + input: { body: { app_ids: [] } }, + enabled: false, + refetchInterval: false, + })) + expect(mockUseQuery).toHaveBeenCalledWith(expect.objectContaining({ + input: { body: { app_ids: [] } }, + enabled: false, + })) + }) + + it('should enable query and polling when collaboration is enabled with app ids', () => { + renderHook(() => useWorkflowOnlineUsers({ + appIds: ['app-1', 'app-2'], + enabled: true, + })) + + expect(mockQueryOptions).toHaveBeenCalledWith(expect.objectContaining({ + input: { body: { app_ids: ['app-1', 'app-2'] } }, + enabled: true, + refetchInterval: 10000, + })) + }) + + it('should disable query while preserving valid input when collaboration is disabled', () => { + renderHook(() => useWorkflowOnlineUsers({ + appIds: ['app-1'], + enabled: false, + })) + + expect(mockQueryOptions).toHaveBeenCalledWith(expect.objectContaining({ + input: { body: { app_ids: ['app-1'] } }, + enabled: false, + refetchInterval: false, + })) + }) + }) + + describe('Response Mapping', () => { + it('should normalize array response data by app id', () => { + renderHook(() => useWorkflowOnlineUsers({ + appIds: ['app-1'], + enabled: true, + })) + + const options = getLastQueryOptions() + const user = { user_id: 'user-1', username: 'Alice' } + + expect(options.select({ + data: [ + { app_id: 'app-1', users: [user] }, + ], + })).toEqual({ + 'app-1': [user], + }) + }) + + it('should normalize record response data without changing user arrays', () => { + renderHook(() => useWorkflowOnlineUsers({ + appIds: ['app-1'], + enabled: true, + })) + + const options = getLastQueryOptions() + const users = [{ user_id: 'user-1', username: 'Alice' }] + + expect(options.select({ + data: { + 'app-1': users, + }, + })).toEqual({ + 'app-1': users, + }) + }) + }) + + describe('Return Value', () => { + it('should return an empty map when query data is unavailable', () => { + const { result } = renderHook(() => useWorkflowOnlineUsers({ + appIds: ['app-1'], + enabled: true, + })) + + expect(result.current.onlineUsersMap).toEqual({}) + }) + }) +}) diff --git a/web/app/components/apps/hooks/use-workflow-online-users.ts b/web/app/components/apps/hooks/use-workflow-online-users.ts index a1778306f3..a0ec6a2d4e 100644 --- a/web/app/components/apps/hooks/use-workflow-online-users.ts +++ b/web/app/components/apps/hooks/use-workflow-online-users.ts @@ -1,5 +1,5 @@ import type { WorkflowOnlineUser, WorkflowOnlineUsersResponse } from '@/models/app' -import { skipToken, useQuery } from '@tanstack/react-query' +import { useQuery } from '@tanstack/react-query' import { consoleQuery } from '@/service/client' type WorkflowOnlineUsersMap = Record @@ -36,9 +36,8 @@ export const useWorkflowOnlineUsers = ({ }: UseWorkflowOnlineUsersParams) => { const shouldFetch = enabled && appIds.length > 0 const { data: onlineUsersMap = {} } = useQuery(consoleQuery.apps.workflowOnlineUsers.queryOptions({ - input: shouldFetch - ? { body: { app_ids: appIds } } - : skipToken, + input: { body: { app_ids: appIds } }, + enabled: shouldFetch, select: normalizeWorkflowOnlineUsers, refetchInterval: shouldFetch ? 10000 : false, })) diff --git a/web/app/components/base/ga/__tests__/index.spec.tsx b/web/app/components/base/ga/__tests__/index.spec.tsx index 619c4514dc..4ce9677a7a 100644 --- a/web/app/components/base/ga/__tests__/index.spec.tsx +++ b/web/app/components/base/ga/__tests__/index.spec.tsx @@ -2,29 +2,24 @@ import type { ReactElement, ReactNode } from 'react' import { render, screen } from '@testing-library/react' type ConfigState = { - isCeEdition: boolean + isCloudEdition: boolean isProd: boolean } -type GaProps = { - gaType: string -} - -type GaRenderFn = (props: GaProps) => Promise -type GaTypeValue = 'admin' | 'webapp' +type GoogleAnalyticsScriptsRenderFn = () => Promise const { mockHeaders, mockHeadersGet, configState } = vi.hoisted(() => ({ mockHeaders: vi.fn(), mockHeadersGet: vi.fn(), configState: ({ - isCeEdition: false, + isCloudEdition: true, isProd: true, }) as ConfigState, })) vi.mock('@/config', () => ({ - get IS_CE_EDITION() { - return configState.isCeEdition + get IS_CLOUD_EDITION() { + return configState.isCloudEdition }, get IS_PROD() { return configState.isProd @@ -41,18 +36,18 @@ vi.mock('@/next/script', () => ({ strategy, src, nonce, - dangerouslySetInnerHTML, + children, }: { id?: string strategy?: string src?: string nonce?: string - dangerouslySetInnerHTML?: { __html?: string } + children?: ReactNode }) => ( ) } -export default React.memo(GA)