diff --git a/eslint-suppressions.json b/eslint-suppressions.json index e4b92e2db43..176341002bf 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -7031,11 +7031,6 @@ "count": 1 } }, - "web/service/access-control/__tests__/use-permission-keys.spec.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/service/access-control/__tests__/use-workspace-access-rules.spec.tsx": { "no-restricted-imports": { "count": 1 @@ -7056,11 +7051,6 @@ "count": 1 } }, - "web/service/access-control/use-permission-keys.ts": { - "no-restricted-imports": { - "count": 1 - } - }, "web/service/access-control/use-workspace-access-rules.ts": { "no-restricted-imports": { "count": 1 @@ -7228,17 +7218,6 @@ "count": 1 } }, - "web/service/use-common.ts": { - "no-restricted-imports": { - "count": 1 - }, - "ts/no-empty-object-type": { - "count": 1 - }, - "ts/no-explicit-any": { - "count": 1 - } - }, "web/service/use-datasource.ts": { "no-restricted-imports": { "count": 1 diff --git a/web/context/__tests__/app-context-provider.spec.tsx b/web/context/__tests__/app-context-provider.spec.tsx index 0e55cd8459e..7b574823025 100644 --- a/web/context/__tests__/app-context-provider.spec.tsx +++ b/web/context/__tests__/app-context-provider.spec.tsx @@ -1,8 +1,18 @@ -import { render, screen } from '@testing-library/react' -import { useAppContext, useSelector } from '../app-context' +import type { ReactNode } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { Provider as JotaiProvider } from 'jotai' +import { queryClientAtom } from 'jotai-tanstack-query' +import { useHydrateAtoms } from 'jotai/react/utils' +import { Suspense } from 'react' +import { setUserId, setUserProperties } from '@/app/components/base/amplitude' +import { flushRegistrationSuccess } from '@/app/components/base/amplitude/registration-tracking' +import { setZendeskConversationFields } from '@/app/components/base/zendesk/utils' +import { ZENDESK_FIELD_IDS } from '@/config' +import { initialWorkspaceInfo, useAppContext, useSelector } from '../app-context' import { AppContextProvider } from '../app-context-provider' -const mockInvalidateQueries = vi.hoisted(() => vi.fn()) +const mockGetRequest = vi.hoisted(() => vi.fn()) const mockPermissionKeysState = vi.hoisted(() => ({ isPending: false, permissionKeys: ['app.create_and_management'], @@ -19,55 +29,84 @@ const mockCurrentWorkspaceResponse = vi.hoisted(() => ({ next_credit_reset_date: 1706745600, custom_config: {}, })) - -vi.mock('@tanstack/react-query', () => ({ - useQueryClient: () => ({ - invalidateQueries: mockInvalidateQueries, - }), - useSuspenseQuery: (options: { queryKey?: readonly unknown[] }) => { - if (options.queryKey?.[0] === 'system-features') { - return { - data: { - branding: { - enabled: false, - }, - }, - } +const mockCurrentWorkspaceQueryState = vi.hoisted(() => ({ + data: mockCurrentWorkspaceResponse as typeof mockCurrentWorkspaceResponse | undefined, + isPending: false, +})) +const mockUserProfileResponseState = vi.hoisted(() => ({ + data: { + profile: { + id: 'user-1', + name: 'User', + email: 'user@example.com', + avatar: '', + avatar_url: '', + is_password_set: true, + }, + meta: { + currentVersion: '1.0.0', + currentEnv: 'cloud', + }, + } as { + profile?: { + id: string + name: string + email: string + avatar: string + avatar_url: string + is_password_set: boolean } - - return { - data: { - profile: { - id: 'user-1', - name: 'User', - email: 'user@example.com', - avatar: '', - avatar_url: '', - is_password_set: true, - }, - meta: { - currentVersion: '1.0.0', - currentEnv: 'cloud', - }, - }, + meta: { + currentVersion: string | null + currentEnv: string | null } }, - useQuery: (options: { select?: (workspace: typeof mockCurrentWorkspaceResponse) => unknown }) => ({ - data: options.select ? options.select(mockCurrentWorkspaceResponse) : mockCurrentWorkspaceResponse, - isFetching: false, - isPending: false, - }), })) +const mockSystemFeaturesState = vi.hoisted(() => ({ + data: { + branding: { + enabled: false, + }, + }, +})) +const mockLangGeniusVersionState = vi.hoisted(() => ({ + data: { + version: '1.0.1', + release_date: '', + release_notes: '', + can_auto_update: false, + } as { + version: string + release_date: string + release_notes: string + can_auto_update: boolean + } | undefined, +})) + +vi.mock('@/config', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + ZENDESK_FIELD_IDS: { + ENVIRONMENT: 'environment-field', + VERSION: 'version-field', + EMAIL: 'email-field', + WORKSPACE_ID: 'workspace-id-field', + }, + } +}) vi.mock('@/features/system-features/client', () => ({ systemFeaturesQueryOptions: () => ({ queryKey: ['system-features'], + queryFn: async () => mockSystemFeaturesState.data, }), })) vi.mock('@/features/account-profile/client', () => ({ userProfileQueryOptions: () => ({ queryKey: ['user-profile'], + queryFn: async () => mockUserProfileResponseState.data, }), })) @@ -77,8 +116,16 @@ vi.mock('@/service/client', () => ({ current: { post: { key: () => ['current-workspace'], - queryOptions: (options: Record) => ({ + queryOptions: (options: { + select?: (workspace?: typeof mockCurrentWorkspaceResponse) => unknown + }) => ({ queryKey: ['current-workspace'], + queryFn: async () => { + if (mockCurrentWorkspaceQueryState.isPending) + return new Promise(() => {}) + + return mockCurrentWorkspaceQueryState.data + }, ...options, }), }, @@ -87,34 +134,9 @@ vi.mock('@/service/client', () => ({ }, })) -vi.mock('@/service/access-control/use-permission-keys', () => ({ - useWorkspacePermissionKeys: () => ({ - data: { - workspace: { - permission_keys: mockPermissionKeysState.permissionKeys, - }, - app: { - default_permission_keys: [], - overrides: [], - }, - dataset: { - default_permission_keys: [], - overrides: [], - }, - }, - isPending: mockPermissionKeysState.isPending, - }), -})) - -vi.mock('@/service/use-common', () => ({ - useLangGeniusVersion: () => ({ - data: { - version: '1.0.1', - release_date: '', - release_notes: '', - can_auto_update: false, - }, - }), +vi.mock('@/service/base', () => ({ + get: mockGetRequest, + post: vi.fn(), })) vi.mock('@/app/components/base/amplitude', () => ({ @@ -145,35 +167,303 @@ function AppContextProbe() { {selectedWorkspacePermissionKeys.join(',')} - loading: + permission loading: {String(context.isLoadingWorkspacePermissionKeys)} + + workspace loading: + {String(context.isLoadingCurrentWorkspace)} + + + workspace validating: + {String(context.isValidatingCurrentWorkspace)} + + + user: + {context.userProfile.email} + + + workspace: + {context.currentWorkspace.name} + role: {context.currentWorkspace.role} + + manager: + {String(context.isCurrentWorkspaceManager)} + + + owner: + {String(context.isCurrentWorkspaceOwner)} + + + editor: + {String(context.isCurrentWorkspaceEditor)} + + + dataset operator: + {String(context.isCurrentWorkspaceDatasetOperator)} + + + version: + {context.langGeniusVersionInfo.current_version} + / + {context.langGeniusVersionInfo.latest_version} + / + {context.langGeniusVersionInfo.current_env} + + + ) } +function TestQueryClientHydrator({ + children, + queryClient, +}: { + children: ReactNode + queryClient: QueryClient +}) { + useHydrateAtoms(new Map([[queryClientAtom, queryClient]])) + + return children +} + +function createTestQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { + retry: false, + staleTime: 0, + }, + }, + }) +} + +function renderProvider() { + const queryClient = createTestQueryClient() + const view = render( + + + + loading}> + + + + + + + , + ) + + return { + ...view, + queryClient, + } +} + describe('AppContextProvider', () => { beforeEach(() => { vi.clearAllMocks() mockPermissionKeysState.isPending = false mockPermissionKeysState.permissionKeys = ['app.create_and_management'] + mockCurrentWorkspaceQueryState.data = mockCurrentWorkspaceResponse + mockCurrentWorkspaceQueryState.isPending = false + mockUserProfileResponseState.data = { + profile: { + id: 'user-1', + name: 'User', + email: 'user@example.com', + avatar: '', + avatar_url: '', + is_password_set: true, + }, + meta: { + currentVersion: '1.0.0', + currentEnv: 'cloud', + }, + } + mockSystemFeaturesState.data = { + branding: { + enabled: false, + }, + } + mockLangGeniusVersionState.data = { + version: '1.0.1', + release_date: '', + release_notes: '', + can_auto_update: false, + } + mockGetRequest.mockImplementation((url: string) => { + if (url === '/workspaces/current/rbac/my-permissions') { + if (mockPermissionKeysState.isPending) + return new Promise(() => {}) + + return Promise.resolve({ + workspace: { + permission_keys: mockPermissionKeysState.permissionKeys, + }, + app: { + default_permission_keys: [], + overrides: [], + }, + dataset: { + default_permission_keys: [], + overrides: [], + }, + }) + } + + if (url === '/version') + return Promise.resolve(mockLangGeniusVersionState.data) + + return Promise.reject(new Error(`Unexpected GET ${url}`)) + }) }) - describe('Workspace Permission Keys', () => { - it('should provide current workspace permission keys from my-permissions', () => { - render( - - - , - ) + describe('Context compatibility values', () => { + it('should provide profile, workspace, permissions, loading state, and version metadata', async () => { + renderProvider() - expect(screen.getByText('keys:app.create_and_management')).toBeInTheDocument() - expect(screen.getByText('loading:false')).toBeInTheDocument() - expect(screen.getByText('role:editor')).toBeInTheDocument() + expect(await screen.findByText('user:user@example.com')).toBeInTheDocument() + expect(await screen.findByText('workspace:Workspace')).toBeInTheDocument() + expect(await screen.findByText('keys:app.create_and_management')).toBeInTheDocument() + expect(screen.getByText('permission loading:false')).toBeInTheDocument() + expect(screen.getByText('workspace loading:false')).toBeInTheDocument() + expect(screen.getByText('workspace validating:false')).toBeInTheDocument() + expect(await screen.findByText('version:1.0.0/1.0.1/cloud')).toBeInTheDocument() + }) + + it('should fall back to placeholder values when profile, workspace, permission, or version data is missing', async () => { + mockUserProfileResponseState.data = { + meta: { + currentVersion: null, + currentEnv: null, + }, + } + mockCurrentWorkspaceQueryState.data = undefined + mockPermissionKeysState.permissionKeys = [] + mockLangGeniusVersionState.data = undefined + + renderProvider() + + expect(await screen.findByText('user:')).toBeInTheDocument() + expect(screen.getByText(`workspace:${initialWorkspaceInfo.name}`)).toBeInTheDocument() + expect(screen.getByText(`role:${initialWorkspaceInfo.role}`)).toBeInTheDocument() + expect(screen.getByText('keys:')).toBeInTheDocument() + expect(screen.getByText('version://')).toBeInTheDocument() + }) + + it('should normalize invalid workspace roles to the initial workspace role', async () => { + mockCurrentWorkspaceQueryState.data = { + ...mockCurrentWorkspaceResponse, + role: 'unsupported-role', + } + + renderProvider() + + expect(await screen.findByText(`role:${initialWorkspaceInfo.role}`)).toBeInTheDocument() + }) + + it('should derive role flags from the current workspace role', async () => { + mockCurrentWorkspaceQueryState.data = { + ...mockCurrentWorkspaceResponse, + role: 'owner', + } + + renderProvider() + + expect(await screen.findByText('manager:true')).toBeInTheDocument() + expect(screen.getByText('owner:true')).toBeInTheDocument() + expect(screen.getByText('editor:true')).toBeInTheDocument() + expect(screen.getByText('dataset operator:false')).toBeInTheDocument() + }) + + it('should expose query loading and validating state', async () => { + mockPermissionKeysState.isPending = true + mockCurrentWorkspaceQueryState.isPending = true + + renderProvider() + + expect(await screen.findByText('workspace loading:true')).toBeInTheDocument() + expect(screen.getByText('workspace validating:true')).toBeInTheDocument() + expect(screen.getByText('permission loading:true')).toBeInTheDocument() + }) + }) + + describe('Refresh actions', () => { + it('should invalidate the source queries when refresh actions are called', async () => { + const { queryClient } = renderProvider() + const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries') + + fireEvent.click(await screen.findByRole('button', { name: /refresh user/i })) + fireEvent.click(screen.getByRole('button', { name: /refresh workspace/i })) + + expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['user-profile'] }) + expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['current-workspace'] }) + }) + }) + + describe('External side effects', () => { + it('should sync Zendesk fields and Amplitude identity when bootstrap data is available', async () => { + renderProvider() + + await waitFor(() => { + expect(setZendeskConversationFields).toHaveBeenCalledWith([{ + id: ZENDESK_FIELD_IDS.ENVIRONMENT, + value: 'cloud', + }]) + }) + expect(setZendeskConversationFields).toHaveBeenCalledWith([{ + id: ZENDESK_FIELD_IDS.VERSION, + value: '1.0.1', + }]) + expect(setZendeskConversationFields).toHaveBeenCalledWith([{ + id: ZENDESK_FIELD_IDS.EMAIL, + value: 'user@example.com', + }]) + await waitFor(() => { + expect(setZendeskConversationFields).toHaveBeenCalledWith([{ + id: ZENDESK_FIELD_IDS.WORKSPACE_ID, + value: 'workspace-1', + }]) + }) + await waitFor(() => { + expect(setUserId).toHaveBeenCalledWith('user@example.com') + expect(setUserProperties).toHaveBeenCalledWith(expect.objectContaining({ + email: 'user@example.com', + workspace_id: 'workspace-1', + workspace_role: 'editor', + })) + expect(flushRegistrationSuccess).toHaveBeenCalled() + }) + }) + + it('should not sync Amplitude identity when user id is missing', async () => { + mockUserProfileResponseState.data = { + profile: { + id: '', + name: '', + email: '', + avatar: '', + avatar_url: '', + is_password_set: false, + }, + meta: { + currentVersion: '1.0.0', + currentEnv: 'cloud', + }, + } + + renderProvider() + + await screen.findByText('user:') + expect(setUserId).not.toHaveBeenCalled() + expect(setUserProperties).not.toHaveBeenCalled() + expect(flushRegistrationSuccess).not.toHaveBeenCalled() }) }) }) diff --git a/web/context/app-context-effects.ts b/web/context/app-context-effects.ts new file mode 100644 index 00000000000..875cc2ec638 --- /dev/null +++ b/web/context/app-context-effects.ts @@ -0,0 +1,82 @@ +'use client' + +import { useAtomValue } from 'jotai' +import { useEffect } from 'react' +import { setUserId, setUserProperties } from '@/app/components/base/amplitude' +import { flushRegistrationSuccess } from '@/app/components/base/amplitude/registration-tracking' +import { setZendeskConversationFields } from '@/app/components/base/zendesk/utils' +import { ZENDESK_FIELD_IDS } from '@/config' +import { + currentWorkspaceAtom, + langGeniusVersionInfoAtom, + userProfileAtom, +} from './app-context-state' + +export function useSyncZendeskFields() { + const userProfile = useAtomValue(userProfileAtom) + const currentWorkspace = useAtomValue(currentWorkspaceAtom) + const langGeniusVersionInfo = useAtomValue(langGeniusVersionInfoAtom) + + useEffect(() => { + if (ZENDESK_FIELD_IDS.ENVIRONMENT && langGeniusVersionInfo?.current_env) { + setZendeskConversationFields([{ + id: ZENDESK_FIELD_IDS.ENVIRONMENT, + value: langGeniusVersionInfo.current_env.toLowerCase(), + }]) + } + }, [langGeniusVersionInfo?.current_env]) + + useEffect(() => { + if (ZENDESK_FIELD_IDS.VERSION && langGeniusVersionInfo?.version) { + setZendeskConversationFields([{ + id: ZENDESK_FIELD_IDS.VERSION, + value: langGeniusVersionInfo.version, + }]) + } + }, [langGeniusVersionInfo?.version]) + + useEffect(() => { + if (ZENDESK_FIELD_IDS.EMAIL && userProfile?.email) { + setZendeskConversationFields([{ + id: ZENDESK_FIELD_IDS.EMAIL, + value: userProfile.email, + }]) + } + }, [userProfile?.email]) + + useEffect(() => { + if (ZENDESK_FIELD_IDS.WORKSPACE_ID && currentWorkspace?.id) { + setZendeskConversationFields([{ + id: ZENDESK_FIELD_IDS.WORKSPACE_ID, + value: currentWorkspace.id, + }]) + } + }, [currentWorkspace?.id]) +} + +export function useSyncAmplitudeIdentity() { + const userProfile = useAtomValue(userProfileAtom) + const currentWorkspace = useAtomValue(currentWorkspaceAtom) + + useEffect(() => { + if (userProfile?.id) { + setUserId(userProfile.email) + const properties: Record = { + email: userProfile.email, + name: userProfile.name, + has_password: userProfile.is_password_set, + } + + if (currentWorkspace?.id) { + properties.workspace_id = currentWorkspace.id + properties.workspace_name = currentWorkspace.name + properties.workspace_plan = currentWorkspace.plan + properties.workspace_status = currentWorkspace.status + properties.workspace_role = currentWorkspace.role + } + + setUserProperties(properties) + flushRegistrationSuccess() + } + }, [userProfile, currentWorkspace]) +} diff --git a/web/context/app-context-normalizers.ts b/web/context/app-context-normalizers.ts new file mode 100644 index 00000000000..244d88fa682 --- /dev/null +++ b/web/context/app-context-normalizers.ts @@ -0,0 +1,78 @@ +import type { PostWorkspacesCurrentResponse } from '@dify/contracts/api/console/workspaces/types.gen' +import type { ICurrentWorkspace, LangGeniusVersionResponse } from '@/models/common' +import { initialLangGeniusVersionInfo, initialWorkspaceInfo } from './app-context' + +const workspaceRoles = new Set(['owner', 'admin', 'editor', 'dataset_operator', 'normal']) + +export const emptyWorkspacePermissionKeys: string[] = [] + +export type WorkspaceRoleFlags = { + isCurrentWorkspaceManager: boolean + isCurrentWorkspaceOwner: boolean + isCurrentWorkspaceEditor: boolean + isCurrentWorkspaceDatasetOperator: boolean +} + +export type ProfileMeta = { + currentVersion: string | null + currentEnv: string | null +} + +function resolveWorkspaceRole(role: PostWorkspacesCurrentResponse['role']): ICurrentWorkspace['role'] { + if (role && workspaceRoles.has(role as ICurrentWorkspace['role'])) + return role as ICurrentWorkspace['role'] + + return initialWorkspaceInfo.role +} + +export function normalizeCurrentWorkspace(workspace?: PostWorkspacesCurrentResponse): ICurrentWorkspace { + if (!workspace) + return initialWorkspaceInfo + + return { + id: workspace.id, + name: workspace.name ?? initialWorkspaceInfo.name, + plan: workspace.plan ?? initialWorkspaceInfo.plan, + status: workspace.status ?? initialWorkspaceInfo.status, + created_at: workspace.created_at ?? initialWorkspaceInfo.created_at, + role: resolveWorkspaceRole(workspace.role), + providers: initialWorkspaceInfo.providers, + trial_credits: workspace.trial_credits ?? initialWorkspaceInfo.trial_credits, + trial_credits_used: workspace.trial_credits_used ?? initialWorkspaceInfo.trial_credits_used, + next_credit_reset_date: workspace.next_credit_reset_date ?? initialWorkspaceInfo.next_credit_reset_date, + trial_end_reason: workspace.trial_end_reason ?? undefined, + custom_config: workspace.custom_config + ? { + remove_webapp_brand: workspace.custom_config.remove_webapp_brand ?? undefined, + replace_webapp_logo: workspace.custom_config.replace_webapp_logo ?? undefined, + } + : undefined, + } +} + +export function getWorkspaceRoleFlags(currentWorkspace: ICurrentWorkspace): WorkspaceRoleFlags { + return { + isCurrentWorkspaceManager: ['owner', 'admin'].includes(currentWorkspace.role), + isCurrentWorkspaceOwner: currentWorkspace.role === 'owner', + isCurrentWorkspaceEditor: ['owner', 'admin', 'editor'].includes(currentWorkspace.role), + isCurrentWorkspaceDatasetOperator: currentWorkspace.role === 'dataset_operator', + } +} + +export function getLangGeniusVersionInfo({ + meta, + versionData, +}: { + meta: ProfileMeta + versionData?: Omit +}): LangGeniusVersionResponse { + if (!meta.currentVersion || !versionData) + return initialLangGeniusVersionInfo + + return { + ...versionData, + current_version: meta.currentVersion, + latest_version: versionData.version, + current_env: meta.currentEnv || '', + } +} diff --git a/web/context/app-context-provider.tsx b/web/context/app-context-provider.tsx index 509d36e3e59..64dda2cc753 100644 --- a/web/context/app-context-provider.tsx +++ b/web/context/app-context-provider.tsx @@ -1,193 +1,65 @@ 'use client' -import type { GetAccountProfileResponse } from '@dify/contracts/api/console/account/types.gen' -import type { PostWorkspacesCurrentResponse } from '@dify/contracts/api/console/workspaces/types.gen' import type { ReactNode } from 'react' -import type { ICurrentWorkspace, LangGeniusVersionResponse } from '@/models/common' -import { useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query' -import { useCallback, useEffect, useMemo } from 'react' -import { setUserId, setUserProperties } from '@/app/components/base/amplitude' -import { flushRegistrationSuccess } from '@/app/components/base/amplitude/registration-tracking' -import { setZendeskConversationFields } from '@/app/components/base/zendesk/utils' -import { ZENDESK_FIELD_IDS } from '@/config' +import { useAtomValue, useSetAtom } from 'jotai' import { AppContext, - initialLangGeniusVersionInfo, - initialWorkspaceInfo, - userProfilePlaceholder, useSelector, } from '@/context/app-context' -import { userProfileQueryOptions } from '@/features/account-profile/client' -import { systemFeaturesQueryOptions } from '@/features/system-features/client' -import { useWorkspacePermissionKeys } from '@/service/access-control/use-permission-keys' -import { consoleQuery } from '@/service/client' import { - useLangGeniusVersion, -} from '@/service/use-common' + currentWorkspaceAtom, + currentWorkspaceLoadingAtom, + currentWorkspaceValidatingAtom, + langGeniusVersionInfoAtom, + refreshCurrentWorkspaceAtom, + refreshUserProfileAtom, + userProfileAtom, + workspacePermissionKeysAtom, + workspacePermissionKeysLoadingAtom, + workspaceRoleFlagsAtom, +} from '@/context/app-context-state' +import { + useSyncAmplitudeIdentity, + useSyncZendeskFields, +} from './app-context-effects' type AppContextProviderProps = { children: ReactNode } -const workspaceRoles = new Set(['owner', 'admin', 'editor', 'dataset_operator', 'normal']) -const emptyWorkspacePermissionKeys: string[] = [] - -const resolveWorkspaceRole = (role: PostWorkspacesCurrentResponse['role']): ICurrentWorkspace['role'] => { - if (role && workspaceRoles.has(role as ICurrentWorkspace['role'])) - return role as ICurrentWorkspace['role'] - - return initialWorkspaceInfo.role -} - -const normalizeCurrentWorkspace = (workspace?: PostWorkspacesCurrentResponse): ICurrentWorkspace => { - if (!workspace) - return initialWorkspaceInfo - - return { - id: workspace.id, - name: workspace.name ?? initialWorkspaceInfo.name, - plan: workspace.plan ?? initialWorkspaceInfo.plan, - status: workspace.status ?? initialWorkspaceInfo.status, - created_at: workspace.created_at ?? initialWorkspaceInfo.created_at, - role: resolveWorkspaceRole(workspace.role), - providers: initialWorkspaceInfo.providers, - trial_credits: workspace.trial_credits ?? initialWorkspaceInfo.trial_credits, - trial_credits_used: workspace.trial_credits_used ?? initialWorkspaceInfo.trial_credits_used, - next_credit_reset_date: workspace.next_credit_reset_date ?? initialWorkspaceInfo.next_credit_reset_date, - trial_end_reason: workspace.trial_end_reason ?? undefined, - custom_config: workspace.custom_config - ? { - remove_webapp_brand: workspace.custom_config.remove_webapp_brand ?? undefined, - replace_webapp_logo: workspace.custom_config.replace_webapp_logo ?? undefined, - } - : undefined, - } -} - export function AppContextProvider({ children }: AppContextProviderProps) { - const queryClient = useQueryClient() - const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) - const { data: userProfileResp } = useSuspenseQuery(userProfileQueryOptions()) - const currentWorkspaceQuery = useQuery(consoleQuery.workspaces.current.post.queryOptions({ - select: normalizeCurrentWorkspace, - })) - const workspacePermissionKeysQuery = useWorkspacePermissionKeys() - const langGeniusVersionQuery = useLangGeniusVersion( - userProfileResp?.meta.currentVersion, - !systemFeatures.branding.enabled, - ) + const userProfile = useAtomValue(userProfileAtom) + const currentWorkspace = useAtomValue(currentWorkspaceAtom) + const roleFlags = useAtomValue(workspaceRoleFlagsAtom) + const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) + const langGeniusVersionInfo = useAtomValue(langGeniusVersionInfoAtom) + const isLoadingCurrentWorkspace = useAtomValue(currentWorkspaceLoadingAtom) + const isValidatingCurrentWorkspace = useAtomValue(currentWorkspaceValidatingAtom) + const isLoadingWorkspacePermissionKeys = useAtomValue(workspacePermissionKeysLoadingAtom) - const userProfile = useMemo(() => userProfileResp?.profile || userProfilePlaceholder, [userProfileResp?.profile]) - const currentWorkspace = currentWorkspaceQuery.data ?? initialWorkspaceInfo - const langGeniusVersionInfo = useMemo(() => { - if (!userProfileResp?.meta?.currentVersion || !langGeniusVersionQuery.data) - return initialLangGeniusVersionInfo + const refreshUserProfile = useSetAtom(refreshUserProfileAtom) + const refreshCurrentWorkspace = useSetAtom(refreshCurrentWorkspaceAtom) - const current_version = userProfileResp.meta.currentVersion - const current_env = userProfileResp.meta.currentEnv || '' - const versionData = langGeniusVersionQuery.data - return { - ...versionData, - current_version, - latest_version: versionData.version, - current_env, - } - }, [langGeniusVersionQuery.data, userProfileResp?.meta]) - - const isCurrentWorkspaceManager = useMemo(() => ['owner', 'admin'].includes(currentWorkspace.role), [currentWorkspace.role]) - const isCurrentWorkspaceOwner = useMemo(() => currentWorkspace.role === 'owner', [currentWorkspace.role]) - const isCurrentWorkspaceEditor = useMemo(() => ['owner', 'admin', 'editor'].includes(currentWorkspace.role), [currentWorkspace.role]) - const isCurrentWorkspaceDatasetOperator = useMemo(() => currentWorkspace.role === 'dataset_operator', [currentWorkspace.role]) - - const mutateUserProfile = useCallback(() => { - queryClient.invalidateQueries({ queryKey: userProfileQueryOptions().queryKey }) - }, [queryClient]) - - const mutateCurrentWorkspace = useCallback(() => { - queryClient.invalidateQueries({ queryKey: consoleQuery.workspaces.current.post.key() }) - }, [queryClient]) - - // #region Zendesk conversation fields - useEffect(() => { - if (ZENDESK_FIELD_IDS.ENVIRONMENT && langGeniusVersionInfo?.current_env) { - setZendeskConversationFields([{ - id: ZENDESK_FIELD_IDS.ENVIRONMENT, - value: langGeniusVersionInfo.current_env.toLowerCase(), - }]) - } - }, [langGeniusVersionInfo?.current_env]) - - useEffect(() => { - if (ZENDESK_FIELD_IDS.VERSION && langGeniusVersionInfo?.version) { - setZendeskConversationFields([{ - id: ZENDESK_FIELD_IDS.VERSION, - value: langGeniusVersionInfo.version, - }]) - } - }, [langGeniusVersionInfo?.version]) - - useEffect(() => { - if (ZENDESK_FIELD_IDS.EMAIL && userProfile?.email) { - setZendeskConversationFields([{ - id: ZENDESK_FIELD_IDS.EMAIL, - value: userProfile.email, - }]) - } - }, [userProfile?.email]) - - useEffect(() => { - if (ZENDESK_FIELD_IDS.WORKSPACE_ID && currentWorkspace?.id) { - setZendeskConversationFields([{ - id: ZENDESK_FIELD_IDS.WORKSPACE_ID, - value: currentWorkspace.id, - }]) - } - }, [currentWorkspace?.id]) - // #endregion Zendesk conversation fields - - useEffect(() => { - // Report user and workspace info to Amplitude when loaded - if (userProfile?.id) { - setUserId(userProfile.email) - const properties: Record = { - email: userProfile.email, - name: userProfile.name, - has_password: userProfile.is_password_set, - } - - if (currentWorkspace?.id) { - properties.workspace_id = currentWorkspace.id - properties.workspace_name = currentWorkspace.name - properties.workspace_plan = currentWorkspace.plan - properties.workspace_status = currentWorkspace.status - properties.workspace_role = currentWorkspace.role - } - - setUserProperties(properties) - - // The user ID is now attached, so replay any registration success event captured - // at signup time. This makes it land on the identified Amplitude profile instead - // of an anonymous one (no-op when nothing was deferred). - flushRegistrationSuccess() - } - }, [userProfile, currentWorkspace]) + useSyncZendeskFields() + useSyncAmplitudeIdentity() return ( { + refreshUserProfile() + }, langGeniusVersionInfo, useSelector, currentWorkspace, - isCurrentWorkspaceManager, - isCurrentWorkspaceOwner, - isCurrentWorkspaceEditor, - isCurrentWorkspaceDatasetOperator, - mutateCurrentWorkspace, - isLoadingCurrentWorkspace: currentWorkspaceQuery.isPending, - isLoadingWorkspacePermissionKeys: workspacePermissionKeysQuery.isPending, - isValidatingCurrentWorkspace: currentWorkspaceQuery.isFetching, - workspacePermissionKeys: workspacePermissionKeysQuery.data?.workspace.permission_keys ?? emptyWorkspacePermissionKeys, + ...roleFlags, + mutateCurrentWorkspace: () => { + refreshCurrentWorkspace() + }, + isLoadingCurrentWorkspace, + isLoadingWorkspacePermissionKeys, + isValidatingCurrentWorkspace, + workspacePermissionKeys, }} > {children} diff --git a/web/context/app-context-state.ts b/web/context/app-context-state.ts new file mode 100644 index 00000000000..780138f4d0f --- /dev/null +++ b/web/context/app-context-state.ts @@ -0,0 +1,116 @@ +'use client' + +import type { GetAccountProfileResponse } from '@dify/contracts/api/console/account/types.gen' +import type { GetSystemFeaturesResponse } from '@dify/contracts/api/console/system-features/types.gen' +import type { DefinedQueryObserverResult } from '@tanstack/react-query' +import type { UserProfileWithMeta } from '@/features/account-profile/client' +import { atom } from 'jotai' +import { atomWithQuery, atomWithSuspenseQuery, queryClientAtom } from 'jotai-tanstack-query' +import { userProfileQueryOptions } from '@/features/account-profile/client' +import { systemFeaturesQueryOptions } from '@/features/system-features/client' +import { workspacePermissionKeysQueryOptions } from '@/service/access-control/use-permission-keys' +import { consoleQuery } from '@/service/client' +import { langGeniusVersionQueryOptions } from '@/service/lang-genius-version' +import { + initialLangGeniusVersionInfo, + initialWorkspaceInfo, + userProfilePlaceholder, +} from './app-context' +import { + emptyWorkspacePermissionKeys, + getLangGeniusVersionInfo, + getWorkspaceRoleFlags, + normalizeCurrentWorkspace, +} from './app-context-normalizers' + +type SuspenseQueryResult = Omit, 'isPlaceholderData'> + +const accountProfileQueryAtom = atomWithSuspenseQuery(() => userProfileQueryOptions()) + +const systemFeaturesQueryAtom = atomWithSuspenseQuery(() => systemFeaturesQueryOptions()) + +export const userProfileAtom = atom((get): GetAccountProfileResponse => { + const accountProfileQuery = get(accountProfileQueryAtom) as SuspenseQueryResult + + return accountProfileQuery.data?.profile || userProfilePlaceholder +}) + +const profileMetaAtom = atom((get) => { + const accountProfileQuery = get(accountProfileQueryAtom) as SuspenseQueryResult + + return accountProfileQuery.data?.meta ?? { + currentVersion: null, + currentEnv: null, + } +}) + +const currentWorkspaceQueryAtom = atomWithQuery(() => { + return consoleQuery.workspaces.current.post.queryOptions({ + select: normalizeCurrentWorkspace, + }) +}) + +const normalizedCurrentWorkspaceAtom = atom((get) => { + return get(currentWorkspaceQueryAtom).data ?? initialWorkspaceInfo +}) + +export const currentWorkspaceAtom = atom((get) => { + return get(normalizedCurrentWorkspaceAtom) +}) + +export const workspaceRoleFlagsAtom = atom((get) => { + return getWorkspaceRoleFlags(get(currentWorkspaceAtom)) +}) + +const workspacePermissionKeysQueryAtom = atomWithQuery((get) => { + const workspaceId = get(currentWorkspaceAtom).id + + return workspacePermissionKeysQueryOptions(workspaceId) +}) + +export const workspacePermissionKeysAtom = atom((get) => { + return get(workspacePermissionKeysQueryAtom).data?.workspace.permission_keys ?? emptyWorkspacePermissionKeys +}) + +export const workspacePermissionKeysLoadingAtom = atom((get) => { + return get(workspacePermissionKeysQueryAtom).isPending +}) + +export const currentWorkspaceLoadingAtom = atom((get) => { + return get(currentWorkspaceQueryAtom).isPending +}) + +export const currentWorkspaceValidatingAtom = atom((get) => { + return get(currentWorkspaceQueryAtom).isFetching +}) + +const versionQueryAtom = atomWithQuery((get) => { + const meta = get(profileMetaAtom) + const systemFeaturesQuery = get(systemFeaturesQueryAtom) as SuspenseQueryResult + const enabled = Boolean(meta.currentVersion && !systemFeaturesQuery.data?.branding.enabled) + + return langGeniusVersionQueryOptions(meta.currentVersion, enabled) +}) + +export const langGeniusVersionInfoAtom = atom((get) => { + const meta = get(profileMetaAtom) + const versionData = get(versionQueryAtom).data + + if (!versionData) + return initialLangGeniusVersionInfo + + return getLangGeniusVersionInfo({ + meta, + versionData, + }) +}) + +export const refreshUserProfileAtom = atom(null, (get) => { + const queryClient = get(queryClientAtom) + queryClient.invalidateQueries({ queryKey: userProfileQueryOptions().queryKey }) +}) + +export const refreshCurrentWorkspaceAtom = atom(null, (get) => { + const queryClient = get(queryClientAtom) + queryClient.invalidateQueries({ queryKey: consoleQuery.workspaces.current.post.key() }) +}) diff --git a/web/service/access-control/__tests__/use-permission-keys.spec.tsx b/web/service/access-control/__tests__/use-permission-keys.spec.tsx index 1849a48a5ed..428dbfe3ad9 100644 --- a/web/service/access-control/__tests__/use-permission-keys.spec.tsx +++ b/web/service/access-control/__tests__/use-permission-keys.spec.tsx @@ -1,26 +1,13 @@ -import type { ReactNode } from 'react' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { renderHook, waitFor } from '@testing-library/react' +import { QueryClient } from '@tanstack/react-query' +// eslint-disable-next-line no-restricted-imports import { get } from '@/service/base' -import { useWorkspacePermissionKeys } from '../use-permission-keys' +import { workspacePermissionKeysQueryOptions } from '../use-permission-keys' vi.mock('@/service/base', () => ({ get: vi.fn(), })) -const createWrapper = () => { - const queryClient = new QueryClient({ - defaultOptions: { - queries: { retry: false }, - }, - }) - - return ({ children }: { children: ReactNode }) => ( - {children} - ) -} - -describe('useWorkspacePermissionKeys', () => { +describe('workspacePermissionKeysQueryOptions', () => { beforeEach(() => { vi.clearAllMocks() vi.mocked(get).mockResolvedValue({ @@ -33,11 +20,15 @@ describe('useWorkspacePermissionKeys', () => { // Current-user permissions come from the my-permissions RBAC endpoint. describe('Queries', () => { it('should fetch workspace permission keys', async () => { - renderHook(() => useWorkspacePermissionKeys(), { wrapper: createWrapper() }) - - await waitFor(() => { - expect(get).toHaveBeenCalledWith('/workspaces/current/rbac/my-permissions') + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, }) + + await queryClient.fetchQuery(workspacePermissionKeysQueryOptions()) + + expect(get).toHaveBeenCalledWith('/workspaces/current/rbac/my-permissions') }) }) }) diff --git a/web/service/access-control/use-permission-keys.ts b/web/service/access-control/use-permission-keys.ts index af19cf3e840..9953b904ce7 100644 --- a/web/service/access-control/use-permission-keys.ts +++ b/web/service/access-control/use-permission-keys.ts @@ -1,12 +1,18 @@ import type { PermissionKeysResponse } from '@/models/access-control' -import { useQuery } from '@tanstack/react-query' +import { queryOptions } from '@tanstack/react-query' +// eslint-disable-next-line no-restricted-imports import { get } from '../base' const NAME_SPACE = 'workspace-permission-keys' -export const useWorkspacePermissionKeys = () => { - return useQuery({ - queryKey: [NAME_SPACE], +const workspacePermissionKeysQueryKey = (workspaceId?: string) => { + return workspaceId ? [NAME_SPACE, workspaceId] as const : [NAME_SPACE] as const +} + +export const workspacePermissionKeysQueryOptions = (workspaceId?: string) => { + return queryOptions({ + queryKey: workspacePermissionKeysQueryKey(workspaceId), queryFn: () => get('/workspaces/current/rbac/my-permissions'), + enabled: workspaceId === undefined || Boolean(workspaceId), }) } diff --git a/web/service/lang-genius-version.ts b/web/service/lang-genius-version.ts new file mode 100644 index 00000000000..6799fcd1ad4 --- /dev/null +++ b/web/service/lang-genius-version.ts @@ -0,0 +1,13 @@ +import type { LangGeniusVersionResponse } from '@/models/common' +import { queryOptions } from '@tanstack/react-query' +// eslint-disable-next-line no-restricted-imports +import { get } from './base' +import { commonQueryKeys } from './use-common' + +export const langGeniusVersionQueryOptions = (currentVersion?: string | null, enabled?: boolean) => { + return queryOptions({ + queryKey: commonQueryKeys.langGeniusVersion(currentVersion || undefined), + queryFn: () => get('/version', { params: { current_version: currentVersion } }), + enabled: !!currentVersion && (enabled ?? true), + }) +} diff --git a/web/service/use-common.ts b/web/service/use-common.ts index 36644a3ef37..f02d1f403dd 100644 --- a/web/service/use-common.ts +++ b/web/service/use-common.ts @@ -10,13 +10,13 @@ import type { CodeBasedExtension, CommonResponse, FileUploadConfigResponse, - LangGeniusVersionResponse, Member, StructuredOutputRulesRequestBody, StructuredOutputRulesResponse, } from '@/models/common' import type { RETRIEVE_METHOD } from '@/types/app' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +// eslint-disable-next-line no-restricted-imports import { get, post } from './base' const NAME_SPACE = 'common' @@ -54,14 +54,6 @@ export const useFileUploadConfig = () => { }) } -export const useLangGeniusVersion = (currentVersion?: string | null, enabled?: boolean) => { - return useQuery({ - queryKey: commonQueryKeys.langGeniusVersion(currentVersion || undefined), - queryFn: () => get('/version', { params: { current_version: currentVersion } }), - enabled: !!currentVersion && (enabled ?? true), - }) -} - export const useGenerateStructuredOutputRules = () => { return useMutation({ mutationKey: [NAME_SPACE, 'generate-structured-output-rules'], @@ -95,7 +87,7 @@ export const useMailValidity = () => { }) } -export type MailRegisterResponse = { result: string, data: {} } +export type MailRegisterResponse = { result: string, data: Record } export const useMailRegister = () => { return useMutation({ @@ -149,7 +141,7 @@ export const useFilePreview = (fileID: string) => { export type SchemaTypeDefinition = { name: string schema: { - properties: Record + properties: Record } }