diff --git a/.superpowers/sdd/hitl-timeout-semantics-impl-report.md b/.superpowers/sdd/hitl-timeout-semantics-impl-report.md deleted file mode 100644 index ba36a15d7dc..00000000000 --- a/.superpowers/sdd/hitl-timeout-semantics-impl-report.md +++ /dev/null @@ -1,30 +0,0 @@ -# HITL timeout semantics implementation report - -## What changed - -- Updated `api/core/workflow/nodes/human_input/callback.py` so `DifyHITLCallback` now preserves Dify's timeout split at the boundary: - - `HumanInputFormStatus.TIMEOUT` returns the graphon timeout branch via `Expired(selected_handle="__timeout__", ...)`. - - `HumanInputFormStatus.EXPIRED` is treated as an invalid resume state and raises `AssertionError`. - - `HumanInputFormStatus.WAITING` with a past global deadline is treated as an invalid resume state and raises `AssertionError`. - - `HumanInputFormStatus.WAITING` with only the node-level deadline expired still returns the timeout branch. -- Added `created_at` to `HumanInputFormEntity` and `_HumanInputFormEntityImpl` so the callback can compute the global deadline using Dify's shared `HUMAN_INPUT_GLOBAL_TIMEOUT_SECONDS` invariant. -- Kept the submitted and pause flows unchanged. -- Added focused unit coverage in `api/tests/unit_tests/core/workflow/test_human_input_callback.py` for: - - node timeout branch - - global expiration rejection - - waiting-form past node deadline timeout - - waiting-form past global deadline rejection - -## Verification - -- `uv run --project api pytest -o addopts='' api/tests/unit_tests/core/workflow/test_human_input_callback.py api/tests/unit_tests/core/workflow/nodes/human_input/test_human_input_form_filled_event.py -q` -- `git diff --check` - -## Result - -- The focused test set is expected to pass with the new `created_at` boundary in place. -- No unrelated files were modified. - -## Concerns - -- The callback now fails fast on invalid resume states by design. That is intentional, but any caller that previously relied on `EXPIRED` being mapped to the timeout branch will now see an assertion failure instead. diff --git a/api/tests/unit_tests/enterprise/telemetry/test_enterprise_trace.py b/api/tests/unit_tests/enterprise/telemetry/test_enterprise_trace.py index 24c905c75c2..29ddf73e24d 100644 --- a/api/tests/unit_tests/enterprise/telemetry/test_enterprise_trace.py +++ b/api/tests/unit_tests/enterprise/telemetry/test_enterprise_trace.py @@ -3,8 +3,9 @@ from __future__ import annotations import json +import logging from datetime import UTC, datetime -from typing import Any +from typing import Any, cast from unittest.mock import MagicMock, patch import pytest @@ -475,13 +476,24 @@ class TestWorkflowTrace: assert span_call[1]["start_time"] == _T0 assert span_call[1]["end_time"] == _T1 - def test_emits_companion_log_with_event_name(self, trace_handler: EnterpriseOtelTrace, mock_exporter): - with patch("enterprise.telemetry.enterprise_trace.emit_telemetry_log") as mock_log: + def test_emits_companion_log_with_event_name( + self, + trace_handler: EnterpriseOtelTrace, + caplog: pytest.LogCaptureFixture, + ): + with caplog.at_level(logging.INFO, logger="dify.telemetry"): trace_handler._workflow_trace(make_workflow_info()) - mock_log.assert_called_once() - assert mock_log.call_args[1]["event_name"] == EnterpriseTelemetryEvent.WORKFLOW_RUN - assert mock_log.call_args[1]["tenant_id"] == "tenant-abc" + records = [record for record in caplog.records if record.name == "dify.telemetry"] + + assert len(records) == 1 + record = records[0] + + attrs = cast(dict[str, Any], record.__dict__["attributes"]) + + assert attrs["dify.event.name"] == EnterpriseTelemetryEvent.WORKFLOW_RUN + assert attrs["dify.event.signal"] == "span_detail" + assert record.__dict__["tenant_id"] == "tenant-abc" def test_companion_log_includes_content_when_enabled(self, trace_handler: EnterpriseOtelTrace, mock_exporter): mock_exporter.include_content = True diff --git a/cli/src/auth/hosts.test.ts b/cli/src/auth/hosts.test.ts index 112538ccbb9..337b785a4c4 100644 --- a/cli/src/auth/hosts.test.ts +++ b/cli/src/auth/hosts.test.ts @@ -113,6 +113,21 @@ describe('Registry (pure)', () => { expect(active?.ctx.account.email).toBe('a@x') }) + it('resolveActive returns the active context with insecureTls', () => { + const reg = baseReg() + reg.upsert('h1', 'a@x', ctx('a@x')) + reg.setInsecureTls('h1', true) + reg.setHost('h1') + reg.setAccount('a@x') + expect(reg.resolveActive()?.insecureTls).toBe(true) + }) + + it('setInsecureTls is a no-op for an unknown host', () => { + const reg = baseReg() + reg.setInsecureTls('missing', true) + expect(reg.hosts.missing).toBeUndefined() + }) + it('resolveActive returns undefined for each missing pointer', () => { const reg = baseReg() expect(reg.resolveActive()).toBeUndefined() diff --git a/cli/src/auth/hosts.ts b/cli/src/auth/hosts.ts index 29305db951e..5df8cdabdbe 100644 --- a/cli/src/auth/hosts.ts +++ b/cli/src/auth/hosts.ts @@ -41,6 +41,7 @@ export type AccountContext = z.infer export const HostEntrySchema = z.object({ scheme: z.string().optional(), + insecure_tls: z.boolean().optional(), current_account: z.string().optional(), accounts: z.record(z.string(), AccountContextSchema).default({}), }) @@ -58,6 +59,7 @@ export type ActiveContext = { readonly email: string readonly ctx: AccountContext readonly scheme?: string + readonly insecureTls?: boolean } export function notLoggedInError(hint = 'run \'difyctl auth login\''): BaseError { @@ -104,7 +106,7 @@ export class Registry { const ctx = entry.accounts[email] if (ctx === undefined) return undefined - return { host, email, ctx, scheme: entry.scheme } + return { host, email, ctx, scheme: entry.scheme, insecureTls: entry.insecure_tls } } requireActive(hint?: string): ActiveContext { @@ -157,6 +159,12 @@ export class Registry { entry.scheme = scheme } + setInsecureTls(host: string, insecure: boolean): void { + const entry = this.data.hosts[host] + if (entry !== undefined) + entry.insecure_tls = insecure + } + activate(host: string, email: string, ctx: AccountContext): void { this.upsert(host, email, ctx) this.setHost(host) diff --git a/cli/src/commands/_shared/authed-command.ts b/cli/src/commands/_shared/authed-command.ts index 1b0d6803180..d40b9342041 100644 --- a/cli/src/commands/_shared/authed-command.ts +++ b/cli/src/commands/_shared/authed-command.ts @@ -13,7 +13,7 @@ import { formatErrorForCli } from '@/errors/format' import { createHttpClient } from '@/http/client' import { getTokenStore } from '@/store/manager' import { realStreams } from '@/sys/io/streams' -import { hostWithScheme, openAPIBase } from '@/util/host' +import { activeHostInfo, openAPIBase } from '@/util/host' import { enforceDifyVersion } from '@/version/enforce' import { versionInfo } from '@/version/info' import { maybeNudgeCompat } from '@/version/nudge' @@ -50,17 +50,17 @@ export async function buildAuthedContext( if (bearer === '') fail(cmd, opts, io) - const host = hostWithScheme(active.host, active.scheme) + const { host, insecure } = activeHostInfo(active) const retryAttempts = resolveRetryAttempts({ flag: opts.retryFlag, env: getEnv }) - const http = createHttpClient({ baseURL: openAPIBase(host), bearer, retryAttempts }) + const http = createHttpClient({ baseURL: openAPIBase(host), bearer, retryAttempts, insecure }) const cache = opts.withCache === true ? await loadAppInfoCache() : undefined // Hard gate: refuse a server too old for this difyctl (throws → exit 6). // Cached per host (1h) so most commands don't re-probe. Then the soft nudge // handles the "server too new" direction. - await enforceDifyVersion(host) - await runCompatNudge({ host, io }) + await enforceDifyVersion(host, { insecure }) + await runCompatNudge({ host, insecure, io }) return { reg, active, store, http, host, io, cache } } @@ -74,6 +74,7 @@ function fail(cmd: Pick, opts: AuthedContextOptions, io: IOStr // command flows through it without per-command wiring. async function runCompatNudge(opts: { readonly host: string + readonly insecure: boolean readonly io: IOStreams }): Promise { try { @@ -81,7 +82,7 @@ async function runCompatNudge(opts: { await maybeNudgeCompat(opts.host, { store, probe: async (host) => { - const http = createHttpClient({ baseURL: openAPIBase(host), timeoutMs: META_PROBE_TIMEOUT_MS, retryAttempts: 0 }) + const http = createHttpClient({ baseURL: openAPIBase(host), timeoutMs: META_PROBE_TIMEOUT_MS, retryAttempts: 0, insecure: opts.insecure }) return new MetaClient(http).serverVersion() }, emit: line => opts.io.err.write(line), diff --git a/cli/src/commands/auth/login/index.ts b/cli/src/commands/auth/login/index.ts index 214f38c3697..0b702262932 100644 --- a/cli/src/commands/auth/login/index.ts +++ b/cli/src/commands/auth/login/index.ts @@ -27,7 +27,7 @@ export default class Login extends DifyCommand { default: false, }), 'insecure': Flags.boolean({ - description: 'allow http:// hosts (local-dev only)', + description: 'allow http:// hosts and skip TLS certificate verification (local-dev only)', default: false, }), } @@ -40,7 +40,7 @@ export default class Login extends DifyCommand { noBrowser: flags['no-browser'], insecure: flags.insecure, verifyServer: async (host) => { - await enforceDifyVersion(host, { forceFresh: true }) + await enforceDifyVersion(host, { forceFresh: true, insecure: flags.insecure }) }, }) } diff --git a/cli/src/commands/auth/login/login.ts b/cli/src/commands/auth/login/login.ts index f0eb14dc8e1..7777f8c7c18 100644 --- a/cli/src/commands/auth/login/login.ts +++ b/cli/src/commands/auth/login/login.ts @@ -44,7 +44,7 @@ export async function runLogin(opts: LoginOptions): Promise { const host = await resolveLoginHost(opts, insecure) const label = opts.deviceLabel ?? defaultDeviceLabel() - const api = opts.api ?? new DeviceFlowApi(createHttpClient({ baseURL: openAPIBase(host) })) + const api = opts.api ?? new DeviceFlowApi(createHttpClient({ baseURL: openAPIBase(host), insecure })) const code = await api.requestCode({ device_label: label }) renderCodePrompt(opts.io.err, cs, code) @@ -88,6 +88,7 @@ export async function runLogin(opts: LoginOptions): Promise { reg.token_storage = storeBundle.mode reg.activate(display, email, ctx) applyScheme(reg, display, host) + reg.setInsecureTls(display, insecure) await reg.save() renderLoggedIn(opts.io.out, cs, host, success) diff --git a/cli/src/commands/auth/logout/index.ts b/cli/src/commands/auth/logout/index.ts index 6476b1726e8..786d525c748 100644 --- a/cli/src/commands/auth/logout/index.ts +++ b/cli/src/commands/auth/logout/index.ts @@ -6,7 +6,7 @@ import { createHttpClient } from '@/http/client' import { getTokenStore } from '@/store/manager' import { runWithSpinner } from '@/sys/io/spinner' import { realStreams } from '@/sys/io/streams' -import { hostWithScheme, openAPIBase } from '@/util/host' +import { activeHostInfo, openAPIBase } from '@/util/host' import { runLogout } from './logout.js' export default class Logout extends DifyCommand { @@ -32,7 +32,8 @@ export default class Logout extends DifyCommand { } catch { /* keyring locked — skip remote revocation, local cleanup still runs */ } if (bearer !== '') { - http = createHttpClient({ baseURL: openAPIBase(hostWithScheme(active.host, active.scheme)), bearer, retryAttempts: 0 }) + const { host, insecure } = activeHostInfo(active) + http = createHttpClient({ baseURL: openAPIBase(host), bearer, retryAttempts: 0, insecure }) } } diff --git a/cli/src/http/client-tls.test.ts b/cli/src/http/client-tls.test.ts new file mode 100644 index 00000000000..089b677fa97 --- /dev/null +++ b/cli/src/http/client-tls.test.ts @@ -0,0 +1,66 @@ +import type { Buffer } from 'node:buffer' +import type { AddressInfo } from 'node:net' +import { execFileSync } from 'node:child_process' +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import * as https from 'node:https' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { createHttpClient } from './client.js' + +function generateSelfSignedCert(dir: string): { key: Buffer, cert: Buffer } { + const keyPath = join(dir, 'key.pem') + const certPath = join(dir, 'cert.pem') + execFileSync('openssl', [ + 'req', + '-x509', + '-newkey', + 'rsa:2048', + '-nodes', + '-keyout', + keyPath, + '-out', + certPath, + '-days', + '1', + '-subj', + '/CN=localhost', + ], { stdio: ['ignore', 'ignore', 'pipe'] }) + return { key: readFileSync(keyPath), cert: readFileSync(certPath) } +} + +// A real server, not a fetch mock, so this also covers Bun's native `tls` +// fetch option (ignored by Node, which only reads undici's `dispatcher`). +describe('createHttpClient against a real self-signed TLS server', () => { + let server: https.Server + let baseURL: string + let certDir: string + + beforeAll(async () => { + certDir = mkdtempSync(join(tmpdir(), 'difyctl-tls-test-')) + const { key, cert } = generateSelfSignedCert(certDir) + server = https.createServer({ key, cert }, (_req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(JSON.stringify({ ok: true })) + }) + await new Promise(resolve => server.listen(0, resolve)) + const port = (server.address() as AddressInfo).port + baseURL = `https://localhost:${port}/` + }) + + afterAll(async () => { + await new Promise(resolve => server.close(() => resolve())) + rmSync(certDir, { recursive: true, force: true }) + }) + + it('rejects the self-signed cert by default', async () => { + const http = createHttpClient({ baseURL, retryAttempts: 0 }) + await expect(http.get('')).rejects.toBeDefined() + }) + + it('accepts the self-signed cert when insecure: true', async () => { + const http = createHttpClient({ baseURL, retryAttempts: 0, insecure: true }) + const res = await http.get<{ ok: boolean }>('') + expect(res.ok).toBe(true) + }) +}) diff --git a/cli/src/http/client.ts b/cli/src/http/client.ts index 940aee9152b..30f8ca27441 100644 --- a/cli/src/http/client.ts +++ b/cli/src/http/client.ts @@ -38,6 +38,7 @@ type ClientState = { readonly logger: HttpLogger | undefined readonly originalOptions: ClientOptions readonly dispatcher: ReturnType + readonly insecure: boolean } function toArray(value: T | T[] | undefined): T[] { @@ -74,7 +75,8 @@ function compileState(opts: ClientOptions): ClientState { hooks: { onRequest, onResponse, onRequestError, onResponseError }, logger: opts.logger, originalOptions: opts, - dispatcher: proxyDispatcher(), + dispatcher: proxyDispatcher({ insecure: opts.insecure }), + insecure: opts.insecure ?? false, } } @@ -171,9 +173,16 @@ async function execute( await runHooks(state.hooks.onRequest, ctx) - const init: RequestInit & { dispatcher?: unknown, verbose?: boolean } = { signal } + // Two runtimes, two options: Node's fetch reads undici's `dispatcher` (used + // below for TLS-skip + proxy routing); Bun's native fetch — what the compiled + // difyctl binary actually runs on — ignores `dispatcher` entirely and instead + // needs its own `tls` option. Set both; each runtime ignores the one it + // doesn't understand. + const init: RequestInit & { dispatcher?: unknown, tls?: { rejectUnauthorized: boolean }, verbose?: boolean } = { signal } if (state.dispatcher !== undefined) init.dispatcher = state.dispatcher + if (state.insecure) + init.tls = { rejectUnauthorized: false } if (isVerbose()) init.verbose = true diff --git a/cli/src/http/proxy.test.ts b/cli/src/http/proxy.test.ts index 80b36d0f295..2c1f386f320 100644 --- a/cli/src/http/proxy.test.ts +++ b/cli/src/http/proxy.test.ts @@ -52,4 +52,28 @@ describe('proxyDispatcher', () => { expect(proxyDispatcher()).toBe(first) await first?.close() }) + + it('builds a plain Agent with TLS verification disabled when insecure is set and no proxy env', async () => { + const { proxyDispatcher } = await import('./proxy.js') + const d = proxyDispatcher({ insecure: true }) + expect(d?.constructor.name).toBe('Agent') + await d?.close() + }) + + it('builds an EnvHttpProxyAgent with TLS verification disabled when insecure and a proxy are both set', async () => { + process.env.HTTP_PROXY = 'http://127.0.0.1:8888' + const { proxyDispatcher } = await import('./proxy.js') + const d = proxyDispatcher({ insecure: true }) + expect(d?.constructor.name).toBe('EnvHttpProxyAgent') + await d?.close() + }) + + it('re-resolves when the insecure flag changes for the same call site', async () => { + const { proxyDispatcher } = await import('./proxy.js') + const secure = proxyDispatcher({ insecure: false }) + expect(secure).toBeUndefined() + const insecure = proxyDispatcher({ insecure: true }) + expect(insecure?.constructor.name).toBe('Agent') + await insecure?.close() + }) }) diff --git a/cli/src/http/proxy.ts b/cli/src/http/proxy.ts index 5e58936f91a..bd9a745c922 100644 --- a/cli/src/http/proxy.ts +++ b/cli/src/http/proxy.ts @@ -1,4 +1,5 @@ -import { EnvHttpProxyAgent } from 'undici' +import type { Dispatcher } from 'undici' +import { Agent, EnvHttpProxyAgent } from 'undici' const PROXY_ENV_KEYS = ['HTTP_PROXY', 'http_proxy', 'HTTPS_PROXY', 'https_proxy'] as const @@ -6,18 +7,30 @@ export function hasProxyEnv(): boolean { return PROXY_ENV_KEYS.some(k => (process.env[k] ?? '') !== '') } -let resolved = false -let agent: EnvHttpProxyAgent | undefined +export type ProxyDispatcherOptions = { + // --insecure on a self-signed https:// host: skip certificate verification + // (local-dev only, same flag that allows plain http:// hosts). + readonly insecure?: boolean +} + +let resolvedKey: string | undefined +let agent: Dispatcher | undefined // Node's global fetch ignores HTTP_PROXY / HTTPS_PROXY / NO_PROXY. When a proxy // var is set, route requests through an EnvHttpProxyAgent (it also reads the -// lowercase variants and honours NO_PROXY); when none is set, return undefined so -// fetch keeps Node's default global dispatcher untouched. Resolved once per -// process — proxy env vars are fixed for a single CLI invocation. -export function proxyDispatcher(): EnvHttpProxyAgent | undefined { - if (!resolved) { - agent = hasProxyEnv() ? new EnvHttpProxyAgent() : undefined - resolved = true +// lowercase variants and honours NO_PROXY); when none is set and TLS verification +// isn't being skipped, return undefined so fetch keeps Node's default global +// dispatcher untouched. Resolved once per (proxy env, insecure) combination — +// both are fixed for a single CLI invocation. +export function proxyDispatcher(opts: ProxyDispatcherOptions = {}): Dispatcher | undefined { + const insecure = opts.insecure ?? false + const key = `${hasProxyEnv()}:${insecure}` + if (resolvedKey !== key) { + const tls = insecure ? { rejectUnauthorized: false } : undefined + agent = hasProxyEnv() + ? new EnvHttpProxyAgent(tls !== undefined ? { connect: tls, requestTls: tls, proxyTls: tls } : undefined) + : (tls !== undefined ? new Agent({ connect: tls }) : undefined) + resolvedKey = key } return agent } diff --git a/cli/src/http/types.ts b/cli/src/http/types.ts index d209e97460c..a022a253296 100644 --- a/cli/src/http/types.ts +++ b/cli/src/http/types.ts @@ -75,6 +75,8 @@ export type ClientOptions = { readonly retryAttempts?: number readonly logger?: HttpLogger readonly hooks?: Hooks + // Skip TLS certificate verification (local-dev only, self-signed hosts). + readonly insecure?: boolean } export type HttpClient = { diff --git a/cli/src/util/host.ts b/cli/src/util/host.ts index 1042b0875ea..453a68149ad 100644 --- a/cli/src/util/host.ts +++ b/cli/src/util/host.ts @@ -1,3 +1,4 @@ +import type { ActiveContext } from '@/auth/hosts' import { BaseError } from '@/errors/base' import { ErrorCode } from '@/errors/codes' @@ -44,6 +45,16 @@ export function hostWithScheme(host: string, scheme: string | undefined): string return `${proto}://${host}` } +// Every call site that builds an HTTP client for the active host needs both its +// scheme-qualified host and whether TLS verification is disabled for it — derive +// them together so neither is forgotten independently. +export function activeHostInfo(active: Pick): { host: string, insecure: boolean } { + return { + host: hostWithScheme(active.host, active.scheme), + insecure: active.insecureTls === true, + } +} + export function bareHost(raw: string): string { try { const u = new URL(raw) diff --git a/cli/src/version/enforce.ts b/cli/src/version/enforce.ts index d1b9fe87da9..8c995a912d1 100644 --- a/cli/src/version/enforce.ts +++ b/cli/src/version/enforce.ts @@ -16,15 +16,18 @@ const UPGRADE_HINT + '(https://docs.dify.ai/en/getting-started/install-self-hosted)' // /_version is unauthenticated; same timeout/no-retry budget as the auto-nudge probe. -const defaultProbe: ServerVersionProbe = async (host) => { - const http = createHttpClient({ baseURL: openAPIBase(host), timeoutMs: META_PROBE_TIMEOUT_MS, retryAttempts: 0 }) - return new MetaClient(http).serverVersion() +function buildDefaultProbe(insecure: boolean): ServerVersionProbe { + return async (host) => { + const http = createHttpClient({ baseURL: openAPIBase(host), timeoutMs: META_PROBE_TIMEOUT_MS, retryAttempts: 0, insecure }) + return new MetaClient(http).serverVersion() + } } export type EnforceOptions = { readonly probe?: ServerVersionProbe readonly store?: CompatStore readonly forceFresh?: boolean + readonly insecure?: boolean } /** @@ -45,7 +48,7 @@ export async function enforceDifyVersion( if (opts.forceFresh !== true && store.isFreshCompatible(host)) return undefined - const probe = opts.probe ?? defaultProbe + const probe = opts.probe ?? buildDefaultProbe(opts.insecure === true) let server: ServerVersionResponse try { server = await probe(host) diff --git a/cli/src/version/probe.ts b/cli/src/version/probe.ts index 266910e7c05..05ab3ea84e3 100644 --- a/cli/src/version/probe.ts +++ b/cli/src/version/probe.ts @@ -6,7 +6,7 @@ import { META_PROBE_TIMEOUT_MS, MetaClient } from '@/api/meta' import { Registry } from '@/auth/hosts' import { createHttpClient } from '@/http/client' import { arch, platform } from '@/sys/index' -import { hostWithScheme, openAPIBase } from '@/util/host' +import { activeHostInfo, openAPIBase } from '@/util/host' import { difyCompat, evaluateCompat } from './compat.js' import { versionInfo } from './info.js' @@ -51,9 +51,11 @@ const defaultLoadActive = async (): Promise => { return (await Registry.load()).resolveActive() } -const defaultProbe: MetaProbe = async (endpoint) => { - const http = createHttpClient({ baseURL: openAPIBase(endpoint), timeoutMs: META_PROBE_TIMEOUT_MS, retryAttempts: 0 }) - return new MetaClient(http).serverVersion() +function buildDefaultProbe(insecure: boolean): MetaProbe { + return async (endpoint) => { + const http = createHttpClient({ baseURL: openAPIBase(endpoint), timeoutMs: META_PROBE_TIMEOUT_MS, retryAttempts: 0, insecure }) + return new MetaClient(http).serverVersion() + } } function buildClientBlock(): ClientBlock { @@ -92,7 +94,6 @@ export async function runVersionProbe(opts: RunVersionProbeOptions): Promise ({ + mockAppContextState: { + userProfile: { + id: 'user-1', + name: 'Ada Lovelace', + email: 'ada@example.com', + avatar: '', + avatar_url: '', + is_password_set: true, + }, + langGeniusVersionInfo: { + current_env: 'CLOUD', + current_version: '1.0.0', + latest_version: '1.1.0', + release_date: '', + release_notes: 'https://example.com/releases/1.1.0', + version: '1.0.0', + can_auto_update: false, + }, + isCurrentWorkspaceOwner: false, + }, mockPush: vi.fn(), mockLogout: vi.fn(), mockResetUser: vi.fn(), @@ -28,21 +49,19 @@ vi.mock('react-i18next', () => ({ })) vi.mock('@/context/app-context', () => ({ - useAppContext: () => ({ - userProfile: { - name: 'Ada Lovelace', - email: 'ada@example.com', - avatar_url: '', - }, - langGeniusVersionInfo: { - current_version: '1.0.0', - latest_version: '1.1.0', - release_notes: 'https://example.com/releases/1.1.0', - }, - isCurrentWorkspaceOwner: false, - }), + useAppContext: () => mockAppContextState, })) +vi.mock('@/context/app-context-state', async (importOriginal) => { + const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateAtomMock(importOriginal, () => mockAppContextState) +}) + +vi.mock('jotai', async (importOriginal) => { + const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateJotaiMock(importOriginal) +}) + vi.mock('@/context/provider-context', () => ({ useProviderContext: () => ({ isEducationAccount: false, @@ -62,7 +81,8 @@ vi.mock('@/context/i18n', () => ({ useDocLink: () => (path: string) => `https://docs.example.com${path}`, })) -vi.mock('@/service/use-common', () => ({ +vi.mock('@/service/use-common', async importOriginal => ({ + ...await importOriginal(), useLogout: () => ({ mutateAsync: mockLogout, }), diff --git a/web/__tests__/utils/mock-app-context-state.ts b/web/__tests__/utils/mock-app-context-state.ts index 5be87eee447..d41dc9a2b47 100644 --- a/web/__tests__/utils/mock-app-context-state.ts +++ b/web/__tests__/utils/mock-app-context-state.ts @@ -13,6 +13,7 @@ export type AppContextStateMockState = { } | null currentWorkspace?: { id?: string + name?: string } | null isCurrentWorkspaceManager?: boolean isCurrentWorkspaceOwner?: boolean @@ -22,6 +23,8 @@ export type AppContextStateMockState = { isLoadingWorkspacePermissionKeys?: boolean workspacePermissionKeys?: string[] langGeniusVersionInfo?: LangGeniusVersionResponse + refreshUserProfile?: () => void + refreshCurrentWorkspace?: () => void } type AppContextStateAtomKind @@ -32,11 +35,14 @@ type AppContextStateAtomKind | 'currentWorkspaceId' | 'workspaceRoleFlags' | 'isCurrentWorkspaceManager' + | 'isCurrentWorkspaceOwner' | 'currentWorkspaceLoading' | 'workspacePermissionKeys' | 'workspacePermissionKeysLoading' | 'langGeniusVersionInfo' | 'langGeniusCurrentVersion' + | 'refreshUserProfile' + | 'refreshCurrentWorkspace' type AppContextStateMockAtom = { [APP_CONTEXT_STATE_ATOM_KIND]: AppContextStateAtomKind @@ -57,6 +63,7 @@ const defaultUserProfile = { const defaultCurrentWorkspace = { id: 'workspace-1', + name: 'Workspace', } const defaultLangGeniusVersionInfo = { @@ -109,11 +116,14 @@ export const createAppContextStateAtomMock = async ( currentWorkspaceIdAtom: createMockAtom('currentWorkspaceId'), workspaceRoleFlagsAtom: createMockAtom('workspaceRoleFlags'), isCurrentWorkspaceManagerAtom: createMockAtom('isCurrentWorkspaceManager'), + isCurrentWorkspaceOwnerAtom: createMockAtom('isCurrentWorkspaceOwner'), currentWorkspaceLoadingAtom: createMockAtom('currentWorkspaceLoading'), workspacePermissionKeysAtom: createMockAtom('workspacePermissionKeys'), workspacePermissionKeysLoadingAtom: createMockAtom('workspacePermissionKeysLoading'), langGeniusVersionInfoAtom: createMockAtom('langGeniusVersionInfo'), langGeniusCurrentVersionAtom: createMockAtom('langGeniusCurrentVersion'), + refreshUserProfileAtom: createMockAtom('refreshUserProfile'), + refreshCurrentWorkspaceAtom: createMockAtom('refreshCurrentWorkspace'), } } @@ -162,6 +172,9 @@ export const createAppContextStateJotaiMock = async ( if (atom[APP_CONTEXT_STATE_ATOM_KIND] === 'isCurrentWorkspaceManager') return state.isCurrentWorkspaceManager ?? false + if (atom[APP_CONTEXT_STATE_ATOM_KIND] === 'isCurrentWorkspaceOwner') + return state.isCurrentWorkspaceOwner ?? false + if (atom[APP_CONTEXT_STATE_ATOM_KIND] === 'currentWorkspaceLoading') return state.isLoadingCurrentWorkspace ?? false @@ -179,5 +192,22 @@ export const createAppContextStateJotaiMock = async ( throw new Error(`Unsupported app context state atom: ${atom[APP_CONTEXT_STATE_ATOM_KIND]}`) }, + useSetAtom: (atom: unknown) => { + if (!isAppContextStateMockAtom(atom)) + return actual.useSetAtom(atom as Parameters[0]) + + if (!appContextStateMockRegistry) + throw new Error('App context state atom mock is not initialized') + + const state = appContextStateMockRegistry.getState() + + if (atom[APP_CONTEXT_STATE_ATOM_KIND] === 'refreshUserProfile') + return state.refreshUserProfile ?? (() => {}) + + if (atom[APP_CONTEXT_STATE_ATOM_KIND] === 'refreshCurrentWorkspace') + return state.refreshCurrentWorkspace ?? (() => {}) + + throw new Error(`Unsupported app context state write atom: ${atom[APP_CONTEXT_STATE_ATOM_KIND]}`) + }, } } diff --git a/web/app/account/(commonLayout)/delete-account/components/check-email.tsx b/web/app/account/(commonLayout)/delete-account/components/check-email.tsx index 7e3351a10a2..b37a4995f02 100644 --- a/web/app/account/(commonLayout)/delete-account/components/check-email.tsx +++ b/web/app/account/(commonLayout)/delete-account/components/check-email.tsx @@ -1,9 +1,10 @@ 'use client' import { Button } from '@langgenius/dify-ui/button' +import { Input } from '@langgenius/dify-ui/input' +import { useAtomValue } from 'jotai' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' -import Input from '@/app/components/base/input' -import { useAppContext } from '@/context/app-context' +import { userProfileEmailAtom } from '@/context/app-context-state' import Link from '@/next/link' import { useSendDeleteAccountEmail } from '../state' @@ -14,7 +15,7 @@ type DeleteAccountProps = { export default function CheckEmail(props: DeleteAccountProps) { const { t } = useTranslation() - const { userProfile } = useAppContext() + const userProfileEmail = useAtomValue(userProfileEmailAtom) const [userInputEmail, setUserInputEmail] = useState('') const { isPending: isSendingEmail, mutateAsync: getDeleteEmailVerifyCode } = useSendDeleteAccountEmail() @@ -45,7 +46,7 @@ export default function CheckEmail(props: DeleteAccountProps) { }} />
- +
diff --git a/web/app/account/(commonLayout)/delete-account/components/feed-back.tsx b/web/app/account/(commonLayout)/delete-account/components/feed-back.tsx index ead73b2319c..697c6df095c 100644 --- a/web/app/account/(commonLayout)/delete-account/components/feed-back.tsx +++ b/web/app/account/(commonLayout)/delete-account/components/feed-back.tsx @@ -3,9 +3,10 @@ import { Button } from '@langgenius/dify-ui/button' import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' import { Textarea } from '@langgenius/dify-ui/textarea' import { toast } from '@langgenius/dify-ui/toast' +import { useAtomValue } from 'jotai' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useAppContext } from '@/context/app-context' +import { userProfileEmailAtom } from '@/context/app-context-state' import { useRouter } from '@/next/navigation' import { useLogout } from '@/service/use-common' import { useDeleteAccountFeedback } from '../state' @@ -17,7 +18,7 @@ type DeleteAccountProps = { export default function FeedBack(props: DeleteAccountProps) { const { t } = useTranslation() - const { userProfile } = useAppContext() + const userProfileEmail = useAtomValue(userProfileEmailAtom) const router = useRouter() const [userFeedback, setUserFeedback] = useState('') const { isPending, mutateAsync: sendFeedback } = useDeleteAccountFeedback() @@ -35,12 +36,12 @@ export default function FeedBack(props: DeleteAccountProps) { const handleSubmit = useCallback(async () => { try { - await sendFeedback({ feedback: userFeedback, email: userProfile.email }) + await sendFeedback({ feedback: userFeedback, email: userProfileEmail }) props.onConfirm() await handleSuccess() } catch (error) { console.error(error) } - }, [handleSuccess, userFeedback, sendFeedback, userProfile, props]) + }, [handleSuccess, userFeedback, sendFeedback, userProfileEmail, props]) const handleSkip = useCallback(() => { props.onCancel() diff --git a/web/app/account/oauth/authorize/layout.tsx b/web/app/account/oauth/authorize/layout.tsx index 053baeca925..339624fe027 100644 --- a/web/app/account/oauth/authorize/layout.tsx +++ b/web/app/account/oauth/authorize/layout.tsx @@ -1,60 +1,40 @@ 'use client' -import { cn } from '@langgenius/dify-ui/cn' -import { useQuery, useSuspenseQuery } from '@tanstack/react-query' +import type { ReactNode } from 'react' +import { useSuspenseQuery } from '@tanstack/react-query' -import Loading from '@/app/components/base/loading' import Header from '@/app/signin/_header' -import { AppContextProvider } from '@/context/app-context-provider' -import { isLegacyBase401, userProfileQueryOptions } from '@/features/account-profile/client' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import useDocumentTitle from '@/hooks/use-document-title' -export default function SignInLayout({ children }: any) { +type Props = { + children: ReactNode +} + +const copyrightYear = new Date().getFullYear() + +export default function OAuthAuthorizeLayout({ children }: Props) { const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) useDocumentTitle('') - // Probe login state. 401 stays as `error` (not thrown) so this layout can render - // the signin/oauth UI for unauthenticated users; other errors bubble to error.tsx. - // (When unauthenticated, service/base.ts's auto-redirect to /signin still fires.) - const { isPending, data: userResp, error } = useQuery({ - ...userProfileQueryOptions(), - throwOnError: err => !isLegacyBase401(err), - }) - const isLoggedIn = !!userResp && !error - if (isPending) { - return ( -
- -
- ) - } return ( - <> -
-
-
-
-
- {isLoggedIn - ? ( - - {children} - - ) - : children} -
+
+
+
+
+
+ {children}
- {systemFeatures.branding.enabled === false && ( -
- © - {' '} - {new Date().getFullYear()} - {' '} - LangGenius, Inc. All rights reserved. -
- )}
+ {systemFeatures.branding.enabled === false && ( +
+ © + {' '} + {copyrightYear} + {' '} + LangGenius, Inc. All rights reserved. +
+ )}
- +
) } diff --git a/web/app/components/header/account-dropdown/__tests__/index.spec.tsx b/web/app/components/header/account-dropdown/__tests__/index.spec.tsx index 741580f6a5c..64065d65e15 100644 --- a/web/app/components/header/account-dropdown/__tests__/index.spec.tsx +++ b/web/app/components/header/account-dropdown/__tests__/index.spec.tsx @@ -43,6 +43,9 @@ vi.mock('@/app/components/base/theme-switcher', () => ({ const { mockSetTheme } = vi.hoisted(() => ({ mockSetTheme: vi.fn(), })) +const mockAppContextState = vi.hoisted(() => ({ + current: undefined as AppContextValue | undefined, +})) vi.mock('next-themes', () => ({ useTheme: () => ({ @@ -55,6 +58,16 @@ vi.mock('@/context/app-context', () => ({ useAppContext: vi.fn(), })) +vi.mock('@/context/app-context-state', async (importOriginal) => { + const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateAtomMock(importOriginal, () => mockAppContextState.current ?? {}) +}) + +vi.mock('jotai', async (importOriginal) => { + const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateJotaiMock(importOriginal) +}) + vi.mock('@/context/provider-context', () => ({ useProviderContext: vi.fn(), })) @@ -63,7 +76,8 @@ vi.mock('@/context/modal-context', () => ({ useModalContext: vi.fn(), })) -vi.mock('@/service/use-common', () => ({ +vi.mock('@/service/use-common', async importOriginal => ({ + ...await importOriginal(), useLogout: vi.fn(), })) @@ -150,6 +164,11 @@ const baseAppContextValue: AppContextValue = { workspacePermissionKeys: [], } +const setAppContextValue = (value: AppContextValue) => { + mockAppContextState.current = value + vi.mocked(useAppContext).mockReturnValue(value) +} + describe('AccountDropdown', () => { const mockPush = vi.fn() const mockLogout = vi.fn() @@ -170,7 +189,7 @@ describe('AccountDropdown', () => { mockConfig.IS_CLOUD_EDITION = false mockEnv.env.NEXT_PUBLIC_SITE_ABOUT = 'show' - vi.mocked(useAppContext).mockReturnValue(baseAppContextValue) + setAppContextValue(baseAppContextValue) vi.mocked(useProviderContext).mockReturnValue({ isEducationAccount: false, plan: { type: Plan.sandbox }, @@ -267,7 +286,7 @@ describe('AccountDropdown', () => { it('should show Compliance in Cloud Edition for workspace owner', () => { // Arrange mockConfig.IS_CLOUD_EDITION = true - vi.mocked(useAppContext).mockReturnValue({ + setAppContextValue({ ...baseAppContextValue, userProfile: { ...baseAppContextValue.userProfile, name: 'User' }, isCurrentWorkspaceOwner: true, @@ -286,7 +305,7 @@ describe('AccountDropdown', () => { it('should hide Compliance in Cloud Edition when user is not workspace owner', () => { // Arrange mockConfig.IS_CLOUD_EDITION = true - vi.mocked(useAppContext).mockReturnValue({ + setAppContextValue({ ...baseAppContextValue, isCurrentWorkspaceOwner: false, }) @@ -382,7 +401,7 @@ describe('AccountDropdown', () => { describe('Version Indicators', () => { it('should show orange indicator when version is not latest', () => { // Arrange - vi.mocked(useAppContext).mockReturnValue({ + setAppContextValue({ ...baseAppContextValue, userProfile: { ...baseAppContextValue.userProfile, name: 'User' }, langGeniusVersionInfo: { @@ -402,7 +421,7 @@ describe('AccountDropdown', () => { it('should show green indicator when version is latest', () => { // Arrange - vi.mocked(useAppContext).mockReturnValue({ + setAppContextValue({ ...baseAppContextValue, userProfile: { ...baseAppContextValue.userProfile, name: 'User' }, langGeniusVersionInfo: { diff --git a/web/app/components/header/account-dropdown/default-menu-content.tsx b/web/app/components/header/account-dropdown/default-menu-content.tsx index bb0aded8e8c..ac5e981d971 100644 --- a/web/app/components/header/account-dropdown/default-menu-content.tsx +++ b/web/app/components/header/account-dropdown/default-menu-content.tsx @@ -10,12 +10,13 @@ import { } from '@langgenius/dify-ui/dropdown-menu' import { StatusDot } from '@langgenius/dify-ui/status-dot' import { useSuspenseQuery } from '@tanstack/react-query' +import { useAtomValue } from 'jotai' import { useTranslation } from 'react-i18next' import PremiumBadge from '@/app/components/base/premium-badge' import ThemeSwitcher from '@/app/components/base/theme-switcher' import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' import { IS_CLOUD_EDITION } from '@/config' -import { useAppContext } from '@/context/app-context' +import { isCurrentWorkspaceOwnerAtom, langGeniusVersionInfoAtom, userProfileAtom } from '@/context/app-context-state' import { useDocLink } from '@/context/i18n' import { useModalContext } from '@/context/modal-context' import { useProviderContext } from '@/context/provider-context' @@ -119,7 +120,9 @@ export function DefaultMenuContent({ const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const { t } = useTranslation() const docLink = useDocLink() - const { userProfile, langGeniusVersionInfo, isCurrentWorkspaceOwner } = useAppContext() + const userProfile = useAtomValue(userProfileAtom) + const langGeniusVersionInfo = useAtomValue(langGeniusVersionInfoAtom) + const isCurrentWorkspaceOwner = useAtomValue(isCurrentWorkspaceOwnerAtom) const { isEducationAccount } = useProviderContext() const { setShowAccountSettingModal } = useModalContext() diff --git a/web/app/components/header/account-dropdown/index.tsx b/web/app/components/header/account-dropdown/index.tsx index dbadfc1f64c..518398c4c62 100644 --- a/web/app/components/header/account-dropdown/index.tsx +++ b/web/app/components/header/account-dropdown/index.tsx @@ -8,11 +8,12 @@ import { DropdownMenuContent, DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' +import { useAtomValue } from 'jotai' import { useState } from 'react' import { useTranslation } from 'react-i18next' import { resetUser } from '@/app/components/base/amplitude/utils' import { useSetEducationExpiredHasNoticed, useSetEducationReverifyHasNoticed, useSetEducationReverifyPrevExpireAt } from '@/app/education-apply/storage' -import { useAppContext } from '@/context/app-context' +import { langGeniusVersionInfoAtom, userProfileAtom } from '@/context/app-context-state' import { useRouter } from '@/next/navigation' import { useLogout } from '@/service/use-common' import AccountAbout from '../account-about' @@ -37,7 +38,8 @@ export default function AppSelector({ const [aboutVisible, setAboutVisible] = useState(false) const [isAccountMenuOpen, setIsAccountMenuOpen] = useState(false) const { t } = useTranslation() - const { userProfile, langGeniusVersionInfo } = useAppContext() + const userProfile = useAtomValue(userProfileAtom) + const langGeniusVersionInfo = useAtomValue(langGeniusVersionInfoAtom) const clearEducationReverifyPrevExpireAt = useSetEducationReverifyPrevExpireAt() const clearEducationReverifyHasNoticed = useSetEducationReverifyHasNoticed() const clearEducationExpiredHasNoticed = useSetEducationExpiredHasNoticed() diff --git a/web/app/components/header/account-dropdown/main-nav-menu-content.tsx b/web/app/components/header/account-dropdown/main-nav-menu-content.tsx index 3ae90caffcb..2faf2ad7f46 100644 --- a/web/app/components/header/account-dropdown/main-nav-menu-content.tsx +++ b/web/app/components/header/account-dropdown/main-nav-menu-content.tsx @@ -16,11 +16,12 @@ import { DropdownMenuSubContent, DropdownMenuSubTrigger, } from '@langgenius/dify-ui/dropdown-menu' +import { useAtomValue } from 'jotai' import { useTheme } from 'next-themes' import { useTranslation } from 'react-i18next' import PremiumBadge from '@/app/components/base/premium-badge' import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' -import { useAppContext } from '@/context/app-context' +import { userProfileAtom } from '@/context/app-context-state' import { useModalContext } from '@/context/modal-context' import { useProviderContext } from '@/context/provider-context' import Link from '@/next/link' @@ -87,7 +88,7 @@ export function MainNavMenuContent({ onLogout, }: MainNavMenuContentProps) { const { t } = useTranslation() - const { userProfile } = useAppContext() + const userProfile = useAtomValue(userProfileAtom) const { isEducationAccount } = useProviderContext() const { setShowAccountSettingModal } = useModalContext() diff --git a/web/app/components/header/account-setting/access-rules-page/__tests__/access-rule-section.spec.tsx b/web/app/components/header/account-setting/access-rules-page/__tests__/access-rule-section.spec.tsx index 35dd91459b6..f2a8fbc8f4d 100644 --- a/web/app/components/header/account-setting/access-rules-page/__tests__/access-rule-section.spec.tsx +++ b/web/app/components/header/account-setting/access-rules-page/__tests__/access-rule-section.spec.tsx @@ -15,6 +15,18 @@ vi.mock('@/context/app-context', () => ({ })), })) +vi.mock('@/context/app-context-state', async (importOriginal) => { + const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateAtomMock(importOriginal, () => ({ + workspacePermissionKeys: mocks.workspacePermissionKeys, + })) +}) + +vi.mock('jotai', async (importOriginal) => { + const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateJotaiMock(importOriginal) +}) + const rule: AccessPolicyWithBindings = { policy: { id: 'app-rule-1', diff --git a/web/app/components/header/account-setting/access-rules-page/access-rule-section.tsx b/web/app/components/header/account-setting/access-rules-page/access-rule-section.tsx index fe1498a64f0..fc5bcbcc4b4 100644 --- a/web/app/components/header/account-setting/access-rules-page/access-rule-section.tsx +++ b/web/app/components/header/account-setting/access-rules-page/access-rule-section.tsx @@ -3,11 +3,12 @@ import type { AccessPolicyWithBindings } from '@/models/access-control' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' +import { useAtomValue } from 'jotai' import { memo, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' import Loading from '@/app/components/base/loading' -import { useSelector as useAppContextWithSelector } from '@/context/app-context' +import { workspacePermissionKeysAtom } from '@/context/app-context-state' import { hasPermission } from '@/utils/permission' import AccessRuleRow from './access-rule-row' @@ -46,7 +47,7 @@ const AccessRuleSection = ({ const [expanded, setExpanded] = useState(defaultExpanded) const listRef = useRef(null) const anchorRef = useRef(null) - const workspacePermissionKeys = useAppContextWithSelector(s => s.workspacePermissionKeys) + const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const canManage = hasPermission(workspacePermissionKeys, 'workspace.role.manage') const ruleCount = totalCount ?? rules.length diff --git a/web/app/components/header/account-setting/api-based-extension-page/__tests__/index.spec.tsx b/web/app/components/header/account-setting/api-based-extension-page/__tests__/index.spec.tsx index af0536fd282..f5f475655e6 100644 --- a/web/app/components/header/account-setting/api-based-extension-page/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/api-based-extension-page/__tests__/index.spec.tsx @@ -24,6 +24,18 @@ vi.mock('@/context/app-context', () => ({ })), })) +vi.mock('@/context/app-context-state', async (importOriginal) => { + const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateAtomMock(importOriginal, () => ({ + workspacePermissionKeys: mockWorkspacePermissionKeys.current, + })) +}) + +vi.mock('jotai', async (importOriginal) => { + const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateJotaiMock(importOriginal) +}) + vi.mock('@/service/client', () => ({ consoleQuery: { apiBasedExtension: { diff --git a/web/app/components/header/account-setting/api-based-extension-page/index.tsx b/web/app/components/header/account-setting/api-based-extension-page/index.tsx index 78faacf711f..45d05fdf875 100644 --- a/web/app/components/header/account-setting/api-based-extension-page/index.tsx +++ b/web/app/components/header/account-setting/api-based-extension-page/index.tsx @@ -2,11 +2,12 @@ import type { ApiBasedExtensionResponse } from '@dify/contracts/api/console/api- import type { ReactNode } from 'react' import { Button } from '@langgenius/dify-ui/button' import { useQuery } from '@tanstack/react-query' +import { useAtomValue } from 'jotai' import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { SearchInput } from '@/app/components/base/search-input' import { SkeletonContainer, SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton' -import { useSelector as useAppContextWithSelector } from '@/context/app-context' +import { workspacePermissionKeysAtom } from '@/context/app-context-state' import { consoleQuery } from '@/service/client' import { hasPermission } from '@/utils/permission' import { Empty } from './empty' @@ -51,7 +52,7 @@ export function ApiBasedExtensionPage({ layout, }: ApiBasedExtensionPageProps = {}) { const { t } = useTranslation() - const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys) + const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const canManage = hasPermission(workspacePermissionKeys, 'api_extension.manage') const { data: apiBasedExtensions = [], isPending: isLoading } = useQuery(consoleQuery.apiBasedExtension.get.queryOptions()) const [dialogState, setDialogState] = useState(null) diff --git a/web/app/components/header/account-setting/index.tsx b/web/app/components/header/account-setting/index.tsx index 3d0be5fb7ef..2766957cd3a 100644 --- a/web/app/components/header/account-setting/index.tsx +++ b/web/app/components/header/account-setting/index.tsx @@ -4,6 +4,7 @@ import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { ScrollArea } from '@langgenius/dify-ui/scroll-area' import { useSuspenseQuery } from '@tanstack/react-query' +import { useAtomValue } from 'jotai' import { useCallback, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import BillingPage from '@/app/components/billing/billing-page' @@ -12,7 +13,7 @@ import { ACCOUNT_SETTING_TAB, } from '@/app/components/header/account-setting/constants' import MenuDialog from '@/app/components/header/account-setting/menu-dialog' -import { useAppContext } from '@/context/app-context' +import { workspacePermissionKeysAtom } from '@/context/app-context-state' import { useProviderContext } from '@/context/provider-context' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' @@ -54,7 +55,7 @@ export default function AccountSetting({ const { t } = useTranslation() const { enableBilling, enableReplaceWebAppLogo } = useProviderContext() const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) - const { workspacePermissionKeys } = useAppContext() + const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const isRbacEnabled = systemFeatures.rbac_enabled const canManageWorkspaceRoles = isRbacEnabled && hasPermission(workspacePermissionKeys, 'workspace.role.manage') const canViewBilling = enableBilling && hasPermission(workspacePermissionKeys, BillingPermission.View) diff --git a/web/app/components/header/account-setting/members-page/__tests__/index.spec.tsx b/web/app/components/header/account-setting/members-page/__tests__/index.spec.tsx index 29cae8e0a68..d93d0c10571 100644 --- a/web/app/components/header/account-setting/members-page/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/members-page/__tests__/index.spec.tsx @@ -14,7 +14,19 @@ import { useUpdateRolesOfMember } from '@/service/access-control/use-member-role import { useMembers } from '@/service/use-common' import MembersPage from '../index' +const mockAppContextState = vi.hoisted(() => ({ + current: {} as Partial, +})) + vi.mock('@/context/app-context') +vi.mock('@/context/app-context-state', async (importOriginal) => { + const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateAtomMock(importOriginal, () => mockAppContextState.current) +}) +vi.mock('jotai', async (importOriginal) => { + const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateJotaiMock(importOriginal) +}) vi.mock('@/context/provider-context') vi.mock('@/hooks/use-format-time-from-now') vi.mock('@/service/access-control/use-member-roles') @@ -41,6 +53,11 @@ const createRole = (overrides: Partial): Role => ({ ...overrides, }) +const setAppContextValue = (value: AppContextValue) => { + mockAppContextState.current = value + vi.mocked(useAppContext).mockReturnValue(value) +} + vi.mock('../edit-workspace-modal', () => ({ default: ({ onCancel }: { onCancel: () => void }) => (
@@ -181,7 +198,7 @@ describe('MembersPage', () => { beforeEach(() => { vi.clearAllMocks() - vi.mocked(useAppContext).mockReturnValue({ + setAppContextValue({ userProfile: { email: 'owner@example.com' }, currentWorkspace: { name: 'Test Workspace', role: 'owner' } as ICurrentWorkspace, isCurrentWorkspaceOwner: true, @@ -289,7 +306,7 @@ describe('MembersPage', () => { }) it('should hide manager controls for non-owner non-manager users', () => { - vi.mocked(useAppContext).mockReturnValue({ + setAppContextValue({ userProfile: { email: 'admin@example.com' }, currentWorkspace: { name: 'Test Workspace', role: 'admin' } as ICurrentWorkspace, isCurrentWorkspaceOwner: false, @@ -390,7 +407,7 @@ describe('MembersPage', () => { }) it('should show invite button when user is manager but not owner', () => { - vi.mocked(useAppContext).mockReturnValue({ + setAppContextValue({ userProfile: { email: 'admin@example.com' }, currentWorkspace: { name: 'Test Workspace', role: 'admin' } as ICurrentWorkspace, isCurrentWorkspaceOwner: false, @@ -405,7 +422,7 @@ describe('MembersPage', () => { }) it('should allow admins to operate other non-owner members only', () => { - vi.mocked(useAppContext).mockReturnValue({ + setAppContextValue({ userProfile: { email: 'admin@example.com' }, currentWorkspace: { name: 'Test Workspace', role: 'admin' } as ICurrentWorkspace, isCurrentWorkspaceOwner: false, @@ -483,7 +500,7 @@ describe('MembersPage', () => { }) it('should render role badge names from account roles', () => { - vi.mocked(useAppContext).mockReturnValue({ + setAppContextValue({ userProfile: { email: 'admin@example.com' }, currentWorkspace: { name: 'Test Workspace', role: 'admin' } as ICurrentWorkspace, isCurrentWorkspaceOwner: false, @@ -551,7 +568,7 @@ describe('MembersPage', () => { it('should not allow assigning roles from member details when target is current user', async () => { const user = userEvent.setup() - vi.mocked(useAppContext).mockReturnValue({ + setAppContextValue({ userProfile: { email: 'admin@example.com' }, currentWorkspace: { name: 'Test Workspace', role: 'admin' } as ICurrentWorkspace, isCurrentWorkspaceOwner: false, diff --git a/web/app/components/header/account-setting/members-page/__tests__/invite-button.spec.tsx b/web/app/components/header/account-setting/members-page/__tests__/invite-button.spec.tsx index 43fcce16f74..7ef5e69bacd 100644 --- a/web/app/components/header/account-setting/members-page/__tests__/invite-button.spec.tsx +++ b/web/app/components/header/account-setting/members-page/__tests__/invite-button.spec.tsx @@ -8,6 +8,16 @@ import { useWorkspacePermissions } from '@/service/use-workspace' import InviteButton from '../invite-button' vi.mock('@/context/app-context') +vi.mock('@/context/app-context-state', async (importOriginal) => { + const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateAtomMock(importOriginal, () => ({ + currentWorkspace: { id: 'workspace-id' }, + })) +}) +vi.mock('jotai', async (importOriginal) => { + const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateJotaiMock(importOriginal) +}) vi.mock('@/service/use-workspace') describe('InviteButton', () => { diff --git a/web/app/components/header/account-setting/members-page/edit-workspace-modal/__tests__/dialog.spec.tsx b/web/app/components/header/account-setting/members-page/edit-workspace-modal/__tests__/dialog.spec.tsx index e134f8f93c6..96cfd07cd54 100644 --- a/web/app/components/header/account-setting/members-page/edit-workspace-modal/__tests__/dialog.spec.tsx +++ b/web/app/components/header/account-setting/members-page/edit-workspace-modal/__tests__/dialog.spec.tsx @@ -1,4 +1,5 @@ import type { ReactNode } from 'react' +import type { AppContextValue } from '@/context/app-context' import { render } from '@testing-library/react' import { useAppContext } from '@/context/app-context' import EditWorkspaceModal from '../index' @@ -10,6 +11,9 @@ type DialogProps = { } let latestOnOpenChange: DialogProps['onOpenChange'] +const mockAppContextState = vi.hoisted(() => ({ + current: {} as Partial, +})) vi.mock('@langgenius/dify-ui/dialog', () => ({ Dialog: ({ children, onOpenChange }: DialogProps) => { @@ -29,14 +33,26 @@ vi.mock('@/context/app-context', () => ({ useAppContext: vi.fn(), })) +vi.mock('@/context/app-context-state', async (importOriginal) => { + const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateAtomMock(importOriginal, () => mockAppContextState.current) +}) + +vi.mock('jotai', async (importOriginal) => { + const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateJotaiMock(importOriginal) +}) + describe('EditWorkspaceModal dialog lifecycle', () => { beforeEach(() => { vi.clearAllMocks() latestOnOpenChange = undefined - vi.mocked(useAppContext).mockReturnValue({ + const appContextValue = { currentWorkspace: { name: 'Test Workspace' }, isCurrentWorkspaceOwner: true, - } as never) + } as never + mockAppContextState.current = appContextValue + vi.mocked(useAppContext).mockReturnValue(appContextValue) }) it('should only call onCancel when the dialog requests closing', () => { diff --git a/web/app/components/header/account-setting/members-page/edit-workspace-modal/__tests__/index.spec.tsx b/web/app/components/header/account-setting/members-page/edit-workspace-modal/__tests__/index.spec.tsx index 1b85319613a..5ec4cb538d7 100644 --- a/web/app/components/header/account-setting/members-page/edit-workspace-modal/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/members-page/edit-workspace-modal/__tests__/index.spec.tsx @@ -10,10 +10,21 @@ import EditWorkspaceModal from '../index' const toastMocks = vi.hoisted(() => ({ mockNotify: vi.fn(), })) +const mockAppContextState = vi.hoisted(() => ({ + current: {} as Partial, +})) const getSaveButton = () => screen.getByRole('button', { name: /operation\.(save|saving)/i }) vi.mock('@/context/app-context') +vi.mock('@/context/app-context-state', async (importOriginal) => { + const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateAtomMock(importOriginal, () => mockAppContextState.current) +}) +vi.mock('jotai', async (importOriginal) => { + const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateJotaiMock(importOriginal) +}) vi.mock('@/service/common') vi.mock('@langgenius/dify-ui/toast', () => ({ default: { @@ -34,10 +45,12 @@ describe('EditWorkspaceModal', () => { beforeEach(() => { vi.clearAllMocks() - vi.mocked(useAppContext).mockReturnValue({ + const appContextValue = { currentWorkspace: { name: 'Test Workspace' } as ICurrentWorkspace, isCurrentWorkspaceOwner: true, - } as unknown as AppContextValue) + } as unknown as AppContextValue + mockAppContextState.current = appContextValue + vi.mocked(useAppContext).mockReturnValue(appContextValue) }) afterEach(() => { diff --git a/web/app/components/header/account-setting/members-page/edit-workspace-modal/index.tsx b/web/app/components/header/account-setting/members-page/edit-workspace-modal/index.tsx index f9d84d414b4..7d17366df84 100644 --- a/web/app/components/header/account-setting/members-page/edit-workspace-modal/index.tsx +++ b/web/app/components/header/account-setting/members-page/edit-workspace-modal/index.tsx @@ -2,11 +2,12 @@ import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' +import { Input } from '@langgenius/dify-ui/input' import { toast } from '@langgenius/dify-ui/toast' +import { useAtomValue } from 'jotai' import { useId, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import Input from '@/app/components/base/input' -import { useAppContext } from '@/context/app-context' +import { currentWorkspaceAtom, isCurrentWorkspaceOwnerAtom } from '@/context/app-context-state' import { updateWorkspaceInfo } from '@/service/common' type IEditWorkspaceModalProps = { @@ -14,7 +15,8 @@ type IEditWorkspaceModalProps = { } const EditWorkspaceModal = ({ onCancel }: IEditWorkspaceModalProps) => { const { t } = useTranslation() - const { currentWorkspace, isCurrentWorkspaceOwner } = useAppContext() + const currentWorkspace = useAtomValue(currentWorkspaceAtom) + const isCurrentWorkspaceOwner = useAtomValue(isCurrentWorkspaceOwnerAtom) const [name, setName] = useState(currentWorkspace.name) const [isSubmitting, setIsSubmitting] = useState(false) const inputId = useId() @@ -82,7 +84,6 @@ const EditWorkspaceModal = ({ onCancel }: IEditWorkspaceModalProps) => { { diff --git a/web/app/components/header/account-setting/members-page/index.tsx b/web/app/components/header/account-setting/members-page/index.tsx index 478952e5c6d..bd745507367 100644 --- a/web/app/components/header/account-setting/members-page/index.tsx +++ b/web/app/components/header/account-setting/members-page/index.tsx @@ -4,12 +4,13 @@ import type { InvitationResult, Member } from '@/models/common' import { toast } from '@langgenius/dify-ui/toast' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { useSuspenseQuery } from '@tanstack/react-query' +import { useAtomValue } from 'jotai' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { NUM_INFINITE } from '@/app/components/billing/config' import { Plan } from '@/app/components/billing/type' import UpgradeBtn from '@/app/components/billing/upgrade-btn' -import { useAppContext } from '@/context/app-context' +import { currentWorkspaceAtom, isCurrentWorkspaceOwnerAtom, userProfileEmailAtom, workspacePermissionKeysAtom } from '@/context/app-context-state' import { useLocale } from '@/context/i18n' import { useProviderContext } from '@/context/provider-context' import { systemFeaturesQueryOptions } from '@/features/system-features/client' @@ -30,7 +31,10 @@ const MembersPage = () => { const locale = useLocale() const language = getAccessControlTemplateLanguage(locale) - const { userProfile, currentWorkspace, isCurrentWorkspaceOwner, workspacePermissionKeys } = useAppContext() + const userProfileEmail = useAtomValue(userProfileEmailAtom) + const currentWorkspace = useAtomValue(currentWorkspaceAtom) + const isCurrentWorkspaceOwner = useAtomValue(isCurrentWorkspaceOwnerAtom) + const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const { data, refetch } = useMembers(language) const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const [inviteModalVisible, setInviteModalVisible] = useState(false) @@ -162,7 +166,7 @@ const MembersPage = () => { key={account.id} member={account} roles={account.roles} - isCurrentUser={userProfile.email === account.email} + isCurrentUser={userProfileEmail === account.email} canManage={canManageMembers} canTransferOwnership={isCurrentWorkspaceOwner && isAllowTransferWorkspace} allowMultipleRoles={systemFeatures.rbac_enabled} @@ -213,7 +217,7 @@ const MembersPage = () => { canAssignRoles={ canManageMembers && detailsMember.role !== 'owner' - && userProfile.email !== detailsMember.email + && userProfileEmail !== detailsMember.email } allowMultipleRoles={systemFeatures.rbac_enabled} onClose={handleCloseDetails} diff --git a/web/app/components/header/account-setting/members-page/invite-button.tsx b/web/app/components/header/account-setting/members-page/invite-button.tsx index e0a9408515a..e08415eedc9 100644 --- a/web/app/components/header/account-setting/members-page/invite-button.tsx +++ b/web/app/components/header/account-setting/members-page/invite-button.tsx @@ -1,9 +1,10 @@ import { Button } from '@langgenius/dify-ui/button' import { RiUserAddLine } from '@remixicon/react' import { useSuspenseQuery } from '@tanstack/react-query' +import { useAtomValue } from 'jotai' import { useTranslation } from 'react-i18next' import Loading from '@/app/components/base/loading' -import { useAppContext } from '@/context/app-context' +import { currentWorkspaceIdAtom } from '@/context/app-context-state' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { useWorkspacePermissions } from '@/service/use-workspace' @@ -14,9 +15,9 @@ type InviteButtonProps = { const InviteButton = (props: InviteButtonProps) => { const { t } = useTranslation() - const { currentWorkspace } = useAppContext() + const currentWorkspaceId = useAtomValue(currentWorkspaceIdAtom) const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) - const { data: workspacePermissions, isFetching: isFetchingWorkspacePermissions } = useWorkspacePermissions(currentWorkspace!.id, systemFeatures.branding.enabled) + const { data: workspacePermissions, isFetching: isFetchingWorkspacePermissions } = useWorkspacePermissions(currentWorkspaceId, systemFeatures.branding.enabled) if (systemFeatures.branding.enabled) { if (isFetchingWorkspacePermissions) { return diff --git a/web/app/components/header/account-setting/members-page/transfer-ownership-modal/__tests__/index.spec.tsx b/web/app/components/header/account-setting/members-page/transfer-ownership-modal/__tests__/index.spec.tsx index 07371740bd8..db02b773cc7 100644 --- a/web/app/components/header/account-setting/members-page/transfer-ownership-modal/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/members-page/transfer-ownership-modal/__tests__/index.spec.tsx @@ -11,8 +11,19 @@ import TransferOwnershipModal from '../index' const toastMocks = vi.hoisted(() => ({ mockNotify: vi.fn(), })) +const mockAppContextState = vi.hoisted(() => ({ + current: {} as Partial, +})) vi.mock('@/context/app-context') +vi.mock('@/context/app-context-state', async (importOriginal) => { + const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateAtomMock(importOriginal, () => mockAppContextState.current) +}) +vi.mock('jotai', async (importOriginal) => { + const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateJotaiMock(importOriginal) +}) vi.mock('@/service/common') vi.mock('@/service/use-common') vi.mock('@langgenius/dify-ui/toast', () => ({ @@ -40,10 +51,12 @@ describe('TransferOwnershipModal', () => { beforeEach(() => { vi.clearAllMocks() - vi.mocked(useAppContext).mockReturnValue({ + const appContextValue = { currentWorkspace: { name: 'Test Workspace' } as ICurrentWorkspace, userProfile: { email: 'owner@example.com', id: 'owner-id' }, - } as unknown as AppContextValue) + } as unknown as AppContextValue + mockAppContextState.current = appContextValue + vi.mocked(useAppContext).mockReturnValue(appContextValue) vi.mocked(useMembers).mockReturnValue({ data: { accounts: [] }, diff --git a/web/app/components/header/account-setting/members-page/transfer-ownership-modal/index.tsx b/web/app/components/header/account-setting/members-page/transfer-ownership-modal/index.tsx index 7bfb3ee3f1f..ee198a7b555 100644 --- a/web/app/components/header/account-setting/members-page/transfer-ownership-modal/index.tsx +++ b/web/app/components/header/account-setting/members-page/transfer-ownership-modal/index.tsx @@ -1,11 +1,12 @@ import { Button } from '@langgenius/dify-ui/button' import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' +import { Input } from '@langgenius/dify-ui/input' import { toast } from '@langgenius/dify-ui/toast' +import { useAtomValue } from 'jotai' import * as React from 'react' import { useCallback, useState } from 'react' import { Trans, useTranslation } from 'react-i18next' -import Input from '@/app/components/base/input' -import { useAppContext } from '@/context/app-context' +import { currentWorkspaceAtom, userProfileAtom } from '@/context/app-context-state' import { ownershipTransfer, sendOwnerEmail, verifyOwnerEmail } from '@/service/common' import MemberSelector from './member-selector' @@ -13,15 +14,22 @@ type Props = Readonly<{ show: boolean onClose: () => void }> -enum STEP { - start = 'start', - verify = 'verify', - transfer = 'transfer', +const STEP = { + start: 'start', + verify: 'verify', + transfer: 'transfer', } +type Step = typeof STEP[keyof typeof STEP] + +const getErrorMessage = (error: unknown) => { + return error instanceof Error ? error.message : '' +} + const TransferOwnershipModal = ({ onClose, show }: Props) => { const { t } = useTranslation() - const { currentWorkspace, userProfile } = useAppContext() - const [step, setStep] = useState(STEP.start) + const currentWorkspace = useAtomValue(currentWorkspaceAtom) + const userProfile = useAtomValue(userProfileAtom) + const [step, setStep] = useState(STEP.start) const [code, setCode] = useState('') const [time, setTime] = useState(0) const [stepToken, setStepToken] = useState('') @@ -71,7 +79,7 @@ const TransferOwnershipModal = ({ onClose, show }: Props) => { } } catch (error) { - toast.error(`Error verifying email: ${error ? (error as any).message : ''}`) + toast.error(`Error verifying email: ${getErrorMessage(error)}`) } } const sendCodeToOriginEmail = async () => { @@ -96,7 +104,7 @@ const TransferOwnershipModal = ({ onClose, show }: Props) => { globalThis.location.reload() } catch (error) { - toast.error(`Error ownership transfer: ${error ? (error as any).message : ''}`) + toast.error(`Error ownership transfer: ${getErrorMessage(error)}`) } finally { setIsTransfer(false) diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/model-list-item.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/model-list-item.spec.tsx index 6b6540ec261..065baa2496f 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/model-list-item.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/model-list-item.spec.tsx @@ -22,6 +22,18 @@ vi.mock('@/context/app-context', () => ({ }), })) +vi.mock('@/context/app-context-state', async (importOriginal) => { + const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateAtomMock(importOriginal, () => ({ + workspacePermissionKeys: mockWorkspacePermissionKeys, + })) +}) + +vi.mock('jotai', async (importOriginal) => { + const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateJotaiMock(importOriginal) +}) + vi.mock('@/context/provider-context', () => ({ useProviderContext: () => ({ plan: { type: mockPlanType }, diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/model-list.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/model-list.spec.tsx index c307407c0fd..1d5055a0741 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/model-list.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/model-list.spec.tsx @@ -11,6 +11,18 @@ vi.mock('@/context/app-context', () => ({ selector({ workspacePermissionKeys: mockWorkspacePermissionKeys }), })) +vi.mock('@/context/app-context-state', async (importOriginal) => { + const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateAtomMock(importOriginal, () => ({ + workspacePermissionKeys: mockWorkspacePermissionKeys, + })) +}) + +vi.mock('jotai', async (importOriginal) => { + const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateJotaiMock(importOriginal) +}) + vi.mock('@/context/modal-context', () => ({ useModalContextSelector: (selector: (state: { setShowModelLoadBalancingModal: typeof mockSetShowModelLoadBalancingModal }) => unknown) => selector({ setShowModelLoadBalancingModal: mockSetShowModelLoadBalancingModal }), diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/index.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/index.tsx index 90bd54c5e63..598254ceeda 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/index.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/index.tsx @@ -7,6 +7,7 @@ import type { PluginDetail } from '@/app/components/plugins/types' import { cn } from '@langgenius/dify-ui/cn' import { useQuery } from '@tanstack/react-query' +import { useAtomValue } from 'jotai' import { memo, useCallback } from 'react' import { useTranslation } from 'react-i18next' import { @@ -14,7 +15,7 @@ import { ManageCustomModelCredentials, } from '@/app/components/header/account-setting/model-provider-page/model-auth' import { IS_CE_EDITION } from '@/config' -import { useSelector as useAppContextWithSelector } from '@/context/app-context' +import { workspacePermissionKeysAtom } from '@/context/app-context-state' import { useProviderContextSelector } from '@/context/provider-context' import { useCredentialPermissions } from '@/hooks/use-credential-permissions' import { renderI18nObject } from '@/i18n-config' @@ -68,7 +69,7 @@ const ProviderAddedCard: FC = ({ })) const hasModelList = hasFetchedModelList && !!modelList.length const showCollapsedSection = !expanded || !hasFetchedModelList - const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys) + const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const showModelProvider = systemConfig.enabled && MODEL_PROVIDER_QUOTA_GET_PAID.includes(currentProviderName as ModelProviderQuotaGetPaid) && !IS_CE_EDITION const canConfigureModels = hasPermission(workspacePermissionKeys, 'plugin.model_config') const { canUseCredential, canCreateCredential, canManageCredential } = useCredentialPermissions() diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-list-item.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-list-item.tsx index ba890ab8c90..ea642daca49 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-list-item.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-list-item.tsx @@ -4,12 +4,13 @@ import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/pop import { Switch } from '@langgenius/dify-ui/switch' import { useQueryClient } from '@tanstack/react-query' import { useDebounceFn } from 'ahooks' +import { useAtomValue } from 'jotai' import { memo, useCallback } from 'react' import { useTranslation } from 'react-i18next' import Badge from '@/app/components/base/badge' import { Balance } from '@/app/components/base/icons/src/vender/line/financeAndECommerce' import { Plan } from '@/app/components/billing/type' -import { useAppContext } from '@/context/app-context' +import { workspacePermissionKeysAtom } from '@/context/app-context-state' import { useProviderContext, useProviderContextSelector } from '@/context/provider-context' import { consoleQuery } from '@/service/client' import { disableModel, enableModel } from '@/service/common' @@ -32,7 +33,7 @@ const ModelListItem = ({ model, provider, isConfigurable, onChange, onModifyLoad const { t } = useTranslation() const { plan } = useProviderContext() const modelLoadBalancingEnabled = useProviderContextSelector(state => state.modelLoadBalancingEnabled) - const { workspacePermissionKeys } = useAppContext() + const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const canConfigureModels = hasPermission(workspacePermissionKeys, 'plugin.model_config') const queryClient = useQueryClient() const updateModelList = useUpdateModelList() diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-list.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-list.tsx index 0b818542e5c..b1a6ceccf27 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-list.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-list.tsx @@ -4,13 +4,14 @@ import type { ModelItem, ModelProvider, } from '../declarations' +import { useAtomValue } from 'jotai' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { AddCustomModel, ManageCustomModelCredentials, } from '@/app/components/header/account-setting/model-provider-page/model-auth' -import { useSelector as useAppContextWithSelector } from '@/context/app-context' +import { workspacePermissionKeysAtom } from '@/context/app-context-state' import { useModalContextSelector } from '@/context/modal-context' import { hasPermission } from '@/utils/permission' import { @@ -33,7 +34,7 @@ const ModelList: FC = ({ }) => { const { t } = useTranslation() const configurativeMethods = provider.configurate_methods.filter(method => method !== ConfigurationMethodEnum.fetchFromRemote) - const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys) + const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const canConfigureModels = hasPermission(workspacePermissionKeys, 'plugin.model_config') const isConfigurable = configurativeMethods.includes(ConfigurationMethodEnum.customizableModel) const setShowModelLoadBalancingModal = useModalContextSelector(state => state.setShowModelLoadBalancingModal) @@ -58,13 +59,14 @@ const ModelList: FC = ({ {t('modelProvider.modelsNum', { ns: 'common', num: models.length })} - onCollapse()} > {t('modelProvider.modelsNum', { ns: 'common', num: models.length })} - + { isConfigurable && canConfigureModels && ( diff --git a/web/app/components/header/account-setting/model-provider-page/system-model-selector/__tests__/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/system-model-selector/__tests__/index.spec.tsx index dc4d716b6ce..4bbf4ec7bb6 100644 --- a/web/app/components/header/account-setting/model-provider-page/system-model-selector/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/system-model-selector/__tests__/index.spec.tsx @@ -40,6 +40,18 @@ vi.mock('@/context/app-context', () => ({ }), })) +vi.mock('@/context/app-context-state', async (importOriginal) => { + const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateAtomMock(importOriginal, () => ({ + workspacePermissionKeys: mockWorkspacePermissionKeys, + })) +}) + +vi.mock('jotai', async (importOriginal) => { + const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateJotaiMock(importOriginal) +}) + vi.mock('@/context/provider-context', () => ({ useProviderContext: () => ({ textGenerationModelList: [], diff --git a/web/app/components/header/account-setting/model-provider-page/system-model-selector/index.tsx b/web/app/components/header/account-setting/model-provider-page/system-model-selector/index.tsx index 6fe3fc917f2..ece50247ec8 100644 --- a/web/app/components/header/account-setting/model-provider-page/system-model-selector/index.tsx +++ b/web/app/components/header/account-setting/model-provider-page/system-model-selector/index.tsx @@ -12,10 +12,11 @@ import { DialogTitle, } from '@langgenius/dify-ui/dialog' import { toast } from '@langgenius/dify-ui/toast' +import { useAtomValue } from 'jotai' import { useState } from 'react' import { useTranslation } from 'react-i18next' import { Infotip } from '@/app/components/base/infotip' -import { useAppContext } from '@/context/app-context' +import { workspacePermissionKeysAtom } from '@/context/app-context-state' import { useProviderContext } from '@/context/provider-context' import { updateDefaultModel } from '@/service/common' import { hasPermission } from '@/utils/permission' @@ -68,7 +69,7 @@ const SystemModel: FC = ({ onOpenMarketplace, }) => { const { t } = useTranslation() - const { workspacePermissionKeys } = useAppContext() + const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const { textGenerationModelList } = useProviderContext() const canManageSystemDefaultModel = hasPermission(workspacePermissionKeys, 'plugin.model_config') const updateModelList = useUpdateModelList() diff --git a/web/app/components/header/account-setting/permissions-page/__tests__/index.spec.tsx b/web/app/components/header/account-setting/permissions-page/__tests__/index.spec.tsx index b37c07d1054..c0c0efb4f95 100644 --- a/web/app/components/header/account-setting/permissions-page/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/permissions-page/__tests__/index.spec.tsx @@ -26,6 +26,18 @@ vi.mock('@/context/app-context', () => ({ } as AppContextValue)), })) +vi.mock('@/context/app-context-state', async (importOriginal) => { + const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateAtomMock(importOriginal, () => ({ + workspacePermissionKeys: mocks.workspacePermissionKeys, + })) +}) + +vi.mock('jotai', async (importOriginal) => { + const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateJotaiMock(importOriginal) +}) + vi.mock('@/service/access-control/use-workspace-roles', () => ({ useCreateWorkspaceRole: vi.fn(), useUpdateWorkspaceRole: vi.fn(), diff --git a/web/app/components/header/account-setting/permissions-page/index.tsx b/web/app/components/header/account-setting/permissions-page/index.tsx index e5d3408106d..f1fa6572ca9 100644 --- a/web/app/components/header/account-setting/permissions-page/index.tsx +++ b/web/app/components/header/account-setting/permissions-page/index.tsx @@ -4,9 +4,10 @@ import type { RoleModalMode, submitRoleData } from './role-modal' import type { Role } from '@/models/access-control' import { Button } from '@langgenius/dify-ui/button' import { toast } from '@langgenius/dify-ui/toast' +import { useAtomValue } from 'jotai' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useSelector as useAppContextWithSelector } from '@/context/app-context' +import { workspacePermissionKeysAtom } from '@/context/app-context-state' import { useLocale } from '@/context/i18n' import { getAccessControlTemplateLanguage } from '@/i18n-config/language' import { useCreateWorkspaceRole, useUpdateWorkspaceRole } from '@/service/access-control/use-workspace-roles' @@ -32,7 +33,7 @@ const PermissionsPage = ({ containerRef }: PermissionsPageProps) => { const [modalState, setModalState] = useState(null) const anchorRef = useRef(null) - const workspacePermissionKeys = useAppContextWithSelector(s => s.workspacePermissionKeys) + const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const language = useMemo(() => getAccessControlTemplateLanguage(locale), [locale]) diff --git a/web/app/components/header/account-setting/permissions-page/role-list/__tests__/row-menu.spec.tsx b/web/app/components/header/account-setting/permissions-page/role-list/__tests__/row-menu.spec.tsx index 90558b49896..46412f3f16f 100644 --- a/web/app/components/header/account-setting/permissions-page/role-list/__tests__/row-menu.spec.tsx +++ b/web/app/components/header/account-setting/permissions-page/role-list/__tests__/row-menu.spec.tsx @@ -36,6 +36,18 @@ vi.mock('@/context/app-context', () => ({ }), })) +vi.mock('@/context/app-context-state', async (importOriginal) => { + const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateAtomMock(importOriginal, () => ({ + workspacePermissionKeys: mockWorkspacePermissionKeys.value, + })) +}) + +vi.mock('jotai', async (importOriginal) => { + const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateJotaiMock(importOriginal) +}) + vi.mock('@/service/access-control/use-workspace-roles', () => ({ useCopyWorkspaceRole: () => ({ mutate: mockCopyRole, diff --git a/web/app/components/header/account-setting/permissions-page/role-list/row-menu.tsx b/web/app/components/header/account-setting/permissions-page/role-list/row-menu.tsx index 678357ea4bc..145a83c3467 100644 --- a/web/app/components/header/account-setting/permissions-page/role-list/row-menu.tsx +++ b/web/app/components/header/account-setting/permissions-page/role-list/row-menu.tsx @@ -18,10 +18,11 @@ import { DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' import { toast } from '@langgenius/dify-ui/toast' +import { useAtomValue } from 'jotai' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' -import { useSelector as useAppContextWithSelector } from '@/context/app-context' +import { workspacePermissionKeysAtom } from '@/context/app-context-state' import { useCopyWorkspaceRole, useDeleteWorkspaceRole } from '@/service/access-control/use-workspace-roles' import { hasPermission } from '@/utils/permission' import { CopyMembersConfirmDialog } from './copy-members-confirm-dialog' @@ -44,7 +45,7 @@ const RowMenu = ({ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) const [showCopyMembersConfirm, setShowCopyMembersConfirm] = useState(false) - const workspacePermissionKeys = useAppContextWithSelector(s => s.workspacePermissionKeys) + const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const canManageRoles = hasPermission(workspacePermissionKeys, 'workspace.role.manage') const handleView = useCallback(() => onView?.(role), [onView, role]) diff --git a/web/app/components/header/account-setting/preference-page/__tests__/index.spec.tsx b/web/app/components/header/account-setting/preference-page/__tests__/index.spec.tsx index 55ee81481ad..db47bfe571d 100644 --- a/web/app/components/header/account-setting/preference-page/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/preference-page/__tests__/index.spec.tsx @@ -75,6 +75,19 @@ vi.mock('@/context/app-context', () => ({ }), })) +vi.mock('@/context/app-context-state', async (importOriginal) => { + const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateAtomMock(importOriginal, () => ({ + userProfile: mockUserProfile, + refreshUserProfile: mockMutateUserProfile, + })) +}) + +vi.mock('jotai', async (importOriginal) => { + const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateJotaiMock(importOriginal) +}) + vi.mock('@/context/i18n', () => ({ useLocale: () => mockLocale, })) diff --git a/web/app/components/header/account-setting/preference-page/index.tsx b/web/app/components/header/account-setting/preference-page/index.tsx index 1574d254cd2..c8a62fb90c5 100644 --- a/web/app/components/header/account-setting/preference-page/index.tsx +++ b/web/app/components/header/account-setting/preference-page/index.tsx @@ -2,10 +2,11 @@ import type { Locale } from '@/i18n-config' import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select' import { toast } from '@langgenius/dify-ui/toast' +import { useAtomValue, useSetAtom } from 'jotai' import { useTheme } from 'next-themes' import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { useAppContext } from '@/context/app-context' +import { refreshUserProfileAtom, userProfileAtom } from '@/context/app-context-state' import { useLocale } from '@/context/i18n' import { setLocaleOnClient } from '@/i18n-config' import { languages } from '@/i18n-config/language' @@ -35,7 +36,8 @@ const isThemeOption = (value: string): value is ThemeOption => { export default function PreferencePage() { const locale = useLocale() - const { userProfile, mutateUserProfile } = useAppContext() + const userProfile = useAtomValue(userProfileAtom) + const refreshUserProfile = useSetAtom(refreshUserProfileAtom) const [editing, setEditing] = useState(false) const { t } = useTranslation() const router = useRouter() @@ -77,7 +79,7 @@ export default function PreferencePage() { try { await updateUserProfile({ url, body: { [bodyKey]: item.value } }) toast.success(t('actionMsg.modifiedSuccessfully', { ns: 'common' })) - mutateUserProfile() + refreshUserProfile() } catch (e) { toast.error((e as Error).message) diff --git a/web/app/components/header/account-setting/update-setting-dialog.tsx b/web/app/components/header/account-setting/update-setting-dialog.tsx index 6e512b12af4..2d72c46cf3b 100644 --- a/web/app/components/header/account-setting/update-setting-dialog.tsx +++ b/web/app/components/header/account-setting/update-setting-dialog.tsx @@ -7,12 +7,13 @@ import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { Dialog, DialogCloseButton, DialogContent, DialogTitle, DialogTrigger } from '@langgenius/dify-ui/dialog' import { toast } from '@langgenius/dify-ui/toast' +import { useAtomValue } from 'jotai' import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { convertTimezoneToOffsetStr } from '@/app/components/base/date-and-time-picker/utils/dayjs' import { AUTO_UPDATE_MODE, AUTO_UPDATE_STRATEGY } from '@/app/components/plugins/reference-setting-modal/auto-update-setting/types' import { convertLocalSecondsToUTCDaySeconds, convertUTCDaySecondsToLocalSeconds, dayjsToTimeOfDay, timeOfDayToDayjs } from '@/app/components/plugins/reference-setting-modal/auto-update-setting/utils' -import { useAppContext } from '@/context/app-context' +import { userProfileAtom } from '@/context/app-context-state' import { useMutationPluginAutoUpgradeSettings, usePluginAutoUpgradeSettings } from '@/service/use-plugins' import UpdateSettingDialogForm from './update-setting-dialog-form' @@ -26,7 +27,7 @@ const UpdateSettingDialog = ({ disabled = false, }: Props) => { const { t } = useTranslation() - const { userProfile } = useAppContext() + const userProfile = useAtomValue(userProfileAtom) const timezone = userProfile.timezone || 'UTC' const { data: autoUpgradeSetting, diff --git a/web/app/components/header/env-nav/__tests__/index.spec.tsx b/web/app/components/header/env-nav/__tests__/index.spec.tsx index 81076e773fe..98832145dc3 100644 --- a/web/app/components/header/env-nav/__tests__/index.spec.tsx +++ b/web/app/components/header/env-nav/__tests__/index.spec.tsx @@ -4,10 +4,24 @@ import { vi } from 'vitest' import { useAppContext } from '@/context/app-context' import EnvNav from '../index' +const mockAppContextState = vi.hoisted(() => ({ + current: {} as Partial, +})) + vi.mock('@/context/app-context', () => ({ useAppContext: vi.fn(), })) +vi.mock('@/context/app-context-state', async (importOriginal) => { + const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateAtomMock(importOriginal, () => mockAppContextState.current) +}) + +vi.mock('jotai', async (importOriginal) => { + const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateJotaiMock(importOriginal) +}) + describe('EnvNav', () => { const mockUseAppContext = vi.mocked(useAppContext) @@ -16,33 +30,39 @@ describe('EnvNav', () => { }) it('should render null when environment is PRODUCTION', () => { - mockUseAppContext.mockReturnValue({ + const appContextValue = { langGeniusVersionInfo: { current_env: 'PRODUCTION', }, - } as unknown as AppContextValue) + } as unknown as AppContextValue + mockAppContextState.current = appContextValue + mockUseAppContext.mockReturnValue(appContextValue) const { container } = render() expect(container.firstChild).toBeNull() }) it('should render TESTING tag and icon when environment is TESTING', () => { - mockUseAppContext.mockReturnValue({ + const appContextValue = { langGeniusVersionInfo: { current_env: 'TESTING', }, - } as unknown as AppContextValue) + } as unknown as AppContextValue + mockAppContextState.current = appContextValue + mockUseAppContext.mockReturnValue(appContextValue) render() expect(screen.getByText('common.environment.testing')).toBeInTheDocument() }) it('should render DEVELOPMENT tag and icon when environment is DEVELOPMENT', () => { - mockUseAppContext.mockReturnValue({ + const appContextValue = { langGeniusVersionInfo: { current_env: 'DEVELOPMENT', }, - } as unknown as AppContextValue) + } as unknown as AppContextValue + mockAppContextState.current = appContextValue + mockUseAppContext.mockReturnValue(appContextValue) render() expect( diff --git a/web/app/components/header/env-nav/index.tsx b/web/app/components/header/env-nav/index.tsx index 6323f3428e9..a27daa7638a 100644 --- a/web/app/components/header/env-nav/index.tsx +++ b/web/app/components/header/env-nav/index.tsx @@ -1,9 +1,10 @@ 'use client' +import { useAtomValue } from 'jotai' import { useTranslation } from 'react-i18next' import { TerminalSquare } from '@/app/components/base/icons/src/vender/solid/development' import { Beaker02 } from '@/app/components/base/icons/src/vender/solid/education' -import { useAppContext } from '@/context/app-context' +import { langGeniusVersionInfoAtom } from '@/context/app-context-state' const headerEnvClassName: { [k: string]: string } = { DEVELOPMENT: 'bg-[#FEC84B] border-[#FDB022] text-[#93370D]', @@ -12,7 +13,7 @@ const headerEnvClassName: { [k: string]: string } = { const EnvNav = () => { const { t } = useTranslation() - const { langGeniusVersionInfo } = useAppContext() + const langGeniusVersionInfo = useAtomValue(langGeniusVersionInfoAtom) const showEnvTag = langGeniusVersionInfo.current_env === 'TESTING' || langGeniusVersionInfo.current_env === 'DEVELOPMENT' if (!showEnvTag) diff --git a/web/app/components/main-nav/__tests__/index.spec.tsx b/web/app/components/main-nav/__tests__/index.spec.tsx index c4175b44418..f9c9f6ca999 100644 --- a/web/app/components/main-nav/__tests__/index.spec.tsx +++ b/web/app/components/main-nav/__tests__/index.spec.tsx @@ -30,6 +30,9 @@ const { mockIsAgentV2Enabled, mockSwitchWorkspace, mockToastSuccess } = vi.hoist mockToastSuccess: vi.fn(), mockIsAgentV2Enabled: vi.fn(() => true), })) +const mockAppContextState = vi.hoisted(() => ({ + current: undefined as AppContextValue | undefined, +})) vi.mock('@/features/agent-v2/feature-flag', () => ({ isAgentV2Enabled: () => mockIsAgentV2Enabled(), @@ -40,6 +43,16 @@ vi.mock('@/context/app-context', () => ({ useSelector: vi.fn(), })) +vi.mock('@/context/app-context-state', async (importOriginal) => { + const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateAtomMock(importOriginal, () => mockAppContextState.current ?? {}) +}) + +vi.mock('jotai', async (importOriginal) => { + const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateJotaiMock(importOriginal) +}) + vi.mock('@/context/provider-context', () => ({ useProviderContext: vi.fn(), })) @@ -240,6 +253,7 @@ const renderMainNav = ( const queryClient = createTestQueryClient() const getMockAppContext = useAppContext as Mock const currentAppContext = getMockAppContext() as AppContextValue + mockAppContextState.current = currentAppContext queryClient.setQueryData(consoleQuery.workspaces.current.post.queryKey(), currentAppContext.currentWorkspace) queryClient.setQueryData(consoleQuery.workspaces.get.queryKey(), { workspaces: mockWorkspaces }) const resolvedSystemFeatures = { @@ -287,6 +301,7 @@ describe('MainNav', () => { refresh: vi.fn(), }) ;(useAppContext as Mock).mockReturnValue(appContextValue) + mockAppContextState.current = appContextValue ;(useAppContextSelector as Mock).mockImplementation((selector: (state: AppContextValue) => unknown) => selector((useAppContext as Mock)() as AppContextValue)) ;(useProviderContext as Mock).mockReturnValue({ enableBilling: true, diff --git a/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx b/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx index 3ae8851a23f..422a602c2d8 100644 --- a/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx +++ b/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx @@ -29,6 +29,18 @@ vi.mock('@/context/app-context', () => ({ }), })) +vi.mock('@/context/app-context-state', async (importOriginal) => { + const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateAtomMock(importOriginal, () => ({ + workspacePermissionKeys: mockWorkspacePermissionKeys.value, + })) +}) + +vi.mock('jotai', async (importOriginal) => { + const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateJotaiMock(importOriginal) +}) + vi.mock('@/app/components/snippets/hooks/use-create-snippet', async () => { const React = await vi.importActual('react') diff --git a/web/app/components/workflow/comment/comment-icon.spec.tsx b/web/app/components/workflow/comment/comment-icon.spec.tsx index df579442fce..017c83b272b 100644 --- a/web/app/components/workflow/comment/comment-icon.spec.tsx +++ b/web/app/components/workflow/comment/comment-icon.spec.tsx @@ -6,6 +6,13 @@ import { CommentIcon } from './comment-icon' type Position = { x: number, y: number } let mockUserId = 'user-1' +const mockAppContextState = vi.hoisted(() => ({ + userProfile: { + id: 'user-1', + name: 'User', + avatar_url: 'avatar', + }, +})) const mockFlowToScreenPosition = vi.fn((position: Position) => position) const mockScreenToFlowPosition = vi.fn((position: Position) => position) @@ -25,13 +32,28 @@ vi.mock('reactflow', () => ({ vi.mock('@/context/app-context', () => ({ useAppContext: () => ({ userProfile: { + ...mockAppContextState.userProfile, id: mockUserId, - name: 'User', - avatar_url: 'avatar', }, }), })) +vi.mock('@/context/app-context-state', async (importOriginal) => { + const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateAtomMock(importOriginal, () => ({ + ...mockAppContextState, + userProfile: { + ...mockAppContextState.userProfile, + id: mockUserId, + }, + })) +}) + +vi.mock('jotai', async (importOriginal) => { + const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateJotaiMock(importOriginal) +}) + vi.mock('@/app/components/base/user-avatar-list', () => ({ UserAvatarList: ({ users }: { users: Array<{ id: string }> }) => (
{users.map(user => user.id).join(',')}
diff --git a/web/app/components/workflow/comment/comment-icon.tsx b/web/app/components/workflow/comment/comment-icon.tsx index 658ad6a2c6b..e46576e145d 100644 --- a/web/app/components/workflow/comment/comment-icon.tsx +++ b/web/app/components/workflow/comment/comment-icon.tsx @@ -2,10 +2,11 @@ import type { FC, PointerEvent as ReactPointerEvent } from 'react' import type { WorkflowCommentList } from '@/app/components/workflow/comment/types' +import { useAtomValue } from 'jotai' import { memo, useCallback, useMemo, useRef, useState } from 'react' import { useReactFlow, useViewport } from 'reactflow' import { UserAvatarList } from '@/app/components/base/user-avatar-list' -import { useAppContext } from '@/context/app-context' +import { userProfileIdAtom } from '@/context/app-context-state' import CommentPreview from './comment-preview' type CommentIconProps = { @@ -18,8 +19,8 @@ type CommentIconProps = { export const CommentIcon: FC = memo(({ comment, onClick, isActive = false, onPositionUpdate }) => { const { flowToScreenPosition, screenToFlowPosition } = useReactFlow() const viewport = useViewport() - const { userProfile } = useAppContext() - const isAuthor = comment.created_by_account?.id === userProfile?.id + const currentUserId = useAtomValue(userProfileIdAtom) + const isAuthor = comment.created_by_account?.id === currentUserId const [showPreview, setShowPreview] = useState(false) const [dragPosition, setDragPosition] = useState<{ x: number, y: number } | null>(null) const [isDragging, setIsDragging] = useState(false) diff --git a/web/app/components/workflow/comment/comment-input.spec.tsx b/web/app/components/workflow/comment/comment-input.spec.tsx index 8796b4ec364..1696f85fa89 100644 --- a/web/app/components/workflow/comment/comment-input.spec.tsx +++ b/web/app/components/workflow/comment/comment-input.spec.tsx @@ -18,6 +18,13 @@ const stableT = (key: string, options?: { ns?: string }) => ( ) let mentionInputProps: MentionInputProps | null = null +const mockAppContextState = vi.hoisted(() => ({ + userProfile: { + id: 'user-1', + name: 'Alice', + avatar_url: 'avatar', + }, +})) vi.mock('react-i18next', () => ({ useTranslation: () => ({ @@ -27,14 +34,20 @@ vi.mock('react-i18next', () => ({ vi.mock('@/context/app-context', () => ({ useAppContext: () => ({ - userProfile: { - id: 'user-1', - name: 'Alice', - avatar_url: 'avatar', - }, + userProfile: mockAppContextState.userProfile, }), })) +vi.mock('@/context/app-context-state', async (importOriginal) => { + const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateAtomMock(importOriginal, () => mockAppContextState) +}) + +vi.mock('jotai', async (importOriginal) => { + const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateJotaiMock(importOriginal) +}) + vi.mock('@langgenius/dify-ui/avatar', () => ({ Avatar: ({ name }: { name: string }) =>
{name}
, default: ({ name }: { name: string }) =>
{name}
, diff --git a/web/app/components/workflow/comment/comment-input.tsx b/web/app/components/workflow/comment/comment-input.tsx index 963111aaa26..2d616e3a798 100644 --- a/web/app/components/workflow/comment/comment-input.tsx +++ b/web/app/components/workflow/comment/comment-input.tsx @@ -1,9 +1,10 @@ import type { FC, PointerEvent as ReactPointerEvent } from 'react' import { Avatar } from '@langgenius/dify-ui/avatar' import { cn } from '@langgenius/dify-ui/cn' +import { useAtomValue } from 'jotai' import { memo, useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useAppContext } from '@/context/app-context' +import { userProfileAtom } from '@/context/app-context-state' import { MentionInput } from './mention-input' type CommentInputProps = { @@ -30,7 +31,7 @@ export const CommentInput: FC = memo(({ }) => { const [content, setContent] = useState('') const { t } = useTranslation() - const { userProfile } = useAppContext() + const userProfile = useAtomValue(userProfileAtom) const dragStateRef = useRef<{ pointerId: number | null startPointerX: number diff --git a/web/app/components/workflow/comment/thread.spec.tsx b/web/app/components/workflow/comment/thread.spec.tsx index 996a8e8d344..771443e37ff 100644 --- a/web/app/components/workflow/comment/thread.spec.tsx +++ b/web/app/components/workflow/comment/thread.spec.tsx @@ -4,6 +4,13 @@ import { CommentThread } from './thread' const mockSetCommentPreviewHovering = vi.hoisted(() => vi.fn()) const mockFlowToScreenPosition = vi.hoisted(() => vi.fn(({ x, y }: { x: number, y: number }) => ({ x, y }))) +const mockAppContextState = vi.hoisted(() => ({ + userProfile: { + id: 'user-1', + name: 'Alice', + avatar_url: 'alice.png', + }, +})) const storeState = vi.hoisted(() => ({ mentionableUsersCache: { @@ -33,14 +40,20 @@ vi.mock('@/hooks/use-format-time-from-now', () => ({ vi.mock('@/context/app-context', () => ({ useAppContext: () => ({ - userProfile: { - id: 'user-1', - name: 'Alice', - avatar_url: 'alice.png', - }, + userProfile: mockAppContextState.userProfile, }), })) +vi.mock('@/context/app-context-state', async (importOriginal) => { + const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateAtomMock(importOriginal, () => mockAppContextState) +}) + +vi.mock('jotai', async (importOriginal) => { + const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateJotaiMock(importOriginal) +}) + vi.mock('reactflow', () => ({ useReactFlow: () => ({ flowToScreenPosition: mockFlowToScreenPosition, diff --git a/web/app/components/workflow/comment/thread.tsx b/web/app/components/workflow/comment/thread.tsx index 97aadf04b39..1d4b9545449 100644 --- a/web/app/components/workflow/comment/thread.tsx +++ b/web/app/components/workflow/comment/thread.tsx @@ -11,13 +11,14 @@ import { } from '@langgenius/dify-ui/dropdown-menu' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { RiArrowDownSLine, RiArrowUpSLine, RiCheckboxCircleFill, RiCheckboxCircleLine, RiCloseLine, RiDeleteBinLine, RiMoreFill } from '@remixicon/react' +import { useAtomValue } from 'jotai' import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useReactFlow, useViewport } from 'reactflow' import Divider from '@/app/components/base/divider' import InlineDeleteConfirm from '@/app/components/base/inline-delete-confirm' import { getUserColor } from '@/app/components/workflow/collaboration/utils/user-color' -import { useAppContext } from '@/context/app-context' +import { userProfileAtom, userProfileIdAtom } from '@/context/app-context-state' import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now' import { useParams } from '@/next/navigation' import { useStore } from '../store' @@ -52,8 +53,7 @@ const ThreadMessage: FC<{ className?: string }> = ({ authorId, authorName, avatarUrl, createdAt, content, mentionableNames, className }) => { const { formatTimeFromNow } = useFormatTimeFromNow() - const { userProfile } = useAppContext() - const currentUserId = userProfile?.id + const currentUserId = useAtomValue(userProfileIdAtom) const isCurrentUser = authorId === currentUserId const userColor = isCurrentUser ? undefined : getUserColor(authorId) @@ -175,7 +175,8 @@ export const CommentThread: FC = memo(({ const appId = params.appId as string const { flowToScreenPosition } = useReactFlow() const viewport = useViewport() - const { userProfile } = useAppContext() + const userProfile = useAtomValue(userProfileAtom) + const currentUserId = userProfile.id const { t } = useTranslation() const [replyContent, setReplyContent] = useState('') const [editingCommentContent, setEditingCommentContent] = useState('') @@ -369,7 +370,7 @@ export const CommentThread: FC = memo(({ }, [editingReply, onReplyEdit]) const replies = comment.replies || [] - const isOwnComment = comment.created_by_account?.id === userProfile?.id + const isOwnComment = comment.created_by_account?.id === currentUserId const messageListRef = useRef(null) const previousReplyCountRef = useRef(undefined) const previousCommentIdRef = useRef(undefined) @@ -604,7 +605,7 @@ export const CommentThread: FC = memo(({
{replies.map((reply) => { const isReplyEditing = editingReply?.id === reply.id - const isOwnReply = reply.created_by_account?.id === userProfile?.id + const isOwnReply = reply.created_by_account?.id === currentUserId return (
({ + userProfile: { + id: '', + name: '', + }, +})) + +vi.mock('@/context/app-context-state', async (importOriginal) => { + const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateAtomMock(importOriginal, () => mockAppContextState) +}) + +vi.mock('jotai', async (importOriginal) => { + const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateJotaiMock(importOriginal) +}) vi.mock('@/context/provider-context', () => ({ useProviderContext: () => ({ diff --git a/web/app/components/workflow/header/__tests__/header-layouts.spec.tsx b/web/app/components/workflow/header/__tests__/header-layouts.spec.tsx index 9b76bc8e46b..a93aaa2a4cd 100644 --- a/web/app/components/workflow/header/__tests__/header-layouts.spec.tsx +++ b/web/app/components/workflow/header/__tests__/header-layouts.spec.tsx @@ -25,6 +25,22 @@ const mockViewHistory = vi.fn() let mockNodesReadOnly = false let mockTheme: 'light' | 'dark' = 'light' +const mockAppContextState = vi.hoisted(() => ({ + userProfile: { + id: '', + name: '', + }, +})) + +vi.mock('@/context/app-context-state', async (importOriginal) => { + const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateAtomMock(importOriginal, () => mockAppContextState) +}) + +vi.mock('jotai', async (importOriginal) => { + const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateJotaiMock(importOriginal) +}) vi.mock('reactflow', () => ({ useNodes: () => mockUseNodes(), diff --git a/web/app/components/workflow/header/header-in-restoring.tsx b/web/app/components/workflow/header/header-in-restoring.tsx index b098bed6018..cb1ab7c7d64 100644 --- a/web/app/components/workflow/header/header-in-restoring.tsx +++ b/web/app/components/workflow/header/header-in-restoring.tsx @@ -2,6 +2,7 @@ import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { toast } from '@langgenius/dify-ui/toast' import { RiHistoryLine } from '@remixicon/react' +import { useAtomValue } from 'jotai' import { useCallback, useState, @@ -9,7 +10,7 @@ import { import { useTranslation } from 'react-i18next' import { PlanUpgradeModal } from '@/app/components/billing/plan-upgrade-modal' import { Plan } from '@/app/components/billing/type' -import { useSelector as useAppContextSelector } from '@/context/app-context' +import { userProfileAtom } from '@/context/app-context-state' import { useProviderContext } from '@/context/provider-context' import useTheme from '@/hooks/use-theme' import { useInvalidAllLastRun, useResetWorkflowVersionHistory, useRestoreWorkflow } from '@/service/use-workflow' @@ -39,7 +40,7 @@ const HeaderInRestoring = ({ const [isRestorePlanUpgradeModalOpen, setIsRestorePlanUpgradeModalOpen] = useState(false) const { plan, enableBilling } = useProviderContext() const workflowStore = useWorkflowStore() - const userProfile = useAppContextSelector(s => s.userProfile) + const userProfile = useAtomValue(userProfileAtom) const configsMap = useHooksStore(s => s.configsMap) const invalidAllLastRun = useInvalidAllLastRun(configsMap?.flowType, configsMap?.flowId) const { diff --git a/web/app/components/workflow/header/online-users.tsx b/web/app/components/workflow/header/online-users.tsx index efef3868193..2094f69d526 100644 --- a/web/app/components/workflow/header/online-users.tsx +++ b/web/app/components/workflow/header/online-users.tsx @@ -9,10 +9,11 @@ import { PopoverTrigger, } from '@langgenius/dify-ui/popover' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' +import { useAtomValue } from 'jotai' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { useReactFlow } from 'reactflow' -import { useAppContext } from '@/context/app-context' +import { userProfileIdAtom } from '@/context/app-context-state' import { getAvatar } from '@/service/common' import { useCollaboration } from '../collaboration/hooks/use-collaboration' import { getUserColor } from '../collaboration/utils/user-color' @@ -54,12 +55,11 @@ const OnlineUsers = () => { const { t } = useTranslation() const appId = useStore(s => s.appId) const { onlineUsers, cursors, isEnabled: isCollaborationEnabled } = useCollaboration(appId as string) - const { userProfile } = useAppContext() + const currentUserId = useAtomValue(userProfileIdAtom) const reactFlow = useReactFlow() const [dropdownOpen, setDropdownOpen] = useState(false) const avatarUrls = useAvatarUrls(onlineUsers || []) - const currentUserId = userProfile?.id const fallbackUsername = t('comments.fallback.user', { ns: 'workflow' }) const currentUserSuffix = t('members.you', { ns: 'common' }) diff --git a/web/app/components/workflow/hooks/__tests__/use-workflow-comment.spec.ts b/web/app/components/workflow/hooks/__tests__/use-workflow-comment.spec.ts index 52cffa1d7ba..548ec5eef25 100644 --- a/web/app/components/workflow/hooks/__tests__/use-workflow-comment.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-workflow-comment.spec.ts @@ -28,6 +28,14 @@ const commentsUpdateState = vi.hoisted(() => ({ const globalFeatureState = vi.hoisted(() => ({ enableCollaboration: true, })) +const mockAppContextState = vi.hoisted(() => ({ + userProfile: { + id: 'user-1', + name: 'Alice', + email: 'alice@example.com', + avatar_url: 'alice.png', + }, +})) vi.mock('reactflow', () => ({ useReactFlow: () => ({ @@ -43,15 +51,20 @@ vi.mock('@/next/navigation', () => ({ vi.mock('@/context/app-context', () => ({ useAppContext: () => ({ - userProfile: { - id: 'user-1', - name: 'Alice', - email: 'alice@example.com', - avatar_url: 'alice.png', - }, + userProfile: mockAppContextState.userProfile, }), })) +vi.mock('@/context/app-context-state', async (importOriginal) => { + const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateAtomMock(importOriginal, () => mockAppContextState) +}) + +vi.mock('jotai', async (importOriginal) => { + const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateJotaiMock(importOriginal) +}) + vi.mock('@/service/client', () => ({ consoleClient: { systemFeatures: { diff --git a/web/app/components/workflow/hooks/use-workflow-comment.ts b/web/app/components/workflow/hooks/use-workflow-comment.ts index 6373dbed6aa..04b17449771 100644 --- a/web/app/components/workflow/hooks/use-workflow-comment.ts +++ b/web/app/components/workflow/hooks/use-workflow-comment.ts @@ -1,9 +1,10 @@ import type { UserProfile, WorkflowCommentDetail, WorkflowCommentList } from '@/app/components/workflow/comment/types' import { useSuspenseQuery } from '@tanstack/react-query' +import { useAtomValue } from 'jotai' import { useCallback, useEffect, useMemo, useRef } from 'react' import { useReactFlow } from 'reactflow' import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager' -import { useAppContext } from '@/context/app-context' +import { userProfileAtom } from '@/context/app-context-state' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { useParams } from '@/next/navigation' import { consoleClient } from '@/service/client' @@ -67,7 +68,7 @@ export const useWorkflowComment = () => { () => new Map(mentionableUsers.map(user => [user.id, user])), [mentionableUsers], ) - const { userProfile } = useAppContext() + const userProfile = useAtomValue(userProfileAtom) const { data: isCollaborationEnabled } = useSuspenseQuery({ ...systemFeaturesQueryOptions(), select: s => s.enable_collaboration_mode, diff --git a/web/app/components/workflow/nodes/_base/__tests__/node.spec.tsx b/web/app/components/workflow/nodes/_base/__tests__/node.spec.tsx index b6f5c32d025..51a3f8813d8 100644 --- a/web/app/components/workflow/nodes/_base/__tests__/node.spec.tsx +++ b/web/app/components/workflow/nodes/_base/__tests__/node.spec.tsx @@ -11,6 +11,25 @@ const mockHandleNodeIterationChildSizeChange = vi.fn() const mockHandleNodeLoopChildSizeChange = vi.fn() const mockUseNodeResizeObserver = vi.fn() const mockUseCollaboration = vi.fn() +const mockAppContextState = vi.hoisted(() => ({ + userProfile: { + id: 'user-1', + name: 'User', + email: 'user@example.com', + avatar: '', + avatar_url: '', + }, +})) + +vi.mock('@/context/app-context-state', async (importOriginal) => { + const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateAtomMock(importOriginal, () => mockAppContextState) +}) + +vi.mock('jotai', async (importOriginal) => { + const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateJotaiMock(importOriginal) +}) vi.mock('@/app/components/workflow/hooks', () => ({ useNodesReadOnly: () => ({ nodesReadOnly: false }), diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx b/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx index f30c428890e..515f307db56 100644 --- a/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx +++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx @@ -13,6 +13,7 @@ import { RiPlayLargeLine, } from '@remixicon/react' import { debounce } from 'es-toolkit/compat' +import { useAtomValue } from 'jotai' import * as React from 'react' import { cloneElement, @@ -69,7 +70,7 @@ import { hasRetryNode, isSupportCustomRunForm, } from '@/app/components/workflow/utils' -import { useAppContext } from '@/context/app-context' +import { userProfileAtom } from '@/context/app-context-state' import { useAllBuiltInTools } from '@/service/use-tools' import { useAllTriggerPlugins } from '@/service/use-triggers' import { FlowType } from '@/types/common' @@ -114,7 +115,7 @@ const BasePanel: FC = ({ const { t } = useTranslation() const language = useLanguage() const appId = useStore(s => s.appId) - const { userProfile } = useAppContext() + const userProfile = useAtomValue(userProfileAtom) const { isConnected, nodePanelPresence } = useCollaboration(appId as string) const { showMessageLogModal } = useAppStore(useShallow(state => ({ showMessageLogModal: state.showMessageLogModal, diff --git a/web/app/components/workflow/nodes/_base/node.tsx b/web/app/components/workflow/nodes/_base/node.tsx index 9fd1fb6f97e..80eeadad016 100644 --- a/web/app/components/workflow/nodes/_base/node.tsx +++ b/web/app/components/workflow/nodes/_base/node.tsx @@ -4,6 +4,7 @@ import type { } from 'react' import type { NodeProps } from '@/app/components/workflow/types' import { cn } from '@langgenius/dify-ui/cn' +import { useAtomValue } from 'jotai' import { cloneElement, memo, @@ -28,7 +29,7 @@ import { NodeRunningStatus, } from '@/app/components/workflow/types' import { hasErrorHandleNode, hasRetryNode } from '@/app/components/workflow/utils' -import { useAppContext } from '@/context/app-context' +import { userProfileAtom } from '@/context/app-context-state' import { selectWorkflowNode } from '../../utils/node-navigation' import AddVariablePopupWithPosition from './components/add-variable-popup-with-position' import EntryNodeContainer, { StartNodeTypeEnum } from './components/entry-node-container' @@ -76,7 +77,7 @@ const BaseNode: FC = ({ const { handleNodeIterationChildSizeChange } = useNodeIterationInteractions() const { handleNodeLoopChildSizeChange } = useNodeLoopInteractions() const toolIcon = useToolIcon(data) - const { userProfile } = useAppContext() + const userProfile = useAtomValue(userProfileAtom) const appId = useStore(s => s.appId) const { nodePanelPresence } = useCollaboration(appId as string) const controlMode = useStore(s => s.controlMode) diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/email-configure-modal.spec.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/email-configure-modal.spec.tsx index d9875c75397..7ba7347869f 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/email-configure-modal.spec.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/email-configure-modal.spec.tsx @@ -3,7 +3,11 @@ import { fireEvent, render, screen } from '@testing-library/react' import EmailConfigureModal from '../email-configure-modal' const mockToastError = vi.hoisted(() => vi.fn()) -const mockUseAppContextSelector = vi.hoisted(() => vi.fn()) +const mockAppContextState = vi.hoisted(() => ({ + userProfile: { + email: 'owner@example.com', + }, +})) vi.mock('@langgenius/dify-ui/toast', () => ({ toast: { @@ -13,9 +17,19 @@ vi.mock('@langgenius/dify-ui/toast', () => ({ vi.mock('@/context/app-context', () => ({ useSelector: (selector: (state: { userProfile: { email: string } }) => string) => - mockUseAppContextSelector(selector), + selector({ userProfile: mockAppContextState.userProfile }), })) +vi.mock('@/context/app-context-state', async (importOriginal) => { + const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateAtomMock(importOriginal, () => mockAppContextState) +}) + +vi.mock('jotai', async (importOriginal) => { + const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateJotaiMock(importOriginal) +}) + vi.mock('../mail-body-input', () => ({ default: ({ value, onChange }: { value: string, onChange: (value: string) => void }) => (